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/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). diff --git a/src/ding/composer.rs b/src/ding/composer.rs new file mode 100644 index 0000000..7b491b6 --- /dev/null +++ b/src/ding/composer.rs @@ -0,0 +1,155 @@ +//! 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..1ab2bdf --- /dev/null +++ b/src/ding/harness/claude.rs @@ -0,0 +1,146 @@ +//! 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. +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..6d5b4f5 --- /dev/null +++ b/src/ding/harness/codex.rs @@ -0,0 +1,214 @@ +//! 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. +/// 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 { + inherited_progress_line(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 80% rename from src/ding.rs rename to src/ding/mod.rs index bdb0567..29d98c5 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,21 +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| { matches!( @@ -452,376 +441,6 @@ 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), -} - -fn classify_composer(screen: &str, expected: &str) -> ComposerState { - let plain = strip_ansi(screen); - if plain.contains("Claude Code v") { - return classify_claude_composer(&plain, expected); - } - 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); - } - 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, expected: &str) -> ComposerState { - let Some((logical_inputs, footer)) = located_bottom_claude_composer(plain) else { - return ComposerState::Ambiguous; - }; - let exact = logical_inputs.iter().any(|input| input == expected); - let placeholder = logical_inputs.len() == 1 && 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<(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]; - let first_row = rows.first()?.trim_end(); - let first = first_row - .strip_prefix("❯\u{00a0}") - .or_else(|| first_row.strip_prefix("❯ "))?; - 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"); - Some((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 -} - -fn interaction_blocked(plain: &str) -> bool { - 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`. pub fn session_alive(session: &str) -> bool { @@ -1479,6 +1098,203 @@ 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}")) + } + + /// 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 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. + 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}" + ) + } + + /// 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}" + ); + } + } + + /// 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. + #[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 = @@ -1526,6 +1342,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