SLOPSHOPPER

session-events

Reports a session's state and PR URL to the local seanced for séance's scheduler

guardprocess
A shopper browsing a rack in a slop shop
README

Séance

Spawn new Claude Code sessions on any of your machines, from your phone.

Setup · Usage · Docs

<img src="docs/images/spawn-form.png" width="320" alt="The Séance spawn form on a phone: a prompt, pickers for machine, repository, model and effort, a fresh-worktree toggle, and a Start button">

  • Starts sessions — doesn't just continue them. Claude Code Remote Control resumes what already exists; Séance creates.
  • Every machine you own, with no pairing step. macOS, Linux, WSL: a daemon holding the right token and key simply appears in the app.
  • A fresh worktree per session, by default. Branched off origin/<default>, so a spawn neither inherits nor disturbs what you're in the middle of.
  • The relay is blind. It routes ciphertext; the pre-shared key never leaves your own devices.
  • Free to run. A Worker and a SQLite-backed Durable Object for the relay, a static-assets Worker for the app — all inside Cloudflare's free tier.
  • Daemons update themselves. git pull and restart on one machine; the rest fetch, fast-forward, and restart on their own.

Why

Claude Code Remote Control lets you continue sessions from your phone, but can't start new ones on a machine remotely. Keeping a persistent "god" session alive just to run a spawn script works, but it's fragile and awkward. Séance replaces it with a per-machine daemon that's always ready.

Security

The relay is a router, not a participant: every payload crossing it is sealed with a pre-shared key you mint yourself, so Cloudflare stores and forwards ciphertext it cannot read. The bearer token only gates access to the relay — the PSK is the actual trust boundary, and it belongs in a platform key store rather than a file (docs/psk.md). On the machine, spawns exec argv arrays — the one shell string is the tmux command line, where every wire-supplied value is shell-quoted and the prompt travels by temp file, never argv — repos resolve by name against a cached scan set rather than by path, and prompt text is never written to the log. DESIGN.md carries the full threat model, including what this deliberately does not defend against.

How it fits together

ComponentWhere it runsWhat it does
relay/Cloudflare Worker + Durable ObjectRoutes opaque encrypted blobs phone↔daemon, persists the machine registry (presence = discovery) and the scheduler's sealed queue
daemon/Each dev machine (Bun; launchd on macOS, systemd user unit on Linux/WSL)Holds WebSocket to relay, scans repo roots, spawns claude in a named tmux session, reports load and quota every 30s and keeps its history; seanced mcp serves the same reach to a local Claude Code
pwa/Cloudflare Worker (static assets)Spawn form, machine and repo pickers, spawn verdicts, per-machine counts of running sessions; a Machines screen with load, quota and history

See DESIGN.md for the full architecture and every decision with its rationale.

Setup

Everything runs from one dev machine — which can also be the first daemon machine. There is no chicken-and-egg: the two secrets are minted by you before anything exists, and the only other shared value (the relay URL) is produced by step 2 and consumed by steps 3 and 4.

Prerequisites: Bun, a Cloudflare account, and this repo cloned with bun install run at the root. The relay is one Worker plus one SQLite-backed Durable Object, so it fits the free tier — no paid plan needed. Each daemon machine (macOS, Linux, or WSL) additionally needs tmux, git, and claude on PATH. Linux machines need systemd for seanced install — a standard Debian/Ubuntu box, VM, or LXC container (unprivileged is fine) qualifies; without systemd, run seanced under your own supervisor. On WSL, enable systemd before anything else: systemd=true under [boot] in /etc/wsl.conf, which takes a wsl.exe --shutdown to apply and kills every session on the box (docs/platform-notes.md).

1. Mint the shared secrets

openssl rand -base64 32 | tr '+/' '-_' | tr -d '='   # bearer token
openssl rand -base64 32                               # PSK

Keep both in a password manager — every daemon and the phone get the same two values. The bearer token must be base64url-safe because the app sends it as a query parameter: a + decodes to a space and you get a silent 401 instead of an error. The PSK is the actual trust boundary; the relay never sees it.

2. Deploy the relay

cd relay
bun run wrangler login                     # once
bun run wrangler secret put BEARER_TOKEN   # paste the bearer token
bun run deploy                             # prints https://seance-relay.<subdomain>.workers.dev

This first deploy is by hand; afterwards CI redeploys the relay on every push to main that touches it, which needs a CLOUDFLARE_API_TOKEN repository secret and a CLOUDFLARE_ACCOUNT_ID repository variable. The bearer token stays a Worker secret and a CI deploy leaves it alone — as does SCHEDULER_LEADERS, the second secret, set in step 4 once your machines have ids. Until it is set no machine leads the scheduler, so Auto queues the task and it waits there until one does (no preview, no placement); seanced spawn --now doesn't need it.

Note the <subdomain> in the printed URL — steps 3 and 4 derive their URLs from it.

The first deploy also mints the workers.dev hostname, which needs a few minutes to route and get a certificate. Until it does, requests fail the TLS handshake or return a 404 with body error code: 1042 — propagation, not a broken deploy. The edge caches those errors, so re-check with a cache-buster (curl -sI '<url>/?x=1') rather than the path you already probed.

3. Deploy the app

VITE_RELAY_URL is required: it pins the CSP's connect-src to your relay, so a build without it fails rather than shipping a page that cannot connect. It also means pointing the app at a different relay later requires a rebuild, not just a settings edit.

cd pwa
VITE_RELAY_URL=wss://seance-relay.<subdomain>.workers.dev/app bun run deploy

Or put it in a gitignored pwa/.env (see .env.example) and just bun run deploy. This first deploy is by hand; afterwards CI redeploys the app on every push to main that touches it, which needs the same value as a VITE_RELAY_URL repository variable on top of step 2's secret and variable. This hostname is new too — the step 2 propagation note applies again.

4. Install seanced on each machine

Distribution is the git checkout itself: the service runs bun <checkout>/daemon/src/main.ts, so updating is git pull + restart.

git clone <this repo> && cd seance && bun install
bun daemon/src/main.ts init      # writes the ~/.config/seance/config.json skeleton

The CLI runs the same way, and to spare the typing:

bun daemon/src/main.ts link      # symlinks `seanced` into a dir on your PATH

The link is a plain symlink to main.ts (executable, bun shebang), so it tracks git pull and source edits with nothing to re-run. It prefers ~/.local/bin, or takes a dir explicitly (link <dir>); seanced unlink removes it, as does uninstall. A shell alias (alias seanced='bun <checkout>/daemon/src/main.ts') works too. doctor and install warn when the name doesn't resolve — or resolves into a different checkout, which link repoints — though they can't see an alias.

Edit ~/.config/seance/config.json:

  • relayUrl — wss://seance-relay.<subdomain>.workers.dev/daemon (must end in /daemon)
  • bearerToken — from step 1
  • psk — from step 1, or leave it empty and run seanced psk-import to keep the key in the platform store instead (macOS login keychain / WSL DPAPI blob / Linux TPM-sealed blob), which is where it belongs — see docs/psk.md. Linux without a usable TPM has no such store; there the PSK stays in config.json, which init creates 0600. On systemd ≥ 256 the sealed blob is instead sealed once as root and delivered to the unit by PID 1, which psk-import can't do — docs/psk.md has that runbook.
  • repoRoots — directories to scan for repos. Only these are scanned, so if you use exclaves (below) the roots must cover their trees for the clones there to appear.
  • machineTag — optional short tag naming this box, suffixed onto the session name Claude Code registers, so the Claude UIs read fix-the-thing (thad). The tmux window name stays bare — locally the machine is never in question. Leave it empty for unsuffixed names.
  • reap — optional: { "intervalHours": 24, "minAgeDays": 7 } runs seanced reap from the daemon every day, with its report in the log. Without intervalHours the schedule is off (the default); minAgeDays also sets the CLI's age gate.
  • quota — optional: { "staleAfterMinutes": 10 } (the default) is how old an account's cached quota may get before the daemon refreshes it with claude -p /usage. Values under 1 are refused: Claude Code debounces /usage for 60 s anyway.

Optional: ~/.exclaves lists extra Claude Code accounts on the box, one name per line. Each name is a tree at ~/<name> whose sessions use the config dir ~/.claude-<name>; default (~, ~/.claude) is implied and never listed. A clone's location decides which account a spawn there runs under. From each exclave's .claude.json the daemon reads the account ids and Claude Code's cached usage figures — never a token or the email — and reports them, sealed, alongside load.

A running daemon watches this file and reloads within a second of a save, so edits need no restart — including ones made by a Claude Code session on the box. A bad edit is refused with the reason in the log and the previous config keeps running, so the machine never drops offline over a typo. Only a code change still needs seanced restart. A repo added inside an existing root needs nothing at all — the hourly rescan, or the app's refresh, picks it up.

bun daemon/src/main.ts doctor    # preflight: config, tmux/git/claude, relay reachability
bun daemon/src/main.ts install   # macOS: launchd agent (RunAtLoad + KeepAlive)
                                 # Linux/WSL: systemd user unit + linger (+ a Windows logon task on WSL)

On systemd ≥ 256 with the PSK in a TPM-sealed blob the daemon needs a root-owned unit instead, so PID 1 can unseal the key and hand it over — sudo --preserve-env=PATH "$(command -v seanced)" install --system, details in docs/psk.md and docs/platform-notes.md.

Both platforms have sharp edges worth knowing about — linger on a headless Linux box, the WSL logon pin task, and what happens while no Windows user is logged in — collected in docs/platform-notes.md.

doctor prints a PSK fingerprint — compare it across machines to confirm they all hold the same key. (Not on a box whose key is delivered to its unit by systemd: nothing outside the unit can read it, so the fingerprint is in the daemon's log instead.) A daemon with the right token and PSK simply appears in the app; there is no pairing step.

Let it lead the scheduler. The relay nominates a scheduler leader — the daemon that runs Auto — only from an allowlist: SCHEDULER_LEADERS, comma-separated device ids, a Worker secret like the bearer token. Without it anyone holding the bearer token could mint an id of their own and hold the role. Read the id off seanced doctor (the deviceId line under state), then from relay/:

bun run wrangler secret put SCHEDULER_LEADERS   # paste every leader's id, comma-separated

It replaces the whole value, so paste the ids already on it too. Unset or empty, no machine leads. List the machines that are usually awake; the role moves between them as they come and go. Putting a secret rolls a new relay version, so every daemon and app drops and reconnects once.

Every machine on that list also needs gh on the PATH that seanced install captured, and gh auth login run as the service's user (under --system, that user), so the leader can read each task's PR status from GitHub: the queue card's coloured chip, and a merged PR settling its task. On a Linux service with no keyring or D-Bus session, gh must hold its token in hosts.yml (gh auth login --insecure-storage), or the unit must carry GH_TOKEN; otherwise gh reads as not logged in. Without gh, PR status does not show, the leader logs one pr-status: warning, and nothing else breaks.

5. Set up the phone

Open https://seance-pwa.<subdomain>.workers.dev, add it to your home screen, and in first-run setup enter the relay URL (wss://seance-relay.<subdomain>.workers.dev/app), the bearer token, and the PSK. Machines appear as their daemons register.

6. Optional: give a local Claude Code the same reach

On any machine that runs seanced:

seanced mcp install    # registers the MCP server with Claude Code (user scope)

Claude Code sessions on that machine can then reach every other machine through the relay — see From another Claude Code session. seanced mcp uninstall removes it; seanced doctor reports whether it is registered.

The entry records bun by its PATH name, so upgrading bun doesn't break it. A user-scope entry lives in one Claude config directory, so mcp install registers it in every exclave's (~/.claude, plus ~/.claude-<name> for each name in ~/.exclaves) and in the shell's own CLAUDE_CONFIG_DIR when no exclave owns it. An exclave whose config directory doesn't exist yet is skipped: add an exclave later and re-run it. A task that settles on its session's own done report (until: "reported") needs it in the exclave the session runs in, since the session reports through this server's report_task_done tool, which must be allowed there (mcp__seance__report_task_done in that exclave's permissions): a call stopped at a permission prompt reports nothing, and the task then waits for Mark done.

7. Optional: spawn from Raycast (macOS)

On a Mac with Raycast installed:

seanced raycast install    # builds this checkout's extension and imports it

Séance then appears in Raycast as Spawn Session — a form that reaches every paired machine, one hotkey away. It reads this machine's config.json and the PSK from the login keychain, so there is no secret to enter and nothing to configure; Raycast's extension preferences hold only which machine, model and effort to preselect.

The install is a local one — the extension is deliberately never published to the Raycast Store — so nothing updates it on its own: re-run it after a git pull, which seanced doctor reminds you of once the sources are newer than the imported copy. seanced raycast uninstall removes the import and the build artifacts; Raycast keeps listing the extension until you also remove it in Manage Extensions (⌘⇧E), which no command can do for you.

8. Optional: Claude Code mods — /rename and session reports

seanced mod install    # installs this checkout's Claude Code mods (user scope, every exclave)

After a /rename in any Claude session in tmux — spawned or started by hand — the remote-control name keeps this machine's (machineTag) suffix (added when you leave it out) and the tmux window takes the name without it, so /rename fix-auth and /rename fix-auth (thad) both name the session fix-auth (thad) and the window fix-auth. The daemon puts the tag in tmux's global environment when it starts, so a shell opened before that (or before a tag change) needs a new window. The mod is read in place from the checkout, so a git pull reaches new sessions with nothing re-run (running ones on /reload-plugins). seanced mod uninstall removes it; seanced doctor reports whether it is installed from this checkout. Like mcp install, it reaches every Claude config directory: plugins are per config directory, so install, uninstall and doctor run once per exclave (~/.claude plus each ~/.claude-<name> listed in ~/.exclaves), skipping any not set up yet. An exclave created later needs seanced mod install run again.

The same command installs session-events. It tells this machine's daemon what each session is doing (busy, idle, waiting on you, ended) and the PR it opened, which is what the scheduler reads. Quota no longer comes from it: the daemon refreshes each signed-in account's own cached reading with a claude -p /usage run once it is ten minutes old, checking every minute and whenever a session there reports. It runs this checkout's daemon/src/main.ts session-event with bun from PATH, so it needs no seanced link. A session started outside tmux reports nothing.

Usage

From the phone

Type what the session should do — or leave the prompt blank to start an empty one — then pick where it runs. Tiles open pickers: machine, account, repository, model (Fable / Opus / Sonnet / Haiku), and effort (Low through Max), plus the authoring ones below. Fresh worktree is on by default; switching it off runs the session in the repo as it stands. Then queue it.

Every spawn from the phone is a queued task: the app never starts a session on a machine itself. The verdict names the task and follows it until it is running, then you continue the session in the Claude app like any other. The header counts sessions across every machine (2 online · 1 asleep · 3 sessions), and the footer says who places the task — the scheduler, or the machine you pinned.

A machine you have retired keeps its place in the picker with a stale last-seen — the relay never expires an entry. The bin on an offline row deletes it there, so it disappears on every device you use the app from, and returns only if that machine's daemon connects again. seanced uninstall deletes it for you on the way out; under --system it can't (as root it would read the wrong user's config) and says so.

There is no title field: the daemon names the tmux window after the prompt.

The machine's repo list comes from its daemon's last scan. ↻ Rescan sits on that machine's row in the Machine picker (offline machines can't answer it, and one rescan runs at a time); it refreshes the list and the row shows when it last scanned.

The Machine and Account pickers each start on Auto, which lets the scheduler choose; picking a machine or an account pins the task to it. The repository picker lists repos by origin, across every machine that has a clone (narrowed to the picked machine and account), and with both pickers on Auto a placement preview under the tiles says where it would start and why each other machine lost. Queue (or Queue on <machine>) puts the task in the relay's queue, and the queued verdict shows the decision behind it, and why it waits while it does. With no scheduler leading (no machine on the relay's SCHEDULER_LEADERS is connected — step 4), or one that can't be verified (its registry entry doesn't open with your key), the task still queues and waits there until one does; only the placement preview needs a leader. Queuing is greyed only when the app isn't connected to the relay. A pinned task waits for its machine or account when it is offline or over a limit, and says why; pinning an account needs a leader that supports it, and says so otherwise. The Queue screen (a column on desktop, in the header menu on a phone) shows everything queued: what needs you, what is running or waiting, and what finished, with Cancel and Mark done (each takes a second tap when it would end a running session), and Pause / Resume. The app pauses the whole queue only; a hold on one account shows with its own Resume, but no client sets one yet. seanced spawn --now starts a session directly on a machine, with or without a scheduler.

Upgrading into the queue: until the machine leading the scheduler runs a build with it, tasks an updated app or seanced mcp queues sit unrun, and an older app's auto is refused by an updated leader. Update the leader-capable machines first.

Machines (a column on desktop, in the header menu on a phone) shows every machine with its exclaves and accounts, current CPU and memory pressure, the newest quota reading per account, the build its daemon runs, and history charts drawn from each daemon's own store — an asleep machine's history waits until it wakes. Its header names who leads the scheduler and under which term, or says no machine does; the leader's card carries a Scheduler badge, or Unverified while its claim to lead doesn't check out with your key.

<img src="docs/images/machine-sheet.png" width="260" alt="The machine picker: two machines online with their repo counts, one offline with when it was last seen"> <img src="docs/images/verdict.png" width="260" alt="The success verdict: it's running on studio, naming the tmux window it created">

From the machine

seanced spawn seance -t "flaky test" -p "fix the flaky spawn test"
#   task 3f2a…: queued — the scheduler picks the machine; needs the relay, and a PSK this shell or its daemon holds
seanced spawn --machine here seance -p "fix the flaky spawn test"
#   pinned to this machine: it waits for a slot here rather than going elsewhere
seanced spawn --account work --not here seance -p "rerun the slow suite"
#   pinned to an account (alias, email or id) and kept off this machine
seanced spawn --now seance -t "flaky test" -p "fix the flaky spawn test"
#   spawned 'flaky test' (~/repos/seance/.claude/worktrees/flaky-test)
seanced spawn --now seance --here   # run in the checkout as it stands, no worktree
seanced sessions              # running claude windows: id, window, repo, path
seanced despawn %12           # end one by id: asks claude to exit, kills the pane after ~5s (--force: now)
seanced reap --dry-run        # what clean-up would remove: worktrees, branches, dead windows
seanced status                # this machine: service, relay, repo count, running vs on-disk sha
seanced doctor                # preflight config, binaries, roots, relay, service
seanced help                  # every command

With --now, <repo> is a name, matched exactly against the cached scan set and never joined as a path — so it is whatever seanced scan calls the repo (a bare basename, or parent/base when two collide); a queued spawn resolves a clone name or origin across the fleet, as below. Bare words after it become the prompt, which makes -p optional: seanced spawn seance fix the flaky test works.

A spawn queues for the scheduler, as the app's and MCP's do: <repo> is a clone name or origin from any machine in the fleet, and the task waits in the relay's queue until a leader places it. It needs the relay: it reads the PSK itself, or on a box where only the daemon can (below) asks the running daemon to enqueue for it. The app's queue shows where it lands. --now is the d

Source 1 files
hooks/register.ts 285 lines
1import type { EngineInterface, Register } from "claude-code";
2
3/**
4 * Tells the local seanced what this session is doing, for séance's scheduler
5 * (DESIGN.md, "Session events"): busy, idle, waiting on a person, ended, and
6 * the PR it opened. Each report is one `session-event` run of this checkout's
7 * daemon CLI (`daemonMain`) with the event as JSON on stdin. The daemon trusts
8 * none of it about *which* session this is — it binds the report to a tmux pane
9 * by this process tree — so nothing here names a session or a task.
10 *
11 * Engine events only, never `classic.*`: on Team/Enterprise orgs a built-in
12 * plugin sits above every installed one on the classic chain and passes each
13 * event on past them, so classic hooks here never ran.
14 *
15 * Waiting on a person is a `tool.check` whose verdict beneath this hook is
16 * `ask` with a person to answer, or an AskUserQuestion call. Each such dialog is
17 * kept with the loop it holds (`agentId`, unset for the main loop) until its
18 * `tool.call` settles or rejects. The state reported is always the current one:
19 * needs-input while any dialog is outstanding, else idle while the main loop is
20 * at its prompt (`atPrompt`: set by its turn.complete, cleared by its
21 * turn.start), else busy. So a subagent's dialog closing after the main turn
22 * ended resumes to idle, and the main loop's own turn events drop its dialogs (one
23 * cannot be open across them; any left is a leak) but never a subagent's.
24 * Every `prUrl` goes with a state, since the daemon refuses a frame without one,
25 * and the last one seen rides on `ended`.
26 *
27 * Auto mode also settles on `ask` (its classifier decides without a person), so
28 * there each such call reports needs-input then busy within the classifier's
29 * seconds: accepted, since no engine event says a dialog was shown and the leader
30 * escalates only a needs-input that lasts.
31 * A plugin above this one may override the verdict this hook read: one that
32 * asks over an allow is a missed needs-input; one that turns an ask into an
33 * allow or deny is a brief needs-input with no dialog. No hook can see either.
34 * Not covered: an MCP server's elicitation, which has no engine event.
35 *
36 * Fire-and-forget throughout: no hook waits on the daemon, and a missing
37 * `bun`, a stopped daemon or a slow one costs the session nothing.
38 */
39
40type State = "busy" | "idle" | "needs-input" | "ended";
41
42interface Report {
43  readonly state?: State;
44  readonly prUrl?: string;
45}
46
47/** Long enough for the daemon's parent walk, short enough to never pile up behind a wedged one. */
48const SEND_TIMEOUT_MS = 3_000;
49
50/** session.end reasons after which the same process, in the same pane, carries on. */
51const CONTINUES: ReadonlySet<string> = new Set(["clear", "resume"]);
52
53/**
54 * The daemon's entry point, by path from this mod's own folder rather than the
55 * CLI's name on PATH: a directory marketplace is read in place, so the mod and
56 * the daemon are always the same checkout, while the PATH link is opt-in and
57 * nothing re-points it on a pull. Exported for tests.
58 */
59export function daemonMain(pluginRoot: string): string {
60  const parts = pluginRoot.replace(/\/+$/u, "").split("/");
61  // The plugin folder is <checkout>/claude-mods/<mod>.
62  return [...parts.slice(0, -2), "daemon", "src", "main.ts"].join("/");
63}
64
65const PR_URL = /https:\/\/[^\s"'<>]+\/pull\/\d+/u;
66
67/**
68 * The PR a `gh pr create` printed. Only that command, only an `https:` URL
69 * ending in `/pull/<n>` — the daemon re-checks the scheme anyway, and nothing
70 * else a command printed is worth reporting.
71 */
72export function prUrlFrom(command: string, stdout: string): string | null {
73  if (!/\bgh\s+pr\s+create\b/u.test(command)) return null;
74  return PR_URL.exec(stdout)?.[0] ?? null;
75}
76
77async function report($: EngineInterface, body: Report): Promise<void> {
78  // Outside a tmux pane (no tmux, or a `claude --bg` session's pty host) the daemon
79  // answers `unbound` every time: not worth a Bun start and its tmux and ps calls
80  // per event. The daemon still decides the binding for every report that goes out.
81  const pane = await $.env.get("TMUX_PANE");
82  if (pane === undefined || pane === "") return;
83  // Bun by bare name, as `update.ts` does: resolved at use, never recorded.
84  await $.process.run(["bun", daemonMain($.plugin.root), "session-event"], {
85    stdin: JSON.stringify(body),
86    timeoutMs: SEND_TIMEOUT_MS,
87  });
88}
89
90/**
91 * Newest state wins, a `prUrl` once seen is kept: a wedged daemon costs
92 * SEND_TIMEOUT_MS a run, so a queue of reports would grow without bound, and
93 * only the latest state is true by the time one is sent.
94 */
95function merge(older: Report | null, newer: Report): Report {
96  const state = newer.state ?? older?.state;
97  const prUrl = newer.prUrl ?? older?.prUrl;
98  return { ...(state === undefined ? {} : { state }), ...(prUrl === undefined ? {} : { prUrl }) };
99}
100
101/**
102 * Everything a registration tracks. Created inside `register`, so each
103 * registration (and each test) starts fresh. It is a value passed to top-level
104 * functions rather than closure state because the hooks compiler only follows `$`
105 * into functions declared at the top of the file.
106 */
107interface Tracker {
108  /** One report in flight and one pending slot: separate Bun starts race, and an
109   * Esc on a permission prompt raises busy then idle within a millisecond, so the
110   * second goes out only after the first has landed. */
111  running: boolean;
112  pending: Report | null;
113  /** What holds a loop on a person, by tool_use_id; the value is the loop's `agentId` (unset: main). */
114  readonly outstanding: Map<string, string | undefined>;
115  /** The main loop is at its prompt: its turn.complete came and no turn.start since. */
116  atPrompt: boolean;
117  lastState: State | undefined;
118  lastPrUrl: string | undefined;
119  anonymous: number;
120}
121
122function newTracker(): Tracker {
123  return {
124    running: false,
125    pending: null,
126    outstanding: new Map(),
127    atPrompt: false,
128    lastState: undefined,
129    lastPrUrl: undefined,
130    anonymous: 0,
131  };
132}
133
134async function drain($: EngineInterface, t: Tracker): Promise<void> {
135  t.running = true;
136  try {
137    while (t.pending !== null) {
138      const body: Report = t.pending;
139      t.pending = null;
140      // oxlint-disable-next-line no-await-in-loop -- one report at a time, in order
141      await report($, body).catch(() => {
142        // bun not on PATH, or the run timed out: nothing for the session to do about it.
143      });
144    }
145  } finally {
146    t.running = false;
147  }
148}
149
150function send($: EngineInterface, t: Tracker, body: Report): void {
151  // Never awaited by a hook: the run resolves when the CLI exits.
152  if (body.state === "ended") {
153    // Terminal, so it skips the queue: it must not wait out a wedged daemon's
154    // timeouts, and whatever was pending is stale. A pending prUrl rides along.
155    const last = merge(t.pending, body);
156    t.pending = null;
157    report($, last).catch(() => {
158      // As above: nothing for the session to do about it.
159    });
160    return;
161  }
162  t.pending = merge(t.pending, body);
163  if (!t.running) {
164    drain($, t).catch(() => {
165      // report() already swallows; this only keeps a bug here from going unhandled.
166    });
167  }
168}
169
170function current(t: Tracker): State {
171  if (t.outstanding.size > 0) return "needs-input";
172  return t.atPrompt ? "idle" : "busy";
173}
174
175/** Reports the current state; an unchanged one goes out only to carry a `prUrl`. */
176function sendCurrent($: EngineInterface, t: Tracker, prUrl?: string): void {
177  const state = current(t);
178  if (state === t.lastState && prUrl === undefined) return;
179  t.lastState = state;
180  send($, t, { state, ...(prUrl === undefined ? {} : { prUrl }) });
181}
182
183function dropMain(t: Tracker): void {
184  for (const [id, loop] of t.outstanding) {
185    if (loop === undefined) t.outstanding.delete(id);
186  }
187}
188
189// Every hook fails open: a report that throws must never cost the event itself.
190// Where the hook already called `next`, the handler's `next(e)` replays that
191// settlement, so a tool never runs twice and its own rejection passes up unchanged.
192// oxlint-disable promise/no-callback-in-promise -- each `.catch` is a hook registration's, not a Promise's
193export const register: Register = (on) => {
194  const t = newTracker();
195  // Also raised on an enable, a hooks-worker respawn or a reload, not only a fresh
196  // process, so a restart mid-turn reads idle until the next turn event. Gating on
197  // a first start would take a `$.state` contract and a value this plugin owns:
198  // too much machinery for a report the next turn event corrects.
199  on("session.start", ($, e, next) => {
200    t.atPrompt = true;
201    sendCurrent($, t);
202    return next(e);
203  }).catch(($, e, next) => next(e));
204  // A subagent's turns say nothing about whether the main loop is at its prompt.
205  on("turn.start", ($, e, next) => {
206    if (e.agentId === undefined) {
207      // Not only a typed prompt: a continuation turn starts with `text: ""` while a
208      // background subagent's dialog may be open, which therefore stays.
209      dropMain(t);
210      t.atPrompt = false;
211      sendCurrent($, t);
212    }
213    return next(e);
214  }).catch(($, e, next) => next(e));
215  on("turn.complete", ($, e, next) => {
216    if (e.agentId === undefined) {
217      dropMain(t);
218      t.atPrompt = true;
219      sendCurrent($, t);
220    }
221    return next(e);
222  }).catch(($, e, next) => next(e));
223  // Any loop's: a subagent's prompt holds the session as the main loop's does.
224  // A `$.tool.check` query carries no tool_use_id and shows no dialog.
225  on("tool.check", async ($, e, next) => {
226    // The verdict beneath this hook: a plugin above may still change it.
227    const verdict = await next(e);
228    if (verdict.decision === "ask" && e.approver === "person" && e.tool_use_id !== undefined) {
229      t.outstanding.set(e.tool_use_id, e.agentId);
230      sendCurrent($, t);
231    }
232    return verdict;
233  }).catch(($, e, next) => next(e));
234  on("tool.call", async ($, e, next) => {
235    const isQuestion = e.tool === "AskUserQuestion";
236    // Core's own check for AskUserQuestion settles ask/person under this id too.
237    const id = e.tool_use_id ?? (isQuestion ? `question-${++t.anonymous}` : undefined);
238    if (isQuestion && id !== undefined) {
239      t.outstanding.set(id, e.agentId);
240      sendCurrent($, t);
241    }
242    /** Closes `id`; a dialog closing or a PR found reports the state the session is in now. */
243    const finish = (prUrl?: string): void => {
244      const wasOpen = id !== undefined && t.outstanding.delete(id);
245      if (prUrl !== undefined) t.lastPrUrl = prUrl;
246      if (prUrl !== undefined || (wasOpen && t.outstanding.size === 0)) sendCurrent($, t, prUrl);
247    };
248    // A rejection closes too, so the session resumes whatever the turn does next.
249    const settled = async <R>(run: Promise<R>): Promise<R> =>
250      run.catch((err: unknown) => {
251        finish();
252        throw err;
253      });
254    // Narrowed before `next`, so it resolves to Bash's own record.
255    if (e.tool === "Bash") {
256      const ran = await settled(next(e));
257      let prUrl: string | undefined;
258      try {
259        const stdout: unknown = ran.result.stdout;
260        if (ran.deny === undefined && ran.isError !== true && typeof stdout === "string") {
261          prUrl = prUrlFrom(e.command, stdout) ?? undefined;
262        }
263      } catch {
264        // A result of a shape this mod does not know: no PR to report.
265      }
266      finish(prUrl);
267      return ran;
268    }
269    const ran = await settled(next(e));
270    finish();
271    return ran;
272  }).catch(($, e, next) => next(e));
273  // Unawaited like the rest, so it may not outlive a fast exit; the daemon's
274  // `ended` backstop counts a launched task whose pane is gone either way.
275  // `/clear` and `/resume` end a conversation, not the process: the pane runs
276  // on, so `ended` — terminal for the task — would be a lie.
277  on("session.end", ($, e, next) => {
278    if (!CONTINUES.has(e.reason)) {
279      send($, t, { state: "ended", ...(t.lastPrUrl === undefined ? {} : { prUrl: t.lastPrUrl }) });
280    }
281    return next(e);
282  }).catch(($, e, next) => next(e));
283};
284// oxlint-enable promise/no-callback-in-promise
285