mirror of
https://github.com/tradecatlabs/vibe-coding-cn.git
synced 2026-08-02 05:37:45 +00:00
725 lines
22 KiB
Bash
725 lines
22 KiB
Bash
#!/usr/bin/env bash
|
|
# auto-tmux: safe tmux automation wrapper for Codex/AI operator workflows.
|
|
set -euo pipefail
|
|
|
|
PROG="${0##*/}"
|
|
DEFAULT_LINES=120
|
|
DEFAULT_RESCUE_PATTERN='(\(y/n\)|\[y/N\]|\[Y/n\]|continue\?|proceed\?|confirm|确认|是否继续)'
|
|
DANGEROUS_PATTERN='(^|[[:space:];|&])(rm[[:space:]]+-rf|mkfs|dd[[:space:]]+if=|shutdown|reboot|halt|poweroff|tmux[[:space:]]+kill-server|git[[:space:]]+reset[[:space:]]+--hard|git[[:space:]]+clean[[:space:]]+-fd|DROP[[:space:]]+(DATABASE|TABLE))'
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
auto-tmux: safe tmux automation wrapper
|
|
|
|
Usage:
|
|
auto-tmux.sh help
|
|
auto-tmux.sh doctor [--session NAME]
|
|
auto-tmux.sh topology [--session NAME]
|
|
auto-tmux.sh inspect -t TARGET [-n LINES] [--no-redact]
|
|
auto-tmux.sh capture -t TARGET [-n LINES] [--save FILE] [--no-redact]
|
|
auto-tmux.sh send -t TARGET (--text TEXT | --key KEY) [--enter] [--clear] [--force] [--dry-run]
|
|
auto-tmux.sh paste -t TARGET --file FILE [--enter] [--clear] [--force] [--dry-run]
|
|
auto-tmux.sh broadcast --session NAME (--text TEXT | --key KEY) [--enter] [--clear] [--force] [--dry-run]
|
|
auto-tmux.sh rescue -t TARGET [--pattern REGEX] [--reply TEXT] [-n LINES] [--force] [--dry-run]
|
|
auto-tmux.sh scan [--session NAME] [-n LINES] [--pattern REGEX] [--rescue] [--reply TEXT] [--save-dir DIR]
|
|
auto-tmux.sh record start -t TARGET [--dir DIR]
|
|
auto-tmux.sh record stop -t TARGET
|
|
auto-tmux.sh snapshot [--session NAME] [--dir DIR] [-n LINES]
|
|
auto-tmux.sh hub [--session NAME] [--workers N] [--cmd CMD] [--commander-cmd CMD] [--attach]
|
|
auto-tmux.sh cleanup --session NAME [--dry-run] [--force]
|
|
auto-tmux.sh wait -t TARGET --pattern REGEX [--timeout SEC] [--interval SEC] [-n LINES]
|
|
|
|
Target format:
|
|
<session>:<window>.<pane>
|
|
|
|
Examples:
|
|
auto-tmux.sh doctor --session ai-hub
|
|
auto-tmux.sh topology
|
|
auto-tmux.sh inspect -t <target-from-topology> -n 40
|
|
auto-tmux.sh capture -t <target-from-topology> -n 80
|
|
auto-tmux.sh send -t <target-from-topology> --text "make test" --enter
|
|
auto-tmux.sh paste -t <target-from-topology> --file /tmp/prompt.md --enter --dry-run
|
|
auto-tmux.sh broadcast --session ai-hub --text "pwd" --enter --dry-run
|
|
auto-tmux.sh rescue -t <target-from-topology> --pattern "(y/n)" --reply y
|
|
auto-tmux.sh scan --session ai-hub --pattern "ERROR|Traceback"
|
|
auto-tmux.sh snapshot --session ai-hub --dir /tmp/auto-tmux-snapshot
|
|
auto-tmux.sh hub --session ai-hub --workers 3 --cmd "codex"
|
|
auto-tmux.sh cleanup --session ai-hub --dry-run
|
|
EOF
|
|
}
|
|
|
|
die() {
|
|
printf 'error: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
warn() {
|
|
printf 'warn: %s\n' "$*" >&2
|
|
}
|
|
|
|
status_line() {
|
|
local status="$1"
|
|
local message="$2"
|
|
printf '[%s] %s\n' "$status" "$message"
|
|
}
|
|
|
|
require_tmux() {
|
|
command -v tmux >/dev/null 2>&1 || die "tmux is not installed or not in PATH"
|
|
tmux list-sessions >/dev/null 2>&1 || die "no tmux server/session found"
|
|
}
|
|
|
|
target_exists() {
|
|
local target="$1"
|
|
tmux display-message -pt "$target" '#{pane_id}' >/dev/null 2>&1
|
|
}
|
|
|
|
require_target() {
|
|
local target="$1"
|
|
[[ -n "$target" ]] || die "missing -t TARGET"
|
|
target_exists "$target" || die "tmux target not found: $target"
|
|
}
|
|
|
|
redact() {
|
|
sed -E \
|
|
-e 's/(OPENAI_API_KEY|ANTHROPIC_API_KEY|GITHUB_TOKEN|GH_TOKEN|API_KEY|TOKEN|PASSWORD|SECRET)=([^[:space:]]+)/\1=<redacted>/g' \
|
|
-e 's/(Bearer[[:space:]]+)[A-Za-z0-9._~+\/=-]+/\1<redacted>/g' \
|
|
-e 's/(sk-[A-Za-z0-9_-]{12,})/<redacted-openai-key>/g'
|
|
}
|
|
|
|
capture_raw() {
|
|
local target="$1"
|
|
local lines="$2"
|
|
tmux capture-pane -t "$target" -p -S "-$lines"
|
|
}
|
|
|
|
capture_print() {
|
|
local target="$1"
|
|
local lines="$2"
|
|
local no_redact="$3"
|
|
if [[ "$no_redact" == "1" ]]; then
|
|
capture_raw "$target" "$lines"
|
|
else
|
|
capture_raw "$target" "$lines" | redact
|
|
fi
|
|
}
|
|
|
|
is_dangerous_text() {
|
|
local text="$1"
|
|
echo "$text" | grep -Eiq "$DANGEROUS_PATTERN"
|
|
}
|
|
|
|
cancel_copy_mode_if_needed() {
|
|
local target="$1"
|
|
local in_mode
|
|
in_mode="$(tmux display-message -pt "$target" '#{pane_in_mode}' 2>/dev/null || echo 0)"
|
|
if [[ "$in_mode" == "1" ]]; then
|
|
tmux send-keys -t "$target" -X cancel
|
|
fi
|
|
}
|
|
|
|
pane_list() {
|
|
local session="$1"
|
|
if [[ -n "$session" ]]; then
|
|
tmux list-panes -a -F '#S:#I.#P' | awk -F: -v s="$session" '$1 == s {print}'
|
|
else
|
|
tmux list-panes -a -F '#S:#I.#P'
|
|
fi
|
|
}
|
|
|
|
cmd_doctor() {
|
|
local session=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--session) session="${2:-}"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown doctor option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
local failures=0
|
|
local script_dir
|
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
if command -v tmux >/dev/null 2>&1; then
|
|
status_line "OK" "tmux found: $(tmux -V)"
|
|
else
|
|
status_line "FAIL" "tmux not found in PATH"
|
|
failures=$((failures + 1))
|
|
fi
|
|
|
|
if tmux list-sessions >/dev/null 2>&1; then
|
|
status_line "OK" "tmux server is reachable"
|
|
else
|
|
status_line "WARN" "tmux server has no reachable session yet"
|
|
fi
|
|
|
|
for script in auto-tmux.sh swarm-state.sh swarm-brief.sh swarm-watch.sh swarm-archive.sh swarm-board.sh swarm-deps-graph.sh swarm-export.sh swarm-timeline.sh swarm-blockers.sh swarm-results.sh swarm-report-pack.sh swarm-assign.sh swarm-health.sh remote-readonly.sh record-summary.sh check-jsonl.sh review-checklist.sh verify-report-pack.sh incident-report.sh safety-check.sh render-swarm-prompt.sh swarm-dispatch.sh auto-tmux-smoke-test.sh validate-auto-tmux.sh; do
|
|
if [[ -x "$script_dir/$script" ]]; then
|
|
status_line "OK" "script executable: $script"
|
|
else
|
|
status_line "FAIL" "script missing or not executable: $script"
|
|
failures=$((failures + 1))
|
|
fi
|
|
done
|
|
|
|
local root
|
|
root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)"
|
|
if [[ -n "$root" ]]; then
|
|
for rel in skills/auto-tmux/assets/oh-my-tmux skills/auto-tmux/assets/tmux-src; do
|
|
if [[ -e "$root/$rel" ]]; then
|
|
status_line "OK" "asset exists: $rel"
|
|
else
|
|
status_line "WARN" "asset missing: $rel (run git submodule update --init --recursive)"
|
|
fi
|
|
done
|
|
else
|
|
status_line "WARN" "not inside a git worktree; skipped asset checks"
|
|
fi
|
|
|
|
if [[ -n "$session" ]]; then
|
|
if tmux has-session -t "$session" 2>/dev/null; then
|
|
status_line "OK" "session exists: $session"
|
|
tmux list-panes -t "$session" -F ' pane #S:#{window_index}.#{pane_index} cmd=#{pane_current_command}'
|
|
else
|
|
status_line "FAIL" "session not found: $session"
|
|
failures=$((failures + 1))
|
|
fi
|
|
fi
|
|
|
|
if (( failures > 0 )); then
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
cmd_topology() {
|
|
local session=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--session) session="${2:-}"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown topology option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
if [[ -n "$session" ]]; then
|
|
tmux has-session -t "$session" 2>/dev/null || die "session not found: $session"
|
|
printf 'session: %s\n' "$session"
|
|
tmux list-windows -t "$session" -F ' window #{window_index}: #{window_name} #{window_flags}'
|
|
tmux list-panes -a -F '#S #S:#{window_index}.#{pane_index} #{pane_current_command} #{pane_current_path}' |
|
|
awk -v s="$session" '$1 == s {print " pane target=" $2 " cmd=" $3 " path=" $4}'
|
|
else
|
|
tmux list-sessions -F 'session: #{session_name} windows=#{session_windows} attached=#{session_attached}'
|
|
tmux list-windows -a -F ' window #S:#{window_index}: #{window_name} #{window_flags}'
|
|
tmux list-panes -a -F ' pane #S:#{window_index}.#{pane_index} cmd=#{pane_current_command} path=#{pane_current_path}'
|
|
fi
|
|
}
|
|
|
|
cmd_inspect() {
|
|
local target=""
|
|
local lines=40
|
|
local no_redact="0"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-t|--target) target="${2:-}"; shift 2 ;;
|
|
-n|--lines) lines="${2:-}"; shift 2 ;;
|
|
--no-redact) no_redact="1"; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown inspect option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
require_target "$target"
|
|
printf '# pane inspect\n\n'
|
|
tmux display-message -pt "$target" 'target=#{session_name}:#{window_index}.#{pane_index}'
|
|
tmux display-message -pt "$target" 'pane_id=#{pane_id}'
|
|
tmux display-message -pt "$target" 'window=#{window_name}'
|
|
tmux display-message -pt "$target" 'command=#{pane_current_command}'
|
|
tmux display-message -pt "$target" 'pid=#{pane_pid}'
|
|
tmux display-message -pt "$target" 'path=#{pane_current_path}'
|
|
tmux display-message -pt "$target" 'title=#{pane_title}'
|
|
tmux display-message -pt "$target" 'in_mode=#{pane_in_mode}'
|
|
tmux display-message -pt "$target" 'size=#{pane_width}x#{pane_height}'
|
|
printf '\n## recent output\n\n'
|
|
capture_print "$target" "$lines" "$no_redact"
|
|
}
|
|
|
|
cmd_broadcast() {
|
|
local session=""
|
|
local text=""
|
|
local key=""
|
|
local enter="0"
|
|
local clear="0"
|
|
local force="0"
|
|
local dry_run="0"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--session) session="${2:-}"; shift 2 ;;
|
|
--text) text="${2:-}"; shift 2 ;;
|
|
--key) key="${2:-}"; shift 2 ;;
|
|
--enter) enter="1"; shift ;;
|
|
--clear) clear="1"; shift ;;
|
|
--force) force="1"; shift ;;
|
|
--dry-run) dry_run="1"; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown broadcast option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
[[ -n "$session" ]] || die "broadcast requires --session NAME"
|
|
tmux has-session -t "$session" 2>/dev/null || die "session not found: $session"
|
|
[[ -n "$text" || -n "$key" ]] || die "broadcast requires --text TEXT or --key KEY"
|
|
[[ -z "$text" || -z "$key" ]] || die "broadcast accepts only one of --text or --key"
|
|
if [[ -n "$text" && "$force" != "1" ]] && is_dangerous_text "$text"; then
|
|
die "refusing dangerous broadcast text without --force: $text"
|
|
fi
|
|
|
|
local panes=()
|
|
mapfile -t panes < <(pane_list "$session")
|
|
[[ "${#panes[@]}" -gt 0 ]] || die "no panes found in session: $session"
|
|
printf 'broadcast targets (%s):\n' "${#panes[@]}"
|
|
printf ' %s\n' "${panes[@]}"
|
|
|
|
local pane
|
|
for pane in "${panes[@]}"; do
|
|
if [[ "$dry_run" == "1" ]]; then
|
|
printf '[dry-run] would send to %s\n' "$pane"
|
|
continue
|
|
fi
|
|
if [[ -n "$text" ]]; then
|
|
local args=(-t "$pane" --text "$text")
|
|
[[ "$enter" == "1" ]] && args+=(--enter)
|
|
[[ "$clear" == "1" ]] && args+=(--clear)
|
|
[[ "$force" == "1" ]] && args+=(--force)
|
|
cmd_send "${args[@]}"
|
|
else
|
|
local args=(-t "$pane" --key "$key")
|
|
[[ "$enter" == "1" ]] && args+=(--enter)
|
|
[[ "$clear" == "1" ]] && args+=(--clear)
|
|
[[ "$force" == "1" ]] && args+=(--force)
|
|
cmd_send "${args[@]}"
|
|
fi
|
|
done
|
|
}
|
|
|
|
cmd_capture() {
|
|
local target=""
|
|
local lines="$DEFAULT_LINES"
|
|
local save_file=""
|
|
local no_redact="0"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-t|--target) target="${2:-}"; shift 2 ;;
|
|
-n|--lines) lines="${2:-}"; shift 2 ;;
|
|
--save) save_file="${2:-}"; shift 2 ;;
|
|
--no-redact) no_redact="1"; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown capture option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
require_target "$target"
|
|
if [[ -n "$save_file" ]]; then
|
|
mkdir -p "$(dirname "$save_file")"
|
|
capture_print "$target" "$lines" "$no_redact" | tee "$save_file"
|
|
else
|
|
capture_print "$target" "$lines" "$no_redact"
|
|
fi
|
|
}
|
|
|
|
cmd_send() {
|
|
local target=""
|
|
local text=""
|
|
local key=""
|
|
local enter="0"
|
|
local clear="0"
|
|
local force="0"
|
|
local dry_run="0"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-t|--target) target="${2:-}"; shift 2 ;;
|
|
--text) text="${2:-}"; shift 2 ;;
|
|
--key) key="${2:-}"; shift 2 ;;
|
|
--enter) enter="1"; shift ;;
|
|
--clear) clear="1"; shift ;;
|
|
--force) force="1"; shift ;;
|
|
--dry-run) dry_run="1"; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown send option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
require_target "$target"
|
|
[[ -n "$text" || -n "$key" ]] || die "send requires --text TEXT or --key KEY"
|
|
[[ -z "$text" || -z "$key" ]] || die "send accepts only one of --text or --key"
|
|
|
|
printf -- '--- current context: %s ---\n' "$target" >&2
|
|
capture_print "$target" 40 0 >&2 || true
|
|
|
|
if [[ -n "$text" && "$force" != "1" ]] && is_dangerous_text "$text"; then
|
|
die "refusing dangerous text without --force: $text"
|
|
fi
|
|
|
|
if [[ "$dry_run" == "1" ]]; then
|
|
printf '[dry-run] target=%s clear=%s text=%q key=%q enter=%s\n' "$target" "$clear" "$text" "$key" "$enter"
|
|
return 0
|
|
fi
|
|
|
|
cancel_copy_mode_if_needed "$target"
|
|
if [[ "$clear" == "1" ]]; then
|
|
tmux send-keys -t "$target" C-u
|
|
fi
|
|
if [[ -n "$text" ]]; then
|
|
tmux send-keys -t "$target" -l "$text"
|
|
else
|
|
tmux send-keys -t "$target" "$key"
|
|
fi
|
|
if [[ "$enter" == "1" ]]; then
|
|
tmux send-keys -t "$target" Enter
|
|
fi
|
|
}
|
|
|
|
cmd_paste() {
|
|
local target=""
|
|
local file=""
|
|
local enter="0"
|
|
local clear="0"
|
|
local force="0"
|
|
local dry_run="0"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-t|--target) target="${2:-}"; shift 2 ;;
|
|
--file) file="${2:-}"; shift 2 ;;
|
|
--enter) enter="1"; shift ;;
|
|
--clear) clear="1"; shift ;;
|
|
--force) force="1"; shift ;;
|
|
--dry-run) dry_run="1"; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown paste option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
require_target "$target"
|
|
[[ -n "$file" ]] || die "paste requires --file FILE"
|
|
[[ -f "$file" ]] || die "paste file not found: $file"
|
|
|
|
printf -- '--- current context: %s ---\n' "$target" >&2
|
|
capture_print "$target" 40 0 >&2 || true
|
|
|
|
if [[ "$force" != "1" ]] && is_dangerous_text "$(cat "$file")"; then
|
|
die "refusing dangerous paste file without --force: $file"
|
|
fi
|
|
|
|
if [[ "$dry_run" == "1" ]]; then
|
|
printf '[dry-run] target=%s clear=%s file=%s bytes=%s enter=%s\n' \
|
|
"$target" "$clear" "$file" "$(wc -c < "$file" | tr -d ' ')" "$enter"
|
|
return 0
|
|
fi
|
|
|
|
cancel_copy_mode_if_needed "$target"
|
|
if [[ "$clear" == "1" ]]; then
|
|
tmux send-keys -t "$target" C-u
|
|
fi
|
|
local buffer_name="auto-tmux-paste-$$"
|
|
tmux load-buffer -b "$buffer_name" "$file"
|
|
tmux paste-buffer -t "$target" -b "$buffer_name" -d
|
|
if [[ "$enter" == "1" ]]; then
|
|
tmux send-keys -t "$target" Enter
|
|
fi
|
|
}
|
|
|
|
cmd_rescue() {
|
|
local target=""
|
|
local pattern="$DEFAULT_RESCUE_PATTERN"
|
|
local reply="y"
|
|
local lines=80
|
|
local force="0"
|
|
local dry_run="0"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-t|--target) target="${2:-}"; shift 2 ;;
|
|
--pattern) pattern="${2:-}"; shift 2 ;;
|
|
--reply) reply="${2:-}"; shift 2 ;;
|
|
-n|--lines) lines="${2:-}"; shift 2 ;;
|
|
--force) force="1"; shift ;;
|
|
--dry-run) dry_run="1"; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown rescue option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
require_target "$target"
|
|
local output
|
|
output="$(capture_raw "$target" "$lines")"
|
|
printf '%s\n' "$output" | redact
|
|
if printf '%s\n' "$output" | grep -Eiq "$pattern"; then
|
|
printf '[match] target=%s pattern=%s reply=%q\n' "$target" "$pattern" "$reply" >&2
|
|
if [[ "$dry_run" == "1" ]]; then
|
|
printf '[dry-run] would send reply to %s\n' "$target"
|
|
else
|
|
if [[ "$force" == "1" ]]; then
|
|
cmd_send -t "$target" --text "$reply" --enter --force
|
|
else
|
|
cmd_send -t "$target" --text "$reply" --enter
|
|
fi
|
|
fi
|
|
else
|
|
printf '[skip] no rescue pattern matched for %s\n' "$target" >&2
|
|
fi
|
|
}
|
|
|
|
cmd_scan() {
|
|
local session=""
|
|
local lines=80
|
|
local pattern=""
|
|
local rescue="0"
|
|
local reply="y"
|
|
local save_dir=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--session) session="${2:-}"; shift 2 ;;
|
|
-n|--lines) lines="${2:-}"; shift 2 ;;
|
|
--pattern) pattern="${2:-}"; shift 2 ;;
|
|
--rescue) rescue="1"; shift ;;
|
|
--reply) reply="${2:-}"; shift 2 ;;
|
|
--save-dir) save_dir="${2:-}"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown scan option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
if [[ -n "$save_dir" ]]; then
|
|
mkdir -p "$save_dir"
|
|
fi
|
|
|
|
local pane output safe_name
|
|
while IFS= read -r pane; do
|
|
[[ -n "$pane" ]] || continue
|
|
printf '\n===== [%s] =====\n' "$pane"
|
|
output="$(capture_raw "$pane" "$lines")"
|
|
printf '%s\n' "$output" | redact
|
|
if [[ -n "$save_dir" ]]; then
|
|
safe_name="${pane//[:.]/-}.log"
|
|
printf '%s\n' "$output" | redact > "$save_dir/$safe_name"
|
|
fi
|
|
if [[ -n "$pattern" ]] && printf '%s\n' "$output" | grep -Eiq "$pattern"; then
|
|
printf '[match] %s matched %s\n' "$pane" "$pattern" >&2
|
|
if [[ "$rescue" == "1" ]]; then
|
|
cmd_send -t "$pane" --text "$reply" --enter
|
|
fi
|
|
fi
|
|
done < <(pane_list "$session")
|
|
}
|
|
|
|
cmd_record() {
|
|
local action="${1:-}"
|
|
[[ -n "$action" ]] || die "record requires start or stop"
|
|
shift || true
|
|
|
|
local target=""
|
|
local dir="/tmp/auto-tmux-records"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-t|--target) target="${2:-}"; shift 2 ;;
|
|
--dir) dir="${2:-}"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown record option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
require_target "$target"
|
|
case "$action" in
|
|
start)
|
|
mkdir -p "$dir"
|
|
local safe_name="${target//[:.]/-}.log"
|
|
local file="$dir/$safe_name"
|
|
tmux pipe-pane -t "$target" -o "cat >> '$file'"
|
|
printf 'recording %s -> %s\n' "$target" "$file"
|
|
;;
|
|
stop)
|
|
tmux pipe-pane -t "$target"
|
|
printf 'recording stopped for %s\n' "$target"
|
|
;;
|
|
*)
|
|
die "record action must be start or stop"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
cmd_snapshot() {
|
|
local session=""
|
|
local dir="/tmp/auto-tmux-snapshot-$(date +%Y%m%d-%H%M%S)"
|
|
local lines=120
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--session) session="${2:-}"; shift 2 ;;
|
|
--dir) dir="${2:-}"; shift 2 ;;
|
|
-n|--lines) lines="${2:-}"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown snapshot option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
mkdir -p "$dir/panes"
|
|
{
|
|
printf 'auto-tmux snapshot\n'
|
|
printf 'created_at=%s\n' "$(date -Is)"
|
|
printf 'session=%s\n\n' "${session:-<all>}"
|
|
cmd_topology ${session:+--session "$session"}
|
|
} > "$dir/topology.txt"
|
|
|
|
local pane safe_name
|
|
while IFS= read -r pane; do
|
|
[[ -n "$pane" ]] || continue
|
|
safe_name="${pane//[:.]/-}.log"
|
|
capture_print "$pane" "$lines" 0 > "$dir/panes/$safe_name"
|
|
done < <(pane_list "$session")
|
|
|
|
printf 'snapshot written: %s\n' "$dir"
|
|
}
|
|
|
|
cmd_hub() {
|
|
local session="ai-hub"
|
|
local workers=3
|
|
local worker_cmd="bash"
|
|
local commander_cmd="bash"
|
|
local attach="0"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--session) session="${2:-}"; shift 2 ;;
|
|
--workers) workers="${2:-}"; shift 2 ;;
|
|
--cmd) worker_cmd="${2:-}"; shift 2 ;;
|
|
--commander-cmd) commander_cmd="${2:-}"; shift 2 ;;
|
|
--attach) attach="1"; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown hub option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
command -v tmux >/dev/null 2>&1 || die "tmux is not installed or not in PATH"
|
|
if tmux has-session -t "$session" 2>/dev/null; then
|
|
warn "session already exists: $session"
|
|
tmux list-windows -t "$session" -F '#S:#I #{window_name}'
|
|
else
|
|
tmux new-session -d -s "$session" -n commander "$commander_cmd"
|
|
local i
|
|
for i in $(seq 1 "$workers"); do
|
|
tmux new-window -t "$session" -n "worker$i" "$worker_cmd"
|
|
done
|
|
tmux select-window -t "$session:commander"
|
|
printf 'created tmux AI hub: %s workers=%s cmd=%q\n' "$session" "$workers" "$worker_cmd"
|
|
fi
|
|
if [[ "$attach" == "1" ]]; then
|
|
tmux attach -t "$session"
|
|
fi
|
|
}
|
|
|
|
cmd_cleanup() {
|
|
local session=""
|
|
local dry_run="0"
|
|
local force="0"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--session) session="${2:-}"; shift 2 ;;
|
|
--dry-run) dry_run="1"; shift ;;
|
|
--force) force="1"; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown cleanup option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
command -v tmux >/dev/null 2>&1 || die "tmux is not installed or not in PATH"
|
|
[[ -n "$session" ]] || die "cleanup requires --session NAME"
|
|
tmux has-session -t "$session" 2>/dev/null || die "session not found: $session"
|
|
|
|
local attached windows
|
|
attached="$(tmux display-message -pt "$session" '#{session_attached}' 2>/dev/null || printf '0')"
|
|
windows="$(tmux display-message -pt "$session" '#{session_windows}' 2>/dev/null || printf '?')"
|
|
printf 'cleanup target: session=%s windows=%s attached=%s\n' "$session" "$windows" "$attached"
|
|
|
|
if [[ "$dry_run" == "1" ]]; then
|
|
printf '[dry-run] would kill session: %s\n' "$session"
|
|
return 0
|
|
fi
|
|
[[ "$force" == "1" ]] || die "cleanup requires --force without --dry-run"
|
|
if [[ "$attached" != "0" ]]; then
|
|
warn "session is attached; killing anyway because --force was provided"
|
|
fi
|
|
tmux kill-session -t "$session"
|
|
printf 'session killed: %s\n' "$session"
|
|
}
|
|
|
|
cmd_wait() {
|
|
local target=""
|
|
local pattern=""
|
|
local timeout=60
|
|
local interval=2
|
|
local lines=80
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-t|--target) target="${2:-}"; shift 2 ;;
|
|
--pattern) pattern="${2:-}"; shift 2 ;;
|
|
--timeout) timeout="${2:-}"; shift 2 ;;
|
|
--interval) interval="${2:-}"; shift 2 ;;
|
|
-n|--lines) lines="${2:-}"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown wait option: $1" ;;
|
|
esac
|
|
done
|
|
|
|
require_tmux
|
|
require_target "$target"
|
|
[[ -n "$pattern" ]] || die "wait requires --pattern REGEX"
|
|
|
|
local start now output
|
|
start="$(date +%s)"
|
|
while true; do
|
|
output="$(capture_raw "$target" "$lines")"
|
|
if printf '%s\n' "$output" | grep -Eiq "$pattern"; then
|
|
printf '%s\n' "$output" | redact
|
|
printf '[matched] target=%s pattern=%s\n' "$target" "$pattern" >&2
|
|
return 0
|
|
fi
|
|
now="$(date +%s)"
|
|
if (( now - start >= timeout )); then
|
|
printf '%s\n' "$output" | redact
|
|
die "timeout waiting for pattern: $pattern"
|
|
fi
|
|
sleep "$interval"
|
|
done
|
|
}
|
|
|
|
main() {
|
|
local cmd="${1:-help}"
|
|
shift || true
|
|
case "$cmd" in
|
|
help|-h|--help) usage ;;
|
|
doctor) cmd_doctor "$@" ;;
|
|
topology) cmd_topology "$@" ;;
|
|
inspect) cmd_inspect "$@" ;;
|
|
capture) cmd_capture "$@" ;;
|
|
send) cmd_send "$@" ;;
|
|
paste) cmd_paste "$@" ;;
|
|
broadcast) cmd_broadcast "$@" ;;
|
|
rescue) cmd_rescue "$@" ;;
|
|
scan) cmd_scan "$@" ;;
|
|
record) cmd_record "$@" ;;
|
|
snapshot) cmd_snapshot "$@" ;;
|
|
hub) cmd_hub "$@" ;;
|
|
cleanup) cmd_cleanup "$@" ;;
|
|
wait) cmd_wait "$@" ;;
|
|
*) die "unknown command: $cmd" ;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|