#!/usr/bin/env python3
"""crew-relay — carry messages between the Architect and Builder while the owner sleeps.

Watches the audit chat folder for new handoff files the agents write, and types
"Read <file>" into the OTHER agent's Konsole pane over D-Bus. It never reads or
changes code and never touches the app.

It never stops on its own. A message for the owner or a test checklist sends a
desktop notification and the relay keeps watching (the owner's rule, 13 Sep 2026).
It exits only when an agent pane cannot be reached.

    crew-relay            run in the foreground
    crew-relay --dry-run  show what it would send, send nothing
"""
import os, re, subprocess, sys, time
from pathlib import Path

# Pairs this relay can carry. "lead" opens work with START; "worker" answers
# COMPLETED. `lead_name`/`worker_name` are the words used in handoff file names.
PAIRS = {}
# Crews created by `pqpw` register themselves here. They are found by pane
# title rather than conversation id, so a brand-new crew needs no ids.
_EXTRA = Path.home() / ".local/share/crew/pairs.json"
if _EXTRA.exists():
    import json as _json
    for _name, _cfg in _json.loads(_EXTRA.read_text() or "{}").items():
        PAIRS[_name] = dict(
            chats=Path(_cfg["chats"]),
            ids=_cfg.get("ids", {}),
            titles=_cfg.get("titles", {}),
            lead="architect", worker="builder",
            lead_name=_cfg.get("lead_name", "architect"),
            worker_name=_cfg.get("worker_name", "builder"))

PAIR_NAME = next((a.split("=", 1)[1] for a in sys.argv if a.startswith("--pair=")), "")
if PAIR_NAME not in PAIRS:
    sys.exit("no such crew: %r. Make one with: pqpw new <name> <folder>" % PAIR_NAME)
PAIR = PAIRS[PAIR_NAME]
CHATS = PAIR["chats"]
REPORTS = CHATS / "Reports"
IDS = PAIR["ids"]
LOG = Path.home() / f".local/share/crew/relay-{PAIR_NAME}.log"
STATE = Path.home() / f".local/share/crew/relay-{PAIR_NAME}.seen"
MAX_RELAYS = 150
STABLE_SECONDS = 8
# The stall alarm. A crew that stops writing files is invisible: the other
# agent simply waits. After this many minutes with no reply the relay nudges
# the agent it is waiting on, and after twice that it tells the Coordinator
# and the owner which agent is holding things up.
STALL_MINUTES = float(os.environ.get("CREW_STALL_MINUTES", "10"))
DRY = "--dry-run" in sys.argv


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


def notify(title, body):
    if DRY:
        log(f"DRY would notify: {title} — {body}")
        return
    subprocess.run(["notify-send", "-u", "critical", title, body], check=False)


def sh(*args):
    return subprocess.run(args, capture_output=True, text=True, timeout=10).stdout


PANES = Path.home() / ".local/share/crew/panes"
PANE_FRESH = 20     # seconds; the harness rewrites each screen every two


def in_harness(role):
    """True while this crew is running in crew-window rather than Konsole.

    Decided by the screen file being recent, so a harness that has closed stops
    claiming the pane and the relay falls back to Konsole on its own.
    """
    f = PANES / f"{PAIR_NAME}-{role}.screen"
    try:
        return time.time() - f.stat().st_mtime < PANE_FRESH
    except OSError:
        return False


def harness_read(role):
    try:
        return (PANES / f"{PAIR_NAME}-{role}.screen").read_text(errors="replace")
    except OSError:
        return ""


def enter_key(role):
    """The Enter keystroke that pane's program actually submits on."""
    script = IDS.get(role, "")
    try:
        body = Path(script).read_text(errors="ignore") if script else ""
    except OSError:
        body = ""
    return "\x1b[13u" if "command claude" in body else "\r"


def harness_type(role, text):
    """Hand text to the harness, which types it into that pane exactly once."""
    box = PANES / f"{PAIR_NAME}-{role}.in"
    try:
        PANES.mkdir(parents=True, exist_ok=True)
        with box.open("a") as f:
            f.write(text)
        return True
    except OSError:
        return False


def find_session(role):
    """(service, session path) of the Konsole pane running that agent.

    Found by the command that pane runs. Never by its title: a crew's three
    panes share one title and every crew's look alike (locked rules 11)."""
    ident = IDS.get(role)
    if not ident:
        # A built-in pair knows only its architect and builder. Asking for any
        # other role is a miss, not a crash: crew-look and crew-say both import
        # this and used to die with KeyError: 'coordinator'.
        return None
    for svc in re.findall(r"org\.kde\.konsole-\d+", sh("qdbus")):
        for sess in re.findall(r"^/Sessions/\d+$", sh("qdbus", svc), re.M):
            pid = sh("qdbus", svc, sess, "org.kde.konsole.Session.processId").strip()
            if pid.isdigit() and ident in sh("pstree", "-a", pid):
                return svc, sess
    return None


def pane_text(role, lines=12):
    """The last few lines that pane is showing. Empty if it cannot be reached."""
    if in_harness(role):
        return "\n".join(harness_read(role).splitlines()[-lines:])
    target = find_session(role)
    if not target:
        return ""
    out = sh("qdbus", *target, "org.kde.konsole.Session.getAllDisplayedText", "true")
    return "\n".join(out.splitlines()[-lines:])


# Live indicators only. Codex prints "Working (12s - esc to interrupt)"; Claude
# prints a spinner glyph then a word ending in an ellipsis, "* Actioning...".
# A finished line like "Crunched for 14s - done" must NOT match: a word like
# that would mark a dead agent busy forever and the alarm would never fire.
# Spinner words can contain hyphens ("Fiddle-faddling…"), and any "…(12m 3s"
# elapsed counter means a turn is running. Missing those raised false stalls.
BUSY = re.compile(r"esc to i|\bWorking\b|queued messages|thinking with|Waiting for \d+ background|\d+ monitors? still running|"
                  r"^\s*\S\s+[\w-]+\u2026|\u2026\s*\(?\d+[ms]",
                  re.M)


def busy(role):
    """True while that agent is visibly thinking. A busy agent is not stalled."""
    return bool(BUSY.search(pane_text(role)))


# What an empty input box looks like. Anything else in it is somebody's typing.
PLACEHOLDER = re.compile(
    r"^(ask (claude|codex)\b|try\b|press up to edit queued messages|"
    r"run /|/help\b|$)", re.I)
LAST_SENT = {}   # role -> the last line the relay typed into that pane
PROMPT = ("\u276f", "\u203a")


def typing(role):
    """Text sitting unsent in that pane's input box — his, or an earlier send.

    Typing into a pane appends to whatever is already there and then presses
    Enter, so sending on top of a half-typed line submits the two glued
    together. It happened to the owner on 15 September 2026, mid-sentence.
    """
    for line in reversed(pane_text(role, 20).splitlines()):
        line = line.strip().replace("\xa0", " ")
        if line.startswith(PROMPT):
            rest = line[1:].strip()
            return "" if PLACEHOLDER.match(rest) else rest
    return ""


def really_held(role):
    """What is actually IN that input box, as opposed to drawn in it.

    Claude Code draws the last thing he sent as grey ghost text in an empty box,
    so reading the pane cannot tell a stranded sentence from a finished one. The
    relay held twice on 15 September 2026 for text that was not there at all.

    So it types one space and looks again. A ghost vanishes the moment a real
    character arrives; real text keeps it and grows by one. The space is removed
    either way, and nothing is ever typed on top of something that is real.
    """
    shown = typing(role)
    if not shown:
        return ""
    target = find_session(role)
    if target:
        svc, sess = target

        def key(k):
            subprocess.run(["qdbus", svc, sess, "org.kde.konsole.Session.sendText", k],
                           capture_output=True, text=True, timeout=10)
    elif in_harness(role):
        # Same ghost probe inside the harness. Without it every Claude Code
        # prompt suggestion ("Read the next START file and continue", drawn
        # grey after a turn) was taken for real text and the relay held.
        def key(k):
            harness_type(role, k)
    else:
        return shown

    key(" ")
    # The window takes typing within half a second but only rewrites what a pane
    # shows every two. Reading sooner saw the old screen, called a grey echo
    # real typing, and held a message behind "whats the latest?" for fifteen
    # minutes on 16 Sep 2026. Wait for a fresh screen.
    time.sleep(3.5 if in_harness(role) else 1.2)
    probed = typing(role)
    key("\x7f")
    time.sleep(2.5 if in_harness(role) else 0.8)
    if probed.strip() == "":
        return ""              # it was a ghost; the box is empty
    return shown


def wait_for_empty(role, minutes=15.0):
    """Hold until that input box is empty. Never clears it; that is his text."""
    held = really_held(role)
    if not held:
        return True
    # Our own line, left sitting because Enter did not take. Finish it rather
    # than waiting on the owner for something he never typed.
    mine = LAST_SENT.get(role, "")
    if mine and (held == mine or mine.startswith(held)):
        log(f"{role} still holds our own last line; trying to submit it")
        target = find_session(role)
        if target:
            svc, sess = target

            def key(k):
                subprocess.run(["qdbus", svc, sess,
                                "org.kde.konsole.Session.sendText", k],
                               capture_output=True, text=True, timeout=10)

            # 1. Enter on its own. Costs nothing and usually takes.
            key("\r")
            time.sleep(2.0)
            if not typing(role):
                return True
            # 2. Erase it with backspaces, then RE-CHECK. Never retype onto a
            # box that still has something in it: Ctrl-U does not clear this
            # TUI, and typing on top of the old text sent the Builder the same
            # order twice on 15 September 2026.
            for _ in range(3):
                key("\x7f" * 300)
                time.sleep(1.0)
                if not typing(role):
                    break
            if typing(role):
                log(f"{role} box will not clear; leaving it alone")
                return False
            key(mine)
            time.sleep(0.6)
            key("\r")
            time.sleep(2.0)
        held = typing(role)
        if not held:
            return True
    # A pane in Auto is covered: the owner's keyboard cannot reach it, so whatever
    # is in its box was typed by an agent. Send it instead of holding and asking
    # the Coordinator — that detour stopped the crew 22 times on 16 Sep 2026.
    if (PANES / f"{PAIR_NAME}-{role}.locked").exists():
        log(f"{role} is in Auto; sending the text left in its box: {held[:50]!r}")
        harness_type(role, enter_key(role))
        time.sleep(2.0)
        if not typing(role):
            return True
    log(f"{role} has unsent text in its box; holding: {held[:60]!r}")
    # The relay never judges a line it did not write. It hands it to the
    # Coordinator, which knows the plan and can decide whether that line belongs
    # in the run — send it, replace it, or clear it — and then the crew carries
    # on. the owner's rule, 15 September 2026: assess it, do not just sit on it.
    if role != "coordinator":
        send("coordinator", f"Unsent text is sitting in the {role} box and the"
             f" relay is holding behind it: {held!r}. If you can tell from the"
             " round log that an agent wrote it, clear it and carry on. If"
             " there is any chance it is the owner's, touch nothing and ask him the"
             " one question. Never press Enter on a line you did not write.")
    deadline = time.time() + minutes * 60
    told = False
    while time.time() < deadline:
        time.sleep(2)
        if not typing(role):
            log(f"{role} box is clear; carrying on")
            return True
        if not told and time.time() > deadline - (minutes - 3) * 60:
            told = True
            notify("Crew waiting on you",
                   f"There is unsent text in the {role} pane. Send it or clear"
                   " it and the crew carries on.")
    log(f"{role} box still not clear after {minutes:g} min; message NOT sent")
    return False


PANE_LIMIT = 400   # characters; longer than this belongs in a file


def as_file(role, text):
    """Put a long message in a file and return the one line that names it.

    Locked rule 12: text typed into a pane is read again on every later turn of
    that session, for as long as it lives. A path is read once. Nineteen whole
    messages had been pasted into one crew's panes before this existed.
    """
    stamp = time.strftime("%Y-%m-%d %H%M%S")
    name = f"{stamp} - note for the {role}.md"
    path = CHATS / name
    try:
        CHATS.mkdir(parents=True, exist_ok=True)
        path.write_text(f"Note for the {role}.\n\n{text}\n")
    except OSError:
        return None
    log(f"long message written to a file instead of pasted: {name}")
    return f'Read "{path}" and continue.'


# crew-handoff moves an agent into a new session and writes this marker while it
# does. Anything typed into that pane meanwhile would land in the half-made
# handoff, so every sender waits. crew-handoff sets HANDOFF_SELF on its own copy.
HANDOFF_SELF = False


def handoff_hold(role, minutes=30.0):
    flag = PANES / f"{PAIR_NAME}-{role}.handoff"
    end, said = time.time() + minutes * 60, False
    while not HANDOFF_SELF and flag.exists() and time.time() < end:
        try:
            owner = int(flag.read_text().split()[0])
            os.kill(owner, 0)
        except (OSError, ValueError, IndexError):
            log(f"{role}: handoff marker left by a stopped run; ignoring it")
            return
        if not said:
            log(f"{role} is being handed to a new session; holding this message")
            print(f"crew: the {role} is being handed to a new session; waiting",
                  file=sys.stderr, flush=True)
            said = True
        time.sleep(5)


def send(role, text):
    handoff_hold(role)
    if len(text) > PANE_LIMIT and not text.startswith("Read "):
        line = as_file(role, text)
        if line:
            text = line
    if not wait_for_empty(role):
        return False
    if in_harness(role):
        if DRY:
            log(f"DRY would send to {role}: {text}")
            return True
        LAST_SENT[role] = text
        harness_type(role, text)
        # Wait until the window has taken the text before handing it the Enter.
        # Both in the inbox at once get read as one chunk, and Enter glued to
        # text arrives as more text: the message sits unsent.
        inbox = PANES / f"{PAIR_NAME}-{role}.in"
        for _ in range(40):
            if not inbox.exists():
                break
            time.sleep(0.25)
        time.sleep(0.6)
        # Claude Code reads Enter as ESC [ 13 u; a plain return only adds a line
        # inside its box. That left an order unsent all day on 16 Sep 2026.
        harness_type(role, enter_key(role))
        time.sleep(1.2)
        if "tab to queue message" in "\n".join(pane_text(role, 4).splitlines()):
            harness_type(role, "\t")
            log(f"{role} was busy; queued with Tab")
        log(f"sent to {role}: {text}")
        return True
    target = find_session(role)
    if not target:
        log(f"NO PANE for {role}; pausing")
        return False
    if DRY:
        log(f"DRY would send to {role}: {text}")
        return True
    svc, sess = target
    LAST_SENT[role] = text
    out = subprocess.run(["qdbus", svc, sess, "org.kde.konsole.Session.sendText", text],
                         capture_output=True, text=True, timeout=10)
    time.sleep(0.4)
    subprocess.run(["qdbus", svc, sess, "org.kde.konsole.Session.sendText", "\r"],
                   capture_output=True, text=True, timeout=10)
    # Codex ignores Enter while it is working and leaves the text sitting in its
    # input box with "tab to queue message"; Tab queues it.
    time.sleep(1.0)
    shown = subprocess.run(["qdbus", svc, sess, "org.kde.konsole.Session.getAllDisplayedText", "true"],
                           capture_output=True, text=True, timeout=10).stdout
    if "tab to queue message" in "\n".join(shown.splitlines()[-4:]):
        subprocess.run(["qdbus", svc, sess, "org.kde.konsole.Session.sendText", "\t"],
                       capture_output=True, text=True, timeout=10)
        log(f"{role} was busy; queued with Tab")
    ok = out.returncode == 0 and "AccessDenied" not in (out.stdout + out.stderr)
    log(("sent to " if ok else "FAILED sending to ") + f"{role}: {text}")
    return ok


def first_line(path):
    """The message's marker line. Normally line one, but an agent sometimes puts
    a report path above it, so check the first five non-empty lines for
    START/COMPLETED/the owner:/Coordinator: before falling back to line one.
    A markdown heading ("# START 32") still counts as the marker line."""
    try:
        lines = [l.strip().lstrip("#").strip() for l in path.read_text().splitlines() if l.strip()][:5]
    except OSError:
        return ""
    for line in lines:
        if re.match(r"^(start|completed?)\s+\d+|^(owner|coordinator):", line, re.I):
            return line
    return lines[0] if lines else ""


def classify(path):
    """Return (target, text) to relay, ('pause', why), or None.

    Routing uses the MARKER, not just the file name, because an agent may write
    its reply into the other agent's file: a COMPLETED always goes back to
    whoever sent the matching START.
    """
    name = path.name.lower()
    first = first_line(path)
    low = first.lower()
    for_coordinator = (low.startswith(("owner:", "coordinator:"))
                       or "to coordinator" in name or "to owner" in name)
    if for_coordinator and PAIR["worker_name"] + " to " in name:
        # Locked rule 3a: the Builder never writes to the Coordinator. It says
        # it to the Architect, which decides whether it is worth raising. A file
        # like this is a mistake, so it goes back to the Architect instead.
        log("Builder wrote to the Coordinator; sent to the Architect instead — "
            + path.name)
        return ("architect", f'Read "{path}" and decide whether it is worth the'
                             ' Coordinator. The Builder does not write to it.')
    if for_coordinator:
        # Only the Coordinator speaks to the owner. An agent that writes to him is
        # writing to the Coordinator, which decides whether he is needed and
        # asks him one question. The agent carries on meanwhile.
        return ("pause", f"message for the Coordinator: {path.name}")
    numbered = re.match(r"^(start|completed?)\s+\d+", low)
    # A numbered handoff whose title mentions a checklist is still a handoff;
    # only a bare checklist file or a START/COMPLETED TEST pauses for the owner.
    if re.match(r"^(start|completed?)\s+test\b", low) or ("checklist" in name and not numbered):
        return ("pause", f"a test for the owner, for the Coordinator to open: {path.name}")
    marker = re.match(r"^(start|completed?)\s+\d+", low)
    if not marker:
        return None
    is_start = marker.group(1) == "start"
    # The file name says FROM -> TO. A "Builder to Architect" file always goes
    # to the Architect. An "Architect to Builder" file goes to the Builder,
    # unless the Builder wrote its COMPLETED reply into it, which goes back.
    worker_to_lead = f"{PAIR['worker_name']} to {PAIR['lead_name']}"
    lead_to_worker = f"{PAIR['lead_name']} to {PAIR['worker_name']}"
    if worker_to_lead in name:
        target = "architect"
    elif lead_to_worker in name:
        target = "builder" if is_start else "architect"
    else:
        target = "builder" if is_start else "architect"
    return (target, f'Read "{path}" and continue.')


AGENT_ORDER = re.compile(r'^(Read "|Resume |Round \d+|START \d+|COMPLETED \d+|'
                         r'Not blocking|Shutting down|Next is )', re.I)


def submit_stuck_orders():
    """Send any agent-addressed order left typed but unsent. Never the owner's text."""
    sent = False
    for role in ("coordinator", "architect", "builder"):
        held = typing(role)
        if not held or not AGENT_ORDER.match(held):
            continue
        log(f"{role} had an order sitting unsent; pressing Enter: {held[:50]!r}")
        if in_harness(role):
            harness_type(role, enter_key(role))
        else:
            target = find_session(role)
            if target:
                svc, sess = target
                subprocess.run(["qdbus", svc, sess, "org.kde.konsole.Session.sendText",
                                enter_key(role)], capture_output=True, text=True, timeout=10)
        sent = True
    return sent


QUIET_SPELL = None   # newest chat file time when the quiet alarm last fired


def check_quiet(last_alarm):
    """The second alarm: nothing written, nobody working, whoever sent the order.

    The first alarm only arms when the relay itself carries a file. An order
    typed by the Coordinator with crew-say never armed it, so on 16 September
    2026 the whole crew sat still from 7:15 AM to 7 PM with no alarm at all.
    This one looks at the crew instead of at what the relay sent.
    """
    now = time.time()
    try:
        newest = max((f.stat().st_mtime for f in CHATS.glob("*.md")), default=0)
    except OSError:
        newest = 0
    if not newest or (now - newest) / 60.0 < STALL_MINUTES * 2:
        return last_alarm
    if any(busy(r) for r in ("coordinator", "architect", "builder")):
        return last_alarm
    # Before bothering anyone: an order the relay or a Coordinator typed that is
    # still sitting in an agent's box is plainly not the owner's — it is phrased like
    # an instruction to an agent. Send it. On 16 Sep 2026 the Coordinator asked
    # the owner to press Enter in the Architect's pane instead.
    if submit_stuck_orders():
        return now
    # Once per quiet spell. It used to repeat every 30 minutes for as long as
    # the crew sat idle, and each repeat woke the Coordinator into a full
    # re-read of its memory: 66 wake-ups in one day on 17-18 Sep 2026, most of
    # them while the crew was simply waiting on the owner. A new file on disk starts
    # a new spell.
    global QUIET_SPELL
    if QUIET_SPELL == newest:
        return last_alarm
    QUIET_SPELL = newest
    quiet = (now - newest) / 60.0
    detail = (f"Nothing has been written for {quiet:.0f} minutes and no agent is"
              " working.")
    log("QUIET: " + detail)
    notify("Crew has gone quiet", detail)
    send("coordinator", "The relay sees the whole crew idle. " + detail
         + " Look at each pane: something is probably sitting unsent in a box,"
           " or a round ended without its COMPLETED. Get it moving again yourself"
           " with crew-say. Never ask the owner to press a key or type into a pane.")
    return now


def check_stall(waiting):
    """Act on an agent that was handed work and has gone quiet.

    Step 1, after STALL_MINUTES: nudge it with the same line. Most stalls are a
    message that never landed in the input box.
    Step 2, after twice that: tell the Coordinator and the owner which agent is
    holding things up, and stop repeating.
    """
    if not waiting:
        return None
    role = waiting["role"]
    now = time.time()
    quiet = (now - waiting["at"]) / 60.0
    total = (now - waiting["since"]) / 60.0
    if quiet < STALL_MINUTES:
        return waiting
    # A pane that looks busy is given more time, but not forever: a static line
    # that happens to match would otherwise defer the alarm for good.
    if busy(role) and total < STALL_MINUTES * 3:
        waiting["at"] = now
        return waiting
    if waiting["step"] == 0:
        log(f"STALL: {role} quiet {quiet:.0f} min and not working — nudging")
        send(role, waiting["text"])
        return {**waiting, "at": time.time(), "step": 1}
    if waiting["step"] == 1:
        detail = f"The {role} has been quiet for {quiet:.0f} minutes and is not working."
        log("STALL: " + detail)
        notify("Crew stalled", detail)
        send("coordinator", "Stall alarm from the relay. " + detail
             + " It was nudged once and did not answer. Look at its pane and"
             + " decide what to do; the relay keeps watching.")
        return {**waiting, "step": 2}
    return waiting


def main():
    fresh = not STATE.exists()
    seen = set() if fresh else set(STATE.read_text().splitlines())
    markers = {}  # path -> first line when last handled
    # On a BRAND-NEW relay everything already on disk is history and is never
    # re-sent. On a restart it is not: a message written while the relay was
    # down must still be carried. Losing one is invisible — the other agent
    # simply waits forever. It happened to a crew round 19 on 15 Sep 2026.
    if fresh:
        for p in CHATS.glob("*.md"):
            seen.add(str(p))
            markers[str(p)] = first_line(p)
        for p in REPORTS.glob("*CHECKLIST*.md"):
            seen.add(str(p))
    else:
        for p in CHATS.glob("*.md"):
            if str(p) in seen:
                markers[str(p)] = first_line(p)
        missed = [p.name for p in CHATS.glob("*.md") if str(p) not in seen]
        if missed:
            log(f"carrying {len(missed)} message(s) written while it was down: "
                + ", ".join(sorted(missed)))
    STATE.write_text("\n".join(sorted(seen)))
    relays = 0
    waiting = None   # {"role": who we sent to, "text": what, "at": when, "step": 0|1|2}
    quiet_alarm = 0
    log(f"watching {CHATS} (no relay limit), stall alarm {STALL_MINUTES:g} min"
        + (" DRY RUN" if DRY else ""))
    while True:
        waiting = check_stall(waiting)
        quiet_alarm = check_quiet(quiet_alarm)
        candidates = []
        for p in list(CHATS.glob("*.md")) + list(REPORTS.glob("*CHECKLIST*.md")):
            if p.name == "ROUND_LOG.md":
                continue
            if str(p) not in seen:
                candidates.append(p)
            elif markers.get(str(p)) is not None and first_line(p) != markers[str(p)]:
                seen.discard(str(p))   # its marker changed: a reply was written into it
                candidates.append(p)
        for p in sorted(candidates, key=lambda p: p.stat().st_mtime):
            if time.time() - p.stat().st_mtime < STABLE_SECONDS:
                continue  # still being written
            seen.add(str(p))
            markers[str(p)] = first_line(p)
            STATE.write_text("\n".join(sorted(seen)))
            verdict = classify(p)
            if verdict is None:
                log(f"skip (no START/COMPLETED): {p.name}")
                continue
            kind, detail = verdict
            if kind == "pause" and "the Coordinator" in detail:
                # The relay carries it to the Coordinator itself. It used to be
                # left for `crew-coordinator-wait`, a separate watcher that died
                # quietly and took the Coordinator out of the loop with it.
                if send("coordinator", f'Read "{p}" and answer it.'):
                    now = time.time()
                    waiting = {"role": "coordinator", "text": f'Read "{p}" and answer it.',
                               "at": now, "since": now, "step": 0}
                else:
                    log("NO Coordinator pane; left on disk — " + detail)
                continue
            if kind == "pause":
                # Nothing reaches the owner except through the Coordinator, so even a
                # test goes to it: it opens the checklist beside the app and
                # tells him. The relay keeps watching either way.
                log("for the Coordinator — " + detail)
                if not send("coordinator", f'Read "{p}" and act on it.'):
                    notify("Crew needs you", detail)
                continue
            if not send(kind, detail):
                notify("Crew relay failed", f"Could not reach the {kind} pane.")
                return
            relays += 1
            # A file arrived and was carried: the crew moved. Start the clock on
            # the agent we just handed it to.
            now = time.time()
            waiting = {"role": kind, "text": detail,
                       "at": now, "since": now, "step": 0}
        time.sleep(5)


if __name__ == "__main__":
    main()
