feat(@scripts): add broadcast session prompt tool

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-17 22:42:01 -07:00
parent 6892585a2d
commit 80b421a21f
3 changed files with 195 additions and 0 deletions

View file

@ -29,6 +29,15 @@ a detached tmux session on the remote so the work survives the SSH drop.
for terminal/network resilience). Defaults to for terminal/network resilience). Defaults to
`--dangerously-skip-permissions`; override with `RCLAUDE_PERMS=default`. `--dangerously-skip-permissions`; override with `RCLAUDE_PERMS=default`.
- **`bin/rclaude send (--all|--host <h>|--match <pat>) [--yes] -- <text...>`** —
Broadcast a prompt to live `claude-*` tmux sessions across `scan_hosts`.
Dry-run by default: prints the resolved target list and exits. Pass `--yes`
to actually deliver. Selectors are mutually exclusive — `--all` hits every
live session, `--host` scopes to one host, `--match` substring-matches the
session name (which embeds a slugified cwd via `claude_slug()`) or the cwd
column. Delivery uses `tmux send-keys -l` (literal mode) so control
sequences in `<text>` cannot be interpreted by the target shell.
## Install ## Install
On every host that should have these on `$PATH`: On every host that should have these on `$PATH`:
@ -48,6 +57,7 @@ via plain `git pull` — symlinks track the repo automatically.
| Interactive shell on a remote | `tssh <host>` | | Interactive shell on a remote | `tssh <host>` |
| One-off command (build, test, query) | `remote-run <host> "<cmd>"` | | One-off command (build, test, query) | `remote-run <host> "<cmd>"` |
| Claude Code session on a remote | `rclaude <host> [dir]` | | Claude Code session on a remote | `rclaude <host> [dir]` |
| Broadcast a prompt to running Claudes | `rclaude send --all --yes -- "<text>"` |
| Long-running job (>1h, must survive reboot)| `systemd --user` unit on the remote, not ssh | | Long-running job (>1h, must survive reboot)| `systemd --user` unit on the remote, not ssh |
## Per-host shims (optional) ## Per-host shims (optional)

View file

@ -28,6 +28,10 @@
# rclaude list sessions # tmux + per-session disk view (uuid + snippet) # rclaude list sessions # tmux + per-session disk view (uuid + snippet)
# rclaude triage [--limit N] [--refresh] # Haiku-powered ranked summary of recent sessions # rclaude triage [--limit N] [--refresh] # Haiku-powered ranked summary of recent sessions
# # (uses claude-code-batch-sdk + content cache) # # (uses claude-code-batch-sdk + content cache)
# rclaude send (--all|--host <h>|--match <pat>) [--yes] -- <text...>
# # broadcast a prompt to live claude-* tmux
# # sessions across scan_hosts. Dry-run by
# # default; --yes to actually deliver.
# rclaude resume # picker: live tmux + most-recent disk (--- separator, # rclaude resume # picker: live tmux + most-recent disk (--- separator,
# # deduped by uuid across hosts) # # deduped by uuid across hosts)
# rclaude resume [pattern] --on <host> # mirror picked session onto <host> (rewrites cwd via # rclaude resume [pattern] --on <host> # mirror picked session onto <host> (rewrites cwd via
@ -683,6 +687,112 @@ cmd_list() {
done done
} }
# Broadcast a prompt to one, some, or all live claude-* tmux sessions across
# scan_hosts(). Dry-run by default — the user must pass --yes to actually
# deliver, since fanning text into every running agent is high-blast-radius
# and a typo'd selector could mis-target.
#
# Usage:
# rclaude send --all -- <text...>
# rclaude send --host <h> -- <text...>
# rclaude send --match <pat> -- <text...>
# rclaude send ... --yes -- <text...> # actually send (default: preview)
# rclaude send ... --dry-run -- <text...> # explicit preview (overrides --yes)
cmd_send() {
_sel=""; _pat=""; _dry=0; _yes=0
while [ $# -gt 0 ]; do
case $1 in
--all) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; }
_sel=all; shift ;;
--host) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; }
shift; _sel=host; _pat=${1:-}; [ -z "$_pat" ] && { echo "rclaude send: --host requires a value" >&2; exit 2; }; shift ;;
--host=*) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; }
_sel=host; _pat=${1#--host=}; shift ;;
--match) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; }
shift; _sel=match; _pat=${1:-}; [ -z "$_pat" ] && { echo "rclaude send: --match requires a value" >&2; exit 2; }; shift ;;
--match=*) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; }
_sel=match; _pat=${1#--match=}; shift ;;
--dry-run) _dry=1; shift ;;
--yes) _yes=1; shift ;;
--) shift; break ;;
-*) echo "rclaude send: unknown flag: $1" >&2; exit 2 ;;
*) break ;;
esac
done
if [ -z "$_sel" ]; then
cat >&2 <<'EOF'
usage: rclaude send (--all | --host <h> | --match <pat>) [--dry-run] [--yes] -- <text...>
--all target every live claude-* tmux session on scan_hosts
--host <h> target every claude-* session on a specific host
--match <pat> substring match against tmux session name (which embeds slug)
--dry-run preview targets and exit (default unless --yes is passed)
--yes actually deliver (still prints preview first)
EOF
exit 2
fi
_text=$*
if [ -z "$_text" ]; then
echo "rclaude send: missing prompt text (after --)" >&2
exit 2
fi
# Gather candidate rows across all hosts, then filter.
_rows=$(scan_hosts | while IFS= read -r _h; do list_tmux_on "$_h"; done \
| filter_targets "$_sel" "$_pat")
if [ -z "$_rows" ]; then
echo "rclaude send: no matching sessions" >&2
exit 2
fi
# Preview is always shown, before any delivery.
echo "Targets:"
printf '%s\n' "$_rows" | awk -F '\t' '{ printf " %-12s %s\n", $1, $3 }'
if [ "$_dry" = 1 ] || [ "$_yes" != 1 ]; then
echo "(dry-run — pass --yes to send)"
exit 0
fi
_quoted_text=$(sh_quote "$_text")
_total=0; _failed=0
# Use a tempfile to drive the loop so the counters survive (a piped
# `while` runs in a subshell under POSIX sh and would lose mutations).
_rowfile=$(mktemp /tmp/rclaude-send.XXXXXX 2>/dev/null || echo /tmp/rclaude-send.$$)
printf '%s\n' "$_rows" > "$_rowfile"
while IFS=$(printf '\t') read -r _host _kind _sess _detail; do
[ -z "$_sess" ] && continue
_total=$((_total + 1))
if is_local "$_host"; then
if tmux send-keys -t "$_sess" -l -- "$_text" 2>/dev/null \
&& tmux send-keys -t "$_sess" Enter 2>/dev/null; then
:
else
_failed=$((_failed + 1))
echo "rclaude send: failed on $_host:$_sess" >&2
fi
else
_q_sess=$(sh_quote "$_sess")
if ssh -o BatchMode=yes -o ConnectTimeout=3 "$_host" \
"tmux send-keys -t $_q_sess -l -- $_quoted_text && tmux send-keys -t $_q_sess Enter" \
</dev/null >/dev/null 2>&1; then
:
else
_failed=$((_failed + 1))
echo "rclaude send: failed on $_host:$_sess" >&2
fi
fi
done < "$_rowfile"
rm -f "$_rowfile"
_sent=$((_total - _failed))
echo "Sent to $_sent of $_total session(s)."
# Exit non-zero only if *every* delivery failed.
[ "$_sent" -gt 0 ]
}
# Resume strategy: # Resume strategy:
# - 1 match → attach directly # - 1 match → attach directly
# - 2+ matches → single-key picker (1-9 then a-z, max 35) # - 2+ matches → single-key picker (1-9 then a-z, max 35)
@ -1249,6 +1359,7 @@ case ${1:-} in
list) shift; cmd_list "$@"; exit ;; list) shift; cmd_list "$@"; exit ;;
resume) shift; cmd_resume "$@"; exit ;; resume) shift; cmd_resume "$@"; exit ;;
triage) shift; cmd_triage "$@"; exit ;; triage) shift; cmd_triage "$@"; exit ;;
send) shift; cmd_send "$@"; exit ;;
setup|install) shift; cmd_setup "$@"; exit ;; setup|install) shift; cmd_setup "$@"; exit ;;
voice) shift; cmd_voice "$@"; exit ;; voice) shift; cmd_voice "$@"; exit ;;
-v|--version) cmd_version; exit ;; -v|--version) cmd_version; exit ;;

View file

@ -95,3 +95,77 @@ test_caller_hostname_default_adds_lan() {
_out=$(unset RCLAUDE_BACK_HOST; caller_hostname) _out=$(unset RCLAUDE_BACK_HOST; caller_hostname)
assert_contains "$_out" "." "caller_hostname output should be dotted" assert_contains "$_out" "." "caller_hostname output should be dotted"
} }
# ---------------------------------------------------------------------------
# sh_quote — POSIX single-quote escape for safe remote shell interpolation
# ---------------------------------------------------------------------------
test_sh_quote_empty() {
assert_eq "''" "$(sh_quote "")"
}
test_sh_quote_plain() {
assert_eq "'hello'" "$(sh_quote "hello")"
}
test_sh_quote_spaces() {
assert_eq "'hello world'" "$(sh_quote "hello world")"
}
test_sh_quote_dollar_passthrough() {
# `$HOME` inside single quotes must remain literal — that's the whole point.
assert_eq "'\$HOME'" "$(sh_quote '$HOME')"
}
test_sh_quote_embedded_single_quote() {
# The classic '\'' escape: close, escape, reopen.
assert_eq "'it'\\''s'" "$(sh_quote "it's")"
}
# ---------------------------------------------------------------------------
# filter_targets — selector-based row filter for `rclaude send`
# ---------------------------------------------------------------------------
# Two tmux rows + one disk row. Disk rows must always be dropped regardless
# of selector (can't send-keys to on-disk sessions). Col 5 is populated on
# one row to exercise the cwd-match branch.
_targets_fixture() {
printf 'local\ttmux\tclaude-natalie-lilith-123\t1 windows\n'
printf 'apricot\ttmux\tclaude-natalie-scripts-456\t2 windows\t/home/natalie/Code/@scripts\n'
printf 'apricot\tdisk\t/home/natalie/Code/@projects/@lilith\tsessions=3\n'
}
test_filter_targets_all_drops_disk() {
_out=$(_targets_fixture | filter_targets all "")
_count=$(printf '%s\n' "$_out" | grep -c '^' || true)
assert_eq "2" "$_count" "all selector keeps both tmux rows, drops disk"
}
test_filter_targets_host_exact() {
_out=$(_targets_fixture | filter_targets host apricot)
_count=$(printf '%s\n' "$_out" | grep -c '^' || true)
assert_eq "1" "$_count" "host=apricot matches one row" || return 1
assert_contains "$_out" "claude-natalie-scripts-456"
}
test_filter_targets_match_session_name() {
_out=$(_targets_fixture | filter_targets match lilith)
assert_contains "$_out" "claude-natalie-lilith-123" "lilith matches session name"
}
test_filter_targets_match_cwd_column() {
# `@scripts` only appears in col 5 of the apricot row (the session-name
# slug strips @ → -). The row must still match via the col-5 cwd branch.
_out=$(_targets_fixture | filter_targets match "@scripts")
assert_contains "$_out" "claude-natalie-scripts-456" "@scripts matches via cwd col"
}
test_filter_targets_match_no_match() {
_out=$(_targets_fixture | filter_targets match nonexistent-pattern-xyz)
assert_eq "" "$_out" "no match → empty output"
}
test_filter_targets_empty_input() {
_out=$(printf '' | filter_targets all "")
assert_eq "" "$_out" "empty input → empty output"
}