243 lines
12 KiB
Bash
Executable File
243 lines
12 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,
|
||
# 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
|