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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +926 -0
  3. package/claude-code/CLAUDE.md.shipwright +125 -0
  4. package/claude-code/hooks/notify-idle.sh +35 -0
  5. package/claude-code/hooks/pre-compact-save.sh +57 -0
  6. package/claude-code/hooks/task-completed.sh +170 -0
  7. package/claude-code/hooks/teammate-idle.sh +68 -0
  8. package/claude-code/settings.json.template +184 -0
  9. package/completions/_shipwright +140 -0
  10. package/completions/shipwright.bash +89 -0
  11. package/completions/shipwright.fish +107 -0
  12. package/docs/KNOWN-ISSUES.md +199 -0
  13. package/docs/TIPS.md +331 -0
  14. package/docs/definition-of-done.example.md +16 -0
  15. package/docs/patterns/README.md +139 -0
  16. package/docs/patterns/audit-loop.md +149 -0
  17. package/docs/patterns/bug-hunt.md +183 -0
  18. package/docs/patterns/feature-implementation.md +159 -0
  19. package/docs/patterns/refactoring.md +183 -0
  20. package/docs/patterns/research-exploration.md +144 -0
  21. package/docs/patterns/test-generation.md +173 -0
  22. package/package.json +49 -0
  23. package/scripts/adapters/docker-deploy.sh +50 -0
  24. package/scripts/adapters/fly-deploy.sh +41 -0
  25. package/scripts/adapters/iterm2-adapter.sh +122 -0
  26. package/scripts/adapters/railway-deploy.sh +34 -0
  27. package/scripts/adapters/tmux-adapter.sh +87 -0
  28. package/scripts/adapters/vercel-deploy.sh +35 -0
  29. package/scripts/adapters/wezterm-adapter.sh +103 -0
  30. package/scripts/cct +242 -0
  31. package/scripts/cct-cleanup.sh +172 -0
  32. package/scripts/cct-cost.sh +590 -0
  33. package/scripts/cct-daemon.sh +3189 -0
  34. package/scripts/cct-doctor.sh +328 -0
  35. package/scripts/cct-fix.sh +478 -0
  36. package/scripts/cct-fleet.sh +904 -0
  37. package/scripts/cct-init.sh +282 -0
  38. package/scripts/cct-logs.sh +273 -0
  39. package/scripts/cct-loop.sh +1332 -0
  40. package/scripts/cct-memory.sh +1148 -0
  41. package/scripts/cct-pipeline.sh +3844 -0
  42. package/scripts/cct-prep.sh +1352 -0
  43. package/scripts/cct-ps.sh +168 -0
  44. package/scripts/cct-reaper.sh +390 -0
  45. package/scripts/cct-session.sh +284 -0
  46. package/scripts/cct-status.sh +169 -0
  47. package/scripts/cct-templates.sh +242 -0
  48. package/scripts/cct-upgrade.sh +422 -0
  49. package/scripts/cct-worktree.sh +405 -0
  50. package/scripts/postinstall.mjs +96 -0
  51. package/templates/pipelines/autonomous.json +71 -0
  52. package/templates/pipelines/cost-aware.json +95 -0
  53. package/templates/pipelines/deployed.json +79 -0
  54. package/templates/pipelines/enterprise.json +114 -0
  55. package/templates/pipelines/fast.json +63 -0
  56. package/templates/pipelines/full.json +104 -0
  57. package/templates/pipelines/hotfix.json +63 -0
  58. package/templates/pipelines/standard.json +91 -0
  59. package/tmux/claude-teams-overlay.conf +109 -0
  60. package/tmux/templates/architecture.json +19 -0
  61. package/tmux/templates/bug-fix.json +24 -0
  62. package/tmux/templates/code-review.json +24 -0
  63. package/tmux/templates/devops.json +19 -0
  64. package/tmux/templates/documentation.json +19 -0
  65. package/tmux/templates/exploration.json +19 -0
  66. package/tmux/templates/feature-dev.json +24 -0
  67. package/tmux/templates/full-stack.json +24 -0
  68. package/tmux/templates/migration.json +24 -0
  69. package/tmux/templates/refactor.json +19 -0
  70. package/tmux/templates/security-audit.json +24 -0
  71. package/tmux/templates/testing.json +24 -0
  72. 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