From 3ec59faf773496441abafff02ee7c9b46184cec4 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:14:21 +0200 Subject: [PATCH 1/8] fix(ding): classify a used Claude pane instead of deferring forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed delivery to a Claude pane never left `Ambiguous` once the pane had been used at all, so every DING deferred indefinitely and messages only ever reached the inbox. Three independent signals in the classifier are read as "unproven" when they are really just "not a freshly started pane": 1. The harness was detected via the `Claude Code v…` startup banner, but `pty peek` returns the viewport only, so that banner scrolls away after the first screenful. Detect the ruled `❯` composer instead, which is durable, and keep an unlocatable Claude pane `Ambiguous` rather than letting it fall through to the Codex heuristics. 2. The composer row of an empty composer is `❯` + U+00A0 and nothing else. U+00A0 is whitespace, so trimming before stripping the prompt erased the prompt and the row failed to parse. Strip the prompt first, then trim. An empty composer is also positive-empty proof in its own right: a human draft cannot be empty, which makes it a stronger signal than the rotating `Try "…"` placeholder that only an unused pane shows. 3. The idle footer required `shift+tab to cycle`. That hint is derived from a keybinding lookup and rendered under two further runtime conditions, so it is absent on real idle panes and changes text when the binding is remapped. The permission-mode indicator next to it ships unconditionally with the composer chrome, so gate on that and let the hint corroborate. Fail-closed behaviour is unchanged: a human draft is still `Changed`, an active turn or modal is still `ExactBlocked` via `interaction_blocked`, and an unrecognised screen is still `Ambiguous`. Only wrong negatives are removed. Extends `maintained_composer_classifiers_require_exact_idle_state`, the test INVARIANTS.md names for this guarantee, with a used-pane screen covering all three signals plus the two fail-closed cases on that same shape. Fixes #26 --- src/ding.rs | 99 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 8 deletions(-) diff --git a/src/ding.rs b/src/ding.rs index bdb0567..d41554e 100644 --- a/src/ding.rs +++ b/src/ding.rs @@ -470,8 +470,15 @@ enum CodexComposer { fn classify_composer(screen: &str, expected: &str) -> ComposerState { let plain = strip_ansi(screen); + // `pty peek` returns only the viewport, so the startup `Claude Code v…` banner has scrolled + // away on any pane that has been used. The ruled `❯` composer is the durable Claude marker. + if let Some(composer) = located_bottom_claude_composer(&plain) { + return classify_claude_composer(&plain, composer, expected); + } + // A Claude pane whose composer cannot be located stays unproven rather than falling through to + // the Codex heuristics below. if plain.contains("Claude Code v") { - return classify_claude_composer(&plain, expected); + return ComposerState::Ambiguous; } if located_bottom_codex_composer(screen).is_some() || plain.contains("OpenAI Codex") @@ -515,12 +522,16 @@ fn codex_idle_footer(screen_from_composer: &str) -> bool { }) } -fn classify_claude_composer(plain: &str, expected: &str) -> ComposerState { - let Some((logical_inputs, footer)) = located_bottom_claude_composer(plain) else { - return ComposerState::Ambiguous; - }; +fn classify_claude_composer( + plain: &str, + (logical_inputs, footer): (Vec, String), + expected: &str, +) -> ComposerState { let exact = logical_inputs.iter().any(|input| input == expected); - let placeholder = logical_inputs.len() == 1 && is_claude_idle_placeholder(&logical_inputs[0]); + // An empty composer is a stronger positive-empty proof than the placeholder below, because no + // human draft can be empty. Claude only shows the rotating placeholder on an unused pane. + let placeholder = logical_inputs.len() == 1 + && (logical_inputs[0].is_empty() || is_claude_idle_placeholder(&logical_inputs[0])); // The keybinding hint is runtime-composed and may be absent or remapped. The mode marker and // permission state are the stable safety signals; active/modal/changed states remain fail-closed. let idle_footer = footer.contains("⏵⏵") && footer.contains("permissions on"); @@ -574,10 +585,13 @@ fn located_bottom_claude_composer(plain: &str) -> Option<(Vec, String)> } let rows = &lines[top + 1..bottom]; - let first_row = rows.first()?.trim_end(); + // Strip the prompt before trimming: the separator Claude emits is U+00A0, which is itself + // trailing whitespace, so trimming first erases the prompt of an empty composer. + let first_row = rows.first()?; let first = first_row .strip_prefix("❯\u{00a0}") - .or_else(|| first_row.strip_prefix("❯ "))?; + .or_else(|| first_row.strip_prefix("❯ "))? + .trim_end(); let input = std::iter::once(first) .chain(rows[1..].iter().map(|row| row.trim_end())) .collect::>() @@ -1479,6 +1493,36 @@ mod tests { ) } + /// A pane that has been used: the startup banner has scrolled out of the peeked viewport, the + /// composer is empty rather than showing a rotating placeholder, and the footer omits the + /// conditional `(shift+tab to cycle)` hint while keeping the permission-mode indicator. + fn mature_claude_screen(composer: &str) -> String { + let rule = claude_rule(); + let footer = " ⏵⏵ bypass permissions on · PR #42 · 2 shells · ← 1 agent"; + format!(" an earlier turn\r\n{rule}\r\n{composer}\r\n{rule}\r\n{footer}") + } + + fn mature_idle_claude_screen() -> String { + mature_claude_screen("❯\u{00a0}") + } + + fn mature_idle_claude_screen_with_hint() -> String { + mature_idle_claude_screen().replace( + "⏵⏵ bypass permissions on", + "⏵⏵ bypass permissions on (shift+tab to cycle)", + ) + } + + /// The same used pane in accept-edits mode. `permissions on` is specific to the bypass footer, + /// so no accept-edits or auto pane is positively idle to this classifier. + fn mature_idle_accept_edits_claude_screen() -> String { + mature_idle_claude_screen().replace("⏵⏵ bypass permissions on", "⏵⏵ accept edits on") + } + + fn mature_staged_claude_screen(text: &str) -> String { + mature_claude_screen(&format!("❯\u{00a0}{text}")) + } + #[test] fn maintained_composer_classifiers_require_exact_idle_state() { let expected = @@ -1526,6 +1570,45 @@ mod tests { ), ComposerState::ExactBlocked ); + // A used pane must classify exactly like a fresh one: neither the scrolled-away banner, the + // empty composer, nor the missing cycle hint is evidence that Return is unsafe. + assert_eq!( + classify_composer(&mature_idle_claude_screen(), expected), + ComposerState::EmptySafe + ); + assert_eq!( + classify_composer(&mature_idle_claude_screen_with_hint(), expected), + ComposerState::EmptySafe + ); + // Only the bypass footer carries `permissions on`, so an otherwise identical accept-edits + // pane is never positively idle. It stays unsubmitted rather than being proven safe. + assert_eq!( + classify_composer(&mature_idle_accept_edits_claude_screen(), expected), + ComposerState::Changed + ); + assert_eq!( + classify_composer(&mature_staged_claude_screen(expected), expected), + ComposerState::ExactSafe + ); + // The same pane shape must still fail closed on a human draft and on an active turn. + assert_eq!( + classify_composer( + &mature_staged_claude_screen("a changed human composer"), + expected + ), + ComposerState::Changed + ); + assert_eq!( + classify_composer( + &format!( + "Esc to interrupt\r\n{}", + mature_staged_claude_screen(expected) + ), + expected + ), + ComposerState::ExactBlocked + ); + assert_eq!( classify_composer("unknown terminal pixels", expected), ComposerState::Ambiguous From 37c4467d666cdf0b9d5b22d063dd714fc68f4e12 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:41:53 +0200 Subject: [PATCH 2/8] fix(ding): classify the lowest composer, not the preferred harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing preferred any Claude-shaped text over the ANSI-detected Codex composer, so a Codex pane whose scrollback holds a captured Claude screen was classified from the capture. Capturing and pasting pane text is routine, so that shape is not exotic. This is a wrong positive rather than a wrong negative, which makes it worse than the deferral bugs around it: the paste and the bare Return always go to the pane's real bottom composer whichever text was read. A transcript row that looks empty lets the paste land in a human's live Codex draft, and one holding the exact notice can satisfy both adjacent observations and submit that draft. A pane has exactly one live composer and it is the lowest one on screen, so compare positions instead of preferring a harness. Scrollback is above the live composer by construction, which makes this one comparison rather than a per-pair special case. The Codex locator reports a byte offset into the raw ANSI screen while the Claude locator works in stripped line indices; every Codex marker starts at an `\x1b[` boundary, so stripping the prefix is faithful and its newline count is that composer's row. Detection also stops consulting the `Claude Code v…` startup banner entirely. An unlocatable composer now reaches the same `Ambiguous` it reached before via the banner branch, so that check no longer carries any routing weight. Reported by automated review on #27. agent-session-id: e5217740-eef6-48eb-a794-3e5e11939e4d agent-tool: Claude Code agent-tool-version: 2.1.220 agent-model: claude-opus-5 agent-runtime-profile: /nix/store/i8y8b542cyqi385ywcjw5fvsq24f75v4-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/i81qxhzlrzcxrrdwpp6i8hagka2gby8y-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- src/ding.rs | 103 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 20 deletions(-) diff --git a/src/ding.rs b/src/ding.rs index d41554e..2a903ea 100644 --- a/src/ding.rs +++ b/src/ding.rs @@ -468,27 +468,39 @@ enum CodexComposer { Typed(String), } +/// Locate every maintained composer and classify the LOWEST one on screen. +/// +/// A pane has exactly one live composer and it sits at the bottom of the viewport, so anything +/// composer-shaped above it is scrollback — typically a pasted or logged screen from the other +/// harness. Preferring one harness unconditionally lets that transcript decide: a Codex pane whose +/// scrollback holds a captured Claude screen would be classified from the capture. That is a +/// wrong *positive* rather than a wrong negative, because the paste and the Return go to the pane's +/// real composer whichever text was read — so it can type into, or submit, a human's live draft. +/// Comparing positions needs no per-pair special case: scrollback is above the live composer by +/// construction. +/// +/// Detection deliberately does not consult the `Claude Code v…` startup banner. `pty peek` returns +/// the viewport only, and the banner is absent from real panes (see the harness note on +/// `located_bottom_claude_composer`), so it cannot gate anything. The ruled `❯` composer is durable. fn classify_composer(screen: &str, expected: &str) -> ComposerState { let plain = strip_ansi(screen); - // `pty peek` returns only the viewport, so the startup `Claude Code v…` banner has scrolled - // away on any pane that has been used. The ruled `❯` composer is the durable Claude marker. - if let Some(composer) = located_bottom_claude_composer(&plain) { - return classify_claude_composer(&plain, composer, expected); - } - // A Claude pane whose composer cannot be located stays unproven rather than falling through to - // the Codex heuristics below. - if plain.contains("Claude Code v") { - return ComposerState::Ambiguous; - } - if located_bottom_codex_composer(screen).is_some() - || plain.contains("OpenAI Codex") - || plain - .lines() - .any(|line| line.trim_start().starts_with("gpt-")) - { - return classify_codex_composer(screen, &plain, expected); + let claude = located_bottom_claude_composer(&plain); + // The Codex locator reports a byte offset into the raw ANSI screen while the Claude locator + // works in `plain` line indices. Every Codex marker starts at an `\x1b[` boundary, so stripping + // the prefix is faithful and its newline count is that composer's `plain` row. + let codex_row = located_bottom_codex_composer(screen) + .map(|(start, _)| strip_ansi(&screen[..start]).matches('\n').count()); + match (claude, codex_row) { + (Some((claude_row, _, _)), Some(codex_row)) if claude_row < codex_row => { + classify_codex_composer(screen, &plain, expected) + } + (Some((_, logical_inputs, footer)), _) => { + classify_claude_composer(&plain, (logical_inputs, footer), expected) + } + (None, Some(_)) => classify_codex_composer(screen, &plain, expected), + // No maintained composer is locatable, so nothing is proven either way. + (None, None) => ComposerState::Ambiguous, } - ComposerState::Ambiguous } fn classify_codex_composer(screen: &str, plain: &str, expected: &str) -> ComposerState { @@ -568,7 +580,7 @@ fn is_claude_idle_placeholder(input: &str) -> bool { /// row Claude may either wrap at a discarded space or split a token, so each proven boundary has /// exactly two candidates: join with one space or with none. The bounded DING length keeps this set /// small; any unfamiliar multiline shape fails closed. -fn located_bottom_claude_composer(plain: &str) -> Option<(Vec, String)> { +fn located_bottom_claude_composer(plain: &str) -> Option<(usize, Vec, String)> { let lines: Vec<&str> = plain.lines().collect(); let separators: Vec = lines .iter() @@ -598,7 +610,9 @@ fn located_bottom_claude_composer(plain: &str) -> Option<(Vec, String)> .join("\n"); let candidates = logical_soft_wrap_candidates(&input, 70); let footer = lines[bottom + 1..].join("\n"); - Some((candidates, footer)) + // `top + 1` is the composer's first row, which is what the router compares against the Codex + // composer's row to find the lower — and therefore live — of the two. + Some((top + 1, candidates, footer)) } /// Enumerate the two logical strings possible at each renderer-shaped soft-wrap row: the TUI either @@ -1523,6 +1537,55 @@ mod tests { mature_claude_screen(&format!("❯\u{00a0}{text}")) } + /// A Codex pane whose scrollback holds a captured Claude screen — two ruled lines around a `❯` + /// row plus a Claude idle footer — above the live, ANSI-detected Codex composer. Capturing and + /// pasting pane text is routine, so this shape is not exotic. + fn codex_screen_below_claude_transcript(transcript_row: &str, codex: &str) -> String { + let rule = claude_rule(); + format!( + " scrollback: a pasted Claude pane\r\n{rule}\r\n❯\u{00a0}{transcript_row}\r\n{rule}\r\n\ + \u{0020} ⏵⏵ bypass permissions on (shift+tab to cycle)\r\n\r\n{codex}" + ) + } + + /// Scrollback that merely looks like a composer must never outrank the live one. The paste and + /// the Return always go to the pane's real bottom composer, so misreading transcript text as + /// "idle" or "already staged" is a wrong positive: it can type into, or submit, a human draft. + #[test] + fn transcript_composers_never_outrank_the_live_bottom_composer() { + let expected = + "[DING] new st2 message: [id:abc123] exact observation (from cos); check your inbox"; + + // The live Codex composer holds a human draft in both cases, so both must stay `Changed`. + // An empty transcript row would otherwise read as positively-empty and allow the paste. + assert_eq!( + classify_composer( + &codex_screen_below_claude_transcript("", &human_codex_screen()), + expected + ), + ComposerState::Changed + ); + // A transcript row holding the exact notice is the worse case: it would otherwise satisfy + // the two adjacent exact observations and send a bare Return to the draft. + assert_eq!( + classify_composer( + &codex_screen_below_claude_transcript(expected, &human_codex_screen()), + expected + ), + ComposerState::Changed + ); + + // The rule is positional, not a Codex preference: a genuine Claude pane whose scrollback + // shows a captured Codex composer still classifies from its own live Claude composer. + assert_eq!( + classify_composer( + &format!("{}\r\n{}", staged_codex_screen("a stale pasted codex draft"), mature_idle_claude_screen()), + expected + ), + ComposerState::EmptySafe + ); + } + #[test] fn maintained_composer_classifiers_require_exact_idle_state() { let expected = From 04b600c0b05b76b0a35f6e017b6abba3c444bf33 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:44:04 +0200 Subject: [PATCH 3/8] fix(ding): treat an in-flight Claude turn as blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude 2.1.220 ships no interrupt hint with its working status line, so none of the strings `interaction_blocked` matches appear while a turn is running. On a 170-column capture the line renders as `✻ Frolicking… (3m 35s · ↓ 6.9k tokens)` in 38 cells, so this is not width truncation — the hint is simply not there. Until now that was harmless: an active pane also has an empty composer, and an empty composer failed to parse, so delivery returned `Ambiguous` and deferred. Fixing that parse removes the accidental guard and leaves a working pane reading as positively idle, which would paste a notice and press Return in the middle of someone else's turn. Gate on the status line instead. Two observations shape the predicate, both sampled from real panes rather than assumed: - The leading glyph animates — `·`, `✶`, `✻`, and `✽` were all observed — so it cannot be matched literally. - The elapsed timer is absent from some active frames (`✽ Schlepping…`), so requiring it would fail open on exactly the frames that matter. What separates the states is the ellipsis directly after a single gerund. An active turn is ` …`; a finished one is ` for s`. That distinction is load-bearing in the other direction too: the finished line sits above every genuinely idle composer, so matching it would stop delivery altogether rather than merely being conservative. The added test pins both directions across four real active frames and four real finished ones. The predicate lives in the shared `interaction_blocked` for now, so a Codex pane whose scrollback shows a Claude spinner also blocks. That is the fail-closed direction, and the per-harness split moves it to the Claude adapter. agent-session-id: e5217740-eef6-48eb-a794-3e5e11939e4d agent-tool: Claude Code agent-tool-version: 2.1.220 agent-model: claude-opus-5 agent-runtime-profile: /nix/store/i8y8b542cyqi385ywcjw5fvsq24f75v4-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/i81qxhzlrzcxrrdwpp6i8hagka2gby8y-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- src/ding.rs | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/src/ding.rs b/src/ding.rs index 2a903ea..5ae6be5 100644 --- a/src/ding.rs +++ b/src/ding.rs @@ -652,8 +652,37 @@ fn logical_soft_wrap_candidates(input: &str, minimum_first_content_chars: usize) candidates } +/// Claude 2.1.220 renders an in-flight turn as a status line and ships no interrupt hint with it. +/// Sampled across real panes, an active turn looks like `✻ Frolicking… (3m 35s · ↓ 6.9k tokens)`, +/// `✽ Schlepping…`, or `· Metamorphosing…`, while a finished turn looks like `✻ Brewed for 5s` or +/// `✻ Cogitated for 11s · 1 shell still running`. +/// +/// Two things follow. The leading glyph animates (`·` `✶` `✻` `✽` all observed), so it cannot be +/// matched literally. And the elapsed timer is absent from some active frames, so requiring it +/// would fail open on exactly the frames that matter. What separates the two states is the +/// ellipsis directly after a single gerund: a finished turn says `for s` instead. +/// +/// Matching the finished shape too would be catastrophic rather than merely conservative — every +/// idle pane shows it immediately after a turn, so DING would never deliver again. +fn claude_turn_in_flight(plain: &str) -> bool { + plain.lines().any(|line| { + let Some((head, _)) = line.trim().split_once('…') else { + return false; + }; + let mut tokens = head.split_whitespace(); + let (Some(glyph), Some(word), None) = (tokens.next(), tokens.next(), tokens.next()) else { + return false; + }; + glyph.chars().count() == 1 + && !glyph.chars().any(char::is_alphanumeric) + && !word.is_empty() + && word.chars().all(char::is_alphabetic) + }) +} + fn interaction_blocked(plain: &str) -> bool { - plain.contains("Working (") + claude_turn_in_flight(plain) + || plain.contains("Working (") || plain.contains("esc to interrupt") || plain.contains("Esc to interrupt") || plain.contains("ctrl+c to interrupt") @@ -1537,6 +1566,32 @@ mod tests { mature_claude_screen(&format!("❯\u{00a0}{text}")) } + /// The same used pane with an in-flight turn: a spinner status line above the composer. Every + /// frame below was observed on a real 2.1.220 pane; the glyph animates and the elapsed timer is + /// not always rendered, so both variations appear here. + fn mid_turn_claude_screen(status: &str, composer: &str) -> String { + let rule = claude_rule(); + let footer = " ⏵⏵ bypass permissions on · PR #42 · 2 shells · ← 1 agent"; + format!(" an earlier turn\r\n{status}\r\n{rule}\r\n{composer}\r\n{rule}\r\n{footer}") + } + + /// Status lines for an ACTIVE turn — Return must never be sent. + const ACTIVE_TURN_STATUS: [&str; 4] = [ + "✻ Frolicking… (3m 35s · ↓ 6.9k tokens)", + "✽ Schlepping…", + "· Metamorphosing…", + "✶ Schlepping… (9s · ↓ 296 tokens · thinking with high effort)", + ]; + + /// Status lines for a FINISHED turn — these sit above every genuinely idle composer, so + /// treating them as blocked would stop delivery entirely. + const FINISHED_TURN_STATUS: [&str; 4] = [ + "✻ Brewed for 5s", + "✻ Crunched for 7s", + "✻ Cogitated for 11s · 1 shell still running", + "✻ Baked for 3s · 1 shell still running", + ]; + /// A Codex pane whose scrollback holds a captured Claude screen — two ruled lines around a `❯` /// row plus a Claude idle footer — above the live, ANSI-detected Codex composer. Capturing and /// pasting pane text is routine, so this shape is not exotic. @@ -1548,6 +1603,50 @@ mod tests { ) } + /// An in-flight Claude turn ships no interrupt hint, so the composer being empty is not proof + /// that Return is safe — the pane is working. The finished-turn line looks almost identical and + /// sits above every genuinely idle composer, so both directions are pinned here. + #[test] + fn an_in_flight_turn_blocks_return_but_a_finished_one_does_not() { + let expected = + "[DING] new st2 message: [id:abc123] exact observation (from cos); check your inbox"; + + for status in ACTIVE_TURN_STATUS { + // Empty composer mid-turn: positively empty, but not safe. + assert_ne!( + classify_composer(&mid_turn_claude_screen(status, "❯\u{00a0}"), expected), + ComposerState::EmptySafe, + "active turn must not be EmptySafe: {status}" + ); + // The notice already staged mid-turn: exact, but Return is still withheld. + assert_eq!( + classify_composer( + &mid_turn_claude_screen(status, &format!("❯\u{00a0}{expected}")), + expected + ), + ComposerState::ExactBlocked, + "active turn must be ExactBlocked: {status}" + ); + } + + // A finished turn is the normal idle screen. Blocking on it would stop delivery forever. + for status in FINISHED_TURN_STATUS { + assert_eq!( + classify_composer(&mid_turn_claude_screen(status, "❯\u{00a0}"), expected), + ComposerState::EmptySafe, + "finished turn must stay deliverable: {status}" + ); + assert_eq!( + classify_composer( + &mid_turn_claude_screen(status, &format!("❯\u{00a0}{expected}")), + expected + ), + ComposerState::ExactSafe, + "finished turn must stay submittable: {status}" + ); + } + } + /// Scrollback that merely looks like a composer must never outrank the live one. The paste and /// the Return always go to the pane's real bottom composer, so misreading transcript text as /// "idle" or "already staged" is a wrong positive: it can type into, or submit, a human draft. From d3f5fd787de825985ba45a6303b47ef9933b473d Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:01:02 +0200 Subject: [PATCH 4/8] refactor(ding): split the composer into per-harness adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure restructure: no behaviour change, no test renamed, no assertion altered. `ding.rs` had grown to hold transport, delivery state, and both harnesses' screen grammars in one file, with routing expressed as an ordered preference between them. The preceding commit replaced that preference with a positional rule — classify the lowest composer, because scrollback is above the live one by construction. This gives that rule a structural home. src/ding/ mod.rs transport, pending notices, DingConfig, run_ding, serve composer.rs ComposerState, the router, strip_ansi, soft-wrap candidates harness/ mod.rs trait Harness + Located + the registry claude.rs locate / classify / idle footer / blocked codex.rs locate / classify / idle footer / blocked The router is now `max_by_key(row)` over every registered harness, so the positional rule is one comparison rather than a special case per harness pair, and a third harness cannot reintroduce an ordering. Claude is registered last so an exact row tie still resolves to Claude, as the explicit comparison did. Active-turn and modal detection moves to the harnesses, since those shapes are harness-specific TUI chrome rather than a shared contract. The shared predicate was already Claude-flavoured — `Create a plan?`, `Our systems are thinking a bit more`, and `Retry with a faster model` were applied to Codex panes too. It is duplicated verbatim into both adapters rather than narrowed, so this commit changes nothing; narrowing the Codex side is separate work. Only the choice-menu heuristic, which is genuinely cross-harness, stays in `composer`. INVARIANTS.md names tests by file path, so its seventeen `src/ding.rs::` references are repointed to `src/ding/mod.rs::`. Every test stayed in that module: the screen fixtures are shared between the composer assertions and the transport ones, so splitting them would have meant duplicating fixtures or exporting them across modules, and neither is worth it for a move. Verified rather than assumed: - `cargo test --lib -- --list` is byte-identical before and after (142 tests). - All 27 `src/.rs::` references in INVARIANTS.md resolve, checked both by grepping the named path for `fn ` and by running each one with `cargo test -- --exact` (27 resolved, 27 passed). - The two real captures behind the preceding commits classify identically. agent-session-id: e5217740-eef6-48eb-a794-3e5e11939e4d agent-tool: Claude Code agent-tool-version: 2.1.220 agent-model: claude-opus-5 agent-runtime-profile: /nix/store/i8y8b542cyqi385ywcjw5fvsq24f75v4-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/i81qxhzlrzcxrrdwpp6i8hagka2gby8y-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- INVARIANTS.md | 8 +- src/ding/composer.rs | 159 +++++++++++++ src/ding/harness/claude.rs | 150 ++++++++++++ src/ding/harness/codex.rs | 202 ++++++++++++++++ src/ding/harness/mod.rs | 45 ++++ src/{ding.rs => ding/mod.rs} | 431 +---------------------------------- 6 files changed, 564 insertions(+), 431 deletions(-) create mode 100644 src/ding/composer.rs create mode 100644 src/ding/harness/claude.rs create mode 100644 src/ding/harness/codex.rs create mode 100644 src/ding/harness/mod.rs rename src/{ding.rs => ding/mod.rs} (78%) diff --git a/INVARIANTS.md b/INVARIANTS.md index 2f7246a..b2fe917 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -10,10 +10,10 @@ materialization, messaging, DING, or presence must preserve them. | **Clean exec teardown** | Killing an exec task reaps its whole process group. | `tests/exec_backend.rs::exec_kill_reaps_the_whole_process_group_not_just_the_leader` | | **Bounded restart diagnostics** | Relaunching an exec task preserves the just-finished log as one prior generation while bounding retained diagnostics to current plus prior. Final retirement removes the PID and both logs. | `tests/exec_backend.rs::exec_restart_reap_keeps_bounded_diagnostics_and_final_remove_cleans_them`; `tests/run.rs::up_once_finally_removes_dead_retired_tasks_without_restarting_them` | | **Exactly-once-safe native bus** | Messages use stable `-.md` files. An archive filename is a durable receipt that shadows and cleans restored inbox replicas and makes repeated archive cleanup idempotent. | `src/message.rs::filename_grammar`; `src/message.rs::archive_receipt_suppresses_and_idempotently_cleans_a_restored_inbox_copy`; `tests/message.rs` | -| **Fail-closed observed native DING** | Each unread message becomes one normalized `[DING]` frame. A maintained Codex or Claude composer must be positively empty before a bracketed paste, then show the exact notice in two immediately adjacent inspections before a separate bare Return. Human, modal, active, changed, timed-out, and unknown states never receive Return. Once paste starts, inspect-only staged ownership prevents duplicate paste across command failures, archive races, and restart adoption. Startup backlog otherwise becomes one generic recovery DING; new arrivals remain FIFO; `busy` delivers immediately; only fresh `dnd` defers. | `src/ding.rs::poke_text_normalizes_and_bounds_untrusted_fields`; `src/ding.rs::malicious_controls_cannot_escape_the_single_paste_frame`; `src/ding.rs::pty_stage_and_submit_are_separate_exact_sequences`; `src/ding.rs::maintained_composer_classifiers_require_exact_idle_state`; `src/ding.rs::paste_then_two_exact_observations_precede_return`; `src/ding.rs::changed_modal_ambiguous_and_bounded_timeout_never_return`; `src/ding.rs::final_observation_change_and_staged_retry_are_fail_closed`; `src/ding.rs::staged_ownership_survives_archive_and_never_repastes`; `src/ding.rs::pty_commands_have_a_real_outer_timeout`; `src/ding.rs::session_watch_has_startup_grace_debounce_and_live_reset`; `src/ding.rs::new_arrivals_is_fifo_and_archive_receipts_prevent_reding`; `src/ding.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry`; `src/ding.rs::startup_recovery_notice_retries_in_memory`; `src/ding.rs::startup_backlog_gets_one_generic_recovery_then_new_arrivals_poke` | -| **Mutation-only filesystem wakeups** | Supervisor and DING filesystem watchers ignore read/open access events and wake early only for create, modify, rename, or remove events. Their own catalog and inbox reads therefore cannot bypass the bounded timer cadence or form a Linux inotify CPU loop. | `src/watch.rs::only_mutations_wake_watch_loops`; `src/watch.rs::linux_reads_are_silent_but_real_mutations_wake`; `src/ding.rs::idle_ding_does_not_spin_on_its_own_inbox_reads`; `src/run.rs::idle_supervisor_does_not_spin_on_its_own_catalog_reads` | -| **Bounded DING PTY probe churn** | An unsafe or active composer retains its FIFO notice but deferred delivery retries use a bounded backoff, so each inbox poll cannot spawn another short-lived PTY probe. | `src/ding.rs::deferred_delivery_backoff_bounds_short_lived_pty_attempts` | -| **Agent-declared presence discipline** | The shipped bus contract requires agents to declare `busy` before executing work, use `available` only while yielding or ready, and reserve `dnd` for an explicit hold. Both native harnesses materialize that contract. Busy remains observable but does not suppress DING; fresh `dnd` is the only delivery gate. | `tests/compile_agent.rs::compile_agent_generates_claude_then_materializes_verbatim_persona`; `tests/compile_agent.rs::compile_agent_generates_codex_then_materializes_composed_agents_md`; `src/ding.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry` | +| **Fail-closed observed native DING** | Each unread message becomes one normalized `[DING]` frame. A maintained Codex or Claude composer must be positively empty before a bracketed paste, then show the exact notice in two immediately adjacent inspections before a separate bare Return. Human, modal, active, changed, timed-out, and unknown states never receive Return. Once paste starts, inspect-only staged ownership prevents duplicate paste across command failures, archive races, and restart adoption. Startup backlog otherwise becomes one generic recovery DING; new arrivals remain FIFO; `busy` delivers immediately; only fresh `dnd` defers. | `src/ding/mod.rs::poke_text_normalizes_and_bounds_untrusted_fields`; `src/ding/mod.rs::malicious_controls_cannot_escape_the_single_paste_frame`; `src/ding/mod.rs::pty_stage_and_submit_are_separate_exact_sequences`; `src/ding/mod.rs::maintained_composer_classifiers_require_exact_idle_state`; `src/ding/mod.rs::paste_then_two_exact_observations_precede_return`; `src/ding/mod.rs::changed_modal_ambiguous_and_bounded_timeout_never_return`; `src/ding/mod.rs::final_observation_change_and_staged_retry_are_fail_closed`; `src/ding/mod.rs::staged_ownership_survives_archive_and_never_repastes`; `src/ding/mod.rs::pty_commands_have_a_real_outer_timeout`; `src/ding/mod.rs::session_watch_has_startup_grace_debounce_and_live_reset`; `src/ding/mod.rs::new_arrivals_is_fifo_and_archive_receipts_prevent_reding`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry`; `src/ding/mod.rs::startup_recovery_notice_retries_in_memory`; `src/ding/mod.rs::startup_backlog_gets_one_generic_recovery_then_new_arrivals_poke` | +| **Mutation-only filesystem wakeups** | Supervisor and DING filesystem watchers ignore read/open access events and wake early only for create, modify, rename, or remove events. Their own catalog and inbox reads therefore cannot bypass the bounded timer cadence or form a Linux inotify CPU loop. | `src/watch.rs::only_mutations_wake_watch_loops`; `src/watch.rs::linux_reads_are_silent_but_real_mutations_wake`; `src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads`; `src/run.rs::idle_supervisor_does_not_spin_on_its_own_catalog_reads` | +| **Bounded DING PTY probe churn** | An unsafe or active composer retains its FIFO notice but deferred delivery retries use a bounded backoff, so each inbox poll cannot spawn another short-lived PTY probe. | `src/ding/mod.rs::deferred_delivery_backoff_bounds_short_lived_pty_attempts` | +| **Agent-declared presence discipline** | The shipped bus contract requires agents to declare `busy` before executing work, use `available` only while yielding or ready, and reserve `dnd` for an explicit hold. Both native harnesses materialize that contract. Busy remains observable but does not suppress DING; fresh `dnd` is the only delivery gate. | `tests/compile_agent.rs::compile_agent_generates_claude_then_materializes_verbatim_persona`; `tests/compile_agent.rs::compile_agent_generates_codex_then_materializes_composed_agents_md`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry` | | **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, explicit retirement state, activity, and inbox counts. Human output marks retired declarations without changing active rows. | `src/agents.rs::agents_json_has_stable_wire_shape`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence` | | **Agent-declared presence** | Refresh preserves non-DND declared status and only advances liveness; a missing status starts as `available`, while `dnd` is never refreshed and an unrefreshed declaration ages to `unknown`. | `src/status.rs::refresh_preserves_value_and_bumps_mtime`; `src/status.rs::refresh_leaves_dnd_to_age_out`; `src/status.rs::refresh_missing_writes_available_default`; `src/status.rs::stale_mtime_reads_as_unknown_regardless_of_contents` | | **Retirement health** | A retired declaration is healthy only after every declared task ID is absent. Any live or dead declared task record reports incomplete retirement; retired declarations do not require presence. Live declarations retain their existing task and presence checks. | `tests/doctor.rs::retired_declaration_is_healthy_when_tasks_and_presence_are_absent`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_declared_task_is_alive`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_dead_task_record_remains` | diff --git a/src/ding/composer.rs b/src/ding/composer.rs new file mode 100644 index 0000000..d448c94 --- /dev/null +++ b/src/ding/composer.rs @@ -0,0 +1,159 @@ +//! What a peeked screen proves about one exact notice, and the routing that decides which +//! composer on the screen is the live one. + +use super::harness::{self, Screen}; + +/// What the current bottom composer proves about one exact normalized notice. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ComposerState { + /// A maintained harness is positively idle and contains only its known placeholder. + EmptySafe, + /// The exact notice is the complete composer and the harness is positively idle. + ExactSafe, + /// The exact notice is present, but a modal, active turn, or non-idle footer blocks Return. + ExactBlocked, + /// A maintained composer contains different text (including a human draft). + Changed, + /// No maintained, unambiguous composer state was proven. + Ambiguous, +} + + +/// Enumerate the two logical strings possible at each renderer-shaped soft-wrap row: the TUI either +/// discarded one inter-word space or split a token. Current 80-column Codex/Claude composers wrap +/// long DING rows at 70+ content cells and indent continuations by exactly two cells. Short or +/// unfamiliar multiline input remains literal and cannot equal a normalized single-line DING. +pub(super) fn logical_soft_wrap_candidates(input: &str, minimum_first_content_chars: usize) -> Vec { + let rows: Vec<&str> = input.lines().collect(); + let Some(first) = rows.first() else { + return vec![String::new()]; + }; + if rows.len() == 1 { + return vec![(*first).to_string()]; + } + let mut candidates = vec![(*first).to_string()]; + let mut previous = *first; + for (index, row) in rows[1..].iter().enumerate() { + let required_previous_width = minimum_first_content_chars + usize::from(index > 0) * 2; + if previous.chars().count() < required_previous_width + || !row.starts_with(" ") + || row.trim().is_empty() + { + return vec![input.to_string()]; + } + let continuation = row.strip_prefix(" ").expect("prefix checked").trim_end(); + let mut next = Vec::with_capacity(candidates.len().saturating_mul(2).min(32)); + for candidate in candidates { + if next.len() >= 32 { + return vec![input.to_string()]; + } + next.push(format!("{candidate}{continuation}")); + next.push(format!("{candidate} {continuation}")); + } + candidates = next; + previous = row; + } + candidates +} + + +pub(super) fn looks_like_choice_menu(plain: &str) -> bool { + let mut first = false; + let mut later = false; + for line in plain.lines().map(str::trim_start) { + first |= line.starts_with("› 1.") || line.starts_with("> 1."); + later |= line.starts_with("2.") || line.starts_with("3."); + } + first && later +} + + +/// Strip the CSI/OSC sequences emitted by `pty peek` while preserving rendered text. Bounded +/// cursor-forward sequences represent visible spaces in current Codex and Claude panes. +pub(super) fn strip_ansi(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = String::with_capacity(input.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != 0x1b { + let ch = input[index..].chars().next().expect("valid UTF-8 boundary"); + out.push(ch); + index += ch.len_utf8(); + continue; + } + index += 1; + if index >= bytes.len() { + break; + } + match bytes[index] { + b'[' => { + index += 1; + let params_start = index; + let mut final_byte = None; + while index < bytes.len() { + let byte = bytes[index]; + index += 1; + if (0x40..=0x7e).contains(&byte) { + final_byte = Some(byte); + break; + } + } + if final_byte == Some(b'C') { + let params = &bytes[params_start..index.saturating_sub(1)]; + let width = if params.is_empty() { + Some(1) + } else if params.iter().all(u8::is_ascii_digit) { + std::str::from_utf8(params) + .ok() + .and_then(|value| value.parse::().ok()) + .map(|value| value.max(1)) + } else { + None + }; + if let Some(width) = width.filter(|width| *width <= 512) { + for _ in 0..width { + out.push(' '); + } + } + } + } + b']' => { + index += 1; + while index < bytes.len() { + if bytes[index] == 0x07 { + index += 1; + break; + } + if bytes[index] == 0x1b && bytes.get(index + 1) == Some(&b'\\') { + index += 2; + break; + } + index += 1; + } + } + _ => index += 1, + } + } + out +} + + +/// Locate every maintained composer and classify the LOWEST one on screen. +/// +/// A pane has exactly one live composer and it sits at the bottom of the viewport, so anything +/// composer-shaped above it is scrollback — typically a pasted or logged screen from the other +/// harness. Preferring one harness unconditionally lets that transcript decide, which is a wrong +/// *positive*: the paste and the Return go to the pane's real composer whichever text was read, so +/// it can type into, or submit, a human's live draft. Scrollback is above the live composer by +/// construction, so picking the lowest needs no per-pair special case. +pub(super) fn classify_composer(screen: &str, expected: &str) -> ComposerState { + let plain = strip_ansi(screen); + let screen = Screen { raw: screen, plain: &plain }; + harness::all() + .into_iter() + .filter_map(|harness| harness.locate(&screen).map(|located| (located.row, harness))) + .max_by_key(|(row, _)| *row) + .map(|(_, harness)| harness.classify(&screen, expected)) + // No maintained composer is locatable, so nothing is proven either way. + .unwrap_or(ComposerState::Ambiguous) +} diff --git a/src/ding/harness/claude.rs b/src/ding/harness/claude.rs new file mode 100644 index 0000000..e277caa --- /dev/null +++ b/src/ding/harness/claude.rs @@ -0,0 +1,150 @@ +//! The Claude Code composer: a `❯` row between two full-width rules, with the permission-mode +//! footer below it. + +use super::{Harness, Located, Screen}; +use crate::ding::composer::{ComposerState, logical_soft_wrap_candidates, looks_like_choice_menu}; + +pub(super) struct Claude; + +impl Harness for Claude { + fn locate(&self, screen: &Screen<'_>) -> Option { + located_bottom_claude_composer(screen.plain).map(|(row, _, _)| Located { row }) + } + + fn classify(&self, screen: &Screen<'_>, expected: &str) -> ComposerState { + let Some((_, logical_inputs, footer)) = located_bottom_claude_composer(screen.plain) else { + return ComposerState::Ambiguous; + }; + classify_claude_composer(screen.plain, (logical_inputs, footer), expected) + } +} + +fn classify_claude_composer( + plain: &str, + (logical_inputs, footer): (Vec, String), + expected: &str, +) -> ComposerState { + let exact = logical_inputs.iter().any(|input| input == expected); + // An empty composer is a stronger positive-empty proof than the placeholder below, because no + // human draft can be empty. Claude only shows the rotating placeholder on an unused pane. + let placeholder = logical_inputs.len() == 1 + && (logical_inputs[0].is_empty() || is_claude_idle_placeholder(&logical_inputs[0])); + // The keybinding hint is runtime-composed and may be absent or remapped. The mode marker and + // permission state are the stable safety signals; active/modal/changed states remain fail-closed. + let idle_footer = footer.contains("⏵⏵") && footer.contains("permissions on"); + let blocked = interaction_blocked(plain); + if exact { + if idle_footer && !blocked { + ComposerState::ExactSafe + } else { + ComposerState::ExactBlocked + } + } else if placeholder && idle_footer && !blocked { + ComposerState::EmptySafe + } else { + ComposerState::Changed + } +} + + +/// Claude 2.1.220 rotates repository-aware examples (file names and verbs vary) without styling +/// them differently from typed input. The exact `Try ""` grammar plus the +/// maintained idle footer is therefore the narrowest available positive heuristic. +fn is_claude_idle_placeholder(input: &str) -> bool { + input + .strip_prefix("Try \"") + .and_then(|example| example.strip_suffix('"')) + .is_some_and(|example| { + !example.is_empty() + && example.chars().count() <= 72 + && !example.chars().any(char::is_control) + && !example.contains(['\r', '\n']) + }) +} + + +/// Extract all logical strings consistent with Claude's renderer-proven soft wraps. At a full-width +/// row Claude may either wrap at a discarded space or split a token, so each proven boundary has +/// exactly two candidates: join with one space or with none. The bounded DING length keeps this set +/// small; any unfamiliar multiline shape fails closed. +fn located_bottom_claude_composer(plain: &str) -> Option<(usize, Vec, String)> { + let lines: Vec<&str> = plain.lines().collect(); + let separators: Vec = lines + .iter() + .enumerate() + .filter_map(|(index, line)| { + let trimmed = line.trim(); + (trimmed.chars().count() >= 40 && trimmed.chars().all(|ch| ch == '─')).then_some(index) + }) + .collect(); + let (&bottom, before_bottom) = separators.split_last()?; + let &top = before_bottom.last()?; + if bottom <= top + 1 { + return None; + } + + let rows = &lines[top + 1..bottom]; + // Strip the prompt before trimming: the separator Claude emits is U+00A0, which is itself + // trailing whitespace, so trimming first erases the prompt of an empty composer. + let first_row = rows.first()?; + let first = first_row + .strip_prefix("❯\u{00a0}") + .or_else(|| first_row.strip_prefix("❯ "))? + .trim_end(); + let input = std::iter::once(first) + .chain(rows[1..].iter().map(|row| row.trim_end())) + .collect::>() + .join("\n"); + let candidates = logical_soft_wrap_candidates(&input, 70); + let footer = lines[bottom + 1..].join("\n"); + // `top + 1` is the composer's first row, which is what the router compares against the Codex + // composer's row to find the lower — and therefore live — of the two. + Some((top + 1, candidates, footer)) +} + + +/// Claude 2.1.220 renders an in-flight turn as a status line and ships no interrupt hint with it. +/// Sampled across real panes, an active turn looks like `✻ Frolicking… (3m 35s · ↓ 6.9k tokens)`, +/// `✽ Schlepping…`, or `· Metamorphosing…`, while a finished turn looks like `✻ Brewed for 5s` or +/// `✻ Cogitated for 11s · 1 shell still running`. +/// +/// Two things follow. The leading glyph animates (`·` `✶` `✻` `✽` all observed), so it cannot be +/// matched literally. And the elapsed timer is absent from some active frames, so requiring it +/// would fail open on exactly the frames that matter. What separates the two states is the +/// ellipsis directly after a single gerund: a finished turn says `for s` instead. +/// +/// Matching the finished shape too would be catastrophic rather than merely conservative — every +/// idle pane shows it immediately after a turn, so DING would never deliver again. +pub(super) fn claude_turn_in_flight(plain: &str) -> bool { + plain.lines().any(|line| { + let Some((head, _)) = line.trim().split_once('…') else { + return false; + }; + let mut tokens = head.split_whitespace(); + let (Some(glyph), Some(word), None) = (tokens.next(), tokens.next(), tokens.next()) else { + return false; + }; + glyph.chars().count() == 1 + && !glyph.chars().any(char::is_alphanumeric) + && !word.is_empty() + && word.chars().all(char::is_alphabetic) + }) +} + + +/// States in which Return must not be sent. Several of these strings are Claude-specific chrome +/// that the pre-split shared predicate also applied to Codex panes; they are duplicated rather +/// than narrowed here so that splitting the harnesses apart changes no behaviour. +fn interaction_blocked(plain: &str) -> bool { + claude_turn_in_flight(plain) + || plain.contains("Working (") + || plain.contains("esc to interrupt") + || plain.contains("Esc to interrupt") + || plain.contains("ctrl+c to interrupt") + || plain.contains("Messages to be submitted after next tool call") + || plain.contains("press esc to interrupt and send") + || plain.contains("Our systems are thinking a bit more") + || plain.contains("Retry with a faster model") + || plain.contains("Create a plan?") + || looks_like_choice_menu(plain) +} diff --git a/src/ding/harness/codex.rs b/src/ding/harness/codex.rs new file mode 100644 index 0000000..a543fb7 --- /dev/null +++ b/src/ding/harness/codex.rs @@ -0,0 +1,202 @@ +//! The Codex composer: a `›` prompt written directly as an ANSI sequence, with the model/cwd +//! status line below it. + +use super::{Harness, Located, Screen}; +use crate::ding::composer::{ + ComposerState, logical_soft_wrap_candidates, looks_like_choice_menu, strip_ansi, +}; + +pub(super) struct Codex; + +impl Harness for Codex { + fn locate(&self, screen: &Screen<'_>) -> Option { + // The markers are ANSI, so this locator works in raw byte offsets while the router compares + // stripped rows. Every marker starts at an `\x1b[` boundary, so stripping the prefix is + // faithful and its newline count is that composer's row. + located_bottom_codex_composer(screen.raw) + .map(|(start, _)| Located { row: strip_ansi(&screen.raw[..start]).matches('\n').count() }) + } + + fn classify(&self, screen: &Screen<'_>, expected: &str) -> ComposerState { + classify_codex_composer(screen.raw, screen.plain, expected) + } +} + +const CODEX_EMPTY_COMPOSERS: [&str; 3] = [ + "\x1b[1;22m›\x1b[1C\x1b[22;2m", + "\x1b[1m›\x1b[1C\x1b[22;2m", + "\x1b[1m›\x1b[22m \x1b[2m", +]; + + +const CODEX_TYPED_COMPOSERS: [&str; 4] = [ + "\x1b[1;22m›\x1b[1C\x1b[0m", + "\x1b[1;2m› \x1b[0m", + "\x1b[1m›\x1b[1C\x1b[0m", + "\x1b[1m›\x1b[22m ", +]; + + +enum CodexComposer { + Empty, + Typed(String), +} + + +fn classify_codex_composer(screen: &str, plain: &str, expected: &str) -> ComposerState { + let blocked = interaction_blocked(plain); + let Some((start, composer)) = located_bottom_codex_composer(screen) else { + return ComposerState::Ambiguous; + }; + let idle_footer = codex_idle_footer(&screen[start..]); + match composer { + CodexComposer::Empty if !blocked && idle_footer => ComposerState::EmptySafe, + CodexComposer::Empty => ComposerState::Ambiguous, + CodexComposer::Typed(input) => { + let exact = logical_soft_wrap_candidates(&input, 70) + .iter() + .any(|input| input == expected); + if exact && !blocked && idle_footer { + ComposerState::ExactSafe + } else if exact { + ComposerState::ExactBlocked + } else { + ComposerState::Changed + } + } + } +} + + +fn codex_idle_footer(screen_from_composer: &str) -> bool { + strip_ansi(screen_from_composer).lines().any(|line| { + let line = line.trim(); + line.starts_with("gpt-") && line.contains(" · /") + }) +} + + +fn located_bottom_codex_composer(screen: &str) -> Option<(usize, CodexComposer)> { + let empty = CODEX_EMPTY_COMPOSERS + .iter() + .filter_map(|marker| { + screen + .rfind(marker) + .map(|start| (start, marker.len(), true)) + }) + .max_by_key(|(start, _, _)| *start); + let typed = CODEX_TYPED_COMPOSERS + .iter() + .filter_map(|marker| { + screen + .rfind(marker) + .map(|start| (start, marker.len(), false)) + }) + .max_by_key(|(start, _, _)| *start); + let (start, marker_len, empty) = match (empty, typed) { + (Some(empty), Some(typed)) if empty.0 >= typed.0 => empty, + (_, Some(typed)) => typed, + (Some(empty), None) => empty, + (None, None) => return None, + }; + if empty { + return Some((start, CodexComposer::Empty)); + } + let tail = &screen[start + marker_len..]; + let input = tail + .split_once("\r\n \x1b[") + .or_else(|| tail.split_once("\n \x1b[")) + .or_else(|| tail.split_once("\r\n\r\n")) + .or_else(|| tail.split_once("\n\n")) + .map(|(input, _)| input) + .unwrap_or(tail); + Some(( + start, + CodexComposer::Typed(normalize_codex_composer_input(input)), + )) +} + + +/// Strip ANSI from one bottom-composer input while joining only renderer-proven Codex soft wraps. +fn normalize_codex_composer_input(input: &str) -> String { + const VIEWPORT_WIDTH: usize = 80; + const PROMPT_WIDTH: usize = 2; + const WRAP_RIGHT_MARGIN: usize = 2; + const CONTINUATION_INDENT: &str = " "; + const WRAP_PADDING: &str = "\x1b[2X"; + + fn split_row(input: &str) -> (&str, Option<&str>) { + let Some(newline) = input.find('\n') else { + return (input, None); + }; + let row_end = if input[..newline].ends_with('\r') { + newline - 1 + } else { + newline + }; + (&input[..row_end], Some(&input[newline + 1..])) + } + + fn proven_wrap(row: &str, next: &str, first: bool) -> bool { + let Some(row_without_padding) = row.strip_suffix(WRAP_PADDING) else { + return false; + }; + let visible = strip_ansi(row_without_padding); + let next_content = next.strip_prefix(CONTINUATION_INDENT); + visible.is_ascii() + && next_content.is_some_and(|content| { + content + .chars() + .next() + .is_some_and(|ch| !ch.is_control() && !ch.is_whitespace()) + }) + && usize::from(first) * PROMPT_WIDTH + visible.len() + WRAP_RIGHT_MARGIN + == VIEWPORT_WIDTH + } + + let mut out = String::with_capacity(input.len()); + let mut rest = input; + let mut first = true; + let mut strip_continuation_indent = false; + loop { + let (row, next) = split_row(rest); + let visible = strip_ansi(row); + if strip_continuation_indent { + out.push_str( + visible + .strip_prefix(CONTINUATION_INDENT) + .unwrap_or(&visible), + ); + } else { + out.push_str(&visible); + } + let Some(next) = next else { + break; + }; + strip_continuation_indent = proven_wrap(row, next, first); + if !strip_continuation_indent { + out.push('\n'); + } + rest = next; + first = false; + } + out +} + + +/// States in which Return must not be sent. Several of these strings are Claude-specific chrome +/// that the pre-split shared predicate also applied to Codex panes; they are duplicated rather +/// than narrowed here so that splitting the harnesses apart changes no behaviour. +fn interaction_blocked(plain: &str) -> bool { + crate::ding::harness::claude::claude_turn_in_flight(plain) + || plain.contains("Working (") + || plain.contains("esc to interrupt") + || plain.contains("Esc to interrupt") + || plain.contains("ctrl+c to interrupt") + || plain.contains("Messages to be submitted after next tool call") + || plain.contains("press esc to interrupt and send") + || plain.contains("Our systems are thinking a bit more") + || plain.contains("Retry with a faster model") + || plain.contains("Create a plan?") + || looks_like_choice_menu(plain) +} diff --git a/src/ding/harness/mod.rs b/src/ding/harness/mod.rs new file mode 100644 index 0000000..0fa8619 --- /dev/null +++ b/src/ding/harness/mod.rs @@ -0,0 +1,45 @@ +//! Per-harness composer adapters behind one positional rule. +//! +//! A pane has exactly one live composer and it is the lowest one on screen; anything +//! composer-shaped above it is scrollback, typically a captured screen from the other harness. +//! Asking every harness where its composer is and then classifying the lowest makes that a single +//! comparison rather than a special case per harness pair, and it means adding a harness cannot +//! reintroduce a preference ordering. +//! +//! Active-turn and modal detection is per-harness on purpose: the shapes are harness-specific TUI +//! chrome, not a shared contract. Only genuinely cross-harness shapes stay in `composer`. + +pub(super) mod claude; +pub(super) mod codex; + +use super::composer::ComposerState; + +/// One peeked screen in both forms a harness may need: the raw bytes, in which the Codex composer +/// markers are written as ANSI sequences, and the stripped text, in which the Claude rules are +/// found. Both describe the same screen, so a row index is comparable across harnesses. +pub(super) struct Screen<'a> { + pub(super) raw: &'a str, + pub(super) plain: &'a str, +} + +/// Where a harness found its live composer. +pub(super) struct Located { + /// The composer's first row in the stripped screen. The router classifies the lowest. + pub(super) row: usize, +} + +pub(super) trait Harness { + /// Locate this harness's composer, if this screen has one. + fn locate(&self, screen: &Screen<'_>) -> Option; + + /// Classify the composer `locate` found. Only called when `locate` returned `Some`, so each + /// implementation re-derives it rather than the registry threading a harness-specific payload + /// through; a screen is one viewport, so the extra scan is a few string searches. + fn classify(&self, screen: &Screen<'_>, expected: &str) -> ComposerState; +} + +/// Every registered harness. Claude is last so that an exact row tie resolves to Claude, which is +/// how the positional comparison behaved before the harnesses were split apart. +pub(super) fn all() -> [&'static dyn Harness; 2] { + [&codex::Codex, &claude::Claude] +} diff --git a/src/ding.rs b/src/ding/mod.rs similarity index 78% rename from src/ding.rs rename to src/ding/mod.rs index 5ae6be5..db4566b 100644 --- a/src/ding.rs +++ b/src/ding/mod.rs @@ -21,7 +21,11 @@ use std::sync::mpsc::{Receiver, channel}; use std::thread; use std::time::{Duration, Instant}; +mod composer; +mod harness; + use crate::message::{self, Message}; +use composer::{ComposerState, classify_composer}; use crate::status; const BRACKETED_PASTE_START: &str = "\x1b[200~"; @@ -292,20 +296,6 @@ fn output_with_timeout(command: &mut Command, timeout: Duration) -> anyhow::Resu }) } -/// What the current bottom composer proves about one exact normalized notice. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ComposerState { - /// A maintained harness is positively idle and contains only its known placeholder. - EmptySafe, - /// The exact notice is the complete composer and the harness is positively idle. - ExactSafe, - /// The exact notice is present, but a modal, active turn, or non-idle footer blocks Return. - ExactBlocked, - /// A maintained composer contains different text (including a human draft). - Changed, - /// No maintained, unambiguous composer state was proven. - Ambiguous, -} fn exact_staged_candidate(screen: &str, candidates: &[String]) -> Option { candidates.iter().find_map(|candidate| { @@ -452,432 +442,19 @@ fn observed_retry_staged( } } -const CODEX_EMPTY_COMPOSERS: [&str; 3] = [ - "\x1b[1;22m›\x1b[1C\x1b[22;2m", - "\x1b[1m›\x1b[1C\x1b[22;2m", - "\x1b[1m›\x1b[22m \x1b[2m", -]; -const CODEX_TYPED_COMPOSERS: [&str; 4] = [ - "\x1b[1;22m›\x1b[1C\x1b[0m", - "\x1b[1;2m› \x1b[0m", - "\x1b[1m›\x1b[1C\x1b[0m", - "\x1b[1m›\x1b[22m ", -]; -enum CodexComposer { - Empty, - Typed(String), -} -/// Locate every maintained composer and classify the LOWEST one on screen. -/// -/// A pane has exactly one live composer and it sits at the bottom of the viewport, so anything -/// composer-shaped above it is scrollback — typically a pasted or logged screen from the other -/// harness. Preferring one harness unconditionally lets that transcript decide: a Codex pane whose -/// scrollback holds a captured Claude screen would be classified from the capture. That is a -/// wrong *positive* rather than a wrong negative, because the paste and the Return go to the pane's -/// real composer whichever text was read — so it can type into, or submit, a human's live draft. -/// Comparing positions needs no per-pair special case: scrollback is above the live composer by -/// construction. -/// -/// Detection deliberately does not consult the `Claude Code v…` startup banner. `pty peek` returns -/// the viewport only, and the banner is absent from real panes (see the harness note on -/// `located_bottom_claude_composer`), so it cannot gate anything. The ruled `❯` composer is durable. -fn classify_composer(screen: &str, expected: &str) -> ComposerState { - let plain = strip_ansi(screen); - let claude = located_bottom_claude_composer(&plain); - // The Codex locator reports a byte offset into the raw ANSI screen while the Claude locator - // works in `plain` line indices. Every Codex marker starts at an `\x1b[` boundary, so stripping - // the prefix is faithful and its newline count is that composer's `plain` row. - let codex_row = located_bottom_codex_composer(screen) - .map(|(start, _)| strip_ansi(&screen[..start]).matches('\n').count()); - match (claude, codex_row) { - (Some((claude_row, _, _)), Some(codex_row)) if claude_row < codex_row => { - classify_codex_composer(screen, &plain, expected) - } - (Some((_, logical_inputs, footer)), _) => { - classify_claude_composer(&plain, (logical_inputs, footer), expected) - } - (None, Some(_)) => classify_codex_composer(screen, &plain, expected), - // No maintained composer is locatable, so nothing is proven either way. - (None, None) => ComposerState::Ambiguous, - } -} -fn classify_codex_composer(screen: &str, plain: &str, expected: &str) -> ComposerState { - let blocked = interaction_blocked(plain); - let Some((start, composer)) = located_bottom_codex_composer(screen) else { - return ComposerState::Ambiguous; - }; - let idle_footer = codex_idle_footer(&screen[start..]); - match composer { - CodexComposer::Empty if !blocked && idle_footer => ComposerState::EmptySafe, - CodexComposer::Empty => ComposerState::Ambiguous, - CodexComposer::Typed(input) => { - let exact = logical_soft_wrap_candidates(&input, 70) - .iter() - .any(|input| input == expected); - if exact && !blocked && idle_footer { - ComposerState::ExactSafe - } else if exact { - ComposerState::ExactBlocked - } else { - ComposerState::Changed - } - } - } -} -fn codex_idle_footer(screen_from_composer: &str) -> bool { - strip_ansi(screen_from_composer).lines().any(|line| { - let line = line.trim(); - line.starts_with("gpt-") && line.contains(" · /") - }) -} -fn classify_claude_composer( - plain: &str, - (logical_inputs, footer): (Vec, String), - expected: &str, -) -> ComposerState { - let exact = logical_inputs.iter().any(|input| input == expected); - // An empty composer is a stronger positive-empty proof than the placeholder below, because no - // human draft can be empty. Claude only shows the rotating placeholder on an unused pane. - let placeholder = logical_inputs.len() == 1 - && (logical_inputs[0].is_empty() || is_claude_idle_placeholder(&logical_inputs[0])); - // The keybinding hint is runtime-composed and may be absent or remapped. The mode marker and - // permission state are the stable safety signals; active/modal/changed states remain fail-closed. - let idle_footer = footer.contains("⏵⏵") && footer.contains("permissions on"); - let blocked = interaction_blocked(plain); - if exact { - if idle_footer && !blocked { - ComposerState::ExactSafe - } else { - ComposerState::ExactBlocked - } - } else if placeholder && idle_footer && !blocked { - ComposerState::EmptySafe - } else { - ComposerState::Changed - } -} -/// Claude 2.1.220 rotates repository-aware examples (file names and verbs vary) without styling -/// them differently from typed input. The exact `Try ""` grammar plus the -/// maintained idle footer is therefore the narrowest available positive heuristic. -fn is_claude_idle_placeholder(input: &str) -> bool { - input - .strip_prefix("Try \"") - .and_then(|example| example.strip_suffix('"')) - .is_some_and(|example| { - !example.is_empty() - && example.chars().count() <= 72 - && !example.chars().any(char::is_control) - && !example.contains(['\r', '\n']) - }) -} -/// Extract all logical strings consistent with Claude's renderer-proven soft wraps. At a full-width -/// row Claude may either wrap at a discarded space or split a token, so each proven boundary has -/// exactly two candidates: join with one space or with none. The bounded DING length keeps this set -/// small; any unfamiliar multiline shape fails closed. -fn located_bottom_claude_composer(plain: &str) -> Option<(usize, Vec, String)> { - let lines: Vec<&str> = plain.lines().collect(); - let separators: Vec = lines - .iter() - .enumerate() - .filter_map(|(index, line)| { - let trimmed = line.trim(); - (trimmed.chars().count() >= 40 && trimmed.chars().all(|ch| ch == '─')).then_some(index) - }) - .collect(); - let (&bottom, before_bottom) = separators.split_last()?; - let &top = before_bottom.last()?; - if bottom <= top + 1 { - return None; - } - - let rows = &lines[top + 1..bottom]; - // Strip the prompt before trimming: the separator Claude emits is U+00A0, which is itself - // trailing whitespace, so trimming first erases the prompt of an empty composer. - let first_row = rows.first()?; - let first = first_row - .strip_prefix("❯\u{00a0}") - .or_else(|| first_row.strip_prefix("❯ "))? - .trim_end(); - let input = std::iter::once(first) - .chain(rows[1..].iter().map(|row| row.trim_end())) - .collect::>() - .join("\n"); - let candidates = logical_soft_wrap_candidates(&input, 70); - let footer = lines[bottom + 1..].join("\n"); - // `top + 1` is the composer's first row, which is what the router compares against the Codex - // composer's row to find the lower — and therefore live — of the two. - Some((top + 1, candidates, footer)) -} -/// Enumerate the two logical strings possible at each renderer-shaped soft-wrap row: the TUI either -/// discarded one inter-word space or split a token. Current 80-column Codex/Claude composers wrap -/// long DING rows at 70+ content cells and indent continuations by exactly two cells. Short or -/// unfamiliar multiline input remains literal and cannot equal a normalized single-line DING. -fn logical_soft_wrap_candidates(input: &str, minimum_first_content_chars: usize) -> Vec { - let rows: Vec<&str> = input.lines().collect(); - let Some(first) = rows.first() else { - return vec![String::new()]; - }; - if rows.len() == 1 { - return vec![(*first).to_string()]; - } - let mut candidates = vec![(*first).to_string()]; - let mut previous = *first; - for (index, row) in rows[1..].iter().enumerate() { - let required_previous_width = minimum_first_content_chars + usize::from(index > 0) * 2; - if previous.chars().count() < required_previous_width - || !row.starts_with(" ") - || row.trim().is_empty() - { - return vec![input.to_string()]; - } - let continuation = row.strip_prefix(" ").expect("prefix checked").trim_end(); - let mut next = Vec::with_capacity(candidates.len().saturating_mul(2).min(32)); - for candidate in candidates { - if next.len() >= 32 { - return vec![input.to_string()]; - } - next.push(format!("{candidate}{continuation}")); - next.push(format!("{candidate} {continuation}")); - } - candidates = next; - previous = row; - } - candidates -} - -/// Claude 2.1.220 renders an in-flight turn as a status line and ships no interrupt hint with it. -/// Sampled across real panes, an active turn looks like `✻ Frolicking… (3m 35s · ↓ 6.9k tokens)`, -/// `✽ Schlepping…`, or `· Metamorphosing…`, while a finished turn looks like `✻ Brewed for 5s` or -/// `✻ Cogitated for 11s · 1 shell still running`. -/// -/// Two things follow. The leading glyph animates (`·` `✶` `✻` `✽` all observed), so it cannot be -/// matched literally. And the elapsed timer is absent from some active frames, so requiring it -/// would fail open on exactly the frames that matter. What separates the two states is the -/// ellipsis directly after a single gerund: a finished turn says `for s` instead. -/// -/// Matching the finished shape too would be catastrophic rather than merely conservative — every -/// idle pane shows it immediately after a turn, so DING would never deliver again. -fn claude_turn_in_flight(plain: &str) -> bool { - plain.lines().any(|line| { - let Some((head, _)) = line.trim().split_once('…') else { - return false; - }; - let mut tokens = head.split_whitespace(); - let (Some(glyph), Some(word), None) = (tokens.next(), tokens.next(), tokens.next()) else { - return false; - }; - glyph.chars().count() == 1 - && !glyph.chars().any(char::is_alphanumeric) - && !word.is_empty() - && word.chars().all(char::is_alphabetic) - }) -} -fn interaction_blocked(plain: &str) -> bool { - claude_turn_in_flight(plain) - || plain.contains("Working (") - || plain.contains("esc to interrupt") - || plain.contains("Esc to interrupt") - || plain.contains("ctrl+c to interrupt") - || plain.contains("Messages to be submitted after next tool call") - || plain.contains("press esc to interrupt and send") - || plain.contains("Our systems are thinking a bit more") - || plain.contains("Retry with a faster model") - || plain.contains("Create a plan?") - || looks_like_choice_menu(plain) -} -fn looks_like_choice_menu(plain: &str) -> bool { - let mut first = false; - let mut later = false; - for line in plain.lines().map(str::trim_start) { - first |= line.starts_with("› 1.") || line.starts_with("> 1."); - later |= line.starts_with("2.") || line.starts_with("3."); - } - first && later -} -fn located_bottom_codex_composer(screen: &str) -> Option<(usize, CodexComposer)> { - let empty = CODEX_EMPTY_COMPOSERS - .iter() - .filter_map(|marker| { - screen - .rfind(marker) - .map(|start| (start, marker.len(), true)) - }) - .max_by_key(|(start, _, _)| *start); - let typed = CODEX_TYPED_COMPOSERS - .iter() - .filter_map(|marker| { - screen - .rfind(marker) - .map(|start| (start, marker.len(), false)) - }) - .max_by_key(|(start, _, _)| *start); - let (start, marker_len, empty) = match (empty, typed) { - (Some(empty), Some(typed)) if empty.0 >= typed.0 => empty, - (_, Some(typed)) => typed, - (Some(empty), None) => empty, - (None, None) => return None, - }; - if empty { - return Some((start, CodexComposer::Empty)); - } - let tail = &screen[start + marker_len..]; - let input = tail - .split_once("\r\n \x1b[") - .or_else(|| tail.split_once("\n \x1b[")) - .or_else(|| tail.split_once("\r\n\r\n")) - .or_else(|| tail.split_once("\n\n")) - .map(|(input, _)| input) - .unwrap_or(tail); - Some(( - start, - CodexComposer::Typed(normalize_codex_composer_input(input)), - )) -} -/// Strip ANSI from one bottom-composer input while joining only renderer-proven Codex soft wraps. -fn normalize_codex_composer_input(input: &str) -> String { - const VIEWPORT_WIDTH: usize = 80; - const PROMPT_WIDTH: usize = 2; - const WRAP_RIGHT_MARGIN: usize = 2; - const CONTINUATION_INDENT: &str = " "; - const WRAP_PADDING: &str = "\x1b[2X"; - - fn split_row(input: &str) -> (&str, Option<&str>) { - let Some(newline) = input.find('\n') else { - return (input, None); - }; - let row_end = if input[..newline].ends_with('\r') { - newline - 1 - } else { - newline - }; - (&input[..row_end], Some(&input[newline + 1..])) - } - fn proven_wrap(row: &str, next: &str, first: bool) -> bool { - let Some(row_without_padding) = row.strip_suffix(WRAP_PADDING) else { - return false; - }; - let visible = strip_ansi(row_without_padding); - let next_content = next.strip_prefix(CONTINUATION_INDENT); - visible.is_ascii() - && next_content.is_some_and(|content| { - content - .chars() - .next() - .is_some_and(|ch| !ch.is_control() && !ch.is_whitespace()) - }) - && usize::from(first) * PROMPT_WIDTH + visible.len() + WRAP_RIGHT_MARGIN - == VIEWPORT_WIDTH - } - - let mut out = String::with_capacity(input.len()); - let mut rest = input; - let mut first = true; - let mut strip_continuation_indent = false; - loop { - let (row, next) = split_row(rest); - let visible = strip_ansi(row); - if strip_continuation_indent { - out.push_str( - visible - .strip_prefix(CONTINUATION_INDENT) - .unwrap_or(&visible), - ); - } else { - out.push_str(&visible); - } - let Some(next) = next else { - break; - }; - strip_continuation_indent = proven_wrap(row, next, first); - if !strip_continuation_indent { - out.push('\n'); - } - rest = next; - first = false; - } - out -} -/// Strip the CSI/OSC sequences emitted by `pty peek` while preserving rendered text. Bounded -/// cursor-forward sequences represent visible spaces in current Codex and Claude panes. -fn strip_ansi(input: &str) -> String { - let bytes = input.as_bytes(); - let mut out = String::with_capacity(input.len()); - let mut index = 0; - while index < bytes.len() { - if bytes[index] != 0x1b { - let ch = input[index..].chars().next().expect("valid UTF-8 boundary"); - out.push(ch); - index += ch.len_utf8(); - continue; - } - index += 1; - if index >= bytes.len() { - break; - } - match bytes[index] { - b'[' => { - index += 1; - let params_start = index; - let mut final_byte = None; - while index < bytes.len() { - let byte = bytes[index]; - index += 1; - if (0x40..=0x7e).contains(&byte) { - final_byte = Some(byte); - break; - } - } - if final_byte == Some(b'C') { - let params = &bytes[params_start..index.saturating_sub(1)]; - let width = if params.is_empty() { - Some(1) - } else if params.iter().all(u8::is_ascii_digit) { - std::str::from_utf8(params) - .ok() - .and_then(|value| value.parse::().ok()) - .map(|value| value.max(1)) - } else { - None - }; - if let Some(width) = width.filter(|width| *width <= 512) { - for _ in 0..width { - out.push(' '); - } - } - } - } - b']' => { - index += 1; - while index < bytes.len() { - if bytes[index] == 0x07 { - index += 1; - break; - } - if bytes[index] == 0x1b && bytes.get(index + 1) == Some(&b'\\') { - index += 2; - break; - } - index += 1; - } - } - _ => index += 1, - } - } - out -} /// `/.pid` + `kill(pid, 0)`; any miss means gone. This mirrors the /// session registry's own liveness probe without forking `pty`. From 2cd0635fb726be6fbec6d45216e6d3e78732280a Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:13:15 +0200 Subject: [PATCH 5/8] style(ding): collapse blank-line runs left by the module split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extraction removed whole items but left their leading blank separators behind, so the previous commit's diff carries a fifteen-line whitespace gap in `mod.rs` and smaller ones in the new modules. `cargo fmt` is deliberately not gated in this repo — and the tree is not rustfmt-clean, so running it broadly would reformat hand-written code — hence collapsing only the runs the split introduced, between top-level items. Whitespace only. The test list stays byte-identical at 142 and every INVARIANTS.md reference still resolves. agent-session-id: e5217740-eef6-48eb-a794-3e5e11939e4d agent-tool: Claude Code agent-tool-version: 2.1.220 agent-model: claude-opus-5 agent-runtime-profile: /nix/store/i8y8b542cyqi385ywcjw5fvsq24f75v4-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/i81qxhzlrzcxrrdwpp6i8hagka2gby8y-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- src/ding/composer.rs | 4 ---- src/ding/harness/claude.rs | 4 ---- src/ding/harness/codex.rs | 7 ------- src/ding/mod.rs | 15 --------------- 4 files changed, 30 deletions(-) diff --git a/src/ding/composer.rs b/src/ding/composer.rs index d448c94..7b491b6 100644 --- a/src/ding/composer.rs +++ b/src/ding/composer.rs @@ -18,7 +18,6 @@ pub(super) enum ComposerState { Ambiguous, } - /// Enumerate the two logical strings possible at each renderer-shaped soft-wrap row: the TUI either /// discarded one inter-word space or split a token. Current 80-column Codex/Claude composers wrap /// long DING rows at 70+ content cells and indent continuations by exactly two cells. Short or @@ -56,7 +55,6 @@ pub(super) fn logical_soft_wrap_candidates(input: &str, minimum_first_content_ch candidates } - pub(super) fn looks_like_choice_menu(plain: &str) -> bool { let mut first = false; let mut later = false; @@ -67,7 +65,6 @@ pub(super) fn looks_like_choice_menu(plain: &str) -> bool { first && later } - /// Strip the CSI/OSC sequences emitted by `pty peek` while preserving rendered text. Bounded /// cursor-forward sequences represent visible spaces in current Codex and Claude panes. pub(super) fn strip_ansi(input: &str) -> String { @@ -137,7 +134,6 @@ pub(super) fn strip_ansi(input: &str) -> String { out } - /// Locate every maintained composer and classify the LOWEST one on screen. /// /// A pane has exactly one live composer and it sits at the bottom of the viewport, so anything diff --git a/src/ding/harness/claude.rs b/src/ding/harness/claude.rs index e277caa..bbf7ec0 100644 --- a/src/ding/harness/claude.rs +++ b/src/ding/harness/claude.rs @@ -46,7 +46,6 @@ fn classify_claude_composer( } } - /// Claude 2.1.220 rotates repository-aware examples (file names and verbs vary) without styling /// them differently from typed input. The exact `Try ""` grammar plus the /// maintained idle footer is therefore the narrowest available positive heuristic. @@ -62,7 +61,6 @@ fn is_claude_idle_placeholder(input: &str) -> bool { }) } - /// Extract all logical strings consistent with Claude's renderer-proven soft wraps. At a full-width /// row Claude may either wrap at a discarded space or split a token, so each proven boundary has /// exactly two candidates: join with one space or with none. The bounded DING length keeps this set @@ -102,7 +100,6 @@ fn located_bottom_claude_composer(plain: &str) -> Option<(usize, Vec, St Some((top + 1, candidates, footer)) } - /// Claude 2.1.220 renders an in-flight turn as a status line and ships no interrupt hint with it. /// Sampled across real panes, an active turn looks like `✻ Frolicking… (3m 35s · ↓ 6.9k tokens)`, /// `✽ Schlepping…`, or `· Metamorphosing…`, while a finished turn looks like `✻ Brewed for 5s` or @@ -131,7 +128,6 @@ pub(super) fn claude_turn_in_flight(plain: &str) -> bool { }) } - /// States in which Return must not be sent. Several of these strings are Claude-specific chrome /// that the pre-split shared predicate also applied to Codex panes; they are duplicated rather /// than narrowed here so that splitting the harnesses apart changes no behaviour. diff --git a/src/ding/harness/codex.rs b/src/ding/harness/codex.rs index a543fb7..55089cc 100644 --- a/src/ding/harness/codex.rs +++ b/src/ding/harness/codex.rs @@ -28,7 +28,6 @@ const CODEX_EMPTY_COMPOSERS: [&str; 3] = [ "\x1b[1m›\x1b[22m \x1b[2m", ]; - const CODEX_TYPED_COMPOSERS: [&str; 4] = [ "\x1b[1;22m›\x1b[1C\x1b[0m", "\x1b[1;2m› \x1b[0m", @@ -36,13 +35,11 @@ const CODEX_TYPED_COMPOSERS: [&str; 4] = [ "\x1b[1m›\x1b[22m ", ]; - enum CodexComposer { Empty, Typed(String), } - fn classify_codex_composer(screen: &str, plain: &str, expected: &str) -> ComposerState { let blocked = interaction_blocked(plain); let Some((start, composer)) = located_bottom_codex_composer(screen) else { @@ -67,7 +64,6 @@ fn classify_codex_composer(screen: &str, plain: &str, expected: &str) -> Compose } } - fn codex_idle_footer(screen_from_composer: &str) -> bool { strip_ansi(screen_from_composer).lines().any(|line| { let line = line.trim(); @@ -75,7 +71,6 @@ fn codex_idle_footer(screen_from_composer: &str) -> bool { }) } - fn located_bottom_codex_composer(screen: &str) -> Option<(usize, CodexComposer)> { let empty = CODEX_EMPTY_COMPOSERS .iter() @@ -116,7 +111,6 @@ fn located_bottom_codex_composer(screen: &str) -> Option<(usize, CodexComposer)> )) } - /// Strip ANSI from one bottom-composer input while joining only renderer-proven Codex soft wraps. fn normalize_codex_composer_input(input: &str) -> String { const VIEWPORT_WIDTH: usize = 80; @@ -183,7 +177,6 @@ fn normalize_codex_composer_input(input: &str) -> String { out } - /// States in which Return must not be sent. Several of these strings are Claude-specific chrome /// that the pre-split shared predicate also applied to Codex panes; they are duplicated rather /// than narrowed here so that splitting the harnesses apart changes no behaviour. diff --git a/src/ding/mod.rs b/src/ding/mod.rs index db4566b..555335e 100644 --- a/src/ding/mod.rs +++ b/src/ding/mod.rs @@ -296,7 +296,6 @@ fn output_with_timeout(command: &mut Command, timeout: Duration) -> anyhow::Resu }) } - fn exact_staged_candidate(screen: &str, candidates: &[String]) -> Option { candidates.iter().find_map(|candidate| { matches!( @@ -442,20 +441,6 @@ fn observed_retry_staged( } } - - - - - - - - - - - - - - /// `/.pid` + `kill(pid, 0)`; any miss means gone. This mirrors the /// session registry's own liveness probe without forking `pty`. pub fn session_alive(session: &str) -> bool { From 39b1067b299b3128dae7815ff058f7f8daed30e7 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:25:28 +0200 Subject: [PATCH 6/8] test(ding): pin the row normalization behind positional dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two locators do not natively work in the same units. Codex is matched with `rfind` over the raw screen and reports a byte offset, inflated by every escape sequence above it; Claude is matched over stripped lines and reports a row. Comparing those directly picks Codex almost always, since an offset dwarfs a row — including when the live composer is Claude's and the Codex match is stale scrollback, which is the dangerous direction. The dispatch already normalizes, so this changes no production code. It pins the property, which was previously only argued in a comment. On the added screen, measured: the Codex composer sits at byte offset 560 but row 10, while the live Claude composer is row 20. Reverting the locator to the raw offset turns the assertion from `EmptySafe` into `Changed` — classifying a pasted draft instead of the real composer. The normalization counts newlines in the *stripped* prefix, which is only faithful if stripping preserves them. Verified rather than assumed, and it does not hold universally: the CSI scanner runs until a byte in `0x40..=0x7e` and `\n` is `0x0a`, so an unterminated CSI eats newlines, and an unterminated OSC consumes to end of input. Well-formed sequences preserve the count exactly. Both directions are now asserted, so a future change to `strip_ansi` cannot silently shift every row. agent-session-id: e5217740-eef6-48eb-a794-3e5e11939e4d agent-tool: Claude Code agent-tool-version: 2.1.220 agent-model: claude-opus-5 agent-runtime-profile: /nix/store/i8y8b542cyqi385ywcjw5fvsq24f75v4-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/i81qxhzlrzcxrrdwpp6i8hagka2gby8y-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- src/ding/mod.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/ding/mod.rs b/src/ding/mod.rs index 555335e..29d98c5 100644 --- a/src/ding/mod.rs +++ b/src/ding/mod.rs @@ -1154,6 +1154,16 @@ mod tests { "✻ Baked for 3s · 1 shell still running", ]; + /// A live Claude composer with a stale Codex composer above it in scrollback, preceded by + /// escape-heavy output. The escapes inflate the Codex byte offset far past the Claude + /// composer's row, which is what makes the two locators' units observably disagree. + fn live_claude_below_escape_heavy_codex_transcript() -> String { + let padding = "\x1b[1;32m\x1b[38;5;204mpadding with lots of escapes\x1b[0m\x1b[0m\r\n".repeat(10); + let codex = staged_codex_screen("a stale pasted codex draft"); + let filler = "\x1b[1;32mmore padding\x1b[0m\r\n".repeat(6); + format!("{padding}{codex}\r\n{filler}{}", mature_idle_claude_screen()) + } + /// A Codex pane whose scrollback holds a captured Claude screen — two ruled lines around a `❯` /// row plus a Claude idle footer — above the live, ANSI-detected Codex composer. Capturing and /// pasting pane text is routine, so this shape is not exotic. @@ -1209,6 +1219,44 @@ mod tests { } } + /// The two locators do not natively work in the same units. Codex is matched with `rfind` over + /// the raw screen, so it reports a **byte offset** inflated by every escape sequence above it; + /// Claude is matched over stripped lines, so it reports a **row**. Comparing those directly + /// picks Codex almost always, since an offset dwarfs a row — including when the live composer + /// is Claude's and the Codex match is stale scrollback. Both must be normalized to a row. + /// + /// On the screen below, measured: the Codex composer sits at byte offset 560 but row 10, while + /// the live Claude composer is row 20. Comparing row against offset picks Codex, so this would + /// classify from a pasted draft instead of the real composer. + #[test] + fn composer_positions_are_compared_as_rows_not_raw_byte_offsets() { + let expected = + "[DING] new st2 message: [id:abc123] exact observation (from cos); check your inbox"; + assert_eq!( + classify_composer(&live_claude_below_escape_heavy_codex_transcript(), expected), + ComposerState::EmptySafe + ); + } + + /// Normalizing the Codex offset means counting newlines in the *stripped* prefix, which is only + /// faithful if stripping preserves them. It does for well-formed input. It does not for an + /// unterminated sequence: the CSI scanner runs until a byte in `0x40..=0x7e` and `\n` is `0x0a`, + /// so it eats newlines, and an unterminated OSC consumes to the end of input. Both are recorded + /// here so a future change to `strip_ansi` cannot silently shift every row. + #[test] + fn stripping_preserves_newlines_for_well_formed_sequences_only() { + let nl = |text: &str| composer::strip_ansi(text).matches('\n').count(); + + assert_eq!( + nl("\x1b[1;32mone\x1b[0m\r\n\x1b[2Ctwo\x1b[0m\r\n\x1b[1mthree\x1b[0m\r\n"), + 3 + ); + // Unterminated CSI: the newline is consumed while hunting for a final byte. + assert_eq!(nl("before\r\n\x1b[999999\r\nafter\r\n"), 2); + // Unterminated OSC: everything to the end of input is consumed. + assert_eq!(nl("before\r\n\x1b]0;no terminator\r\nafter\r\n"), 1); + } + /// Scrollback that merely looks like a composer must never outrank the live one. The paste and /// the Return always go to the pane's real bottom composer, so misreading transcript text as /// "idle" or "already staged" is a wrong positive: it can type into, or submit, a human draft. From bf61aa1ac317bad358a1147c4b08332597ed7685 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:29:11 +0200 Subject: [PATCH 7/8] refactor(ding): make the Codex blocked predicate independent of Claude The split left the Codex predicate calling into the Claude module for the progress-line shape, which is the coupling the harness boundary exists to prevent: one harness's rendering reaching directly into another's safety gate, and a narrowing edit to one silently changing the other. Duplicate the shape into the Codex adapter instead, so narrowing it later is a local edit. That duplication is the accepted cost recorded in the dispatch decision, and `claude_turn_in_flight` goes back to private. Behaviour is unchanged: the predicate is the same shape over the same input, and the two harnesses still block on the same set they did before the split. agent-session-id: e5217740-eef6-48eb-a794-3e5e11939e4d agent-tool: Claude Code agent-tool-version: 2.1.220 agent-model: claude-opus-5 agent-runtime-profile: /nix/store/i8y8b542cyqi385ywcjw5fvsq24f75v4-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/i81qxhzlrzcxrrdwpp6i8hagka2gby8y-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- src/ding/harness/claude.rs | 2 +- src/ding/harness/codex.rs | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/ding/harness/claude.rs b/src/ding/harness/claude.rs index bbf7ec0..1ab2bdf 100644 --- a/src/ding/harness/claude.rs +++ b/src/ding/harness/claude.rs @@ -112,7 +112,7 @@ fn located_bottom_claude_composer(plain: &str) -> Option<(usize, Vec, St /// /// Matching the finished shape too would be catastrophic rather than merely conservative — every /// idle pane shows it immediately after a turn, so DING would never deliver again. -pub(super) fn claude_turn_in_flight(plain: &str) -> bool { +fn claude_turn_in_flight(plain: &str) -> bool { plain.lines().any(|line| { let Some((head, _)) = line.trim().split_once('…') else { return false; diff --git a/src/ding/harness/codex.rs b/src/ding/harness/codex.rs index 55089cc..6d5b4f5 100644 --- a/src/ding/harness/codex.rs +++ b/src/ding/harness/codex.rs @@ -180,8 +180,27 @@ fn normalize_codex_composer_input(input: &str) -> String { /// States in which Return must not be sent. Several of these strings are Claude-specific chrome /// that the pre-split shared predicate also applied to Codex panes; they are duplicated rather /// than narrowed here so that splitting the harnesses apart changes no behaviour. +/// The progress-line shape inherited from the pre-split shared predicate. It is another harness's +/// rendering, kept here only so the split changed no behaviour, and duplicated rather than called +/// across module lines so that narrowing it is a local edit to this file. +fn inherited_progress_line(plain: &str) -> bool { + plain.lines().any(|line| { + let Some((head, _)) = line.trim().split_once('…') else { + return false; + }; + let mut tokens = head.split_whitespace(); + let (Some(glyph), Some(word), None) = (tokens.next(), tokens.next(), tokens.next()) else { + return false; + }; + glyph.chars().count() == 1 + && !glyph.chars().any(char::is_alphanumeric) + && !word.is_empty() + && word.chars().all(char::is_alphabetic) + }) +} + fn interaction_blocked(plain: &str) -> bool { - crate::ding::harness::claude::claude_turn_in_flight(plain) + inherited_progress_line(plain) || plain.contains("Working (") || plain.contains("esc to interrupt") || plain.contains("Esc to interrupt") From aca98bcd15aabf3da8718779e10c5eb7175ee60e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:29:25 +0200 Subject: [PATCH 8/8] docs(vrs): decompose the DING subsystem into requirements and per-harness specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `docs/vrs/01-ding/` — subsystem requirements, the shared mechanism spec, and one spec per maintained harness — plus the decision record behind positional dispatch and harness-owned vocabulary. The requirements decompose the ratified `R05` floor into the obligations DING actually has to meet, written as preconditions for pressing Return rather than as a delivery guarantee, since this is the one subsystem that writes into a surface a human may also be typing into. Harnesses get spec only: the fail-closed guarantees are one contract owned at the DING level, and a harness adapter is mechanism rather than constitution. Two things here are new conventions for this repo and are flagged for the owner rather than assumed: a `.decisions/` directory, which did not previously exist, and a subsystem-level `requirements.md`. `docs/vrs/spec.md` reserves changes to vision and requirements for the owner's explicit approval; the reading taken here is that this covers the ratified root documents rather than forbidding a subsystem decomposition beneath them, but that is the owner's call to make and both are cheap to move or drop. Written against the code as it stands. Where the prose and the landed code disagreed, the prose was corrected — those corrections are listed in the pull request rather than left silent, because a doc quietly reshaped to match code stops being independent evidence about it. agent-session-id: e5217740-eef6-48eb-a794-3e5e11939e4d agent-tool: Claude Code agent-tool-version: 2.1.220 agent-model: claude-opus-5 agent-runtime-profile: /nix/store/i8y8b542cyqi385ywcjw5fvsq24f75v4-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/i81qxhzlrzcxrrdwpp6i8hagka2gby8y-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- ...ispatch-is-positional-and-harness-owned.md | 79 ++++++++++++ docs/vrs/01-ding/01-claude/spec.md | 111 +++++++++++++++++ docs/vrs/01-ding/02-codex/spec.md | 76 ++++++++++++ docs/vrs/01-ding/requirements.md | 97 +++++++++++++++ docs/vrs/01-ding/spec.md | 117 ++++++++++++++++++ 5 files changed, 480 insertions(+) create mode 100644 docs/vrs/.decisions/0001-ding-harness-dispatch-is-positional-and-harness-owned.md create mode 100644 docs/vrs/01-ding/01-claude/spec.md create mode 100644 docs/vrs/01-ding/02-codex/spec.md create mode 100644 docs/vrs/01-ding/requirements.md create mode 100644 docs/vrs/01-ding/spec.md diff --git a/docs/vrs/.decisions/0001-ding-harness-dispatch-is-positional-and-harness-owned.md b/docs/vrs/.decisions/0001-ding-harness-dispatch-is-positional-and-harness-owned.md new file mode 100644 index 0000000..9741c48 --- /dev/null +++ b/docs/vrs/.decisions/0001-ding-harness-dispatch-is-positional-and-harness-owned.md @@ -0,0 +1,79 @@ +# DING harness dispatch is positional, and each harness owns its own vocabulary + +Status: accepted + +## Context + +DING classified a screen by asking, in a fixed order, whether it looked like one +maintained harness and then the other, and it applied one shared active-turn and +modal predicate to every harness. Both choices were sound while the classifier +was small. Both produced defects once a real fleet ran against it. + +Ordered dispatch has no way to express *which composer will receive the +keystrokes*. It answers "does this screen contain harness X" when the safety +question is "what will this pane do with input". A shared blocked-predicate has +the same shape of error one level down: it lets one harness's rendering decide +another harness's safety gate. + +Splitting the implementation into one module per harness forced the question, +because a module boundary has to be drawn somewhere, and the wrong boundary +would have preserved both defects behind a tidier layout. + +## Evidence and Argument + +Three independent forms, none of which is reasoning alone: + +- **Implementation fact.** The shared active-turn predicate contains literals + drawn from one harness's rendering — plan prompts, capacity notices, retry + hints — and applies them to every pane. It also *omitted* that harness's + animated progress line, which does not always carry an interrupt hint. The + omission is only invisible while a separate parse defect keeps such screens + unclassifiable; removing that defect makes a mid-turn pane satisfy both the + idle and empty proofs. +- **Independent critique.** An automated review of the change that reordered the + two harness checks identified the transcript case: a pane whose scrollback + contains a captured screen from another harness would be classified on that + scrollback rather than on its own live composer, admitting a paste — or a + Return — into a human's draft. This was found by an adversarial reader, not by + the change's author. +- **Implementation fact.** The two harnesses do not match against the same + representation. One locates its composer in the raw screen including escape + sequences, yielding a byte offset; the other locates against ANSI-stripped + lines, yielding a line index. Any positional rule must therefore normalize to + a common unit, which an ordered chain never had to confront and which a naive + positional comparison would get silently wrong. + +## Options + +| Option | Tradeoffs | +| --- | --- | +| Keep ordered dispatch, special-case the transcript screen | Smallest change. The rule has to be written for each pair of harnesses, so it grows quadratically, and the next harness reopens the same class of defect. Does not address the shared predicate. | +| Reverse the order instead | Trades one wrong answer for another: it restores the original defect, where a used pane of the first harness is unclassifiable, from the opposite side. | +| Positional dispatch over a harness registry, with per-harness vocabulary | Requires an interface, a position type, and a unit conversion that did not previously exist. Duplicates some near-identical interrupt literals across harnesses. Makes the transcript case structural rather than special-cased, and confines each harness's rendering to its own safety gate. | + +## Decision + +Positional dispatch over a registry, with each harness owning its composer +markers, footer chrome, idle proof, and active-turn and modal detection. + +Every registered harness is asked to locate a composer; the one lowest on the +screen is classified. This is correct by construction rather than by +enumeration: scrollback is above the live composer, so the composer that will +receive the keystrokes is the lowest one, whatever a transcript happens to +contain. Positions are normalized to a screen row before comparison, because the +harnesses report incomparable units. + +Shared code keeps only what is genuinely common to every harness. The duplicated +interrupt literals are accepted cost: a shared vocabulary is what let one +harness's strings, and one harness's omissions, silently govern another's panes. + +## Consequences + +- Adding a harness is a new module and a registry entry; it does not edit shared + classification. This is the property that keeps the transcript defect from + recurring per pair. +- Widening what counts as blocked remains always-safe and may be done per + harness. Widening what counts as idle requires evidence from a real screen — + a rule this decision does not relax. +- The requirements this pins are `DING-R04` and `DING-R06` in + [`../01-ding/requirements.md`](../01-ding/requirements.md). diff --git a/docs/vrs/01-ding/01-claude/spec.md b/docs/vrs/01-ding/01-claude/spec.md new file mode 100644 index 0000000..1335689 --- /dev/null +++ b/docs/vrs/01-ding/01-claude/spec.md @@ -0,0 +1,111 @@ +# Claude harness specification + +The screen grammar by which DING recognizes a Claude composer. It realizes +[`../requirements.md`](../requirements.md) through the mechanism in +[`../spec.md`](../spec.md). + +## Status + +Active. + +## Locating the composer + +The composer is drawn between two horizontal rules. Both are located by +structure rather than by width: a rule is a line whose trimmed content is at +least forty characters and consists entirely of the box-drawing horizontal +character. The composer occupies the rows between the last two rules on the +ANSI-stripped screen; the footer is everything below the lower rule. + +The prompt is the `❯` character followed by a no-break space. A narrow space is +load-bearing here: the no-break space is Unicode whitespace, so trimming a +composer row before removing the prompt erases the prompt on an *empty* +composer and makes it unlocatable. The prompt is therefore removed before any +trimming. + +Locating the composer is what identifies the harness. Startup output, including +the version banner, is not used: an inspection sees the current viewport, so +anything printed once at session start is absent from every screen after the +first (`DING-R05`). The banner is in fact weaker than that argument assumes — at +common pane widths it is not rendered at all, so it is absent from the very +first screen too. + +Failing to locate a composer means this harness proves nothing about the screen; +it does not by itself make the screen `Ambiguous`. Under positional dispatch the +screen is offered to every maintained harness, so a pane holding another +harness's live composer is classified by that harness (`DING-R04`). `Ambiguous` +is the result when *no* harness locates a composer. + +## Proving idle + +The footer carries the permission-mode indicator that ships with the composer +chrome. Idle requires both the mode marker and the permission state to be +present. + +The keybinding hint that may appear beside them is **not** an idle signal. It is +composed at runtime from a keybinding lookup, so its text changes when the +binding is remapped and it is absent from many screens that are equally idle. +It may corroborate an idle footer; it may not decide one. + +## Proving empty + +Two shapes count as positively empty: + +- an empty composer, and +- a composer holding only the rotating example placeholder, matched as the exact + `Try ""` grammar with a bounded, control-free example. + +The empty composer is the stronger of the two. The placeholder is not styled +differently from typed input, so recognizing it relies on a text grammar a human +could in principle type; an empty composer cannot be confused with a human +draft, because a draft is not empty. + +## Blocked states + +Return is withheld while the screen shows an active turn or a modal. Two +distinct shapes prove an active turn: + +- an explicit interrupt hint, and +- the animated progress line, which carries a rotating verb followed by an + ellipsis, and which **carries no interrupt hint at all**. + +The second shape is required. A pane mid-turn has an empty composer and +unchanged footer chrome, so without it a working agent's screen satisfies both +the idle and empty proofs and would receive a Return mid-turn. On the current +build the first shape is not observed at all: no interrupt hint accompanies the +progress line at any width, so the second shape is the only one that fires. The +first is retained because it is cheap and fails closed. + +The predicate is derived from real captured screens, and two properties of those +screens constrain it more tightly than it first appears. + +**The leading glyph is not a stable key.** It animates through several +box-drawing and punctuation characters within a single turn. Matching any one of +them fails open for most of the turn. + +**The elapsed timer is not always present.** Some active frames render the verb +and ellipsis alone, with no parenthesized timer. Requiring a timer would fail +open on exactly those frames. + +**A completed turn renders an almost identical line**, distinguished only by +using a past-tense verb with an elapsed duration in place of the ellipsis. This +is the trap: that completed line sits above *every* idle composer, immediately +after every turn. A predicate keyed on the glyph, or on any shape that both +lines share, would therefore treat every idle pane as blocked and stop delivery +permanently, rather than erring conservatively as a widened blocked-check +normally does. The distinguishing signal is the ellipsis directly following a +single verb; nothing coarser is safe in the delivery-stopping direction. + +Modal prompts specific to this harness — plan prompts, retry and capacity +notices, queued-message notices — are also blocking and are owned here, not by +shared code (`DING-R06`). + +## Known limits + +- The permission-state literal proves one permission mode. A pane in another + mode does not reach a positively idle state and defers indefinitely rather + than delivering. Widening this is a behavior change to what counts as idle, + which per [`../spec.md`](../spec.md) requires evidence from a real screen, not + a looser match. +- Recognition is tied to this harness's current rendering. A renderer change + defers delivery until the grammar is updated; it does not produce a wrong + delivery, because every unrecognized shape is `Ambiguous`. diff --git a/docs/vrs/01-ding/02-codex/spec.md b/docs/vrs/01-ding/02-codex/spec.md new file mode 100644 index 0000000..8c4676f --- /dev/null +++ b/docs/vrs/01-ding/02-codex/spec.md @@ -0,0 +1,76 @@ +# Codex harness specification + +The screen grammar by which DING recognizes a Codex composer. It realizes +[`../requirements.md`](../requirements.md) through the mechanism in +[`../spec.md`](../spec.md). + +## Status + +Active. + +## Locating the composer + +This harness is located against the **raw** screen, including escape sequences, +rather than against ANSI-stripped text. Its composer is recognized by the exact +styled prompt sequences it emits: one set of markers for an empty composer, a +second set for a composer holding text. The styling is part of the evidence — +matching the prompt character alone would also match the same character sitting +in transcript output. + +Several marker variants exist for each case because the emitted sequence differs +across renderer versions. The lowest occurrence on the screen is the live +composer. + +Matching raw bytes yields a byte offset, while a harness matching stripped text +yields a line index. These are not comparable, so the located position is +converted to a screen row before dispatch compares it against other harnesses +(`DING-R04`). + +Harness identification is the successful location of a composer, with the +product name and the model-identifier line as corroborating evidence. + +## Proving idle + +The footer is read from the composer downward. Idle requires the status line +this harness renders below its composer, identified by the model-identifier +prefix together with the separator that precedes its command hint. + +## Composer contents + +Text recovered from the composer is normalized before comparison, because the +renderer may introduce styling and spacing that are not part of the logical +input. An empty composer proves emptiness directly from the empty-composer +markers rather than by inspecting recovered text. + +Comparison against the expected notice uses the shared soft-wrap candidate +enumeration in [`../spec.md`](../spec.md). + +## Blocked states + +Active-turn and modal detection is owned here rather than shared +(`DING-R06`). Shapes drawn by another harness are not evidence about this one: +this harness's panes are not made unsafe by another harness's interrupt hints, +and must not be made to look safe by their absence. + +That ownership is currently structural rather than complete. The predicate lives +in this harness and can be changed without touching the other, but its contents +are still the undivided set inherited from the shared predicate that preceded +the split, including several shapes only the other harness renders. Splitting +ownership and narrowing the contents were deliberately separated so that the +move carried no behavior change; narrowing is outstanding, and is recorded as a +known limit below rather than described as done. + +## Known limits + +- Recognition depends on exact styled sequences, so a renderer change that + alters the emitted styling defers delivery until the marker set is updated. An + unrecognized composer is `Ambiguous`, never assumed idle. +- A transcript that contains a captured screen from another harness is resolved + by positional dispatch rather than by this harness's own matching; see + `DING-R04`. +- This harness's blocked-state predicate still carries shapes only the other + harness renders, inherited from the shared predicate that preceded the split. + The effect is over-blocking, not under-blocking, so it is consistent with + `DING-T01`; the cost is that a pane can defer on evidence drawn from a screen + this harness never emits. Narrowing it to this harness's own shapes is + outstanding. diff --git a/docs/vrs/01-ding/requirements.md b/docs/vrs/01-ding/requirements.md new file mode 100644 index 0000000..c23a28d --- /dev/null +++ b/docs/vrs/01-ding/requirements.md @@ -0,0 +1,97 @@ +# DING requirements + +## Context + +DING is the sidecar that turns an unread inbox message into text in a running +agent's terminal composer. It is the only st2 subsystem that writes into a +surface a human may also be typing into, so its contract is written as a set of +preconditions for pressing Return rather than as a delivery guarantee. + +This decomposes [`R05`](../requirements.md) — the ratified floor for inbox +delivery, archive precedence, retries, suppression, and restart recovery — into +the subsystem's own obligations, and inherits the watcher obligations in `R14` +and `R15`. Where this file and the root disagree, the root wins and this file is +wrong. + +The realization per maintained harness is specified in +[`01-claude/spec.md`](./01-claude/spec.md) and +[`02-codex/spec.md`](./02-codex/spec.md). The mechanism shared by all harnesses +is in [`spec.md`](./spec.md). + +## Assumptions + +- **DING-A01 Rendered screens only:** The only available evidence about a + composer's state is a rendered terminal screen. No maintained harness exposes + an evented idle signal, so every precondition below is a measured heuristic + over text. `DQ2` in [`../spec.md`](../spec.md) tracks closing that gap. +- **DING-A02 Cooperative human:** The human sharing a pane is not adversarial. + A screen that deliberately imitates another harness's composer is a + correctness concern, not a security boundary, consistent with `A02`. + +## Acceptable Tradeoffs + +- **DING-T01 Deferral over delivery:** A message that is never delivered is a + worse outcome than one delivered late, but both are better than text typed + into a human's draft or a Return pressed on their behalf. Every ambiguous + case resolves toward deferral. +- **DING-T02 Narrow positive recognition:** Recognizing fewer screen states + than a harness can render is preferred to recognizing a state loosely. A + documented unsupported screen is consistent with `T01`; a loosely matched one + is not. + +## Requirements + +### Must never disturb a human + +- **DING-R01 Positive idle precondition:** Text is pasted only after a + maintained harness's composer has been positively identified as present, + empty, and idle. Absence of evidence that a human is typing is not evidence + of an idle composer. +- **DING-R02 Two adjacent exact observations:** Return is pressed only after the + exact staged notice has been observed as the complete composer contents twice + in immediately adjacent inspections, with the final observation adjacent to + the Return itself. Any change, block, or uncertainty between them prevents + submission. +- **DING-R03 Fail-closed default:** A changed composer, a human draft, an + active turn, a modal, an unreadable screen, an unrecognized harness, and a + bounded observation timeout all withhold Return. The default for anything not + positively understood is deferral. + +### Must classify the surface it will actually type into + +- **DING-R04 Live composer, not transcript:** Classification targets the + composer that will receive the keystrokes — the live composer at the bottom of + the viewport. Harness-shaped text appearing anywhere in scrollback, including + a captured or pasted screen from another harness, must not be classified in + its place. A pane is classified by what it will do with input, not by what its + transcript resembles. +- **DING-R05 No dependence on transient output:** Harness identification relies + on durable screen structure. Output present only near session start, such as a + startup banner, must not be load-bearing, because a screen inspection sees the + current viewport rather than the session's history. +- **DING-R06 Harness-owned recognition:** Each maintained harness owns the + vocabulary that identifies its own composer, footer chrome, and active or + modal states. Shared classification code carries only what is genuinely common + to every harness, so one harness's rendering cannot silently decide another + harness's safety gate. + +### Must remain bounded and idempotent + +- **DING-R07 Staged ownership:** Once a paste command has started, the payload + is owned by that attempt. Ambiguity about whether the paste landed is resolved + by inspection only; the same notice is never pasted again until the exact + staged payload has disappeared or changed. +- **DING-R08 Bounded probing:** Deferred delivery retries on a bounded backoff, + so an indefinitely busy composer cannot make every inbox poll spawn another + terminal probe. +- **DING-R09 Presence gate:** Declared `busy` is observable but never suppresses + delivery; only fresh `dnd` defers it. Delivery may wake a working agent. + +## Evidence + +Each guarantee above is pinned by a named test in +[`INVARIANTS.md`](../../../INVARIANTS.md) under **Fail-closed observed native +DING**, **Bounded DING PTY probe churn**, **Mutation-only filesystem wakeups**, +and **Agent-declared presence discipline**. A change that keeps those tests +green but violates a requirement here means the invariant set is incomplete, not +that the requirement is satisfied. diff --git a/docs/vrs/01-ding/spec.md b/docs/vrs/01-ding/spec.md new file mode 100644 index 0000000..5e13e41 --- /dev/null +++ b/docs/vrs/01-ding/spec.md @@ -0,0 +1,117 @@ +# DING specification + +This document specifies the mechanism shared by every maintained harness. It +builds on [requirements.md](./requirements.md). Per-harness screen grammars are +specified in [`01-claude/spec.md`](./01-claude/spec.md) and +[`02-codex/spec.md`](./02-codex/spec.md). + +## Status + +Active. A map to the implementation and its evidence, not a replacement for the +tests. + +## Composer states + +One inspection of a rendered screen, evaluated against one exact expected +notice, yields exactly one state: + +| State | Meaning | +| --- | --- | +| `EmptySafe` | A maintained harness is positively idle with an empty composer | +| `ExactSafe` | The exact notice is the complete composer, and the harness is idle | +| `ExactBlocked` | The exact notice is present, but a modal, active turn, or non-idle footer blocks Return | +| `Changed` | A maintained composer holds different text, including a human draft | +| `Ambiguous` | No maintained, unambiguous composer state was proven | + +`Ambiguous` is the default for anything not positively understood, per +`DING-R03`. It is not an error state and carries no diagnostic obligation. + +## Delivery + +```text +peek ─► classify ─┬─ ExactSafe ────► final observation ─► Return ─► Delivered + ├─ ExactBlocked ─────────────────────────────────► Staged + ├─ Changed / Ambiguous ──────────────────────────► Deferred + └─ EmptySafe ─► paste ─► observe until deadline ─┬► Delivered + ├► Staged + └► Deferred +``` + +Delivery is two-phase: a bracketed paste that carries no Return, then a separate +bare Return gated on a second exact observation (`DING-R02`). The observation +loop after paste runs to a bounded deadline; expiry yields `Staged`, never a +Return. + +Every failure of a terminal command after paste has begun resolves to `Staged` +rather than `Deferred` (`DING-R07`): the paste may already have reached the +harness, so ownership is retained and retry re-inspects instead of re-pasting. A +retry of a staged payload is inspect-only — it may submit or defer, but it never +pastes. + +## Harness dispatch + +Each maintained harness implements one interface: locate its composer on a +screen, reporting the row it occupies, and classify that screen against the +expected notice. Only the row crosses the interface — the located composer's +contents stay inside the harness that found them, so the registry never carries +a harness-specific payload and stays a uniform list. A registry enumerates the +maintained harnesses; adding one is a new module and a new registry entry, with +no edit to shared classification. + +Dispatch is positional, not preferential. Every registered harness is asked to +locate a composer, and the one **lowest on the screen** is classified +(`DING-R04`). Scrollback is above the live composer by construction, so this +resolves harness-shaped transcript text — a captured screen from another harness +pasted into a pane — without a per-pair special case. When exactly one harness +locates a composer, that one is classified; when none do, the screen is +`Ambiguous`. + +Positions are compared in one unit. Harnesses do not agree on what they match +against: some locate against the raw screen including escape sequences, others +against ANSI-stripped lines. A raw byte offset and a stripped line index are not +comparable, so each harness reports its position converted to a screen row, and +the comparison is over rows. + +## Ownership of vocabulary + +Shared code owns only what is common to every harness: the composer states, the +dispatch, ANSI stripping, soft-wrap candidate enumeration, and screen shapes +that are not specific to any harness. + +Each harness owns its own composer markers, footer chrome, idle proof, and +active-turn and modal detection (`DING-R06`). Active-turn detection is +deliberately not shared: interrupt hints, progress indicators, and modal +prompts are rendering details of one harness, and applying one harness's strings +to another's pane makes the safety gate depend on a screen the other harness +never draws. Some near-identical strings are therefore duplicated across +harnesses, which is the intended cost. + +Widening what counts as blocked is the fail-closed direction and is always safe. +Widening what counts as idle is not, and requires evidence from a real screen. + +## Soft-wrap candidates + +A composer may wrap a notice across rows. At each renderer-proven wrap boundary +the original text either lost one inter-word space or split a token, so each +boundary yields exactly two candidates. Comparison against the expected notice +succeeds if any candidate matches exactly. The bounded notice length keeps the +candidate set small; an unfamiliar multiline shape yields no match and fails +closed. + +## Retry and suppression + +Deferred notices retain FIFO order and retry on a bounded backoff, so an +indefinitely occupied composer cannot spawn a terminal probe per inbox poll +(`DING-R08`). Archive receipts remove pending notices without another attempt. +Declared `busy` never suppresses delivery; only fresh `dnd` defers it +(`DING-R09`). + +## Known limits + +- Idle proof depends on footer chrome that a harness may render differently + across permission or approval modes. A harness whose footer is not recognized + in a given mode defers indefinitely rather than delivering. This is an + explicit limit per `T01`, and each harness spec states which modes it proves. +- The classifier is a measured heuristic over rendered text, not an evented + signal, so a renderer change can defer delivery until the grammar is updated. + Tracked as `DQ2` in [`../spec.md`](../spec.md).