tmux resolves the two halves of a target with different, individually
dangerous defaults, and send-message.sh took both defaults:
* An unpinned name PREFIX-matches. With `foobar` alive and no `foo`,
`-t foo` resolves to `foobar` at rc=0 -- pasted, Enter-ed, verified
and reported OK against the wrong agent's pane.
* A bare `=name` is only half a pin. capture-pane REJECTS it ("can't
find pane") while list-panes silently PREFIX-MATCHES it, and the
validation at :76 uses list-panes -- so for any caller already
supplying `=name`, that rewrite was the only thing between them and
a wrong-session pass.
The direction is what makes this expensive. Paste (:93-94), Enter (:151)
and the verifying capture (:153) all read one EFFECTIVE_TARGET, so a
wrong-window send is confirmed by a wrong-window read: it manufactures a
false "delivered", not a loud failure. A false negative gets
investigated; a false positive gets believed.
Normalise to `=session:` -- exact session, active window. Explicit tmux
ids (%pane, @window, $session) pass through untouched.
BEHAVIOUR CHANGE for callers that already pass `=name`: they previously
landed on `:0.0` (window 0 unconditionally) and now land on the session's
ACTIVE window. This is the intended fix -- window 0 is not where a
multi-window agent is sitting -- but it does move a live target rather
than being a no-op normalisation.
Test: test-send-message-target.sh covers all four arms (absent name must
not prefix-match, delivery follows the active window, an explicit window
part is preserved, a unique prefix is still refused). Proven able to go
red: against the pre-fix script it FAILs at arm 1, and with arm 1 removed
it FAILs at arm 2. The multi-window fixture is load-bearing -- a
single-window session cannot tell `=s:` from `=s:0.0`, which is why this
survived.
It is registered as a signed enumeration exclusion rather than on a CI
surface: it drives a real tmux server and the CI image ships no tmux,
the same condition its two siblings are already excluded under. It
hard-fails when tmux is absent rather than skipping, so it cannot go
quietly green where it cannot run.
200 lines
10 KiB
Bash
Executable File
200 lines
10 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 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.
|
||
# 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
|
||
|
||
# Normalise the target to an EXACT session plus a window part, because tmux
|
||
# resolves the two halves with different and individually dangerous defaults:
|
||
#
|
||
# * An unpinned name is a PREFIX match. With a session `foobar` alive and
|
||
# no session `foo`, `-t foo` resolves to `foobar` at rc=0, so a message is
|
||
# delivered, verified and reported OK against the wrong agent's pane.
|
||
# * A bare `=name` is not enough on its own: capture-pane REJECTS it
|
||
# ("can't find pane") while list-panes silently PREFIX-MATCHES it, so the
|
||
# validation below would pass on a session the capture cannot read.
|
||
# * A trailing `:` follows the session's ACTIVE window. Pinning `:0.0`
|
||
# instead addresses window 0 unconditionally, and since the paste, the
|
||
# Enter and the verifying capture all use EFFECTIVE_TARGET, a multi-window
|
||
# agent gets typed into window 0 and confirmed by reading window 0 --
|
||
# a false "delivered" rather than a loud failure.
|
||
#
|
||
# Explicit tmux ids (%pane, @window, $session) are passed through untouched;
|
||
# prefixing `=` to them would break addressing that is already unambiguous.
|
||
EFFECTIVE_TARGET=$TARGET
|
||
case "$TARGET" in
|
||
=*|%*|@*|\$*) ;;
|
||
*) EFFECTIVE_TARGET="=$TARGET" ;;
|
||
esac
|
||
case "$EFFECTIVE_TARGET" in
|
||
=*) [[ "$EFFECTIVE_TARGET" == *:* ]] || EFFECTIVE_TARGET="${EFFECTIVE_TARGET}:" ;;
|
||
esac
|
||
|
||
# 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
|
||
|
||
# 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)
|
||
[ -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
|
||
[ "$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 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.
|
||
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 grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||
status="queued"; break
|
||
fi
|
||
# 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 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
|
||
# only path to success besides the queued banner.
|
||
status="delivered"; 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 ;;
|
||
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 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
|