Files
stack/docs/plans/reviews/2026-09-07_tmux-r3-export/tools/tmux/send-message.sh
T

243 lines
12 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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,
# then observe. This revision sends one Enter only; an ambiguous visual
# region cannot authorize an extra submission key.
#
# 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 Legacy compatibility option; no automatic extra Enter is sent
# -v verbose: print a short tail of the pane after delivery
# -h help
#
# EXIT CODES
# 0 observed new message visibility and cleared supported editor (not ACK)
# 1 tmux target not found
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
# 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 new message visibility plus a supported editor clear of it, 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,/^set -uo pipefail/{ /^set -uo pipefail/d; p; }' "$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'
# Compare the whole message without ASCII layout whitespace. Preserve UTF-8
# bytes even in LC_ALL=C; never drop Unicode or take a partial-byte suffix.
# This tolerates plain whitespace wrapping, not arbitrary terminal rendering.
snippet=$(printf '%s' "$MSG" | LC_ALL=C tr -d ' \t\r\n')
[ -n "$snippet" ] || { echo "ERROR: message has no usable comparison content" >&2; exit 3; }
# 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).
# Snapshot before any message effect. A later empty-looking editor alone
# cannot establish acceptance; require newly visible whole-message evidence.
if ! baseline_pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null); then
echo "ERROR: baseline capture failed for $TARGET; nothing pasted" >&2
exit 2
fi
baseline_normalized=$(printf '%s' "$baseline_pane" | LC_ALL=C tr -d ' \t\r\n')
BUF="__mosaic_send_$$_$(date +%s%N)"
if ! printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -; then
echo "ERROR: could not load message buffer for $TARGET" >&2
exit 2
fi
# -p = bracketed paste when the client supports it; fall back if not.
# FAIL LOUD if neither paste attempt succeeds: a silent continue here sent
# bare Enters with no message and could report "delivered" while nothing
# was delivered (measured defect, 2026-09-07). Exit 2 = submission NOT
# confirmed, which is exactly true when nothing was pasted.
if ! { "${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET"; }; then
"${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
echo "ERROR: paste into $TARGET failed — nothing was delivered (buffer discarded)" >&2
exit 2
fi
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. A synthetic trailing
# em-dash variant is also tolerated; it is not an established live shape.
# Group the literal UTF-8 sequence before repetition: under LC_ALL=C,
# an ungrouped quantifier repeats only its last byte, not the whole glyph.
#
# 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
# A historical prompt anywhere in the transcript is not the current input.
# For glyph-only layouts require the final nonblank line to be a prompt.
glyph_line=$(printf '%s\n' "$pane" | grep -vE '^[[:space:]]*$' | tail -1)
if printf '%s\n' "$glyph_line" | grep -qE '^[[:space:]]*(|>|│ >)'; 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)
[ -n "$rule_lines" ] || return 1
# Split the (at most two) captured line numbers with parameter expansion. Not
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
# With one rule captured both halves resolve to the same value and the
# ordering test below rejects it, which is the answer we want anyway.
top=${rule_lines%%$'\n'*}
bottom=${rule_lines##*$'\n'}
[ "$top" != "$bottom" ] || return 1
# Adjacent rules have no editor content row; reject rather than constructing
# a reversed sed range and mistaking a border for an empty editor.
[ "$bottom" -gt "$((top + 1))" ] || return 1
# Require the supported pi footer immediately below the lower rule.
# Transcript rules followed by arbitrary output are not an editor boundary.
# This is a layout heuristic, not an authenticated receipt; unknown layouts
# deliberately remain unconfirmed. Limit the suffix to the compact footer.
local footer suffix_lines
footer=$(printf '%s\n' "$pane" | sed -n "$((bottom + 1))p")
printf '%s\n' "$footer" | grep -qE '^(/|~/).+ [(][^()]+[)][[:space:]]*$' || return 1
suffix_lines=$(printf '%s\n' "$pane" | sed -n "$((bottom + 1)),\$p" | grep -cve '^[[:space:]]*$')
[ "$suffix_lines" -le 4 ] || return 1
# Adjacent rules were rejected above. Extract at least one editor row.
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
return 0
}
# 2) Submit once, then observe a message-correlated visual transition.
# Unknown shapes, repeated baseline content and queued banners cannot confirm.
# This is not recipient acknowledgement; see README for heuristic limitations.
if ! "${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter; then
echo "ERROR: submission key failed for $TARGET; do not blindly resend" >&2
exit 2
fi
sleep 1.2
status="unconfirmed"; pane=""; confirmation_reason="not-observed"
# Bound observation-only retries; -r is retained only for compatibility.
# Ten seconds is a chosen patience limit, not a measured rendering guarantee.
deadline=$(( SECONDS + 10 ))
while :; do
if ! pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null); then
echo "ERROR: capture failed for $TARGET; submission remains unconfirmed" >&2
exit 2
fi
# A queued banner alone is not correlated with this message. It may be
# historical or belong to an earlier send; never upgrade on that alone.
if grep -qF "$QUEUED_RE" <<<"$pane"; then
confirmation_reason="queued-banner-ambiguous"
if [ "$SECONDS" -lt "$deadline" ]; then sleep 1.0; continue; fi
status="unconfirmed"; break
fi
# If we cannot see the input box, we have NO evidence of submission state —
# stay UNCONFIRMED, keep re-capturing until the patience deadline; never
# infer delivery, never re-submit blind.
if ! inputbox=$(locate_input_box "$pane"); then
confirmation_reason="editor-shape-unrecognized"
if [ "$SECONDS" -lt "$deadline" ]; then sleep 1.0; continue; fi
status="unconfirmed"; break
fi
# A matching message in the visual input region remains draft/unconfirmed.
# Never issue another Enter on this visual evidence.
normalized_input=$(printf '%s' "$inputbox" | LC_ALL=C tr -d ' \t\r\n')
if grep -qF -- "$snippet" <<<"$normalized_input"; then
status="draft"
# A visual matching region may be historical. Until current-editor
# identity is established, it cannot authorize another submission key.
break
fi
# Clear input alone is insufficient: require newly visible whole-message
# evidence relative to the pre-paste baseline.
observed_normalized=$(printf '%s' "$pane" | LC_ALL=C tr -d ' \t\r\n')
if ! grep -qF -- "$snippet" <<<"$baseline_normalized" &&
grep -qF -- "$snippet" <<<"$observed_normalized"; then
status="delivered"; break
fi
if grep -qF -- "$snippet" <<<"$baseline_normalized"; then
confirmation_reason="message-present-in-baseline"
else
confirmation_reason="new-message-not-visible"
fi
# A static historical editor or a message disappearing without a visible
# transcript transition is insufficient. Do not resubmit to manufacture it.
if [ "$SECONDS" -lt "$deadline" ]; then sleep 1.0; continue; fi
status="unconfirmed"; break
done
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
case "$status" in
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
draft) echo "✗ still an unsubmitted draft on $TARGET after the initial submission key; no automatic flush attempted" >&2; exit 2 ;;
unconfirmed) echo "✗ could not confirm submission on $TARGET: message-correlated editor evidence unavailable within the observation window (reason=$confirmation_reason) — 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