From dc6db5f14e51a3f7a0a4834329a0e7acf8247ba3 Mon Sep 17 00:00:00 2001 From: veronica Date: Fri, 21 Aug 2026 16:54:39 -0500 Subject: [PATCH] fix(tmux): locate the REPL input box by shape, not by a Claude-only glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send-message.sh confirmed delivery by grepping the captured pane for a prompt glyph (`❯`, a leading `>`, or `│ >`). Those are Claude Code shapes. pi draws its input box as two horizontal `─` rules with the input between them and no glyph anywhere, so on a pi pane the grep matched nothing, the `delivered` and `draft` branches were unreachable, and every send exited 2 with "may be UNDELIVERED" after burning all its retries — on messages that had in fact been delivered. Measured on five live seats: pi seats vision, tess and medic returned 0 glyph matches on non-empty captures (3653 / 1086 / 1890 bytes); Claude seats fred and tuesday returned 3 and 1. This is a different arm from #1257. On the measured host `tmux capture-pane -t -p` and `-t :0.0 -p` both return the same 3653 bytes, so #1257's empty-capture mechanism does not reproduce here; fixing only that would leave every pi send at rc=2 and would verify green against a Claude pane. The probe becomes locate_input_box(), which returns a status code instead of a string. Found-but-empty is a real answer (an empty input box is what a submitted message leaves behind) and is now distinguishable from not-found, which the old `[ -z "$promptline" ]` test conflated. Box detection anchors on the LAST pair of `─` rules: agent output can contain its own rules, but nothing is drawn below the input box except the status line. Adding a runtime means adding its shape in that one function. A missing shape does not degrade gracefully — it turns every send to that runtime into a false "may be UNDELIVERED", which is exactly what this fixes. Tests: two fixtures added to test-send-message-verdict.sh driving a glyphless box-drawn REPL, one that submits (expect exit 0 delivered) and one that holds the text in the box (expect exit 2 draft). Patched suite PASS=5 FAIL=0. Control against the pristine origin/next send-message.sh: PASS=3 FAIL=2, fixture 4 failing as `rc=2 ... REPL input prompt not locatable after 3 attempts — message may be UNDELIVERED`, which is the defect reproduced in a test. Sibling suites unchanged and green: test-send-message-socket.sh rc=0, agent-send.test.sh PASS=19 FAIL=0. Note the suite is in test-enumeration-exclusions.txt (the CI image ships no tmux), so these fixtures are verified locally only. That exclusion and its #1017 burndown reason are unchanged by this commit. Not addressed here: auto-submit-drafts.sh lines 20 and 26 carry the same Claude-only `❯` assumption. Separate surface, separate change. Closes #1362 --- .../framework/tools/tmux/send-message.sh | 52 ++++++++++++++--- .../tools/tmux/test-send-message-verdict.sh | 57 +++++++++++++++++++ 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/packages/mosaic/framework/tools/tmux/send-message.sh b/packages/mosaic/framework/tools/tmux/send-message.sh index 0ea5b09f..88ffe3ba 100755 --- a/packages/mosaic/framework/tools/tmux/send-message.sh +++ b/packages/mosaic/framework/tools/tmux/send-message.sh @@ -32,7 +32,9 @@ # 0 delivered (submitted) or queued (agent busy; will process when free) # 1 tmux target not found # 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL -# input prompt could not be located to confirm the message actually landed. +# input box could not be located to confirm the message actually landed. +# Locating the box is runtime-specific; see locate_input_box() below, and +# add a shape there before pointing this tool at a new runtime. # Delivery is NEVER inferred from absence of evidence: if we cannot positively # see the input box clear of the message (or the queued banner), we fail loud # so the sender learns immediately instead of a silent worker->lead stall. @@ -97,10 +99,43 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" - # would otherwise accumulate forever. sleep 0.5 +# Locate the REPL input box in a captured pane. Prints the box's contents on +# stdout and returns 0 when the box was FOUND; returns 1 when it could not be +# located at all. Found-but-empty is a real, distinct answer (an empty input box +# is what a submitted message leaves behind), so the caller must branch on the +# return code, never on whether the output is empty. +# +# Two REPL shapes are recognised: +# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most +# readline REPLs. +# * a box drawn as two horizontal `─` rules with the input between them and NO +# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is +# what makes it safe: agent output can contain its own rules, but nothing is +# drawn below the input box except the status line. +# +# Adding a runtime means adding its shape HERE. A shape that is missing does not +# degrade gracefully: it turns every send to that runtime into a false +# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another +# arm of the same probe. +locate_input_box() { + local pane=$1 glyph_line rule_lines top bottom + glyph_line=$(printf '%s\n' "$pane" | grep -E '❯|^>|│ >' | tail -1) + if [ -n "$glyph_line" ]; then printf '%s\n' "$glyph_line"; return 0; fi + rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*─{4,}[[:space:]]*$' | cut -d: -f1 | tail -2) + [ "$(printf '%s\n' "$rule_lines" | grep -c .)" -eq 2 ] || return 1 + top=$(printf '%s\n' "$rule_lines" | head -1) + bottom=$(printf '%s\n' "$rule_lines" | tail -1) + [ "$bottom" -gt "$top" ] || return 1 + # An empty range (adjacent rules) prints nothing and still returns 0: found, + # empty, which is the delivered shape. + printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p" + return 0 +} + # 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is # still a draft. Success requires positive evidence — the queued banner, OR the # REPL input box located AND clear of our message tail. The historical bug was -# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched +# treating ABSENCE of a draft as delivery: if the input box was never located # (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered" # and worker->lead relays stalled silently. We now default to UNCONFIRMED and only # upgrade to delivered on positive evidence; anything we cannot confirm fails loud. @@ -113,15 +148,14 @@ for attempt in $(seq 1 $((RETRIES + 1))); do if grep -qF "$QUEUED_RE" <<<"$pane"; then status="queued"; break fi - # Locate the REPL input box (prompt glyph). If we cannot see it, we have NO - # evidence of submission state — stay UNCONFIRMED and retry; never infer delivery. - promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1) - if [ -z "$promptline" ]; then + # If we cannot see the input box, we have NO evidence of submission state — + # stay UNCONFIRMED and retry; never infer delivery. + if ! inputbox=$(locate_input_box "$pane"); then status="unconfirmed"; continue fi # Input box located AND still carrying our tail => unsubmitted draft. Flush + retry. - # (Submitted messages scroll up into history; a draft stays on the ❯ line.) - if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then + # (Submitted messages scroll up into history; a draft stays in the box.) + if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then status="draft"; continue fi # Input box located AND clear of our tail => positively submitted. This is the @@ -135,6 +169,6 @@ case "$status" in delivered) echo "✓ delivered to $TARGET"; exit 0 ;; queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;; draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;; - unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input prompt not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;; + unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;; *) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;; esac diff --git a/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh b/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh index f306b693..1186c657 100755 --- a/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh +++ b/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh @@ -10,6 +10,13 @@ # "could not confirm submission"). # 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the # input line) => exit 2, stderr "unsubmitted draft". +# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph +# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362 +# the glyph probe could not see this box at all, so EVERY send +# to such a pane reported "may be UNDELIVERED" while landing. +# 5. DRAFT — the same glyphless box, holding our tail across every flush +# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this +# also reported unconfirmed, so the true state was invisible. set -uo pipefail HERE=$(cd -- "$(dirname -- "$0")" && pwd) @@ -69,6 +76,56 @@ else fi fi +# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the +# text between them and NO prompt glyph anywhere, so the glyph probe alone can +# never locate it and every send reports "may be UNDELIVERED" (#1362). The +# renderer below is the shape, not the runtime: MODE=clear submits (box empties), +# MODE=keep leaves the text sitting in the box. +cat > "$TMP/pibox.sh" <<'PIBOX' +#!/usr/bin/env bash +MODE=${1:-clear} +RULE=$(printf '─%.0s' $(seq 1 60)) +buf="" +draw() { + printf '\033[H\033[2J' + printf 'fixture output line\n\n' + printf '%s\n' "$RULE" + printf '%s\n' "$buf" + printf '%s\n' "$RULE" + printf '~/fixture (main)\n' + printf 'tok 0 model fixture\n' +} +draw +while IFS= read -r line; do + # keep: hold the tail across every flush Enter, which is what a stuck draft does. + if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi + draw +done +PIBOX +chmod +x "$TMP/pibox.sh" + +tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear" +sleep 0.3 +out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$? +if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then + ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" +else + no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]" +fi + +tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep" +sleep 0.3 +if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then + no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])" +else + rc=$? + if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then + ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'" + else + no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]" + fi +fi + echo "---" echo "PASS=$PASS FAIL=$FAIL" [ "$FAIL" -eq 0 ]