- 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
This commit is contained in:
+10
-5
@@ -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" <<EOF
|
||||
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"m","adapter":"mock"}}
|
||||
EOF
|
||||
REL="$(tr -d '[:space:]' < RELEASE)"
|
||||
mkdir -p "$SANDBOX/data/state"
|
||||
printf '{"pointerVersion":1,"release":"%s","imageTag":"mosaic-poc-agent:0.84.4-r%s","activatedAt":"2026-01-01T00:00:00Z"}\n' "$REL" "$REL" > "$SANDBOX/data/state/active.json"
|
||||
scripts/agent.sh skillseat </dev/null >"$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 </dev/null >"$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"
|
||||
|
||||
@@ -29,6 +29,10 @@ recurring failures this skill exists to prevent.
|
||||
- node scripts/mosaic-task.mjs retry <runId> 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 <name> launches an interactive seat. It onboards the user
|
||||
|
||||
@@ -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:
|
||||
|
||||
```
|
||||
[<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
```
|
||||
|
||||
- `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 `[<dst> -> <src>] ...`.
|
||||
|
||||
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 <dst_session> -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 <dst_session> -m "message"
|
||||
|
||||
# From a file / stdin
|
||||
agent-send.sh -H user@host -s <dst_session> -f msg.txt
|
||||
echo "msg" | agent-send.sh -s <dst_session>
|
||||
```
|
||||
|
||||
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.
|
||||
Executable
+229
@@ -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:
|
||||
#
|
||||
# [<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
#
|
||||
# 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: [<dst> -> <src>] ... (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 <dst_session> -m "message" # local target
|
||||
# agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
|
||||
# agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -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 <dst_session>
|
||||
#
|
||||
# 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=<CLASS>` token INSIDE the bracket:
|
||||
# [<src> -> <dst> class=terminal-log] <message>
|
||||
# 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 "<host>:<session>" (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 <socket> 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
|
||||
Executable
+182
@@ -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 <c> => ` class=<c>` token emitted inside the bracket.
|
||||
# 3. --class=<c> (equals form) parses identically to the space form.
|
||||
# 4. -C <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 ]
|
||||
Executable
+80
@@ -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
|
||||
Executable
+181
@@ -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 <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
|
||||
Executable
+227
@@ -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
|
||||
Executable
+200
@@ -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"
|
||||
Executable
+131
@@ -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 ]
|
||||
Reference in New Issue
Block a user