shipwright-cli 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +926 -0
- package/claude-code/CLAUDE.md.shipwright +125 -0
- package/claude-code/hooks/notify-idle.sh +35 -0
- package/claude-code/hooks/pre-compact-save.sh +57 -0
- package/claude-code/hooks/task-completed.sh +170 -0
- package/claude-code/hooks/teammate-idle.sh +68 -0
- package/claude-code/settings.json.template +184 -0
- package/completions/_shipwright +140 -0
- package/completions/shipwright.bash +89 -0
- package/completions/shipwright.fish +107 -0
- package/docs/KNOWN-ISSUES.md +199 -0
- package/docs/TIPS.md +331 -0
- package/docs/definition-of-done.example.md +16 -0
- package/docs/patterns/README.md +139 -0
- package/docs/patterns/audit-loop.md +149 -0
- package/docs/patterns/bug-hunt.md +183 -0
- package/docs/patterns/feature-implementation.md +159 -0
- package/docs/patterns/refactoring.md +183 -0
- package/docs/patterns/research-exploration.md +144 -0
- package/docs/patterns/test-generation.md +173 -0
- package/package.json +49 -0
- package/scripts/adapters/docker-deploy.sh +50 -0
- package/scripts/adapters/fly-deploy.sh +41 -0
- package/scripts/adapters/iterm2-adapter.sh +122 -0
- package/scripts/adapters/railway-deploy.sh +34 -0
- package/scripts/adapters/tmux-adapter.sh +87 -0
- package/scripts/adapters/vercel-deploy.sh +35 -0
- package/scripts/adapters/wezterm-adapter.sh +103 -0
- package/scripts/cct +242 -0
- package/scripts/cct-cleanup.sh +172 -0
- package/scripts/cct-cost.sh +590 -0
- package/scripts/cct-daemon.sh +3189 -0
- package/scripts/cct-doctor.sh +328 -0
- package/scripts/cct-fix.sh +478 -0
- package/scripts/cct-fleet.sh +904 -0
- package/scripts/cct-init.sh +282 -0
- package/scripts/cct-logs.sh +273 -0
- package/scripts/cct-loop.sh +1332 -0
- package/scripts/cct-memory.sh +1148 -0
- package/scripts/cct-pipeline.sh +3844 -0
- package/scripts/cct-prep.sh +1352 -0
- package/scripts/cct-ps.sh +168 -0
- package/scripts/cct-reaper.sh +390 -0
- package/scripts/cct-session.sh +284 -0
- package/scripts/cct-status.sh +169 -0
- package/scripts/cct-templates.sh +242 -0
- package/scripts/cct-upgrade.sh +422 -0
- package/scripts/cct-worktree.sh +405 -0
- package/scripts/postinstall.mjs +96 -0
- package/templates/pipelines/autonomous.json +71 -0
- package/templates/pipelines/cost-aware.json +95 -0
- package/templates/pipelines/deployed.json +79 -0
- package/templates/pipelines/enterprise.json +114 -0
- package/templates/pipelines/fast.json +63 -0
- package/templates/pipelines/full.json +104 -0
- package/templates/pipelines/hotfix.json +63 -0
- package/templates/pipelines/standard.json +91 -0
- package/tmux/claude-teams-overlay.conf +109 -0
- package/tmux/templates/architecture.json +19 -0
- package/tmux/templates/bug-fix.json +24 -0
- package/tmux/templates/code-review.json +24 -0
- package/tmux/templates/devops.json +19 -0
- package/tmux/templates/documentation.json +19 -0
- package/tmux/templates/exploration.json +19 -0
- package/tmux/templates/feature-dev.json +24 -0
- package/tmux/templates/full-stack.json +24 -0
- package/tmux/templates/migration.json +24 -0
- package/tmux/templates/refactor.json +19 -0
- package/tmux/templates/security-audit.json +24 -0
- package/tmux/templates/testing.json +24 -0
- package/tmux/tmux.conf +167 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
|
3
|
+
# ║ cct-ps.sh — Show running agent process status ║
|
|
4
|
+
# ║ ║
|
|
5
|
+
# ║ Displays a table of agents running in claude-* tmux windows with ║
|
|
6
|
+
# ║ PID, status, idle time, and pane references. ║
|
|
7
|
+
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
# ─── Colors ──────────────────────────────────────────────────────────────────
|
|
11
|
+
CYAN='\033[38;2;0;212;255m'
|
|
12
|
+
PURPLE='\033[38;2;124;58;237m'
|
|
13
|
+
BLUE='\033[38;2;0;102;255m'
|
|
14
|
+
GREEN='\033[38;2;74;222;128m'
|
|
15
|
+
YELLOW='\033[38;2;250;204;21m'
|
|
16
|
+
RED='\033[38;2;248;113;113m'
|
|
17
|
+
DIM='\033[2m'
|
|
18
|
+
BOLD='\033[1m'
|
|
19
|
+
RESET='\033[0m'
|
|
20
|
+
|
|
21
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
22
|
+
info() { echo -e "${CYAN}${BOLD}▸${RESET} $*"; }
|
|
23
|
+
success() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
|
|
24
|
+
warn() { echo -e "${YELLOW}${BOLD}⚠${RESET} $*"; }
|
|
25
|
+
error() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
|
|
26
|
+
|
|
27
|
+
# ─── Format idle time ───────────────────────────────────────────────────────
|
|
28
|
+
format_idle() {
|
|
29
|
+
local seconds="$1"
|
|
30
|
+
if [[ "$seconds" -lt 60 ]]; then
|
|
31
|
+
echo "${seconds}s"
|
|
32
|
+
elif [[ "$seconds" -lt 3600 ]]; then
|
|
33
|
+
echo "$((seconds / 60))m $((seconds % 60))s"
|
|
34
|
+
else
|
|
35
|
+
echo "$((seconds / 3600))h $((seconds % 3600 / 60))m"
|
|
36
|
+
fi
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# ─── Determine status from command and idle time ─────────────────────────────
|
|
40
|
+
get_status() {
|
|
41
|
+
local cmd="$1"
|
|
42
|
+
local idle="$2"
|
|
43
|
+
local is_dead="${3:-0}"
|
|
44
|
+
|
|
45
|
+
if [[ "$is_dead" == "1" ]]; then
|
|
46
|
+
echo "dead"
|
|
47
|
+
return
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Active process patterns — claude, node, npm are likely active agents
|
|
51
|
+
case "$cmd" in
|
|
52
|
+
claude|node|npm|npx)
|
|
53
|
+
if [[ "$idle" -gt 300 ]]; then
|
|
54
|
+
echo "idle"
|
|
55
|
+
else
|
|
56
|
+
echo "running"
|
|
57
|
+
fi
|
|
58
|
+
;;
|
|
59
|
+
bash|zsh|fish|sh)
|
|
60
|
+
# Shell prompt — agent likely finished or hasn't started
|
|
61
|
+
echo "idle"
|
|
62
|
+
;;
|
|
63
|
+
*)
|
|
64
|
+
if [[ "$idle" -gt 300 ]]; then
|
|
65
|
+
echo "idle"
|
|
66
|
+
else
|
|
67
|
+
echo "running"
|
|
68
|
+
fi
|
|
69
|
+
;;
|
|
70
|
+
esac
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
status_display() {
|
|
74
|
+
local status="$1"
|
|
75
|
+
case "$status" in
|
|
76
|
+
running) echo -e "${GREEN}${BOLD}running${RESET}" ;;
|
|
77
|
+
idle) echo -e "${YELLOW}idle${RESET}" ;;
|
|
78
|
+
dead) echo -e "${RED}${BOLD}dead${RESET}" ;;
|
|
79
|
+
*) echo -e "${DIM}${status}${RESET}" ;;
|
|
80
|
+
esac
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# ─── Header ─────────────────────────────────────────────────────────────────
|
|
84
|
+
echo ""
|
|
85
|
+
echo -e "${CYAN}${BOLD} Claude Code Teams — Process Status${RESET}"
|
|
86
|
+
echo -e "${DIM} $(date '+%Y-%m-%d %H:%M:%S')${RESET}"
|
|
87
|
+
echo -e "${DIM} ══════════════════════════════════════════${RESET}"
|
|
88
|
+
echo ""
|
|
89
|
+
|
|
90
|
+
# ─── Collect pane data ──────────────────────────────────────────────────────
|
|
91
|
+
HAS_AGENTS=false
|
|
92
|
+
CURRENT_WINDOW=""
|
|
93
|
+
|
|
94
|
+
# Format strings for tmux:
|
|
95
|
+
# window_name | pane_title | pane_pid | pane_current_command | pane_active | pane_idle | pane_dead | session:window.pane
|
|
96
|
+
FORMAT='#{window_name}|#{pane_title}|#{pane_pid}|#{pane_current_command}|#{pane_active}|#{pane_idle}|#{pane_dead}|#{session_name}:#{window_index}.#{pane_index}'
|
|
97
|
+
|
|
98
|
+
while IFS='|' read -r window_name pane_title pane_pid cmd pane_active pane_idle pane_dead pane_ref; do
|
|
99
|
+
[[ -z "$window_name" ]] && continue
|
|
100
|
+
|
|
101
|
+
# Only show claude-* windows
|
|
102
|
+
echo "$window_name" | grep -qi "^claude" || continue
|
|
103
|
+
HAS_AGENTS=true
|
|
104
|
+
|
|
105
|
+
# Print team header when window changes
|
|
106
|
+
if [[ "$window_name" != "$CURRENT_WINDOW" ]]; then
|
|
107
|
+
if [[ -n "$CURRENT_WINDOW" ]]; then
|
|
108
|
+
echo ""
|
|
109
|
+
fi
|
|
110
|
+
echo -e "${PURPLE}${BOLD} ${window_name}${RESET}"
|
|
111
|
+
echo -e "${DIM} ──────────────────────────────────────────${RESET}"
|
|
112
|
+
printf " ${DIM}%-20s %-8s %-10s %-10s %s${RESET}\n" "AGENT" "PID" "STATUS" "IDLE" "PANE"
|
|
113
|
+
CURRENT_WINDOW="$window_name"
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
# Determine status
|
|
117
|
+
local_status="$(get_status "$cmd" "$pane_idle" "$pane_dead")"
|
|
118
|
+
local_idle_fmt="$(format_idle "$pane_idle")"
|
|
119
|
+
|
|
120
|
+
# Active pane indicator
|
|
121
|
+
local active_marker=""
|
|
122
|
+
if [[ "$pane_active" == "1" ]]; then
|
|
123
|
+
active_marker=" ${CYAN}●${RESET}"
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
# Agent display name
|
|
127
|
+
local agent_name="${pane_title:-${cmd}}"
|
|
128
|
+
|
|
129
|
+
printf " %-20s %-8s " "$agent_name" "$pane_pid"
|
|
130
|
+
status_display "$local_status"
|
|
131
|
+
# Re-align after color codes in status
|
|
132
|
+
printf " %-10s %s" "$local_idle_fmt" "$pane_ref"
|
|
133
|
+
echo -e "${active_marker}"
|
|
134
|
+
|
|
135
|
+
done < <(tmux list-panes -a -F "$FORMAT" 2>/dev/null | sort -t'|' -k1,1 -k2,2 || true)
|
|
136
|
+
|
|
137
|
+
if ! $HAS_AGENTS; then
|
|
138
|
+
echo -e " ${DIM}No Claude team windows found.${RESET}"
|
|
139
|
+
echo -e " ${DIM}Start one with: ${CYAN}shipwright session <name>${RESET}"
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
# ─── Summary ─────────────────────────────────────────────────────────────────
|
|
143
|
+
echo ""
|
|
144
|
+
echo -e "${DIM} ──────────────────────────────────────────${RESET}"
|
|
145
|
+
|
|
146
|
+
if $HAS_AGENTS; then
|
|
147
|
+
# Quick counts
|
|
148
|
+
running=0
|
|
149
|
+
idle=0
|
|
150
|
+
dead=0
|
|
151
|
+
total=0
|
|
152
|
+
|
|
153
|
+
while IFS='|' read -r window_name _ _ cmd _ pane_idle pane_dead _; do
|
|
154
|
+
echo "$window_name" | grep -qi "^claude" || continue
|
|
155
|
+
total=$((total + 1))
|
|
156
|
+
s="$(get_status "$cmd" "$pane_idle" "$pane_dead")"
|
|
157
|
+
case "$s" in
|
|
158
|
+
running) running=$((running + 1)) ;;
|
|
159
|
+
idle) idle=$((idle + 1)) ;;
|
|
160
|
+
dead) dead=$((dead + 1)) ;;
|
|
161
|
+
esac
|
|
162
|
+
done < <(tmux list-panes -a -F "$FORMAT" 2>/dev/null || true)
|
|
163
|
+
|
|
164
|
+
echo -e " ${GREEN}${running} running${RESET} ${YELLOW}${idle} idle${RESET} ${RED}${dead} dead${RESET} ${DIM}(${total} total)${RESET}"
|
|
165
|
+
else
|
|
166
|
+
echo -e " ${DIM}No active agents.${RESET}"
|
|
167
|
+
fi
|
|
168
|
+
echo ""
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
|
3
|
+
# ║ cct-reaper.sh — Automatic tmux pane cleanup when agents exit ║
|
|
4
|
+
# ║ ║
|
|
5
|
+
# ║ Detects agent panes where the Claude process has exited (shell is ║
|
|
6
|
+
# ║ idle), kills them after a grace period, and cleans up associated ║
|
|
7
|
+
# ║ team/task directories when no panes remain. ║
|
|
8
|
+
# ║ ║
|
|
9
|
+
# ║ Modes: ║
|
|
10
|
+
# ║ shipwright reaper One-shot scan, reap, exit ║
|
|
11
|
+
# ║ shipwright reaper --watch Continuous loop (default: 5s) ║
|
|
12
|
+
# ║ shipwright reaper --dry-run Preview what would be reaped ║
|
|
13
|
+
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
# ─── Colors (matches Seth's tmux theme) ────────────────────────────────────
|
|
17
|
+
CYAN='\033[38;2;0;212;255m'
|
|
18
|
+
PURPLE='\033[38;2;124;58;237m'
|
|
19
|
+
GREEN='\033[38;2;74;222;128m'
|
|
20
|
+
YELLOW='\033[38;2;250;204;21m'
|
|
21
|
+
RED='\033[38;2;248;113;113m'
|
|
22
|
+
DIM='\033[2m'
|
|
23
|
+
BOLD='\033[1m'
|
|
24
|
+
RESET='\033[0m'
|
|
25
|
+
|
|
26
|
+
info() { echo -e "${CYAN}${BOLD}▸${RESET} $*"; }
|
|
27
|
+
success() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
|
|
28
|
+
warn() { echo -e "${YELLOW}${BOLD}⚠${RESET} $*"; }
|
|
29
|
+
error() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
|
|
30
|
+
|
|
31
|
+
# ─── Defaults ──────────────────────────────────────────────────────────────
|
|
32
|
+
WATCH=false
|
|
33
|
+
DRY_RUN=false
|
|
34
|
+
VERBOSE=false
|
|
35
|
+
INTERVAL=5
|
|
36
|
+
GRACE_PERIOD=15
|
|
37
|
+
LOG_FILE=""
|
|
38
|
+
PID_FILE="${HOME}/.cct-reaper.pid"
|
|
39
|
+
|
|
40
|
+
# ─── Parse Args ────────────────────────────────────────────────────────────
|
|
41
|
+
show_help() {
|
|
42
|
+
echo -e "${CYAN}${BOLD}shipwright reaper${RESET} — Automatic pane cleanup when agents exit"
|
|
43
|
+
echo ""
|
|
44
|
+
echo -e "${BOLD}USAGE${RESET}"
|
|
45
|
+
echo -e " shipwright reaper ${DIM}# One-shot: scan, reap, exit${RESET}"
|
|
46
|
+
echo -e " shipwright reaper --watch ${DIM}# Continuous loop (5s interval)${RESET}"
|
|
47
|
+
echo -e " shipwright reaper --dry-run ${DIM}# Preview what would be reaped${RESET}"
|
|
48
|
+
echo -e " shipwright reaper --dry-run --verbose ${DIM}# Show all panes and their status${RESET}"
|
|
49
|
+
echo ""
|
|
50
|
+
echo -e "${BOLD}OPTIONS${RESET}"
|
|
51
|
+
echo -e " --watch Run continuously instead of one-shot"
|
|
52
|
+
echo -e " --dry-run Show what would be reaped without doing it"
|
|
53
|
+
echo -e " --verbose Show details for every pane scanned"
|
|
54
|
+
echo -e " --interval <sec> Seconds between watch scans (default: ${INTERVAL})"
|
|
55
|
+
echo -e " --grace-period <sec> Idle seconds before reaping (default: ${GRACE_PERIOD})"
|
|
56
|
+
echo -e " --log-file <path> Append reaper activity to a log file"
|
|
57
|
+
echo -e " --help, -h Show this help"
|
|
58
|
+
echo ""
|
|
59
|
+
echo -e "${BOLD}DETECTION ALGORITHM${RESET}"
|
|
60
|
+
echo -e " ${DIM}1. pane_dead == 1 → REAP (zombie pane)${RESET}"
|
|
61
|
+
echo -e " ${DIM}2. command ∉ (bash,zsh,fish,sh) → SKIP (agent still running)${RESET}"
|
|
62
|
+
echo -e " ${DIM}3. pane_title is empty → SKIP (not initialized)${RESET}"
|
|
63
|
+
echo -e " ${DIM}4. pane_idle < grace_period → SKIP (may be starting)${RESET}"
|
|
64
|
+
echo -e " ${DIM}5. All checks passed → REAP (agent exited)${RESET}"
|
|
65
|
+
echo ""
|
|
66
|
+
echo -e "${BOLD}EXAMPLES${RESET}"
|
|
67
|
+
echo -e " ${DIM}shipwright reaper --watch --interval 10 --grace-period 30${RESET}"
|
|
68
|
+
echo -e " ${DIM}shipwright reaper --watch --log-file ~/.cct-reaper.log &${RESET}"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
while [[ $# -gt 0 ]]; do
|
|
72
|
+
case "$1" in
|
|
73
|
+
--watch|-w) WATCH=true; shift ;;
|
|
74
|
+
--dry-run|-n) DRY_RUN=true; shift ;;
|
|
75
|
+
--verbose|-v) VERBOSE=true; shift ;;
|
|
76
|
+
--interval) INTERVAL="${2:?--interval requires a value}"; shift 2 ;;
|
|
77
|
+
--grace-period) GRACE_PERIOD="${2:?--grace-period requires a value}"; shift 2 ;;
|
|
78
|
+
--log-file) LOG_FILE="${2:?--log-file requires a path}"; shift 2 ;;
|
|
79
|
+
--help|-h) show_help; exit 0 ;;
|
|
80
|
+
*) error "Unknown option: $1"; echo ""; show_help; exit 1 ;;
|
|
81
|
+
esac
|
|
82
|
+
done
|
|
83
|
+
|
|
84
|
+
# ─── Logging ───────────────────────────────────────────────────────────────
|
|
85
|
+
log() {
|
|
86
|
+
local msg="$1"
|
|
87
|
+
if [[ -n "$LOG_FILE" ]]; then
|
|
88
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $msg" >> "$LOG_FILE"
|
|
89
|
+
fi
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# ─── PID file management (watch mode) ─────────────────────────────────────
|
|
93
|
+
acquire_pid_lock() {
|
|
94
|
+
if [[ -f "$PID_FILE" ]]; then
|
|
95
|
+
local existing_pid
|
|
96
|
+
existing_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
|
|
97
|
+
if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
|
|
98
|
+
warn "Reaper already running (PID ${existing_pid}). Stop it first or remove ${PID_FILE}"
|
|
99
|
+
exit 1
|
|
100
|
+
fi
|
|
101
|
+
# Stale PID file — clean it up
|
|
102
|
+
rm -f "$PID_FILE"
|
|
103
|
+
fi
|
|
104
|
+
echo $$ > "$PID_FILE"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
release_pid_lock() {
|
|
108
|
+
rm -f "$PID_FILE"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# ─── tmux format string (reused from cct-ps.sh) ──────────────────────────
|
|
112
|
+
# Fields: window_name | pane_title | pane_pid | pane_current_command | pane_active | pane_idle | pane_dead | session:window.pane
|
|
113
|
+
FORMAT='#{window_name}|#{pane_title}|#{pane_pid}|#{pane_current_command}|#{pane_active}|#{pane_idle}|#{pane_dead}|#{session_name}:#{window_index}.#{pane_index}'
|
|
114
|
+
|
|
115
|
+
# ─── Detection: should this pane be reaped? ───────────────────────────────
|
|
116
|
+
# Returns 0 (reap) or 1 (skip), and sets REAP_REASON
|
|
117
|
+
REAP_REASON=""
|
|
118
|
+
|
|
119
|
+
should_reap() {
|
|
120
|
+
local pane_title="$1"
|
|
121
|
+
local cmd="$2"
|
|
122
|
+
local pane_idle="$3"
|
|
123
|
+
local pane_dead="$4"
|
|
124
|
+
|
|
125
|
+
# 1. Zombie pane — reap immediately
|
|
126
|
+
if [[ "$pane_dead" == "1" ]]; then
|
|
127
|
+
REAP_REASON="zombie (pane_dead=1)"
|
|
128
|
+
return 0
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
# 2. Agent still running — skip
|
|
132
|
+
case "$cmd" in
|
|
133
|
+
claude|node|npm|npx|python|python3)
|
|
134
|
+
REAP_REASON="agent running (${cmd})"
|
|
135
|
+
return 1
|
|
136
|
+
;;
|
|
137
|
+
esac
|
|
138
|
+
|
|
139
|
+
# 3. Pane hasn't been initialized — skip
|
|
140
|
+
if [[ -z "$pane_title" ]]; then
|
|
141
|
+
REAP_REASON="no pane title (not initialized)"
|
|
142
|
+
return 1
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
# 4. Shell is present but hasn't been idle long enough — skip
|
|
146
|
+
case "$cmd" in
|
|
147
|
+
bash|zsh|fish|sh)
|
|
148
|
+
if [[ "$pane_idle" -lt "$GRACE_PERIOD" ]]; then
|
|
149
|
+
REAP_REASON="idle ${pane_idle}s < grace ${GRACE_PERIOD}s"
|
|
150
|
+
return 1
|
|
151
|
+
fi
|
|
152
|
+
REAP_REASON="idle shell (${cmd}, ${pane_idle}s > ${GRACE_PERIOD}s grace)"
|
|
153
|
+
return 0
|
|
154
|
+
;;
|
|
155
|
+
esac
|
|
156
|
+
|
|
157
|
+
# 5. Unknown command, not idle long enough — skip
|
|
158
|
+
if [[ "$pane_idle" -lt "$GRACE_PERIOD" ]]; then
|
|
159
|
+
REAP_REASON="unknown cmd (${cmd}), idle ${pane_idle}s < grace ${GRACE_PERIOD}s"
|
|
160
|
+
return 1
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
REAP_REASON="idle process (${cmd}, ${pane_idle}s)"
|
|
164
|
+
return 0
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# ─── Format idle time for display ─────────────────────────────────────────
|
|
168
|
+
format_idle() {
|
|
169
|
+
local seconds="$1"
|
|
170
|
+
if [[ "$seconds" -lt 60 ]]; then
|
|
171
|
+
echo "${seconds}s"
|
|
172
|
+
elif [[ "$seconds" -lt 3600 ]]; then
|
|
173
|
+
echo "$((seconds / 60))m $((seconds % 60))s"
|
|
174
|
+
else
|
|
175
|
+
echo "$((seconds / 3600))h $((seconds % 3600 / 60))m"
|
|
176
|
+
fi
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# ─── Single scan pass ─────────────────────────────────────────────────────
|
|
180
|
+
scan_and_reap() {
|
|
181
|
+
local reaped=0
|
|
182
|
+
local skipped=0
|
|
183
|
+
local scanned=0
|
|
184
|
+
|
|
185
|
+
while IFS='|' read -r window_name pane_title pane_pid cmd pane_active pane_idle pane_dead pane_ref; do
|
|
186
|
+
[[ -z "$window_name" ]] && continue
|
|
187
|
+
|
|
188
|
+
# Only target claude-* windows
|
|
189
|
+
echo "$window_name" | grep -qi "^claude" || continue
|
|
190
|
+
scanned=$((scanned + 1))
|
|
191
|
+
|
|
192
|
+
if should_reap "$pane_title" "$cmd" "$pane_idle" "$pane_dead"; then
|
|
193
|
+
if $DRY_RUN; then
|
|
194
|
+
echo -e " ${YELLOW}○${RESET} Would reap: ${BOLD}${pane_title:-<untitled>}${RESET} ${DIM}(${pane_ref})${RESET} — ${REAP_REASON}"
|
|
195
|
+
else
|
|
196
|
+
tmux kill-pane -t "$pane_ref" 2>/dev/null && {
|
|
197
|
+
echo -e " ${RED}✗${RESET} Reaped: ${BOLD}${pane_title:-<untitled>}${RESET} ${DIM}(${pane_ref})${RESET} — ${REAP_REASON}"
|
|
198
|
+
log "REAP pane=${pane_ref} title=${pane_title} reason=${REAP_REASON}"
|
|
199
|
+
} || {
|
|
200
|
+
warn " Could not kill pane: ${pane_ref}"
|
|
201
|
+
}
|
|
202
|
+
fi
|
|
203
|
+
reaped=$((reaped + 1))
|
|
204
|
+
else
|
|
205
|
+
if $VERBOSE; then
|
|
206
|
+
echo -e " ${DIM} skip: ${pane_title:-<untitled>} (${pane_ref}) — ${REAP_REASON}${RESET}"
|
|
207
|
+
fi
|
|
208
|
+
skipped=$((skipped + 1))
|
|
209
|
+
fi
|
|
210
|
+
done < <(tmux list-panes -a -F "$FORMAT" 2>/dev/null | sort -t'|' -k1,1 -k2,2 || true)
|
|
211
|
+
|
|
212
|
+
# Return values via globals (bash doesn't have multi-return)
|
|
213
|
+
SCAN_REAPED=$reaped
|
|
214
|
+
SCAN_SKIPPED=$skipped
|
|
215
|
+
SCAN_SCANNED=$scanned
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# ─── Clean up empty windows ───────────────────────────────────────────────
|
|
219
|
+
cleanup_empty_windows() {
|
|
220
|
+
local killed=0
|
|
221
|
+
|
|
222
|
+
while IFS= read -r line; do
|
|
223
|
+
[[ -z "$line" ]] && continue
|
|
224
|
+
local win_target win_name pane_count
|
|
225
|
+
win_target="$(echo "$line" | cut -d' ' -f1)"
|
|
226
|
+
win_name="$(echo "$line" | cut -d' ' -f2)"
|
|
227
|
+
pane_count="$(echo "$line" | cut -d' ' -f3)"
|
|
228
|
+
|
|
229
|
+
echo "$win_name" | grep -qi "^claude" || continue
|
|
230
|
+
|
|
231
|
+
if [[ "$pane_count" -eq 0 ]]; then
|
|
232
|
+
if $DRY_RUN; then
|
|
233
|
+
echo -e " ${YELLOW}○${RESET} Would kill empty window: ${BOLD}${win_name}${RESET}"
|
|
234
|
+
else
|
|
235
|
+
tmux kill-window -t "$win_target" 2>/dev/null && {
|
|
236
|
+
echo -e " ${RED}✗${RESET} Killed empty window: ${BOLD}${win_name}${RESET}"
|
|
237
|
+
log "KILL_WINDOW window=${win_name} target=${win_target}"
|
|
238
|
+
killed=$((killed + 1))
|
|
239
|
+
} || true
|
|
240
|
+
fi
|
|
241
|
+
fi
|
|
242
|
+
done < <(tmux list-windows -a -F '#{session_name}:#{window_index} #{window_name} #{window_panes}' 2>/dev/null || true)
|
|
243
|
+
|
|
244
|
+
WINDOWS_KILLED=$killed
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
# ─── Clean up team/task dirs when no matching windows remain ──────────────
|
|
248
|
+
cleanup_team_dirs() {
|
|
249
|
+
local cleaned=0
|
|
250
|
+
|
|
251
|
+
local teams_dir="${HOME}/.claude/teams"
|
|
252
|
+
local tasks_dir="${HOME}/.claude/tasks"
|
|
253
|
+
|
|
254
|
+
[[ -d "$teams_dir" ]] || return 0
|
|
255
|
+
|
|
256
|
+
while IFS= read -r team_dir; do
|
|
257
|
+
[[ -z "$team_dir" ]] && continue
|
|
258
|
+
local team_name
|
|
259
|
+
team_name="$(basename "$team_dir")"
|
|
260
|
+
|
|
261
|
+
# Check if ANY claude-{team}* window still exists
|
|
262
|
+
local has_windows=false
|
|
263
|
+
while IFS= read -r win_name; do
|
|
264
|
+
if echo "$win_name" | grep -qi "^claude-${team_name}"; then
|
|
265
|
+
has_windows=true
|
|
266
|
+
break
|
|
267
|
+
fi
|
|
268
|
+
done < <(tmux list-windows -a -F '#{window_name}' 2>/dev/null || true)
|
|
269
|
+
|
|
270
|
+
if ! $has_windows; then
|
|
271
|
+
if $DRY_RUN; then
|
|
272
|
+
echo -e " ${YELLOW}○${RESET} Would remove team dir: ${BOLD}${team_name}/${RESET}"
|
|
273
|
+
if [[ -d "${tasks_dir}/${team_name}" ]]; then
|
|
274
|
+
echo -e " ${YELLOW}○${RESET} Would remove task dir: ${BOLD}${team_name}/${RESET}"
|
|
275
|
+
fi
|
|
276
|
+
else
|
|
277
|
+
rm -rf "$team_dir" && {
|
|
278
|
+
echo -e " ${RED}✗${RESET} Removed team dir: ${BOLD}${team_name}/${RESET}"
|
|
279
|
+
log "CLEAN_TEAM team=${team_name}"
|
|
280
|
+
cleaned=$((cleaned + 1))
|
|
281
|
+
}
|
|
282
|
+
if [[ -d "${tasks_dir}/${team_name}" ]]; then
|
|
283
|
+
rm -rf "${tasks_dir}/${team_name}" && {
|
|
284
|
+
echo -e " ${RED}✗${RESET} Removed task dir: ${BOLD}${team_name}/${RESET}"
|
|
285
|
+
log "CLEAN_TASKS team=${team_name}"
|
|
286
|
+
}
|
|
287
|
+
fi
|
|
288
|
+
fi
|
|
289
|
+
fi
|
|
290
|
+
done < <(find "$teams_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
|
|
291
|
+
|
|
292
|
+
DIRS_CLEANED=$cleaned
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# ─── One-shot mode ─────────────────────────────────────────────────────────
|
|
296
|
+
run_oneshot() {
|
|
297
|
+
echo ""
|
|
298
|
+
if $DRY_RUN; then
|
|
299
|
+
info "Reaper scan ${DIM}(dry-run, grace: ${GRACE_PERIOD}s)${RESET}"
|
|
300
|
+
else
|
|
301
|
+
info "Reaper scan ${DIM}(grace: ${GRACE_PERIOD}s)${RESET}"
|
|
302
|
+
fi
|
|
303
|
+
echo ""
|
|
304
|
+
|
|
305
|
+
echo -e "${BOLD}Agent Panes${RESET}"
|
|
306
|
+
echo -e "${DIM}────────────────────────────────────────${RESET}"
|
|
307
|
+
|
|
308
|
+
scan_and_reap
|
|
309
|
+
|
|
310
|
+
if [[ $SCAN_SCANNED -eq 0 ]]; then
|
|
311
|
+
echo -e " ${DIM}No Claude team panes found.${RESET}"
|
|
312
|
+
fi
|
|
313
|
+
|
|
314
|
+
echo ""
|
|
315
|
+
echo -e "${BOLD}Empty Windows${RESET}"
|
|
316
|
+
echo -e "${DIM}────────────────────────────────────────${RESET}"
|
|
317
|
+
|
|
318
|
+
WINDOWS_KILLED=0
|
|
319
|
+
if [[ $SCAN_REAPED -gt 0 ]] || $DRY_RUN; then
|
|
320
|
+
cleanup_empty_windows
|
|
321
|
+
fi
|
|
322
|
+
|
|
323
|
+
if [[ $WINDOWS_KILLED -eq 0 ]] && ! $DRY_RUN; then
|
|
324
|
+
echo -e " ${DIM}None.${RESET}"
|
|
325
|
+
fi
|
|
326
|
+
|
|
327
|
+
echo ""
|
|
328
|
+
echo -e "${BOLD}Team Directories${RESET}"
|
|
329
|
+
echo -e "${DIM}────────────────────────────────────────${RESET}"
|
|
330
|
+
|
|
331
|
+
DIRS_CLEANED=0
|
|
332
|
+
cleanup_team_dirs
|
|
333
|
+
|
|
334
|
+
if [[ $DIRS_CLEANED -eq 0 ]] && ! $DRY_RUN; then
|
|
335
|
+
echo -e " ${DIM}None to clean.${RESET}"
|
|
336
|
+
fi
|
|
337
|
+
|
|
338
|
+
# Summary
|
|
339
|
+
echo ""
|
|
340
|
+
echo -e "${DIM}────────────────────────────────────────${RESET}"
|
|
341
|
+
if $DRY_RUN; then
|
|
342
|
+
if [[ $SCAN_REAPED -gt 0 ]]; then
|
|
343
|
+
warn "Would reap ${SCAN_REAPED} pane(s). Run without --dry-run to execute."
|
|
344
|
+
else
|
|
345
|
+
success "All ${SCAN_SCANNED} pane(s) are healthy. Nothing to reap."
|
|
346
|
+
fi
|
|
347
|
+
else
|
|
348
|
+
if [[ $SCAN_REAPED -gt 0 ]]; then
|
|
349
|
+
success "Reaped ${SCAN_REAPED} pane(s), skipped ${SCAN_SKIPPED}."
|
|
350
|
+
else
|
|
351
|
+
success "All ${SCAN_SCANNED} pane(s) are healthy. Nothing to reap."
|
|
352
|
+
fi
|
|
353
|
+
fi
|
|
354
|
+
echo ""
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
# ─── Watch mode ────────────────────────────────────────────────────────────
|
|
358
|
+
run_watch() {
|
|
359
|
+
acquire_pid_lock
|
|
360
|
+
|
|
361
|
+
# Clean up on exit
|
|
362
|
+
trap 'release_pid_lock; echo ""; info "Reaper stopped."; exit 0' SIGTERM SIGINT EXIT
|
|
363
|
+
|
|
364
|
+
info "Reaper watching ${DIM}(interval: ${INTERVAL}s, grace: ${GRACE_PERIOD}s, PID: $$)${RESET}"
|
|
365
|
+
log "START interval=${INTERVAL} grace=${GRACE_PERIOD} pid=$$"
|
|
366
|
+
echo ""
|
|
367
|
+
|
|
368
|
+
while true; do
|
|
369
|
+
scan_and_reap
|
|
370
|
+
|
|
371
|
+
if [[ $SCAN_REAPED -gt 0 ]]; then
|
|
372
|
+
# After reaping, clean up empty windows and dirs
|
|
373
|
+
cleanup_empty_windows
|
|
374
|
+
DIRS_CLEANED=0
|
|
375
|
+
cleanup_team_dirs
|
|
376
|
+
log "SCAN reaped=${SCAN_REAPED} skipped=${SCAN_SKIPPED} windows_killed=${WINDOWS_KILLED} dirs_cleaned=${DIRS_CLEANED}"
|
|
377
|
+
elif $VERBOSE; then
|
|
378
|
+
echo -e "${DIM} [$(date '+%H:%M:%S')] scanned ${SCAN_SCANNED}, all healthy${RESET}"
|
|
379
|
+
fi
|
|
380
|
+
|
|
381
|
+
sleep "$INTERVAL"
|
|
382
|
+
done
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
# ─── Main ──────────────────────────────────────────────────────────────────
|
|
386
|
+
if $WATCH; then
|
|
387
|
+
run_watch
|
|
388
|
+
else
|
|
389
|
+
run_oneshot
|
|
390
|
+
fi
|