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

Spawn new Claude Code sessions on any of your machines, from your phone.
<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">
origin/<default>, so a spawn neither inherits nor disturbs what you're in the middle of.git pull and restart on one machine; the rest fetch, fast-forward, and restart on their own.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.
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.
| Component | Where it runs | What it does |
|---|---|---|
relay/ | Cloudflare Worker + Durable Object | Routes 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.
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).
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.
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.
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.
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 1psk — 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.
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.
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.
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.
/rename and session reportsseanced 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.
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">
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
hooks/register.ts 285 lines1import 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