diff --git a/src/agents/seat.py b/src/agents/seat.py
index aac3528d..78cb62b0 100644
--- a/src/agents/seat.py
+++ b/src/agents/seat.py
@@ -1,11 +1,16 @@
 import time
+from functools import cached_property
 from pathlib import Path
 from resources.types import COMPACTING, WORKING
-from providers import PROVIDERS
-from engine.stored import write_json
+from providers import PROVIDERS, workspace_folders
+from engine import runtime
+from engine.record import Record
+from engine.sessions import SessionsSnapshot, agent_pid, alive
+from engine.stored import Growth, write_json
 from engine.proc import git
 from engine.seats import seat_file
-from controllers.types import Agents
+from engine.worktree import checkout, environment
+from controllers.types import Agents, Environments
 from resources.base import SYSTEM
 from resources.types import AgentRow
 from engine.fields import Loaded
@@ -13,6 +18,7 @@ from dataclasses import dataclass
 
 DISPATCHED, RETURNED = "dispatched", "returned"
 WEB_HOSTS = ("github.com", "gitlab.com", "bitbucket.org")
+LOOK_EVERY = 10.0
 
 
 def web_remote(url: str) -> str:
@@ -42,11 +48,21 @@ class SubagentRow(Loaded):
     ended: float = 0.0
     status: str = ""
 
-class Seat:
+class SeatReport:
+    def __init__(self, record, agent):
+        self.record = record
+        self.agent = agent
+        self.branched_at = 0.0
+        self.branch_name = ""
+        self.branch_stamp = None
+        self.crewed_at = 0.0
+        self.crew_growth = Growth()
+        self.subagents_ended: dict | None = None
+
     def branch(self) -> str:
         last = self.agent.driver.last_report()
         cwd = (last and last.cwd) or str(self.record.root.parent)
-        if time.time() - self.branched_at < 10:
+        if time.time() - self.branched_at < LOOK_EVERY:
             return self.branch_name
         self.branched_at = time.time()
         try:
@@ -66,20 +82,15 @@ class Seat:
     def crew(self) -> None:
         last = self.agent.driver.last_report()
         path = last and last.title and last.transcript
-        if not path or time.time() - self.crewed_at < 10:
+        if not path or time.time() - self.crewed_at < LOOK_EVERY:
             return
         self.crewed_at = time.time()
-        try:
-            size = Path(path).stat().st_size
-        except OSError:
+        if not self.crew_growth.grew(Path(path)):
             return
-        if size == self.crewed_size:
-            return
-        self.crewed_size = size
         facts = PROVIDERS[last.provider]().crew(Path(path)) if last.provider in PROVIDERS else {}
         self.subagents_moved(last, facts.get(AgentRow.subagent_rows))
         if facts and any(last.data.get(k) != v for k, v in facts.items()):
-            compacting = facts.get("compacting")
+            compacting = facts.get(AgentRow.compacting)
             status = status_after(compacting, last.status)
             self.agent.mark(status, last.event, at=last.at, **facts)
 
@@ -99,10 +110,56 @@ class Seat:
                 agents.subagent(last.n, RETURNED, **data, status=sub.status)
         self.subagents_ended = {sub.id: sub.ended for sub in rows}
 
-    def seat(self) -> None:
+    def write(self, why: str) -> None:
         self.branch()
         self.crew()
         last = self.agent.driver.last_report()
         write_json(seat_file(self.record.root, self.agent.driver.session), {"at": time.time(), "agent": self.agent.driver.name, "state": self.agent.state(), "env": self.record.env,
-                                 "why": self.why, "printed": self.agent.driver.last_printed(),
+                                 "why": why, "printed": self.agent.driver.last_printed(),
                                  "report": {"title": last.title, **last.data} if last else {}})
+
+
+class HookBinding:
+    def __init__(self, root: Path, provider, pid: int):
+        self.root = root
+        self.provider = provider
+        self.pid = agent_pid(pid)
+        self.sessions = SessionsSnapshot(root)
+
+    @cached_property
+    def owned(self) -> set[str]:
+        return {r.title for r in Environments(Record(self.root, runtime.env(self.root)), actor=SYSTEM).all() if r.owner}
+
+    def environment(self, hook, prefer: str) -> str:
+        session = hook.session
+        held = self.sessions.read(session)
+        worked = "" if self.provider.is_subagent(hook) else environment(checkout(Path(hook.cwd), workspace_folders()) if hook.cwd else None)
+        stays = bool(held.environment and held.provider) and not self.moving(session, held.environment, worked)
+        env = held.environment if stays else self.bound(session, worked, prefer)
+        if stays and not alive(held.pid):
+            self.relaunched(session, held.pid)
+        self.sessions.touch(session)
+        return env
+
+    def moving(self, session: str, env: str, worked: str) -> bool:
+        if not worked or worked == env or worked in self.owned:
+            return False
+        own = {session, self.sessions.terminal(self.provider.name, self.pid)}
+        return not set(self.sessions.holders(worked)) - own
+
+    def bound(self, session: str, worked: str, prefer: str) -> str:
+        env = worked or prefer or self.sessions.choose(session, self.provider.name, runtime.default_env(self.root), self.owned)
+        self.sessions.bind(session, env, pid=self.pid, provider=self.provider.name)
+        environments = Environments(Record(self.root, env), actor=SYSTEM)
+        environments._seat(env, session)
+        terminal = self.sessions.terminal(self.provider.name, self.pid)
+        if worked and terminal:
+            self.sessions.bind(terminal, env)
+            environments._seat(env, terminal)
+        return env
+
+    def relaunched(self, session: str, old: int) -> None:
+        terminal = self.sessions.terminal(self.provider.name, old)
+        self.sessions.write(session, pid=self.pid)
+        if terminal:
+            self.sessions.write(terminal, pid=self.pid)
diff --git a/src/engine/gates.py b/src/engine/gates.py
index 9e44a713..b2aa7813 100644
--- a/src/engine/gates.py
+++ b/src/engine/gates.py
@@ -9,21 +9,42 @@ from engine.stored import read_json, write_json
 POLICIES: list = []
 AFTERWARDS: list = []
 CANCELERS: dict[str, list] = {}
+RESPONDERS: dict[str, list] = {}
 DISPATCHING, LONG_COMMAND = "agent.dispatching", "agent.command.long"
 CANCELABLE = (DISPATCHING, LONG_COMMAND)
 
 
+@dataclass(frozen=True)
+class HookCall:
+    provider: object
+    record: object
+    hook: object
+    row: object
+
+    @property
+    def session(self) -> str:
+        return self.row.title
+
+    @property
+    def subagent(self) -> bool:
+        return self.row.subagent if self.hook is None else self.provider.is_subagent(self.hook)
+
+
 @dataclass(frozen=True)
 class Hold(Loaded):
     why: str = ""
     reach: Reach = Reach.MAIN
 
 
-def cancelled(name: str, provider, record, hook, session: str, data: dict, subagent: bool) -> str:
-    reasons = [reason for cancel in CANCELERS.get(name, []) if cancel.guard.reaches(subagent) and (reason := cancel(provider, record, hook, session, data))]
+def cancelled(name: str, call: HookCall, data: dict) -> str:
+    reasons = [reason for cancel in CANCELERS.get(name, []) if cancel.guard.reaches(call.subagent) and (reason := cancel(call, data))]
     return "; ".join(reasons)
 
 
+def responded(call: HookCall) -> str:
+    return "".join(respond(call) for respond in RESPONDERS.get(call.hook.event, []))
+
+
 def start_file(root: Path, env: str, compacted: bool = False) -> Path:
     return runtime.folder(root) / f"{'compact' if compacted else 'start'}-{env}.md"
 
diff --git a/src/engine/sessions.py b/src/engine/sessions.py
index 75e510c8..6c3c992f 100644
--- a/src/engine/sessions.py
+++ b/src/engine/sessions.py
@@ -2,6 +2,7 @@ import fcntl
 import os
 import time
 from dataclasses import dataclass, field
+from functools import lru_cache
 from pathlib import Path
 
 from engine.fields import Loaded
@@ -80,6 +81,7 @@ def live(session: SessionRecord) -> bool:
     return time.time() - session.last_heard < RECENT
 
 
+@lru_cache(maxsize=256)
 def agent_pid(pid: int) -> int:
     for _ in range(4):
         try:
@@ -187,6 +189,21 @@ class Sessions:
         return env in self.read(session).grants
 
 
+class SessionsSnapshot(Sessions):
+    def __init__(self, root: Path):
+        super().__init__(root)
+        self.held: dict[str, SessionRecord] | None = None
+
+    def all(self) -> dict[str, SessionRecord]:
+        if self.held is None:
+            self.held = super().all()
+        return self.held
+
+    def write(self, session: str, **fields) -> SessionRecord:
+        self.held = None
+        return super().write(session, **fields)
+
+
 def allowed(sessions: Sessions, session: str, env: str, actor_id: str, type_: str) -> str:
     if not actor_id:
         return ""
diff --git a/src/serve.py b/src/serve.py
index 0415da4c..4b2c1cc1 100644
--- a/src/serve.py
+++ b/src/serve.py
@@ -14,18 +14,19 @@ import features
 from surfaces import updates  # noqa: E402
 import migrations  # noqa: E402
 import commands.cli  # noqa: E402,F401
-from commands.http import dispatch  # noqa: E402
+from commands.http import dispatch, unanswered  # noqa: E402
 from engine import runtime  # noqa: E402
 from engine.stop import asked  # noqa: E402
 from engine.viewer import elsewhere, heartbeat, known, remember  # noqa: E402
 from runner.engines import Children  # noqa: E402
 from controllers.types import warm, warm_record  # noqa: E402
 from providers.turns import read_transcripts  # noqa: E402
-from runner.hooks import replay  # noqa: E402
+from runner.chat_mirror import replay  # noqa: E402
 from engine.runtime import default_env
 from engine.record import Record  # noqa: E402
 from engine.package import CODE, ZIPPED, entry
 
+DEFAULT_PORT = 8430
 LOOPBACK = re.compile(r"^http://(?:127\.0\.0\.1|localhost)(?::(\d+))?$")
 
 
@@ -107,7 +108,7 @@ class Handler(BaseHTTPRequestHandler):
         self.end_headers()
 
 
-def serve(root: Path, port: int = 8430) -> ThreadingHTTPServer:
+def serve(root: Path, port: int = DEFAULT_PORT) -> ThreadingHTTPServer:
     Handler.root = root
     other = elsewhere(root)
     if other:
@@ -168,6 +169,13 @@ def watch_stop(root: Path, server: ThreadingHTTPServer, halting: threading.Event
             server.shutdown()
 
 
+def watch_runtime(root: Path, halting: threading.Event) -> None:
+    while not halting.wait(WATCH_SECONDS):
+        runtime.refresh_flags(root)
+        if runtime.hook_failures(root).is_file():
+            unanswered(root, "")
+
+
 def freeze_caches(halting: threading.Event) -> None:
     while not halting.wait(FREEZE_SECONDS):
         gc.freeze()
@@ -188,7 +196,7 @@ def warm_viewer(root: Path, env: str) -> None:
     dispatch("GET", f"/api/{env}/family", root, {}, {})
 
 
-def run(root: Path, port: int = 8430) -> None:
+def run(root: Path, port: int = DEFAULT_PORT) -> None:
     runtime.STARTED[0] = time.time()
     server = serve(root, port)
     print(f"http://127.0.0.1:{server.server_address[1]}/", flush=True)
@@ -201,6 +209,7 @@ def run(root: Path, port: int = 8430) -> None:
     gc.freeze()
     threading.Thread(target=watch_code, args=(CODE.with_name("journal.pyz") if ZIPPED else CODE, server, changed), daemon=True).start()
     threading.Thread(target=watch_stop, args=(root, server, halting, time.time() - LATE_STOP), daemon=True).start()
+    threading.Thread(target=watch_runtime, args=(root, halting), daemon=True).start()
     threading.Thread(target=freeze_caches, args=(halting,), daemon=True).start()
     threading.Thread(target=replay, args=(root,), daemon=True).start()
     threading.Thread(target=warm, args=(root,), daemon=True).start()
@@ -228,5 +237,5 @@ def run(root: Path, port: int = 8430) -> None:
 
 if __name__ == "__main__":
     root = Path(sys.argv[1] if len(sys.argv) > 1 else ".journal").resolve()
-    port = int(sys.argv[2]) if len(sys.argv) > 2 else 8430
+    port = int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_PORT
     run(root, port)
diff --git a/src/supervisor.py b/src/supervisor.py
index ee2cdf92..05f4e224 100644
--- a/src/supervisor.py
+++ b/src/supervisor.py
@@ -26,6 +26,7 @@ LONGEST = 65536
 INBOX = 1 << 20
 TYPED_EVERY = 1.0
 HEADLESS_SIZE = (40, 120)
+PRINTED, SCREEN, SCREEN_SHAPE, TYPED, LAUNCHED = "printed", "screen", "screen.json", "typed", "launched.json"
 ESCAPES = re.compile(rb"\x1b(?:\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[P_^X][^\x1b]*\x1b\\|O[\x40-\x7e]|[@-_])")
 
 
@@ -33,19 +34,26 @@ def typing(data: bytes) -> bool:
     return any(byte >= 0x20 for byte in ESCAPES.sub(b"", data))
 
 
+def waited(pid: int, seconds: float, drain) -> int | None:
+    until = time.time() + seconds
+    while time.time() < until:
+        drain()
+        ended, status = os.waitpid(pid, os.WNOHANG)
+        if ended:
+            return status
+        time.sleep(STEP)
+    return None
+
+
 def stop(pid: int, grace: float = GRACE, drain=lambda: None) -> int:
     for sent in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL):
         try:
             os.kill(pid, sent)
         except ProcessLookupError:
             break
-        until = time.time() + grace
-        while time.time() < until:
-            drain()
-            ended, status = os.waitpid(pid, os.WNOHANG)
-            if ended:
-                return status
-            time.sleep(STEP)
+        status = waited(pid, grace, drain)
+        if status is not None:
+            return status
     try:
         return os.waitpid(pid, 0)[1]
     except ChildProcessError:
@@ -120,8 +128,8 @@ class Supervisor:
         os.set_inheritable(self.alive, True)
         self.folder = self.root / "runtime" / "sessions" / self.session
         self.folder.mkdir(parents=True, exist_ok=True)
-        self.printed = (self.folder / "printed").open("ab")
-        self.screen = (self.folder / "screen").open("ab")
+        self.printed = (self.folder / PRINTED).open("ab")
+        self.screen = (self.folder / SCREEN).open("ab")
         self.inbox = self.listen()
         self.sources = [self.fd, self.inbox] if self.headless else [self.fd, self.inbox, self.stdin]
         self.typed_at = 0.0
@@ -130,9 +138,9 @@ class Supervisor:
         self.resize(self.fd)
 
     def record_launch(self) -> None:
-        written = self.folder / "launched.json.writing"
+        written = self.folder / f"{LAUNCHED}.writing"
         written.write_text(json.dumps({"pid": self.pid, "command": self.command, "args": self.args, "cwd": str(self.cwd), "launch": self.launch}))
-        os.replace(written, self.folder / "launched.json")
+        os.replace(written, self.folder / LAUNCHED)
 
     def spawn(self, command: list[str], environ: dict) -> tuple[int, int]:
         pid, fd = pty.fork()
@@ -169,7 +177,7 @@ class Supervisor:
             rows, cols = struct.unpack("HHHH", size)[:2]
         except OSError:
             return
-        (self.folder / "screen.json").write_text(json.dumps({"rows": rows, "cols": cols, "at": self.screen.tell(), "printed": self.printed.tell()}))
+        (self.folder / SCREEN_SHAPE).write_text(json.dumps({"rows": rows, "cols": cols, "at": self.screen.tell(), "printed": self.printed.tell()}))
 
     def start_worker(self) -> None:
         command = [*self.worker_command, str(self.root), str(self.cwd), self.env, self.agent, self.session, str(self.alive)]
@@ -194,14 +202,7 @@ class Supervisor:
             os.write(self.fd, b"\r")
         except OSError:
             return None
-        until = time.time() + EXITING
-        while time.time() < until:
-            self.drain()
-            ended, status = os.waitpid(self.pid, os.WNOHANG)
-            if ended:
-                return status
-            time.sleep(STEP)
-        return None
+        return waited(self.pid, EXITING, self.drain)
 
     def drain(self) -> None:
         while select.select([self.fd], [], [], 0)[0]:
@@ -274,7 +275,7 @@ class Supervisor:
         self.printed.flush()
 
     def typing(self, data: bytes) -> None:
-        typed = self.folder / "typed"
+        typed = self.folder / TYPED
         if b"\r" in data or b"\n" in data:
             typed.unlink(missing_ok=True)
         elif typing(data) and time.time() - self.typed_at >= TYPED_EVERY:
1:import fcntl
2:import hashlib
3:import json
4:import os
5:import pty
6:import re
7:import select
8:import signal
9:import socket
10:import struct
11:import subprocess
12:import sys
13:import termios
14:import time
15:import tty
16:from collections import deque
17:from dataclasses import dataclass, field
18:from pathlib import Path
import re
import threading
import time
from contextlib import contextmanager
from copy import deepcopy
from pathlib import Path
from typing import Iterator

from controllers.types import Agents
from engine import chat, runtime
from engine.record import Record
from engine.sessions import Sessions
from engine.stored import read_json, write_json
from engine.wording import digest
from providers import PROVIDERS
from providers.payload import Chunk
from resources.base import SYSTEM

DISPLAYED = "displayed.json"
PRIVATE = "_"
DONE = "_done"
SENT = "_sent"
MATCHED = "_matched"
FINALS = "_finals"
KEPT_DONE = 50
KEPT_SENT = 500
LINE_KEY = re.compile(r"transcript:\d+")
COMMIT = re.compile(r"[0-9a-f]{40}")
CATCH_UP = 600.0
REPLAYING = threading.Lock()


class SessionLocks:
    def __init__(self):
        self.locks: dict[str, threading.Lock] = {}
        self.guard = threading.Lock()

    def of(self, session: str) -> threading.Lock:
        with self.guard:
            return self.locks.setdefault(session, threading.Lock())


LOCKS = SessionLocks()


class DisplayedLedger:
    def __init__(self, root: Path, session: str):
        self.session = session
        self.file = runtime.session_file(root, session, DISPLAYED)

    @contextmanager
    def changing(self) -> Iterator[dict]:
        with LOCKS.of(self.session):
            held = read_json(self.file, dict, {})
            before = deepcopy(held)
            yield held
            if held != before:
                write_json(self.file, held)


def unsent(root: Path) -> Path:
    return runtime.folder(root) / "unsent"


def replay(root: Path) -> None:
    with REPLAYING:
        for f in sorted(unsent(root).glob("*.json"), key=lambda f: (f.stat().st_mtime_ns, f.name)):
            raw = read_json(f, dict, {})
            f.unlink(missing_ok=True)
            for chunk in display_chunks(raw):
                displayed(root, chunk)


def display_chunks(raw: dict) -> list[Chunk]:
    return [chunk for provider in PROVIDERS.values() if (chunk := provider.display_chunk(raw)) is not None]


def displayed(root: Path, chunk: Chunk) -> None:
    session, message = chunk.session, chunk.message
    ledger = DisplayedLedger(root, session)
    with ledger.changing() as held:
        if message in held.get(DONE, []):
            return
        parts = held[message] = {**held.get(message, {}), str(chunk.index): chunk.delta}
        finals = held[FINALS] = {**held.get(FINALS, {})}
        if chunk.final:
            finals[message] = chunk.index
        if message not in finals or any(str(i) not in parts for i in range(finals[message] + 1)):
            return
        last = finals[message]
    if not send_to_chat(root, session, "".join(parts[str(i)] for i in range(last + 1)), message or None, streamed=True):
        return
    with ledger.changing() as held:
        held.pop(message, None)
        held[FINALS] = {key: value for key, value in held.get(FINALS, {}).items() if key != message}
        held[DONE] = [*held.get(DONE, []), message][-KEPT_DONE:]


def joined_parts(parts: dict) -> str:
    return "".join(parts[i] for i in sorted(parts, key=int)).strip()


def belongs(text: str, parts: dict) -> bool:
    piece = joined_parts(parts)
    return bool(piece) and (text.strip().startswith(piece) or ("0" not in parts and text.strip().endswith(piece)))


def fingerprint(text: str) -> str:
    return digest(" ".join(text.split()))


def turn_ended(root: Path, session: str, row, last_message: str) -> None:
    if last_message:
        stopped(root, session, last_message)
    unfinished(root, session, row)


def turn_began(root: Path, session: str, row) -> None:
    unfinished(root, session, row)
    next_turn(root, session)


def stopped(root: Path, session: str, text: str) -> None:
    with DisplayedLedger(root, session).changing() as held:
        cut = [message for message, parts in held.items() if not message.startswith(PRIVATE) and belongs(text, parts)]
        if not cut:
            return
        for message in cut:
            del held[message]
        held[FINALS] = {key: value for key, value in held.get(FINALS, {}).items() if key not in cut}
        held[DONE] = [*held.get(DONE, []), *cut][-KEPT_DONE:]
    send_to_chat(root, session, text, cut[0] or None, streamed=True)


def next_turn(root: Path, session: str) -> None:
    with DisplayedLedger(root, session).changing() as held:
        held[SENT] = [mark for mark in held.get(SENT, []) if not COMMIT.fullmatch(mark)]
        held[MATCHED] = []


def unfinished(root: Path, session: str, row) -> None:
    provider = PROVIDERS.get(row.provider)
    if provider is None or not row.transcript:
        return
    turns = [turn for turn in provider().transcript(Path(row.transcript)) if turn.has_agent_text and turn.at >= time.time() - CATCH_UP]
    ledger = DisplayedLedger(root, session)
    with ledger.changing() as held:
        sent = [*held.get(SENT, [])]
        streamed = [*held.get(MATCHED, [])]
        pending = []
        if any(LINE_KEY.fullmatch(key) for key in sent):
            written = ledger.file.stat().st_mtime
            sent = [turn.key for turn in turns if turn.at <= written]
            turns = [turn for turn in turns if turn.at > written]
        for turn in turns:
            key = turn.key
            if key in sent:
                continue
            matched = fingerprint(turn.text)
            if matched in sent:
                sent.remove(matched)
                sent.append(key)
                continue
            if matched in streamed:
                streamed.remove(matched)
                sent.append(key)
                continue
            pending.append(turn)
        held[SENT] = sent[-KEPT_SENT:]
        held[MATCHED] = streamed[-KEPT_SENT:]
    for turn in pending:
        send_to_chat(root, session, turn.text, turn.key)


def send_to_chat(root: Path, session: str, text: str, turn: str | None = None, streamed: bool = False) -> bool:
    if not text.strip():
        return False
    record = Record(root, Sessions(root).environment(session) or runtime.env(root))
    row = Agents(record, actor=SYSTEM)._titled(session)
    if row is None:
        return False
    ledger = DisplayedLedger(root, session)
    key = fingerprint(text)
    mark = key if turn is None else turn
    with ledger.changing() as held:
        sent, matched = held.get(SENT, []), held.get(MATCHED, [])
        if mark in sent or key in sent or key in matched:
            return True
        held[SENT] = [*sent, mark][-KEPT_SENT:]
        if streamed:
            held[MATCHED] = [*matched, key][-KEPT_SENT:]
    if chat.send(record, row, text, turn=turn):
        return True
    with ledger.changing() as held:
        held[SENT] = [one for one in held.get(SENT, []) if one != mark]
        held[MATCHED] = [one for one in held.get(MATCHED, []) if not streamed or one != key]
    return False
