Keeps /rename tagged with séance's machine tag and the tmux window bare

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 machine registry (presence = discovery) |
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; 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 |
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.
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 reposmachineTag — 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.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 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.
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. It is per Claude config directory, though: if you run Claude Code with a non-default CLAUDE_CONFIG_DIR, run seanced mcp install once from a session using it.
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.
/renameseanced mod install # installs this checkout's Claude Code mods (user scope)
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 is per Claude config directory.
Type what the session should do — or leave the prompt blank to start an empty one — then pick where it runs. Four tiles open pickers: machine, repository, model (Fable / Opus / Sonnet), and effort (Low through Max). Fresh worktree is on by default; switching it off runs the session in the repo as it stands. Then start it.
The verdict names the tmux window it created, and from there you continue the session in the Claude app like any other. Two things keep you from spawning duplicates: the header counts sessions across every machine (2 online · 1 asleep · 3 sessions), and the footer counts what is already running on the machine you picked. The repo list comes from that daemon's last scan — ↻ Rescan repos in the repository picker refreshes it, and a spawn that cannot find its repo offers to rescan and retry.
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.
<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"
# spawned 'flaky test' (~/repos/seance/.claude/worktrees/flaky-test)
seanced spawn seance --here # run in the checkout as it stands, no worktree
seanced sessions # running claude windows: window, repo, path
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
<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). Bare words after it become the prompt, which makes -p optional: seanced spawn seance fix the flaky test works.
Worktree mode, the default, puts the session in .claude/worktrees/<slug> on a worktree-<slug> branch. Claude Code does the git work — it branches from origin/<default> and fetches when its copy is a day stale — so what your checkout is on, and whether it is clean, changes nothing and is left untouched. If it can't reach origin it says so in the session and branches off your checkout instead, so a spawn that reports success on an offline machine may be on an older base than you expect. (A machine whose settings.json sets worktree.baseRef to "head" branches from its checkout instead; that is claude's setting, not séance's.) --here skips the worktree and runs in the checkout you already have.
status reports the machine you run it on — including the sha the daemon is running versus the one on disk, which is how you spot a git pull that still needs a restart. The cross-machine view is the app, or list_machines over MCP.
With seanced mcp install done (step 6), a local Claude Code can list machines, query running sessions, and spawn sessions on other machines through the relay: list_machines, get_sessions, spawn_session. The server reads the daemon's own config and speaks to the relay exactly like the phone does.
Naming this machine skips the relay entirely: the request goes straight to the local daemon over a unix socket in the state directory, so spawning where you are sitting costs no round trip and works with the relay down. It is the same daemon, backend and audit trail either way — the log says origin=local instead of origin=relay, and seanced status and doctor report whether the socket is up. If a remote machine shares this one's name, local wins; reach the remote one by its deviceId.
Treat spawn_session like the remote it is: leave it behind Claude Code's per-tool approval rather than allowlisting it.
git pull && bun daemon/src/main.ts restart # daemon, on one machine
Daemons self-propagate: the restarted daemon notices its version changed and announces it over the relay, and every other machine fetches, fast-forwards its default branch, runs bun install --frozen-lockfile, and restarts itself. A machine that was asleep or offline catches up on its next reconnect. A checkout that is dirty, on another branch, or has local commits is skipped — never forced — and reports why; seanced status on that machine shows its version and its last update outcome. The relay and the app deploy themselves from CI on a push to main that touches them (Deploy in Actions also runs on demand, per component), so deploying either by hand would race that.
The Raycast extension does not self-propagate: it is a built copy, so a machine that has it needs seanced raycast install re-run after the pull. seanced doctor flags one that is out of date.
Service definitions already on disk lag behind the code that writes them, and machines installed before certain fixes need a one-time catch-up — both in docs/platform-notes.md. seanced doctor flags a machine that still needs it.
This is a personal project shared publicly rather than one looking for contributors: issues and observations are welcome, but the roadmap is whatever I happen to need next.
hooks/register.ts 54 lines1import type { Register } from "claude-code";
2
3/**
4 * Séance names a spawned session `slug (machineTag)` but its window just
5 * `slug` (DESIGN.md, "Session name vs window name"), and hands the session its
6 * tag as SEANCE_MACHINE_TAG. A /rename keeps both shapes: the session name
7 * gains the suffix when missing, the window drops it. Only the exact
8 * ` (<tag>)` counts, so `fix login (urgent)` is the user's text, never the tag.
9 */
10function splitTag(name: string, tag: string | undefined): { readonly bare: string; readonly suffix: string } {
11 const trimmed = name.trim();
12 if (tag === undefined || tag === "") return { bare: trimmed, suffix: "" };
13 const suffix = ` (${tag})`;
14 const bare = trimmed.endsWith(suffix) ? trimmed.slice(0, -suffix.length).trimEnd() : trimmed;
15 return { bare, suffix };
16}
17
18export function sessionName(name: string, tag: string | undefined): string {
19 const { bare, suffix } = splitTag(name, tag);
20 return `${bare}${suffix}`;
21}
22
23export function windowName(name: string, tag: string | undefined): string {
24 return splitTag(name, tag).bare;
25}
26
27export const register: Register = (on) => {
28 on("command.run", { command: "rename" }, async ($, e, next) => {
29 // bare /rename lets Claude pick the name, which this hook can't see
30 if (e.args.trim() === "") return next(e);
31
32 const tag = await $.env.get("SEANCE_MACHINE_TAG");
33 const name = windowName(e.args, tag);
34 const result = await next({ ...e, args: sessionName(e.args, tag) });
35
36 const pane = await $.env.get("TMUX_PANE");
37 if (pane === undefined || pane === "") return result;
38
39 // -t pane id: rename Claude's own window, not whichever has focus;
40 // `--` so a name like `-wip fix` isn't read as tmux flags
41 try {
42 const ran = await $.process.run(["tmux", "rename-window", "-t", pane, "--", name]);
43 if (ran.exitCode !== 0) $.ui.toast(`tmux rename-window failed: ${ran.stderr.trim()}`);
44 } catch (err) {
45 // rejects when tmux can't start or outlives the timeout
46 $.ui.toast(`tmux rename-window failed: ${err instanceof Error ? err.message : String(err)}`);
47 }
48 return result;
49 })
50 // fail open: whatever else throws must never cost the rename itself
51 // oxlint-disable-next-line promise/no-callback-in-promise -- a hook registration's `.catch`, not a Promise's
52 .catch(($, e, next) => next(e));
53};
54