diff --git a/.agents/skills/harness-adapters/references/harness/omp.md b/.agents/skills/harness-adapters/references/harness/omp.md index b584ebebf2e..7cc6eabb9a1 100644 --- a/.agents/skills/harness-adapters/references/harness/omp.md +++ b/.agents/skills/harness-adapters/references/harness/omp.md @@ -18,7 +18,7 @@ Cross-harness provider and credential identity is owned by `references/common/mo | Effort flag | `--thinking `, a superset of the shared vocabulary, so every level including `max` maps straight across. | | Model discovery | `omp models [--json]` lists built-in and auto-discovered providers only; extension-registered providers such as `claude-bridge` never appear, so those models pass through the spawn unvalidated with a stderr notice. `omp usage` shows provider windows; `quota-axi` covers the `claude` provider when the bridge is in use. | | Marker | None of omp's own (verified: `PI_CODING_AGENT` absent from the binary, no `PI_CODING_AGENT_DIR` or `OMP_PROFILE` in the default profile). `FM_OMP_HARNESS=omp` is Firstmate's launch marker; ancestry matches the exact process name `omp`. | -| Composer | See [`fm-spawn.sh --help`](../../../../../bin/fm-spawn.sh) for the composer posture pin; busy text is `Working…` (U+2026), the only spelling the omp busy regex accepts (the three-dot form its headless `-p` mode writes never reaches a supervised pane), with the status row's braille spinner plus elapsed cell as the second signal. | +| Composer | See [`fm-spawn.sh --help`](../../../../../bin/fm-spawn.sh) for the composer posture pin; busy text is `Working…` (U+2026), the only spelling the omp busy regex accepts (the three-dot form its headless `-p` mode writes never reaches a supervised pane), with the status row's braille spinner plus elapsed cell as the second signal; box-shape support and overlay live-reload evidence are recorded in [omp box composer through Herdr](../../../../../docs/verification/runtime-backends.md#2026-10-06-omp-box-composer-through-herdr). | | Autonomy | Approval and unattended-session posture are owned by [`fm-spawn.sh --help`](../../../../../bin/fm-spawn.sh). | | Trust | No project-trust gate at all; a fresh profile shows a provider-login wizard instead, suppressed by `OMP_SKIP_SETUP=1`. | | Resume | `-c/--continue` and `-r/--resume` exist but carry no verified pane-resume contract; use deterministic relaunch. | diff --git a/.live-validation/omp-state/predict/ngram/cursor.json b/.live-validation/omp-state/predict/ngram/cursor.json new file mode 100644 index 00000000000..70959328e44 --- /dev/null +++ b/.live-validation/omp-state/predict/ngram/cursor.json @@ -0,0 +1 @@ +{"historyId":1} \ No newline at end of file diff --git a/.live-validation/omp-state/predict/ngram/ngram.snapshot b/.live-validation/omp-state/predict/ngram/ngram.snapshot new file mode 100644 index 00000000000..ea372874193 Binary files /dev/null and b/.live-validation/omp-state/predict/ngram/ngram.snapshot differ diff --git a/.omp/fm-session-overlay.yml b/.omp/fm-session-overlay.yml index b3493f570b0..db1457c78d1 100644 --- a/.omp/fm-session-overlay.yml +++ b/.omp/fm-session-overlay.yml @@ -9,9 +9,10 @@ # or make its composer unreadable to bin/fm-composer-lib.sh. Verified against # omp 18.1.11's own settings schema (`omp config list`). composer: - # Renders a bare `❯` (U+276F) row, a glyph the shared composer classifier - # already reads; the shipped default `band` and six other shapes are not in - # its catalogue, and the shape is otherwise a captain-level setting. + # Pins the bare `❯` (U+276F) row for predictable worker rendering. + # omp live-reloads this overlay, so removing the pin also changes sessions + # already running on this path; see the omp box-composer evidence in + # docs/verification/runtime-backends.md for the verified fallback behavior. shape: borderless plan: # `plan.defaultOnStartup: true` opens every session read-only; a worker that diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 88b122c59ce..f1ebafe25bf 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -64,6 +64,13 @@ # directly below its composer - omp's status row and # braille-only animation rows (declared once below, next to # the idle placeholders) - none of which is ever typed input. +# omp box - omp's `composer.shape: box`: a rounded top border carrying +# omp's status line (`╭── π > … ──╮`), optional `│ … │` rows, +# and a folded last row that IS the bottom border (`╰─ text ─╯`). +# Proven only by omp's status identity in the top border (see +# FM_COMPOSER_OMP_BOX_TOP_RE_DEFAULT); not a bordered box +# because that rule needs a side-bordered row and an aligned +# top border. # left-bar - opencode: rows prefixed by a heavy left bar `┃` with no # closing border, holding the idle hint, blank rows, and a # mode/model footer line. @@ -116,7 +123,7 @@ # glyph deliberately outside the agent set, so no opencode shape recorded here # can prove a left-bar envelope and open a zone under it. # -# THE SAFETY RULE for glyphs: a bare shell prompt glyph (`>` `$` `%` `#`) - +# THE SAFETY RULE for prompt-bearing shapes: a bare shell glyph (`>` `$` `%` `#`) - # what a pane shows once its agent has exited to a plain login shell - is a # genuine empty agent composer ONLY inside a bordered container. On a bare row # it is a dead-shell prompt and classifies `unknown` (never a safe injection @@ -450,10 +457,9 @@ fm_busy_lines_match() { # [harness] [ -n "$regex" ] && printf '%s' "$lines" | grep -qiE "$regex" } -# The prompt glyphs, each declared exactly once (see THE SAFETY RULE above). -# AGENT glyphs are a genuine empty agent composer on any row, bordered or bare. -# SHELL glyphs are one only INSIDE a composer container; on a bare row they are -# a dead-shell prompt and must never read `empty`. Newline-separated and +# The prompt glyphs for prompt-bearing shapes, each declared exactly once. +# See THE SAFETY RULE above; omp's glyphless box does not consume these lists. +# Newline-separated and # consumed by `read` rather than word splitting, so `$`, `%`, and `#` stay # literal and no entry is ever exposed to pathname expansion. FM_COMPOSER_AGENT_PROMPT_GLYPHS=$(printf '%s\n' '❯' '›' '⟩' '→' '❭') @@ -503,6 +509,23 @@ FM_COMPOSER_MODE_HINT_RE_DEFAULT='^[[:space:]]*(⏵|⏸)' # a middle dot. It is consulted only as the boundary BELOW a bare composer, # never on the composer row itself. FM_COMPOSER_OMP_STATUS_RE_DEFAULT='^[[:space:]]*(π|󰵗)[[:space:]]+·[[:space:]]|^[[:space:]]*'"$FM_OMP_SPINNER_FRAMES_RE"'[[:space:]]+[0-9]+[smh]([[:space:]]|$)|[[:space:]]·[[:space:]].*[0-9]+(\.[0-9]+)?%/[0-9]+K' +# omp's `box` composer shape carries its status line IN the top border and +# folds the editor's last row into the bottom border (verified live through +# Herdr on omp 18.6.3; docs/verification/runtime-backends.md): +# ╭── π > ◒ GPT-6.1-Sol 🙈 > 🌳 path > ⑂ branch > … ──╮ +# ╰─ typed text ─╯ +# The status opens with the same identity cell as the borderless status row, +# then a `>` (or `·`) separator. That identity is the whole proof that a +# rounded top border belongs to an omp composer; any other titled border stays +# an ambiguous box. The ascii preset's `pi` and a busy spinner cell are left +# out on purpose: an unverified shape must read `unknown`, never `empty`. +FM_COMPOSER_OMP_BOX_TOP_RE_DEFAULT='^(π|󰵗)[[:space:]]+(>|·)[[:space:]]' +# omp draws this hint, right-aligned, in the box shape's EMPTY last row until a +# turn has completed. Its key glyphs are bright and its words dim, so a styled +# read keeps `⇧⇥` and a plain read cannot tell the whole hint from typed text. +# It is consulted only on an omp box's last row, never as a fleet-wide idle +# placeholder. +FM_COMPOSER_OMP_BOX_HINT_RE_DEFAULT='^⇧⇥ to change thinking effort$' # Pi's footer stats row opens at column 0 with the session cost when every # token counter is zero (`$0.000 (sub) 5.4%/272k (auto)` on pi 0.85.1). # That leading `$` is a cost cell, not a dead-shell prompt, only when a digit @@ -772,12 +795,15 @@ _fm_composer_scan_screen() { # [extract-wrap] local top_inner top_spaces='' geometry_check=0 geometry_ambiguous=0 local content_inner content_spaces bottom_inner bottom_spaces glyph local current_indent='' current_family='' row=0 top=-1 valid=0 content_rows=0 + local top_omp=0 omp_indent_ok=1 # Complete-box results: the box containing the cursor (cursor mode) or the # bottom-most complete box (no cursor). FM_COMPOSER_SCAN_BOX_TOP=-1 FM_COMPOSER_SCAN_BOX_BOTTOM=-1 FM_COMPOSER_SCAN_BOX_AMBIG=0 FM_COMPOSER_SCAN_INCOMPLETE_BOX_FROM=-1 + FM_COMPOSER_SCAN_OMPBOX_TOP=-1 + FM_COMPOSER_SCAN_OMPBOX_BOTTOM=-1 FM_COMPOSER_SCAN_UNSAFE=0 FM_COMPOSER_SCAN_CURSOR_EDGE=0 FM_COMPOSER_SCAN_BARE_ROW=-1 @@ -922,6 +948,11 @@ _fm_composer_scan_screen() { # [extract-wrap] box_glyph='' geometry_ambiguous=0 geometry_check=1 + top_omp=0 + omp_indent_ok=1 + if [ "$family" = rounded ] && _fm_composer_top_is_omp_box "$trimmed"; then + top_omp=1 + fi top_inner=$trimmed case "$family" in rounded) top_inner=${top_inner#╭}; top_inner=${top_inner%╮}; top_spaces=${top_inner//─/ } ;; @@ -933,6 +964,23 @@ _fm_composer_scan_screen() { # [extract-wrap] case "$top_spaces" in *[![:space:]]*) geometry_check=0; geometry_ambiguous=1 ;; esac + elif [ "$top_omp" = 1 ] && [ "$kind" = bottom ] && [ "$top" -ge 0 ] \ + && [ "$family" = rounded ] && [ "$valid" = 1 ] && [ "$omp_indent_ok" = 1 ] \ + && [ "$indent" = "$current_indent" ] && _fm_composer_omp_box_last_row "$trimmed"; then + # omp's box shape: the status-bearing top border proves the container and + # the closing `╰─ … ─╯` row IS the editor's last row (never a bare rule), + # so the box is complete with zero side-bordered rows above it. + if [ -z "$cy" ] || { [ "$top" -lt "$cy" ] && [ "$cy" -le "$row" ]; }; then + FM_COMPOSER_SCAN_OMPBOX_TOP=$top + FM_COMPOSER_SCAN_OMPBOX_BOTTOM=$row + fi + FM_COMPOSER_SCAN_INCOMPLETE_BOX_FROM=-1 + top=-1 + current_family= + current_indent= + valid=0 + content_rows=0 + top_omp=0 elif [ "$kind" = bottom ] || { [ "$kind" = ascii ] && [ "$top" -ge 0 ]; }; then if [ "$top" -ge 0 ] && [ "$family" = "$current_family" ] \ && [ "$valid" = 1 ] && [ "$content_rows" -gt 0 ]; then @@ -988,6 +1036,7 @@ _fm_composer_scan_screen() { # [extract-wrap] current_indent= valid=0 content_rows=0 + top_omp=0 elif [ "$top" -ge 0 ]; then side_family= case "$trimmed" in @@ -1004,6 +1053,7 @@ _fm_composer_scan_screen() { # [extract-wrap] box_glyph=$row_glyph fi [ "$indent" = "$current_indent" ] || geometry_ambiguous=1 + [ "$indent" = "$current_indent" ] || omp_indent_ok=0 if [ "$geometry_check" = 1 ]; then content_inner=$trimmed case "$side_family" in @@ -1227,6 +1277,93 @@ _fm_composer_row_is_omp_status() { # fm_composer_idle_matches "$1" "${FM_COMPOSER_OMP_STATUS_RE:-$FM_COMPOSER_OMP_STATUS_RE_DEFAULT}" sensitive } +# _fm_composer_top_is_omp_box: 0 when the trimmed rounded top border carries +# omp's status identity (FM_COMPOSER_OMP_BOX_TOP_RE_DEFAULT above), which is +# the proof that the box below it is omp's own composer. +_fm_composer_top_is_omp_box() { # + local inner=$1 + inner=${inner#╭} + inner=${inner%╮} + while [ "${inner#─}" != "$inner" ]; do inner=${inner#─}; done + fm_composer_normalize_trim_var inner + fm_composer_idle_matches "$inner" "$FM_COMPOSER_OMP_BOX_TOP_RE_DEFAULT" sensitive +} + +# _fm_composer_omp_box_last_row: 0 when the trimmed row is an omp box's folded +# last row: `╰─` then the editor's last row then `╯`. A bare rule (nothing +# between the corners but `─`) is the separate bottom border omp draws only +# around an unfinished IME composition; it carries no proof of an empty row, so +# it never qualifies. +_fm_composer_omp_box_last_row() { # + local row=$1 rest + case "$row" in + '╰─'*'╯') ;; + *) return 1 ;; + esac + rest=${row#╰} + rest=${rest%╯} + [ -n "${rest//─/}" ] +} + +# _fm_composer_ompbox_row_content: the editor text of one row of an omp box. +# A side-bordered row is read like any other box row. The folded last row has +# exactly one `─` after its `╰` and one before its `╯` (omp's own geometry), so +# a typed leading or trailing `─` survives. +_fm_composer_ompbox_row_content() { # -> content on stdout + local raw=$1 styled=$2 last=$3 stripped + if [ "$last" != 1 ]; then + _fm_composer_row_content "$raw" "$styled" + return 0 + fi + if [ "$styled" = 1 ]; then + stripped=$(printf '%s\n' "$raw" | fm_composer_strip_ghost) + else + stripped=$(printf '%s\n' "$raw" | fm_composer_strip_ansi) + fi + fm_composer_normalize_trim_var stripped + case "$stripped" in '╰─'*) stripped=${stripped#╰─} ;; esac + stripped=${stripped%╯} + fm_composer_normalize_trim_var stripped + case "$stripped" in *'─') stripped=${stripped%─} ;; esac + fm_composer_normalize_trim_var stripped + printf '%s' "$stripped" +} + +# _fm_composer_classify_ompbox: omp's box composer (rows through +# , the last being the folded `╰─ … ─╯` row). Any editor text is +# pending, never read through the shared prompt-glyph rules because omp's box +# draws no prompt glyph, so a typed `>` or `❯` is text. +_fm_composer_classify_ompbox() { # + local screen=$1 styled=$2 first=$3 last=$4 row raw content plain is_last hint + row=$first + while [ "$row" -le "$last" ]; do + raw=$(_fm_composer_screen_row "$row" "$screen") + is_last=0 + [ "$row" -ne "$last" ] || is_last=1 + content=$(_fm_composer_ompbox_row_content "$raw" "$styled" "$is_last") + plain=$(_fm_composer_ompbox_row_content "$raw" 0 "$is_last") + if [ -n "$content" ]; then + hint=0 + if [ "$is_last" = 1 ] && fm_composer_idle_matches "$plain" "$FM_COMPOSER_OMP_BOX_HINT_RE_DEFAULT" sensitive; then + if [ "$styled" = 1 ]; then + if [ "${#content}" -lt "${#plain}" ]; then + case "$plain" in *"$content"*) hint=1 ;; esac + fi + else + printf 'unknown' + return 0 + fi + fi + if [ "$hint" != 1 ]; then + printf 'pending' + return 0 + fi + fi + row=$((row + 1)) + done + printf 'empty' +} + # _fm_composer_row_is_pi_status: 0 when the trimmed row is Pi's dollar-first # footer stats row (FM_COMPOSER_PI_STATUS_RE_DEFAULT above). Furniture below # the separated pair; a `$` cost cell must not count as a dead-shell prompt. @@ -1467,6 +1604,12 @@ _fm_composer_select_cursorless() { FM_COMPOSER_SELECTED_LAST=$((FM_COMPOSER_SCAN_BOX_BOTTOM - 1)) FM_COMPOSER_SELECTED_AMBIG=$FM_COMPOSER_SCAN_BOX_AMBIG fi + if [ "$FM_COMPOSER_SCAN_OMPBOX_BOTTOM" -gt "$generic" ]; then + generic=$FM_COMPOSER_SCAN_OMPBOX_BOTTOM + FM_COMPOSER_SELECTED_KIND=ompbox + FM_COMPOSER_SELECTED_FIRST=$((FM_COMPOSER_SCAN_OMPBOX_TOP + 1)) + FM_COMPOSER_SELECTED_LAST=$FM_COMPOSER_SCAN_OMPBOX_BOTTOM + fi # A bare candidate standing in a proven envelope's footer zone is that # harness's own furniture, never a composer. The envelope it sits under is # what the screen actually shows, so when that envelope's proving glyph row @@ -1530,10 +1673,13 @@ _fm_composer_select_cursorless() { done fi if [ "$FM_COMPOSER_SELECTED_KIND" = box ] \ + || [ "$FM_COMPOSER_SELECTED_KIND" = ompbox ] \ || [ "$FM_COMPOSER_SELECTED_KIND" = leftbar ]; then boundary=$FM_COMPOSER_SELECTED_LAST if [ "$FM_COMPOSER_SELECTED_KIND" = box ]; then boundary=$FM_COMPOSER_SCAN_BOX_BOTTOM + elif [ "$FM_COMPOSER_SELECTED_KIND" = ompbox ]; then + boundary=$FM_COMPOSER_SCAN_OMPBOX_BOTTOM else next=$((boundary + 1)) raw=$(_fm_composer_screen_row "$next" "$plain") @@ -1563,7 +1709,7 @@ _fm_composer_select_cursorless() { fm_composer_extract_selected_content() { # local caps=$1 screen=$2 styled=0 kv plain row raw content glyph joined='' footer_re prompt_row=-1 - local leading_blank=1 placeholder_position=0 prompt_is_shell=0 + local leading_blank=1 placeholder_position=0 prompt_is_shell=0 is_last omp_plain footer_re=${FM_COMPOSER_LEFTBAR_FOOTER_RE:-$FM_COMPOSER_LEFTBAR_FOOTER_RE_DEFAULT} while IFS= read -r kv; do [ "$kv" = styled=1 ] && styled=1 @@ -1579,6 +1725,20 @@ EOF content=$(_fm_composer_row_content "$raw" "$styled") placeholder_position=0 case "$FM_COMPOSER_SELECTED_KIND" in + ompbox) + is_last=0 + [ "$row" -ne "$FM_COMPOSER_SELECTED_LAST" ] || is_last=1 + content=$(_fm_composer_ompbox_row_content "$raw" "$styled" "$is_last") + if [ "$is_last" = 1 ] && [ -n "$content" ]; then + omp_plain=$(_fm_composer_ompbox_row_content "$raw" 0 1) + if fm_composer_idle_matches "$omp_plain" \ + "$FM_COMPOSER_OMP_BOX_HINT_RE_DEFAULT" sensitive; then + if [ "$styled" = 1 ] && [ "${#content}" -lt "${#omp_plain}" ]; then + case "$omp_plain" in *"$content"*) content= ;; esac + fi + fi + fi + ;; bare) if [ "$row" -eq "$FM_COMPOSER_SELECTED_FIRST" ] \ && fm_composer_leading_agent_glyph_var glyph "$content"; then @@ -1665,6 +1825,11 @@ EOF "$((FM_COMPOSER_SCAN_BOX_TOP + 1))" "$((FM_COMPOSER_SCAN_BOX_BOTTOM - 1))" return 0 fi + if [ "$FM_COMPOSER_SCAN_OMPBOX_TOP" -ge 0 ]; then + _fm_composer_classify_ompbox "$screen" "$styled" \ + "$((FM_COMPOSER_SCAN_OMPBOX_TOP + 1))" "$FM_COMPOSER_SCAN_OMPBOX_BOTTOM" + return 0 + fi if [ "$FM_COMPOSER_SCAN_LEFTBAR_START" -ge 0 ] \ && [ "$cy" -ge "$FM_COMPOSER_SCAN_LEFTBAR_START" ] \ && [ "$cy" -le "$FM_COMPOSER_SCAN_LEFTBAR_END" ]; then @@ -1737,6 +1902,10 @@ EOF _fm_composer_classify_bare_row "$screen" "$styled" "$FM_COMPOSER_SELECTED_FIRST" fi ;; + ompbox) + _fm_composer_classify_ompbox "$screen" "$styled" \ + "$FM_COMPOSER_SELECTED_FIRST" "$FM_COMPOSER_SELECTED_LAST" + ;; leftbar) _fm_composer_classify_leftbar "$screen" "$styled" \ "$FM_COMPOSER_SELECTED_FIRST" "$FM_COMPOSER_SELECTED_LAST" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index ed41b5a8476..712ad3fd1fc 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -364,6 +364,7 @@ family_for_basename() { fm-opencode-primary-live-e2e.test.sh|fm-pi-branch-live-e2e.test.sh|\ fm-pi-branch-responsiveness-live-e2e.test.sh|\ fm-pi-primary-live-e2e.test.sh|fm-pi-codex-native.test.sh|fm-omp-primary-live-e2e.test.sh|\ + fm-omp-composer-box-live-e2e.test.sh|\ fm-pr-state-live-e2e.test.sh|\ fm-sessionstart-hook-live-e2e.test.sh|fm-sessionstart-instruction-refresh-live-e2e.test.sh|\ fm-supervision-host-live-e2e.test.sh|fm-supervision-host-attended-live-e2e.test.sh|\ diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index e868f6b2e11..d747df18ec3 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -604,7 +604,7 @@ That comparison ignores whitespace and U+2063, the invisible mark that starts op It ignores U+2063 because Claude's Herdr read-back never shows it. Other harnesses, and panes with no native identity, skip this proof and keep the type-then-Enter path. -They skip it because their paste placeholders and composer shapes are not live-verified. +Their pre-Enter typed-payload proof has not been live-verified; composer classification evidence is recorded separately in the [Composer classification matrix](verification/runtime-backends.md#composer-classification-matrix). ### Submit confirmation @@ -667,6 +667,7 @@ The adapter is a thin capture. It hands the visible pane's ANSI viewport plus Herdr's capability facts to the fleet-wide classifier in `bin/fm-composer-lib.sh`, which owns every shape: - Bordered boxes. +- omp's rounded box, with its status in the top border and its last input row folded into the bottom border. - Bare agent-glyph rows, including muse's `⟩`, which the adapter's retired local pattern silently omitted. - opencode's left bar. - The Pi separator region this adapter pioneered, admitted only when native `agent get` identity is exactly Pi and state is idle or done. @@ -684,6 +685,11 @@ ANSI capture preserves de-emphasized placeholder style. `bin/fm-composer-lib.sh` is the fleet-wide owner that strips dim or faint runs and dark truecolor placeholders while retaining bright typed input. For the Herdr-specific exception, see [Claude composer proof](#claude-composer-proof). +omp's box has no prompt glyph, so typed `>`, `❯`, and `─` remain draft text and read `pending`, not `empty`. +Its last-row `⇧⇥ to change thinking effort` hint proves emptiness only when styling distinguishes the dim hint from typed text. +A plain capture of those exact bytes stays `unknown` and preserves them during content extraction; a bright typed copy stays `pending` even in a styled capture. +The verified shape and live exit/relaunch refresh command are recorded in [omp box composer through Herdr](verification/runtime-backends.md#2026-10-06-omp-box-composer-through-herdr). + If the ANSI capture ever fails, the plain fallback declares itself unstyled. The classifier then degrades a glyph row carrying trailing text to `unknown` instead of misreading ghost suggestions as typed input. That safely defers injection and eventually raises the wedge alarm. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 110417c5033..33bd31d03b7 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -71,12 +71,11 @@ Run the real-harness guard after any harness upgrade and before trusting refresh ### Composer, busy state, and delivery Agent liveness and composer safety are separate checks. -The tmux reader is a thin adapter over the fleet-wide classifier in `bin/fm-composer-lib.sh`: it contributes one styled full-pane capture, the `#{cursor_y}` cursor row, and foreground-process identity probes, and the shape containing the cursor - a complete bordered box (titled bottom borders tolerated), a bare agent-glyph row with its wrapped input, opencode's left bar, or Pi's identity-corroborated separator pair - normally decides the verdict. +The tmux reader is a thin adapter over the fleet-wide classifier in `bin/fm-composer-lib.sh`: it contributes one styled full-pane capture, the `#{cursor_y}` cursor row, and foreground-process identity probes, and the supported shape containing the cursor normally decides the verdict. Real text in an identified shape is pending, while only positively proven emptiness reads empty. A blank or otherwise unidentified cursor row is `unknown` and every consumer defers, except that a foreground process proven to be Cursor is re-read cursorlessly because Cursor parks its terminal cursor below its footer. That identity-gated exception preserves the strict container-proof rule for every other pane, so a modal dialog, a dead shell between stale rules, or a mid-redraw pane is never an injection target. -The shared classifier accepts a shell glyph as an empty agent composer only inside a bordered container. -A bare shell prompt is `unknown`, so away-mode escalation is never injected into a dead shell. +The shared supported shapes, prompt-glyph limits, and plain-capture safety boundary are owned by [Composer and injection safety](herdr-backend.md#composer-and-injection-safety). Busy state is not read from rendered text on this backend. A task's busy, idle, unknown, or dead verdict comes from the semantic busy-state contract owned by `bin/fm-busy-lib.sh`; [architecture](architecture.md#busy-state-is-semantic-per-adapter) owns its boundaries. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 42e59b45660..b6132d1cb94 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1003,6 +1003,45 @@ tests/fm-composer-codex-idle-live-e2e.test.sh The verification machine runs its fleet on Herdr and has no tmux installed, so on 2026-09-15 that guard reported `skip: live: tmux absent` there, and the Herdr capture above is this entry's live evidence. The guard also notes whether the starfield and the placeholder were actually drawn during its read, because codex need not animate them under every model or mode; a refresh on a tmux host should record that note beside the verdict rather than assume the starfield was exercised. +### 2026-10-06 omp box composer through Herdr + +Verified on 2026-10-06 on macOS arm64 against omp 18.6.3 in an isolated Herdr 0.9.1 lab session, read through Herdr's ANSI capture with its exact capability descriptor (`styled=1`, `cursor=0`, `identity=1`). +[`fm-spawn.sh --help`](../../bin/fm-spawn.sh) owns the session composer pin; the remaining user configuration selected `box` in this verification. +omp live-reloads every `--config` overlay file it was started with: an idle worker launched with the overlay pinning `borderless` drew a bare `❯` row, and removing the `shape` line from that file in place made the same worker redraw as the box shape within seconds, with no restart. +The observed empty single-line composer had its status in the top border and its last input row folded into the bottom border: + +```text +╭── π > ◒ GPT-6.1-Sol 🙈 > 🌳 firstmate/firstmate > ⑂ fm/fm-model-index > … 🆔 01a111e1 ──╮ +╰─ ─╯ +``` + +Typed text appears in the folded row (`╰─ hello world typed text ─╯`), wrapped text takes `│ … │` rows above it, and the empty row shows a right-aligned `⇧⇥ to change thinking effort` hint whose key glyphs are bright and whose words are dim italic (luminance 113, below the default ghost ceiling of 128). +The current classification and plain-hint safety boundary is owned by [Composer and injection safety](../herdr-backend.md#composer-and-injection-safety); exact container predicates are owned by `bin/fm-composer-lib.sh`. +`test_matrix_omp_box_composer` and `test_omp_box_requires_omp_identity_and_complete_shape` in `tests/fm-composer-lib.test.sh` carry the captured idle screen and the captured typed, wrapped, and hint rows. + +The live guard that refreshes this entry launches the installed omp idle with the box shape pinned in a guarded Herdr lab and drives the public lifecycle commands. +Its default-on checks spend no tokens wherever omp, herdr, and jq are installed; the relaunch proof submits a real worker brief and remains opt-in: + +```sh +FM_OMP_COMPOSER_BOX_LIVE_RELAUNCH=1 tests/fm-omp-composer-box-live-e2e.test.sh +``` + +Setting `FM_OMP_COMPOSER_BOX_LIVE_RELAUNCH=1` forces the shared capability gate even when `FM_LIVE=0` or `FM_OMP_COMPOSER_BOX_LIVE=0`; a missing required tool fails rather than skips. +Setting the relaunch flag to `0` skips only the token-spending relaunch portion and leaves token-free checks governed by their existing live controls. +The relaunch proof first requires a live agent with a proven empty box composer, failing if that readiness wait expires, then checks replacement-agent liveness and endpoint preservation; it does not require a model-generated acknowledgement. + +Observed output: + +```text +ok - live omp box composer: omp (omp/18.6.3) on herdr 0.9.1 draws the box shape (status in the top border, folded last row) in isolated session fm-lab-omp-composer-box-26478-21255 +ok - live omp box composer: omp (omp/18.6.3) on herdr 0.9.1 idle empty composer reads empty through the production Herdr adapter +ok - live omp box composer: omp (omp/18.6.3) on herdr 0.9.1 reads a typed draft pending and fm-control exit refuses it by name without typing +ok - live omp box composer: omp (omp/18.6.3) on herdr 0.9.1 fm-control exit stops the idle box-shaped worker and preserves its endpoint +ok - live omp box composer: omp (omp/18.6.3) on herdr 0.9.1 fm-control relaunch replaces the box-shaped worker with a live agent in the same endpoint +``` + +With the classifier change reverted, the same run stopped at `an idle empty box composer read 'unknown', not empty`. + ## Steering-inbox doorbell The steering channel's one behavioral assumption - a real worker agent follows the constant self-describing doorbell line (list the inbox, read and act on its records in numeric order, then `mv` each into `handled/`) - was verified on 2026-08-23 against every installed verified harness, on tmux 3.6a, macOS arm64, on an isolated private socket, driving the REAL `bin/fm-send.sh` end to end (durable record plus doorbell, with one mid-wait re-ring playing the watcher's role). diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 44f6b9a80dd..a9485db65c6 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -552,6 +552,114 @@ codex_cell() { printf '%s[38;2;%s;%s;%sm%s[48;2;57;57;57m%s%s[0m' "$ESC" "$1" "$1" "$1" "$ESC" "$2" "$ESC" } +# omp's `box` composer shape: the status line rides the TOP border and the +# editor's last row is folded into the bottom border (`╰─ text ─╯`). This is the +# screen 11 idle omp workers drew after live-reloading an overlay file that no +# longer pinned `borderless` (task fm-omp-composer-unknown-blocks-control); +# every verdict read `unknown`, which stopped fm-control exit and relaunch. +# The captured screen is the task's own capture; the rest are real omp 18.6.3 +# captures through Herdr (typed text, wrapped rows, the empty-row hint). +omp_box_top() { + printf '%s' '╭── π > ◒ GPT-6.1-Sol 🙈 > 🌳 firstmate/firstmate > ⑂ fm/fm-model-index > S1.95 + 👁 1.11 ▶─────────────38%─────────╎──┃────272K─◀ ⚙ 1 < Implement fleet model index < 🆔 01a111e1 ──╮' +} + +omp_box_last() { # + printf '╰─ %-176s ─╯' "$1" +} + +test_matrix_omp_box_composer() { + local top captured typed multi hint styled_hint styled_typed multi_hint + top=$(omp_box_top) + captured=$'⚠ Operation aborted + + TODO + ├─ II. Validate · 0/1 + │ └─ ☐ Drive no-mistakes through every gate to CI readiness + ├─ III. Deliver · 0/1 + └───── + + F5 to retry + +'"$top"$' +╰─ ─╯' + # The captured idle worker: an empty editor reads empty on every profile. + assert_screen "omp box idle on herdr" empty "$CAPS_STYLED" "$captured" '' probe-absent + assert_screen "omp box idle on zellij" empty "$CAPS_STYLED_NOID" "$captured" + assert_screen "omp box idle on cmux/orca" empty "$CAPS_PLAIN" "$captured" + # Typed text is pending (the plain profile cannot be fooled either: a bordered + # row reads pending without ghost proof). + typed=$'transcript\n\n'"$top"$'\n'"$(omp_box_last 'hello world typed text')" + assert_screen "omp box typed on herdr" pending "$CAPS_STYLED" "$typed" '' probe-absent + assert_screen "omp box typed on plain backends" pending "$CAPS_PLAIN" "$typed" + multi=$'transcript\n\n'"$top"$'\n│ first line'"$(printf '%*s' 160 '')"$'│\n'"$(omp_box_last '')" + assert_screen "omp box draft in an upper row" pending "$CAPS_STYLED" "$multi" '' probe-absent + # omp's box draws no prompt glyph, so a typed glyph is text, never an empty + # composer (the shared bordered rule would have read `>` as empty). + assert_screen "omp box typed >" pending "$CAPS_STYLED" \ + $'transcript\n\n'"$top"$'\n'"$(omp_box_last '>')" '' probe-absent + assert_screen "omp box typed ❯" pending "$CAPS_STYLED" \ + $'transcript\n\n'"$top"$'\n'"$(omp_box_last '❯')" '' probe-absent + assert_screen "omp box typed dash" pending "$CAPS_STYLED" \ + $'transcript\n\n'"$top"$'\n'"$(omp_box_last '─')" '' probe-absent + hint=$'transcript\n\n'"$top"$'\n╰─'"$(printf '%*s' 60 '')"$'⇧⇥ to change thinking effort ─╯' + assert_screen "omp box unstyled hint stays ambiguous" unknown "$CAPS_PLAIN" "$hint" + assert_screen "omp box unstyled hint with cursor" unknown \ + $'styled=0\ncursor=1\nidentity=1' "$hint" 3 probe-absent + styled_hint=$'transcript\n\n'"$top"$'\n'"${ESC}[0m${ESC}[38;2;0;180;255m╰─ ${ESC}[0m${ESC}[38;2;229;229;231m$(printf '%*s' 60 '')${ESC}[0m${ESC}[38;2;0;180;255m⇧⇥${ESC}[0m${ESC}[38;2;229;229;231m ${ESC}[0m${ESC}[3m${ESC}[38;2;107;114;128mto change thinking effort${ESC}[0m${ESC}[38;2;0;180;255m ─╯" + assert_screen "omp box styled hint" empty "$CAPS_STYLED" "$styled_hint" '' probe-absent + styled_typed=$'transcript\n\n'"$top"$'\n'"${ESC}[0m${ESC}[38;2;0;180;255m╰─ ${ESC}[0m${ESC}[38;2;229;229;231m⇧⇥ to change thinking effort${ESC}[0m${ESC}[38;2;0;180;255m ─╯" + assert_screen "omp box whole hint typed bright" pending "$CAPS_STYLED" "$styled_typed" '' probe-absent + assert_screen "omp box hint loses proof without styling" unknown "$CAPS_PLAIN" "$styled_hint" + assert_screen "omp box styled hint with cursor" empty "$CAPS_TMUX" "$styled_hint" 3 probe-absent + [ "$(fm_composer_extract_selected_content "$CAPS_PLAIN" "$hint")" = '⇧⇥ to change thinking effort' ] \ + || fail "omp box plain extraction must preserve ambiguous hint-like text" + [ "$(fm_composer_extract_selected_content "$CAPS_STYLED" "$styled_typed")" = '⇧⇥ to change thinking effort' ] \ + || fail "omp box styled extraction must preserve a bright typed hint" + multi_hint=$'transcript\n\n'"$top"$'\n│ first line │\n'"$(omp_box_last '⇧⇥ to change thinking effort')" + assert_screen "omp box draft precedes ambiguous last row" pending "$CAPS_PLAIN" "$multi_hint" + [ "$(fm_composer_extract_selected_content "$CAPS_PLAIN" "$multi_hint")" = 'first line ⇧⇥ to change thinking effort' ] \ + || fail "omp box extraction must preserve both the upper draft and ambiguous last row" + [ "$(fm_composer_extract_selected_content "$CAPS_STYLED" "$styled_hint")" = '' ] \ + || fail "omp box extraction must drop the empty-row hint" + [ "$(fm_composer_extract_selected_content "$CAPS_STYLED" "$typed")" = 'hello world typed text' ] \ + || fail "omp box extraction must return the typed text without its borders" + # Cursor mode (tmux): the cursor sits on the folded last row. + assert_screen "omp box idle on tmux" empty "$CAPS_TMUX" "$captured" 11 probe-absent + assert_screen "omp box typed on tmux" pending "$CAPS_TMUX" "$typed" 3 probe-absent + pass "matrix: omp's box composer (status in the top border, folded last row) reads empty, pending, and never a typed glyph as empty" +} + +test_omp_box_requires_omp_identity_and_complete_shape() { + local top empty_last + top=$(omp_box_top) + empty_last=$(omp_box_last '') + # Only omp's own status identity proves the container; an arbitrary titled + # rounded border, a busy spinner status, or the ascii preset's `pi` stays + # an unprovable shape and reads unknown, never empty. + assert_screen "titled non-omp border" unknown "$CAPS_STYLED" \ + $'transcript\n\n╭── some other title ──╮\n'"$empty_last" '' probe-absent + assert_screen "omp busy spinner status" unknown "$CAPS_STYLED" \ + $'transcript\n\n╭── ⠧ 11s > ◒ GPT-6.1-Sol ──╮\n'"$empty_last" '' probe-absent + assert_screen "omp ascii-preset status" unknown "$CAPS_STYLED" \ + $'transcript\n\n╭── pi - GPT-6.1-Sol ──╮\n'"$empty_last" '' probe-absent + # A bare rule closing the box is not the folded last row. + assert_screen "bare rule bottom" unknown "$CAPS_STYLED" \ + $'transcript\n\n'"$top"$'\n╰'"$(printf '─%.0s' $(seq 1 60))"$'╯' '' probe-absent + # A broken interior (blank row, shifted indent) is not a proven box. + assert_screen "blank row inside the box" unknown "$CAPS_STYLED" \ + $'transcript\n\n'"$top"$'\n\n'"$empty_last" '' probe-absent + assert_screen "shifted side-border indent" unknown "$CAPS_STYLED" \ + $'transcript\n\n'"$top"$'\n │'"$(printf '%*s' 60 '')"$'│\n'"$empty_last" '' probe-absent + # Anything live below the box makes it stale, and a newer shape outranks it. + assert_screen "activity below the box" unknown "$CAPS_STYLED" \ + $'transcript\n\n'"$top"$'\n'"$empty_last"$'\nsome later activity' '' probe-absent + assert_screen "dead shell below the box" unknown "$CAPS_STYLED" \ + $'transcript\n\n'"$top"$'\n'"$empty_last"$'\n$ ls -la' '' probe-absent + assert_screen "cursor outside the box" unknown "$CAPS_TMUX" \ + $'transcript\n\n'"$top"$'\n'"$empty_last" 0 probe-absent + pass "matrix: an omp box needs omp's status identity and a complete shape; every other variant reads unknown" +} + test_matrix_codex_idle_starfield_furniture() { # Real idle codex-cli 0.154.0 (gpt-6-astra, fast mode) captured byte-for-byte # through Herdr (`pane read --format ansi`) from the first codex second mate: @@ -1037,6 +1145,8 @@ test_matrix_cursor_reverse_video_placeholder_remnant test_matrix_herdr_halfblock_rule_bounds_bare_wrap test_matrix_omp_status_row_bounds_bare_composer test_matrix_omp_effort_hint_remnant +test_matrix_omp_box_composer +test_omp_box_requires_omp_identity_and_complete_shape test_matrix_codex_idle_starfield_furniture test_matrix_pi_separated_needs_identity test_matrix_pi_dollar_status_footer_is_empty diff --git a/tests/fm-omp-composer-box-live-e2e.test.sh b/tests/fm-omp-composer-box-live-e2e.test.sh new file mode 100755 index 00000000000..f53397d8afc --- /dev/null +++ b/tests/fm-omp-composer-box-live-e2e.test.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# tests/fm-omp-composer-box-live-e2e.test.sh - the live omp box-composer guard +# (live-harness-optin family; task fm-omp-composer-unknown-blocks-control). +# +# omp draws its composer in the shape `composer.shape` selects. Firstmate pins +# `borderless` for every worker it launches (.omp/fm-session-overlay.yml), but a +# running omp live-reloads the overlay files it was started with: when the +# tracked overlay stopped carrying the pin, every already-running worker fell +# back to the captain's own `box` shape, which carries its status line in the +# top border and folds the editor's last row into the bottom border. The +# classifier read that screen as `unknown`, so fm-control exit and relaunch +# refused every idle worker ("composer state is 'unknown', not proven empty"). +# That shape is vendor-rendered, so per .agents/skills/firstmate-coding-guidelines +# the portable fixtures in tests/fm-composer-lib.test.sh are not enough on +# their own: this guard launches the INSTALLED omp idle in an isolated Herdr lab +# with the box shape pinned, and requires, through the production Herdr adapter +# and the public fm-control lifecycle commands: +# - the idle pane really is the box shape (status in the top border); +# - an empty composer reads `empty`, and a typed draft reads `pending` and +# makes fm-control exit refuse by name without typing anything; +# - fm-control exit then stops the idle worker and preserves its endpoint. +# It fails naming omp and `omp --version`. +# +# Reading an idle screen and exiting submit no prompt, so no model tokens are +# spent and the gate is default-on wherever omp, herdr, and jq are installed +# (fm_live_gate): FM_OMP_COMPOSER_BOX_LIVE=1 forces it (an absent tool then +# fails instead of skipping) and =0 disables it. The relaunch proof starts a +# real worker on the brief, which does spend tokens, so it stays opt-in behind +# FM_OMP_COMPOSER_BOX_LIVE_RELAUNCH=1. +# Refresh docs/verification/runtime-backends.md ("omp box composer") from this +# guard's output after any omp upgrade. +# Every Herdr call, including adapter calls, is routed through bin/fm-herdr-lab.sh. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LAB_HELPER=${HERDR_LAB_HELPER:-$ROOT/bin/fm-herdr-lab.sh} + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +LIVE_CONTROLS=FM_OMP_COMPOSER_BOX_LIVE +if [ "${FM_OMP_COMPOSER_BOX_LIVE_RELAUNCH:-0}" = 1 ]; then + LIVE_CONTROLS="$LIVE_CONTROLS,FM_OMP_COMPOSER_BOX_LIVE_RELAUNCH" +fi +fm_live_gate default-on "$LIVE_CONTROLS" herdr jq omp + +[ -x "$LAB_HELPER" ] || fail "FM_OMP_COMPOSER_BOX_LIVE=1 but the Herdr lab helper is not executable at $LAB_HELPER" + +# shellcheck source=tests/herdr-test-safety.sh +. "$ROOT/tests/herdr-test-safety.sh" +herdr_forget_inherited_pane + +ORIGINAL_PATH=$PATH +SESSION=$("$LAB_HELPER" name omp-composer-box-live) +TMP_ROOT=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-omp-composer-box-live.XXXXXX") +FAKEBIN="$TMP_ROOT/fakebin" +mkdir -p "$FAKEBIN" +TASK_ID="ompbox$$" +LAUNCH_DIR= + +cleanup() { + local rc=$? + trap - EXIT + if ! PATH="$ORIGINAL_PATH" "$LAB_HELPER" teardown "$SESSION"; then + rc=1 + fi + # fm-spawn write-protects the task's git hook directory in the control home. + chmod -R u+w "$TMP_ROOT" 2>/dev/null || true + if [ -n "$LAUNCH_DIR" ]; then + rm -rf "$LAUNCH_DIR" + fi + rm -rf "/tmp/fm-$TASK_ID" "$TMP_ROOT" + exit "$rc" +} +trap cleanup EXIT + +cat > "$FAKEBIN/herdr" <&2; exit 97; } + args=("\${args[@]:0:\$((n-2))}") +else + echo "wrapper requires trailing --session $SESSION" >&2 + exit 98 +fi +exec env PATH="$ORIGINAL_PATH" "$LAB_HELPER" run "$SESSION" "\${args[@]}" +EOF +chmod +x "$FAKEBIN/herdr" + +"$LAB_HELPER" provision "$SESSION" || fail "could not provision the isolated Herdr lab" +export PATH="$FAKEBIN:$ORIGINAL_PATH" + +# shellcheck source=/dev/null +. "$ROOT/bin/backends/herdr.sh" + +lab() { env PATH="$ORIGINAL_PATH" "$LAB_HELPER" run "$SESSION" "$@"; } +VERSION=$(PATH="$ORIGINAL_PATH" omp --version 2>/dev/null | head -1 || printf 'version-unknown') +HERDR_VER=$(PATH="$ORIGINAL_PATH" herdr --version 2>/dev/null | head -1 || printf 'herdr-unknown') +SUBJECT="omp ($VERSION) on $HERDR_VER" + +# The session overlay with its composer pin swapped for the box shape: the same +# posture every worker launches with, minus the one setting under test. +BOX_OVERLAY="$TMP_ROOT/box-overlay.yml" +sed 's/^ shape: borderless$/ shape: box/' "$ROOT/.omp/fm-session-overlay.yml" > "$BOX_OVERLAY" + +CONTROL_HOME="$TMP_ROOT/control-home" +PROJECT="$TMP_ROOT/proj" +WORKTREE="$TMP_ROOT/wt" +mkdir -p "$CONTROL_HOME/state" "$CONTROL_HOME/data/$TASK_ID" +CONTROL_HOME_ROOT=$(cd "$CONTROL_HOME" 2>/dev/null && pwd -P) || CONTROL_HOME_ROOT=$CONTROL_HOME +if command -v shasum >/dev/null 2>&1; then + CONTROL_HOME_HASH=$(printf '%s' "$CONTROL_HOME_ROOT" | shasum -a 256 | awk '{print $1}') +elif command -v sha256sum >/dev/null 2>&1; then + CONTROL_HOME_HASH=$(printf '%s' "$CONTROL_HOME_ROOT" | sha256sum | awk '{print $1}') +else + fail "test needs shasum or sha256sum" +fi +LAUNCH_DIR="/tmp/fm-$TASK_ID+$CONTROL_HOME_HASH" +fm_git_worktree "$PROJECT" "$WORKTREE" "$TASK_ID" \ + || fail "could not create the task worktree" +cat > "$CONTROL_HOME/data/$TASK_ID/brief.md" <<'EOF' +# Task +## Captain's intent +Verify that an idle box-shaped omp worker can be safely relaunched. + +## Firstmate spec +Do not edit any file. +EOF + +ws=$(lab workspace create --cwd "$WORKTREE" --label "fm-$TASK_ID" --no-focus) \ + || fail "could not create the isolated workspace" +PANE=$(printf '%s' "$ws" | jq -er '.result.root_pane.pane_id') \ + || fail "workspace create did not return a pane id" +cat > "$CONTROL_HOME/state/$TASK_ID.meta" </dev/null \ + || fail "could not launch $SUBJECT in the isolated pane" + +control() { # + FM_HOME="$CONTROL_HOME" FM_SPAWN_NO_GUARD=1 FM_CONTROL_POLL=0.2 FM_CONTROL_EXIT_WAIT=30 \ + "$ROOT/bin/fm-control.sh" "$@" 2>&1 +} + +# Wait for both the box shape and Herdr's live-agent registration: omp can draw +# its composer before the detector registers it. A splash, different shape, or +# unregistered launch must never satisfy the guard's lifecycle precondition. +i=0 +screen= +while [ "$i" -lt 60 ]; do + screen=$(lab pane read "$PANE" --source visible 2>/dev/null || true) + if printf '%s\n' "$screen" | grep -Eq '^╭── (π|󰵗) [>·] ' \ + && [ "$(fm_backend_herdr_composer_state "$TARGET")" = empty ] \ + && [ "$(fm_backend_herdr_agent_state "$TARGET")" = alive ]; then + break + fi + i=$((i + 1)) + sleep 1 +done +printf '%s\n' "$screen" | grep -Eq '^╭── (π|󰵗) [>·] ' || { + printf '%s\n' "$screen" >&2 + fail "$SUBJECT never drew its box composer with the status line in the top border" +} +printf '%s\n' "$screen" | grep -Eq '^╰─ .* ─╯$' \ + || fail "$SUBJECT drew no folded last row (╰─ … ─╯) under its box status border" +pass "live omp box composer: $SUBJECT draws the box shape (status in the top border, folded last row) in isolated session $SESSION" + +state=$(fm_backend_herdr_composer_state "$TARGET") +[ "$state" = empty ] \ + || fail "$SUBJECT: an idle empty box composer read '$state', not empty" +[ "$(fm_backend_herdr_agent_state "$TARGET")" = alive ] \ + || fail "$SUBJECT: the initial empty box composer has no live agent" +pass "live omp box composer: $SUBJECT idle empty composer reads empty through the production Herdr adapter" + +fm_backend_herdr_send_literal "$TARGET" 'unsent draft text' \ + || fail "$SUBJECT: could not type a draft into the box composer" +i=0 +while [ "$i" -lt 20 ]; do + sleep 1 + screen=$(lab pane read "$PANE" --source visible 2>/dev/null || true) + case "$screen" in *'unsent draft text'*) break ;; esac + i=$((i + 1)) +done +case "$screen" in + *'unsent draft text'*) ;; + *) printf '%s\n' "$screen" >&2; fail "$SUBJECT: the typed draft never rendered in the box composer" ;; +esac +state=$(fm_backend_herdr_composer_state "$TARGET") +[ "$state" = pending ] \ + || fail "$SUBJECT: a box composer holding a typed draft read '$state', not pending" +if out=$(control "$TASK_ID" exit); then + fail "$SUBJECT: fm-control exit typed over a pending draft in the box composer: $out" +fi +case "$out" in + *'composer visibly holds pending text'*) ;; + *) fail "$SUBJECT: fm-control exit did not name the pending draft in the box composer: $out" ;; +esac +[ ! -e "$CONTROL_HOME/state/$TASK_ID.control-exit" ] \ + || fail "$SUBJECT: a refused exit left a deliberate-exit marker" +screen=$(lab pane read "$PANE" --source visible 2>/dev/null || true) +case "$screen" in + *'unsent draft text/quit'*|*'unsent draft text /quit'*) fail "$SUBJECT: the exit command was concatenated onto the draft" ;; +esac +pass "live omp box composer: $SUBJECT reads a typed draft pending and fm-control exit refuses it by name without typing" + +# Clear the draft (Ctrl+U clears the composer line) and prove it reads empty +# again before the real exit. +lab pane send-keys "$PANE" ctrl+u >/dev/null || fail "$SUBJECT: could not clear the draft" +i=0 +state= +while [ "$i" -lt 20 ]; do + state=$(fm_backend_herdr_composer_state "$TARGET") + [ "$state" != empty ] || break + i=$((i + 1)) + sleep 1 +done +[ "$state" = empty ] || fail "$SUBJECT: the cleared box composer read '$state', not empty" +[ "$(fm_backend_herdr_agent_state "$TARGET")" = alive ] \ + || fail "$SUBJECT: the cleared empty box composer has no live agent" + +out=$(control "$TASK_ID" exit) \ + || fail "$SUBJECT: fm-control exit refused an idle box composer: $out" +case "$out" in + *"stopped $TASK_ID"*) ;; + *) fail "$SUBJECT: exit did not report a verified stop: $out" ;; +esac +[ "$(fm_backend_herdr_agent_state "$TARGET")" = dead ] \ + || fail "$SUBJECT: exit returned but the agent is still running" +lab pane get "$PANE" >/dev/null || fail "exit removed the endpoint it must preserve" +pass "live omp box composer: $SUBJECT fm-control exit stops the idle box-shaped worker and preserves its endpoint" + +if [ "${FM_OMP_COMPOSER_BOX_LIVE_RELAUNCH:-0}" = 1 ]; then + # A second box-shaped launch, then the relaunch through fm-spawn's own launch. + lab pane run "$PANE" "env OMP_SKIP_SETUP=1 FM_OMP_HARNESS=omp omp --config '$BOX_OVERLAY' --auto-approve --cwd '$WORKTREE'" >/dev/null \ + || fail "could not relaunch $SUBJECT in the box shape" + i=0 + while [ "$i" -lt 60 ]; do + state=$(fm_backend_herdr_composer_state "$TARGET") + agent=$(fm_backend_herdr_agent_state "$TARGET") + screen=$(lab pane read "$PANE" --source visible 2>/dev/null || true) + if [ "$state" = empty ] && [ "$agent" = alive ] \ + && printf '%s\n' "$screen" | grep -Eq '^╭── (π|󰵗) [>·] ' \ + && printf '%s\n' "$screen" | grep -Eq '^╰─ .* ─╯$'; then + break + fi + i=$((i + 1)) + sleep 1 + done + [ "$i" -lt 60 ] \ + || fail "$SUBJECT: the second launch never proved a live agent with an empty box composer (agent='$agent', composer='$state')" + out=$(control "$TASK_ID" relaunch --note "Live guard relaunch.") \ + || fail "$SUBJECT: fm-control relaunch refused an idle box composer: $out" + case "$out" in + *"relaunched $TASK_ID harness=omp"*"endpoint=$TARGET "*) ;; + *) fail "$SUBJECT: relaunch did not report a replacement in the same endpoint: $out" ;; + esac + [ "$(fm_backend_herdr_agent_state "$TARGET")" = alive ] \ + || fail "$SUBJECT: relaunch returned but no agent is running in the preserved endpoint" + lab pane get "$PANE" >/dev/null || fail "relaunch removed the endpoint it must preserve" + pass "live omp box composer: $SUBJECT fm-control relaunch replaces the box-shaped worker with a live agent in the same endpoint" +else + printf 'skip: live omp box composer relaunch: opt-in; set FM_OMP_COMPOSER_BOX_LIVE_RELAUNCH=1 to run\n' +fi