#!/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 <<&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`. printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send - # -p = bracketed paste when the client supports it; fall back if not. "${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \ || "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_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_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter sleep 1.2 pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_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