From 91bf99efe1ba53c83819e81c4e07907a39b636bc Mon Sep 17 00:00:00 2001 From: "Mos (mos-claude)" Date: Thu, 11 Jun 2026 12:46:31 -0500 Subject: [PATCH] =?UTF-8?q?feat(framework/tools):=20inter-agent=20tmux=20c?= =?UTF-8?q?omms=20=E2=80=94=20agent-send.sh=20+=20standard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tools/tmux/ to the framework source (previously only present in installed ~/.config/mosaic copies, never committed): - agent-send.sh: inter-agent messaging wrapper. Prepends the canonical addressing preamble [: -> :] (auto-detecting the sender), and delivers reliably to local OR remote panes. Remote delivery ships send-message.sh over ssh and runs it local to the target pane, sidestepping the ssh->nested-tmux Enter/C-m submission swallow; the remote needs only bash+tmux+base64 (no framework install required there). - send-message.sh: low-level reliable single-pane submitter (bracketed paste + Enter-flush + draft detection). Adds a -b base64 input for ssh-safe transport. - README.md: documents the addressing standard (replies flip the preamble) and the submission gotcha the helper exists to solve. Propagates to each host via install.sh rsync on next framework upgrade. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mosaic/framework/tools/tmux/README.md | 78 ++++++++++++++ .../mosaic/framework/tools/tmux/agent-send.sh | 100 ++++++++++++++++++ .../framework/tools/tmux/send-message.sh | 97 +++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 packages/mosaic/framework/tools/tmux/README.md create mode 100755 packages/mosaic/framework/tools/tmux/agent-send.sh create mode 100755 packages/mosaic/framework/tools/tmux/send-message.sh diff --git a/packages/mosaic/framework/tools/tmux/README.md b/packages/mosaic/framework/tools/tmux/README.md new file mode 100644 index 0000000..12f368f --- /dev/null +++ b/packages/mosaic/framework/tools/tmux/README.md @@ -0,0 +1,78 @@ +# Inter-Agent tmux Comms — Standard & Tooling + +Reliable, self-identifying messaging between Mosaic agents running in tmux panes +(Claude Code / Codex / OpenCode REPLs), across hosts. + +## The addressing standard (required) + +Every cross-agent tmux message MUST begin with an addressing preamble: + +``` +[: -> :] +``` + +- `host` = `hostname -s` of the machine the agent runs on (e.g. `web1`, `sb-it-mgr-0-lt`). +- `session` = the tmux session name (e.g. `mos-claude`, `rev0-4`, `installer-1`). +- **Replies FLIP the preamble**: the recipient answers with `[ -> ] ...`. + +Why: a fresh or context-wiped agent always knows who sent a message and to whom. +No ambiguity about origin or lane after a tmux wipe / session restart. + +Example exchange: + +``` +[web1:mos-claude -> sb-it-mgr-0-lt:installer-1] status on #29? +[sb-it-mgr-0-lt:installer-1 -> web1:mos-claude] Q2 done, opening PR #34. +``` + +## The helper: `agent-send.sh` + +Prepends the preamble automatically (auto-detecting your own `host:session`) and +delivers reliably to local OR remote panes. + +```bash +# Local target (same host) +agent-send.sh -s -m "message" + +# Remote target (over ssh) +agent-send.sh -H user@host -s -m "message" + +# From a file / stdin +agent-send.sh -H user@host -s -f msg.txt +echo "msg" | agent-send.sh -s +``` + +Key flags: `-s` dst session (required) · `-H` ssh target for remote · `-n` dst +hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S` +override source label · `-v` verbose · `-r N` Enter-flush attempts. + +## Why a helper exists (the submission gotcha) + +Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a +trailing `Enter` is frequently swallowed and the message sits as an **unsubmitted +draft** ("Press up to edit queued messages"). Over an `ssh -> nested tmux` hop the +plain `Enter` keyname often does not register at all — `C-m` is needed. + +`send-message.sh` solves this for a **local** pane: bracketed-paste the body +(so multi-line content doesn't submit early), pause, then send `Enter` as its own +keystroke and flush with a second, verifying against a draft heuristic. + +`agent-send.sh` solves the **remote** case by _shipping `send-message.sh` over ssh_ +(`ssh host bash -s -- ... < send-message.sh`) and running it local to the target +pane — so the reliable send-keys always happens on the pane's own host. The remote +needs only `bash` + `tmux` + `base64`; **no mosaic install required there**. The +message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards. + +## Files + +- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch). +- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input). + +## Distribution + +These live in the installed framework copy at +`~/.config/mosaic/tools/tmux/`. `install.sh` rsyncs the framework **source tree** +to each host, so to propagate permanently, land both files in the framework +source repo and re-run the installer on each host. Until then, `agent-send.sh` +already works against any reachable host because it ships `send-message.sh` over +ssh per-send — no pre-install on the target host is needed to _send to_ it. diff --git a/packages/mosaic/framework/tools/tmux/agent-send.sh b/packages/mosaic/framework/tools/tmux/agent-send.sh new file mode 100755 index 0000000..fe30d1b --- /dev/null +++ b/packages/mosaic/framework/tools/tmux/agent-send.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# agent-send.sh — standard inter-agent tmux messaging for the Mosaic stack. +# +# WHAT IT DOES +# Sends a message to another agent's tmux pane (local or on a remote host) +# with the canonical addressing preamble prepended: +# +# [: -> :] +# +# The preamble makes every inter-agent message self-identifying, so a fresh +# or context-wiped agent always knows who sent a message and to whom — no +# ambiguity about lanes or origin. Recipients replying should FLIP the +# preamble: [ -> ] ... (this tool sends; it does not auto-reply). +# +# WHY A WRAPPER +# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly: +# a trailing Enter is often swallowed and the message sits as an unsubmitted +# DRAFT. tools/tmux/send-message.sh already solves that for a LOCAL pane via +# bracketed-paste + Enter-flush + draft-detection. For REMOTE targets this +# wrapper SHIPS send-message.sh over ssh (stdin) and runs it there, so the +# reliable send-keys happens local to the target pane — sidestepping the +# ssh->nested-tmux Enter/C-m swallow entirely. No mosaic install needed on +# the remote host; only bash + tmux + base64 (standard). +# +# USAGE +# agent-send.sh -s -m "message" # local target +# agent-send.sh -H user@host -s -m "message" # remote target +# agent-send.sh -H user@host -n -s -f msg.txt +# echo "msg" | agent-send.sh -H user@host -s +# +# OPTIONS +# -s DST_SESSION target tmux session (or session:window.pane) [required] +# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local +# -n DST_HOST hostname to show in the preamble for the target. +# Default: local hostname, or (remote) resolved via one ssh. +# -m MESSAGE message text (single- or multi-line) +# -f FILE read message from FILE instead of -m +# -S SRC_LABEL override source label ":" (default: auto) +# -r N Enter-flush attempts passed through (default 2) +# -v verbose: print pane tail after delivery +# -h help +# +# EXIT CODES (passed through from send-message.sh) +# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error +set -uo pipefail + +SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd) +SENDER="$SELF_DIR/send-message.sh" + +DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE="" +SRC_LABEL=""; RETRIES=2; VERBOSE=0 +usage() { sed -n '2,44p' "$0"; exit "${1:-3}"; } + +while getopts "s:H:n:m:f:S:r:vh" o; do + case "$o" in + s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;; + m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;; + r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;; + esac +done + +[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; } +[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; } + +# Message body from -f / -m / stdin. +if [ -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; } + +# Source label: this agent's host:session (auto-detected, overridable). +if [ -z "$SRC_LABEL" ]; then + src_host=$(hostname -s 2>/dev/null || echo "?") + src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?") + SRC_LABEL="${src_host}:${src_sess}" +fi + +# Destination host label for the preamble. +if [ -z "$DST_HOST" ]; then + if [ -n "$SSH_TARGET" ]; then + DST_HOST=$(ssh -o ConnectTimeout=8 -o BatchMode=yes "$SSH_TARGET" 'hostname -s' 2>/dev/null || echo "${SSH_TARGET#*@}") + else + DST_HOST=$(hostname -s 2>/dev/null || echo "local") + fi +fi + +PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}]" +FULL="${PREAMBLE} ${MSG}" +B64=$(printf '%s' "$FULL" | base64 -w0) + +vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v" + +if [ -z "$SSH_TARGET" ]; then + # Local pane: call the canonical sender directly. + exec "$SENDER" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag +else + # Remote pane: ship the sender over ssh and run it local to the target. + ssh -o ConnectTimeout=10 "$SSH_TARGET" \ + "bash -s -- -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER" +fi diff --git a/packages/mosaic/framework/tools/tmux/send-message.sh b/packages/mosaic/framework/tools/tmux/send-message.sh new file mode 100755 index 0000000..77daa30 --- /dev/null +++ b/packages/mosaic/framework/tools/tmux/send-message.sh @@ -0,0 +1,97 @@ +#!/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 -- 2.49.1