#!/usr/bin/env python3 """Claude Code sessions for the project repos: one tmux window each. They run on a private tmux server (`tmux -L claude`), not in terminal tabs, so they outlive any window and a script can restart them: at login, from the Update button, from the nightly timer, or from a session on the phone. claude-sessions.py list every project in ~/projects: auto-start, running, idle/busy claude-sessions.py start [NAME ...] [--open [--if-unattached]] no NAME: start every auto-start session that is missing (what login does) NAME: start that project's session now, auto-start or not claude-sessions.py stop NAME ... | --all [--force] end the session (only if idle, unless --force) and close its window claude-sessions.py autostart NAME on|off add it to, or take it off, what starts at login claude-sessions.py open [--if-unattached] show them in a terminal window (tmux attach) claude-sessions.py status one line per session claude-sessions.py restart [NAME ...] [--outdated] [--min-idle MIN] [--adopt] --outdated only sessions running an older Claude than the one installed --min-idle MIN only sessions idle at least MIN minutes (default 0) --adopt also move idle sessions running in a plain terminal tab into tmux (whatever their version: that is the one-time switch-over) claude-sessions.py continue NAME [--session ID] MESSAGE the next start of NAME resumes that conversation (default: the folder's latest) and sends MESSAGE, once. For carrying a conversation across a reboot. claude-sessions.py reboot-pending exit 0 if the Pi needs a reboot to finish an update claude-sessions.py all-idle [--min-idle MIN] exit 0 if every Claude session is idle MIN+ minutes claude-sessions.py tmux-status the right-hand text of the tab bar A session is only ever restarted while Claude reports it idle. A busy one (mid-turn, or waiting on a permission prompt) is skipped and reported, never interrupted. """ import argparse import json import os import re import shutil import signal import socket import subprocess import sys import time from pathlib import Path HERE = Path(__file__).resolve().parent CONF = Path(os.environ.get("CLAUDE_SESSIONS_CONF", HERE / "sessions.conf")) PROJECTS_ROOT = Path(os.environ.get("CLAUDE_PROJECTS_ROOT", Path.home() / "projects")) SOCKET = os.environ.get("CLAUDE_SESSIONS_SOCKET", "claude") SESSION = "claude" TMUX = ["tmux", "-L", SOCKET, "-f", str(HERE / "tmux.conf")] STATE = Path(os.environ.get("CLAUDE_SESSIONS_STATE", Path.home() / ".claude" / "sessions")) CLAUDE_BIN = Path.home() / ".local" / "bin" / "claude" CLAUDE_CMD = os.environ.get("CLAUDE_SESSIONS_CMD", "claude") RUNTIME = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) REBOOT_MARKER = RUNTIME / "pi-update-reboot-required" # tmpfs, so a reboot clears it # What runs in each window. Claude exiting drops you at a shell in that folder, # exactly like typing `claude` in a terminal tab did. `-n` names the session after # its folder, which is what the phone app and /resume list show. Without it the # name is "folder-3f", or a topic Claude picks. PANE_CMD = "bash -ic '{claude} -n {name}; exec bash -i'" # One-shot notes left by `continue`: on disk, so they survive the reboot they are for. CONTINUE_DIR = Path.home() / ".cache" / "claude-sessions" / "continue" def pane_cmd(name): """A fresh `claude`, unless `continue` left a note for this window: then resume that conversation once and hand it the note as its first message. The pane deletes the note before starting, so it is used exactly once.""" note = CONTINUE_DIR / name assert re.fullmatch(r"[\w.-]+", name) and "'" not in str(note) if not note.exists(): return PANE_CMD.format(claude=CLAUDE_CMD, name=name) sid = (CONTINUE_DIR / f"{name}.session") how = f"--resume $(cat {sid})" if sid.exists() else "--continue" return (f"bash -ic 'msg=$(cat {note}); how=\"{how}\"; rm -f {note} {sid}; " f"{CLAUDE_CMD} $how -n {name} \"$msg\"; exec bash -i'") # ── environment ──────────────────────────────────────────────────────────────── def clean_env(): """The environment new sessions inherit, whoever calls this script. ⚠ Called from inside a Claude session, os.environ carries CLAUDECODE, CLAUDE_CODE_CHILD_SESSION and the rest. A tmux server started with those would hand them to every session it spawns, and each would think it was a child of this one. So every CLAUDE* variable is dropped. """ env = {k: v for k, v in os.environ.items() if not k.startswith("CLAUDE") and k not in ("TMUX", "TMUX_PANE")} local_bin = str(Path.home() / ".local" / "bin") if local_bin not in env.get("PATH", "").split(":"): env["PATH"] = local_bin + ":" + env.get("PATH", "/usr/bin:/bin") # Started over ssh or by a timer there is no desktop in the environment; # point at the Pi's own so clipboard paste and URL opening still work. env.setdefault("XDG_RUNTIME_DIR", str(RUNTIME)) if "WAYLAND_DISPLAY" not in env and (RUNTIME / "wayland-0").exists(): env["WAYLAND_DISPLAY"] = "wayland-0" if "DISPLAY" not in env and Path("/tmp/.X11-unix/X0").exists(): env["DISPLAY"] = ":0" if "DBUS_SESSION_BUS_ADDRESS" not in env and (RUNTIME / "bus").exists(): env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path={RUNTIME / 'bus'}" return env ENV = clean_env() def tmux(*args, check=True): return subprocess.run(TMUX + list(args), capture_output=True, text=True, check=check, env=ENV) def detached(cmd): """Run cmd in its own systemd scope, outside the caller's cgroup. ⚠ Whatever starts the tmux server owns it otherwise. From the nightly timer that is a oneshot service, and systemd kills everything left in a service's cgroup when it finishes — every Claude session with it. From a Bash tool call, the call reaps its process group the same way. """ if shutil.which("systemd-run"): return ["systemd-run", "--user", "--scope", "--quiet", "--collect"] + cmd return cmd def wait_for_network(seconds=120): """At boot the desktop can come up before DNS does; Claude would start offline.""" deadline = time.time() + seconds while True: try: socket.getaddrinfo("api.anthropic.com", 443) return True except OSError: if time.time() > deadline: print(" network still down after 2 min, starting anyway") return False time.sleep(3) # ── what is running ──────────────────────────────────────────────────────────── def projects(): out = [] for line in CONF.read_text().splitlines(): line = line.split("#", 1)[0].strip() if line: out.append(Path(os.path.expanduser(line))) return out def resolve(name): """A project by name (a folder in ~/projects) or by path.""" p = Path(os.path.expanduser(name)) return p if "/" in name else PROJECTS_ROOT / name def all_projects(): """Every git checkout in ~/projects, plus anything sessions.conf names elsewhere.""" found = [d for d in sorted(PROJECTS_ROOT.iterdir()) if (d / ".git").exists()] if PROJECTS_ROOT.is_dir() else [] return list(dict.fromkeys(found + [p for p in projects() if p not in found])) def version_key(v): return tuple(int(x) for x in re.findall(r"\d+", v or "")) def latest_version(): try: return Path(os.readlink(CLAUDE_BIN)).name except OSError: return None def proc_start(pid): try: stat = Path(f"/proc/{pid}/stat").read_text() return stat.rsplit(")", 1)[1].split()[19] # field 22, counted after comm except (OSError, IndexError): return None def parent_map(): parents = {} for d in Path("/proc").iterdir(): if d.name.isdigit(): try: stat = (d / "stat").read_text() parents[int(d.name)] = int(stat.rsplit(")", 1)[1].split()[1]) except (OSError, IndexError, ValueError): pass return parents def live_sessions(): """pid -> Claude's own record of it, for interactive sessions still running.""" out = {} for f in STATE.glob("*.json"): try: d = json.loads(f.read_text()) except (OSError, ValueError): continue pid = d.get("pid") if d.get("kind") != "interactive" or not isinstance(pid, int): continue # A leftover file whose pid has since been reused by something else. if str(d.get("procStart")) != proc_start(pid): continue out[pid] = d return out def windows(): """Our tmux windows: name -> {id, pane_pid, cmd, path}.""" r = tmux("list-panes", "-s", "-t", SESSION, "-F", "#{window_name}\t#{window_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_start_path}", check=False) if r.returncode: return {} out = {} for line in r.stdout.splitlines(): name, wid, ppid, cmd, path = line.split("\t") out[name] = {"id": wid, "pane_pid": int(ppid), "cmd": cmd, "path": path} return out def ancestors(pid, parents): seen = [] while pid in parents and pid > 1 and len(seen) < 64: pid = parents[pid] seen.append(pid) return seen def survey(): """Everything status/restart need, gathered once.""" sess = live_sessions() wins = windows() parents = parent_map() pane_of = {w["pane_pid"]: name for name, w in wins.items()} in_tmux, outside = {}, {} for pid, rec in sess.items(): owner = next((pane_of[a] for a in ancestors(pid, parents) if a in pane_of), None) if owner: in_tmux[owner] = (pid, rec) else: outside.setdefault(rec.get("cwd"), []).append((pid, rec)) return wins, in_tmux, outside def transcript(rec): key = rec.get("cwd", "").replace("/", "-").replace(".", "-") return Path.home() / ".claude" / "projects" / key / f"{rec.get('sessionId')}.jsonl" def idle_minutes(rec): """Minutes idle, or None if the session is working. ⚠ Two signals, because the status flag alone has lagged: a resumed session read "idle 26m" mid-turn (2026-09-25). A conversation file written in the last minute means something is happening, whatever the flag says.""" if rec.get("status") != "idle": return None try: if time.time() - transcript(rec).stat().st_mtime < 60: return None except OSError: pass since = rec.get("statusUpdatedAt") or rec.get("updatedAt") or 0 return max(0.0, (time.time() * 1000 - since) / 60000) def describe(pid, rec, latest): v = rec.get("version", "?") old = latest and version_key(v) < version_key(latest) idle = idle_minutes(rec) state = (f"idle {idle:.0f}m" if idle is not None else "working" if rec.get("status") == "idle" else rec.get("status", "?")) return f"v{v}{' (old)' if old else ''} {state} pid {pid}" # ── actions ──────────────────────────────────────────────────────────────────── def create_window(path): name = path.name if tmux("has-session", "-t", SESSION, check=False).returncode: # The first window starts the tmux server, so it goes in its own scope. subprocess.run(detached(TMUX + ["new-session", "-d", "-s", SESSION, "-n", name, "-c", str(path), "-x", "200", "-y", "50", pane_cmd(name)]), env=ENV, check=True, capture_output=True, text=True) else: tmux("new-window", "-d", "-t", f"{SESSION}:", "-n", name, "-c", str(path), pane_cmd(name)) def stop(pid, timeout=20): """SIGTERM, then wait. Claude saves its transcript on the way out.""" try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: return True deadline = time.time() + timeout while time.time() < deadline: if not Path(f"/proc/{pid}").exists(): return True time.sleep(0.25) return False def cmd_start(args): wins, _, outside = survey() todo = [] for p in ([resolve(n) for n in args.names] if args.names else projects()): if p.name in wins: continue if not p.is_dir(): print(f" {p.name:<20} no such folder {p}, skipped") continue if str(p) in outside: pid = outside[str(p)][0][0] print(f" {p.name:<20} already running in a terminal tab (pid {pid}), left alone") continue todo.append(p) if todo: wait_for_network() for p in todo: create_window(p) print(f" {p.name:<20} started") if not todo: print(" nothing to start") if args.open: cmd_open(args) def cmd_stop(args): wins, in_tmux, _ = survey() names = list(wins) if args.all else [resolve(n).name for n in args.names] for name in names: if name not in wins: print(f" {name:<20} not running in tmux") continue if name in in_tmux: pid, rec = in_tmux[name] if idle_minutes(rec) is None and not args.force: print(f" {name:<20} {rec.get('status', '?')} — left alone (--force to stop it anyway)") continue if not stop(pid): print(f" {name:<20} did not exit within 20 s — left alone") continue tmux("kill-window", "-t", wins[name]["id"], check=False) print(f" {name:<20} stopped") def cmd_autostart(args): p = resolve(args.name) lines = CONF.read_text().splitlines() def names_line(line): entry = line.split("#", 1)[0].strip() return entry and Path(os.path.expanduser(entry)).name == p.name kept = [l for l in lines if not names_line(l)] if args.state == "on": if not p.is_dir(): sys.exit(f" no such project: {p}") home = str(Path.home()) kept.append(str(p).replace(home, "~", 1)) print(f" {p.name}: starts at login") else: print(f" {p.name}: no longer starts at login" if len(kept) < len(lines) else f" {p.name}: wasn't set to start at login") CONF.write_text("\n".join(kept) + "\n") def cmd_list(args): _, in_tmux, outside = survey() wins = windows() auto = {p.name for p in projects()} print(f" {'project':<28} {'at login':<9} {'now':<22} last commit") for p in all_projects(): name = p.name if name in in_tmux: idle = idle_minutes(in_tmux[name][1]) st = in_tmux[name][1].get("status") now = f"running, idle {idle:.0f}m" if idle is not None else f"running, {'working' if st == 'idle' else st}" elif name in wins: now = "window, no Claude" elif str(p) in outside: now = "running in a tab" else: now = "—" last = subprocess.run(["git", "-C", str(p), "log", "-1", "--format=%cs"], capture_output=True, text=True).stdout.strip() print(f" {name:<28} {'yes' if name in auto else '—':<9} {now:<22} {last}") def cmd_open(args): if tmux("has-session", "-t", SESSION, check=False).returncode: print(" no sessions in tmux to show") return if getattr(args, "if_unattached", False) and tmux("list-clients", "-t", SESSION).stdout.strip(): print(" already on screen") return subprocess.Popen( detached(["lxterminal", "--title=Claude sessions", "--geometry=180x50", f"--command=tmux -L {SOCKET} attach -t {SESSION}"]), env=ENV, start_new_session=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print(" opened a terminal on the sessions") def cmd_status(args): latest = latest_version() wins, in_tmux, outside = survey() print(f" installed Claude {latest}" + (" ⟳ REBOOT NEEDED to finish an update" if reboot_pending() else "")) paths = {p.name: str(p) for p in projects()} for name in dict.fromkeys(list(paths) + list(wins)): if name in in_tmux: pid, rec = in_tmux[name] print(f" {name:<20} tmux {describe(pid, rec, latest)}") elif name in wins: print(f" {name:<20} tmux no Claude — shell ({wins[name]['cmd']})") elif paths.get(name) in outside: for pid, rec in outside[paths[name]]: print(f" {name:<20} tab {describe(pid, rec, latest)}") else: print(f" {name:<20} — not running") def cmd_restart(args): latest = latest_version() wins, in_tmux, outside = survey() wanted = set(args.names) def eligible(name, pid, rec, outdated_only=args.outdated): if outdated_only and not (latest and version_key(rec.get("version")) < version_key(latest)): return "current" idle = idle_minutes(rec) if idle is None: return f"{rec.get('status', '?')} — left alone" if idle < args.min_idle: return f"idle only {idle:.0f}m — left alone" return None acted = 0 for name, w in wins.items(): if wanted and name not in wanted: continue if name in in_tmux: pid, rec = in_tmux[name] why = eligible(name, pid, rec) if why: print(f" {name:<20} {why}") continue if not stop(pid): print(f" {name:<20} did not exit within 20 s — left alone") continue was = rec.get("version") elif w["cmd"] == "bash": was = None # Claude had exited; the pane is an idle shell else: print(f" {name:<20} running {w['cmd']} instead of Claude — left alone") continue tmux("respawn-pane", "-k", "-t", w["id"], "-c", w["path"], pane_cmd(name)) print(f" {name:<20} restarted" + (f" ({was} → {latest})" if was else " (Claude had exited)")) acted += 1 if args.adopt: for p in projects(): if p.name in wins or str(p) not in outside or (wanted and p.name not in wanted): continue pid, rec = outside[str(p)][0] # Moving a session out of a plain tab is worth doing even when it is # current: it is the one-time switch-over, and then it is restartable. why = eligible(p.name, pid, rec, outdated_only=False) if why: print(f" {p.name:<20} (terminal tab) {why}") continue if not stop(pid): print(f" {p.name:<20} (terminal tab) did not exit within 20 s — left alone") continue create_window(p) print(f" {p.name:<20} moved from its terminal tab into tmux ({rec.get('version')} → {latest})") acted += 1 if not acted: print(" nothing restarted") def cmd_all_idle(args): """Exit 0 if no interactive Claude session anywhere is busy or recently used.""" for pid, rec in live_sessions().items(): idle = idle_minutes(rec) if idle is None or idle < args.min_idle: print(f" {Path(rec.get('cwd', '?')).name}: {'busy' if idle is None else f'idle only {idle:.0f}m'}") sys.exit(1) sys.exit(0) # ── reboot check ─────────────────────────────────────────────────────────────── def reboot_pending(): if Path("/run/reboot-required").exists() or REBOOT_MARKER.exists(): return True # A newer kernel of the running flavour installed but not booted. running = os.uname().release # 6.18.50+rpt-rpi-2712 if "+" not in running: return False flavour = "+" + running.split("+", 1)[1] try: kernels = [d.name for d in Path("/lib/modules").iterdir() if d.name.endswith(flavour)] except OSError: return False return bool(kernels) and max(kernels, key=version_key) != running def cmd_continue(args): """Next time NAME starts (a reboot, a restart), resume instead of starting fresh.""" CONTINUE_DIR.mkdir(parents=True, exist_ok=True) (CONTINUE_DIR / args.name).write_text(" ".join(args.message)) sid = CONTINUE_DIR / f"{args.name}.session" if args.session: sid.write_text(args.session) else: sid.unlink(missing_ok=True) print(f" {args.name}: next start resumes {args.session or 'its latest conversation'} with that message") def cmd_reboot_pending(args): pending = reboot_pending() print("reboot needed" if pending else "no reboot needed") sys.exit(0 if pending else 1) def cmd_tmux_status(args): latest = latest_version() _, in_tmux, _ = survey() old = sum(1 for _, rec in in_tmux.values() if latest and version_key(rec.get("version")) < version_key(latest)) parts = [] if reboot_pending(): parts.append("#[fg=colour214,bold]⟳ reboot needed#[default]") if old: parts.append(f"#[fg=colour214]{old} on old Claude#[default]") parts.append(f"claude {latest}") print(" · ".join(parts)) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) s = sub.add_parser("start") s.add_argument("names", nargs="*") s.add_argument("--open", action="store_true") s.add_argument("--if-unattached", action="store_true", help="with --open: only if no terminal shows them yet") o = sub.add_parser("open") o.add_argument("--if-unattached", action="store_true") sub.add_parser("status") sub.add_parser("list") st = sub.add_parser("stop") st.add_argument("names", nargs="*") st.add_argument("--all", action="store_true") st.add_argument("--force", action="store_true", help="stop it even if Claude is busy") au = sub.add_parser("autostart") au.add_argument("name") au.add_argument("state", choices=["on", "off"]) r = sub.add_parser("restart") r.add_argument("names", nargs="*") r.add_argument("--outdated", action="store_true") r.add_argument("--min-idle", type=float, default=0) r.add_argument("--adopt", action="store_true") c = sub.add_parser("continue") c.add_argument("name") c.add_argument("message", nargs="+") c.add_argument("--session", help="the session id to resume (default: the folder's latest)") sub.add_parser("reboot-pending") a = sub.add_parser("all-idle") a.add_argument("--min-idle", type=float, default=30) sub.add_parser("tmux-status") args = ap.parse_args() {"start": cmd_start, "open": cmd_open, "status": cmd_status, "restart": cmd_restart, "list": cmd_list, "stop": cmd_stop, "autostart": cmd_autostart, "continue": cmd_continue, "reboot-pending": cmd_reboot_pending, "all-idle": cmd_all_idle, "tmux-status": cmd_tmux_status}[args.cmd](args) if __name__ == "__main__": main()