#!/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 -t -m "message" # send-message.sh -t -f # echo "message" | send-message.sh -t # ssh host bash -s -- -t -b "$(base64 -w0 <<&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; } # Target must resolve to a live pane. if ! tmux list-panes -t "$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`. printf '%s' "$MSG" | tmux load-buffer -b __mosaic_send - # -p = bracketed paste when the client supports it; fall back if not. tmux paste-buffer -d -p -b __mosaic_send -t "$TARGET" 2>/dev/null \ || tmux paste-buffer -d -b __mosaic_send -t "$TARGET" sleep 0.5 # 2) Submit, then verify; flush with another Enter if it is still a draft. status="sent" for attempt in $(seq 1 $((RETRIES + 1))); do tmux send-keys -t "$TARGET" Enter sleep 1.2 pane=$(tmux capture-pane -t "$TARGET" -p 2>/dev/null) if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then status="queued"; break fi # Draft heuristic: the prompt glyph line still carries our message tail. # (Submitted messages scroll up into history; a draft stays on the ❯ line.) promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1) if [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then status="draft"; continue fi 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 ;; *) echo "✓ sent to $TARGET (submission state indeterminate; verify with -v)"; exit 0 ;; esac