feat(@scripts): ✨ add broadcast session prompt tool
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
6892585a2d
commit
80b421a21f
3 changed files with 195 additions and 0 deletions
10
README.md
10
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
|
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)
|
||||||
|
|
|
||||||
111
bin/rclaude
111
bin/rclaude
|
|
@ -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 ;;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue