CI 2604 red on scripts/pipefail-early-exit.test.mjs. Two lines I added in the previous commit tripped its scan of send-message.sh: [ "$(printf '%s\n' "$rule_lines" | grep -c .)" -eq 2 ] || return 1 top=$(printf '%s\n' "$rule_lines" | head -1) The second is a real violation of the rule the guard enforces: `head` exits after its count, the producer takes SIGPIPE, and under `set -euo pipefail` the caller aborts with rc=141 and no output. That is the same failure mode test-mosaic-worktree-large-repo.sh exists to pin. The first is the guard reading `-eq` as an early-exit grep flag (`-[A-Za-z]*q` matches `-eq`), so `grep ... | ... -eq 2` looks like `grep -q` on one line. A false positive, but the fix removes the shape either way. Both go away by splitting the captured line numbers with parameter expansion instead of a second pass through the pipe. With one rule captured, both halves resolve to the same value and the new `[ "$top" != "$bottom" ]` test rejects it, which is the answer that case wanted anyway. Verified: node --test scripts/pipefail-early-exit.test.mjs green on the load-bearing assertion; test-send-message-verdict.sh still PASS=5 FAIL=0; test-send-message-socket.sh rc=0; agent-send.test.sh PASS=19 FAIL=0.
182 lines
9.3 KiB
Bash
Executable File
182 lines
9.3 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
|
||
|
||
# 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
|
||
|
||
# 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
|