From 80b421a21f1f6269dd098856933160236e40c833 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 17 May 2026 22:42:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(@scripts):=20=E2=9C=A8=20add=20broadcast?= =?UTF-8?q?=20session=20prompt=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- README.md | 10 +++ bin/rclaude | 111 ++++++++++++++++++++++++++++++++++ tests/test_rclaude_helpers.sh | 74 +++++++++++++++++++++++ 3 files changed, 195 insertions(+) diff --git a/README.md b/README.md index e53aed1..3894ef0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,15 @@ a detached tmux session on the remote so the work survives the SSH drop. for terminal/network resilience). Defaults to `--dangerously-skip-permissions`; override with `RCLAUDE_PERMS=default`. +- **`bin/rclaude send (--all|--host |--match ) [--yes] -- `** — + 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 `` cannot be interpreted by the target shell. + ## Install 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 ` | | One-off command (build, test, query) | `remote-run ""` | | Claude Code session on a remote | `rclaude [dir]` | +| Broadcast a prompt to running Claudes | `rclaude send --all --yes -- ""` | | Long-running job (>1h, must survive reboot)| `systemd --user` unit on the remote, not ssh | ## Per-host shims (optional) diff --git a/bin/rclaude b/bin/rclaude index f9f6187..07b0781 100755 --- a/bin/rclaude +++ b/bin/rclaude @@ -28,6 +28,10 @@ # rclaude list sessions # tmux + per-session disk view (uuid + snippet) # rclaude triage [--limit N] [--refresh] # Haiku-powered ranked summary of recent sessions # # (uses claude-code-batch-sdk + content cache) +# rclaude send (--all|--host |--match ) [--yes] -- +# # 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, # # deduped by uuid across hosts) # rclaude resume [pattern] --on # mirror picked session onto (rewrites cwd via @@ -683,6 +687,112 @@ cmd_list() { 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 -- +# rclaude send --host -- +# rclaude send --match -- +# rclaude send ... --yes -- # actually send (default: preview) +# rclaude send ... --dry-run -- # 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 | --match ) [--dry-run] [--yes] -- + --all target every live claude-* tmux session on scan_hosts + --host target every claude-* session on a specific host + --match 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 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: # - 1 match → attach directly # - 2+ matches → single-key picker (1-9 then a-z, max 35) @@ -1249,6 +1359,7 @@ case ${1:-} in list) shift; cmd_list "$@"; exit ;; resume) shift; cmd_resume "$@"; exit ;; triage) shift; cmd_triage "$@"; exit ;; + send) shift; cmd_send "$@"; exit ;; setup|install) shift; cmd_setup "$@"; exit ;; voice) shift; cmd_voice "$@"; exit ;; -v|--version) cmd_version; exit ;; diff --git a/tests/test_rclaude_helpers.sh b/tests/test_rclaude_helpers.sh index 0614e81..e46dbf6 100644 --- a/tests/test_rclaude_helpers.sh +++ b/tests/test_rclaude_helpers.sh @@ -95,3 +95,77 @@ test_caller_hostname_default_adds_lan() { _out=$(unset RCLAUDE_BACK_HOST; caller_hostname) 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" +}