feat: claude-sessions lifecycle manager + shutdown script

claude-sessions: session registry manager with subcommands:
  - list: live tmux sessions + resumable registry entries
  - recover: scan profile dirs for sessions active before restart
  - spawn/resume: start or resume sessions
  - register: called by claude-tmux to auto-populate registry
  - gc: prune stale entries and backfill session IDs from .jsonl
  - (no args): interactive numbered menu

claude-sessions-shutdown: called by systemd at halt/reboot;
  sends /exit-session to each Claude tmux session, waits up to
  90s for graceful exit, force-kills stragglers.

Symlinks: ~/sbin/claude-sessions, ~/sbin/claude-sessions-shutdown
This commit is contained in:
Paul O'Reilly
2026-10-12 15:47:02 +13:00
parent 77a066edd2
commit 2335a73a02
2 changed files with 872 additions and 0 deletions

729
scripts/claude-sessions Executable file
View File

@@ -0,0 +1,729 @@
#!/usr/bin/env python3
"""
claude-sessions — Session lifecycle manager for Claude Code tmux sessions.
Commands:
(no args) Interactive menu
list Show live Claude tmux sessions and resumable registry entries
recover [--days N] Detect sessions active before a restart
spawn <project> Spawn a new session (wraps claude-tmux)
resume <session-id> Resume a past session by conversation ID
register Record a new session in the registry (called by claude-tmux)
gc [--days N] Prune stale entries and backfill session IDs
"""
import argparse
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
# === Terminal colours ===
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[0;33m"
CYAN = "\033[0;36m"
BOLD = "\033[1m"
DIM = "\033[2m"
NC = "\033[0m"
# === Paths ===
HOME = Path.home()
CLAUDE_ROOT = HOME / "dev" / "claude"
CONFIG_FILE = HOME / ".config" / "claude" / "profiles.yaml"
REGISTRY_FILE = HOME / ".local" / "share" / "claude-sessions" / "registry.json"
HOSTNAME = subprocess.run(["hostname", "-s"], capture_output=True, text=True).stdout.strip()
PATH_SCAN_ROOTS = [
(HOME / "dev" / "claude", 2),
(HOME / "dev" / "claude" / "octopus", 1),
(HOME / "dev" / "claude" / "gamedev", 1),
(HOME / "dev" / "claude" / "worktrees", 1),
(HOME, 1),
]
DEFAULT_PROFILES = {
"oreillyit-anthropic": {
"display_name": "O'Reilly IT",
"claude_dir": str(HOME / ".claude-oreillyit-anthropic"),
"default_mode": "quick",
},
"default": {
"display_name": "Personal",
"claude_dir": str(HOME / ".claude"),
"default_mode": "quick",
},
}
# ── Helpers ──────────────────────────────────────────────────────────────────
def warn(msg: str) -> None:
print(f"{YELLOW}Warning:{NC} {msg}", file=sys.stderr)
def die(msg: str) -> None:
print(f"{RED}Error:{NC} {msg}", file=sys.stderr)
sys.exit(1)
# ── Profiles ────────────────────────────────────────────────────────────────
def load_profiles() -> dict:
if CONFIG_FILE.exists():
try:
import yaml
with CONFIG_FILE.open() as f:
config = yaml.safe_load(f)
if config and "profiles" in config:
profiles = config["profiles"]
for p in profiles.values():
if "claude_dir" in p:
p["claude_dir"] = str(Path(p["claude_dir"]).expanduser())
return profiles
except Exception as e:
warn(f"Could not load {CONFIG_FILE}: {e}")
return DEFAULT_PROFILES
def all_profile_dirs(profiles: dict) -> list[tuple[str, Path]]:
result = []
for name, cfg in profiles.items():
d = Path(cfg.get("claude_dir", "")).expanduser()
if d.is_dir():
result.append((name, d))
for d in HOME.iterdir():
if d.is_dir() and d.name.startswith(".claude-") and not d.name.endswith((".json", ".backup")):
pname = d.name[len(".claude-"):]
if not any(n == pname for n, _ in result):
result.append((pname, d))
return result
# ── Registry ────────────────────────────────────────────────────────────────
def load_registry() -> dict:
if REGISTRY_FILE.exists():
try:
return json.loads(REGISTRY_FILE.read_text())
except Exception:
pass
return {"sessions": []}
def save_registry(registry: dict) -> None:
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
REGISTRY_FILE.write_text(json.dumps(registry, indent=2))
# ── Path hash utilities ──────────────────────────────────────────────────────
def path_to_hash(path: str | Path) -> str:
return str(path).replace("/", "-")
def build_path_map() -> dict[str, str]:
result: dict[str, str] = {}
def scan(p: Path, depth: int) -> None:
if not p.is_dir() or depth < 0:
return
result[path_to_hash(p)] = str(p)
if depth > 0:
try:
for child in p.iterdir():
if child.is_dir() and not child.name.startswith("."):
scan(child, depth - 1)
except PermissionError:
pass
for root, depth in PATH_SCAN_ROOTS:
scan(root, depth)
return result
# ── tmux utilities ───────────────────────────────────────────────────────────
def list_tmux_sessions() -> set[str]:
try:
r = subprocess.run(["tmux", "ls", "-F", "#{session_name}"], capture_output=True, text=True)
if r.returncode == 0:
return set(r.stdout.strip().splitlines())
except Exception:
pass
return set()
def title_case(s: str) -> str:
return " ".join(w.capitalize() for w in s.replace("-", " ").replace("_", " ").split())
# ── Session file parsing ─────────────────────────────────────────────────────
def read_jsonl_header(path: Path, max_lines: int = 30) -> dict:
session_id = None
start_time = None
try:
with path.open(errors="replace") as f:
for i, line in enumerate(f):
if i >= max_lines:
break
try:
obj = json.loads(line.strip())
if not session_id and "sessionId" in obj:
session_id = obj["sessionId"]
if not start_time and "timestamp" in obj:
start_time = obj["timestamp"]
if session_id and start_time:
break
except json.JSONDecodeError:
pass
except Exception:
pass
return {"session_id": session_id or path.stem, "start_time": start_time}
def scan_profile_sessions(profile_name: str, claude_dir: Path,
path_map: dict[str, str], cutoff: float) -> list[dict]:
projects_dir = claude_dir / "projects"
if not projects_dir.is_dir():
return []
records = []
for proj_dir in projects_dir.iterdir():
if not proj_dir.is_dir():
continue
project_hash = proj_dir.name
real_path = path_map.get(project_hash)
jsonl_files = sorted(
proj_dir.glob("*.jsonl"),
key=lambda f: f.stat().st_mtime,
reverse=True,
)
for jf in jsonl_files:
mtime = jf.stat().st_mtime
if mtime < cutoff:
break
meta = read_jsonl_header(jf)
records.append({
"session_id": meta["session_id"],
"profile": profile_name,
"project_hash": project_hash,
"project_dir": real_path,
"start_time": meta["start_time"],
"last_activity": datetime.fromtimestamp(mtime).strftime("%b %d %H:%M"),
"mtime": mtime,
})
return records
# ── Registry backfill ────────────────────────────────────────────────────────
def backfill_session_ids(registry: dict, profiles: dict) -> int:
"""For registry entries with no session_id, find the matching .jsonl by scan."""
updated = 0
for entry in registry.get("sessions", []):
if entry.get("session_id"):
continue
profile_name = entry.get("profile", "")
profile_cfg = profiles.get(profile_name, {})
claude_dir = Path(profile_cfg.get("claude_dir", "")).expanduser()
if not claude_dir.is_dir():
continue
project_dir = entry.get("project_dir", "")
if not project_dir:
continue
proj_hash = path_to_hash(project_dir)
proj_path = claude_dir / "projects" / proj_hash
if not proj_path.is_dir():
continue
spawned_at = entry.get("spawned_at", "")
spawn_ts = 0.0
if spawned_at:
try:
spawn_ts = datetime.fromisoformat(spawned_at).timestamp()
except ValueError:
pass
best: Optional[Path] = None
best_mtime = 0.0
for jf in proj_path.glob("*.jsonl"):
mtime = jf.stat().st_mtime
if mtime > spawn_ts and mtime > best_mtime:
best, best_mtime = jf, mtime
if best:
meta = read_jsonl_header(best)
entry["session_id"] = meta["session_id"]
entry["last_activity"] = datetime.fromtimestamp(best_mtime).isoformat()
updated += 1
return updated
# ── Spawning ─────────────────────────────────────────────────────────────────
def resolve_claude_profile_bin() -> str:
r = subprocess.run(["which", "claude-profile"], capture_output=True, text=True)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip()
die("claude-profile not found in PATH")
def spawn_session(project_dir: str, profile: str, mode: str,
resume_id: Optional[str] = None,
name_override: Optional[str] = None,
attach: bool = False) -> bool:
proj_path = Path(project_dir)
if not proj_path.is_dir():
warn(f"Project directory not found: {project_dir}")
return False
project_basename = proj_path.name
base_name = name_override or title_case(project_basename)
if resume_id:
base_name = f"{base_name} (resume)"
live = list_tmux_sessions()
name = base_name
n = 2
while name in live:
name = f"{base_name} #{n}"
n += 1
rc_flag = f'--remote-control "{name}"'
resume_flag = f"--resume {resume_id}" if resume_id else ""
extra = " ".join(filter(None, [rc_flag, resume_flag]))
claude_profile_bin = resolve_claude_profile_bin()
launch_cmd = (
f"{claude_profile_bin} {profile} --mode {mode} "
f"--project {project_basename} -- {extra}"
)
r = subprocess.run(
["tmux", "new-session", "-d", "-s", name, "-c", str(proj_path)],
capture_output=True,
)
if r.returncode != 0:
warn(f"tmux new-session failed: {r.stderr.decode().strip()}")
return False
subprocess.run(["tmux", "send-keys", "-t", name, "-l", launch_cmd])
subprocess.run(["tmux", "send-keys", "-t", name, "Enter"])
print(f"{GREEN}Spawned{NC} {BOLD}{name}{NC}")
print(f" dir: {project_dir}")
print(f" profile: {profile} / mode: {mode}")
if resume_id:
print(f" resume: {resume_id[:8]}…")
print(f" attach: tmux attach -t \"{name}\"")
if attach:
if os.environ.get("TMUX"):
subprocess.run(["tmux", "switch-client", "-t", f"={name}"])
else:
os.execvp("tmux", ["tmux", "attach", "-t", f"={name}"])
return True
# ── Commands ─────────────────────────────────────────────────────────────────
def cmd_list(args) -> None:
live = list_tmux_sessions()
if live:
print(f"\n{BOLD}Live tmux sessions:{NC}")
for s in sorted(live):
print(f" {GREEN}●{NC} {s}")
else:
print(f"\n{DIM}No active tmux sessions.{NC}")
registry = load_registry()
resumable = [s for s in registry.get("sessions", []) if s.get("tmux_name") not in live]
if resumable:
print(f"\n{BOLD}Resumable (registry, not currently live):{NC}")
fmt = " {:<4} {:<32} {:<24} {:<10} {}"
print(fmt.format("", "Project", "Profile", "Session ID", "Last active"))
print(fmt.format("", "-" * 32, "-" * 24, "-" * 10, "-" * 15))
for i, s in enumerate(resumable, 1):
proj = Path(s.get("project_dir", "")).name or s.get("project", "?")
sid = (s.get("session_id") or "")[:8] or "pending…"
ts = (s.get("last_activity") or s.get("spawned_at") or "?")[:16]
profile = s.get("profile", "?")
print(fmt.format(f"{i})", proj, profile, sid, ts))
else:
print(f"\n{DIM}Registry is empty — run 'spawn' to start tracking sessions.{NC}")
print()
def cmd_recover(args) -> None:
profiles = load_profiles()
path_map = build_path_map()
cutoff = time.time() - args.days * 86400
all_sessions: list[dict] = []
for profile_name, claude_dir in all_profile_dirs(profiles):
all_sessions.extend(scan_profile_sessions(profile_name, claude_dir, path_map, cutoff))
all_sessions.sort(key=lambda s: s["mtime"], reverse=True)
if not all_sessions:
print(f"No sessions found in the last {args.days} days.")
return
print(f"\n{BOLD}Sessions active in the last {args.days} day(s):{NC}\n")
print(f" {'#':<4} {'Project':<32} {'Profile':<24} {'Session ID':<10} {'Last Active':<16} Note")
print(f" {'-'*4} {'-'*32} {'-'*24} {'-'*10} {'-'*16} {'-'*20}")
for i, s in enumerate(all_sessions, 1):
if s["project_dir"]:
proj_raw = Path(s["project_dir"]).name
proj = proj_raw[:30] + ("…" if len(proj_raw) > 30 else "")
dim_proj = ""
else:
h = s["project_hash"]
for prefix in ("-home-paul-", "-root-", "-home-"):
if h.startswith(prefix):
h = h[len(prefix):]
break
parts = h.rsplit("-", 4)
hint = "/".join(parts[-2:]) if len(parts) >= 2 else h
proj = hint[:30]
dim_proj = DIM
sid = s["session_id"][:8]
ts = s["last_activity"]
profile_raw = s["profile"]
profile = profile_raw[:22] + ("…" if len(profile_raw) > 22 else "")
note = "" if s["project_dir"] else f"{YELLOW}path?{NC}"
proj_padded = f"{proj:<32}"
print(f" {i:<4} {dim_proj}{proj_padded}{NC if dim_proj else ''} {profile:<24} {sid:<10} {ts:<16} {note}")
print()
if not sys.stdin.isatty():
return
try:
raw = input("Resume which sessions? (e.g. 1,3 or 'all', Enter to skip): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
if not raw:
return
if raw.lower() == "all":
selected = [s for s in all_sessions if s["project_dir"]]
else:
try:
indices = [int(x.strip()) - 1 for x in raw.replace(" ", "").split(",")]
selected = [all_sessions[i] for i in indices if 0 <= i < len(all_sessions)]
except ValueError:
die("Invalid selection — expected numbers like 1,3,5 or 'all'")
return
if not selected:
return
profiles_cfg = load_profiles()
print()
for s in selected:
if not s["project_dir"]:
warn(f"Skipping: project directory not found for {s['project_hash']}")
continue
profile_cfg = profiles_cfg.get(s["profile"], {})
mode = getattr(args, "mode", None) or profile_cfg.get("default_mode", "quick")
spawn_session(project_dir=s["project_dir"], profile=s["profile"],
mode=mode, resume_id=s["session_id"])
print()
def cmd_spawn(args) -> None:
claude_tmux = subprocess.run(["which", "claude-tmux"], capture_output=True, text=True).stdout.strip()
if not claude_tmux:
die("claude-tmux not found in PATH")
cmd = [claude_tmux, args.project]
if args.mode:
cmd += ["--mode", args.mode]
if args.profile:
cmd += ["--profile", args.profile]
if args.name:
cmd += ["--name", args.name]
if args.attach:
cmd += ["--attach"]
os.execvp(claude_tmux, cmd)
def cmd_resume(args) -> None:
session_id = args.session_id
registry = load_registry()
entry = next(
(s for s in registry.get("sessions", [])
if (s.get("session_id") or "").startswith(session_id)),
None,
)
if entry:
project_dir = entry.get("project_dir", "")
profile = args.profile or entry.get("profile", "oreillyit-anthropic")
mode = args.mode or entry.get("mode", "quick")
else:
if not args.project:
die(f"Session {session_id!r} not in registry.\nProvide --project (and optionally --profile, --mode).")
profile = args.profile or "oreillyit-anthropic"
profiles_cfg = load_profiles()
mode = args.mode or profiles_cfg.get(profile, {}).get("default_mode", "quick")
path_map = build_path_map()
project_dir = next((p for p in path_map.values() if Path(p).name == args.project), None)
if not project_dir:
die(f"Project directory not found for '{args.project}'")
spawn_session(project_dir=project_dir, profile=profile, mode=mode,
resume_id=session_id, attach=args.attach)
def cmd_register(args) -> None:
"""Called by claude-tmux after spawning a session."""
registry = load_registry()
entry = {
"tmux_name": args.tmux_name,
"project": Path(args.project_dir).name,
"project_dir": args.project_dir,
"profile": args.profile,
"mode": args.mode,
"machine": HOSTNAME,
"spawned_at": datetime.now(timezone.utc).isoformat(),
"session_id": None,
"last_activity": None,
}
# Remove any prior entry for this tmux session name
registry["sessions"] = [
s for s in registry.get("sessions", [])
if s.get("tmux_name") != args.tmux_name
]
registry["sessions"].append(entry)
save_registry(registry)
def cmd_gc(args) -> None:
registry = load_registry()
profiles = load_profiles()
live = list_tmux_sessions()
cutoff = time.time() - args.days * 86400
# Backfill session IDs first
backfilled = backfill_session_ids(registry, profiles)
if backfilled:
print(f"Backfilled {backfilled} session ID(s).")
before = len(registry.get("sessions", []))
def keep(s: dict) -> bool:
if s.get("tmux_name") in live:
return True
spawned = s.get("spawned_at", "")
if spawned:
try:
ts = datetime.fromisoformat(spawned).timestamp()
return ts > cutoff
except ValueError:
pass
return False
registry["sessions"] = [s for s in registry.get("sessions", []) if keep(s)]
after = len(registry["sessions"])
save_registry(registry)
print(f"Pruned {before - after} stale entries ({after} remaining).")
# ── Interactive menu ─────────────────────────────────────────────────────────
def cmd_menu(_args=None) -> None:
"""Interactive session picker. Shown when claude-sessions is invoked with no args."""
profiles_cfg = load_profiles()
while True:
live = sorted(list_tmux_sessions())
registry = load_registry()
resumable = [
s for s in registry.get("sessions", [])
if s.get("tmux_name") not in live and s.get("session_id")
]
# Build numbered options
options: list[tuple[str, dict]] = [] # (kind, data)
for name in live:
options.append(("live", {"tmux_name": name}))
for entry in resumable:
options.append(("resumable", entry))
# ── Print menu ──
os.system("clear 2>/dev/null || true")
print(f"\n{BOLD}Claude Sessions{NC}")
print("─" * 50)
if not options:
print(f"\n {DIM}No sessions.{NC}")
else:
if live:
print(f"\n {BOLD}Live:{NC}")
for i, (kind, data) in enumerate(options, 1):
if kind != "live":
continue
idx = options.index(("live", data)) + 1
print(f" {CYAN}{idx:>2}){NC} {GREEN}●{NC} {data['tmux_name']}")
if resumable:
print(f"\n {BOLD}Resumable:{NC}")
for i, (kind, data) in enumerate(options, 1):
if kind != "resumable":
continue
proj = Path(data.get("project_dir", "")).name or data.get("project", "?")
sid = (data.get("session_id") or "")[:8]
ts = (data.get("last_activity") or data.get("spawned_at") or "?")[:16]
profile = data.get("profile", "?")
print(f" {CYAN}{i:>2}){NC} {DIM}○{NC} {proj:<28} {DIM}{profile} · {sid} · {ts}{NC}")
print(f"\n {CYAN} r){NC} Recover sessions from restart (--days 7)")
print(f" {CYAN} n){NC} New session")
print(f" {CYAN} q){NC} Quit")
print()
try:
raw = input(" Choice: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
break
if raw == "q":
break
elif raw == "r":
import types
a = types.SimpleNamespace(days=7, mode=None, attach=False)
cmd_recover(a)
input("\nPress Enter to return to menu…")
elif raw == "n":
try:
project = input(" Project name: ").strip()
except (KeyboardInterrupt, EOFError):
print()
continue
if not project:
continue
import types
a = types.SimpleNamespace(project=project, mode=None, profile=None, name=None, attach=False)
print()
cmd_spawn(a) # this exec's — won't return
input("\nPress Enter to return to menu…")
elif raw.isdigit():
idx = int(raw) - 1
if idx < 0 or idx >= len(options):
print(f" {YELLOW}Invalid choice{NC}")
time.sleep(1)
continue
kind, data = options[idx]
if kind == "live":
name = data["tmux_name"]
print(f"\n Attaching to {BOLD}{name}{NC}…")
if os.environ.get("TMUX"):
subprocess.run(["tmux", "switch-client", "-t", f"={name}"])
else:
os.execvp("tmux", ["tmux", "attach", "-t", f"={name}"])
break # exec'd — this line only reached on switch-client
elif kind == "resumable":
project_dir = data.get("project_dir", "")
profile = data.get("profile", "oreillyit-anthropic")
mode = profiles_cfg.get(profile, {}).get("default_mode", "quick")
session_id = data.get("session_id", "")
if not project_dir:
print(f" {YELLOW}Project directory unknown — cannot resume{NC}")
time.sleep(1)
continue
print()
spawn_session(project_dir=project_dir, profile=profile,
mode=mode, resume_id=session_id)
input("\nPress Enter to return to menu…")
else:
print(f" {YELLOW}Unknown choice: {raw!r}{NC}")
time.sleep(1)
# ── Entrypoint ───────────────────────────────────────────────────────────────
def main() -> None:
p = argparse.ArgumentParser(
prog="claude-sessions",
description="Session lifecycle manager for Claude Code tmux sessions",
add_help=True,
)
sub = p.add_subparsers(dest="command", metavar="COMMAND")
sub.add_parser("list", help="Show live and resumable sessions")
sub.add_parser("menu", help="Interactive session menu (default when no args)")
rp = sub.add_parser("recover", help="Detect sessions active before a restart")
rp.add_argument("--days", type=int, default=7, help="Look back N days (default: 7)")
rp.add_argument("--mode", default=None)
rp.add_argument("--attach", action="store_true")
sp = sub.add_parser("spawn", help="Spawn a new session (wraps claude-tmux)")
sp.add_argument("project", help="Project name or path")
sp.add_argument("--mode", default=None)
sp.add_argument("--profile", default=None)
sp.add_argument("--name", default=None)
sp.add_argument("--attach", action="store_true")
rsp = sub.add_parser("resume", help="Resume a past conversation by session ID")
rsp.add_argument("session_id", help="Session UUID or prefix")
rsp.add_argument("--project", default=None)
rsp.add_argument("--profile", default=None)
rsp.add_argument("--mode", default=None)
rsp.add_argument("--attach", action="store_true")
reg = sub.add_parser("register", help="Record a new session (called by claude-tmux)")
reg.add_argument("--project-dir", required=True)
reg.add_argument("--profile", required=True)
reg.add_argument("--mode", required=True)
reg.add_argument("--tmux-name", required=True)
gp = sub.add_parser("gc", help="Prune stale entries and backfill session IDs")
gp.add_argument("--days", type=int, default=30)
args = p.parse_args()
# No args + interactive TTY → menu
if not args.command:
if sys.stdin.isatty() and sys.stdout.isatty():
cmd_menu()
else:
p.print_help()
sys.exit(0)
{
"list": cmd_list,
"menu": cmd_menu,
"recover": cmd_recover,
"spawn": cmd_spawn,
"resume": cmd_resume,
"register": cmd_register,
"gc": cmd_gc,
}[args.command](args)
if __name__ == "__main__":
main()

143
scripts/claude-sessions-shutdown Executable file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# claude-sessions-shutdown — Gracefully exit all Claude Code tmux sessions.
#
# Called by the systemd user service before halt/reboot.
# Sends /exit-session to each Claude tmux session, waits for them to close,
# then force-kills any stragglers.
#
# Usage:
# claude-sessions-shutdown [--timeout N] # graceful timeout per session (default: 90)
# claude-sessions-shutdown --force # skip graceful exit, force-kill immediately
set -uo pipefail
REGISTRY="${HOME}/.local/share/claude-sessions/registry.json"
TIMEOUT=90
FORCE=0
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m'
log() { echo -e "$(date +%T) [claude-shutdown] $*"; }
warn() { echo -e "$(date +%T) [claude-shutdown] ${YELLOW}$*${NC}"; }
err() { echo -e "$(date +%T) [claude-shutdown] ${RED}$*${NC}" >&2; }
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout) TIMEOUT="${2:-90}"; shift 2 ;;
--force) FORCE=1; shift ;;
*) err "Unknown flag: $1"; exit 1 ;;
esac
done
# ── Collect Claude sessions ──────────────────────────────────────────────────
collect_claude_sessions() {
local seen=()
local sessions=()
# Source 1: registry entries with a live tmux session
if [[ -f "$REGISTRY" ]]; then
while IFS= read -r name; do
[[ -n "$name" ]] || continue
if tmux has-session -t "=$name" 2>/dev/null; then
sessions+=("$name")
seen+=("$name")
fi
done < <(python3 -c "
import json, sys
try:
d = json.load(open('$REGISTRY'))
for s in d.get('sessions', []):
n = s.get('tmux_name', '')
if n: print(n)
except Exception: pass
" 2>/dev/null)
fi
# Source 2: tmux sessions whose pane runs a node/claude child process
while IFS= read -r line; do
session="${line%% *}"
pane_pid="${line##* }"
[[ -n "$session" && -n "$pane_pid" ]] || continue
# Already captured from registry?
skip=0
for s in "${seen[@]+"${seen[@]}"}"; do [[ "$s" == "$session" ]] && { skip=1; break; }; done
(( skip )) && continue
# Check if any child of the pane PID is node (Claude Code)
if pgrep -P "$pane_pid" -x node &>/dev/null 2>&1 || \
pgrep -a -P "$pane_pid" 2>/dev/null | grep -qE 'claude|\.claude'; then
sessions+=("$session")
fi
done < <(tmux list-panes -a -F '#{session_name} #{pane_pid}' 2>/dev/null)
printf '%s\n' "${sessions[@]+"${sessions[@]}"}"
}
# ── Main ─────────────────────────────────────────────────────────────────────
if ! command -v tmux &>/dev/null; then
log "tmux not found — nothing to do"
exit 0
fi
if ! tmux ls &>/dev/null 2>&1; then
log "No tmux server running — nothing to do"
exit 0
fi
mapfile -t claude_sessions < <(collect_claude_sessions)
if [[ ${#claude_sessions[@]} -eq 0 ]]; then
log "No Claude tmux sessions found — nothing to do"
exit 0
fi
log "Found ${#claude_sessions[@]} Claude session(s): ${claude_sessions[*]}"
if (( FORCE )); then
log "Force mode: killing sessions immediately"
for name in "${claude_sessions[@]}"; do
tmux kill-session -t "=$name" 2>/dev/null && log "Killed: $name" || warn "Could not kill: $name"
done
exit 0
fi
# Send /exit-session to each Claude session
for name in "${claude_sessions[@]}"; do
log "Sending /exit-session to: $name"
# Clear any partial input, then send the command
tmux send-keys -t "=$name" "" "" 2>/dev/null || true
tmux send-keys -t "=$name" -l "/exit-session" 2>/dev/null || true
tmux send-keys -t "=$name" Enter 2>/dev/null || true
done
# Wait for sessions to close
log "Waiting up to ${TIMEOUT}s for sessions to exit…"
deadline=$(( $(date +%s) + TIMEOUT ))
remaining=("${claude_sessions[@]}")
while [[ ${#remaining[@]} -gt 0 && $(date +%s) -lt $deadline ]]; do
still_open=()
for name in "${remaining[@]}"; do
if tmux has-session -t "=$name" 2>/dev/null; then
still_open+=("$name")
else
echo -e " ${GREEN}✓${NC} $name exited cleanly"
fi
done
remaining=("${still_open[@]+"${still_open[@]}"}")
[[ ${#remaining[@]} -gt 0 ]] && sleep 2
done
# Force-kill stragglers
if [[ ${#remaining[@]} -gt 0 ]]; then
warn "${#remaining[@]} session(s) did not exit gracefully — force-killing"
for name in "${remaining[@]}"; do
tmux kill-session -t "=$name" 2>/dev/null \
&& warn "Force-killed: $name" \
|| err "Failed to kill: $name"
done
fi
log "Shutdown complete"