#!/usr/bin/env python3
"""crews — Crews Lite: one window that runs every crew on this computer.

The full product this became the prototype for is a full desktop crew app.

    crews              the window
    crews <pair>       the window, with that crew already open

Each crew is three real terminals you type in exactly as before. The panel down
the left lists the crews, shows the one you are in — its alerts, its round
state, what each agent is doing — and carries the buttons. It slides out of the
way when you want the width.

It replaces Konsole for running crews, and it keeps the relay working without
Konsole. For each pane it writes:

    ~/.local/share/crew/panes/<pair>-<role>.screen   what that pane is showing
    ~/.local/share/crew/panes/<pair>-<role>.in       text to type into it, once

The logic in here — reading a pane, telling ghost text from real typing,
counting rounds, the alerts — is written down in
`Agent crew - turn taking and timing.md` in the rules folder so it can be built again
in Rust inside the desktop app.
"""
import os
import re
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import unquote, urlparse

import gi

# crewlib sits beside this file. The parts of Crews Lite that are not the
# window live there, so they can be read and tested without starting GTK.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from crewlib.check import check  # noqa: E402
from crewlib.files import (BIN, CREW, HOME, PANES, ROLES,  # noqa: E402
                           private_dir, write_private)
from crewlib.read import (deliveries, describe, last_handoff,  # noqa: E402
                          mark_relay)
from crewlib.state import (CHOICES, DEFAULTS, EFFORTS, is_crews,  # noqa: E402
                           record_sessions, registry, rounds,
                           standalone_panes, unit_state)

gi.require_version("Gdk", "3.0")
gi.require_version("GdkPixbuf", "2.0")
gi.require_version("Gtk", "3.0")
gi.require_version("Vte", "2.91")
from gi.repository import (Gdk, GdkPixbuf, GLib, Gtk,  # noqa: E402
                           Pango, Vte)






# the desktop app's own tokens (app/lib/main.dart), so Crews Lite reads as a version of it.
BG = "#070C12"
SURFACE = "#080D14"
ACCENT = "#27D7F2"
GREEN = "#33FF44"
ORANGE = "#FF8A2A"
DANGER = "#FF4242"
FAINT = "#596678"
EDGE = "#1C2B3A"
TINT = {role: SURFACE for role in ("coordinator", "architect", "builder")}
# One symbol per job, shown at the top of its pane and beside it in the panel.
SYMBOL = {"coordinator": "🧭", "architect": "📐", "builder": "🔨"}
INK = "#EAF1F7"
MUTED = "#8190A5"
PAPER = BG
TONE = {"bad": DANGER, "warn": ORANGE, "info": MUTED, "good": GREEN}

# Who is working is shown on each agent's own row and pane header, not as one
# light that says "someone". What is left are the two that mean something on
# their own: a question for the owner, and the crew having nothing left to do. "All
# done" sits last, and when it is lit nothing above it can be.
LIGHTS = [
    ("waiting", "Waiting on you"),
    ("done", "All done"),
]
FLASH_SECONDS = 12   # how long a pane shows that a message just arrived

SCRAPE_SECONDS = 2
PANEL_WIDTH = 260
STALL_MINUTES = 10
# An agent that quits is started again once, on the same saved chat. If it quits
# again within this many seconds, the pane shows a Restart button instead of
# looping. Codex quits after updating itself ("Please restart Codex"), which left
# the desktop app's Architect pane dead on 19 Sep 2026.
RESTART_WINDOW = 60

CSS = f"""
* {{ font-family: "Noto Sans"; }}
window {{ background: {BG}; border: 4px solid #1B4352; }}
label  {{ color: {INK}; font-size: 13px; }}
separator {{ background: {EDGE}; min-height: 1px; min-width: 1px; }}
.sidebar, .sidebar viewport {{
    background-image: linear-gradient(to bottom right, #103B43, #0A1A24 45%, #0A111A);
    border-right: 1px solid #263747;
}}
button {{
    background: #0D131D; background-image: none; color: {MUTED};
    border: 1px solid {EDGE}; border-radius: 8px;
    padding: 4px 10px; font-size: 12px; font-weight: bold; min-height: 0;
    box-shadow: none; text-shadow: none; -gtk-icon-shadow: none;
    transition: all 150ms ease-out;
}}
button:hover  {{ background: #10283A; color: {INK}; border-color: {ACCENT}; }}
button:active {{ background: #13273A; }}
button.on     {{ background: #10283A; color: {INK}; border-color: {ACCENT}; }}
button.go     {{ background: #10283A; color: {ACCENT}; border-color: {ACCENT}; }}
button.go:hover {{ box-shadow: 0 0 10px rgba(39, 215, 242, 0.35); }}
button.hold   {{ background: #1f1408; color: {ORANGE}; border-color: {ORANGE}; }}
button.plus   {{ border-radius: 999px; color: {ACCENT}; padding: 2px 9px; }}
button.plain  {{ border: 0; padding: 2px 6px; color: {MUTED}; background: transparent; }}
combobox button {{ padding: 3px 8px; font-size: 12px; }}
combobox {{ font-size: 12px; }}
menu, .menu, combobox window {{ background: #111824; color: {INK}; border: 1px solid #263747; }}
menuitem:hover {{ background: #10283A; }}
button.restart {{
    background: #1f1408; color: {ORANGE}; border-color: {ORANGE};
    padding: 8px 18px; font-size: 13px;
}}
button.handle {{
    border: 0; border-radius: 0; padding: 0; margin: 0; min-width: 10px;
    background: #0A111A; color: {FAINT}; font-size: 11px;
    border-right: 1px solid {EDGE};
}}
button.handle:hover {{ background: #10283A; color: {ACCENT}; box-shadow: none; }}
/* The Auto | Manual toggle is the desktop app's own segmented control. */
.pill {{ background: rgba(9, 16, 25, 0.4); border: 1px solid #17293A; border-radius: 9px; padding: 2px; }}
button.pill-left, button.pill-right {{
    border: 1px solid transparent; background: transparent; color: {MUTED};
    padding: 3px 10px; min-height: 0; font-size: 11px; border-radius: 7px;
}}
button.pill-on {{ background: #13273A; color: {INK}; border-color: #20465E; }}
button.pill-left:hover:not(.pill-on), button.pill-right:hover:not(.pill-on) {{
    color: {INK}; background: transparent; box-shadow: none;
}}
/* Each agent is a card, outlined in the accent while it works. */
.pane {{
    background: {SURFACE}; border: 1px solid {EDGE}; border-radius: 10px;
    margin: 6px 3px; padding: 2px 6px 6px 6px;
    transition: border-color 300ms ease-out, box-shadow 300ms ease-out;
}}
.pane.working {{ border-color: {ACCENT}; box-shadow: 0 0 14px rgba(39, 215, 242, 0.18); }}
.pane.fresh   {{ border-color: {GREEN};  box-shadow: 0 0 14px rgba(51, 255, 68, 0.22); }}
.paneheader {{ min-height: 30px; padding: 6px 4px 5px 4px; border-bottom: 1px solid {EDGE}; margin-bottom: 4px; }}
/* A status dot that breathes while its agent works, as the desktop app's tab dots do. */
.dot {{ min-width: 10px; min-height: 10px; border-radius: 999px; background: #1C2B3A; margin: 0 2px; }}
.dot.working {{ background: {ORANGE}; animation: breathe 3s ease-in-out infinite; }}
.dot.done    {{ background: {GREEN}; box-shadow: 0 0 8px rgba(51, 255, 68, 0.6); }}
.dot.warn    {{ background: {ORANGE}; box-shadow: 0 0 8px rgba(255, 138, 42, 0.6); }}
.dot.gone    {{ background: {DANGER}; }}
@keyframes breathe {{
    0%   {{ background: {ORANGE}; box-shadow: 0 0 10px 1px rgba(255, 138, 42, 0.6); }}
    50%  {{ background: rgba(255, 138, 42, 0.45); box-shadow: 0 0 16px 2px rgba(255, 138, 42, 0.27); }}
    100% {{ background: {ORANGE}; box-shadow: 0 0 10px 1px rgba(255, 138, 42, 0.6); }}
}}
.badge {{ border-radius: 999px; padding: 0 8px; font-size: 11px; font-weight: bold; }}
.badge.mail {{ color: {GREEN}; border: 1px solid {GREEN}; background: rgba(51, 255, 68, 0.08); }}
.badge.auto {{ color: {MUTED}; border: 1px solid #20465E; background: #13273A; }}
.inputcover {{
    background: rgba(7, 12, 18, 0.95); border-top: 1px solid #1B4352;
    border-radius: 0 0 8px 8px;
}}
""".encode()



# Pasted pictures: where they are kept, and the longest edge they keep.
# 1000 pixels is about 900 tokens, which the image guard lets through.
PASTES = CREW / "pastes"
PASTE_PIXELS = 1000

# What bold text is drawn in, in every pane.
BOLD_INK = "#FFD54A"


def pane_font(size):
    """The pane font, drawn light so that bold is unmistakable.

    Noto Sans Mono bold puts only 16% more ink on the screen than its
    regular, and Claude Code paints its own colour over the bold colour
    below, so a **Complete:** label read as plain text. Against a light
    weight the same bold is obvious, whatever colour is painted on it.
    """
    font = Pango.FontDescription(f"Noto Sans Mono {size}")
    font.set_weight(Pango.Weight.LIGHT)
    return font

# ANSI colours in the desktop app's hues, so what the agents print sits in the same world.
PALETTE = ["#0B111A", DANGER, GREEN, "#FFD54A", "#4D8DFF", "#C792EA", ACCENT, "#C9D4E2",
           "#3A4A5E", "#FF7A7A", "#7DFF88", "#FFE27A", "#7FB0FF", "#DDB3FF", "#7BE8F8", INK]


def rgba(colour):
    c = Gdk.RGBA()
    c.parse(colour)
    return c


def set_dot(dot, state):
    """Status dots are drawn by the stylesheet: breathing orange while working,
    green when done, amber when it wants a look, dark when nothing."""
    ctx = dot.get_style_context()
    for cls in ("working", "done", "warn", "gone"):
        ctx.remove_class(cls)
    if state:
        ctx.add_class(state)


def escape(text):
    return GLib.markup_escape_text(text or "")




































def clipboard_image():
    """The picture on the clipboard, whichever clipboard is really holding it.

    This is a Wayland desktop and the window may be running on either backend.
    A screenshot copied by Spectacle sits on the Wayland clipboard, and GTK
    asked through XWayland saw nothing there at all, so wl-paste is asked
    first and GTK is the fallback.
    """
    try:
        kinds = subprocess.run(["wl-paste", "--list-types"], capture_output=True,
                               text=True, timeout=5).stdout
        for kind in ("image/png", "image/jpeg", "image/bmp"):
            if kind in kinds:
                raw = subprocess.run(["wl-paste", "--type", kind],
                                     capture_output=True, timeout=10).stdout
                if raw:
                    loader = GdkPixbuf.PixbufLoader()
                    loader.write(raw)
                    loader.close()
                    return loader.get_pixbuf()
    except (OSError, subprocess.SubprocessError, GLib.Error):
        pass
    got = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD).wait_for_image()
    if got is not None:
        return got
    return clipboard_image_file()


def clipboard_image_file():
    """The picture a copied FILE PATH points at, or None.

    Spectacle is set to copy the path of the shot it just saved, not the
    picture itself, so the clipboard holds text that names a png.
    """
    try:
        text = subprocess.run(["wl-paste", "--no-newline"], capture_output=True,
                              text=True, timeout=5).stdout.strip()
    except (OSError, subprocess.SubprocessError):
        text = ""
    if not text:
        text = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD).wait_for_text() or ""
        text = text.strip()
    if text.startswith("file://"):
        text = unquote(urlparse(text).path)
    if len(text.splitlines()) != 1:
        return None
    spot = Path(text).expanduser()
    if spot.suffix.lower() not in (".png", ".jpg", ".jpeg", ".bmp", ".webp"):
        return None
    if not spot.is_file():
        return None
    try:
        return GdkPixbuf.Pixbuf.new_from_file(str(spot))
    except GLib.Error:
        return None


class CrewPage(Gtk.Box):
    """One crew: three terminals, and the two files that let the relay in."""

    def __init__(self, pair, cfg, harness):
        super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=0, homogeneous=True)
        self.pair, self.cfg, self.harness = pair, cfg, harness
        self.terms, self.pids = {}, {}
        self.headers = {}
        self.locked = {}
        self.covers = {}
        self.font_size = {}
        self.spawn_args = {}
        self.restarted_at = {}
        self.restart_buttons = {}
        self.pending = set()   # roles whose automatic restart is already queued
        self.tick = 0
        self.closed = False
        self.zoomed = None
        private_dir(PANES)
        for i, role in enumerate(ROLES):
            # Each job is its own card; the gap between cards separates them.
            self.pack_start(self.make_pane(role), True, True, 0)
        GLib.timeout_add_seconds(SCRAPE_SECONDS, self.scrape)
        GLib.timeout_add(500, self.watch_inbox)
        GLib.timeout_add(500, self.watch_close)
        GLib.timeout_add(450, self.animate)

    def make_pane(self, role):
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        box.get_style_context().add_class("pane")
        header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        header.get_style_context().add_class("paneheader")
        dot = Gtk.Box()
        dot.get_style_context().add_class("dot")
        dot.set_valign(Gtk.Align.CENTER)
        header.pack_start(dot, False, False, 0)
        name = Gtk.Label(xalign=0)
        name.set_markup(
            f"<span size='large'>{SYMBOL[role]}</span>  "
            f"<span weight='heavy' letter_spacing='1600' foreground='{INK}'>"
            f"{role.upper()}</span>")
        header.pack_start(name, False, False, 0)
        state = Gtk.Label(xalign=0)
        header.pack_start(state, False, False, 0)
        auto = Gtk.Label(label="auto")
        auto.get_style_context().add_class("badge")
        auto.get_style_context().add_class("auto")
        auto.set_no_show_all(True)
        header.pack_end(auto, False, False, 0)
        mail = Gtk.Label(label="📨 new message")
        mail.get_style_context().add_class("badge")
        mail.get_style_context().add_class("mail")
        mail.set_no_show_all(True)
        for widget in (auto, mail, name, state):
            widget.set_valign(Gtk.Align.CENTER)
        header.pack_end(mail, False, False, 0)
        zoom = Gtk.Button(label="\u2922")
        zoom.set_relief(Gtk.ReliefStyle.NONE)
        zoom.set_valign(Gtk.Align.CENTER)
        zoom.set_tooltip_text("Fill the window with this chat")
        zoom.connect("clicked", self.toggle_zoom, role)
        header.pack_end(zoom, False, False, 0)
        self.headers[role] = {"box": box, "dot": dot, "state": state,
                              "mail": mail, "auto": auto, "zoom": zoom,
                              "working": False}
        box.pack_start(header, False, False, 0)

        term = Vte.Terminal()
        term.set_scrollback_lines(20000)
        term.set_font(pane_font(11))
        term.set_mouse_autohide(True)
        # Both masks: a modern mouse or touchpad sends only smooth scroll
        # events, and a widget that has not asked for them never sees the
        # wheel at all, so Ctrl+wheel did nothing.
        term.add_events(Gdk.EventMask.SCROLL_MASK
                        | Gdk.EventMask.SMOOTH_SCROLL_MASK)
        term.connect("scroll-event", self.on_terminal_scroll, role)
        fg = Gdk.RGBA(); fg.parse(INK)
        bg = Gdk.RGBA(); bg.parse(TINT[role])
        term.set_colors(fg, bg, [rgba(c) for c in PALETTE])
        term.set_color_cursor(rgba(ACCENT))
        # Bold in a colour, not just a heavier face. Measured on this machine,
        # Noto Sans Mono's bold puts only 16% more ink on the screen than its
        # regular, so the agents' **Complete:** labels were invisible however
        # correctly they were written. Amber makes every label findable.
        term.set_color_bold(rgba(BOLD_INK))
        # A cover that sits over the bottom of the pane, where the agent draws
        # its input box. In Auto it hides that box and blocks clicks on it; the
        # agent's work above stays readable, and the relay still types in.
        overlay = Gtk.Overlay()
        overlay.add(term)
        cover = Gtk.EventBox()
        cover.get_style_context().add_class("inputcover")
        cover.set_valign(Gtk.Align.END)
        cover.set_halign(Gtk.Align.FILL)
        cover.set_no_show_all(True)
        note = Gtk.Label()
        note.set_markup(f"<span foreground='{MUTED}'>\U0001F512  Auto \u2014 "
                        "this agent is driven by the crew</span>")
        cover.add(note)
        note.show()
        overlay.add_overlay(cover)
        overlay.set_overlay_pass_through(cover, False)
        self.covers[role] = cover
        restart = Gtk.Button(label="\u21bb  Restart agent")
        restart.get_style_context().add_class("restart")
        restart.set_halign(Gtk.Align.CENTER)
        restart.set_valign(Gtk.Align.CENTER)
        restart.set_no_show_all(True)
        restart.connect("clicked", lambda _b, r=role: self.restart_agent(r))
        overlay.add_overlay(restart)
        self.restart_buttons[role] = restart
        box.pack_start(overlay, True, True, 0)

        script = (self.cfg.get("ids") or {}).get(role, "")
        if os.environ.get("CREW_WINDOW_DRYRUN"):
            argv = ["/bin/bash", "-lc",
                    f"echo 'dry run: this pane would run {script}'; exec bash"]
        else:
            # The script is passed as an argument, not pasted into the command:
            # a path holding a quote would otherwise end the quoting and the
            # rest of it would run as commands.
            argv = ["/bin/bash", "-lc", 'exec "$0"', script] if script \
                else ["/bin/bash", "-l"]
        # CREW_PAIR and CREW_ROLE connect the agent to the live rules card
        # (crew-role-reminder, a Claude and Codex hook).
        envv = [f"{k}={v}" for k, v in os.environ.items()
                if k not in ("CREW_PAIR", "CREW_ROLE")]
        envv += [f"CREW_PAIR={self.pair}", f"CREW_ROLE={role}"]
        self.spawn_args[role] = (argv, envv)
        term.connect("child-exited", self.agent_exited, role)
        self.terms[role] = term
        self.start_agent(role)
        term.connect("key-press-event", self.on_terminal_key, role)
        term.connect("button-press-event",
                     lambda _w, e, r=role: bool(self.locked.get(r)) and e.button == 2)
        return box

    def on_terminal_key(self, term, event, role):
        """Ctrl+V pastes a screenshot as well as text.

        An agent cannot be handed a picture through a terminal, so a picture on
        the clipboard is written to a file and the path is typed instead, which
        is what Claude Code and Codex both read. It is scaled down on the way:
        a full screen costs thousands of tokens in every later turn of that
        chat, and nothing can take an image back out once it is in.
        """
        # Ctrl+V and Ctrl+Shift+V both paste here, so either habit works.
        ctrl = event.state & Gdk.ModifierType.CONTROL_MASK
        if ctrl and Gdk.keyval_name(event.keyval) in ("v", "V"):
            # Paste works even in a locked pane: pasting is always his own act.
            if self.paste_image(term, role):
                return True
            # No picture on the clipboard. A terminal has no Ctrl+V of its own,
            # so paste the text here or the key would do nothing at all.
            term.paste_clipboard()
            return True
        if self.locked.get(role):
            return True
        return False

    def paste_image(self, term, role):
        """True if a picture was pasted; False leaves the paste to the terminal."""
        pic = clipboard_image()
        if pic is None:
            return False
        wide, high = pic.get_width(), pic.get_height()
        biggest = max(wide, high)
        if biggest > PASTE_PIXELS:
            scale = PASTE_PIXELS / biggest
            pic = pic.scale_simple(max(1, int(wide * scale)),
                                   max(1, int(high * scale)),
                                   GdkPixbuf.InterpType.BILINEAR)
        PASTES.mkdir(parents=True, exist_ok=True)
        path = PASTES / f"{self.pair}-{role}-{time.strftime('%Y%m%d-%H%M%S')}.png"
        pic.savev(str(path), "png", [], [])
        os.chmod(path, 0o600)
        term.feed_child(f"{path} ".encode())
        self.harness.say(f"Complete: Pasted a picture into {role}, "
                         f"{pic.get_width()} by {pic.get_height()}.",
                         "Next: Press Enter to send it.")
        return True

    def on_terminal_scroll(self, term, event, role):
        """Ctrl+wheel zooms that pane's text; plain wheel scrolls as usual."""
        if not (event.state & Gdk.ModifierType.CONTROL_MASK):
            return False
        size = self.font_size.get(role, 11)
        direction = getattr(event, "direction", None)
        delta = 0
        if direction == Gdk.ScrollDirection.UP:
            delta = 1
        elif direction == Gdk.ScrollDirection.DOWN:
            delta = -1
        elif direction == Gdk.ScrollDirection.SMOOTH:
            # Three values, not two: got-them, sideways, up-and-down. Unpacking
            # two raised ValueError on every turn of the wheel, which GTK
            # printed and swallowed, so the zoom never ran (23 Sep 2026).
            got = event.get_scroll_deltas()
            dy = got[-1] if got and got[0] else 0
            delta = -1 if dy > 0 else (1 if dy < 0 else 0)
        if delta:
            size = max(6, min(28, size + delta))
            self.font_size[role] = size
            term.set_font(pane_font(size))
        return True

    def toggle_zoom(self, _button, role):
        """One chat fills the window, or all three come back.

        Three panes sharing the width is right while a round is running and
        wrong the moment he wants to read one of them. Nothing is stopped or
        moved: the other two are hidden and go on working.
        """
        self.zoomed = None if self.zoomed == role else role
        for other in ROLES:
            head = self.headers[other]
            show = self.zoomed in (None, other)
            # no_show_all as well as hide: a later show_all would otherwise
            # bring the hidden panes straight back.
            head["box"].set_no_show_all(not show)
            head["box"].show() if show else head["box"].hide()
            head["zoom"].set_label("\u2921" if self.zoomed == other else "\u2922")
            head["zoom"].set_tooltip_text(
                "Back to all three" if self.zoomed == other
                else "Fill the window with this chat")
        return True

    def set_locked(self, role, locked):
        """While the crew runs on its own, only the Coordinator takes typing.
        The Architect and Builder are driven by the relay; a stray keystroke in
        their box is exactly the kind of thing that wedged a round. feed_child
        still works, so the relay is unaffected."""
        # Swallow the keyboard and middle-click paste instead of disabling input:
        # disabling input also blocks what the relay feeds in, which stopped the
        # Builder receiving orders at all (caught by crews-test, 16 Sep 2026).
        self.locked[role] = locked
        # Tell the relay too. Nothing typed into a covered pane can be the owner's,
        # so the relay may send it without asking anyone.
        flag = PANES / f"{self.pair}-{role}.locked"
        try:
            if locked:
                flag.write_text("auto\n")
            else:
                flag.unlink(missing_ok=True)
        except OSError:
            pass
        cover = self.covers.get(role)
        term = self.terms.get(role)
        if cover is None or term is None:
            return
        if locked:
            # Only the box itself: the input line, its two rules and the status
            # line under it. Six rows also hid the "Working… (12m · ↓ 4k tokens)"
            # line above the box, which is the one thing he watches in Auto.
            rows = 4
            cover.set_size_request(-1, int(term.get_char_height() * rows) + 2)
            cover.show()
        else:
            cover.hide()

    def set_header(self, role, state, fresh_message):
        """The top of a pane says who it is, breathes while it works, and shows
        an envelope for a few seconds when a message has just been handed to it
        — so you can watch the crew pass work along instead of reading a line
        that claims they are talking."""
        h = self.headers[role]
        working = state == "working"
        h["working"] = working
        for widget, cls, on in ((h["dot"], "working", working),
                                (h["dot"], "gone", state == "gone"),
                                (h["box"], "working", working and not fresh_message),
                                (h["box"], "fresh", fresh_message)):
            ctx = widget.get_style_context()
            ctx.add_class(cls) if on else ctx.remove_class(cls)
        h["mail"].set_visible(fresh_message)
        h["auto"].set_visible(bool(self.locked.get(role)))
        self.draw_state(role)

    def draw_state(self, role):
        h = self.headers[role]
        if h["working"]:
            # the desktop app's "Working" with its dots filling in and emptying again.
            dots = "." * (1 + self.tick % 3)
            h["state"].set_markup(
                f"<span foreground='{ORANGE}' weight='bold' size='small'>Working{dots}</span>")
        else:
            h["state"].set_markup(f"<span foreground='{FAINT}' size='small'>idle</span>")

    def animate(self):
        self.tick += 1
        for role in ROLES:
            if self.headers[role]["working"]:
                self.draw_state(role)
        return not self.closed

    def start_agent(self, role):
        self.pending.discard(role)
        if self.closed:
            return False
        argv, envv = self.spawn_args[role]
        self.terms[role].spawn_async(Vte.PtyFlags.DEFAULT, str(HOME), argv, envv,
                                     GLib.SpawnFlags.DEFAULT, None, None, -1, None,
                                     self.spawned, role)
        return False

    def agent_exited(self, term, status, role):
        """The agent in this pane quit. Start it again once, on the same saved
        chat (the pane script resumes it). A second quit within RESTART_WINDOW
        seconds shows a Restart button instead, so a broken agent cannot loop."""
        if self.closed:
            return   # the crew is closing; its agents are meant to stop
        self.pids.pop(role, None)
        code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else status
        now = time.time()
        if now - self.restarted_at.get(role, 0) > RESTART_WINDOW:
            self.restarted_at[role] = now
            term.feed(f"\r\n\x1b[33m[Crews] The agent quit (exit {code}). "
                      "Starting it again on the same chat.\x1b[0m\r\n".encode())
            self.harness.say(f"{self.pair} {role}: the agent quit; started it again.")
            self.pending.add(role)
            GLib.timeout_add(1500, self.start_agent, role)
            return
        term.feed(f"\r\n\x1b[31m[Crews] The agent quit again within a minute "
                  f"(exit {code}). Press Restart when it is ready.\x1b[0m\r\n".encode())
        self.harness.say(f"{self.pair} {role}: the agent quit twice; waiting for Restart.")
        self.restart_buttons[role].show()
        try:
            write_private(PANES / f"{self.pair}-{role}.stopped", f"exit {code}\n")
        except OSError:
            pass

    def restart_agent(self, role):
        """The Restart button, or a <pair>-<role>.restart file from a crew tool."""
        if self.closed or role in self.pids or role in self.pending:
            return
        self.restart_buttons[role].hide()
        (PANES / f"{self.pair}-{role}.stopped").unlink(missing_ok=True)
        # A fresh start earns one more automatic restart.
        self.restarted_at[role] = 0
        self.terms[role].feed(b"\r\n\x1b[33m[Crews] Restarting the agent.\x1b[0m\r\n")
        self.start_agent(role)

    def spawned(self, term, pid, error, role):
        if error:
            self.harness.say(f"{self.pair} {role} did not start: {error.message}")
            return
        self.pids[role] = pid

    def scrape(self):
        if self.closed:
            return False   # a closed crew's timers stop, or they rewrite its files
        for role, term in self.terms.items():
            # Rows are absolute in the scrollback, not 0..visible. Asking for
            # 0..row_count returns the top of the buffer, which on a fresh pane
            # is empty — which is why every agent read as idle.
            # Two ways of asking, because they disagree. A normal shell keeps
            # its rows in the scrollback, where the visible window is near the
            # bottom; a full-screen program like Claude Code draws on the
            # alternate screen, which starts at row zero. Take whichever answer
            # actually has something in it.
            text = ""
            # The simplest ask first: the whole terminal as text. It is the only
            # one that works for a full-screen program like Claude Code, which
            # draws on the alternate screen where row ranges come back empty.
            fn = getattr(term, "get_text_format", None)
            if fn:
                try:
                    got = fn(Vte.Format.TEXT)
                    got = got[0] if isinstance(got, tuple) else got
                except Exception:
                    got = ""
                if got and len(got.strip()) > len(text.strip()):
                    text = got
            for lo, hi in self.ranges(term):
                try:
                    got = term.get_text_range_format(Vte.Format.TEXT, lo, 0, hi, -1)
                    got = got[0] if isinstance(got, tuple) else got
                except Exception:
                    got = ""
                if got and len(got.strip()) > len(text.strip()):
                    text = got
            try:
                write_private(PANES / f"{self.pair}-{role}.screen", text or "")
            except OSError:
                pass
        return True

    def ranges(self, term):
        rows = term.get_row_count()
        out = [(0, rows)]
        try:
            adj = term.get_vadjustment()
            top = int(adj.get_value())
            out.append((top, top + int(adj.get_page_size())))
        except Exception:
            pass
        return out

    def watch_close(self):
        """Close this crew if something asked for it with <pair>.close.

        crew-shutdown used to stop the panes it could see and clear their files,
        while the harness kept this page alive and wrote the files straight back.
        Caught by crews-test on 16 September 2026.
        """
        if self.closed:
            return False   # a closed crew's timers stop, or they rewrite its files
        flag = PANES / f"{self.pair}.close"
        if flag.exists():
            try:
                flag.unlink()
            except OSError:
                pass
            GLib.idle_add(self.harness.close_pair, self.pair)
        return True

    def watch_inbox(self):
        """Type anything written to <pair>-<role>.in into that pane, once.

        It appends exactly what it is given, the same as a person typing. It
        never clears the box; what is already in there may be the owner's.
        """
        if self.closed:
            return False   # a closed crew's timers stop, or they rewrite its files
        for role, term in self.terms.items():
            box = PANES / f"{self.pair}-{role}.in"
            if not box.exists():
                continue
            try:
                text = box.read_text()
                box.unlink()
            except OSError:
                continue
            if text:
                self.type_into(term, text, role)
        for role in self.terms:
            flag = PANES / f"{self.pair}-{role}.restart"
            if flag.exists():
                flag.unlink(missing_ok=True)
                self.restart_agent(role)
        return True

    def type_into(self, term, text, role=None):
        """Type text, then press Enter the way that pane's program wants it.

        Claude Code reads keys through the enhanced keyboard protocol, where a
        plain carriage return is a new line INSIDE the box, not a send. Enter is
        ESC [ 13 u. So on 16 September 2026 a whole day's order sat typed and
        unsent in the Builder. Codex still takes a plain return.
        Measured, not guessed: a plain return left the text in Claude's box, and
        ESC [ 13 u sent it at once.
        """
        # Every Enter, in any spelling, is peeled off the end and pressed on its
        # own a moment later. The relay writes the message and its Enter as two
        # separate hand-offs, but when this window is busy it reads both in one
        # go. Fed as one chunk, ESC [ 13 u arrives as pasted text, not a key
        # press — the message sits unsent. It happened to the desktop app's Coordinator and
        # Builder on 16 Sep 2026 with the right key already in place.
        body, enters = text, 0
        while True:
            if body.endswith("\x1b[13u"):
                body, enters = body[:-len("\x1b[13u")], enters + 1
            elif body.endswith(("\r", "\n")):
                body, enters = body[:-1], enters + 1
            else:
                break
        if body:
            term.feed_child(body.encode())
        key = self.enter_key(role)
        for i in range(enters):
            GLib.timeout_add(400 + 300 * i,
                             lambda t=term, k=key: (t.feed_child(k), False)[1])

    def enter_key(self, role):
        script = (self.cfg.get("ids") or {}).get(role or "", "")
        try:
            body = Path(script).read_text(errors="ignore") if script else ""
        except OSError:
            body = ""
        return b"\x1b[13u" if "command claude" in body else b"\r"

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

    def close(self):
        self.closed = True
        owner = PANES / f"{self.pair}.owner"
        try:
            if owner.read_text().strip() == str(os.getpid()):
                owner.unlink()
        except (OSError, ValueError):
            pass
        for pid in list(self.pids.values()):
            try:
                os.killpg(os.getpgid(pid), 15)
            except OSError:
                try:
                    os.kill(pid, 15)
                except OSError:
                    pass
        for role in ROLES:
            for suffix in (".screen", ".in", ".locked", ".stopped", ".restart"):
                try:
                    (PANES / f"{self.pair}-{role}{suffix}").unlink()
                except OSError:
                    pass


class Harness(Gtk.Window):
    def __init__(self, first=None):
        # A test window is named so it can never be mistaken for the real one.
        super().__init__(title="Crews Lite (test)" if os.environ.get("CREWS_SEPARATE") else "Crews Lite")
        self.set_default_size(1600, 1000)
        self.connect("destroy", self.quit)
        self.connect("delete-event", self.ask_close)
        self.pages = {}
        self.current = None

        outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
        self.add(outer)

        self.panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
        self.panel.set_border_width(14)
        # The panel is a fixed width, full stop. A long status line used to push
        # it — and the whole window — wider the moment Start was pressed.
        self.panel_holder = Gtk.ScrolledWindow()
        self.panel_holder.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        self.panel_holder.set_min_content_width(PANEL_WIDTH)
        self.panel_holder.set_max_content_width(PANEL_WIDTH)
        self.panel_holder.set_size_request(PANEL_WIDTH, -1)
        self.panel_holder.set_hexpand(False)
        self.panel_holder.get_style_context().add_class("sidebar")
        self.panel_holder.add(self.panel)
        outer.pack_start(self.panel_holder, False, False, 0)

        self.handle = Gtk.Button(label="\u2039")
        self.handle.get_style_context().add_class("handle")
        self.handle.set_tooltip_text("Hide the panel")
        self.handle.set_size_request(10, -1)
        self.handle.set_relief(Gtk.ReliefStyle.NONE)
        self.handle.connect("clicked", self.toggle_panel)
        outer.pack_start(self.handle, False, False, 0)

        self.stack = Gtk.Stack()
        self.blank = Gtk.Label()
        self.blank.set_markup(
            f"<span foreground='{MUTED}'>Pick a crew on the left.\n"
            "Opening one starts its three agents.</span>")
        self.stack.add_named(self.blank, "blank")
        outer.pack_start(self.stack, True, True, 0)

        self.build_panel()
        self.style()
        GLib.timeout_add_seconds(4, self.refresh)
        if first and first in self.order:
            self.chooser.set_active(self.order.index(first))
            GLib.timeout_add(400, lambda: (self.activate(first), False)[1])

    # ------------------------------------------------------------------ panel
    def build_panel(self):
        self.project = Gtk.Label(xalign=0)
        self.project.set_line_wrap(True)
        self.project.set_max_width_chars(16)
        self.project.set_margin_bottom(8)
        self.panel.pack_start(self.project, False, False, 0)

        section = Gtk.Label(xalign=0)
        section.set_markup(f"<span foreground='{MUTED}' size='small' weight='heavy' "
                           "letter_spacing='1800'>CREWS</span>")
        section.set_margin_bottom(4)
        self.panel.pack_start(section, False, False, 0)
        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        self.chooser = Gtk.ComboBoxText()
        self.order = []
        for pair, cfg in sorted(registry().items()):
            self.order.append(pair)
            self.chooser.append_text(cfg.get("name", pair))
        self.chooser.set_wrap_width(1)
        self.chooser.connect("changed", self.chose)
        row.pack_start(self.chooser, True, True, 0)
        plus = Gtk.Button(label="+")
        plus.get_style_context().add_class("plus")
        plus.set_tooltip_text("New crew")
        plus.connect("clicked", self.new_crew)
        row.pack_start(plus, False, False, 0)
        self.panel.pack_start(row, False, False, 0)

        # Open and Start are different things. Open brings the three agents up
        # and nothing more. Start sets them working on their own.
        # Picking a crew opens it. This one button sets it working, or pauses it.
        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
        self.autoplay = Gtk.Button(label="Start")
        self.autoplay.connect("clicked", self.toggle_autoplay)
        row.pack_start(self.autoplay, True, True, 0)
        # Auto: only the Coordinator takes typing. Manual: all three do. It
        # changes who can type, never whether the agents talk to each other.
        # Auto | Manual as one pill: the side that is on is filled in.
        self.mode_button = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
        self.mode_button.get_style_context().add_class("pill")
        self.mode_button.set_tooltip_text(
            "Auto: the Architect and Builder are driven by the crew and their"
            " typing boxes are covered. Manual: you can type into all three."
            " Either way they keep talking to each other.")
        self.pill_auto = Gtk.Button(label="Auto")
        self.pill_manual = Gtk.Button(label="Manual")
        for side, button, manual in (("left", self.pill_auto, False),
                                     ("right", self.pill_manual, True)):
            button.get_style_context().add_class("pill-" + side)
            button.connect("clicked", lambda _b, m=manual: self.set_mode(m))
            self.mode_button.pack_start(button, True, True, 0)
        row.pack_start(self.mode_button, True, True, 0)
        self.mode_row = row
        self.panel.pack_start(row, False, False, 0)
        self.manual = {}

        self.state_label = Gtk.Label(xalign=0)
        self.state_label.set_line_wrap(True)
        self.state_label.set_max_width_chars(30)
        self.state_label.set_margin_bottom(4)
        self.panel.pack_start(self.state_label, False, False, 0)

        self.panel.pack_start(Gtk.Separator(), False, False, 4)

        self.round_label = Gtk.Label(xalign=0)
        self.round_label.set_line_wrap(True)
        self.round_label.set_max_width_chars(30)
        self.panel.pack_start(self.round_label, False, False, 0)

        self.agent_labels = {}
        self.agent_details = {}
        self.agent_lights = {}
        for role in ROLES:
            row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
            light = Gtk.Box()
            light.get_style_context().add_class("dot")
            light.set_valign(Gtk.Align.CENTER)
            row.pack_start(light, False, False, 0)
            self.agent_lights[role] = light
            dot = Gtk.Label()
            dot.set_markup(SYMBOL[role])
            row.pack_start(dot, False, False, 0)
            lbl = Gtk.Label(xalign=0)
            row.pack_start(lbl, True, True, 0)
            self.agent_labels[role] = lbl
            self.panel.pack_start(row, False, False, 0)
            detail = Gtk.Label(xalign=1)
            self.agent_details[role] = detail
            row.pack_end(detail, False, False, 0)

        self.lights = {}
        for key, words in LIGHTS:
            row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=7)
            dot = Gtk.Box()
            dot.get_style_context().add_class("dot")
            dot.set_valign(Gtk.Align.CENTER)
            row.pack_start(dot, False, False, 0)
            lbl = Gtk.Label(label=words, xalign=0)
            row.pack_start(lbl, True, True, 0)
            self.lights[key] = (dot, lbl, row)
            self.panel.pack_start(row, False, False, 0)

        self.panel.pack_start(Gtk.Separator(), False, False, 4)

        self.relay_line = Gtk.Label(xalign=0)
        self.relay_line.set_line_wrap(True)
        self.relay_line.set_max_width_chars(30)
        self.panel.pack_start(self.relay_line, False, False, 0)

        self.alerts = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
        self.panel.pack_start(self.alerts, False, False, 0)
        self.panel.pack_start(Gtk.Box(), True, True, 0)

        b = Gtk.Button(label="Status")
        b.connect("clicked", self.ask_status)
        self.panel.pack_start(b, False, False, 0)
        self.close_button = Gtk.Button(label="Close this crew")
        self.close_button.connect("clicked", self.close_selected)
        self.panel.pack_start(self.close_button, False, False, 0)

        self.footer = Gtk.Label(xalign=0)
        self.footer.set_line_wrap(True)
        self.footer.set_max_width_chars(30)
        self.panel.pack_start(self.footer, False, False, 0)

    def toggle_panel(self, _b):
        showing = self.panel_holder.get_visible()
        self.panel_holder.set_visible(not showing)
        self.handle.set_label("\u203a" if showing else "\u2039")
        self.handle.set_tooltip_text("Show the panel" if showing else "Hide the panel")

    # ---------------------------------------------------------------- actions
    def chose(self, combo):
        """Show the picked crew, opening it first if it is not open yet.

        Opening puts its three panes up with their context loaded. It does not
        hand anyone work: autopilot is turned off here and stays off until
        Start is pressed.
        """
        i = combo.get_active()
        if i < 0:
            return
        self.current = self.order[i]
        if self.current not in self.pages:
            if self.activate(self.current) is False:
                self.stack.set_visible_child_name("blank")
                self.refresh()
                return
            # Opening is quiet: nobody is handed work until Start.
            subprocess.run([str(BIN / "pqpw"), "auto", "off", self.current],
                           capture_output=True, text=True)
        self.stack.set_visible_child_name(self.current)
        self.refresh()

    def activate(self, pair):
        cfg = registry().get(pair)
        if not cfg or pair in self.pages:
            return
        # One crew, one window. Two windows opening the same crew start two
        # copies of each agent on the same conversation, and both windows read
        # the same message files, so messages land in whichever copy grabs them
        # first. That silently broke the desktop app on 16 Sep 2026.
        owner = PANES / f"{pair}.owner"
        try:
            other = int(owner.read_text().strip())
        except (OSError, ValueError):
            other = 0
        if other and other != os.getpid() and is_crews(other):
            self.say(f"Found: {cfg.get('name', pair)} is already open in another"
                     " Crews window.",
                     "Next: Use that one, or close it there first.")
            return False
        loose = standalone_panes(cfg)
        if loose:
            self.say(f"Found: {cfg.get('name', pair)} is already open in its own"
                     f" Konsole window (pid {', '.join(loose[:3])}).",
                     "Next: Close that first, or two copies fight over the same"
                     " messages.")
            return False
        private_dir(PANES)
        owner.write_text(f"{os.getpid()}\n")
        # Mark where the relay log stands, so --check reports what this crew has
        # done since it opened. Without this, one pasted message from a week ago
        # is reported for ever and the check never comes clean again.
        mark_relay(pair)
        # Now, and again once a Codex agent has had time to record its id.
        record_sessions(pair)
        GLib.timeout_add_seconds(45, lambda p=pair: (record_sessions(p), False)[1])
        page = CrewPage(pair, cfg, self)
        self.pages[pair] = page
        self.stack.add_named(page, pair)
        page.show_all()
        self.stack.set_visible_child_name(pair)
        # The Coordinator is the one he talks to, so that is where his typing
        # goes the moment the crew is up. Nothing asks him anything first.
        GLib.timeout_add(600, lambda: (page.terms["coordinator"].grab_focus(), False)[1])
        self.say(f"Complete: Opened {cfg.get('name', pair)}.",
                 "Next: Press Start to set them working.")

    def watch_raise(self):
        """Another click on the Crews icon asks this window to come forward."""
        flag = CREW / "crews.raise"
        if flag.exists():
            try:
                pair = flag.read_text().strip()
                flag.unlink()
            except OSError:
                pair = ""
            self.present()
            if pair and pair in self.order:
                self.chooser.set_active(self.order.index(pair))
        return True

    HANDOFF_MINUTES = 5

    def write_handoffs(self, pairs):
        """Have every agent write its handoff before its pane goes.

        Closing promised "nothing is lost", and that was only true while a
        session could be resumed. A pane that closes with no handoff and comes
        back on another day starts clean by crew-start-mode's rule, with nothing
        to read, and nobody can ask a session that has already gone. So the ask
        happens here, at the one moment the agents are still alive.
        """
        jobs = [subprocess.Popen(
            [str(BIN / "crew-handoff"), "--write-only", f"--pair={pair}"],
            env={**os.environ,
                 "CREW_HANDOFF_WRITE_MINUTES": str(self.HANDOFF_MINUTES)},
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for pair in pairs]
        dlg = Gtk.MessageDialog(
            transient_for=self, modal=True, message_type=Gtk.MessageType.INFO,
            buttons=Gtk.ButtonsType.NONE, text="Writing handoffs")
        dlg.format_secondary_text(
            "Each agent is writing down what it knows, so its next session can"
            f" read it. Up to {self.HANDOFF_MINUTES} minutes.")
        bar = Gtk.ProgressBar()
        dlg.get_content_area().pack_start(bar, False, False, 6)
        bar.show()
        dlg.add_buttons("Close without waiting", Gtk.ResponseType.CANCEL)

        def tick():
            bar.pulse()
            if any(j.poll() is None for j in jobs):
                return True
            dlg.response(Gtk.ResponseType.OK)
            return False

        tid = GLib.timeout_add(200, tick)
        dlg.run()
        GLib.source_remove(tid)
        dlg.destroy()
        for j in jobs:
            if j.poll() is None:
                j.terminate()

    def close_selected(self, _b):
        if not self.current or self.current not in self.pages:
            return
        subprocess.run([str(BIN / "pqpw"), "auto", "off", self.current],
                       capture_output=True, text=True)
        self.write_handoffs([self.current])
        self.close_crew(None)
        self.refresh()

    def close_pair(self, pair):
        """Close one crew by name, whichever crew is on screen."""
        page = self.pages.pop(pair, None)
        if page:
            page.close()
            self.stack.remove(page)
        if self.current == pair:
            self.stack.set_visible_child_name("blank")
        self.say(f"Complete: Shut down the {pair} crew.")
        self.refresh()
        return False

    def close_crew(self, _b):
        if not self.current:
            return
        pair = self.current
        page = self.pages.pop(pair, None)
        if page:
            page.close()
            self.stack.remove(page)
        self.stack.set_visible_child_name("blank")
        self.say(f"Complete: Turned off the {pair} crew.")

    KICKOFF = (
        "Autopilot is on. Read ROUND_LOG.md and the newest handoffs in the crew"
        " folder. If there is unfinished work, carry on from exactly where it"
        " stopped: write the next START or finish the open round, and keep the"
        " loop going with the Builder. If there is nothing left to do, write one"
        " message whose first word is Coordinator: the latest results, what is"
        " still to do, and what you need decided. Never write a the owner: line and"
        " never write a four-block reply; the Coordinator decides what reaches"
        " him (locked rules 3b and 3d). Do not re-read the rules and do not"
        " repeat work that is already proven."
    )

    def set_mode(self, manual):
        if not self.current or self.manual.get(self.current, False) == manual:
            return
        self.toggle_mode(None)

    def toggle_mode(self, _b):
        if not self.current:
            return
        self.manual[self.current] = not self.manual.get(self.current, False)
        self.say("Complete: Manual — you can type into all three panes."
                 if self.manual[self.current]
                 else "Complete: Auto — only the Coordinator takes your typing.")
        self.refresh()

    def toggle_autoplay(self, _b):
        """Start: the crew works on its own, from where it left off. Pause: it
        stops taking new work but stays open."""
        if not self.current:
            return
        pair = self.current
        on = unit_state(pair) == "active"
        if on:
            subprocess.run([str(BIN / "pqpw"), "auto", "off", pair],
                           capture_output=True, text=True)
            self.say("Complete: Paused.",
                     "Next: The agents stay open; nobody is handed new work.")
            self.refresh()
            return
        opening = pair not in self.pages
        if opening:
            self.activate(pair)
        subprocess.run([str(BIN / "pqpw"), "auto", "on", pair],
                       capture_output=True, text=True)
        # A crew that was just opened needs a moment for its agents to come up
        # before it can be told anything.
        GLib.timeout_add_seconds(25 if opening else 1,
                                 lambda p=pair: (self.kick(p), False)[1])
        self.say("Complete: Starting.",
                 "Next: The Architect picks up where it left off, or asks you what"
                 " to do if there is nothing left.")
        self.refresh()

    def kick(self, pair):
        subprocess.Popen([str(BIN / "crew-say"), pair, "architect", self.KICKOFF],
                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    def ask_status(self, _b):
        if not self.current:
            return
        subprocess.Popen(
            [str(BIN / "crew-say"), self.current, "coordinator",
             "Where are we? Answer the owner in his four blocks: what is done, what is"
             " running now, what is waiting on him. Do not start new work for this."],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        self.say("Complete: Asked the Coordinator.",
                 "Next: It answers in its own pane.")

    def new_crew(self, _b):
        """Everything `pqpw new` does, in a box: the name, and which model runs
        each job. The three start on the arrangement that already works, so the
        only thing that has to be typed is the name."""
        dlg = Gtk.Dialog(title="New crew", transient_for=self, modal=True)
        dlg.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
        dlg.add_buttons("Cancel", Gtk.ResponseType.CANCEL, "Make it", Gtk.ResponseType.OK)
        dlg.set_default_size(340, -1)
        dlg.set_resizable(False)
        box = dlg.get_content_area()
        box.set_spacing(8)
        box.set_border_width(14)

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        row.pack_start(Gtk.Label(label="Name"), False, False, 0)
        name_entry = Gtk.Entry()
        name_entry.set_placeholder_text("what the project is called")
        row.pack_start(name_entry, True, True, 0)
        box.pack_start(row, False, False, 0)

        if self.order:
            row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
            row.pack_start(Gtk.Label(label="or open"), False, False, 0)
            for pair in self.order:
                crew_name = registry().get(pair, {}).get("name", pair)
                b = Gtk.Button(label=crew_name)
                b.connect("clicked", lambda _b, n=crew_name: name_entry.set_text(n))
                row.pack_start(b, False, False, 0)
            box.pack_start(row, False, False, 0)

        note = Gtk.Label(xalign=0)
        note.set_markup(f"<span size='medium' foreground='{MUTED}'>"
                        "The three below are your defaults. Leave them alone unless"
                        " you have a reason.</span>")
        box.pack_start(note, False, False, 0)

        pickers = {}
        for role in ROLES:
            row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
            lbl = Gtk.Label(label=role.capitalize(), xalign=0)
            lbl.set_size_request(92, -1)
            row.pack_start(lbl, False, False, 0)
            model = Gtk.ComboBoxText()
            for m in CHOICES[role]:
                model.append_text(m)
            model.set_active(CHOICES[role].index(DEFAULTS[role][0]))
            row.pack_start(model, True, True, 0)
            effort = Gtk.ComboBoxText()
            for e in EFFORTS:
                effort.append_text(e)
            effort.set_active(EFFORTS.index(DEFAULTS[role][1]))
            row.pack_start(effort, False, False, 0)
            pickers[role] = (model, effort)
            box.pack_start(row, False, False, 0)

        dlg.show_all()
        if dlg.run() != Gtk.ResponseType.OK:
            dlg.destroy()
            return
        name = name_entry.get_text().strip()
        env = dict(os.environ, PQPW_NO_OPEN="1")
        for role, (model, effort) in pickers.items():
            env[f"PQPW_{role.upper()}_MODEL"] = model.get_active_text()
            env[f"PQPW_{role.upper()}_EFFORT"] = effort.get_active_text()
        dlg.destroy()
        if not name:
            self.say("Found: A crew needs a name.")
            return
        pair = re.sub(r"[^a-z0-9]", "", name.lower()) or "crew"
        known = {p: c.get("name", p).lower() for p, c in registry().items()}
        match = next((p for p, n in known.items()
                      if p == pair or n == name.lower()), None)
        if match:
            # Typing the name of a crew that exists opens it rather than
            # refusing. It is the same thing he meant either way.
            self.chooser.set_active(self.order.index(match))
            self.say("Found: That crew already exists, so it is selected.",
                     "Next: Press Start.")
            return
        out = subprocess.run([str(BIN / "pqpw"), "new", pair, name],
                             capture_output=True, text=True, env=env)
        if out.returncode != 0:
            self.say("Found: Could not make it — "
                     + (out.stderr.strip().splitlines() or ["?"])[-1])
            return
        self.order.append(pair)
        self.chooser.append_text(name)
        self.chooser.set_active(len(self.order) - 1)
        self.say(f"Complete: {name} is ready.", "Next: Press Start.")

    def say(self, *blocks):
        """The footer speaks in his reply shape: a bold label, then one line.

        His chats and the Coordinator both write Complete: / Found: / Next: with
        the label in bold, and the window was the only thing left talking in a
        flat sentence. Each block is "Label: what happened".
        """
        parts = []
        for block in blocks:
            label, _, rest = block.partition(": ")
            safe = GLib.markup_escape_text(rest or block)
            parts.append(f"<b>{GLib.markup_escape_text(label)}:</b> {safe}"
                         if rest else safe)
        self.footer.set_markup(f"<span foreground='{MUTED}' size='medium'>"
                               + "   ".join(parts) + "</span>")

    # ---------------------------------------------------------------- refresh
    def refresh(self):
        running = self.current in self.pages if self.current else False
        # Start only exists once there is an actual crew open — three panes up,
        # their context loaded. Before that there is nothing for it to start.
        self.autoplay.set_visible(running)
        self.mode_button.set_visible(running)
        if self.current:
            manual = self.manual.get(self.current, False)
            for button, on in ((self.pill_auto, not manual), (self.pill_manual, manual)):
                ctx = button.get_style_context()
                ctx.add_class("pill-on") if on else ctx.remove_class("pill-on")
        self.close_button.set_visible(running)
        if self.current:
            state = unit_state(self.current)
            if not running:
                word, colour = "off", FAINT
            elif state == "active":
                word, colour = "open, working on its own", TONE["good"]
            else:
                word, colour = "open, waiting for Start", TONE["warn"]
            self.state_label.set_markup(f"<span foreground='{colour}'>{word}</span>")
            pausing = running and state == "active"
            self.autoplay.set_label("Pause" if pausing else "Start")
            ctx = self.autoplay.get_style_context()
            ctx.remove_class("go" if pausing else "hold")
            ctx.add_class("hold" if pausing else "go")
        else:
            self.state_label.set_text("")

        for child in self.alerts.get_children():
            self.alerts.remove(child)

        if not self.current:
            for key in dict(LIGHTS):
                self.set_light(key, False)
            self.lights["waiting"][2].set_visible(False)
            for light in self.agent_lights.values():
                set_dot(light, "")
            self.project.set_markup(
                f"<span size='xx-large' weight='heavy' foreground='{INK}'>Crews</span>"
                f"<span size='xx-large' weight='heavy' foreground='{ACCENT}'> Lite</span>")
            self.round_label.set_text("")
            for lbl in self.agent_labels.values():
                lbl.set_text("")
            for lbl in self.agent_details.values():
                lbl.set_text("")
            self.relay_line.set_text("")
            self.autoplay.set_label("Start")
            self.alerts.show_all()
            return True

        cfg = registry().get(self.current, {})
        page = self.pages.get(self.current)
        name = cfg.get("name", self.current)
        self.project.set_markup(
            f"<span size='xx-large' weight='bold' foreground='{INK}'>{escape(name)}</span>")
        on = unit_state(self.current) == "active"

        opened, closed, still = rounds(cfg.get("chats", ""))
        if opened is None:
            self.round_label.set_markup(f"<span foreground='{MUTED}'>no rounds yet</span>")
        else:
            bits = [f"<span size='large' weight='bold'>Round {opened}</span>"]
            if still:
                bits.append(f"<span foreground='{TONE['warn']}'>"
                            f"{len(still)} unfinished</span>")
            else:
                bits.append(f"<span foreground='{TONE['good']}'>all closed</span>")
            self.round_label.set_markup("  ".join(bits))

        # Read each pane once and work out its state once. refresh runs every
        # four seconds; reading the same .screen file three times over and
        # re-parsing 200 lines of relay log once per role is work done for
        # nothing, on a timer, all day.
        live = bool(page and running)
        screens = {role: (page.screen(role) if live else "") for role in ROLES}
        seen = {role: (describe(screens[role]) if screens[role]
                       else ("off", "off", "")) for role in ROLES}
        recent = deliveries(self.current) if live else {}

        for role in ROLES:
            state, headline, _body = seen[role]
            colour = {"working": ORANGE, "idle": MUTED,
                      "gone": TONE["bad"], "off": FAINT}[state]
            # One word, then how long. "working, Catapulting... 10m" becomes
            # "working 10m"; a wall of words is not something you can glance at.
            word = "working" if state == "working" else (
                "idle" if state == "idle" else state)
            when = ""
            m = re.search(r"(\d+\s*[hms]\b|\d+m\s*\d+s|\d+:\d+\s*[AP]M)", headline)
            if m:
                when = m.group(1).replace(" ", "")
            self.agent_labels[role].set_markup(
                f"<span foreground='{colour}'>{role.capitalize()}</span>")
            self.agent_details[role].set_markup(
                f"<span foreground='{colour}'>{word}{('  ' + when) if when else ''}</span>")
            set_dot(self.agent_lights[role], state if state in ("working", "gone") else "")
            if live:
                page.set_locked(role, (not self.manual.get(self.current, False))
                                and role != "coordinator")
                page.set_header(role, state,
                                recent.get(role, 1e9) < FLASH_SECONDS)


        newest, ago = last_handoff(cfg.get("chats", ""))
        self.relay_line.set_markup(
            f"<span foreground='{FAINT}'>last message {ago:.0f} min ago</span>"
            if newest else "")

        anyone_working = any(seen[r][0] == "working" for r in ROLES)
        # "Waiting on you" means the Coordinator has actually asked him
        # something. Its four-block reply ends in a the owner: line, and that line is
        # either a question or the word nothing. Reading the input boxes instead
        # lit the light on ghost text, which is not a question.
        asked = ""
        if live:
            for line in reversed(screens["coordinator"].splitlines()):
                m = re.match(r"\s*the owner:\s*(.+)", line)
                if m:
                    asked = "" if m.group(1).strip().lower().startswith("nothing") \
                        else m.group(1).strip()
                    break
        # "Waiting on you" only appears when there is actually a question.
        self.set_light("waiting", False, warn=bool(asked))
        self.lights["waiting"][2].set_visible(bool(asked))
        # "All done": running, every round closed, nobody working, nothing
        # asked. When this is lit there is nothing above it left to show.
        done = (running and opened is not None and not still
                and not anyone_working and not asked)
        self.set_light("done", done)

        if asked:
            self.add_alert("warn", asked[:70])
        if opened is not None and len(still) > 1:
            self.add_alert("warn", f"{len(still)} rounds never finished")
        self.alerts.show_all()
        return True

    def set_light(self, key, lit, warn=False):
        """Lit means yes, amber means it wants a look, grey means no."""
        dot, lbl, _row = self.lights[key]
        set_dot(dot, "warn" if warn and not lit else ("done" if lit else ""))
        lbl.set_markup(
            f"<span foreground='{INK if lit or warn else FAINT}'>"
            f"{escape(dict(LIGHTS)[key])}</span>")

    def add_alert(self, kind, text):
        lbl = Gtk.Label(xalign=0)
        lbl.set_line_wrap(True)
        lbl.set_max_width_chars(30)
        lbl.set_markup(f"<span foreground='{TONE[kind]}' size='medium'>● {text}</span>")
        self.alerts.pack_start(lbl, False, False, 0)

    def style(self):
        provider = Gtk.CssProvider()
        provider.load_from_data(CSS)
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

    def all_resume(self, pairs):
        """True when every agent picks its own chat up again on the next open.

        crew-start-mode is the same judge the panes use when they start: a log
        under the size line and from today is resumed. While that holds, a
        handoff buys nothing, and asking for one turns a restart into a wait.
        """
        for pair in pairs:
            for role in ROLES:
                note = CREW / "session-ids" / f"{pair}-{role}.id"
                if not note.exists():
                    continue     # a Codex pane carries its own id elsewhere
                got = subprocess.run([str(BIN / "crew-start-mode"),
                                      note.read_text().strip()],
                                     capture_output=True, text=True)
                if got.stdout.strip() != "resume":
                    return False
        return True

    def ask_close(self, *_):
        """The X closes everything, after one question.

        It was taking ten to fifteen presses to get anything to close, one
        terminal at a time. Now one press names what will stop, one answer
        stops all of it, and the window goes.
        """
        running = [registry().get(p, {}).get("name", p) for p in self.pages]
        if not running:
            self.quit(None)
            return False
        agents = 3 * len(running)
        dlg = Gtk.MessageDialog(
            transient_for=self, modal=True,
            message_type=Gtk.MessageType.WARNING,
            buttons=Gtk.ButtonsType.NONE,
            text="Close Crews?")
        stopping = (f"This stops {agents} agents in {len(running)} crew"
                    f"{'s' if len(running) > 1 else ''}: {', '.join(running)}.")
        if self.all_resume(list(self.pages)):
            # Reopening the window to pick up a change is the common case, and
            # every agent carries on with its own chat, so there is nothing to
            # ask about.
            dlg.format_secondary_text(
                f"{stopping}\n\nEach one carries on with the same chat when you"
                " open it again.")
            dlg.add_buttons("Keep open", Gtk.ResponseType.CANCEL,
                            "Close", Gtk.ResponseType.REJECT)
            dlg.set_default_response(Gtk.ResponseType.REJECT)
        else:
            dlg.format_secondary_text(
                f"{stopping}\n\n"
                "An agent that writes its handoff first picks up where it left"
                " off. One that closes without one starts its next session with"
                " nothing.")
            dlg.add_buttons("Keep open", Gtk.ResponseType.CANCEL,
                            "Close without handoffs", Gtk.ResponseType.REJECT,
                            "Write handoffs, then close", Gtk.ResponseType.OK)
            dlg.set_default_response(Gtk.ResponseType.OK)
        answer = dlg.run()
        dlg.destroy()
        if answer == Gtk.ResponseType.OK:
            self.write_handoffs(list(self.pages))
        elif answer != Gtk.ResponseType.REJECT:
            return True          # keep the window
        self.quit(None)
        self.destroy()
        return False

    def quit(self, _w):
        if getattr(self, "_quitting", False):
            return
        self._quitting = True
        # One window, one close: every crew that is open goes with it.
        for page in list(self.pages.values()):
            page.close()
        Gtk.main_quit()




def main():
    if "--check" in sys.argv:
        sys.exit(check())
    first = sys.argv[1] if len(sys.argv) > 1 else None
    if first and first not in registry():
        sys.exit(f"no crew called {first}")
    # One Crews window, always. A second one used to open beside the first, and
    # a crew already running in the first could not be picked in the second, so
    # it looked missing from the list. Clicking the icon again now brings the
    # open window forward (and switches to a named crew) instead.
    lock = CREW / "crews.pid"
    if not os.environ.get("CREWS_SEPARATE"):
        try:
            other = int(lock.read_text().strip())
        except (OSError, ValueError):
            other = 0
        if other and other != os.getpid() and is_crews(other):
            (CREW / "crews.raise").write_text((first or "") + "\n")
            sys.exit(0)
        lock.write_text(f"{os.getpid()}\n")
    win = Harness(first)
    # Closing by signal (a kill, a logout) must close the crews too. Without
    # this only the window's own X ran the cleanup, and a killed window left
    # its agents running with nowhere to show them.
    import signal as _signal
    for sig in (_signal.SIGTERM, _signal.SIGHUP, _signal.SIGINT):
        GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, sig,
                             lambda *_: (win.quit(None), False)[1])
    win.show_all()
    win.panel.set_visible(True)
    win.panel_holder.set_visible(True)
    win.refresh()
    if not os.environ.get("CREWS_SEPARATE"):
        GLib.timeout_add(500, win.watch_raise)
    Gtk.main()
    try:
        if lock.read_text().strip() == str(os.getpid()):
            lock.unlink()
    except (OSError, ValueError, NameError):
        pass


if __name__ == "__main__":
    main()
