#!/usr/bin/env python3
"""list-sessions — table of every live Claude Code session on this machine.

Read-only listing by default; stdlib only. `--kill` (detached+idle only) sends SIGTERM.
One row per live claude session, grouped by project. TITLE carries `[<slug>]` when the
session runs inside a worktree (cwd under `.worktrees/<slug>` or `.claude/worktrees/<slug>`).

Per-process CPU% (instantaneous, delta over a 1s window, % of one core) and MEM
(VmRSS) are shown; values at/above the excessive threshold — 100% CPU or 1 GiB RAM —
are printed red on a terminal.

Sources of truth, most authoritative first:
  ~/.claude/sessions/<pid>.json  (sessionId, name, cwd, status, updatedAt, kind, tmux)
  argv --resume <uuid|name> / --session-id <uuid>   (legacy sessions without a state file)
  ~/.claude/projects/*/<sessionId>.jsonl           (title = first user message, activity = file mtime)
  tmux -S ~/.local/state/human-session/tmux.sock   (per-session attached flag)

Processes that are NOT sessions are excluded: daemons, bg-spare hosts, print-mode
(-p) tool runs, and tool children re-executed by a session.
"""

import argparse
import json
import os
import re
import signal
import subprocess
import sys
import time

HOME = os.path.expanduser("~")
STATE_DIR = os.path.join(HOME, ".claude", "sessions")
PROJECTS_DIR = os.path.join(HOME, ".claude", "projects")
TMUX_SOCK = os.path.join(HOME, ".local", "state", "human-session", "tmux.sock")
PROJECTS_HOME = os.path.join(HOME, "Projects")
PLUMBING_TOKENS = {"daemon", "bg-spare", "bg-pty-host", "--output-format", "-p"}
SAMPLE_WINDOW = 1.0             # seconds the CPU% delta sample spans
CPU_EXCESSIVE = 100.0           # % of one core at/above which a session is flagged
MEM_EXCESSIVE_KB = 1024 * 1024  # VmRSS at/above which a session is flagged (1 GiB)


def is_plumbing(argv):
    if any(m in argv[0] for m in ("bg-pty-host", "bg-spare", "daemon")):
        return True
    return any(a in PLUMBING_TOKENS for a in argv[1:])


def tool_child_of_session(pid, ppid, state_pids):
    for _ in range(6):
        if ppid in state_pids:
            return True
        if ppid == "1" or ppid == "0":
            return False
        stat = read_proc(ppid, "stat").rsplit(")", 1)
        fields = stat[1].split() if len(stat) == 2 else []
        if not fields:
            return False
        comm = fields[0]
        if comm == "claude" and ppid not in state_pids:
            return False
        ppid = fields[1]
    return False


def read_proc(pid, name):
    try:
        return open(f"/proc/{pid}/{name}").read()
    except OSError:
        return ""


def stat_fields(pid):
    stat = read_proc(pid, "stat").rsplit(")", 1)
    return stat[1].split() if len(stat) == 2 else []


def jiffies_used(fields):
    if len(fields) > 14:
        try:
            return int(fields[13]) + int(fields[14])
        except ValueError:
            return None
    return None


def machine_jiffies():
    try:
        with open("/proc/stat") as f:
            line = f.readline()
    except OSError:
        return None
    if not line.startswith("cpu "):
        return None
    try:
        return sum(int(x) for x in line.split()[1:9])
    except ValueError:
        return None


def read_rss_kb(pid):
    for line in read_proc(pid, "status").splitlines():
        if line.startswith("VmRSS:"):
            try:
                return int(line.split()[1])
            except (IndexError, ValueError):
                return None
    return None


def sample_usage(pids, window=SAMPLE_WINDOW):
    """Instantaneous CPU% (per core) and VmRSS kB per pid.

    Lifetime averages hide a runaway that started recently, so cpu% comes from
    two jiffy samples taken `window` seconds apart.
    """
    ncpus = os.cpu_count() or 1
    first = {}
    for pid in pids:
        j = jiffies_used(stat_fields(pid))
        if j is not None:
            first[pid] = j
    total0 = machine_jiffies()
    if not first or total0 is None:
        return {pid: {"cpu_pct": None, "mem_kb": None} for pid in pids}
    time.sleep(window)
    total1 = machine_jiffies()
    dtotal = total1 - total0 if total1 is not None else 0
    out = {}
    for pid in pids:
        cpu = None
        if dtotal > 0 and pid in first:
            j1 = jiffies_used(stat_fields(pid))
            if j1 is not None and j1 >= first[pid]:
                cpu = (j1 - first[pid]) / dtotal * ncpus * 100
        out[pid] = {"cpu_pct": cpu, "mem_kb": read_rss_kb(pid)}
    return out


def mem_fmt(kb):
    if kb < 1024 * 1024:
        return f"{kb / 1024:.0f}M"
    return f"{kb / (1024 * 1024):.1f}G"


def claude_processes():
    """pid -> (argv, cwd, tty, ppid, start_ts) for live claude binaries."""
    try:
        uptime = float(open("/proc/uptime").read().split()[0])
    except (OSError, ValueError):
        uptime = 0
    out = {}
    for entry in os.listdir("/proc"):
        if not entry.isdigit():
            continue
        try:
            exe = os.readlink(f"/proc/{entry}/exe")
        except OSError:
            continue
        if "/claude/versions/" not in exe:
            continue
        raw = read_proc(entry, "cmdline")
        argv = [a for a in raw.split("\0") if a]
        if not argv:
            continue
        try:
            cwd = os.readlink(f"/proc/{entry}/cwd")
        except OSError:
            cwd = ""
        stat = read_proc(entry, "stat").rsplit(")", 1)
        fields = stat[1].split() if len(stat) == 2 else []
        tty = fields[4] if len(fields) > 4 else "?"
        ppid = fields[1] if len(fields) > 1 else "0"
        start_ts = time.time() - uptime + (int(fields[19]) / 100) if len(fields) > 19 and uptime else None
        out[int(entry)] = (argv, cwd, tty, ppid, start_ts)
    return out


def load_state(pid):
    try:
        with open(os.path.join(STATE_DIR, f"{pid}.json")) as f:
            return json.load(f)
    except (OSError, ValueError):
        return None


def session_id_from_argv(argv):
    for i, a in enumerate(argv):
        if a == "--":
            break
        if a in ("--resume", "--session-id") and i + 1 < len(argv):
            return argv[i + 1]
    return None


def transcript_map():
    """sessionId (with .jsonl) -> (path, mtime) across the transcript tree."""
    out = {}
    try:
        project_dirs = [d for d in os.scandir(PROJECTS_DIR) if d.is_dir()]
    except OSError:
        return out
    for d in project_dirs:
        try:
            for t in os.scandir(d.path):
                if t.is_file() and t.name.endswith(".jsonl"):
                    out.setdefault(t.name, (t.path, t.stat().st_mtime))
        except OSError:
            pass
    return out


def first_user_message(path):
    try:
        with open(path, "rb") as f:
            for _ in range(600):
                line = f.readline()
                if not line:
                    break
                try:
                    d = json.loads(line)
                except ValueError:
                    continue
                if d.get("type") != "user":
                    continue
                msg = d.get("message") or {}
                content = msg.get("content")
                if isinstance(content, str):
                    text = content
                elif isinstance(content, list):
                    text = " ".join(
                        x.get("text", "") for x in content if isinstance(x, dict)
                    )
                else:
                    continue
                text = re.sub(r"\s+", " ", text).strip()
                if text:
                    return text[:60]
    except OSError:
        pass
    return None


def tmux_attached_map():
    """session name -> (attached: 0|1, last attach epoch ts or 0)."""
    if not os.path.exists(TMUX_SOCK):
        return None
    try:
        r = subprocess.run(
            ["tmux", "-S", TMUX_SOCK, "list-sessions",
             "-F", "#{session_name} #{?session_attached,1,0} #{session_last_attached}"],
            capture_output=True, text=True, timeout=5,
        )
    except (OSError, subprocess.TimeoutExpired):
        return None
    if r.returncode != 0:
        return None
    out = {}
    for line in r.stdout.splitlines():
        parts = line.rsplit(" ", 2)
        if len(parts) != 3:
            continue
        name, att, ts = parts
        try:
            out[name] = (int(att), int(ts or 0))
        except ValueError:
            continue
    return out


TERMINAL_COMMS = ("gnome-terminal-", "vte", "xterm", "konsole", "kitty", "alacritty",
                  "wezterm", "foot", "tilix", "sshd", "mosh-server")


def _pts_holder_map():
    """Two maps built in one machine-wide pass.

    pane_map: pane tty -> (attached: bool, socket) straight from every tmux server's
    own `list-panes -a` — tmux is the only authority on which pts is one of its panes
    and whether a client is watching. Sockets come from server cmdlines (`-S`), the
    per-session sock dir, the human-session sock, and the default /tmp/tmux-<uid>.

    holders: pts -> {comm} of processes whose stdin is that pts, for the non-tmux case
    (a bare terminal emulator or sshd holding the same pts is the window)."""
    holders = {}
    socks = set()
    for pid in os.listdir("/proc"):
        if not pid.isdigit():
            continue
        try:
            t = os.readlink(f"/proc/{pid}/fd/0")
        except OSError:
            continue
        comm = read_proc(pid, "comm").strip()
        if t.startswith("/dev/pts/"):
            holders.setdefault(t, set()).add(comm)
        if comm.startswith("tmux"):
            cmd = read_proc(pid, "cmdline").split("\0")
            if "-S" in cmd:
                socks.add(cmd[cmd.index("-S") + 1])
    for d in (os.path.join(HOME, ".local", "state", "agent-sessions", "sock"),
              f"/tmp/tmux-{os.getuid()}"):
        try:
            socks.update(os.path.join(d, n) for n in os.listdir(d))
        except OSError:
            pass
    socks.add(TMUX_SOCK)
    pane_map = {}
    for sock in socks:
        try:
            r = subprocess.run(
                ["tmux", "-S", sock, "list-panes", "-a",
                 "-F", "#{pane_tty} #{?session_attached,1,0} #{window_id} #{session_name}"],
                capture_output=True, text=True, timeout=5)
        except (OSError, subprocess.TimeoutExpired):
            continue
        if r.returncode != 0:
            continue
        for line in r.stdout.splitlines():
            parts = line.split(None, 3)
            if len(parts) >= 3 and parts[0].startswith("/dev/pts/"):
                pane_map[parts[0]] = (parts[1] == "1", sock, parts[2],
                                      parts[3] if len(parts) > 3 else "")
    return holders, pane_map


_CLIENT_CACHE = {}


def _tmux_has_client(sock):
    """True/False from `list-clients` on the session's OWN socket; None if unknowable."""
    if sock in _CLIENT_CACHE:
        return _CLIENT_CACHE[sock]
    verdict = None
    try:
        r = subprocess.run(["tmux", "-S", sock, "list-clients"],
                           capture_output=True, text=True, timeout=5)
        if r.returncode == 0:
            verdict = bool(r.stdout.strip())
    except (OSError, subprocess.TimeoutExpired):
        pass
    _CLIENT_CACHE[sock] = verdict
    return verdict


def session_pts(pid):
    try:
        t = os.readlink(f"/proc/{pid}/fd/0")
    except OSError:
        return None
    return t if t.startswith("/dev/pts/") else None


def classify(state, tty, ppid, tmux_map, pts_holders=None, pane_map=None, pts=None):
    if state and state.get("kind") == "bg":
        return "headless"
    if state and state.get("tmux"):
        sess = state["tmux"].split(":", 1)[0]
        if tmux_map is not None and sess in tmux_map:
            return "attached" if tmux_map[sess][0] == 1 else "detached"
        sock = state.get("tmuxSocket") or state.get("sock")
        if sock:
            has = _tmux_has_client(sock)
            if has is True:
                return "attached"
            if has is False:
                return "detached (no window)"
        return "headless (tmux server gone)"
    if tty == "?":
        return "headless"
    if read_proc(ppid, "comm").strip() == "dtach":
        return "detached (dtach)"
    # A pts is a WINDOW only when tmux says a client watches its pane, or a terminal
    # emulator / sshd holds the same pts directly. The old fallback answered "attached"
    # whenever nothing proved otherwise — exactly how 73 clientless tmux servers hid in
    # plain sight — so unproven now reads dark, not safe.
    if pts_holders is not None and pts:
        pane = (pane_map or {}).get(pts)
        if pane is not None:
            if pane[0]:
                return f"attached {pane[2]}"
            return "detached (no window)"
        held = pts_holders.get(pts, set())
        if any(h.startswith(TERMINAL_COMMS) for h in held):
            return "attached"
        # Walk ancestry: a terminal emulator or sshd above means a real window even
        # when it holds only the pty master (invisible to the fd0 scan). Hitting pid 1
        # or the user manager with no window-shaped ancestor is proven windowless;
        # an indirect chain (systemd-run/script pty forwarding) stays honestly unknown.
        cur = ppid
        for _ in range(15):
            if not cur or cur in ("0", "1"):
                return "detached (no window)"
            comm = read_proc(cur, "comm").strip()
            if comm.startswith(TERMINAL_COMMS):
                return "attached"
            if comm.startswith("systemd") or comm in ("script", "systemd-run"):
                return "attached?"
            nxt = read_proc(cur, "stat").rsplit(")", 1)
            cur = nxt[1].split()[1] if len(nxt) == 2 and nxt[1].split() else None
        return "attached?"
    return "attached?"


def detach_proxy(state, tmux_map):
    """Last detach moment, proxied by tmux's last-attach time.

    tmux records attaches, not detaches; for a currently detached tmux session
    the last attach is the closest provable bound on when it was left.
    """
    if not state or not state.get("tmux") or not tmux_map:
        return None
    sess = state["tmux"].split(":", 1)[0]
    att, last_ts = tmux_map.get(sess, (None, None))
    if att is None or att == 1 or not last_ts:
        return None
    return last_ts


def dt(ts):
    if not ts:
        return "-"
    if abs(time.time() - ts) > 365 * 86400:
        return time.strftime("%Y-%m-%d", time.localtime(ts))
    return time.strftime("%m-%d %H:%M", time.localtime(ts))


def reltime(ts):
    if not ts:
        return "?"
    d = time.time() - ts
    if d < 90:
        return f"{int(d)}s"
    if d < 5400:
        return f"{int(d // 60)}m"
    if d < 129600:
        return f"{int(d // 3600)}h"
    return f"{int(d // 86400)}d"


def resume_command(sid, cwd):
    """The one resume-command shape this script ever prints — `cd <cwd> && claude
    --resume <sessionId>`, the same shape agent-sessions and agent-session-ledger
    already use. Every resolver path below MUST call this, never format the string
    itself, so the shape only ever changes in one place."""
    if not cwd:
        return None
    return f"cd {cwd} && claude --resume {sid}"


def build_rows(procs, trans, tmux_map, pts_holders, pane_map):
    """One row per live claude session — the same construction main() prints,
    factored out so the name/id resolver can reuse it instead of re-walking /proc."""
    state_pids = {pid for pid in procs if load_state(pid)}
    rows = []
    for pid, (argv, cwd, tty, ppid, start_ts) in sorted(procs.items()):
        state = load_state(pid)
        resume = session_id_from_argv(argv)
        real_session = bool(state) or bool(resume)

        if is_plumbing(argv):
            continue
        if not real_session:
            if tool_child_of_session(pid, ppid, state_pids) or tty == "?":
                continue

        sid = (state or {}).get("sessionId") or resume
        rec = trans.get((sid or "") + ".jsonl")
        last_ts = rec[1] if rec else None
        if last_ts is None:
            upd = (state or {}).get("updatedAt")
            if upd:
                last_ts = upd / 1000
        status = (state or {}).get("status") or "-"
        title = first_user_message(rec[0]) if rec else None
        name = (state or {}).get("name") or ""
        if not title:
            title = name or (resume or "?")
        started_at = (state or {}).get("startedAt")
        started_ts = (started_at / 1000) if started_at else start_ts
        state_cls = classify(state, tty, ppid, tmux_map, pts_holders, pane_map, session_pts(pid))
        rows.append({
            "project": project_of(cwd),
            "title": title,
            "name": name,
            "slug": slug_of(cwd),
            "session": (sid or f"pid{pid}")[:8],
            "sid": sid,
            "pid": pid,
            "state": state_cls,
            "status": status,
            "last": reltime(last_ts if last_ts is not None else start_ts),
            "last_ts": last_ts if last_ts is not None else (start_ts or -1),
            "cwd": cwd,
            "started": dt(started_ts),
            "detached": dt(detach_proxy(state, tmux_map)),
        })
    return rows


def find_dead_sessions(query, trans):
    """Sessions no longer running but still resumable from their own transcript —
    matched by session-id prefix (case-insensitive) against the transcript filename.
    cwd is read honestly from the transcript's own recorded `cwd`, never guessed from
    the project-dir slug (that mapping is lossy: '/' and '-' collide)."""
    q = query.lower()
    matches = []
    seen = set()
    for fname, (path, _mtime) in trans.items():
        sid = fname[:-len(".jsonl")] if fname.endswith(".jsonl") else fname
        if sid in seen:
            continue
        if not sid.lower().startswith(q):
            continue
        seen.add(sid)
        cwd = None
        try:
            with open(path, "rb") as f:
                for _ in range(50):
                    line = f.readline()
                    if not line:
                        break
                    try:
                        d = json.loads(line)
                    except ValueError:
                        continue
                    c = d.get("cwd")
                    if isinstance(c, str) and c:
                        cwd = c
                        break
        except OSError:
            pass
        matches.append({"sid": sid, "cwd": cwd, "path": path})
    return matches


def resolve_session(query, rows, trans):
    """Resolve what the owner can SEE (a friendly name, or a short/full session-id
    prefix) to what he needs to RESUME (full session id + cwd). Returns
    (status, payload):
      "single"    payload = the one matching row/dead-session dict
      "ambiguous" payload = list of matching rows/dead-session dicts
      "none"      payload = None
    Precedence: exact name/title match, then case-insensitive substring on
    name/title, then session-id prefix — each tier only applies if the previous
    tier had zero matches, so a short id never loses to an unrelated substring."""
    candidates = [r for r in rows if (r.get("name") or r.get("title"))]

    exact = [r for r in candidates if query == r.get("name") or query == r.get("title")]
    if exact:
        return ("single", exact[0]) if len(exact) == 1 else ("ambiguous", exact)

    q_low = query.lower()
    substr = [
        r for r in candidates
        if (r.get("name") and q_low in r["name"].lower())
        or (r.get("title") and q_low in r["title"].lower())
    ]
    if substr:
        return ("single", substr[0]) if len(substr) == 1 else ("ambiguous", substr)

    id_matches = [
        r for r in rows
        if (r.get("sid") and r["sid"].lower().startswith(q_low))
        or (r.get("session") and r["session"].lower().startswith(q_low))
    ]
    if id_matches:
        return ("single", id_matches[0]) if len(id_matches) == 1 else ("ambiguous", id_matches)

    dead = find_dead_sessions(query, trans)
    if not dead:
        return ("none", None)
    return ("single", dead[0]) if len(dead) == 1 else ("ambiguous", dead)


def print_resolution(query, rows, trans):
    """Prints what `list-sessions <query>` reports; returns the process exit code."""
    status, payload = resolve_session(query, rows, trans)
    if status == "none":
        print(f'No session matches "{query}".')
        return 1
    if status == "ambiguous":
        print(f'"{query}" matches {len(payload)} sessions — be more specific:')
        for r in payload:
            label = r.get("name") or r.get("title") or r.get("sid") or "?"
            sid = r.get("sid") or "?"
            cwd = r.get("cwd") or "(directory unknown)"
            print(f"  {label}  [{sid[:8] if sid != '?' else '?'}]  {cwd}")
        return 1
    row = payload
    sid = row.get("sid")
    cwd = row.get("cwd")
    label = row.get("name") or row.get("title") or sid or query
    if not sid:
        print(f'"{query}" matched {label!r} but it has no session id on record — cannot resume it.')
        return 1
    cmd = resume_command(sid, cwd)
    if cmd is None:
        print(f'"{query}" resolved to session {sid} — directory unknown, cannot build a resume command.')
        return 1
    print(cmd)
    return 0


def slug_of(cwd):
    """Worktree slug embedded in cwd (`.worktrees/<slug>` / `.claude/worktrees/<slug>`), else None."""
    if not cwd:
        return None
    m = re.search(r"/(?:\.worktrees|\.claude/worktrees)/([^/]+)", cwd)
    if not m:
        return None
    seg = m.group(1)
    if seg.endswith(" (deleted)"):
        seg = seg[: -len(" (deleted)")]
    if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", seg):
        return None
    return seg


def project_of(cwd):
    if not cwd or not cwd.startswith(PROJECTS_HOME + "/"):
        return "~"
    return cwd[len(PROJECTS_HOME) + 1 :].split("/", 1)[0]


def kill_detached_idle(rows):
    """Victims of --kill: sessions that are both detached and idle."""
    self_pid = os.getpid()
    victims = [r for r in rows if r["state"].startswith("detached")
               and r["status"] == "idle" and r["pid"] != self_pid]
    killed, skipped = [], []
    for r in victims:
        try:
            exe = os.readlink(f"/proc/{r['pid']}/exe")
        except OSError:
            skipped.append((r, "already gone"))
            continue
        if "/claude/versions/" not in exe:
            skipped.append((r, "no longer a claude session"))
            continue
        try:
            os.kill(r["pid"], signal.SIGTERM)
            killed.append(r)
        except OSError as e:
            skipped.append((r, str(e)))
    return killed, skipped


def main():
    ap = argparse.ArgumentParser(
        description="List live Claude Code sessions (read-only)",
        epilog=("filters: repeat a flag to OR its values; flags combine as AND.\n"
                "  -t attached|detached|headless  exact session state\n"
                "  -s busy|idle|waiting           status from the session state file\n"
                "  -c STRING                      case-insensitive cwd path substring\n"
                "  --kill                          terminate every session that is BOTH\n"
                "                                  detached AND idle (SIGTERM; no prompt)\n"
                "examples:\n"
                "  list-sessions -t attached -s busy\n"
                "  list-sessions -c .worktrees -c collector\n"
                "  list-sessions --kill"),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    ap.add_argument("--json", action="store_true", help="emit machine-readable JSON")
    ap.add_argument("-t", "--type", action="append", choices=["attached", "detached", "headless"],
                    metavar="STATE", help="only sessions in this state")
    ap.add_argument("-s", "--status", action="append", choices=["busy", "idle", "waiting"],
                    metavar="STATUS", help="only sessions with this status")
    ap.add_argument("-c", "--cwd", action="append", metavar="STRING",
                    help="only sessions whose cwd path contains STRING (case-insensitive)")
    ap.add_argument("--kill", action="store_true",
                    help="terminate every session that is both detached AND idle")
    ap.add_argument("query", nargs="?", metavar="NAME-OR-ID",
                     help="resolve a friendly session name or a session-id prefix "
                          "(as seen in Claude Code's ListAgents/peer-session listing) "
                          "to a resume command, checking live sessions then dead "
                          "transcripts; prints nothing else and exits non-zero unless "
                          "exactly one session matches")
    args = ap.parse_args()

    trans = transcript_map()

    if args.query:
        procs = claude_processes()
        tmux_map = tmux_attached_map()
        pts_holders, pane_map = _pts_holder_map()
        rows = build_rows(procs, trans, tmux_map, pts_holders, pane_map)
        return print_resolution(args.query, rows, trans)

    procs = claude_processes()
    if not procs:
        print("no claude sessions running")
        return 0

    tmux_map = tmux_attached_map()
    pts_holders, pane_map = _pts_holder_map()
    rows = build_rows(procs, trans, tmux_map, pts_holders, pane_map)

    if not args.kill and rows:
        usage = sample_usage([r["pid"] for r in rows])
        for r in rows:
            u = usage.get(r["pid"], {})
            r["cpu_pct"] = u.get("cpu_pct")
            r["mem_kb"] = u.get("mem_kb")

    def keep(r):
        if args.type and r["state"].split(" ", 1)[0] not in args.type:
            return False
        if args.status and r["status"] not in args.status:
            return False
        if args.cwd:
            low = r["cwd"].lower()
            if not any(s.lower() in low for s in args.cwd):
                return False
        return True

    total = len(rows)

    if args.kill:
        killed, skipped = kill_detached_idle(rows)
        remaining = total - len(killed) - len(skipped)
        if args.json:
            for r in killed:
                r.pop("last_ts", None)
            print(json.dumps({
                "killed": killed,
                "skipped": [(r["pid"], why) for r, why in skipped],
                "remaining": remaining,
            }, indent=1))
            return 0
        for r in killed:
            print(f"killed {r['pid']}  {r['title'][:60]}")
        for r, why in skipped:
            print(f"skipped {r['pid']}  {r['title'][:60]}  ({why})")
        print(f"Killed {len(killed)} of {total} sessions ({len(skipped)} skipped); {remaining} remain")
        return 0

    rows = [r for r in rows if keep(r)]

    if args.json:
        for r in rows:
            r.pop("last_ts", None)
        print(json.dumps(rows, indent=1))
        return 0

    busy = sum(1 for r in rows if r["status"] == "busy")
    attached = sum(1 for r in rows if r["state"].startswith("attached"))
    summ = f"{len(rows)} sessions — {busy} busy, {attached} attached"
    if len(rows) != total:
        summ += f" (of {total} total)"
    if not rows:
        print(summ)
        return 0
    rows.sort(key=lambda r: r["last_ts"], reverse=True)
    projects = {}
    for r in rows:
        projects.setdefault(r["project"], []).append(r)
    projects = dict(
        sorted(projects.items(), key=lambda kv: kv[1][0]["last_ts"], reverse=True)
    )

    use_color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")

    def cell(text, excessive):
        if use_color and excessive:
            return f"\x1b[1;31m{text}\x1b[0m"
        return text

    hdr = f"{'TITLE':46} {'SESSION':10} {'PID':8} {'CPU':6} {'MEM':6} {'STATE':22} {'STATUS':9} {'LAST':7} {'STARTED':12} {'DETACHED':12} CWD"
    print(hdr)
    print("-" * len(hdr))
    for proj, sess in projects.items():
        print(f"[{proj}]")
        for r in sess:
            cpu = r.get("cpu_pct")
            mem = r.get("mem_kb")
            cp_s = f"{cpu:.0f}%" if cpu is not None else "-"
            me_s = mem_fmt(mem) if mem is not None else "-"
            title_col = f"[{r['slug']}] {r['title']}" if r["slug"] else r["title"]
            print(f"{title_col[:46]:46} {r['session']:10} {r['pid']:<8} "
                  f"{cell(f'{cp_s:>6}', cpu is not None and cpu >= CPU_EXCESSIVE)} "
                  f"{cell(f'{me_s:>6}', mem is not None and mem >= MEM_EXCESSIVE_KB)} "
                  f"{r['state']:22} {r['status']:9} {r['last']:7} "
                  f"{r['started']:12} {r['detached']:12} {r['cwd'][:44]}")
    print()
    print(summ)


if __name__ == "__main__":
    sys.exit(main())