ci/woodpecker/pr/ci Pipeline failed
send-message.sh located the REPL input box with grep -E '❯|^>|│ >'. That set is
Claude Code's box. A pi seat renders a bare U+2500 rule with no glyph, so on every
idle pi seat the capture succeeded, the grep matched nothing, status stayed
"unconfirmed", and the tool exited 2 "may be UNDELIVERED" with the paste and the
Enter both landed. The stderr tells the operator to retry, and that retry is the
duplicate delivery reported against the same tool.
Confirmation is now runtime-agnostic: our message tail sits on the input line
(located by cursor row, no glyph) before Enter and has left it after. That
transition is positive proof of submission.
Absence still proves nothing, which is the guard the 2026-08 fix was reaching for
and got backwards. Two positive checks keep it:
- a prompt box that IS locatable and still carries our tail => draft, exit 2.
This covers the cursor-row blind spot: a cooked pane whose foreground process
never reads stdin echoes the paste through the kernel line discipline and
moves the cursor off it on Enter, which by cursor row alone is indistinguishable
from a real submit.
- no draft ever observed on the input line => unconfirmed, non-zero.
Tests, both red-first against the shipping blob d397907:
test-send-message-glyph-agnostic.sh (new, 6 fixtures) 4/6 -> 6/6
test-send-message-verdict.sh (fixture 2 reshaped, 2b added) 3/4 -> 4/4
Fixture 2 of the verdict suite asserted exit 2 for a glyphless pane that submits
and was labelled "false-positive FIXED". A pi seat is that fixture, so the suite
was locking the bug in. It is reshaped deliberately, and the guard it was credited
with moves to new fixture 2b (glyphless AND non-submitting, raw/no-echo) so the
"never infer delivered from absence" property is tested positively rather than as
a side effect.
Measured on tmux 3.7b (sb-it-1-dt), 3.5a (fomo-lin), and dragon-lin.
Co-authored-by: scooby <[email protected]>
168 lines
8.6 KiB
Bash
Executable File
168 lines
8.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# send-message.sh — reliably deliver a message to a tmux pane running an
|
||
# interactive REPL (e.g. a Claude Code / Codex agent).
|
||
#
|
||
# WHY THIS EXISTS
|
||
# Pasting multi-line text into an interactive agent REPL via `tmux send-keys`
|
||
# is unreliable: the text lands in the input box but a single trailing Enter
|
||
# in the same keystroke stream is frequently swallowed, so the message sits as
|
||
# an UNSUBMITTED DRAFT ("Press up to edit queued messages") and the agent never
|
||
# sees it. The mechanical fix is: paste as a bracketed paste (so embedded
|
||
# newlines don't submit early), pause, then send Enter as its OWN keystroke,
|
||
# pause, and send Enter again to flush. An extra Enter on an empty prompt is a
|
||
# no-op in Claude Code, so the double-Enter is safe.
|
||
#
|
||
# USAGE
|
||
# send-message.sh [-L socket_name] -t <target> -m "message"
|
||
# send-message.sh [-L socket_name] -t <target> -f <file>
|
||
# echo "message" | send-message.sh [-L socket_name] -t <target>
|
||
# ssh host bash -s -- -L socket -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
|
||
#
|
||
# OPTIONS
|
||
# -L NAME tmux socket name passed to `tmux -L NAME` (optional)
|
||
# -t TARGET tmux target: session, or session:window.pane [required]
|
||
# -m MESSAGE message text (single- or multi-line)
|
||
# -f FILE read message from FILE instead of -m
|
||
# -b BASE64 message as base64 (ssh-safe transport; decoded internally)
|
||
# -r N Enter-flush attempts (default 2)
|
||
# -v verbose: print a short tail of the pane after delivery
|
||
# -h help
|
||
#
|
||
# EXIT CODES
|
||
# 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.
|
||
# 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.
|
||
# 3 usage error
|
||
set -uo pipefail
|
||
|
||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
|
||
usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; }
|
||
|
||
while getopts "L:t:m:f:b:r:vh" o; do
|
||
case "$o" in
|
||
L) SOCKET_NAME=$OPTARG ;;
|
||
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||
esac
|
||
done
|
||
|
||
[ -n "$TARGET" ] || { echo "ERROR: -t TARGET is required" >&2; usage 3; }
|
||
if [ -n "$B64" ]; then MSG=$(printf '%s' "$B64" | base64 -d) || { echo "ERROR: bad -b base64" >&2; exit 3; }
|
||
elif [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||
fi
|
||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||
|
||
tmux_cmd=(tmux)
|
||
if [ -n "$SOCKET_NAME" ]; then
|
||
tmux_cmd+=(-L "$SOCKET_NAME")
|
||
fi
|
||
|
||
# tmux accepts `=session` for some commands, but pane-level commands such as
|
||
# capture-pane require a pane-qualified target. Keep exact-session addressing
|
||
# convenient while avoiding accidental prefix matches.
|
||
EFFECTIVE_TARGET=$TARGET
|
||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
|
||
EFFECTIVE_TARGET="${TARGET}:0.0"
|
||
fi
|
||
|
||
# Target must resolve to a live pane.
|
||
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
|
||
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
|
||
fi
|
||
|
||
QUEUED_RE='Press up to edit queued messages'
|
||
# A distinctive tail of the message to spot an unsubmitted draft on the input line.
|
||
snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g' | tail -c 32)
|
||
|
||
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
||
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
||
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
|
||
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
|
||
# simultaneous restart (briefs swapped between sessions).
|
||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
||
# -p = bracketed paste when the client supports it; fall back if not.
|
||
"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|
||
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
|
||
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
|
||
# (e.g. the target vanished since the liveness check), delete explicitly —
|
||
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
|
||
# would otherwise accumulate forever.
|
||
sleep 0.5
|
||
|
||
# 2) Submit, then POSITIVELY confirm submission by DRAFT TRANSITION, not by prompt
|
||
# glyph. The historical bug was treating ABSENCE of a draft as delivery; the
|
||
# 2026-08 fix over-corrected to glyph inference (grep '❯|^>|│ >'), which locates
|
||
# only Claude Code's box and false-NEGATIVES every glyphless REPL (pi renders a
|
||
# U+2500 rule, no glyph) — a delivered message reported "UNDELIVERED", driving a
|
||
# retry that duplicates it. Runtime-agnostic evidence: our message tail sits on
|
||
# the INPUT line (located by the cursor row, not a glyph) BEFORE Enter, and has
|
||
# LEFT it AFTER — that transition is positive proof of submission and needs no
|
||
# glyph. Absence alone still never means delivered: if we never saw our draft on
|
||
# the input line we stay UNCONFIRMED (wrong/dead pane), and a draft that never
|
||
# leaves the input line stays a DRAFT (exit 2), preserving both historical guards.
|
||
_cursor_line() { # echo the pane's current input (cursor) line, glyph-free
|
||
local cy line
|
||
cy=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" -F '#{cursor_y}' 2>/dev/null) || return 1
|
||
[ -n "$cy" ] || return 1
|
||
"${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null | sed -n "$((cy + 1))p"
|
||
}
|
||
_draft_on_input() { # true iff our message tail is sitting on the input line now
|
||
[ -n "$snippet" ] || return 1
|
||
printf '%s' "$(_cursor_line)" | grep -qF "$snippet"
|
||
}
|
||
|
||
# Baseline: after the paste, our draft must be on the input line. This is positive
|
||
# proof we are on the right pane and the paste landed — the anchor the transition
|
||
# check measures against.
|
||
saw_draft=0
|
||
_draft_on_input && saw_draft=1
|
||
|
||
status="unconfirmed"
|
||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||
sleep 1.2
|
||
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
|
||
|
||
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then
|
||
status="queued"; break
|
||
fi
|
||
# POSITIVE draft evidence from a located prompt box, when one exists. This is the
|
||
# cursor-row check's blind spot: a pane in COOKED mode (a plain shell whose
|
||
# foreground process never reads stdin) echoes our paste via the kernel line
|
||
# discipline and moves the cursor off it on Enter, which is indistinguishable from
|
||
# a real submit by cursor row alone. If a prompt box IS locatable and still carries
|
||
# our tail, that is affirmative proof the message was not consumed. Absence of a
|
||
# glyph is still never used for anything — that inference is the original E7 bug.
|
||
promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||
if [ -n "$promptline" ] && [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then
|
||
status="draft"; continue
|
||
fi
|
||
if [ "$saw_draft" = 1 ]; then
|
||
if _draft_on_input; then
|
||
status="draft"; continue # still on the input line => not submitted; flush + retry
|
||
fi
|
||
status="delivered"; break # left the input line => positively submitted
|
||
fi
|
||
# No confirmed baseline yet: try to (re)acquire it; never infer delivery from absence.
|
||
if _draft_on_input; then saw_draft=1; status="draft"; continue; fi
|
||
status="unconfirmed"; continue
|
||
done
|
||
|
||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||
|
||
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 ;;
|
||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||
esac
|