Skip to content

Commit d3f5fd7

Browse files
refactor(ding): split the composer into per-harness adapters
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/<file>.rs::<test>` references in INVARIANTS.md resolve, checked both by grepping the named path for `fn <name>` and by running each one with `cargo test <full::path> -- --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
1 parent 04b600c commit d3f5fd7

6 files changed

Lines changed: 564 additions & 431 deletions

File tree

INVARIANTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ materialization, messaging, DING, or presence must preserve them.
1010
| **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` |
1111
| **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` |
1212
| **Exactly-once-safe native bus** | Messages use stable `<unix-ms>-<rand6>.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` |
13-
| **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` |
14-
| **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` |
15-
| **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` |
16-
| **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` |
13+
| **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` |
14+
| **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` |
15+
| **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` |
16+
| **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` |
1717
| **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` |
1818
| **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` |
1919
| **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` |

src/ding/composer.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
//! What a peeked screen proves about one exact notice, and the routing that decides which
2+
//! composer on the screen is the live one.
3+
4+
use super::harness::{self, Screen};
5+
6+
/// What the current bottom composer proves about one exact normalized notice.
7+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8+
pub(super) enum ComposerState {
9+
/// A maintained harness is positively idle and contains only its known placeholder.
10+
EmptySafe,
11+
/// The exact notice is the complete composer and the harness is positively idle.
12+
ExactSafe,
13+
/// The exact notice is present, but a modal, active turn, or non-idle footer blocks Return.
14+
ExactBlocked,
15+
/// A maintained composer contains different text (including a human draft).
16+
Changed,
17+
/// No maintained, unambiguous composer state was proven.
18+
Ambiguous,
19+
}
20+
21+
22+
/// Enumerate the two logical strings possible at each renderer-shaped soft-wrap row: the TUI either
23+
/// discarded one inter-word space or split a token. Current 80-column Codex/Claude composers wrap
24+
/// long DING rows at 70+ content cells and indent continuations by exactly two cells. Short or
25+
/// unfamiliar multiline input remains literal and cannot equal a normalized single-line DING.
26+
pub(super) fn logical_soft_wrap_candidates(input: &str, minimum_first_content_chars: usize) -> Vec<String> {
27+
let rows: Vec<&str> = input.lines().collect();
28+
let Some(first) = rows.first() else {
29+
return vec![String::new()];
30+
};
31+
if rows.len() == 1 {
32+
return vec![(*first).to_string()];
33+
}
34+
let mut candidates = vec![(*first).to_string()];
35+
let mut previous = *first;
36+
for (index, row) in rows[1..].iter().enumerate() {
37+
let required_previous_width = minimum_first_content_chars + usize::from(index > 0) * 2;
38+
if previous.chars().count() < required_previous_width
39+
|| !row.starts_with(" ")
40+
|| row.trim().is_empty()
41+
{
42+
return vec![input.to_string()];
43+
}
44+
let continuation = row.strip_prefix(" ").expect("prefix checked").trim_end();
45+
let mut next = Vec::with_capacity(candidates.len().saturating_mul(2).min(32));
46+
for candidate in candidates {
47+
if next.len() >= 32 {
48+
return vec![input.to_string()];
49+
}
50+
next.push(format!("{candidate}{continuation}"));
51+
next.push(format!("{candidate} {continuation}"));
52+
}
53+
candidates = next;
54+
previous = row;
55+
}
56+
candidates
57+
}
58+
59+
60+
pub(super) fn looks_like_choice_menu(plain: &str) -> bool {
61+
let mut first = false;
62+
let mut later = false;
63+
for line in plain.lines().map(str::trim_start) {
64+
first |= line.starts_with("› 1.") || line.starts_with("> 1.");
65+
later |= line.starts_with("2.") || line.starts_with("3.");
66+
}
67+
first && later
68+
}
69+
70+
71+
/// Strip the CSI/OSC sequences emitted by `pty peek` while preserving rendered text. Bounded
72+
/// cursor-forward sequences represent visible spaces in current Codex and Claude panes.
73+
pub(super) fn strip_ansi(input: &str) -> String {
74+
let bytes = input.as_bytes();
75+
let mut out = String::with_capacity(input.len());
76+
let mut index = 0;
77+
while index < bytes.len() {
78+
if bytes[index] != 0x1b {
79+
let ch = input[index..].chars().next().expect("valid UTF-8 boundary");
80+
out.push(ch);
81+
index += ch.len_utf8();
82+
continue;
83+
}
84+
index += 1;
85+
if index >= bytes.len() {
86+
break;
87+
}
88+
match bytes[index] {
89+
b'[' => {
90+
index += 1;
91+
let params_start = index;
92+
let mut final_byte = None;
93+
while index < bytes.len() {
94+
let byte = bytes[index];
95+
index += 1;
96+
if (0x40..=0x7e).contains(&byte) {
97+
final_byte = Some(byte);
98+
break;
99+
}
100+
}
101+
if final_byte == Some(b'C') {
102+
let params = &bytes[params_start..index.saturating_sub(1)];
103+
let width = if params.is_empty() {
104+
Some(1)
105+
} else if params.iter().all(u8::is_ascii_digit) {
106+
std::str::from_utf8(params)
107+
.ok()
108+
.and_then(|value| value.parse::<usize>().ok())
109+
.map(|value| value.max(1))
110+
} else {
111+
None
112+
};
113+
if let Some(width) = width.filter(|width| *width <= 512) {
114+
for _ in 0..width {
115+
out.push(' ');
116+
}
117+
}
118+
}
119+
}
120+
b']' => {
121+
index += 1;
122+
while index < bytes.len() {
123+
if bytes[index] == 0x07 {
124+
index += 1;
125+
break;
126+
}
127+
if bytes[index] == 0x1b && bytes.get(index + 1) == Some(&b'\\') {
128+
index += 2;
129+
break;
130+
}
131+
index += 1;
132+
}
133+
}
134+
_ => index += 1,
135+
}
136+
}
137+
out
138+
}
139+
140+
141+
/// Locate every maintained composer and classify the LOWEST one on screen.
142+
///
143+
/// A pane has exactly one live composer and it sits at the bottom of the viewport, so anything
144+
/// composer-shaped above it is scrollback — typically a pasted or logged screen from the other
145+
/// harness. Preferring one harness unconditionally lets that transcript decide, which is a wrong
146+
/// *positive*: the paste and the Return go to the pane's real composer whichever text was read, so
147+
/// it can type into, or submit, a human's live draft. Scrollback is above the live composer by
148+
/// construction, so picking the lowest needs no per-pair special case.
149+
pub(super) fn classify_composer(screen: &str, expected: &str) -> ComposerState {
150+
let plain = strip_ansi(screen);
151+
let screen = Screen { raw: screen, plain: &plain };
152+
harness::all()
153+
.into_iter()
154+
.filter_map(|harness| harness.locate(&screen).map(|located| (located.row, harness)))
155+
.max_by_key(|(row, _)| *row)
156+
.map(|(_, harness)| harness.classify(&screen, expected))
157+
// No maintained composer is locatable, so nothing is proven either way.
158+
.unwrap_or(ComposerState::Ambiguous)
159+
}

0 commit comments

Comments
 (0)