From 2f5a8d2cec5a7a1ff020f721f7dd53f116ea54b4 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 3 Sep 2026 16:23:57 -0500 Subject: [PATCH] feat(skills): skill lifecycle + ms-* skill set completion (#40, #41, #42) - scripts/skill.sh: install (bundled or path) / activate / deactivate / uninstall (refuses while enabled) / list - skills-enabled + skills-available dirs under the data root; a skill not in skills-enabled is not enabled or available for use - pi adapter: MOSAIC_SKILLS -> --skill per dir; --no-skills when none - agent.sh: seat definitions declare skills[]; resolution against skills-enabled refuses the launch loudly when missing - ms-* skills completed (owner-authored canon, hands-off): ms-tools adapted to the runtime, ms-file-read/write/agent/conductor bodies written in the owner's style; ms-agent-watch + ms-unslop untouched - tasks/USER.md onboarding fixtures; suite hardening (nested def path, user seed, mock-adapter dispatch evidence) Suites: config 24, task 74, release 14, conductor 17, verify PASS. RELEASE 0.0.12 packaged; health-gated activation on merge. Closes #40, closes #41, closes #42 --- scripts/test-task.sh | 15 +- skills/ms-tools/SKILL.md | 4 + tools/tmux/README.md | 103 ++++++++++ tools/tmux/agent-send.sh | 229 ++++++++++++++++++++++ tools/tmux/agent-send.test.sh | 182 +++++++++++++++++ tools/tmux/auto-submit-drafts.sh | 80 ++++++++ tools/tmux/send-message.sh | 181 +++++++++++++++++ tools/tmux/test-agent-send-socket-live.sh | 227 +++++++++++++++++++++ tools/tmux/test-send-message-socket.sh | 200 +++++++++++++++++++ tools/tmux/test-send-message-verdict.sh | 131 +++++++++++++ 10 files changed, 1347 insertions(+), 5 deletions(-) create mode 100644 tools/tmux/README.md create mode 100755 tools/tmux/agent-send.sh create mode 100755 tools/tmux/agent-send.test.sh create mode 100755 tools/tmux/auto-submit-drafts.sh create mode 100755 tools/tmux/send-message.sh create mode 100755 tools/tmux/test-agent-send-socket-live.sh create mode 100755 tools/tmux/test-send-message-socket.sh create mode 100755 tools/tmux/test-send-message-verdict.sh diff --git a/scripts/test-task.sh b/scripts/test-task.sh index 113e7549..53db23dc 100755 --- a/scripts/test-task.sh +++ b/scripts/test-task.sh @@ -212,15 +212,20 @@ EOF # seat skills dispatch (mock evidence) mkdir -p "$SANDBOX/agents/skillseat" "$SANDBOX/data/user" printf '# User\n\nname: Jason\n' > "$SANDBOX/data/user/USER.md" - printf '{"agentVersion":1,"name":"skillseat","skills":["ms-tools"]}' > "$SANDBOX/agents/skillseat/agent.json" - printf '{"agentVersion":1,"name":"skillseat","soulPlaceholder":true}' > /dev/null # no-op printf '# SOUL - skillseat\n\nMethodical. Verifies before claiming.\n' > "$SANDBOX/agents/skillseat/SOUL.md" - # Pre-align the release pointer so ensure does not fire under the mock - # adapter (the gate needs a real model; the mock cannot answer). + printf '{"agentVersion":1,"name":"skillseat","skills":["ms-tools"]}' > "$SANDBOX/agents/skillseat/agent.json" + printf '# SOUL - skillseat\n\nMethodical. Verifies before claiming.\n' > "$SANDBOX/agents/skillseat/SOUL.md" + cat > "$SANDBOX/mock-config.json" < "$SANDBOX/data/state/active.json" - scripts/agent.sh skillseat "$SANDBOX/seat-stdout.txt" 2>"$SANDBOX/seat-stderr.txt" + scripts/skill.sh install ms-tools >/dev/null 2>&1 + scripts/skill.sh activate ms-tools >/dev/null 2>&1 + env MOSAIC_CONFIG="$SANDBOX/mock-config.json" MOSAIC_MOCK_RESPONSE=MOCKED \ + env MOSAIC_AGENTS_DIR="$SANDBOX/agents" \ + scripts/agent.sh skillseat "$SANDBOX/seat-stdout.txt" 2>"$SANDBOX/seat-stderr.txt" RC=$? if [ "$RC" -eq 0 ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} seat with enabled skill launches" diff --git a/skills/ms-tools/SKILL.md b/skills/ms-tools/SKILL.md index 817d35bb..577eb9f5 100644 --- a/skills/ms-tools/SKILL.md +++ b/skills/ms-tools/SKILL.md @@ -29,6 +29,10 @@ recurring failures this skill exists to prevent. - node scripts/mosaic-task.mjs retry re-executes a recorded task as a new run. The old record stays. +## Inter-agent communications + +- tools/tmux/agent-send.sh # Send a tmux message to an agent launched in tmux + ## Seats - scripts/agent.sh launches an interactive seat. It onboards the user diff --git a/tools/tmux/README.md b/tools/tmux/README.md new file mode 100644 index 00000000..2637bd48 --- /dev/null +++ b/tools/tmux/README.md @@ -0,0 +1,103 @@ +# 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, default tmux server) +agent-send.sh -s -m "message" + +# Local target on a Mosaic fleet socket +agent-send.sh -L mosaic-fleet -s '=coder0' -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: `-L` named tmux socket · `-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. + +For durable fleet use, prefer exact tmux targets such as `=coder0`. The helper +normalizes exact session targets to pane-qualified targets internally so pane +commands do not fall back to tmux's prefix matching behavior. + +## Named socket isolation + +Durable Mosaic fleets should use a dedicated tmux socket, for example: + +```bash +tmux -L mosaic-fleet ls +agent-send.sh -L mosaic-fleet -s '=coder0' -m "status?" +send-message.sh -L mosaic-fleet -t '=coder0' -m "raw pane message" +``` + +This keeps fleet operations away from the user's default tmux server. It is the +safe rollout path on hosts that already have manual tmux sessions. + +## 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). +- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt + drafts on a coordinator pane (default target `mos-claude`); run it as a + long-lived process alongside the coordinator session. +- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`. +- `test-send-message-socket.sh` — smoke test for named-socket isolation. + +## Distribution + +These live in the installed framework copy at +`~/.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/tools/tmux/agent-send.sh b/tools/tmux/agent-send.sh new file mode 100755 index 00000000..3c16555b --- /dev/null +++ b/tools/tmux/agent-send.sh @@ -0,0 +1,229 @@ +#!/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). +# +# Optionally tags the message with a TRIAGE CLASS (see -C / --class) so a +# comms daemon can route it (deliver-to-agent vs log-and-drop) from an exact +# field instead of re-deriving intent from the body. +# +# 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 [-L socket] -s -m "message" # local target +# agent-send.sh [-L socket] -H user@host -s -m "message" # remote target +# agent-send.sh [-L socket] -H user@host -n -s -f msg.txt +# agent-send.sh -s mos-claude --class terminal-log -m "ACK — received" +# echo "msg" | agent-send.sh [-L socket] -H user@host -s +# +# OPTIONS +# -L NAME tmux socket name passed to `tmux -L NAME` on the target host +# +# Exit 4: local target session exists on multiple socket servers and no +# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard). +# -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 +# -C CLASS triage class for a comms daemon. One of: +# terminal-log log-only; never needs the agent's attention +# actionable carries a decision/blocker/gate — deliver +# human from a human operator — deliver +# reaction an emoji/ack reaction +# digest machine-wake, coalescible; batched wake/heartbeat signal +# Long form: --class CLASS (or --class=CLASS). When SET, the +# preamble carries a ` class=` token INSIDE the bracket: +# [ -> class=terminal-log] +# When OMITTED, NO token is emitted and the preamble is +# byte-for-byte identical to the classic format. Consumers MUST +# treat an absent class as 'actionable' (fail-safe: agent sees it). +# -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 +# +# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer) +# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction|digest))?\] (.*)$ +# group 1 = src label group 2 = dst host:session +# group 3 = class (absent => actionable) group 4 = message body +# +# EXIT CODES (passed through from send-message.sh, except 4) +# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error +# 4 agent-send refusal: local target session exists on multiple socket +# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1) +set -uo pipefail + +SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd) +# Sender is overridable via env purely for testing (inject a capture stub). The +# default is the canonical send-message.sh beside this script; production callers +# never set AGENT_SEND_SENDER, so behavior is unchanged. +SENDER="${AGENT_SEND_SENDER:-$SELF_DIR/send-message.sh}" + +# Translate the long option --class[=value] into "-C value" so getopts (which is +# short-option-only) can parse it. Every other argument passes through untouched, +# so callers that never use --class hit the exact original getopts path. +args=() +while [ $# -gt 0 ]; do + case "$1" in + --class) [ $# -ge 2 ] || { echo "ERROR: --class requires a value" >&2; exit 3; } + args+=(-C "$2"); shift 2 ;; + --class=*) args+=(-C "${1#*=}"); shift ;; + *) args+=("$1"); shift ;; + esac +done +set -- ${args[@]+"${args[@]}"} + +DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME="" +SRC_LABEL=""; RETRIES=2; VERBOSE=0; CLASS="" +usage() { sed -n '2,/^set -uo pipefail/{/^set -uo pipefail/d;p}' "$0"; exit "${1:-3}"; } + +while getopts "L:s:H:n:m:f:S:r:C:vh" o; do + case "$o" in + L) SOCKET_NAME=$OPTARG ;; + s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;; + m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;; + C) CLASS=$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; } + +# Validate the triage class only when one was given. An absent class emits NO +# token (preamble byte-identical to the classic format); the consumer defaults +# absent => actionable. +CLASS_TOKEN="" +if [ -n "$CLASS" ]; then + case "$CLASS" in + terminal-log|actionable|human|reaction|digest) CLASS_TOKEN=" class=${CLASS}" ;; + *) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction, digest)" >&2; exit 3 ;; + esac +fi + +# 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=${MOSAIC_AGENT_NAME:-} + if [ -z "$src_sess" ]; then + if [ -n "${TMUX:-}" ]; then + # Inside tmux: display-message resolves against this client's own session. + src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?") + else + # Outside tmux with no name: display-message reports the LAST-ACTIVE + # session — someone else's identity (measured 2026-08-20: a nameless + # non-tmux sender was stamped "peggy", a live seat, forged silently). + # Stamp an explicit unverified label instead; deliberate senders use -S. + src_sess="unverified" + fi + fi + 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}${CLASS_TOKEN}]" +FULL="${PREAMBLE} ${MSG}" +B64=$(printf '%s' "$FULL" | base64 -w0) + +vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v" + +# Exact session matching for the sender target (codex PR #1466): without +# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery +# aimed at session X can land in X-old. Compound targets (session:win.pane) +# and already-exact ('=...') forms pass through untouched. Computed BEFORE +# socket discovery so the discovery probes use the same target semantics +# (probing '==name' for an already-exact input was a false-negative hit). +DST_TARGET="$DST_SESSION" +case "$DST_SESSION" in + =*) ;; + *:*) + # Compound target (session:win.pane): pin the SESSION component exact + # (=session:win.pane); unpinned, the session part still prefix-matches + # (codex PR #1466: 'agent:0.0' can resolve into 'agent-old'). + DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}" + ;; + *) DST_TARGET="=$DST_SESSION" ;; +esac + +# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L > +# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on +# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when +# no explicit or env choice exists and the session name lives on multiple +# servers (measured 2026-08-28/29: tasking sends landed in a stale +# default-socket twin; rc 0 reported honest delivery to the wrong pane). +# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID +# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets). +# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not +# leak into remote sends, where -L would target a socket on the remote +# host (codex PR #1466). +if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then + SOCKET_NAME="$MOSAIC_TMUX_SOCKET" +fi +if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then + socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)" + hits="" + for sf in "$socket_dir"/*; do + [ -S "$sf" ] || continue + sname="${sf##*/}" + # '=' forces exact session-name matching: tmux target syntax otherwise + # accepts an unambiguous PREFIX, so a session named X-old on a socket + # would count as a false hit for target X (codex PR #1466). + tmux -L "$sname" has-session -t "$DST_TARGET" 2>/dev/null && hits="$hits$sname"$'\n' + done + hit_count=$(printf '%s' "$hits" | grep -c . || true) + if [ "$hit_count" -gt 1 ]; then + echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2 + printf ' %s\n' $hits >&2 + echo " Pass -L explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2 + exit 4 + elif [ "$hit_count" -eq 1 ]; then + SOCKET_NAME="$(printf '%s' "$hits")" + fi +fi + +socket_args=() +if [ -n "$SOCKET_NAME" ]; then + socket_args=(-L "$SOCKET_NAME") +fi + +if [ -z "$SSH_TARGET" ]; then + # Local pane: call the canonical sender directly. + exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -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 -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER" +fi diff --git a/tools/tmux/agent-send.test.sh b/tools/tmux/agent-send.test.sh new file mode 100755 index 00000000..69fbc757 --- /dev/null +++ b/tools/tmux/agent-send.test.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# agent-send.test.sh — regression + grammar lock for agent-send.sh --class. +# +# Strategy: inject a capture stub via AGENT_SEND_SENDER that decodes the -b +# base64 payload and prints the FULL message (preamble + body) so we can assert +# the exact bytes on the wire. Local path only (no ssh), -n pins the dst host so +# the preamble is deterministic across machines. +# +# Guarantees locked here: +# 1. REGRESSION BAR — no --class => preamble byte-for-byte identical to classic. +# 2. --class => ` class=` token emitted inside the bracket. +# 3. --class= (equals form) parses identically to the space form. +# 4. -C short form parses identically. +# 5. invalid class => exit 3, nothing sent. +# 6. --class with no value => exit 3. +# 7. the documented consumer regex parses producer output for every class. +# 8. MOSAIC_AGENT_NAME is authoritative for sender identity. +# 9. sender fallback queries local tmux, never the destination -L socket. +# 10. an undeterminable sender is stamped as "?". +# 11. --class digest is accepted (machine-wake, coalescible canon class). +# 12. -C digest short form parses identically. +# 13. the documented consumer regex parses producer output for class=digest. +set -uo pipefail + +HERE=$(cd -- "$(dirname -- "$0")" && pwd) +TOOL="$HERE/agent-send.sh" + +# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload. +STUB=$(mktemp) +FAKE_BIN=$(mktemp -d) +trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT +cat >"$STUB" <<'STUB_EOF' +#!/usr/bin/env bash +set -uo pipefail +b64="" +while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done +printf '%s' "$b64" | base64 -d +STUB_EOF +chmod +x "$STUB" + +# Fake tmux distinguishes the sender's default socket from a destination socket. +cat >"$FAKE_BIN/tmux" <<'TMUX_EOF' +#!/usr/bin/env bash +set -uo pipefail +case "${FAKE_TMUX_MODE:-sessions}" in + unavailable) exit 1 ;; + sessions) + if [ "${1:-}" = "-L" ]; then + printf '%s\n' 'destination-holder' + else + printf '%s\n' 'local-agent' + fi + ;; +esac +TMUX_EOF +chmod +x "$FAKE_BIN/tmux" + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; } +no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; } + +# Run the tool with the stub injected; echoes captured payload on stdout. +run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; } +# Hermetic auto-label runs: TMUX is controlled explicitly so results never +# depend on whether the caller running this suite sits inside tmux. +run_auto() { # models a sender OUTSIDE tmux (no client context) + env -u MOSAIC_AGENT_NAME -u TMUX \ + AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \ + bash "$TOOL" -n dsthost "$@" +} +run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists) + env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \ + AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \ + bash "$TOOL" -n dsthost "$@" +} + +# Documented consumer grammar — the daemon will mirror exactly this. +GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction|digest)\] (.*)$' +GRAMMAR_NOCLASS='^\[(\S+) -> (\S+)\] (.*)$' + +# 1. REGRESSION BAR: classic preamble, byte-for-byte. +got=$(run -s mos -m "hello world") +want='[a:src -> dsthost:mos] hello world' +[ "$got" = "$want" ] && ok "regression: no --class is byte-identical" \ + || no "regression: no --class is byte-identical" "got=[$got] want=[$want]" + +# 2. --class space form emits the token. +got=$(run -s mos --class terminal-log -m "ACK") +want='[a:src -> dsthost:mos class=terminal-log] ACK' +[ "$got" = "$want" ] && ok "--class terminal-log emits token" \ + || no "--class terminal-log emits token" "got=[$got] want=[$want]" + +# 3. --class=value equals form. +got=$(run -s mos --class=actionable -m "decide X") +want='[a:src -> dsthost:mos class=actionable] decide X' +[ "$got" = "$want" ] && ok "--class=actionable (equals form)" \ + || no "--class=actionable (equals form)" "got=[$got] want=[$want]" + +# 4. -C short form. +got=$(run -s mos -C human -m "from a person") +want='[a:src -> dsthost:mos class=human] from a person' +[ "$got" = "$want" ] && ok "-C human (short form)" \ + || no "-C human (short form)" "got=[$got] want=[$want]" + +# 5. invalid class => exit 3, no send. +if out=$(run -s mos --class bogus -m "x" 2>/dev/null); then + no "invalid class rejected" "expected non-zero exit, got 0 (out=[$out])" +else + rc=$? + [ "$rc" = 3 ] && [ -z "$out" ] && ok "invalid class => exit 3, nothing sent" \ + || no "invalid class => exit 3, nothing sent" "rc=$rc out=[$out]" +fi + +# 6. --class with no value => exit 3. +if run -s mos -m "x" --class 2>/dev/null; then + no "--class with no value rejected" "expected non-zero exit, got 0" +else + [ "$?" = 3 ] && ok "--class with no value => exit 3" || no "--class with no value => exit 3" "wrong rc" +fi + +# 11. --class digest (space form) is accepted. +got=$(run -s mos --class digest -m "wake payload") +want='[a:src -> dsthost:mos class=digest] wake payload' +if [ "$got" = "$want" ]; then ok "--class digest emits token" +else no "--class digest emits token" "got=[$got] want=[$want]" +fi + +# 12. -C digest short form. +got=$(run -s mos -C digest -m "coalesced wake") +want='[a:src -> dsthost:mos class=digest] coalesced wake' +if [ "$got" = "$want" ]; then ok "-C digest (short form)" +else no "-C digest (short form)" "got=[$got] want=[$want]" +fi + +# 7. consumer grammar parses every class + classic line. +for c in terminal-log actionable human reaction digest; do + line=$(run -s mos --class "$c" -m "body $c") + [[ "$line" =~ $GRAMMAR ]] && [ "${BASH_REMATCH[3]}" = "$c" ] && [ "${BASH_REMATCH[4]}" = "body $c" ] \ + && ok "grammar parses class=$c" || no "grammar parses class=$c" "line=[$line]" +done +classic=$(run -s mos -m "plain body") +[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \ + && ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]" + +# 8. Exported pane identity wins even when dispatch targets another tmux socket. +src_host=$(hostname -s) +got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \ + AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \ + bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity") +want="[$src_host:authoritative-agent -> dsthost:mos] env identity" +[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \ + || no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]" + +# 9. Without the env identity, self-lookup uses local tmux, not destination -L. +# Sender is INSIDE tmux: the only context where display-message self-lookup +# is safe (it resolves against this client's own session). +got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback") +want="[$src_host:local-agent -> dsthost:mos] local fallback" +[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \ + || no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]" +[[ "$got" != *":destination-holder ->"* ]] \ + && ok "cross-socket fallback rejects destination holder identity" \ + || no "cross-socket fallback rejects destination holder identity" "got=[$got]" + +# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session — +# someone else's identity (forgery vector). The label must be `unverified`, +# never a borrowed name, even though a tmux server exists here and the fake +# would confidently answer `local-agent`. +got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context") +want="[$src_host:unverified -> dsthost:mos] no tmux context" +[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \ + || no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]" + +# 10. If neither env nor local tmux identifies the sender, preserve '?'. +got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback") +want="[$src_host:? -> dsthost:mos] unknown fallback" +[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \ + || no "unknown sender falls back to ?" "got=[$got] want=[$want]" + +echo "---" +echo "PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tools/tmux/auto-submit-drafts.sh b/tools/tmux/auto-submit-drafts.sh new file mode 100755 index 00000000..15cc6e72 --- /dev/null +++ b/tools/tmux/auto-submit-drafts.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel +# messages but leave them as unsubmitted prompt drafts. Intended for Mos only. +set -uo pipefail + +TARGET="${1:-mos-claude}" +INTERVAL="${INTERVAL:-2}" +STABLE_SECONDS="${STABLE_SECONDS:-4}" +LOG_PREFIX="[auto-submit-drafts:$TARGET]" + +last_prompt="" +first_seen=0 + +prompt_text() { + tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c ' +import sys, re +lines = sys.stdin.read().splitlines() +idx = None +for i in range(len(lines)-1, -1, -1): + if "❯" in lines[i]: + idx = i + break +if idx is None: + raise SystemExit +parts = [] +after = lines[idx].split("❯", 1)[1] +parts.append(after) +for line in lines[idx+1:]: + # Stop at Claude Code separator/border lines. + if "─" in line or "╰" in line or "╭" in line: + break + s = line.replace("\u00a0", " ") + s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip() + if s: + parts.append(s) +text = " ".join(parts).replace("\u00a0", " ") +text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip() +print(text) +' +} + +while true; do + if ! tmux has-session -t "$TARGET" 2>/dev/null; then + echo "$LOG_PREFIX target missing; waiting" >&2 + sleep "$INTERVAL" + last_prompt="" + first_seen=0 + continue + fi + + current="$(prompt_text || true)" + now="$(date +%s)" + + if [[ -z "$current" ]]; then + last_prompt="" + first_seen=0 + sleep "$INTERVAL" + continue + fi + + if [[ "$current" != "$last_prompt" ]]; then + last_prompt="$current" + first_seen="$now" + sleep "$INTERVAL" + continue + fi + + age=$(( now - first_seen )) + if (( age >= STABLE_SECONDS )); then + echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2 + tmux send-keys -t "$TARGET" C-j + sleep 0.8 + tmux send-keys -t "$TARGET" C-m + sleep 2 + last_prompt="" + first_seen=0 + else + sleep "$INTERVAL" + fi +done diff --git a/tools/tmux/send-message.sh b/tools/tmux/send-message.sh new file mode 100755 index 00000000..56144d54 --- /dev/null +++ b/tools/tmux/send-message.sh @@ -0,0 +1,181 @@ +#!/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 <<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 diff --git a/tools/tmux/test-agent-send-socket-live.sh b/tools/tmux/test-agent-send-socket-live.sh new file mode 100755 index 00000000..db7ca64c --- /dev/null +++ b/tools/tmux/test-agent-send-socket-live.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# test-agent-send-socket-live.sh — S2 v2 INDEPENDENT contract validation (P5). +# +# Author: code-be-02 (fresh-seat; derives from the DOCUMENTED CONTRACT of PR +# #1466's socket resolution, deliberately not from test-send-message-socket.sh's +# structure — marcie's arms cover the implementation, these cover the contract). +# +# LIVE tmux fixtures on PRIVATE scratch sockets under a scratch TMUX_TMPDIR: +# the discovery loop reads ${TMUX_TMPDIR:-/tmp}/tmux-UID, so pointing +# TMUX_TMPDIR at a scratch dir makes production sockets (mosaic-fleet included) +# invisible to the tested process. Live tmux semantics ('=' targets, prefix +# matching, socket dirs) are exercised for real. +# +# Contract under test (agent-send.sh, canonical usage/EXIT CODES sections): +# C1 explicit -L wins over MOSAIC_TMUX_SOCKET; when pinned, discovery is +# skipped ENTIRELY (zero has-session probes, not merely zero hits) +# C2 MOSAIC_TMUX_SOCKET applies when no -L (local sends only) +# C3 session on multiple sockets with no -L/env -> refusal rc 4, message +# names the conflicting sockets and the -L hint; nothing sent +# C4 socket discovery reads TMUX_TMPDIR (never plain TMPDIR) +# C5 no unique hit -> default socket (sender invoked with no -L); +# remote (-H) sends do NO local discovery and do not forward the env +# C6 '=name' targets match exactly (no prefix); explicit '=X' passes +# through verbatim; compound 'sess:win.pane' pins the session component +# exact ('=sess:win.pane') +# +# Seams: AGENT_SEND_SENDER (intended stub seam) captures the sender args; +# a PATH-front tmux wrapper logs probes then execs the real binary; a PATH +# ssh stub captures the remote command line. Sabotage controls prove the +# arms bind: moved env-default -> C2 red; dropped exit-4 -> C3 red. +# Skip rc 77 without a tmux binary. Scratch servers killed via trap. +set -uo pipefail + +# NOTE: running a COPY of this suite from another directory resolves TOOL next +# to the COPY (readlink -f) — agent-send.sh must sit beside it, or set +# AGENT_SEND_TOOL_OVERRIDE. Debugging artifact of the here-relative design. +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +TOOL="${AGENT_SEND_TOOL_OVERRIDE:-$HERE/agent-send.sh}" +REAL_TMUX="$(command -v tmux 2>/dev/null || true)" +[ -n "$REAL_TMUX" ] || { echo "SKIP: no tmux binary (live fixtures impossible)"; exit 77; } + +SCRATCH="$(mktemp -d)"; SCRATCH="$(cd "$SCRATCH" && pwd)" # absolute (marcie input b) +DECOY="$(mktemp -d)"; DECOY="$(cd "$DECOY" && pwd)" +mkdir -p "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)" +# tmux refuses socket dirs with group/other bits ('unsafe permissions'): +# mktemp -d is 0700 but mkdir'd children default to umask (0755) — pin 0700 +chmod 700 "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)" +BIN="$SCRATCH/bin"; mkdir -p "$BIN" +CAP="$SCRATCH/sender-captured"; PROBES="$SCRATCH/tmux-probes"; SSHLOG="$SCRATCH/ssh-captured" +: > "$PROBES" + +# sender stub: capture args, "send" nothing (socket/target choice is the test) +printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" > %s\nexit 0\n' "$CAP" > "$BIN/sender-stub" +# tmux wrapper: log invocations, exec the real binary (live semantics) +printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> %s\nexec %s "$@"\n' "$PROBES" "$REAL_TMUX" > "$BIN/tmux" +# ssh stub: capture the remote command line; swallow stdin (the sender script) +printf '#!/usr/bin/env bash\nprintf "SSH:%%s\\n" "$*" >> %s\ncat > /dev/null\nexit 0\n' "$SSHLOG" > "$BIN/ssh" +chmod +x "$BIN/sender-stub" "$BIN/tmux" "$BIN/ssh" + +sock_pid_a=""; sock_pid_b="" +cleanup() { + [ -n "$sock_pid_a" ] && kill "$sock_pid_a" 2>/dev/null + for s in sockA sockB sockX decoyD; do + TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null + TMUX_TMPDIR="$DECOY" "$REAL_TMUX" -L "decoyD" kill-server 2>/dev/null + done + rm -rf "$SCRATCH" "$DECOY" +} +trap cleanup EXIT + +mk_server() { # $1 socket, $2 session-name, $3 dir (SCRATCH|DECOY) + TMUX_TMPDIR="${3:?}" "$REAL_TMUX" -L "$1" new-session -d -s "$2" 2>/dev/null +} + +run() { # passes through; caller sets env per arm + PATH="$BIN:$PATH" AGENT_SEND_SENDER="$BIN/sender-stub" MOSAIC_AGENT_NAME=code-be-02 \ + bash "$TOOL" -S test:src "$@" +} + +probe_count() { grep -c "has-session" "$PROBES" || true; } +cap_has() { grep -qF -e "$1" "$CAP" 2>/dev/null; } + +fail=0 +ck() { if [ "$2" -eq 0 ]; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi; } +probes_reset() { : > "$PROBES"; } +cap_reset() { rm -f "$CAP"; } + +# --- fixtures: sockA=t1, sockB=t1 (same name, two sockets), sockX=t1 ------------ +mk_server sockA t1 "$SCRATCH" +mk_server sockB t1 "$SCRATCH" +mk_server sockX t1 "$SCRATCH" + +# --- C1: explicit -L beats env; discovery skipped entirely ----------------------- +cap_reset; probes_reset +MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -L sockX -s t1 -m hi >/dev/null 2>&1 +cap_has "-L sockX" && ! grep -qF -- "-L envsock" "$CAP" +ck "C1: explicit -L wins over MOSAIC_TMUX_SOCKET (sender got -L sockX, not envsock)" $? +[ "$(probe_count)" -eq 0 ] +ck "C1: pinned -L skips discovery ENTIRELY (0 has-session probes, not 0 hits)" $? + +# --- C2: env applies when no -L; discovery skipped ------------------------------- +cap_reset; probes_reset +MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1 +cap_has "-L envsock" +ck "C2: MOSAIC_TMUX_SOCKET used when no -L (sender got -L envsock)" $? +[ "$(probe_count)" -eq 0 ] +ck "C2: env pin skips discovery (0 probes)" $? + +# --- C3: multi-socket ambiguity refuses rc 4, names sockets, sends nothing ------- +cap_reset; probes_reset +unset MOSAIC_TMUX_SOCKET +err="$(TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi 2>&1)"; rc=$? +[ "$rc" -eq 4 ] +ck "C3: ambiguous session (no -L/env) refuses with rc 4 (contract-stable)" $? +echo "$err" | grep -q "multiple sockets" && echo "$err" | grep -qF "sockA" && echo "$err" | grep -qF "sockB" +ck "C3: refusal message names BOTH conflicting sockets (sockA, sockB)" $? +echo "$err" | grep -qF -- "-L" +ck "C3: refusal message carries the -L disambiguation hint" $? +[ ! -f "$CAP" ] +ck "C3: nothing sent on refusal (sender never invoked)" $? + +# --- C4: discovery reads TMUX_TMPDIR, never plain TMPDIR ------------------------- +# decoy server lives under $DECOY/tmux-UID; TMPDIR points there, TMUX_TMPDIR at $SCRATCH +mk_server decoyD onlydecoy "$DECOY" +cap_reset; probes_reset +TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s onlydecoy -m hi >/dev/null 2>&1 +! cap_has "-L decoyD" +ck "C4: a TMPDIR-only socket is NOT consulted (no -L decoyD despite TMPDIR=decoy)" $? +# and a session unique in the TMUX_TMPDIR tree IS discovered there +cap_reset; probes_reset +TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s t1 -m hi >/dev/null 2>&1 +[ "$(probe_count)" -ge 2 ] +ck "C4: TMUX_TMPDIR tree probed when unpinned (discovery active; ambiguous name exercises the probe loop)" $? + +# --- C5: no unique hit -> default socket; remote sends: no local resolution ------ +# kill sockA/sockB/sockX so the scratch tree holds only decoy-free empties +for s in sockA sockB sockX; do TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null; done +cap_reset; probes_reset +TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1 +[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP" +ck "C5: zero unique hit -> default socket (sender invoked with NO -L)" $? +cap_reset; probes_reset +rm -f "$SSHLOG" +MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -H user@fakehost -s t1 -m hi >/dev/null 2>&1 +[ "$(probe_count)" -eq 0 ] +ck "C5: remote send does NO local discovery (0 probes with -H)" $? +[ -f "$SSHLOG" ] && ! grep -qF -- "-L envsock" "$SSHLOG" +ck "C5: MOSAIC_TMUX_SOCKET not forwarded to remote (ssh line carries no -L envsock)" $? + +# --- C6: '=name' exact matching; verbatim '=X'; compound pinning ----------------- +mk_server sockA t1old "$SCRATCH" # ONLY t1old exists now +cap_reset; probes_reset +TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1 +[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP" +ck "C6: t1 does NOT prefix-match t1old ('=t1' probe exact; zero hit -> default)" $? +grep -qF 'has-session -t =t1' "$PROBES" +ck "C6: discovery probes used the exact ('=t1') target form" $? +cap_reset +TMUX_TMPDIR="$SCRATCH" run -L sockA -s =t1old -m hi >/dev/null 2>&1 +grep -qF -- '-t =t1old' "$CAP" +ck "C6: already-exact '=X' input passes through verbatim" $? +cap_reset +TMUX_TMPDIR="$SCRATCH" run -L sockA -s t1old:0.0 -m hi >/dev/null 2>&1 +grep -qF -- '-t =t1old:0.0' "$CAP" +ck "C6: compound 'sess:win.pane' pins the session component exact (=sess:0.0)" $? + +# --- red controls: the arms bind -------------------------------------------------- +SAB="$SCRATCH/agent-send-sabotaged.sh" +# (a) move the env-default AFTER discovery: C2 must go red +python3 - "$TOOL" "$SAB" <<'PY' +import sys +src, dst = sys.argv[1], sys.argv[2] +s = open(src).read() +envblk = '''if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then + SOCKET_NAME="$MOSAIC_TMUX_SOCKET" +fi +''' +assert s.count(envblk) == 1 +s2 = s.replace(envblk, "") +anchor = 'socket_args=()' +assert s.count(anchor) == 1 +s2 = s2.replace(anchor, envblk + anchor) +assert s2 != s +open(dst, "w").write(s2) +PY +cap_reset; probes_reset +AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \ + bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1 +if cap_has "-L envsock"; then ck "red-a: sabotaged precedence (env moved after discovery) is CAUGHT by C2 shape" 0; else ck "red-a: sabotaged precedence CAUGHT (envsock lost -> discovered/default socket used)" 0; fi +# control validity: with sabotage, the SABOTAGED tool must NOT pin envsock with 0 probes +cap_reset; probes_reset +AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \ + bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1 +if [ "$(probe_count)" -gt 0 ] || ! cap_has "-L envsock"; then + ck "red-a validity: sabotage effective (behavior differs from clean tool)" 0 +else + ck "red-a validity: sabotage was a NO-OP — control invalid" 1 +fi +# (b) drop the exit 4: C3 must go red (send proceeds instead of refusing) +python3 - "$TOOL" "$SAB" <<'PY' +import sys +src, dst = sys.argv[1], sys.argv[2] +s = open(src).read() +old = " exit 4\n" +assert s.count(old) == 1 +s = s.replace(old, " :\n") +open(dst, "w").write(s) +PY +mk_server sockB t1old "$SCRATCH" # second socket carrying the same name -> ambiguity shape +cap_reset; probes_reset +unset MOSAIC_TMUX_SOCKET +AGENT_SEND_TOOL_OVERRIDE="$SAB" TMUX_TMPDIR="$SCRATCH" \ + bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1; src_rc=$? +TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L sockB kill-server 2>/dev/null +if [ "$src_rc" -eq 4 ]; then + ck "red-b: sabotaged refusal still exits 4 — sabotage was a NO-OP, control invalid" 1 +else + ck "red-b: sabotage effective (exit 4 dropped; rc=$src_rc) — C3 pins what the clean tool restores" 0 +fi + +# --- verdict ----------------------------------------------------------------------- +if [ "$fail" -eq 0 ]; then + echo "agent-send socket contract (live): all arms OK (C1-C6 + both red controls)" + exit 0 +fi +echo "agent-send socket contract (live): FAILURES above" +exit 1 diff --git a/tools/tmux/test-send-message-socket.sh b/tools/tmux/test-send-message-socket.sh new file mode 100755 index 00000000..8e64b983 --- /dev/null +++ b/tools/tmux/test-send-message-socket.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# Live tmux semantics on private sockets only. A caller may run this suite from +# inside mosaic-fleet, where inherited TMUX otherwise overrides TMUX_TMPDIR for +# every bare tmux command. Clear pane context and keep both the named and +# default fixtures below one scratch TMUX_TMPDIR. +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) +SEND_MESSAGE="$SCRIPT_DIR/send-message.sh" +AGENT_SEND="$SCRIPT_DIR/agent-send.sh" +SOCKET="mosaic-test-$RANDOM-$$" +TARGET="target-$RANDOM" +DEFAULT_TARGET="default-target-$RANDOM" +TMPDIR=$(mktemp -d) +TEST_TMUX_TMPDIR="$TMPDIR/tmux" +mkdir -p "$TEST_TMUX_TMPDIR" +chmod 700 "$TEST_TMUX_TMPDIR" +unset TMUX TMUX_PANE +export TMUX_TMPDIR="$TEST_TMUX_TMPDIR" +ART_OUT=$(mktemp) +AMB_OUT=$(mktemp) +AMB_ERR=$(mktemp) +A2_OUT=$(mktemp) +A2_ERR=$(mktemp) +UNIQ_OUT=$(mktemp) +UNIQ_ERR=$(mktemp) +TWIN="twin-$RANDOM-$$" +cleanup() { + local test_rc=$? residue=0 + trap - EXIT + env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \ + tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true + env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \ + tmux -L default kill-server >/dev/null 2>&1 || true + sleep 0.2 + if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \ + tmux -L "$SOCKET" list-sessions >/dev/null 2>&1; then + echo "FAIL: named scratch server still answering during cleanup" >&2 + residue=1 + fi + if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \ + tmux -L default list-sessions >/dev/null 2>&1; then + echo "FAIL: default scratch server still answering during cleanup" >&2 + residue=1 + fi + rm -rf "$TMPDIR" "$ART_OUT" "$AMB_OUT" "$AMB_ERR" "$A2_OUT" "$A2_ERR" "$UNIQ_OUT" "$UNIQ_ERR" + if [ "$test_rc" -ne 0 ]; then + exit "$test_rc" + fi + exit "$residue" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +require_tmux() { + command -v tmux >/dev/null 2>&1 || fail "tmux is required" +} + +capture_named() { + tmux -L "$SOCKET" capture-pane -t "=$TARGET:0.0" -p +} + +capture_default() { + tmux capture-pane -t "=$DEFAULT_TARGET:0.0" -p +} + +require_tmux + +tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' +tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' + +"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out +sleep 0.2 +named_pane="$(capture_named)" || fail "could not capture named socket pane" +grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket" +default_pane="$(capture_default)" || fail "could not capture default socket pane" +if grep -qF "named socket hello" <<<"$default_pane"; then + fail "send-message.sh leaked named-socket message to default tmux server" +fi + +"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out +sleep 0.2 +named_pane="$(capture_named)" || fail "could not capture named socket pane" +grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble" +grep -qF "agent socket hello" <<<"$named_pane" || fail "agent-send.sh did not deliver to named socket" +default_pane="$(capture_default)" || fail "could not capture default socket pane" +if grep -qF "agent socket hello" <<<"$default_pane"; then + fail "agent-send.sh leaked named-socket message to default tmux server" +fi + +# Concurrency: parallel senders on one server must not cross-deliver or drop. +# Locks the unique-per-invocation paste buffer (a fixed buffer name raced: +# load overwrote load, -d deleted underneath — messages swapped between panes). +CONC_N=5 +for i in $(seq 1 "$CONC_N"); do + tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' +done +pids=() +for i in $(seq 1 "$CONC_N"); do + "$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null & + pids+=($!) +done +for pid in "${pids[@]}"; do + wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero" +done +sleep 0.2 +for i in $(seq 1 "$CONC_N"); do + pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p) + grep -qF "CONCPAYLOAD-${i}-END" <<<"$pane" \ + || fail "concurrent send dropped payload for pane conc-$i" + for j in $(seq 1 "$CONC_N"); do + [ "$j" = "$i" ] && continue + if grep -qF "CONCPAYLOAD-${j}-END" <<<"$pane"; then + fail "concurrent send cross-delivered payload $j to pane conc-$i" + fi + done +done + +# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured +# defect: tasking sends without -L landed in a stale default-socket twin of +# the target seat; rc 0 reported honest delivery to the wrong pane. + +# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both. +tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' +tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' +amb_rc=0 +env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$? +[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))" +grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list" +grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket" +tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true +tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true + +# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous: +# the env var disambiguates by precedence (codex PR #1466 blocker). +tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' +tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' +a2_rc=0 +MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$? +[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))" +sleep 0.2 +a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)" +grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket" +a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true +if grep -qF "env disambiguated" <<<"$a2_default"; then + fail "env disambiguation cross-delivered to the default-socket twin" +fi +tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true +tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true + +# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket +# and deliver there. +# Arm A3: prefix matching must not produce false socket hits (codex PR +# #1466): a session named TWIN-old must not count as a hit for target +# TWIN (tmux target syntax prefix-matches without '='). +PSEUDO="${TWIN}-old" +tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' +A3_ERR=$(mktemp) +a3_rc=0 +env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$? +# TWIN exists nowhere (both twins killed after arm A2); with '=' the +# PSEUDO session is not a hit, so the sender must fail target-not-found +# (rc 1) instead of delivering into the prefix-named session. +[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))" +if tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null | grep -qF "prefix trap"; then + fail "delivery landed in the prefix-named session (false socket hit)" +fi +tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true +rm -f "$A3_ERR" + +# Arm A4: compound targets pin the SESSION component exact (codex PR +# #1466): 'TWIN:0.0' must not resolve into the prefix-named session. +PSEUDO2="${TWIN}-old" +tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' +A4_ERR=$(mktemp) +a4_rc=0 +env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$? +[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))" +if tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null | grep -qF "compound trap"; then + fail "compound delivery landed in the prefix-named session" +fi +tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true +rm -f "$A4_ERR" + +uniq_rc=0 +env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$? +[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))" +sleep 0.2 +auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)" +grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane" +default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)" +if grep -qF "autoresolved hello" <<<"$default_pane2"; then + fail "auto-resolution cross-delivered to the default socket pane" +fi + +echo "ok - named tmux socket send tools" diff --git a/tools/tmux/test-send-message-verdict.sh b/tools/tmux/test-send-message-verdict.sh new file mode 100755 index 00000000..1186c657 --- /dev/null +++ b/tools/tmux/test-send-message-verdict.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# test-send-message-verdict.sh — locks the fail-loud verdict logic of the patched +# send-message.sh against three real tmux-pane fixtures on a throwaway socket: +# +# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter +# (text scrolls to history, box clears) => exit 0 "✓ delivered". +# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact +# historical FALSE POSITIVE: pre-patch it printed "✓ delivered" +# exit 0; post-patch it MUST fail loud (exit 2, stderr +# "could not confirm submission"). +# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the +# input line) => exit 2, stderr "unsubmitted draft". +# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph +# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362 +# the glyph probe could not see this box at all, so EVERY send +# to such a pane reported "may be UNDELIVERED" while landing. +# 5. DRAFT — the same glyphless box, holding our tail across every flush +# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this +# also reported unconfirmed, so the true state was invisible. +set -uo pipefail + +HERE=$(cd -- "$(dirname -- "$0")" && pwd) +SEND="$HERE/send-message.sh" +SOCKET="verdict-test-$RANDOM-$$" +TMP=$(mktemp -d) +trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; } + +command -v tmux >/dev/null 2>&1 || { echo "tmux required" >&2; exit 1; } + +# --- Fixture 1: a submitting REPL with a ❯ prompt box (interactive bash, glyph PS1). +# readline strips bracketed-paste markers just like a real agent REPL; Enter +# executes (text -> scrollback), leaving a fresh empty `❯ ` box. +tmux -L "$SOCKET" new-session -d -s repl -c "$TMP" \ + 'PS1="❯ " exec bash --noprofile --norc -i' +sleep 0.3 +out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$? +if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then + ok "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" +else + no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]" +fi + +# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this +# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2. +tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \ + 'PS1="sh-noglyph$ " exec bash --noprofile --norc -i' +sleep 0.3 +if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then + no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])" +else + rc=$? + if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then + ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)" + else + no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]" + fi +fi + +# --- Fixture 3: a ❯ box that never submits (sleep ignores stdin; TTY echo keeps the +# pasted tail sitting on the ❯ line) => draft => exit 2. +tmux -L "$SOCKET" new-session -d -s draft -c "$TMP" \ + 'printf "❯ "; exec sleep infinity' +sleep 0.3 +if out=$("$SEND" -L "$SOCKET" -t "=draft" -r 1 -m "verdict fixture three stuck unsubmitted draft" 2>"$TMP/e3"); then + no "draft: unsubmitted message must NOT report success" "expected exit 2, got 0 (out=[$out])" +else + rc=$? + if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e3"; then + ok "draft: stuck ❯-line message => exit 2 + 'unsubmitted draft'" + else + no "draft: stuck ❯-line message => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e3")]" + fi +fi + +# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the +# text between them and NO prompt glyph anywhere, so the glyph probe alone can +# never locate it and every send reports "may be UNDELIVERED" (#1362). The +# renderer below is the shape, not the runtime: MODE=clear submits (box empties), +# MODE=keep leaves the text sitting in the box. +cat > "$TMP/pibox.sh" <<'PIBOX' +#!/usr/bin/env bash +MODE=${1:-clear} +RULE=$(printf '─%.0s' $(seq 1 60)) +buf="" +draw() { + printf '\033[H\033[2J' + printf 'fixture output line\n\n' + printf '%s\n' "$RULE" + printf '%s\n' "$buf" + printf '%s\n' "$RULE" + printf '~/fixture (main)\n' + printf 'tok 0 model fixture\n' +} +draw +while IFS= read -r line; do + # keep: hold the tail across every flush Enter, which is what a stuck draft does. + if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi + draw +done +PIBOX +chmod +x "$TMP/pibox.sh" + +tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear" +sleep 0.3 +out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$? +if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then + ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" +else + no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]" +fi + +tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep" +sleep 0.3 +if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then + no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])" +else + rc=$? + if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then + ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'" + else + no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]" + fi +fi + +echo "---" +echo "PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ]