#!/usr/bin/env python3
"""crew-handoff — move a crew agent into a fresh session before its memory is big.

No AI. It reads each agent's own transcript for how much the agent is carrying,
and when that passes the limit it:

  1. asks the agent to write a handoff file in a fixed short form and reply
     HANDOFF DONE;
  2. starts a new conversation in the same pane (/clear in Claude Code, /new in
     Codex) and has the new agent read the standing prompt and the handoff;
  3. checks the new agent names the round the chat files are actually on;
  4. only then records the new session in pairs.json and the pane script, so
     the next opening resumes the new session. Until that check passes the old
     session stays the one on record, and it is never deleted.

Why: an agent re-reads everything it carries on every step. Left alone an agent
re-read 500k-640k tokens per step (18 Sep 2026). The 200k trim (locked rules 21)
stays as the safety net; this keeps agents well under it with a clean handoff
instead of a summary nobody checked.

    crew-handoff --pair=P            watch one crew (the service pqpw starts)
    crew-handoff --status [--pair=P] print each agent's size, change nothing
    crew-handoff --now --pair=P ROLE hand that agent off now (still waits for idle)
    crew-handoff --dry-run --pair=P  watch and say what it would do
    crew-handoff --write-only --pair=P [ROLE...]
                                     have each agent write its handoff now and
                                     change nothing else; what crew-shutdown asks
                                     for before it closes the panes

Limits: CREW_HANDOFF_AT (default 150000) at any idle moment, and
CREW_HANDOFF_ROUND_END (default 120000) straight after a round closes, which is
the cheaper moment because there is no open work to carry.
"""
import importlib.machinery
import importlib.util
import json
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path

HOME = Path.home()
CREW = HOME / ".local/share/crew"
DOCS = Path(os.environ.get("CREW_DOCS", HOME / "agent-docs"))
PAIRS = CREW / "pairs.json"
PANES = CREW / "panes"
CLAUDE_HOME = Path(os.environ.get("CREW_CLAUDE_HOME", HOME / ".claude"))
CODEX_HOME = Path(os.environ.get("CREW_CODEX_HOME", HOME / ".codex"))
AT = int(os.environ.get("CREW_HANDOFF_AT", "150000"))
ROUND_END = int(os.environ.get("CREW_HANDOFF_ROUND_END", "120000"))
POLL = float(os.environ.get("CREW_HANDOFF_POLL", "30"))
# How long each step may take before the handoff is abandoned. Writing the
# handoff is one turn; reading it back is one turn plus the rule files.
WRITE_MINUTES = float(os.environ.get("CREW_HANDOFF_WRITE_MINUTES", "10"))
READ_MINUTES = float(os.environ.get("CREW_HANDOFF_READ_MINUTES", "10"))
# After a failed attempt, wait this long before trying the same agent again, so
# a stuck agent is not asked every thirty seconds.
BACKOFF_MINUTES = float(os.environ.get("CREW_HANDOFF_BACKOFF_MINUTES", "30"))
ROLES = ("coordinator", "architect", "builder")
HEADINGS = ("Round", "Open work", "Decisions and why", "Next step", "Do not touch")
DRY = "--dry-run" in sys.argv
PAIR_ARG = next((a.split("=", 1)[1] for a in sys.argv if a.startswith("--pair=")), "")
LOG = CREW / f"handoff-{PAIR_ARG or 'all'}.log"


def log(msg):
    line = time.strftime("%Y-%m-%d %H:%M:%S ") + msg
    print(line, flush=True)
    CREW.mkdir(parents=True, exist_ok=True)
    with LOG.open("a") as f:
        f.write(line + "\n")


def notify(title, body):
    # The stand-in test sets this: a test must not pop anything on his screens.
    if DRY or os.environ.get("CREW_HANDOFF_NO_NOTIFY"):
        log(f"notice not shown: {title} — {body}")
        return
    subprocess.run(["notify-send", "-u", "critical", title, body], check=False)


def pairs():
    try:
        return json.loads(PAIRS.read_text() or "{}")
    except (OSError, ValueError):
        return {}


# ------------------------------------------------------------- pane access
# Typing into a pane and reading it is the relay's job, with all its care about
# Enter keys, ghost text and the owner's half-typed lines. Use it; never a second copy.
_RELAYS = {}


def relay(pair):
    if pair not in _RELAYS:
        saved = sys.argv
        sys.argv = ["crew-relay", f"--pair={pair}"] + (["--dry-run"] if DRY else [])
        try:
            loader = importlib.machinery.SourceFileLoader(
                f"relay_{pair}", str(HOME / "bin/crew-relay"))
            mod = importlib.util.module_from_spec(
                importlib.util.spec_from_loader(loader.name, loader))
            loader.exec_module(mod)
        finally:
            sys.argv = saved
        mod.HANDOFF_SELF = True        # its own marker must not hold it
        _RELAYS[pair] = mod
    return _RELAYS[pair]


# ------------------------------------------------------------ the agent
def children(pid):
    try:
        out = subprocess.run(["ps", "-o", "pid=", "--ppid", str(pid)],
                             capture_output=True, text=True, timeout=5).stdout
    except (OSError, subprocess.SubprocessError):
        return []
    return [int(p) for p in out.split()]


def descendants(pid):
    found, todo = [], [pid]
    while todo:
        for c in children(todo.pop()):
            found.append(c)
            todo.append(c)
    return found


def pane_shell(script):
    """The shell running that pane script. Found by its command, never a title."""
    try:
        out = subprocess.run(["pgrep", "-f", f"^(/bin/)?bash (-lc )?'?{re.escape(script)}'?$"],
                             capture_output=True, text=True, timeout=5).stdout
    except (OSError, subprocess.SubprocessError):
        return None
    pids = [int(p) for p in out.split()]
    return pids[0] if pids else None


def rollout_of(pid):
    """The Codex conversation file that process has open, if any."""
    try:
        for fd in (Path(f"/proc/{pid}/fd")).iterdir():
            try:
                target = os.readlink(fd)
            except OSError:
                continue
            if "/rollout-" in target and target.endswith(".jsonl") \
                    and target.startswith(str(CODEX_HOME)):
                return Path(target)
    except OSError:
        pass
    return None


def claude_state(pid):
    try:
        return json.loads((CLAUDE_HOME / "sessions" / f"{pid}.json").read_text())
    except (OSError, ValueError):
        return None


def agent(pair, role):
    """What is running in that pane right now, read from the live process.

    pairs.json can be wrong: on 18 Sep 2026 a Architect's recorded id
    pointed at an unrelated `codex exec` run. The process itself cannot be.
    Returns {"tool", "pid", "session", "transcript"} or None.
    """
    script = (pairs().get(pair, {}).get("ids") or {}).get(role, "")
    shell = pane_shell(script) if script else None
    if not shell:
        return None
    for pid in descendants(shell):
        state = claude_state(pid)
        if state and state.get("sessionId"):
            sid = state["sessionId"]
            hits = list((CLAUDE_HOME / "projects").glob(f"*/{sid}.jsonl"))
            return {"tool": "claude", "pid": pid, "session": sid,
                    "transcript": hits[0] if hits else None,
                    "idle": state.get("status") == "idle"}
        rollout = rollout_of(pid)
        if rollout:
            # The id is the UUID at the end. Stripping the date from the front
            # also ate an id's leading digits (test, 18 Sep 2026).
            m = re.search(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$", rollout.stem)
            sid = m.group(0) if m else rollout.stem
            return {"tool": "codex", "pid": pid, "session": sid,
                    "transcript": rollout, "idle": None}
    return None


def tail_lines(path, size=400_000):
    try:
        with path.open("rb") as f:
            f.seek(0, 2)
            end = f.tell()
            f.seek(max(0, end - size))
            return f.read().decode(errors="replace").splitlines()
    except OSError:
        return []


def context_size(a):
    """Tokens the agent sends on its next step: its whole current memory."""
    if not a or not a["transcript"]:
        return None
    for line in reversed(tail_lines(a["transcript"])):
        if a["tool"] == "claude":
            if '"usage"' not in line or '"assistant"' not in line:
                continue
            try:
                u = json.loads(line)["message"]["usage"]
            except (ValueError, KeyError, TypeError):
                continue
            return (u.get("input_tokens", 0) + u.get("cache_creation_input_tokens", 0)
                    + u.get("cache_read_input_tokens", 0))
        if '"token_count"' in line:
            try:
                info = json.loads(line)["payload"]["info"]
                return info["last_token_usage"]["input_tokens"]
            except (ValueError, KeyError, TypeError):
                continue
    return None


def replies_since(path, offset):
    """Everything the agent said after byte OFFSET of its transcript."""
    out = []
    try:
        with path.open("rb") as f:
            f.seek(offset)
            data = f.read().decode(errors="replace")
    except OSError:
        return ""
    for line in data.splitlines():
        try:
            d = json.loads(line)
        except ValueError:
            continue
        if d.get("type") == "assistant":                       # Claude Code
            for part in (d.get("message") or {}).get("content") or []:
                if isinstance(part, dict) and part.get("type") == "text":
                    out.append(part.get("text", ""))
        p = d.get("payload") or {}
        if p.get("type") == "message" and p.get("role") == "assistant":   # Codex
            for part in p.get("content") or []:
                if isinstance(part, dict) and part.get("type") == "output_text":
                    out.append(part.get("text", ""))
    return "\n".join(out)


def size_of(path):
    try:
        return path.stat().st_size
    except (OSError, AttributeError):
        return 0


# ------------------------------------------------------------ the rounds
ROUND = re.compile(r"^#*\s*(START|COMPLETED?)\s+(\d+)", re.I)


def newest_round(chats):
    """The highest round number any chat file opens or closes."""
    best = 0
    for f in Path(chats).glob("*.md"):
        try:
            head = [l for l in f.read_text(errors="ignore").splitlines() if l.strip()][:5]
        except OSError:
            continue
        for line in head:
            m = ROUND.match(line.strip())
            if m:
                best = max(best, int(m.group(2)))
                break
    return best


def closed_rounds(chats):
    done = set()
    for f in Path(chats).glob("*.md"):
        try:
            first = next((l for l in f.read_text(errors="ignore").splitlines() if l.strip()), "")
        except OSError:
            continue
        m = ROUND.match(first.strip())
        if m and m.group(1).upper().startswith("COMPLETED"):
            done.add(int(m.group(2)))
    return done


# --------------------------------------------------------- waiting helpers
def wait_until(test, minutes, step=5):
    end = time.time() + minutes * 60
    while time.time() < end:
        got = test()
        if got:
            return got
        time.sleep(step)
    return test()


def idle(pair, role, a):
    """Between turns, and nothing sitting in its input box."""
    r = relay(pair)
    if a["idle"] is False or r.busy(role):
        return False
    # really_held, not typing: Claude Code draws grey suggestions in an empty
    # box, and the plain read takes them for his typing.
    return not r.really_held(role)


# ------------------------------------------------------------ titles
# the owner, 18 Sep 2026: chats carry simple numbered titles, <Role> <n>, the number
# up by one per handoff. No "PQPW" in a title; the standard lives in the rules.
TITLES = CREW / "titles.json"


def next_title(pair, role, old):
    try:
        counts = json.loads(TITLES.read_text())
    except (OSError, ValueError):
        counts = {}
    # The old chat may already be numbered by hand ("a crew — Builder 2").
    m = re.search(rf"{role}\s+(\d+)\s*$", title_of(old) or "", re.I)
    had = int(m.group(1)) if m else 1                # the first session was 1
    n = max(counts.get(f"{pair}-{role}", 1), had) + 1
    return f"{role.capitalize()} {n}", counts, n


def save_title(pair, role, counts, n):
    counts[f"{pair}-{role}"] = n
    tmp = TITLES.with_suffix(".json.new")
    tmp.write_text(json.dumps(counts, indent=1) + "\n")
    tmp.replace(TITLES)


def title_of(a):
    """The title that chat carries now, read from where each tool keeps it."""
    if a["tool"] == "claude":
        found = None
        for line in tail_lines(a["transcript"]) if a["transcript"] else []:
            if '"custom-title"' in line:
                try:
                    found = json.loads(line).get("customTitle")
                except ValueError:
                    pass
        return found
    found = None
    for line in tail_lines(CODEX_HOME / "session_index.jsonl", 200_000):
        try:
            d = json.loads(line)
        except ValueError:
            continue
        if d.get("id") == a["session"]:
            found = d.get("thread_name")
    return found


def set_title(pair, role, a, title):
    r = relay(pair)
    r.send(role, f"/rename {title}")
    if wait_until(lambda: title_of(agent(pair, role) or a) == title, 0.5, step=2):
        return True
    # Codex may answer /rename with a box asking for the name.
    if a["tool"] == "codex":
        r.send(role, title)
        return bool(wait_until(lambda: title_of(agent(pair, role) or a) == title, 0.5, step=2))
    return False


def marker(pair, role):
    return PANES / f"{pair}-{role}.handoff"


# ------------------------------------------------------------ recording
def record(pair, role, a, new_sid):
    """Make the new session the one on record: pairs.json and the pane script.

    The pane script is replaced by writing a new file and moving it over, never
    edited in place: the shell running it is still reading it.
    """
    data = pairs()
    cfg = data.get(pair, {})
    sessions = cfg.setdefault("sessions", {})
    old = (sessions.get(role) or {}).get("id", "")
    sessions[role] = {"tool": a["tool"], "id": new_sid}
    tmp = PAIRS.with_suffix(".json.new")
    tmp.write_text(json.dumps(data, indent=1) + "\n")
    tmp.replace(PAIRS)

    script = Path((cfg.get("ids") or {}).get(role, ""))
    try:
        body = script.read_text()
    except OSError:
        body = ""
    changed = body
    for sid in {a["session"], old} - {""}:
        changed = changed.replace(sid, new_sid)
    if changed != body:
        tmp = script.with_name(script.name + ".new")
        tmp.write_text(changed)
        tmp.chmod(script.stat().st_mode)
        tmp.replace(script)
    # Codex panes that remember their session in a side file.
    m = re.search(r'CODEX_ID_FILE="\$HOME/([^"]+)"', body)
    if m:
        idf = HOME / m.group(1)
        idf.parent.mkdir(parents=True, exist_ok=True)
        idf.write_text(new_sid + "\n")
    return changed != body or bool(m)


# ------------------------------------------------------------ the handoff
def standing_prompt(pair, role):
    """The pane's own opening prompt if it has one, else the rule files."""
    cfg = pairs().get(pair, {})
    script = (cfg.get("ids") or {}).get(role, "")
    try:
        body = Path(script).read_text()
    except OSError:
        body = ""
    m = re.search(r'CREW_PROMPT=\("(.+?)"\)', body, re.S)
    if m:
        return m.group(1)
    name = cfg.get("name", pair)
    facts = DOCS / f"{name} - crew facts.md"
    extra = f" and '{facts}'" if facts.exists() else ""
    return (f"You are the {role.upper()} of the {name} crew. Read "
            f"{DOCS}/'Agent crew - locked rules.md', "
            f"{DOCS}/'Agent crew - turn taking and timing.md' and "
            f"{DOCS}/'Agent crew - the message contract.md'{extra}. "
            f"Handoffs are files in '{cfg.get('chats', '')}'.")


def pending_answers(chats):
    """the owner's checklist answers from the last day, named for the new chat.

    23 Sep 2026: his a crew answers were handed to the Coordinator at 20:58 and
    the pane rolled to a fresh chat at 22:07, so the new one never saw them and
    told him they had never arrived. A pointer that lives only in a conversation
    dies with it; this puts it back in front of the agent that replaces it.
    """
    try:
        files = [f for f in Path(chats).rglob("*_ANSWERS.md")
                 if time.time() - f.stat().st_mtime < 24 * 3600]
    except OSError:
        return ""
    if not files:
        return ""
    files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
    names = "; ".join(f'"{f}"' for f in files[:3])
    return (" the owner answered a checklist in the last day and the answers are in"
            f" {names}. Read them before you ask him anything; never ask him to"
            " retype an answer he has already given.")


def go_back(pair, role, b, old_sid):
    """Put a failed handoff's pane back in the old session. Claude Code only:
    Codex's /resume opens a picker, which needs a person."""
    if b["tool"] != "claude":
        return False
    relay(pair).send(role, f"/resume {old_sid}")
    back = wait_until(lambda: (agent(pair, role) or {}).get("session") == old_sid, 1, step=2)
    log(f"{pair} {role}: " + ("back in the old session" if back else
                              "could NOT return to the old session"))
    return bool(back)


def note_paths(pair, role):
    """Where this agent's handoff and the prompt that restarts it go."""
    folder = Path(pairs().get(pair, {}).get("chats", "")) / "Handoffs"
    stamp = time.strftime("%Y-%m-%d %H%M")
    return (folder / f"{stamp} - {role.capitalize()} handoff.md",
            folder / f"{stamp} - {role.capitalize()} restart.md")


def ask_for_handoff(pair, role, a, r, note):
    """Type the request into the pane and give back a test for "it is written".

    Split from the waiting so several agents can be asked before anyone is
    waited on, which is what a shutdown needs.
    """
    offset = size_of(a["transcript"])
    ask = (f'Memory handoff. Write "{note}" with exactly these headings, a few'
           " plain lines each, no pasted file contents: # Round (the number),"
           " # Open work, # Decisions and why, # Next step, # Do not touch."
           " Then reply only: HANDOFF DONE")
    note.parent.mkdir(parents=True, exist_ok=True)
    if not r.send(role, ask):
        log(f"{pair} {role}: could not type the request; left as it was")
        return None

    def written():
        if "HANDOFF DONE" not in replies_since(a["transcript"], offset):
            return False
        try:
            text = note.read_text()
        except OSError:
            return False
        return all(re.search(rf"^#+\s*{h}\b", text, re.M | re.I) for h in HEADINGS)
    return written


def ask_and_wait(pair, role, a, r, note, minutes):
    """One agent writes its handoff now. Gives back the round it names, None if
    it names none, or False if no complete handoff arrived."""
    written = ask_for_handoff(pair, role, a, r, note)
    if written is None:
        return False
    if not wait_until(written, minutes):
        log(f"{pair} {role}: no complete handoff after {minutes:g} min;"
            " old session kept, nothing changed")
        return False
    m = re.search(r"^#+\s*Round\b.*?(\d+)", note.read_text(), re.M | re.I | re.S)
    named = int(m.group(1)) if m else None
    log(f"{pair} {role}: handoff written ({note.name}), round {named}")
    return named


def write_only(pair, roles, minutes):
    """Every named agent writes its handoff now, and nothing else changes.

    A pane that closes without one takes everything it knew with it: the next
    opening finds a stale log, starts clean under the 10MB-or-new-day rule, and
    has nothing to read. The ask can only be made while the agent is alive, so
    it belongs before the panes close, not at the next launch.

    Gives back role -> the handoff written, or None for an agent that wrote none.
    """
    r = relay(pair)
    tests, notes, out = {}, {}, {}
    for role in roles:
        a = agent(pair, role)
        if not a:
            continue
        notes[role], _ = note_paths(pair, role)
        out[role] = None
        if DRY:
            log(f"DRY would ask the {role} for {notes[role].name}")
            continue
        test = ask_for_handoff(pair, role, a, r, notes[role])
        if test:
            tests[role] = test
    if DRY or not tests:
        return out
    # Everyone is asked before anyone is waited on, so the three write at once
    # instead of one after another.
    wait_until(lambda: all(t() for t in tests.values()), minutes)
    for role, test in tests.items():
        if test():
            out[role] = notes[role]
            log(f"{pair} {role}: handoff written ({notes[role].name})")
        else:
            log(f"{pair} {role}: no handoff written; nothing was changed")
    return out


def handoff(pair, role, reason):
    cfg = pairs().get(pair, {})
    chats = Path(cfg.get("chats", ""))
    r = relay(pair)
    a = agent(pair, role)
    if not a:
        log(f"{pair} {role}: no agent running; nothing to hand off")
        return False
    note, restart = note_paths(pair, role)
    log(f"{pair} {role}: handing off ({reason}); session {a['session']}")
    if DRY:
        log(f"DRY would ask for {note.name}, then start a new session")
        return True

    flag = marker(pair, role)
    PANES.mkdir(parents=True, exist_ok=True)
    flag.write_text(f"{os.getpid()} {time.time():.0f}\n")
    try:
        # 1. The handoff, written by the agent that knows the work.
        named = ask_and_wait(pair, role, a, r, note, WRITE_MINUTES)
        if named is False:
            return False
        expected = {newest_round(chats)}

        # 2. A new conversation in the same pane. The old one stays on disk.
        old_sid = a["session"]
        r.send(role, "/clear" if a["tool"] == "claude" else "/new")

        def fresh():
            b = agent(pair, role)
            return b if b and b["session"] != old_sid else None

        if a["tool"] == "claude":
            b = wait_until(fresh, 2, step=2)
            started = bool(b)
        else:
            # Codex keeps the old file open after /new and opens the new one
            # only on the next message (real check, 18 Sep 2026). What shows the
            # new chat started is Codex's own goodbye line for the old one.
            b = None
            started = wait_until(lambda: "To continue this session" in r.pane_text(role, 40),
                                 1, step=2)
        if not started:
            log(f"{pair} {role}: no new session appeared; old session {old_sid} still"
                " on record")
            notify("Crew handoff failed", f"The {role} did not start a new session.")
            return False
        log(f"{pair} {role}: new session started" + (f" {b['session']}" if b else ""))

        # 3. The new agent reads its rules and the handoff, and says the round.
        restart.write_text(
            standing_prompt(pair, role) + "\n\n"
            f'You are picking up from a handoff. Read "{note}". Your first reply is'
            " one line, ROUND n, the round that handoff is on, and nothing else."
            " Then wait for the go-ahead."
            + pending_answers(chats) + "\n")
        # The new session's transcript may not exist until it has spoken.
        start = 0
        r.send(role, f'Read "{restart}" and follow it.')

        def answered():
            c = agent(pair, role)
            if not c or c["session"] == old_sid or not c["transcript"]:
                return None
            got = re.search(r"\bROUND\s+(\d+)", replies_since(c["transcript"], start))
            return (c, int(got.group(1))) if got else None

        res = wait_until(answered, READ_MINUTES)
        b = res[0] if res else (fresh() or b)
        expected.add(newest_round(chats))
        if named is not None:
            expected.add(named)
        if not res or res[1] not in expected:
            said = res[1] if res else "nothing"
            log(f"{pair} {role}: new session named round {said}, expected"
                f" {sorted(expected)}; NOT recorded, old session {old_sid} kept on record")
            back = go_back(pair, role, b or a, old_sid)
            notify("Crew handoff failed",
                   f"The {role}'s new session could not say which round it is on."
                   + (" It is back in its old session." if back else
                      " It is still in the new session; the old one is on record."))
            if role != "coordinator":
                r.send("coordinator", f"Handoff check failed for the {role}: its new"
                       f" session named round {said}, the chat files are on"
                       f" {max(expected)}. Its handoff is \"{note}\". Correct it"
                       " with crew-say; the old session is still the one on record.")
            return False

        # 4. Checked. Title it, record it and let the agent carry on. The title
        # comes last because a Codex chat has nothing to name until it speaks.
        title, counts, number = next_title(pair, role, a)
        if set_title(pair, role, b, title):
            save_title(pair, role, counts, number)
            log(f"{pair} {role}: titled {title}")
        else:
            log(f"{pair} {role}: could not set the title {title}; carrying on")
        record(pair, role, b, b["session"])
        log(f"{pair} {role}: round {res[1]} confirmed; recorded {b['session']},"
            f" old session {old_sid} kept on disk")
        r.send(role, "Handoff checked. Carry on from the next step in your handoff.")
        return True
    finally:
        flag.unlink(missing_ok=True)


# ------------------------------------------------------------ the watch
def status(only):
    for pair, cfg in pairs().items():
        if only and pair != only:
            continue
        for role in ROLES:
            a = agent(pair, role)
            if not a:
                print(f"{pair:10} {role:12} not running")
                continue
            n = context_size(a)
            print(f"{pair:10} {role:12} {a['tool']:7} "
                  f"{(f'{n:,}' if n is not None else '?'):>10} tokens  {a['session']}")


def watch(pair):
    chats = pairs().get(pair, {}).get("chats", "")
    seen = closed_rounds(chats)
    backoff = {}
    log(f"watching {pair}: hand off at {AT:,} tokens, or {ROUND_END:,} when a"
        " round closes" + (" DRY RUN" if DRY else ""))
    while True:
        now_closed = closed_rounds(chats)
        round_ended = bool(now_closed - seen)
        seen = now_closed
        for role in ROLES:
            if time.time() < backoff.get(role, 0):
                continue
            a = agent(pair, role)
            n = context_size(a)
            if n is None:
                continue
            reason = (f"{n:,} tokens" if n >= AT else
                      f"{n:,} tokens at a round's end" if round_ended and n >= ROUND_END
                      else None)
            if not reason or not idle(pair, role, a):
                continue
            if not handoff(pair, role, reason):
                backoff[role] = time.time() + BACKOFF_MINUTES * 60
        time.sleep(POLL)


def main():
    # systemctl stop sends SIGTERM; turn it into a normal exit so a handoff in
    # progress removes its marker instead of leaving the pane held.
    signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
    if "--status" in sys.argv:
        status(PAIR_ARG)
        return 0
    if not PAIR_ARG or PAIR_ARG not in pairs():
        sys.exit("crew-handoff: give --pair=<crew> (see pqpw list)")
    if "--write-only" in sys.argv:
        roles = [a for a in sys.argv[1:] if a in ROLES] or list(ROLES)
        got = write_only(PAIR_ARG, roles, WRITE_MINUTES)
        return 1 if [r for r, n in got.items() if not n] else 0
    if "--now" in sys.argv:
        role = next((a for a in sys.argv[1:] if a in ROLES), "")
        if not role:
            sys.exit("crew-handoff --now --pair=P coordinator|architect|builder")
        a = agent(PAIR_ARG, role)
        if not a:
            sys.exit(f"no {role} running in {PAIR_ARG}")
        if not wait_until(lambda: idle(PAIR_ARG, role, agent(PAIR_ARG, role) or a), 15):
            sys.exit(f"the {role} did not go idle in 15 minutes; nothing done")
        return 0 if handoff(PAIR_ARG, role, "asked for") else 1
    watch(PAIR_ARG)


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