#!/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 -m "message" # send-message.sh [-L socket_name] -t -f # echo "message" | send-message.sh [-L socket_name] -t # ssh host bash -s -- -L socket -t -b "$(base64 -w0 <<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 # 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 ONLY # on positive evidence of an unsubmitted 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 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. # Flush Enters are sent ONLY after a located-but-still-draft box. When the # box is merely not locatable, we re-capture WITHOUT another Enter. # A redraw is only one possible explanation, not an established cause. 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="" flushes=0 # Bound observation-only retries separately from the -r flush budget. # 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 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 if [ "$SECONDS" -lt "$deadline" ]; then sleep 1.0; continue; fi status="unconfirmed"; break fi # Input box located AND still carrying our tail => unsubmitted draft. Flush # (evidence-based Enter, capped by -r) + retry. (Submitted messages scroll # up into history; a draft stays in the box.) 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 # Input box located AND clear of our tail => positively submitted. This is the # only path to success besides the queued banner. 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 # 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 — 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