---
description: Session enumeration, group filtering, direct-attach relay + move-based board pin-sessions (`_rk-pin-*`), window addressing, SSE poll-set reap on dead-server fetch error, unified test-socket naming + `rk reaper`, env-gated `RK_SERVER_ALLOWLIST` test-scoping
---
# tmux Session Enumeration

## Multi-Server Model

run-kit connects to **multiple tmux servers**. Server identity is part of the URL path: `/` shows the server list, `/$server` is the per-server shell, `/$server/$window` is the terminal route (window = stable `@N`; `$session` was dropped from the route by `260529-jad6-window-api-stability`). The user navigates between servers by changing the `$server` route parameter (via sidebar, command palette `Server: Switch to <name>`, or direct URL).

The backend is stateless — every API request carries the server identity (`?server={name}` query parameter for REST/SSE/WebSocket; `serverFromRequest(r)` validates and defaults to `"default"` when missing). All tmux operations use `tmuxExecServer(ctx, server, args...)` which prepends `-L {server}` for named servers. The `"default"` server uses no `-L` flag, connecting to the user's standard tmux server. The config flag `-f {path}` is applied to all named servers (not just runkit).

### Per-View Server Scope

While run-kit *as a whole* is multi-server, **any single view (terminal, dashboard, sidebar tree) is scoped to one server** — the one in the URL. Components that derive state from "current server" (sidebar tree, optimistic overlays, SSE polling) read it from the route via `useSessionContext()`. The `optimistic-context` filters ghosts/overlays by `currentServer` so cross-server in-flight mutations don't leak into the wrong view.

Features that span multiple servers (e.g., the boards view) open multiple concurrent connections — one SSE per contributing server, one WebSocket per pinned terminal — and tag each entry with its source server in API responses.

### Server Discovery

`ListServers(ctx)` discovers available tmux servers by scanning the socket directory at `/tmp/tmux-{uid}/`. Each socket file represents a running server. Returns sorted server names — only sockets whose probe succeeds (dead sockets are dropped). The frontend calls `listServers()` at mount and refreshes via SSE-driven invalidation.

Since `260529-fww2-rk-reaper-command` the raw scan and the probe are factored into three shared helpers in `internal/tmux/tmux.go` so the `/tmp/tmux-{uid}` convention lives in exactly one place (consumed by both `ListServers` and the reaper — see § rk reaper):

| Helper | Responsibility |
|--------|----------------|
| `socketDirPath() string` | The single definition of the socket-dir path: `fmt.Sprintf("/tmp/tmux-%d", os.Getuid())`. |
| `ScanSocketDir(ctx) ([]string, error)` | Raw reapable candidate names via `os.ReadDir(socketDirPath())` + `filterSocketEntries`. Does NOT probe — **dead sockets ARE included**. Returns `nil, nil` when the dir is absent/unreadable (no servers running). |
| `filterSocketEntries(entries []os.DirEntry) []string` | Keeps the full reapable candidate set: `os.ModeSocket` entries (live/dead tmux servers) **plus** `*.lock` regular files. tmux `.lock` files are **regular files, not sockets**, so they are matched by name suffix (`LockSocketSuffix = ".lock"`) — a socket-mode-only filter would silently drop them and leave the reaper's `.lock` branch dead in real runs. Directories and non-`.lock` regular files are dropped. Split out so the filter is testable against a temp dir without the hardcoded path (`TestFilterSocketEntries`, which drives a real socket + real `.lock` file). |
| `probeServerAlive(ctx, name) bool` | Liveness probe: `tmux -L <name> list-sessions` via `exec.CommandContext` with a 2s timeout; `true` iff the command succeeds. |

`ListServers` calls `ScanSocketDir`, **skips any `.lock` candidate** (those are never servers — a probe would be a doomed subprocess), then probes each remaining candidate **concurrently** (bounded 10-slot semaphore) via `probeServerAlive`, appends only the live ones, and sorts. Its observable behavior is unchanged by the extraction — it still returns only sorted probe-success names and silently drops dead sockets.

### Server Lifecycle

- **Create**: Implicit — `CreateSession("0", $HOME, serverName)` starts a new server when the first session is created on it
- **Kill**: `KillServer(server)` runs `tmux [-L server] kill-server`, destroying all sessions
- **Switch**: navigate to `/$newServer/...` — the route change re-mounts the SessionProvider with the new `server` prop and reconnects SSE/WebSocket with the new `?server=` param

## Session-Group Filtering

tmux has a **session groups** feature. When multiple clients attach to the same session (e.g., via `tmux attach`), tmux may create derived session-group copies. This means `tmux list-sessions` returns both the original and derived copies:

```
devshell     grouped=1  group=devshell    ← primary
devshell-82  grouped=1  group=devshell    ← derived copy
run-kit      grouped=0  group=            ← standalone (no group)
```

Grouped sessions share the same windows, so displaying both is incorrect — it shows duplicate projects in the dashboard.

## How We Filter

`parseSessions()` in `internal/tmux/tmux.go` parses three format variables per session:

| Variable | Meaning |
|----------|---------|
| `#{session_name}` | The session name (e.g., `devshell-82`) |
| `#{session_grouped}` | `1` if the session belongs to ANY group, `0` otherwise |
| `#{session_group}` | The group name (e.g., `devshell`) — empty if not grouped |

**Filter rule**: keep sessions where `grouped=0` OR `name === group`. Applied to the queried server's results.

- `devshell` → grouped=1, name=group → **keep** (primary)
- `devshell-82` → grouped=1, name≠group → **filter out** (derived copy)
- `run-kit` → grouped=0 → **keep** (standalone)

## Why `session_grouped` Alone Isn't Enough

`session_grouped=1` for ALL members of a group — including the primary session. You cannot simply filter out `grouped=1` sessions without also losing the primaries. The `name === group` check distinguishes primaries from copies.

## Terminal Relay — Direct Attach (no ephemeral)

Since `260602-qn62-move-based-board-pin-sessions`, the per-WebSocket ephemeral grouped session (`rk-relay-*`) is **gone**. The relay (`app/backend/api/relay.go`) attaches the PTY **directly** to the real session that owns the window. The ephemeral existed solely to give each WebSocket its own active-window pointer (a tmux session has exactly ONE, shared across all attachments) so board panes pointing at different windows of the same session wouldn't collide. The move-based board model removes window *sharing* — each board pane's window now lives in its own single-window pin-session whose sole window is permanently active — so the isolation layer has no remaining purpose and was deleted wholesale.

**What was removed** (all gone — do NOT reference as live): `tmux.RelaySessionPrefix = "rk-relay-"`, `newEphemeralRelayName` (the `crypto/rand` 8-hex generator), `tmux.NewGroupedSession`, the `@rk_owner_pid` ownership stamp + `tmux.SetSessionOwnerPID`/`GetSessionOwnerPID` + `tmux.OwnerPIDOption`, the ephemeral `defer KillSessionCtx`, the scoped `SelectWindowInSession`-on-ephemeral, `tmux.ListRawSessionNames`, and the startup `sweepOrphanedRelaySessions` (whole file `cmd/rk/serve_sweep.go` deleted). `parseSessions` **no longer skips** `rk-relay-*`.

**`handleRelay` flow now**: `decodeWindowID(r)` validates the `@N` path param (400 before upgrade), `serverFromRequest(r)` picks the server, then `s.tmux.ResolveWindowSession(ctx, server, windowID)` (5s timeout) resolves the owning session — either a normal home session or a `_rk-pin-*` board pin-session. A missing window closes the socket with `4004`. The relay then does a direct `s.tmux.SelectWindow(windowID, server)` (bare `select-window -t @N`) on the real session and `attach-session -t <session>` via `creack/pty`. There is no defer-kill — the session is durable and owned by tmux.

**Accepted behavioural tradeoffs** (the user explicitly accepted these — `260602-qn62`): the real (home) session has a single active-window pointer shared across attachments, so multi-client navigation now mutates the real session's active window (two tabs on different windows of one session yank each other; a viewer navigating moves the real pointer). For a pin-session this is a no-op — its sole window is permanently active. These were the precise behaviours the ephemeral previously masked.

**Filter at the chokepoint**: `parseSessions` in `internal/tmux/tmux.go` early-skips `_rk-pin-*` sessions and the `_rk-ctl` anchor (it NO LONGER skips `rk-relay-*` — those don't exist). See § Pin Sessions and § parseSessions Filter Chokepoint below.

**Active-window highlight is event-derived per group** (since `260530-v6hm-active-window-event-derivation`, unchanged by `260602-qn62`): the sidebar/URL highlight is derived from tmux control-mode `%session-window-changed` events tracked per session group (two-tier: event-tracked `@wid` authoritative, base `#{window_active}` only as a cold-start/reconnect fallback). The `internal/tmuxctl/*` derivation subsystem was scoped *investigate-only, NOT deletable* by `260602-qn62` (it is driven by the `_rk-ctl` anchor and serves the SESSIONS highlight independent of boards) and is **left untouched** — its internal comments still mention relays, which is out of scope. The `$sid`→group resolution it relies on still does NOT filter `_rk-ctl` (the anchor must resolve to its base group). Design in `architecture.md` § Active-Window Event Derivation.

## Pin Sessions (`_rk-pin-*`) — Move-Based Board Membership

Since `260602-qn62-move-based-board-pin-sessions`, a window pinned to a board is physically **moved** (`tmux move-window`, not `link-window`) into its **own single-window session** named `_rk-pin-<windowDigits>`. The core invariant: **every window lives in exactly ONE session at a time** — either its home session (visible in SESSIONS) or a pin-session (visible only as a BOARDS pane), never both. A "board" is **not a tmux session** — it is the *set of pin-sessions sharing an `@rk_board` value*.

**Reserved naming + reversible helpers** (`internal/tmux/tmux.go`): `tmux.PinSessionPrefix = "_rk-pin-"`. The name is derived deterministically from the window's `@N` id by stripping the leading `@` (tmux session names disallow `@`): `PinSessionName("@42") → ("_rk-pin-42", true)`; the inverse `WindowIDFromPinSession("_rk-pin-42") → ("@42", true)`. Both validate via `ValidWindowID`. The pure, reversible mapping means membership needs **no** name→id lookup table. `ListPinSessionNames(ctx, server)` runs `list-sessions -F '#{session_name}'` and returns every `_rk-pin-*` name (nil, no error, when no server runs) — board membership reads flow through it.

**Membership = session-scoped vars on the pin-session** (`internal/tmux/board.go`), read via `show-options -v -t <pinSession> <opt>`:

| Var (constant) | Meaning |
|----------------|---------|
| `@rk_board` (`tmux.BoardOption`) | which board this pinned window belongs to |
| `@rk_home` (`tmux.HomeOption`) | the home session to restore the window to on unpin |
| `@rk_board_order` (`tmux.BoardOrderOption`) | fractional order key within the board (`ComputeOrderKey`) |

> **Deliberate retention**: `BoardOption` (`@rk_board`) survives `260602-qn62` — but its meaning changed from a SERVER-option key (the old comma/colon encoding) to a per-pin-session SESSION-var key. `ComputeOrderKey` and `nextAppendKey` also survive (ordering); only the bespoke server-option *encoding* helpers were deleted.

**`Pin(ctx, server, windowID, board)`**: validates `windowID`/`board` (Constitution §I); idempotent — if `_rk-pin-<id>` already exists it is a no-op when the board matches, and **re-stamps `@rk_board`** when it differs (wrong-board re-pin must not silently leave the window on its old board, since the pin-session is the only authoritative place membership lives). Otherwise: resolve the home session via `ResolveWindowSession`; compute the append order key (`nextAppendKey` over the board's existing keys, restricted to this board, computed BEFORE the move); `new-session -d -s _rk-pin-<id>` (starts with a placeholder window); capture the placeholder's `#{window_id}` via `list-windows` (robust to base-index config, not assumed index 0); `move-window` the target window in; `kill-window` the captured placeholder so the moved window is the session's sole window; then stamp `@rk_home`/`@rk_board`/`@rk_board_order`.
- **Rollback (load-bearing)**: a half-stamped pin is an invisible lost window — `pinEntry` rejects a pin-session with no/invalid `@rk_board` (→ absent from BOARDS) and `parseSessions` filters `_rk-pin-*` (→ absent from SESSIONS). On any post-move `set-option` failure the move is undone (`MoveWindowToSession` back to home) and the pin-session killed. **Double-fault guard**: the pin-session is killed only if the move-back SUCCEEDED — if move-back itself fails the window is still physically inside the pin-session, so killing it would destroy a live window; the (still-named) pin-session is left intact so the window stays recoverable via unpin/re-pin.
- **`context.Background()` for teardown**: all rollback/teardown `KillSessionCtx` calls AND the rollback `MoveWindowToSession` are rooted in `context.Background()`, NOT Pin's ctx — Pin's ctx may be at/near its deadline, and a cancelled parent would make the kill a no-op and orphan the session (same reasoning the old relay teardown used).

**`Unpin(ctx, server, windowID, board)`**: idempotent — a missing pin-session is a silent success. Reads `@rk_home`; if that home session is alive, `move-window` the window back into it (tmux **appends** at the next free index — no original-position restore) and `killPinSessionIfPresent` (moving a pin-session's sole window out may auto-destroy the now-empty pin-session under tmux's exit-empty, so the explicit kill tolerates an already-gone session). If the home is dead, it is recreated by `rename-session`-ing the single-window pin-session to the home name (so the moved window becomes the recreated home's sole window — **no placeholder**), then the leftover `@rk_board`/`@rk_home`/`@rk_board_order` vars are unset on the recreated home so a future read doesn't mistake it for a pin. A pin-session with no recorded `@rk_home` is an error (Pin always stamps it; this should not happen).

**Multi-unpin** appends each window in unpin order; there is no stored original index. **Pinning a home's only window** leaves an empty home session that persists via `exit-empty off` (the `260602-a1wo` backstop); unpin later finds it via `@rk_home` and moves the window back. **Pins are persistent across rk restarts** (durable user intent; tmux survives restarts per Constitution VI) → there is **NO restore-sweep** (and no relay sweep — both gone).

**`Reorder(ctx, server, windowID, board, newOrderKey)`**: rewrites exactly the target pin-session's `@rk_board_order` var (via `setSessionOption`); errors if the pin-session is absent or its `@rk_board` ≠ `board`. No sibling renumber. The new key is computed server-side by `ReorderBoard` in `router.go` via `lookupNeighbourKeys` + `ComputeOrderKey`.

**Board listing** (all server-scoped — `move-window` can't cross tmux servers): `ListBoardEntries(ctx, server)` enumerates `_rk-pin-*` sessions on one server and derives `[]BoardEntry` via `pinEntry` (a session with no/invalid `@rk_board` is defensively skipped, not an error). `ListBoards(ctx)` iterates `ListServers(ctx)`, groups entries by `@rk_board`, returns an alphabetical `[]BoardSummary` with per-board pin counts. `GetBoard(ctx, name)` filters entries to one board across reachable servers, sorts by order key, with **NO stale write-back** — membership is derived live, so a killed pinned window's session simply disappears from the listing. A board exists only while ≥1 pin-session carries its name (**no empty boards, no name registry**); the last unpin makes the board vanish.

## parseSessions Filter Chokepoint

`parseSessions` in `internal/tmux/tmux.go` is the single chokepoint feeding every user-facing session list — REST `/api/sessions`, SSE `event: sessions`, board derivation, server-aggregate `/api/servers`. It early-skips two name classes:

1. **`_rk-pin-*`** (`strings.HasPrefix(name, PinSessionPrefix)`) — a pinned window is physically MOVED into its pin-session, so it leaves its home session's tab list; the pin-session is never a SESSIONS entry (it renders only as a BOARDS pane). A single early-skip here guarantees no pin-session leaks into the SESSIONS UI regardless of future consumers.
2. **`_rk-ctl`** (`name == ControlAnchorSessionName`) — the tmuxctl control-mode anchor (unchanged).

Since `260602-qn62-move-based-board-pin-sessions`, `parseSessions` **NO LONGER skips `rk-relay-*`** — those ephemerals were deleted (the relay attaches directly; see § Terminal Relay). A `rk-relay-xxxx` name, if one somehow appeared, would NOT be filtered.

## `_rk-ctl` Anchor Session (tmuxctl control mode)

`app/backend/internal/tmuxctl/` opens a long-running `tmux -CC` control-mode connection per tmux server (one Client per socket; see `architecture.md` § tmux Control-Mode Subscription for the package-level design). `tmux -CC` requires an attached session to emit notifications, so the Client creates a hidden anchor session named `_rk-ctl`.

Since `260602-a1wo-prevent-exit-empty-server-death`, the anchor is created **unconditionally on every observed server** — it is a permanent **session floor**, not a fallback. Earlier it was created only when the server had zero pre-existing sessions; that left a server that had real sessions at attach time with **no floor**, so when its last real session later closed (and, pre-`260602-qn62`, the remaining `rk-relay-*` ephemerals drained to zero), tmux's default `exit-empty on` reaped the whole server (a recurring Constitution VI violation — `runWork`/`utils`/`kit`, ≥3x). The floor now keeps the session count above zero for the server's lifetime regardless of how many real sessions exist. (Since `260602-qn62` removed the relay ephemerals, the floor + `exit-empty off` is also what keeps an empty home session alive after its only window is pinned away — pin-session persistence relies on this backstop.) **The floor is decoupled from the control-mode attach target** — see § Anchored Target Form below. (Test/e2e sockets are still excluded from the control-mode candidate set entirely by `isTmuxSocketCandidate`→`IsTestServerName` in the supervisor, so no anchor is ever created on a leaked `rk-test-*` socket and the always-create path cannot resurrect them — see `architecture.md` § `IsTestServerName`.)

**Naming convention**: literal name `_rk-ctl`. The leading underscore follows the project's internal-entity convention. The single exported constant `tmux.ControlAnchorSessionName = "_rk-ctl"` lives alongside `tmux.PinSessionPrefix` in `app/backend/internal/tmux/tmux.go` (it is a literal name rather than a prefix because there is exactly one anchor per tmux server; since `260602-qn62` removed `RelaySessionPrefix`, `PinSessionPrefix` is its neighbour). It is the single source of truth for the literal — `tmuxctl.Client` and `parseSessions` reference the constant (the relay sweep that also referenced it was deleted with `serve_sweep.go`).

**Keepalive tag**: immediately after `new-session -d`, the Client runs `tmux set-option -t =_rk-ctl @rk_ctl_keepalive 1` (the option name is exported as `tmuxctl.AnchorKeepaliveOption = "@rk_ctl_keepalive"`). The tag is a defensive marker only — v1 has no runtime consumer, but the marker exists so future code can identify the anchor without depending on the literal name. `set-option` is idempotent and safe to re-run when another `rk serve` instance created the anchor first.

**Anchored target form**: the Client invokes `tmux [-L <socket>] -CC attach-session -t =<first-existing-real-session> -r` when at least one real user session exists, else `-t =_rk-ctl -r`. The `=` prefix forces exact-match targeting and prevents prefix-match collisions, consistent with the convention established in PR #196 (daemon detection's `=rk-daemon:=serve`). The `-r` flag puts the connection in **read-only mode** — restricts input only; notifications still emit. Defensive default — future refactors that accidentally wire commands through the control-mode connection cannot mutate tmux state.

**Floor vs. attach target are decoupled** (since `260602-a1wo-prevent-exit-empty-server-death`). On every dial `resolveBootstrap` (`client.go`) does two separate things **once the server is confirmed alive** (see § Anchor is join-only below): (1) `createAnchor` + `setAnchorKeepalive` (the floor — R1), then (2) pick the attach target — the first real session if one exists, else `_rk-ctl` (R2). The attach target is the first real name surfaced by `probeAndFirstSession`, which runs `tmux [-L <socket>] list-sessions -F '#{session_name}'` and **skips `_rk-ctl`** so the always-present anchor is never picked when a real session exists (`_rk-ctl` sorts ahead of a lowercase name like `runkit`, so an unskipped listing would wrongly select it and regress the "prefer a real session" contract). (Pre-`260602-qn62`, `rk-relay-*` ephemerals were valid attach targets and not skipped; they no longer exist. `_rk-pin-*` pin-sessions are single-window board sessions; whether the picker prefers or skips them is out of scope here.) Attaching control mode to `_rk-ctl` would also be correct (`%session-window-changed` is global on tmux 3.6a — see [[tmux-control-mode-event-scope]]); preferring a real session is a minimal-diff, zero-event-scope-risk choice, not a correctness requirement.

**Anchor is join-only — never resurrects a killed server** (since `260602-poka-guard-anchor-no-resurrect`). `createAnchor`'s `tmux new-session -d -s _rk-ctl` has an implicit side effect: it *starts* a server when none is listening on the socket. Because `resolveBootstrap` is also the reconnect FSM's dial (`productionDial`, ~250ms→5s backoff), a UI-initiated `kill-server` used to lose a race — the server died, the FSM re-dialed ~250ms later, and `createAnchor` **recreated the dead server** (the new socket file also fired an fsnotify `CREATE` that re-opened a fresh Client — a self-reinforcing resurrection loop). The fix makes `createAnchor` **only ever join an already-running server**: `resolveBootstrap` runs a side-effect-free `list-sessions` probe FIRST via `probeAndFirstSession` (which doubles as the first-real-session selector — folding in what the former `firstSessionName` did, so net tmux round-trips stay flat at **4**: `SetExitEmptyOff`, the unified probe+first-session, `createAnchor`, `setAnchorKeepalive`). A genuinely dead server — `list-sessions` exit 1 whose stderr matches `no server running` / `failed to connect` / `No such file or directory` (classified by `isServerDeadError` → `matchesServerDeadText`, which since `260603-gs2t-reap-dead-tmux-servers-sse` **delegates to the shared `tmux.IsServerGone`** so the three sentinels are defined in exactly one place — its former local constants were removed; `internal/tmux`'s `ListKeys`/`KillServer` key off the same three including the socket-file variant, while `board.go`'s `isAbsentOption` keys off only the first two — `no server running` / `failed to connect`) — is declined with the `errServerDead` sentinel **before** `createAnchor` runs. `productionDial` propagates it, the reconnect FSM backs off instead of resurrecting, and the kill sticks. **Constitution VI floor is preserved**: an alive-but-zero-session server (probe exit 0, empty output) still passes and still gets its `_rk-ctl` anchor — only a truly dead server (exit 1) is declined. The probe lives locally in `tmuxctl` (mirroring `internal/tmux.probeServerAlive`'s `exec.CommandContext` + 2s-timeout pattern rather than calling cross-package) because `doc.go` documents `tmuxctl` as the sanctioned self-contained bypass of the `internal/tmux/` boundary, and `probeServerAlive` collapses the dead-vs-empty distinction this guard must surface.

**Concurrent-rk race**: when two `rk serve` instances open a Client against the same fresh tmux server, only one `new-session -d` call succeeds; the loser gets tmux's "duplicate session" error. `tmuxctl.isDuplicateSessionError` treats this as benign and the loser proceeds to attach — multi-rk is supported by construction since `tmux -CC attach` is independent per client.

**Filtering at the chokepoint**: `parseSessions` in `app/backend/internal/tmux/tmux.go` early-skips any line whose `#{session_name}` equals `ControlAnchorSessionName` — parallel to the `_rk-pin-*` pin-session skip (since `260602-qn62`; the old `RelaySessionPrefix` skip was removed with the relay ephemerals). Single chokepoint — every user-facing list (REST `/api/sessions`, SSE `event: sessions`, board derivation, server-aggregate `/api/servers`) flows through `ListSessions`, so the anchor never leaks into the UI.

**No relay sweep to exclude from** (since `260602-qn62`): the startup `sweepOrphanedRelaySessions` and its file `cmd/rk/serve_sweep.go` are deleted, so there is no longer a sweep that could observe the anchor as an orphan. `cmd/rk/serve.go` now orders startup as `EnsureConfig → tmuxctl.Supervisor.Start → server.ListenAndServe` (no sweep step). The `rk reaper` operator command still hard-skips `_rk-ctl` (see § rk reaper).

**Lifecycle**: the anchor outlives any single `Client` invocation. `Close()` does not delete it — closing the PTY only ends the subscription; the detached session lives on. This is intentional: subsequent `rk serve` invocations re-attach to the existing anchor rather than re-creating it. The anchor only disappears when the entire tmux server is killed.

**`exit-empty off` backstop** (since `260602-a1wo-prevent-exit-empty-server-death`): the anchor floor is the primary guarantee, but it is briefly absent during the close-then-reopen window of a daemon restart/reconnect (the old `-CC` client is gone before the new one runs `createAnchor`). To close that reapable zero-session sliver, run-kit sets `set-option -g exit-empty off` **server-globally** on every socket it dials — imperatively via `tmux.SetExitEmptyOff(ctx, server)` called from `tmuxctl`'s `productionDial` **BEFORE** `resolveBootstrap` runs, on the initial dial **AND** every reconnect of the Client FSM (ordering matters: setting it after the anchor is created would leave the sliver reapable). It is best-effort either way and on a dead socket the call simply fails non-fatally — `resolveBootstrap`'s own probe then declines the dead server (see § Anchor is join-only above). It is best-effort — a failure logs at Debug and does not abort the dial. The embedded `configs/tmux/default.conf` (build copy `app/backend/build/tmux.conf`) also carries `set -g exit-empty off`, so run-kit-*created* servers get the floor at birth via `-f` before the first dial; the imperative path covers the hand-created/foreign servers the embedded conf never reaches (the exact gap that let `kit` inherit tmux's default `on`).

**Lifetime contract — explicit kill only** (since `260602-a1wo-prevent-exit-empty-server-death`): a managed tmux server is torn down **only** via `kill-server` / `rk reaper` (`260529-fww2-rk-reaper-command`). Empty (anchor-only) servers persist by design — this is the deliberate tradeoff that makes accidental `exit-empty` death impossible. run-kit adds no auto-reaping of anchor-only servers and no cross-process refcounting / shared state (Constitution II forbids it — no single `rk serve` can know it is the last one that wants a given server's anchor).

**Why a hidden anchor, not an arbitrary user session**: attaching to a user session would couple the control-mode lifecycle to that session's lifetime — killing the user session would tear down the subscription. A purpose-built anchor decouples the two. `tmux -CC` cannot use `new-session -d` without attach (it requires an attached client to emit notifications), so "no anchor at all" is not an option.

## Window Addressing Identity (`@N`)

Since `260529-chgz-window-id-routing`, a *specific existing window* is addressed by its stable tmux **window ID** (`@N`, tmux's `#{window_id}`) rather than the mutable window *index*. A window ID is **server-global and a self-contained `-t` target** — `tmux kill-window -t @5` needs no session prefix — so window-targeting tmux commands pass the windowID directly and the `session:index` target string is gone. The window **index** is retained only for *positional* operations (reorder/move), which are inherently "move to position N" and cannot be expressed by ID. **Sessions are still identified by name** — only window addressing changed.

This identity is consistent across all layers: URL (`/$server/$window` — `$session` was dropped from the route shape by `260529-jad6-window-api-stability`, leaving `@N` as the only window identity in the URL), HTTP API (`/api/windows/{windowId}/...`), WS relay (`/relay/{windowId}`), tmux targets, and the fab pane-map enrichment join. The window store and the boards feature (`@rk_board` stores `<window_id>:...`) were already windowID-keyed. New path params are validated by `validate.ValidateWindowID` (`^@[0-9]+$`) — stricter than `ValidateName`, which permits `@` but not the `@N` shape (constitution §I). Window IDs are never user-typed; they originate from tmux's `#{window_id}`. See `architecture.md` § API Layer and § Terminal Relay, and `ui-patterns.md` § URL Structure.

## Impact on Other Operations

All tmux functions accept a `server string` parameter:

- `ListSessions(server)` — queries only the specified server
- `ListWindows(session, server)` — lists windows for a session on the specified server
- `SelectWindow(windowID, server)` — selects a window on the specified server by its stable window ID via a bare `select-window -t @N` (`@N` is a self-contained `-t` target; no session:index string — since `260529-chgz-window-id-routing`). Retains **no production callers** (still on the `TmuxOps` interface): both the relay and the REST `/select` handler use the session-scoped `SelectWindowInSession` — a bare `-t @N` is ambiguous inside session groups
- `SelectWindowInSession(session, windowID, server)` — session-scoped select (`select-window -t <session>:@N`). Two production callers: the REST `/select` handler resolves the owning session via `ResolveWindowSession` then calls this, and the relay does the same on every connect (`relay.go` — resolve → scoped select on the resolved real owning session → direct attach; pre-`260602-qn62` the relay's scoped select targeted its per-WebSocket ephemeral instead). (A stale "relay uses the bare select / REST `/select` is the sole caller" claim was corrected against `relay.go` during `260610-qf25-defer-terminal-reset-flicker` hydrate)
- `ResolveWindowSession(ctx, server, windowID)` — returns the owning session name for a window ID via a targeted O(1) `display-message -t <windowID> -p '#{session_name}'` lookup (since `260609-enic-restore-display-message-resolve-window`; restored from the prior O(n) `list-windows -a` enumerate + first-match scan, which had been a #205 workaround for a session-group ambiguity that `260602-qn62` later removed). The move-based one-session-per-window invariant (`260602-qn62`) is what makes the targeted lookup unambiguous and safe — the window resolves to its single owning session, a normal home session OR a `_rk-pin-*` pin-session, so no `RelaySessionPrefix` filtering is needed (those ephemerals are gone). Used by the relay (direct attach, on the 5s connect-time budget), `Pin` (to remember the home), the REST `/select` handler, and `ProjectRoot`. A tmux non-zero exit (missing `-t @N`) or an empty result both mean "window not found" — the relay closes with code 4004. See [[resolve-window-session-on-relay-connect]] for the regression this restored
- `ListPinSessionNames(ctx, server)` — returns every `_rk-pin-*` session name via `list-sessions -F '#{session_name}'`; board-membership reads (`ListBoardEntries`) flow through it. Returns nil (no error) when no server runs. **Replaces** the deleted `ListRawSessionNames` as the only "raw session-name listing" helper, but it is scoped to pin-sessions, not a general escape hatch
- `CreateSession(name, cwd, server)` — creates sessions on the specified server
- `ReloadConfig(server)` — hot-reloads config via `source-file` on the specified server
- `KillSession(session, server)` — kills the named session on the specified server (thin `context.Background()` wrapper around `KillSessionCtx`)
- `KillSessionCtx(ctx, server, session)` — ctx-accepting variant; `Pin`'s rollback/teardown passes `context.Background()` so the kill survives a near-deadline Pin ctx (an expired parent would make the kill a no-op and orphan the session)
- `SendKeys(windowID, keys, server)` — sends keys to the window identified by `windowID` on the specified server
- `MoveWindow(windowID, dstIndex, server)` — reorders a window within its own session. Source addressed by stable `windowID`; destination remains positional. Resolves the source's current index from the ID exactly once (via `resolveWindowSessionIndex`, a `display-message -p '#{session_name}\t#{window_index}'` lookup), then (since `260529-jad6`) emits the full adjacent-`swap-window` bubble sequence as a **single `\;`-chained tmux invocation** rather than one subprocess per step — so no other mutation can interleave mid-reorder (insert-before semantics). tmux preserves the window's ID across the swaps
- `MoveWindowToSession(windowID, dstSession, server)` — moves a window from its current session to another on the specified server via `tmux move-window -s {windowID} -t {dstSession}:` (window-ID source, session destination). Destination index is auto-assigned by tmux; the window's ID is preserved (tmux contract)

## API Server Parameter

All API endpoints accept `?server=` query parameter via `serverFromRequest(r)` helper. The helper validates the server name using `validate.ValidateName` and defaults to `"default"` on invalid/missing input. The SSE hub polls per-server — only servers with active SSE clients are polled.

Server management endpoints:
- `GET /api/servers` — lists available servers via socket directory scan
- `POST /api/servers` — creates a server (starts session "0" in $HOME)
- `POST /api/servers/kill` — kills a server via `tmux kill-server`

### SSE Poll-Set Lifecycle — Connect to Reap (`api/sse.go`)

A server enters the SSE poll set exactly once — when a browser opens `GET /api/sessions/stream?server=<name>`, `addClient` adds it to `h.clients` and re-spawns the poll goroutine if it had exited (the `!h.polling` guard). The hub's per-tick work-list is the snapshot of `h.clients` keys. Since `260603-gs2t-reap-dead-tmux-servers-sse` a server leaves the poll set via **two** paths, not one:

1. **Last-client-disconnect** — `removeClient` deletes the server from `h.clients` when its final SSE client drops; the next tick observing zero clients lets the goroutine stop.
2. **Dead-server reap (new)** — when `FetchSessions` returns an error matching `tmux.IsServerGone`, the server is reaped even while clients are still connected. Killing a tmux server does NOT disconnect the browser's `EventSource`, so before this change the dead socket lingered in `h.clients` and every ~2.5s tick re-shelled `tmux -L <name> list-sessions` against the gone socket, logging a `slog.Warn("SSE poll error")` drumbeat forever (the `utils`/runaway-polling incident).

**Reap mechanics**: `sseHub.poll` collects gone servers into a **loop-local `deadServers` slice** during the per-server iteration — the fetch-error branch now splits on `tmux.IsServerGone(err)`: a gone error logs at **Info** (`"SSE: tmux server gone, reaping from poll set"`) and appends to `deadServers`; a non-gone error keeps the existing `slog.Warn("SSE poll error", ...)` and is **not** reaped. Both still `continue`. After the per-server loop completes (before the metrics broadcast), a single `h.mu.Lock()` block emits one `event: server-gone\ndata: {}\n\n` frame to each dead server's currently-registered clients, then deletes the server from **every** per-server map so no stale state leaks into a later re-registration: `h.clients`, `h.cache`, `h.previousJSON`, `h.previousRealSessions`, `h.orderBootstrapAttempts`, `h.previousOrderJSON`, plus the loop-local `perServerGen` and `eventDrivenServers` maps.

**Concurrency contract** (load-bearing): the work-list is snapshotted under `RLock`, dead servers are collected into the loop-local slice *during* iteration, and all deletes happen *after* iteration under one write lock — never deleting from a map mid-range over its snapshot, never holding the write lock across `FetchSessions`. **Re-registration is free**: when the last client for a server is reaped, the next tick sees zero clients and the goroutine exits; a later `addClient` (e.g. the user restarts the server and navigates back) re-adds it and re-spawns the goroutine. The frontend reaction to `server-gone` is in `ui-patterns.md` § Server-Gone Reap (SSE event + onerror fallback).

**Shared dead-server sentinel** (`tmux.IsServerGone`): the dead-server detection used by the reap is the **single** exported sentinel helper `tmux.IsServerGone(err error) bool` in `internal/tmux/tmux.go`, backed by the unexported `serverGoneText` slice (`"no server running"`, `"failed to connect"`, `"No such file or directory"`) via the string-accepting inner `containsServerGoneText`. This is the one definition of the bare dead-server sentinel set, shared across the `tmux` and `tmuxctl` layers (Constitution III): `tmuxctl.matchesServerDeadText` (used by `isServerDeadError`/`resolveBootstrap` — see § Anchor is join-only) delegates to `tmux.IsServerGone(errors.New(s))`, and its former local constants (`noServerRunningText`/`failedToConnectText`/`noSocketFileText`) were removed. Note the *narrower* "absent-option-OR-dead" checks elsewhere in the `tmux` package (`ListKeys`/`KillServer`, `board.go`'s `isAbsentOption`) intentionally pair these phrasings with `"invalid option"`/`"unknown option"` and are a distinct set — out of scope for `IsServerGone`.

## Server-Scoped User Options

tmux distinguishes window-scoped (`-w`) options, server-scoped (`-s`) options, and session-scoped user options (the default — `set-option -t <session>`). We use all three: window-scoped for per-window state (`@color`, `@rk_type`, `@rk_url`), server-scoped for state belonging to the tmux server as a whole (`@rk_session_order`), and session-scoped on `_rk-pin-*` pin-sessions for board membership (`@rk_board`/`@rk_home`/`@rk_board_order`, since `260602-qn62`) and on `_rk-ctl` for the control-mode keepalive marker.

| Option | Scope | Set via | Read via | Owner |
|--------|-------|---------|----------|-------|
| `@color` | window (`-w`) | `tmux.SetWindowOptions` (via `POST /options`; since `260529-jad6`) | `ListWindows` format string field 8 | per-window |
| `@rk_type` | window (`-w`) | `CreateWindowWithOptions`, `tmux.SetWindowOptions` (both via `appendOptionOps`; since `260529-jad6`) | `ListWindows` format string field 9 | per-window (iframe) |
| `@rk_url` | window (`-w`) | `CreateWindowWithOptions`, `tmux.SetWindowOptions` (both via `appendOptionOps`; since `260529-jad6`) | `ListWindows` format string field 10 | per-window (iframe) |
| `@rk_session_order` | server (`-s`) | `tmux.SetSessionOrder(ctx, server, order)` | `tmux.GetSessionOrder(ctx, server)` | sidebar reorder |
| `@rk_board` | session-scoped on each `_rk-pin-*` (set via `set-option -t <pinSession>`) | `tmux.Pin` / `tmux.Reorder` (re-stamp on wrong-board re-pin) | `tmux.ListBoardEntries(ctx, server)` (per-pin `show-options -v`) | board membership (which board; const `tmux.BoardOption`) |
| `@rk_home` | session-scoped on each `_rk-pin-*` | `tmux.Pin` (stamped at pin time) | `tmux.Unpin` (restore target) | board pin restore-to-home (const `tmux.HomeOption`) |
| `@rk_board_order` | session-scoped on each `_rk-pin-*` | `tmux.Pin` (append key) / `tmux.Reorder` | `tmux.ListBoardEntries` / `tmux.GetBoard` (sort) | board pin fractional order (const `tmux.BoardOrderOption`; via `ComputeOrderKey`) |
| `@rk_ctl_keepalive` | session-scoped on `_rk-ctl` (set via `set-option -t =_rk-ctl`) | `tmuxctl.Client.setAnchorKeepalive` | (no runtime consumer; defensive marker) | tmuxctl control-mode anchor |

`@rk_session_order` stores a JSON-encoded array of session names defining the user-preferred sidebar render order. Because the value is server-scoped, it is shared by every client connected to the same tmux server — laptop and phone hitting the same `tmux -L runkit` see the same order. Lifetime matches the tmux server (lost on server kill, NOT on rk-go restart per Constitution VI). Both wrapper functions wrap their context with `context.WithTimeout(ctx, TmuxTimeout)` (10s) and route through `tmuxExecRawServer` (which captures stderr in error messages so callers can pattern-match "invalid option" / "no server running" to distinguish operational empty-state from real failures).

The HTTP endpoints `GET /api/sessions/order` and `POST /api/sessions/order` (migrated PUT→POST by `260529-jad6` per §IX; see `architecture.md` § Endpoints) layer over these wrappers. The mutating POST triggers a synchronous SSE broadcast (`event: session-order`) so all connected clients on that server reorder live; the SSE hub also bootstraps the cache once per server on first poll so the order survives an rk-go restart that left tmux running.

### `@rk_board` — Board Membership (pin-session SESSION var)

Since `260602-qn62-move-based-board-pin-sessions`, `@rk_board` is **no longer a server-option comma/colon encoding**. It is a **session-scoped var on each `_rk-pin-*` pin-session** holding just the board name. Board membership is derived entirely from the set of pin-sessions and their vars — a **board is the set of pin-sessions sharing an `@rk_board` value**. There is no `@rk_boards` registry; **empty boards cannot exist** (a board appears when its first pin is created and vanishes when its last pin is unpinned). Boards are **server-scoped** — `move-window` can't cross tmux servers, so a pin-session always lives on its window's server; the board *list* is summarized across reachable servers but no cross-server window union exists.

**Deleted with this change** (the old bespoke serialization — do NOT reference as live): the `@rk_board` server-option value format `<window_id>:<board>:<order_key>`, `parseBoardValue`, `serializeBoardValue`, `setBoardValue`, `ListAllBoardEntries` (cross-server union), `nextAppendKey`'s old behaviour (the helper survives, see below), `RemoveAllByWindowID`, the `boardEntrySep`/`boardFieldSep` separators, and the `GetBoard` lazy stale write-back. The `BoardOption` constant survives as the `@rk_board` SESSION-var key (repurposed in place — see § Pin Sessions for the deliberate retention).

**Validators** (unchanged): board name `^[A-Za-z0-9_-]{1,32}$` (`ValidBoardName`), window id `^@\d+$` (`ValidWindowID`), order key `^[a-z]{1,16}$` (`ValidOrderKey`).

**The board API surface** (`internal/tmux/board.go`):

| Function | Purpose |
|----------|---------|
| `pinEntry(ctx, server, pinSession)` | derive one `BoardEntry` by reading the pin-session's `@rk_board`/`@rk_board_order` vars; a session with no/invalid `@rk_board` is defensively skipped (`ok=false`, no error) |
| `ListBoardEntries(ctx, server)` | per-server entries from `ListPinSessionNames` → `pinEntry` (no-server/absent-option ⇒ `([]BoardEntry{}, nil)`) |
| `ListBoards(ctx)` | iterate `ListServers`, group by `@rk_board`, alphabetical `[]BoardSummary` with per-board pin counts |
| `GetBoard(ctx, name)` | entries for one board across reachable servers, sorted by `OrderKey`, **no write-back** (membership is live; killed pins just disappear) |
| `Pin(ctx, server, windowID, board)` | move-window into `_rk-pin-<id>` + stamp 3 vars; idempotent (re-stamp `@rk_board` on wrong-board re-pin); rollback rooted in `context.Background()` (see § Pin Sessions) |
| `Unpin(ctx, server, windowID, board)` | restore window to `@rk_home` (recreate dead home via rename-session, no placeholder) + kill pin-session; idempotent on missing pin-session |
| `Reorder(ctx, server, windowID, board, newOrderKey)` | rewrite only that pin-session's `@rk_board_order`; errors if absent or `@rk_board` ≠ board |
| `ComputeOrderKey(before, after)` | fractional indexing helper (**retained**) |
| `nextAppendKey(entries)` | append key strictly greater than the max existing key, via `ComputeOrderKey` (**retained**, thin wrapper) |

Reads route through `tmuxExecRawServer` + `context.WithTimeout(ctx, TmuxTimeout)`; `isAbsentOption` treats `invalid option`/`unknown option`/`no server running`/`failed to connect` as the empty case (mirrors `GetSessionOrder`). A pin-session whose var read fails is logged via `slog.Warn` and skipped — well-formed entries still return.

**Lexicographic / fractional order keys** (`ComputeOrderKey`, unchanged, pure Go): returns a string strictly between `before` and `after` lexicographically; an empty neighbour means prepend/append. Examples: `(null,"b")→"a"`, `("c",null)→"d"`, `("b","c")→"bm"`, `("b","bm")→"bg"`. `initialAppendKey = "m"` is the first key on an empty board (midpoint letter leaves prepend headroom — there is no key `< "a"`). Inserts MUST NOT renumber siblings — a reorder rewrites exactly one `@rk_board_order` var. **The key is now stored per pin-session** in `@rk_board_order` rather than embedded in the old encoding.

**No eager/lazy cleanup, no bootstrap** (deleted with this change): there is no SSE poll-tick window-kill diff and no `RemoveAllByWindowID` consumer — a killed pinned window's pin-session simply drops out of the next `ListBoardEntries` read, picked up by the frontend's refetch on the next session-list change. There is no `@rk_board` first-poll bootstrap broadcast. Membership changes surface only via the explicit pin/unpin/reorder `board-changed` SSE events (see § SSE Board Events below and `architecture.md` § Boards Feature).

**SSE event** — `event: board-changed` rides the per-server SSE stream. Payload `{"board":"main","change":"pin"|"unpin"|"reorder","server":"runkit","windowId":"@1234","orderKey":"bm"}` (`orderKey` `omitempty`). The `"cleanup"`/`"bootstrap"` change variants are GONE. Each pin/unpin/reorder handler emits its own event. See `architecture.md` § Boards Feature.

**Window ID stability across `move-window`** — pins rely on tmux's documented contract that `move-window` preserves `#{window_id}` (`@N`) and only changes `#{window_index}`. This is now load-bearing for the move-based model: Pin (`move-window` into the pin-session) and Unpin (`move-window` back to home) keep the window's `@N` stable, so the URL/selection that addresses it by ID survives the move, and `WindowIDFromPinSession` recovers the same id from the pin-session name. `MoveWindow` (reorder via adjacent `swap-window`) and `MoveWindowToSession` (`move-window -s <windowID> -t <dstSession>:`) likewise preserve `@N`.

## Frontend Server Routing Contract

Every API client function in `app/frontend/src/api/client.ts` that hits a server-scoped endpoint takes `server: string` as its **first positional argument** and forwards it to `withServer(url, server)` to build `?server=<server>`. There is **no module-level `_getServer` global, no `setServerGetter` export, and no ambient state** — `server` is always passed explicitly per call. This mirrors the backend `tmuxExecServer(ctx, server, args...)` shape so the routing parameter is visible in every signature on both sides of the wire.

Functions that take `server` (read + mutation):

| Category | Functions |
|----------|-----------|
| Read | `getSessions`, `getKeybindings`, `getSessionOrder` |
| Session mutation | `createSession`, `renameSession`, `killSession`, `setSessionOrder` |
| Window mutation | `createWindow` (session-scoped), `renameWindow`, `killWindow`, `moveWindow`, `moveWindowToSession`, `selectWindow`, `splitWindow`, `closePane`, `sendKeys` (all but `createWindow` take `windowId: string` as the 2nd positional arg since `260529-chgz-window-id-routing` and hit `/api/windows/{windowId}/...`) |
| Window options | `updateWindowUrl`, `updateWindowType` (both `(server, windowId, …)`) |
| Color | `setWindowColor`, `setSessionColor` |
| Server-scoped | `reloadTmuxConfig` |
| Session-scoped | `uploadFile` |

Signature shape:

```ts
function withServer(url: string, server: string): string {
  const sep = url.includes("?") ? "&" : "?";
  return `${url}${sep}server=${encodeURIComponent(server)}`;
}

export async function renameSession(server: string, session: string, name: string): Promise<{ ok: boolean }> {
  const res = await fetch(
    withServer(`/api/sessions/${encodeURIComponent(session)}/rename`, server),
    { method: "POST", headers: {...}, body: JSON.stringify({ name }) },
  );
  ...
}
```

**Functions that intentionally do NOT take `server`** (operate on the server itself or are global): `listServers`, `createServer`, `killServer`, theme settings (`getThemePreference`, `setThemePreference`), and server-color settings. `killServer` targets the server via request body (`{"name":"runkit"}`) rather than `?server=` query.

SSE and the WebSocket relay both interpolate `?server=` directly when constructing their connection URLs (not via `withServer`) and re-open on server change, so they are unaffected by the closure-race class of bugs that motivated this contract.

### Why the explicit server contract exists (closure-race fix)

Pre-fix, the client kept a module-level getter (`let _getServer = () => "runkit"`) that `SessionProvider` installed once via `setServerGetter(() => serverRef.current)`. `withServer` dereferenced the getter at fetch time, so any switch of `serverRef.current` between user intent (open dialog, type new name) and fetch dispatch (Enter pressed) silently retargeted the mutation at the **new** server. A typical reproducer: open rename for `foo` on server-A → Cmd+K → switch to server-B → return → Enter — the rename then ran against server-B, hitting either the wrong `foo` or returning an error while the optimistic overlay still drew the rename on server-A until SSE reconciled.

The fix retired the global entirely. With `server` threaded explicitly, the captured server is fixed at the moment the React handler runs (see `ui-patterns.md` → "Optimistic UI & Mutation Feedback" → "Server Capture Convention" for the React idiom). The single source of truth for `server` is `useSessionContext()`; callers read it inside their event handler and pass it as the first arg.

### Verifying the contract

A single grep enforces the invariant — the symbols `_getServer` and `setServerGetter` MUST NOT exist anywhere under `app/frontend/src/`. The `useDialogState` regression test (`app/frontend/src/hooks/use-dialog-state.test.tsx`) flips `SessionProvider`'s `server` prop between `openRenameSessionDialog("foo")` and `handleRenameSession()` and asserts `renameSession` was called with the post-flip server (`"server-B"`) and never with the pre-flip server.

Window cross-session move endpoint:
- `POST /api/windows/{windowId}/move-to-session` — moves a window to another session. `{windowId}` validated via `validate.ValidateWindowID`. Request body: `{ "targetSession": "string" }`. Validates the window ID and target session name. Returns `200 { "ok": true }` on success. Handler in `api/windows.go`, `MoveWindowToSession(windowID, dstSession, server)` method on the `TmuxOps` interface in `router.go`. (Route moved from the session-scoped `/api/sessions/{session}/windows/{index}/move-to-session` in `260529-chgz-window-id-routing`.)

## `rk riff` Window Creation

`rk riff` creates N parallel worktree/window pairs (N defaults to 1) in the **user's current tmux server** (not the managed `runkit`/`default` server that `internal/tmux` targets). Each window may contain multiple panes — composed from argv-ordered `--skill`/`--cmd` occurrences or a preset's `panes:` list. The command lives at `app/backend/cmd/rk/riff.go` and bypasses `internal/tmux`'s execution helpers — it invokes `tmux` directly via `exec.CommandContext` so windows land where the user is attached.

Mechanics:

- **Preconditions** — `$TMUX` must be set (read via `tmux.OriginalTMUX`, captured before `internal/tmux` init() strips `$TMUX` from the process env) and `wt` must be on PATH. Fast-fail, exit 2. `--list-presets` short-circuits before preconditions.
- **Per-window pane sequence** — each window is built via:
  1. `tmux new-window -P -F '#{pane_id}' -n <resolvedName> -c <worktree-path> <pane-0-shell>` creates the window and prints the new pane's id (e.g. `%87`), captured by the orchestrator for the focus step below.
  2. `tmux split-window -t <resolvedName> -c <worktree-path> <pane-i-shell>` appends each additional pane (panes 1..N-1). The initial split direction is `-h` by default; the final arrangement is set by `select-layout`.
  3. `tmux select-layout -t <resolvedName> <canonical>` applies the resolved layout (skipped when pane count is 1 and layout is `auto`, or when `--layout` produces an empty canonical for the pane count).
  4. `tmux select-pane -t <captured-pane-id>` focuses the first pane. Targeting by pane id (rather than a hardcoded `<window>.0` index) is portable across tmux servers configured with `pane-base-index 1` — the previous index-based form errored with `can't find pane: 0` on those servers.
- **Pane shell strings** — skill panes wrap `<launcher> '<escaped-skill>'` (or bare `<launcher>` when skill is empty) inside `${SHELL:-/bin/sh} -i -c '...'` then `shellWrap` (`; exec "${SHELL:-/bin/sh}"`). Cmd panes use the user's command directly + `shellWrap` (no interactive `sh -i -c` wrap — would alter argv semantics of user commands). Empty `--cmd` yields the bare `exec "${SHELL:-/bin/sh}"` form (drops to `$SHELL`). All handled by pure helpers `buildSkillShellString`, `buildCmdShellString`, `paneShellString`, and the argv builder `buildSpawnArgvs`.
- **Window naming** — `riff-<basename>` where `<basename>` is `filepath.Base(worktreePath)`. For `--count N` (N ≥ 2), each worktree gets its own name (assigned by `wt create`'s random adjective-noun generator; rk does NOT impose a `-1..-N` suffix scheme), so windows end up named `riff-swift-fox`, `riff-clever-crab`, etc. Collision resolution applies per-window via `resolveWindowName` (auto-suffix `-2`, `-3`, …).
- **`--count` orchestration** — `runCount` spawns N goroutines sharing one `context.Context`. Each goroutine runs `runWtCreate` + `spawnRiffReturningName`; first failure cancels the context (killing sibling subprocesses via `exec.CommandContext`) and triggers rollback. The pure `planFanOutRollback(results, failureIdx)` computes which worktrees + windows to clean up — excludes the failing goroutine's own artifacts — and `rollbackFanOut` invokes `wt delete --non-interactive <basename>` (positional arg, not the deprecated `--worktree-name`; `--non-interactive` so the prompt does not block on EOF) then `tmux kill-window -t <name>`. Rollback uses a fresh (non-cancelled) context so it runs to completion; rollback errors are logged but do not mask the primary error.
- **Child env** — `tmuxChildEnv()` restores `TMUX=<OriginalTMUX>` in every spawned tmux process so tmux targets the user's server. Mirrors `cmd/rk/context.go`.
- **Timeouts** — `exec.CommandContext` with 30s for `wt create` / `wt delete`, 10s per tmux call.
- **Signals** — SIGINT/SIGTERM once, at the top of `runRiff` via `signal.NotifyContext`; propagates to all goroutines and their subprocess calls.

The new windows never appear on the managed `runkit`/`default` servers unless the user's current `$TMUX` happens to point there. See `rk-riff.md` for flag surface (`--skill`, `--cmd`, `--layout`, `--count`/`-N`, `--preset`, `--list-presets`), exit codes, and preset schema.

`rk riff`'s bare `new-window` is intentionally left unchanged by `260530-v6hm-active-window-event-derivation`: when it lands on a server run-kit observes, the resulting `%session-window-changed` is now what drives the sidebar/URL to follow the new window (event-derived highlight — see § Per-WebSocket Ephemeral Grouped Sessions > Active-window highlight). The fix is creator-agnostic in the derivation path, so `rk riff`, `wt open`, and raw external `new-window` all update the highlight correctly without any creator-local change.

## Unified Test-Socket Naming — `rk-test-<role>-<pid>-<ns>`

Since `260530-cf3g-unify-test-socket-reaping`, **every** test tmux-socket name — Go *and* Playwright — follows one umbrella form:

```
rk-test-<role>-<pid>-<ns>
```

- `<role>` identifies the test family: `unit`, `relay`, `tmuxctl`, `daemon`, `e2e`, and the hyphenated e2e secondaries `e2e-multi`, `e2e-coupling`, `e2e-msb`. **A role MAY contain hyphens.**
- `<pid>` is the real OS PID of the test binary (`os.Getpid()` in Go, `process.pid` in Playwright).
- `<ns>` is a single **hyphen-free** uniqueness token (Go: `time.Now().UnixNano()`; Playwright: a `Date.now().toString().slice(-6)` suffix). It being hyphen-free is what makes `<pid>` unambiguously the **second-to-last** hyphen field regardless of how many hyphens the role has.

This **replaced the old five-prefix scheme** (`rk-test-`, `rk-relay-test-`, `rk-verify-`, the fixed names `rk-tmuxctl-test` / `rk-daemon-test`) plus the separate `rk-e2e` exclusion. The two formerly fixed-name sockets (`rk-tmuxctl-test`, `rk-daemon-test`) now carry a PID and namespace, so the automatic post-sweep can classify them (previously they relied on `t.Cleanup` alone).

**Single shared naming helper**: Go tests build the name via `testSocketName(role string) string` → `fmt.Sprintf("rk-test-%s-%d-%d", role, os.Getpid(), time.Now().UnixNano())`. Because Go `_test.go` symbols are package-private, the helper is duplicated across the test-support files of each package that needs it (`internal/tmux/main_test.go`, `api/main_test.go`, and small local equivalents in `internal/tmuxctl`/`internal/daemon`); all seven Go naming sites route through it — no inline `fmt.Sprintf("rk-test-…")` socket literal remains (the only intentional exceptions are the helper definitions and `socketsweep_test.go`, which hand-builds live-vs-dead-PID sockets for the sparing test).

**E2E (Playwright) naming**: secondary per-spec servers embed `process.pid` as the second-to-last field — `rk-test-e2e-multi-${process.pid}-${suffix}`, `rk-test-e2e-coupling-${process.pid}-${suffix}`, `rk-test-e2e-msb-${process.pid}-${suffix}` (was the old `rk-e2e-multi-${Date.now().slice(-6)}` epoch suffix, which was never a PID and so could not be reasoned about by `parseTestSocketPID`). The **primary** harness server is the fixed name `rk-test-e2e` (was `rk-e2e`) — created once by `scripts/test-e2e.sh`, torn down by its trap/glob, and caught by the manual reaper's `rk-test` brute-force. A fixed-name primary cannot be PID-swept and does not need a PID; only the secondaries carry one. The `rk-test-e2e` name lives in `scripts/test-e2e.sh` (`E2E_TMUX_SERVER`), `app/frontend/tests/e2e/global-teardown.ts` (glob default), the `just pw` recipe default, and all e2e specs.

### PID parsing — second-to-last hyphen field

`parseTestSocketPID(name) (int, bool)` (duplicated in `internal/tmux/main_test.go` and `api/main_test.go`) extracts the PID from the **second-to-last** hyphen-delimited field — `strings.Split(name, "-")`, take element `len-2`, `strconv.Atoi`. It returns `ok=false` when the name lacks the `rk-test-` prefix, has fewer than 5 fields (`rk`, `test`, `<role>`, `<pid>`, `<ns>`), or the candidate field is non-numeric.

Parsing from the right (fixed `len-2` index) is what makes hyphenated roles work: `rk-test-e2e-coupling-48213-1717…` yields PID `48213` because the role (`e2e-coupling`) occupies the middle fields and never shifts the PID's position. The **old** rule — PID = the field immediately after the prefix (a fixed *left* index) — broke the moment a role contained a hyphen, and is gone.

`testPIDAlive(pid)` is unchanged: liveness via `syscall.Kill(pid, 0)` with a biased-alive interpretation — only a definitive `ESRCH` marks the PID dead; any other error (incl. `EPERM`) is treated as alive (leak-not-kill bias). A non-positive PID is treated as dead defensively.

### `IsTestServerName` — single-prefix identity check

`IsGoTestServerName` (the five-prefix allowlist, including the dead `rk-verify-` entry) is **DELETED**. Its replacement is the exported `tmux.IsTestServerName(name) bool` → `strings.HasPrefix(name, "rk-test-")` (`internal/tmux/tmux.go`). "Is this a test artifact?" now collapses to one check, and the `"rk-test-"` literal lives in exactly this one place.

`IsTestServerName` is **intentionally NOT applied** in `ListServers` nor in the `/api/servers` handler — internal consumers (`board.go` in particular) iterate every real tmux server, and `/api/servers` surfaces every server so the operator sees exactly what `rk reaper` will reap. Its only consumer is the **tmuxctl supervisor's resurrection guard** (`isTmuxSocketCandidate` in `internal/tmuxctl/supervisor.go`): leaked `rk-test-*` sockets (now including `rk-test-e2e-*`) are excluded from the control-mode candidate set so `resolveBootstrap`'s `new-session -s _rk-ctl` does not *resurrect* every orphan test socket on bootstrap. This is a **correctness guard, not UI noise reduction**, and stays in force regardless of the `/api/servers` change.

## Automatic Test-Socket Sweep — POST-sweep in `TestMain`

Both packages that have it (`internal/tmux/main_test.go`, `api/main_test.go`) run a **post-sweep** — `sweepDeadTestSockets()` runs *after* `m.Run()`, never before:

```go
func TestMain(m *testing.M) {
    code := m.Run()
    sweepDeadTestSockets()
    os.Exit(code)
}
```

The **pre-sweep was dropped entirely** (was `sweepDeadTestSockets(); os.Exit(m.Run())`). The old pre-sweep cleaned the *previous* run's residue before tests started; the post-sweep means **each run reaps its OWN dead-PID residue** on the way out.

**PID-scoped to dead owners only — never a blanket wipe.** `sweepDeadTestSockets` enumerates `/tmp/tmux-<uid>/` and `kill-server`s a socket only when its embedded PID **parses** (`parseTestSocketPID`) **AND is dead** (`testPIDAlive` reports `ESRCH`). Live-PID sockets — which belong to a **concurrent `go test ./...` package running as a separate process** — are spared, so packages running in parallel do not kill each other. Sockets without a parseable PID (no role/pid/ns shape) are left untouched. Kills use `exec.CommandContext` + a 5s timeout and an argument slice (constitution I) — never a shell string. Best-effort: enumeration or kill failures are ignored (a leaked socket is harmless residue; never blocking tests is the priority).

`t.Cleanup(kill-server)` still reaps each socket on the normal path; the post-sweep handles only **un-catchable SIGKILL / panic / OOM residue**. With the pre-sweep gone, the only automatic cleanup for un-catchable residue is this post-sweep — and the manual `rk reaper` is the by-hand janitor for cruft that has already accumulated on disk across runs.

## `rk reaper` — Brute-Force-by-Prefix Operator Cleanup

`rk reaper` (introduced `260529-fww2-rk-reaper-command`, rewritten by `260530-cf3g-unify-test-socket-reaping`) is a **top-level, operator-invoked** command — a sibling of `rk serve`/`rk riff`, registered via `rootCmd.AddCommand(reaperCmd)` in `cmd/rk/root.go`. It is **NOT** wired into any startup path. The command body (`cmd/rk/reaper.go`) is thin — flag parsing + summary rendering; all scan/classify/reap logic lives in `internal/tmux/reaper.go` (constitution §III).

### There is no longer a relay startup sweep to coexist with

When `rk reaper` was introduced (`260529-fww2`) it was orthogonal to the relay startup sweep `sweepOrphanedRelaySessions`. Since `260602-qn62-move-based-board-pin-sessions` that sweep — and its whole file `cmd/rk/serve_sweep.go` — is **deleted**: relay ephemerals no longer exist (the relay attaches directly), and board pin-sessions (`_rk-pin-*`) are PERSISTENT across rk restarts (a valid state, not an orphan), so there is no in-server session class to reap at startup. The reaper remains the operator-only janitor for **whole test servers and dead/stale sockets/`.lock` files** — different scope, different trigger.

### Brute-force-by-prefix — no liveness probe to match

The reaper matches **purely by name prefix** — no PID parse, no name-shape reasoning, no e2e exclusion, no `.lock`-inherits-base-server logic. It iterates **RAW** socket-dir candidates via `ScanSocketDir(ctx)` (NOT `ListServers`, which probes dead sockets away) and classifies each:

- **Bare `rk reaper`** ≡ `rk reaper --prefix rk-test` — matches every `rk-test*` socket, `.lock` file, and live server.
- **`rk reaper --prefix <p>`** applies identical behavior to `<p>*`.
- A matched **live server** → `KillServer` (`ReapActionKill`); a matched **socket** (dead) or **`.lock` file** → `os.Remove` (`ReapActionRemove`).

The only thing requiring the outside world is the live-vs-dead distinction for a matched, non-`.lock` candidate. `classifyReap(name, prefix, serverLive)` is a **pure function** (`internal/tmux/reaper.go`) — `serverLive` is supplied by the caller (`reapCandidates`, which calls `probeServerAlive` only when `probeNeeded` says the kill-vs-remove decision depends on it). `ReapAction` is the exported enum (`ReapActionSkip`/`Kill`/`Remove`); the full matrix is unit-testable via `TestClassifyReap` without spawning servers.

### Hard-skips (never reaped, even under `--prefix` + `--force`)

- **`_rk-ctl` control anchor** (`ControlAnchorSessionName`) — owned by `tmuxctl`.
- **Live `rk-daemon` production server** (named const `productionDaemonServer = "rk-daemon"`, a local literal to avoid an `internal/daemon` import edge).

Both are short-circuited before the prefix check in *both* `classifyReap` and `probeNeeded`, so a broad or mistyped `--prefix rk` can never take down production — the dry-run default alone is not sufficient protection for the daemon.

### Dry-run is the DEFAULT; `--yes`/`--force` to act

Invoking `rk reaper` (bare or `--prefix`) with no action flag **prints the match list** with each entry's classified action (`kill`/`remove`) and **touches nothing**. The action gate is `act := (reaperYes || reaperForce) && !reaperDryRun`:

- `--yes` (or `--force`) → actually reap.
- `--dry-run` is retained as an **explicit alias** for the default preview and always wins (forces preview even if `--yes`/`--force` were also passed).
- `--force` is the **ONLY** flag that bypasses the dangerous-prefix guard. `--yes` acts but does NOT bypass the guard, so an operator who only confirms is still protected from a typo'd short prefix.

**Dangerous-prefix guard** (`ReapTestServers`): an empty prefix or one of length ≤ 3 (e.g. `rk-`) matches nearly everything (`runkit`, `runWork`, production) and is **refused** unless `--force`. The guard refuses regardless of `act`, so even a dry-run with a dangerous prefix reports the refusal rather than previewing a near-everything match.

### Operating contract — do NOT run while tests are running

Because the manual reaper has **no live-run protection by design** (no name allowlist, no PID gate), the operating contract is: **do not run `rk reaper` (bare or `--prefix`) while tests are running** — it will kill their live tmux servers. The automatic post-sweep's PID-scoping protects concurrent `go test` packages; the manual tool relies on the human. This contract is stated in the command's `Long` help text and here.

### I/O routine, test seam, and partial-failure contract

`ReapTestServers(ctx, prefix, act, force) (ReapResult, error)` is the public entry point: it applies the dangerous-prefix guard, scans via `ScanSocketDir`, then delegates per-candidate work to the internal seam `reapCandidates(ctx, dir, prefix, candidates, probe, act)` — passing `socketDirPath()` and `probeServerAlive`. Tests drive `reapCandidates` directly with a temp dir + fake prober (no real tmux server spawned). `ReapResult` carries `Killed []string`, `RemovedSockets []string`, and `DryRunPlan []ReapPlanEntry` (`{Name, Action}` pairs, populated only on a dry-run).

**Partial failure** (the pattern the deleted relay sweep also used): each kill/remove failure is logged via `slog.Warn` and skipped; iteration continues; a joined aggregate error (`reaper partial failures: …`) is returned at the end (nil when all succeed). The command renders the summary *before* surfacing the aggregate error, so the operator sees what was reaped even on partial failure.

## `/api/servers` Lists Every Server — the Test-Socket Hide Was Deleted

Since `260530-cf3g-unify-test-socket-reaping`, the `/api/servers` test-socket **hide filter is deleted entirely** (no helper swap, no conditional, no e2e exemption). `handleServersList` (`api/servers.go`) returns the output of `tmux.ListServers` directly, so the response includes **every** tmux server discovered — including leaked `rk-test-*` orphans. The reaper is the **sole** mechanism that keeps this list clean.

**Accepted consequence**: after a crashed test run, the dev UI lists the orphans **and opens an SSE stream per orphan server** until the operator runs `rk reaper`. This is intended ("surface everything") — the user sees exactly the pile the reaper will reap. The `servers_test.go` fixture was inverted accordingly: it now asserts that ALL servers (including `rk-test-*` / `rk-test-e2e-*`) are returned (`TestHandleServersList_ReturnsAllServersIncludingTestSockets`).

## `RK_SERVER_ALLOWLIST` — Env-Gated Test-Isolation Filter in `ListServers`

Since `260531-tmnm-test-scoped-server-enumeration`, `ListServers` applies an **env-gated allowlist filter** read from `RK_SERVER_ALLOWLIST` (const `tmux.ServerAllowlistEnv`, `internal/tmux/tmux.go`). The env var is read **in-package** via `os.Getenv` — matching the `RK_TMUX_CONF`/`OriginalTMUX` precedent — **NOT** threaded through `internal/config` (`ListServers` is a `ctx`-only free function and `internal/tmux` has no `config` dependency to carry it).

- **Unset / whitespace-only (production default)**: the filter is a no-op — `ListServers` returns all live servers, byte-for-byte identical to before. The `/api/servers` "surface every server" contract (see § above) and the `IsTestServerName` design intent are preserved exactly. An empty value is treated as unset, so a blank env never means "match nothing".
- **Set (test only)**: the post-probe live-server list is narrowed to names admitted by `matchesServerAllowlist(name, allowlist)` — a pure, table-tested predicate (`TestMatchesServerAllowlist`, no live tmux server needed). The allowlist is a **comma-separated list of prefixes**; each token is trimmed, empty tokens skipped, and a name matches when it `strings.HasPrefix` ANY token (exact match = prefix-of-itself).

**Why prefix, not exact**: multi-server e2e specs create secondaries in `beforeAll` named `rk-test-e2e-<role>-<pid>-<epoch>` (e.g. `rk-test-e2e-multi-*`, `rk-test-e2e-coupling-*`, `rk-test-e2e-msb-*`). Exact match on `rk-test-e2e` would wrongly exclude them and break those specs; prefix admits the primary plus this-run secondaries (cross-worktree-safe via the embedded `process.pid`). The allowlist targets `rk-test-e2e*` specifically — a `rk-test-relay-*` Go-test server under the broader `rk-test-` umbrella is NOT admitted.

**Why the filter lives in `ListServers`, not the `/api/servers` handler**: the board route attaches servers from **two** distinct `ListServers`-rooted paths — (1) `GET /api/servers` (`api/servers.go`) populating `useSessionContext().servers`, and (2) the internal `board.go` board-entry enumeration (`ListBoards` / `GetBoard`, which iterate `ListServers` per-server since `260602-qn62`). Filtering only the HTTP handler leaves path (2) unscoped, so the SSE inflation persists. Placing it in `ListServers` means **all** enumeration consumers inherit the scope when the env is set: `/api/servers` and `board.go`. (Pre-`260602-qn62` the startup relay-orphan sweep `serve_sweep.go` was a third consumer; it is now deleted.) This is the intended outcome in the test environment (the only environment that sets the var).

**Why it matters (the bug it fixes)**: on the board route, the frontend attaches **all** known servers and opens **one SSE `EventSource` per server** (boards are cross-server by design). On a busy operator box, N live `kit`/`runWork`/orphan servers → N EventSources → N of the browser's 6 HTTP/1.1 connection slots consumed before any relay WebSocket or xterm chunk fetch. Scoping the backend READ path to `rk-test-e2e*` bounds the board route to one SSE per test server, removing the **load-dependent** connection-pool starvation that made board e2e specs flaky only on busy sessions (the environmental third vector of `e2e-flakiness-board-route-dynamic-import-hang`).

### Allowlist (new) vs `IsTestServerName` denylist (existing) — opposite directions

These two mechanisms point in **opposite directions** and coexist without conflict (this change touches only the allowlist):

| Mechanism | Question | Hides | Used by |
|-----------|----------|-------|---------|
| `RK_SERVER_ALLOWLIST` (new, forward allowlist) | "Is this a server THIS test run may see?" | **normal** servers from tests | `ListServers` (env-gated) |
| `IsTestServerName` (`HasPrefix "rk-test-"`, denylist) | "Is this a test server?" | **test** servers from normal operation | tmuxctl supervisor resurrection guard only |

The `tmuxctl` supervisor is **unaffected**: it does NOT call `ListServers` — it enumerates the socket dir via `os.ReadDir` + `isTmuxSocketCandidate` (`supervisor.go`), so its resurrection guard is identical to before regardless of the allowlist.

### Harness wiring — backend READ path vs WRITE socket

`scripts/test-e2e.sh` exports `RK_SERVER_ALLOWLIST` (set to the `E2E_TMUX_SERVER` value, `rk-test-e2e`) into the **dev backend** process — the `setsid bash -c "… exec just dev"` launch — so the backend's `ListServers` read path is scoped for the run. This is distinct from `E2E_TMUX_SERVER`, which scopes the **socket the tests WRITE to** (a shell/TS-only variable Go never reads). A dedicated `RK_*` name is honest about allowlist intent and matches the env-var convention rather than repurposing the socket-naming variable for Go config.

## Related Files

- `app/backend/internal/tmux/tmux.go` — `serverArgs()`, `tmuxExecServer()`, `ListSessions()`, `ListServers()` (delegates the raw socket-dir scan to `ScanSocketDir` and the probe to `probeServerAlive`; applies the env-gated `RK_SERVER_ALLOWLIST` filter post-probe), `ServerAllowlistEnv` const + `matchesServerAllowlist(name, allowlist)` pure prefix-match predicate (test-isolation filter; see § `RK_SERVER_ALLOWLIST`), `ScanSocketDir(ctx)`, `socketDirPath()`, `filterSocketEntries()`, `probeServerAlive(ctx, name)`, `IsTestServerName()` (single `HasPrefix("rk-test-")`; consumed only by the tmuxctl supervisor resurrection guard), `LockSocketSuffix`, `ListKeys()`, `KillServer()`, `CreateSession()`, `SelectWindow(windowID, server)`, `ResolveWindowSession()` (first-match owning session — home or `_rk-pin-*`), `resolveWindowSessionIndex()`, `MoveWindow(windowID, dstIndex, server)`, `MoveWindowToSession(windowID, dstSession, server)`, `ReloadConfig()`, `EnsureConfig()`, `ConfigPath()`, plus the pin-session helpers `PinSessionPrefix`/`PinSessionName(windowID)`/`WindowIDFromPinSession(name)`/`ListPinSessionNames(ctx, server)` (since `260602-qn62`; the deleted `RelaySessionPrefix`/`OwnerPIDOption`/`NewGroupedSession`/`SetSessionOwnerPID`/`GetSessionOwnerPID`/`ListRawSessionNames` are gone)
- `app/backend/internal/tmux/reaper.go` — brute-force-by-prefix reaper logic: pure `classifyReap(name, prefix, serverLive) ReapAction` (enum `ReapActionSkip`/`ReapActionKill`/`ReapActionRemove`), `probeNeeded(name, prefix)` (gates the kill-vs-remove subprocess probe), the consts `productionDaemonServer = "rk-daemon"` + `minSafePrefixLen = 3`, `ReapResult`/`ReapPlanEntry`, the public `ReapTestServers(ctx, prefix, act, force)` (applies the dangerous-prefix guard), and the test seam `reapCandidates(ctx, dir, prefix, candidates, probe, act)` (log-and-skip per entry via `slog`, aggregate error at end). Tested in `reaper_test.go`
- `app/backend/cmd/rk/reaper.go` — thin `reaperCmd` (top-level; `--prefix` default `rk-test`, `--yes`/`--force` action gate, `--dry-run` explicit-alias for the default preview); `act := (yes||force) && !dryRun`; calls `tmux.ReapTestServers(ctx, prefix, act, force)` and renders summary/dry-run (`renderReapSummary`/`renderDryRun`); `Long` help states the brute-force/no-liveness-protection/operating contract; no scan/probe/remove/kill in `cmd/rk`
- `app/backend/internal/sessions/sessions.go` — `FetchSessions(server)` builds the dashboard view, `ProjectSession` has `Name` and `Windows` (no `Server` field); pane-map enrichment re-keys from `session:index` to windowID before joining; `ProjectRoot(ctx, windowID, server)` resolves by window ID
- `app/backend/api/router.go` — `serverFromRequest()` helper, `TmuxOps` interface with server params, route registration
- `app/backend/api/windows.go` — window action handlers keyed by `/api/windows/{windowId}` (kill, move, move-to-session, rename, color, url/type PUT, keys, select, split, close-pane); `parseWindowID(r) (string, bool)` helper validates the path param; `handleWindowCreate` stays session-scoped
- `app/backend/api/servers.go` — server list/create/kill handlers
- `app/backend/api/keybindings.go` — `GET /api/keybindings` handler (runs `list-keys`, filters via whitelist, returns JSON)
- `app/backend/api/sse.go` — per-server SSE polling hub
- `app/backend/api/relay.go` — WebSocket relay at `/relay/{windowId}` reads `?server=` query param, validates the window ID via `decodeWindowID`, resolves the owning session via `tmux.ResolveWindowSession` (home OR `_rk-pin-*`), does a direct bare `tmux.SelectWindow(windowID, server)` on that real session, and `attach-session -t <session>` via `creack/pty`. **No ephemeral, no `@rk_owner_pid` stamp, no defer-kill** (all removed by `260602-qn62`)
- `app/backend/internal/tmux/board.go` — move-based board layer: `Pin`/`Unpin`/`Reorder`, `pinEntry`, `ListBoardEntries`/`ListBoards`/`GetBoard` (derived from `_rk-pin-*` session vars), `ComputeOrderKey`/`nextAppendKey`/`initialAppendKey`, validators (`ValidBoardName`/`ValidWindowID`/`ValidOrderKey`), and the `BoardOption`/`HomeOption`/`BoardOrderOption` session-var key constants. The old `@rk_board` server-option encoding (`parseBoardValue`/`serializeBoardValue`/`setBoardValue`/`ListAllBoardEntries`/`RemoveAllByWindowID`) is deleted
- *(`app/backend/cmd/rk/serve_sweep.go` — DELETED by `260602-qn62`; the relay startup sweep no longer exists)*
- `app/backend/internal/tmuxctl/` — control-mode subscription package; `Client` opens `tmux -CC ... -t =<bootstrap> -r` per socket, **always** creates the `_rk-ctl` anchor floor (since `260602-a1wo-prevent-exit-empty-server-death`) and tags it with `@rk_ctl_keepalive 1`, and sets `exit-empty off` via `tmux.SetExitEmptyOff` in `productionDial` before the anchor on every dial/reconnect. See `architecture.md` § tmux Control-Mode Subscription
- `app/backend/internal/tmux/tmux.conf` — canonical tmux configuration (Go-embedded, written to `~/.run-kit/tmux.conf` on first run)
- `app/backend/cmd/rk/riff.go` — `rk riff` subcommand: N-pane `tmux new-window` + `split-window` + `select-layout` + `select-pane` sequence per window on the user's current tmux server (via `tmux.OriginalTMUX` restore in child env), with parallel fan-out + rollback on failure
- `app/backend/cmd/rk/pane_spec.go` — `paneFlag` pflag.Value + `rewritePaneSpaceForm` argv pre-processor supporting bare/space/equals forms for repeatable `--skill`/`--cmd`
- `app/backend/cmd/rk/layout.go` — `layoutAliases` table + `resolveLayout` + `autoLayout` helpers
- `app/backend/cmd/rk/layout_help.go` — `renderLayoutMocks` Unicode box-drawing mocks rendered inline in `rk riff -h`
- `app/backend/internal/fabconfig/fabconfig.go` — `ReadSpawnCommand(repoRoot)` reads `agent.spawn_command` from `fab/project/config.yaml` (best-effort `yaml.v3`; returns `""` on any failure)
- `app/backend/internal/tmux/main_test.go`, `app/backend/api/main_test.go` — per-package `TestMain` dead-PID **POST-sweep** (`code := m.Run(); sweepDeadTestSockets(); os.Exit(code)`; since `260530-cf3g`, was a pre-sweep): after tests, scans `/tmp/tmux-<uid>/` and `kill-server`s any `rk-test-*` socket whose embedded PID (the **second-to-last** hyphen field of `rk-test-<role>-<pid>-<ns>`, parsed by `parseTestSocketPID`) is dead per `testPIDAlive` — self-healing un-catchable SIGKILL/panic/OOM residue. PID-scoped (never a blanket wipe): live-PID sockets (a concurrent `go test` process) and names without a parseable PID are spared. Also host the shared `testSocketName(role)` helper and the `testSocketPrefix = "rk-test-"` const. Logic is duplicated across the two packages (Go `_test.go` symbols are package-private); kills use `exec.CommandContext` + 5s timeout (constitution I)
- `app/backend/internal/tmux/socketsweep_test.go` — `TestSweepDeadTestSockets_sparesLivePIDReapsDead` proves the post-sweep spares a live-PID `rk-test-*` socket while reaping a dead-PID orphan (hand-builds both names via `os.Getpid()` + a known-dead PID — the only sanctioned non-helper `rk-test-` literals); plus `TestParseTestSocketPID` / `TestTestPIDAlive`. `app/backend/api/socketsweep_test.go` carries the parse/liveness unit tests for the `api` copy
- `scripts/test-e2e.sh` (`cleanup`/`trap cleanup EXIT`), `app/frontend/tests/e2e/global-teardown.ts` — e2e teardown globs **all** `rk-test-e2e*` sockets (was `rk-e2e*`) in `/tmp/tmux-<uid>/`, best-effort, so PID-named secondaries (`rk-test-e2e-multi-*`, `rk-test-e2e-coupling-*`, `rk-test-e2e-msb-*`) don't leak on crash/Ctrl-C. **Process-group fix (since `260530-cf3g`)**: the dev server is launched via `setsid bash -c "… exec just dev" &` into its OWN process group (`DEV_PGID=$!`), and `cleanup` kills only `kill -- "-$DEV_PGID"` — never `kill 0`. A non-detached `kill 0` signalled the *caller's* process group, SIGTERMing live tmux servers / `-CC` control clients sharing it when the e2e run executed inline in an interactive/agent session (proven root cause of `kit`/`abbb`/`runWork` dying mid-session — a 16-server death burst with zero `audit=kill-server` lines; constitution VI). The `rk-test-e2e*` socket reap loop is otherwise unchanged. **Backend READ-path scoping (since `260531-tmnm`)**: the `setsid … just dev` backend launch now also exports `RK_SERVER_ALLOWLIST=$E2E_TMUX_SERVER` (`rk-test-e2e`), scoping the backend's `ListServers` to test servers for the run — distinct from `E2E_TMUX_SERVER`, which scopes the WRITE socket (see § `RK_SERVER_ALLOWLIST`)

## Design Decisions

These are reusable patterns established by `260530-cf3g-unify-test-socket-reaping` that future changes should follow:

1. **Never `kill 0` from a non-detached shell script.** A script that runs inline (sourced into an interactive shell or spawned by an agent) shares the **caller's** process group, so `kill 0` / `kill -- -$$` SIGTERMs the caller's unrelated processes — including live tmux servers and `-CC` control clients. The safe pattern for "tear down the subtree I launched" is to launch the subtree into its **own** process group via `setsid bash -c "… exec <cmd>" &`, capture `PGID=$!` (setsid makes `$!` equal the new group's PGID), and kill **only** that group by negative PGID: `kill -- "-$PGID"`. Guard with `[ -n "$PGID" ]` so a trap firing before launch is a no-op. (Root cause of a 16-server death burst with zero `audit=kill-server` lines; constitution VI.)

2. **Embedded-PID naming: PID = second-to-last hyphen field.** When a name encodes `<prefix>-<role>-<pid>-<ns>` and `<role>` may itself contain hyphens (e.g. `e2e-multi`), parse the PID from the **right** (`strings.Split(name, "-")`, element `len-2`) rather than a fixed left index, and keep `<ns>` a single hyphen-free token. This decouples PID extraction from the role's segment count — a left-index parse breaks the moment a role gains a hyphen.

3. **Manual cleanup tools default to dry-run; only `--force` bypasses safety guards.** A brute-force-by-prefix janitor (`rk reaper`) is intentionally not PID-gated — the operator asserts nothing live needs the matched artifacts. The safety budget is: dry-run-default preview, unconditional hard-skips for production-critical names (`_rk-ctl`, `rk-daemon`), a dangerous-prefix guard (empty/≤3 chars), and a documented operating contract ("don't run while tests run"). Crucially `--yes` (confirm) and `--force` (bypass guard) are **separate** — confirming an action does not waive typo protection.

Established by `260531-tmnm-test-scoped-server-enumeration`:

4. **Test isolation via an env-gated allowlist at the enumeration root, not the HTTP handler.** When a behavior fans out to multiple backend paths (here: the board route reaches `ListServers` via both `/api/servers` and internal `board.go` enumeration), scope it at the shared **root function** (`ListServers`), not at one consumer (`handleServersList`). A handler-only filter leaves sibling paths unscoped. Gate the filter behind an env var read **in-package** (matching the package's existing `os.Getenv` precedent — no new cross-package import for one scoping value) so production (env UNSET) is a byte-for-byte no-op and only the test harness narrows the list. Extract the match logic as a pure predicate (`matchesServerAllowlist`) so it is table-testable without live tmux servers.

5. **A forward allowlist and a reverse denylist can coexist — keep them distinct.** Hiding *normal* servers *from tests* (the new `RK_SERVER_ALLOWLIST`) is the opposite direction from hiding *test* servers *from normal operation* (the existing `IsTestServerName` resurrection guard). They are not interchangeable: `rk-test-e2e` is matched by `IsTestServerName`, so a `!IsTestServerName` denylist would hide the very server the tests need. When you need the opposite-direction scoping, add a new mechanism rather than flipping the existing one.

## Changelog

| Date | Change | Reference |
|------|--------|-----------|
| 2026-03-18 | Rewrote for multi-server architecture — dedicated `runkit` tmux server replaces byobu integration. `ListSessions()` queries both runkit and default servers. `parseSessions()` extracted as testable function with server tagging. `CreateSession()` uses plain `tmux new-session` (byobu dependency removed). `ListWindows()` accepts server parameter. | `260318-0gjh-dedicated-tmux-server` |
| 2026-03-20 | Multi-server operations — `SelectWindowOnServer()` routes select-window to correct server. `ReloadConfig(server)` hot-reloads config on specified server. Relay and select-window endpoints accept `?server=` query param. `RK_TMUX_CONF` resolved to absolute path at init. Stderr captured in tmux exec errors. | `260318-0gjh-dedicated-tmux-server` |
| 2026-03-20 | Single-active-server model — replaced dual-server merge with `?server=` on every request. All tmux functions accept `server` param. Unified `tmuxExec`/`tmuxExecDefault` into `tmuxExecServer`. Added `ListServers()` (socket scan), `KillServer()`. SSE hub polls per-server. Removed `SessionInfo.Server` and `ProjectSession.Server` fields. New endpoints: `GET/POST /api/servers`, `POST /api/servers/kill`. `serverFromRequest()` validates input. | `260320-1335-tmux-server-switcher` |
| 2026-03-20 | tmux config and keybindings — `EnsureConfig()` auto-creates `~/.run-kit/tmux.conf` on serve startup. `-f` config flag scoped to `CreateSession`/`ReloadConfig` via `configArgs()`. Enhanced `internal/tmux/tmux.conf` with agent-optimized defaults and power-user keybindings. `ListKeys(server)` runs `tmux list-keys`, returns raw output (nil on "no server"). New `GET /api/keybindings` endpoint filters `list-keys` via whitelist map. `KillServer()` handles socket teardown gracefully (returns nil on "No such file or directory"). | `260320-9ldy-ui-polish-tmux-config-embed` |
| 2026-04-04 | Cross-session window move — `MoveWindowToSession(srcSession, srcIndex, dstSession, server)` wraps `tmux move-window -s {src}:{idx} -t {dst}:` with `tmuxExecServer` and `withTimeout()`. New `POST /api/sessions/{session}/windows/{index}/move-to-session` endpoint with `{ "targetSession" }` body. `TmuxOps` interface extended with `MoveWindowToSession`. Validates source/target differ (400 if same). | `260404-dq70-move-window-between-sessions` |
| 2026-04-06 | Pane CWD tracking — `ListWindows` now calls `list-panes -s -t <session>` after `list-windows` to populate `Panes []PaneInfo` on each `WindowInfo`; failure is non-fatal. `parsePanes(lines []string) map[int][]PaneInfo` parses 6-field tab-delimited output (field 0 = `#{window_index}` for grouping, fields 1–5 = pane data). Package-level `paneFormat` var: `#{window_index}\t#{pane_id}\t#{pane_index}\t#{pane_current_path}\t#{pane_current_command}\t#{pane_active}`. `WorktreePath` unchanged — still sourced from `list-windows #{pane_current_path}`. | `260405-rx38-pane-cwd-tracking` |
| 2026-04-17 | `rk riff` window creation — new subcommand creates `tmux new-window -c <path> "<launcher> '<cmd>'"` on the user's current tmux server (not the managed `runkit`/`default` servers). `$TMUX` recovered via `tmux.OriginalTMUX` and restored in child env (mirrors `context.go`). Optional `--split` appends `tmux split-window -h -c <path> "<setup>; exec \"${SHELL:-/bin/sh}\""` via the shared `shellWrap` helper. Bypasses `internal/tmux` because that package targets specific named servers via `-L <server>`. | `260416-r1j6-add-riff-command` |
| 2026-04-18 | Frontend server-routing contract — `server: string` now threaded as the first positional argument through every `api/client.ts` function that hits a server-scoped endpoint (`getSessions`, `createSession`, `renameSession`, `killSession`, `createWindow`, `renameWindow`, `killWindow`, `moveWindow`, `moveWindowToSession`, `selectWindow`, `splitWindow`, `closePane`, `sendKeys`, `updateWindowUrl`, `updateWindowType`, `setWindowColor`, `setSessionColor`, `reloadTmuxConfig`, `uploadFile`, `getKeybindings`). `withServer(url, server)` is pure — the module-level `_getServer` global and `setServerGetter` export are removed; `SessionProvider` no longer wires a getter. Server-management endpoints (`listServers`, `createServer`, `killServer`) and theme/server-color settings remain server-parameter-free by design. Eliminates a closure-race where mid-action server switches retargeted in-flight rename/kill/create calls at the wrong tmux server. Mirrors the backend `tmuxExecServer(ctx, server, …)` shape. | `260418-yadg-fix-mutation-server-race` |
| 2026-04-23 | `rk riff` window creation now multi-pane + fan-out. Each window is built by a `tmux new-window` + N-1 `tmux split-window` sequence, followed by `tmux select-layout <canonical>` and `tmux select-pane -t <window>.0` (focus pane 0 per the argv-order rule). Skill panes preserve the three-layer `<launcher> '<escaped-skill>'` → `sh -i -c` → `shellWrap` composition from earlier changes; cmd panes skip the interactive wrap (would alter user-command argv). Fan-out with N ≥ 2 spawns N goroutines sharing one context; failure triggers rollback via the pure `planFanOutRollback(results, failureIdx)` builder + `wt delete` / `tmux kill-window` calls against a fresh (non-cancelled) context so rollback runs to completion. Worktree names come from `wt`'s generator (no rk-side `-1..-N` scheme); windows named `riff-<wt-basename>` with `resolveWindowName` suffix fallback on collision. `--setup-pane`/`--split` removed entirely — `--cmd` (repeatable) subsumes the single-split use case. | `260423-jmwu-rk-riff-workflow-features` |
| 2026-05-09 | Per-WebSocket ephemeral grouped sessions for the relay. New `tmux.NewGroupedSession(ctx, server, realSession, ephemeral)` wraps `tmux new-session -d -s <ephemeral> -t <realSession>` (with a `has-session` probe to avoid leaking empty-group sessions when the target is missing), `tmux.KillSessionCtx(ctx, server, session)` exposes a ctx-accepting kill so relay cleanup can pass `context.Background()` (existing `KillSession` becomes a thin wrapper), and `tmux.RelaySessionPrefix = "rk-relay-"` reserves the name space. `parseSessions` early-skips any `rk-relay-*` line — single chokepoint for all user-facing session lists (REST, SSE, board derivation, server-aggregate). New `tmux.ListRawSessionNames(ctx, server)` returns the unfiltered list for housekeeping callers (the startup sweep, which would otherwise be unable to see the very ephemerals it needs to reap). Relay handler (`api/relay.go`) generates the ephemeral name via `crypto/rand` (constitution I), creates the grouped session, defers `KillSessionCtx(context.Background(), …)` (request context is cancelled at cleanup time), then `select-window` and `attach-session` against the ephemeral — never against the real session. Fixes the central bug where N board panes targeting different windows of one tmux session showed identical content (tmux's per-session active-window state); fixes a latent two-tabs-same-session focus-stealing bug as a side effect. Startup sweep `sweepOrphanedRelaySessions(ctx)` in `cmd/rk/serve_sweep.go` reaps orphans across all `tmux.ListServers(ctx)` before HTTP bind (synchronous, 30s bounded; per-server failures logged and skipped). `TmuxOps` interface gains `NewGroupedSession` and `KillSessionCtx`; `mockTmuxOps` updated. WebSocket URL contract unchanged. | `260508-hdjr-relay-grouped-sessions-board-panes` |
| 2026-05-07 | `@rk_board` server-scoped option for pane boards. New `internal/tmux/board.go` exposes `ListBoardEntries`, `ListAllBoardEntries`, `ListBoards`, `GetBoard`, `Pin`, `Unpin`, `Reorder`, `RemoveAllByWindowID`, and `ComputeOrderKey`. Distributed storage: each tmux server stores entries only for its own windows; aggregate views read from every server returned by `ListServers()` and union/tag entries with their source server. Value format: comma-separated `<window_id>:<board>:<order_key>` entries (`,` and `:` reserved separators excluded by the board-name regex `^[A-Za-z0-9_-]{1,32}$`). Order keys use lowercase-`a..z` fractional indexing — inserts never renumber. Lazy stale-entry cleanup at read time intersects with `liveWindowIDs(server)` and best-effort writes back the cleaned slice (read still returns success on write-back failure). Eager cleanup via the SSE poll-tick: window-kill detection scans per-server entries and calls `RemoveAllByWindowID`, then broadcasts one `event: board-changed { change: "cleanup" }` per affected board. SSE hub bootstraps `@rk_board` once per server on first poll and emits a synthetic `board-changed { change: "bootstrap", entries: [...] }` so an rk-go restart with tmux still running rehydrates the boards UI (parity with `@rk_session_order`). All wrappers route through `tmuxExecRawServer` with `context.WithTimeout(ctx, TmuxTimeout)`. | `260507-4vuv-pane-boards` |
| 2026-05-28 | `_rk-ctl` hidden anchor session for tmuxctl control mode. New exported `tmux.ControlAnchorSessionName = "_rk-ctl"` constant (alongside `RelaySessionPrefix` in `internal/tmux/tmux.go`); `parseSessions` early-skips any line whose `#{session_name}` equals it — single chokepoint mirroring the existing `rk-relay-*` skip, so every consumer (REST `/api/sessions`, SSE `event: sessions`, board derivation, server-aggregate `/api/servers`) automatically excludes the anchor. `app/backend/internal/tmuxctl/Client` creates the anchor via `tmux [-L <socket>] new-session -d -s _rk-ctl` when the target tmux server has zero pre-existing sessions (`tmux -CC` requires an attached session to emit notifications), then tags it with the server-scoped user-option `@rk_ctl_keepalive 1` via `set-option -t =_rk-ctl @rk_ctl_keepalive 1` (exported as `tmuxctl.AnchorKeepaliveOption`; defensive marker only, no v1 runtime consumer). Concurrent-rk "duplicate session" error treated as benign by `isDuplicateSessionError`. The Client invokes `tmux -CC attach-session -t =<bootstrap> -r` with the anchored `=` target form (PR #196 convention) and the `-r` read-only flag (restricts input, notifications still emit). `sweepOrphanedRelaySessions` is excluded by construction (matches only the `rk-relay-` prefix), with an explicit `name == ControlAnchorSessionName` guard added for defense-in-depth. Anchor outlives any single Client lifetime; only `tmux kill-server` removes it. | `260528-nvlp-active-window-sync` |
| 2026-05-29 | `rk reaper` operator command — a top-level sibling of `rk serve` (registered in `cmd/rk/root.go`, thin command in `cmd/rk/reaper.go`, logic in `internal/tmux/reaper.go`), NOT wired into startup and NOT replacing `sweepOrphanedRelaySessions`. Reaps three leak shapes in `/tmp/tmux-{uid}/` by iterating RAW candidates via the extracted `ScanSocketDir` (NOT `ListServers`, which drops dead sockets): (a) live orphan test servers → `KillServer`; (b) dead test sockets → `os.Remove`; (c) stale `*.lock` sockets → `os.Remove`. Pure `classifyReap(name, probeAlive) ReapAction` (`ReapActionSkip`/`Kill`/`Remove`) with branch order `.lock` → `_rk-ctl` anchor skip → `IsGoTestServerName`(+probe) → skip; `ReapTestServers(ctx, dryRun)` is the I/O entry, `reapCandidates` the temp-dir test seam. No PID-liveness gate — all five test prefixes (incl. fixed-name `rk-tmuxctl-test`/`rk-daemon-test`) and `.lock` files reap unconditionally; `rk-e2e-*` excluded for free (not in `IsGoTestServerName`), `_rk-ctl` and live non-test servers excluded explicitly. `--dry-run` previews by action and mutates nothing; default prints count/names. Per-entry `slog` log-and-skip + aggregate error mirrors the startup sweep. **Complements `260529-wtg4`'s automatic PID-gated test-socket pre-sweep (`TestMain`): that prevents new leaks during test runs; `rk reaper` is the manual, unconditional janitor for cruft already accumulated on disk.** Refactor: the socket-dir scan/probe inlined in `ListServers` was extracted into shared `socketDirPath()`/`ScanSocketDir(ctx)`/`filterSocketEntries()`/`probeServerAlive(ctx, name)` helpers (single source for the `/tmp/tmux-{uid}` convention); `ListServers` behavior preserved (probe-success-only, sorted). | `260529-fww2-rk-reaper-command` |
| 2026-05-30 | Relay startup sweep scoped to owner PID; test-artifact leaks closed. Each `rk-relay-*` ephemeral is stamped at creation with the session-scoped `@rk_owner_pid` user option (`tmux.SetSessionOwnerPID` / const `tmux.OwnerPIDOption = "@rk_owner_pid"`, mirroring `SetSessionColor`'s `set-option -t <session> @opt`; read back via `tmux.GetSessionOwnerPID` with `GetSessionOrder`-style unset/no-server tolerance). `sweepOrphanedRelaySessions` (`cmd/rk/serve_sweep.go`) now reaps a relay **only** when its owner is empty/unstamped (`relayOwnerIsDead("")→true`) or names a dead PID — `pidAlive(pid)` via `syscall.Kill(pid,0)` with a leak-not-kill bias (`nil`/`EPERM`/other→spare, `ESRCH`→reap; intentionally opposite to `daemon_portowner.go:processAlive`'s EPERM→dead). Fixes the cross-instance destruction bug where any second `rk serve` (e2e backend, `air` rebuild, manual instance) tore down a live dashboard's open terminal relays. Stamp ordering is load-bearing: after `NewGroupedSession` + cleanup-defer, before `SelectWindowInSession`/attach — an attachable-but-unstamped relay (owner=="") would be wrongly reaped; on stamp failure `handleRelay` aborts clean (slog.Warn + WS close 4001 + return; deferred `KillSessionCtx` reaps the half-owned ephemeral). Read scope is unchanged — `ListServers`/`/api/servers`/`IsGoTestServerName` still surface foreign servers (incl. `rk-e2e-*`) in the UI; only the destructive reap is scoped (single-uid socket model means EPERM is not an expected owner state). `TmuxOps` interface + `mockTmuxOps` gain `SetSessionOwnerPID`. Test-harness leaks closed too: `scripts/test-e2e.sh` trap + `global-teardown.ts` now glob all `rk-e2e*` sockets (not just literal `rk-e2e`) so timestamp-named secondaries don't leak on crash/Ctrl-C; per-package `TestMain` (`internal/tmux`, `api`) pre-sweeps `rk-test-*`/`rk-relay-test-*` sockets whose embedded PID (2nd hyphen field) is dead, self-healing SIGKILL/panic residue while sparing live-PID and fixed-name (`rk-daemon-test`/`rk-tmuxctl-test`) sockets. | `260529-wtg4-isolate-relay-sweep-test-leaks` |
| 2026-05-29 | Window addressing migrated from mutable index to stable window ID (`@N`). New § Window Addressing Identity documents the cross-layer contract: window-targeting tmux commands pass the windowID directly as `-t` (no `session:index` string); the index is retained only for positional reorder/move; sessions are still name-identified. `internal/tmux` window methods take `windowID string` — `SelectWindow`/`RenameWindow`/`SendKeys`/`KillWindow`/`SplitWindow`/`KillActivePane`/`SetWindowColor`/`SetWindowOption` etc. New `ResolveWindowSession(ctx, server, windowID)` (`display-message -p '#{session_name}'`) and `resolveWindowSessionIndex` (adds `#{window_index}` for positional ops). `MoveWindow(windowID, dstIndex, server)` resolves source index then bubble-swaps via adjacent `swap-window`; `MoveWindowToSession(windowID, dstSession, server)` runs `move-window -s <windowID> -t <dstSession>:` — both preserve the window's `@N` (tmux contract, same as the boards pin-stability invariant). WS relay route `/relay/{windowId}` resolves the owning session from the ID before building the grouped ephemeral (malformed → 400 pre-upgrade; unknown → close 4004); grouped-session mechanism otherwise unchanged. HTTP window-targeting routes moved to `/api/windows/{windowId}/...` (create stays session-scoped); new `validate.ValidateWindowID` (`^@[0-9]+$`). `internal/sessions` pane-map enrichment + `ProjectRoot` re-keyed to windowID. Frontend `api/client.ts` window-targeting fns take `windowId` as the 2nd positional arg (server first) per the routing contract. | `260529-chgz-window-id-routing` |
| 2026-05-30 | Window-addressing follow-ons from the API-stability remediation. **Select scoping**: the REST `/select` handler now resolves the owning session via `ResolveWindowSession` and issues a session-scoped `SelectWindowInSession` (`select-window -t <session>:@N`) instead of the group-ambiguous bare `SelectWindow(@N)`; the relay was already scoped to its ephemeral. `SelectWindow` (bare target) retains no production callers. **Route shape**: `$session` dropped from the URL (`/$server/$session/$window` → `/$server/$window`) — `@N` is the only window identity in the URL; the owning session is derived from the SSE snapshot. **`MoveWindow` atomic**: the adjacent-`swap-window` bubble sequence now executes as one `\;`-chained invocation (source index resolved once) so no concurrent mutation can interleave mid-reorder. **Option setters**: `@color`/`@rk_type`/`@rk_url` are now set through the batched `tmux.SetWindowOptions` / `appendOptionOps` chained primitive (one atomic invocation) behind the unified `POST /api/windows/{windowId}/options`; `@rk_session_order` / theme / server-color HTTP routes migrated PUT→POST (§IX). | `260529-jad6-window-api-stability` |
| 2026-05-31 | **Test-scoped server enumeration — env-gated `RK_SERVER_ALLOWLIST` allowlist in `ListServers`.** New const `tmux.ServerAllowlistEnv = "RK_SERVER_ALLOWLIST"` + pure predicate `matchesServerAllowlist(name, allowlist)` (`internal/tmux/tmux.go`). `ListServers` reads the env directly via `os.Getenv` in-package (matching the `RK_TMUX_CONF`/`OriginalTMUX` precedent — NOT via `internal/config`; `ListServers` is a `ctx`-only free fn) and, when set, narrows the post-probe live-server list to names that `HasPrefix` ANY trimmed, non-empty comma-delimited token. Empty/whitespace-only value = treated as UNSET = admits everything (production no-op, byte-for-byte). Prefix (not exact) because multi-server e2e specs create `rk-test-e2e-<role>-<pid>-<epoch>` secondaries in `beforeAll` that exact match would exclude; `rk-test-relay-*` under the broader `rk-test-` umbrella is NOT admitted. Filter lives in `ListServers` (not `handleServersList`) so ALL enumeration consumers inherit the scope — `/api/servers`, `board.go` `ListAllBoardEntries`/`GetBoard`, the `serve_sweep.go` relay-orphan sweep; the `tmuxctl` supervisor is unaffected (uses `os.ReadDir` + `isTmuxSocketCandidate`, never `ListServers`). The new forward allowlist (hides NORMAL servers from tests) is orthogonal to and does not touch the existing `IsTestServerName` denylist (hides TEST servers from normal operation). `scripts/test-e2e.sh` exports `RK_SERVER_ALLOWLIST=$E2E_TMUX_SERVER` into the `setsid … just dev` backend launch — scoping the backend READ path, distinct from `E2E_TMUX_SERVER` (the WRITE socket). Hermetic unit test `TestMatchesServerAllowlist`. **Why:** bounds board-route SSE to one `EventSource` per test server, removing the load-dependent HTTP/1.1 connection-pool starvation (N live operator servers → N EventSources → N of 6 slots) that made board e2e specs flaky only on busy boxes — the environmental third vector of the board-route hang. No prod behavior change (env unset). | `260531-tmnm-test-scoped-server-enumeration` |
| 2026-05-30 | **Unified test-socket reaping.** All test tmux-socket names (Go + Playwright) collapsed under one umbrella `rk-test-<role>-<pid>-<ns>` (roles `unit`/`relay`/`tmuxctl`/`daemon`/`e2e`/`e2e-multi`/`e2e-coupling`/`e2e-msb`), replacing the old 5-prefix scheme (`rk-test-`, `rk-relay-test-`, `rk-verify-`, fixed `rk-tmuxctl-test`/`rk-daemon-test`) + the separate `rk-e2e` exclusion. `IsGoTestServerName` (5-prefix allowlist) **deleted** → `IsTestServerName(name) = HasPrefix("rk-test-")`, consumed only by the tmuxctl supervisor resurrection guard. `parseTestSocketPID` now parses the PID as the **second-to-last** hyphen field (was the field right after the prefix) so hyphenated roles parse; `<ns>` is a single hyphen-free token. `TestMain` sweep flipped **pre-sweep → post-sweep** (`code := m.Run(); sweepDeadTestSockets(); os.Exit(code)`) in `internal/tmux` + `api`; still PID-scoped to dead owners only (concurrent `go test` packages spared), pre-sweep dropped. Manual `rk reaper` rewritten **brute-force-by-prefix** (bare ≡ `--prefix rk-test`): no liveness probe / e2e exclusion / `.lock`-inheritance; **dry-run is the default** (both bare and `--prefix`), `--yes`/`--force` to act, only `--force` bypasses the dangerous-prefix guard (empty or ≤3 chars refused); `_rk-ctl` + live `rk-daemon` hard-skipped unconditionally. Operating contract: do NOT run `rk reaper` while tests are running (no live-run protection by design). `/api/servers` test-socket **hide filter deleted** — it now lists every server, incl. leaked `rk-test-*` orphans (accepted cost: one SSE stream per orphan until reaped). E2E (Playwright) socket names embed `process.pid` (was a `Date.now().slice(-6)` epoch); harness server is `rk-test-e2e` (was `rk-e2e`) in `scripts/test-e2e.sh`, `global-teardown.ts`, the `just pw` default, and all specs. **Separate bug fixed (constitution VI):** `scripts/test-e2e.sh` used `kill 0` in its EXIT-trap cleanup, signalling the caller's (non-detached) process group — SIGTERMing live tmux servers / `-CC` clients sharing it when run inline in an interactive/agent session (proven root cause of `kit`/`abbb`/`runWork` dying mid-session, distinct from `exit-empty`). Fixed: dev server launched via `setsid` into its own group; cleanup kills only `kill -- "-$DEV_PGID"`. | `260530-cf3g-unify-test-socket-reaping` |
| 2026-06-09 | **`ResolveWindowSession` restored to O(1) targeted lookup.** The body went back from the O(n) `list-windows -a -F '#{session_name}\t#{window_id}'` enumerate + first-match scan to a single targeted `display-message -t <windowID> -p '#{session_name}'` call via `tmuxExecServer` (mirroring `resolveWindowSessionIndex`). The enumeration scan was a #205 workaround for a session-group ambiguity (a window appeared under every group member, so `display-message -t @N` could resolve to an `rk-relay-*` ephemeral); `260602-qn62`'s move-based one-session-per-window model removed window sharing and the ephemerals, so the targeted lookup is now unambiguous — no `RelaySessionPrefix` filtering. Signature `(ctx, server, windowID) (string, error)` and the `fmt.Errorf("window %q not found", windowID)` not-found contract are unchanged: a tmux non-zero exit (missing `-t @N`) and an empty result both map to not-found, so the relay's "any error ⇒ close 4004" path is preserved. Fixes a relay-connect latency regression — the O(n) scan could approach the 5s connect-time budget on busy servers (many sessions + `_rk-pin-*` + `_rk-ctl`), spuriously closing healthy windows with 4004. The 5s relay timeout was intentionally left unchanged (the algorithm, not the duration, was the root cause). Scope was `tmux.go` + a `tmux_test.go` not-found-branch test only; `relay.go`/`windows.go` call sites untouched. See [[resolve-window-session-on-relay-connect]]. | `260609-enic-restore-display-message-resolve-window` |
| 2026-06-02 | **`exit-empty` server-death prevention — always-on `_rk-ctl` anchor floor + imperative `exit-empty off`.** Fixes the *other* recurring whole-server-death cause (distinct from the `260530-cf3g` `kill 0` grenade): tmux's default `exit-empty on` reaping a server the instant its session count hits zero. Root cause was `tmuxctl.resolveBootstrap` creating the `_rk-ctl` anchor **only** when a server was empty at first control-mode connect — so a server that already had real sessions got no floor, and when its last real session later closed (only `rk-relay-*` ephemerals left) the next relay disconnect drained it to zero and tmux reaped the whole server (no run-kit `kill-server`; killed live agent sessions; ≥3× — runWork/utils/kit). `resolveBootstrap` now **always** creates the anchor as a permanent session floor, decoupled from the control-mode **attach target** (still prefers the first real session; `firstSessionName` now skips `_rk-ctl` so the always-present anchor — which sorts ahead of lowercase names — isn't picked; `%session-window-changed` is global so attach-target choice is a minimal-diff preference, not a correctness need). New `tmux.SetExitEmptyOff(ctx, server)` (`set-option -g exit-empty off`, mirrors `tmuxExecServer`/`serverArgs`, ctx+`TmuxTimeout`) is called in `tmuxctl.productionDial` **BEFORE** `resolveBootstrap`/anchor creation, on the initial dial **and** every reconnect — ordering is load-bearing: it closes the reapable zero-session sliver during the close-then-reopen restart window before the floor is reinstalled. The embedded `configs/tmux/default.conf` also `set -g exit-empty off` for run-kit-created servers (belt-and-suspenders; imperative set covers hand-created/foreign servers the `-f` conf never reaches). Server-lifetime contract is now **explicit-kill-only**: a server dies only via `kill-server`/`rk reaper`; empty (anchor-only) servers persist by design (no auto-reap, no cross-process refcounting — §II). Concurrent multi-`rk serve` anchor creation stays benign via `isDuplicateSessionError` (now made actually-reachable + correct: `createAnchor` captures stderr since `cmd.Run()` doesn't populate `ExitError.Stderr`, and the classifier matches both the wrapped message and `ee.Stderr`). Test/e2e sockets are still excluded by `isTmuxSocketCandidate`→`IsTestServerName` so the always-create path can't resurrect them. The `sse.go` "real session disappeared" WARN is kept as defense-in-depth (covers external `kill-session`/OOM/shell-exit that prevention can't). | `260602-a1wo-prevent-exit-empty-server-death` |
| 2026-06-02 | **Move-based server-scoped boards (pin sessions) — relay ephemerals removed.** Boards switched from a per-WebSocket `rk-relay-*` ephemeral isolation layer + `@rk_board` server-option comma/colon encoding to a **move-based pin-session model**. New `tmux.PinSessionPrefix = "_rk-pin-"` + reversible `PinSessionName`/`WindowIDFromPinSession` helpers (strip/restore the `@`) + `ListPinSessionNames`. A pinned window is physically `move-window`'d into its own single-window session `_rk-pin-<id>`; a board = the set of pin-sessions sharing an `@rk_board` SESSION var (server-scoped — `move-window` can't cross servers), with `@rk_home` (restore target) and `@rk_board_order` (fractional `ComputeOrderKey`). `Pin` stamps the 3 vars after the move with `context.Background()`-rooted rollback on partial failure (double-fault guard: kill the pin-session only if move-back succeeded); idempotent incl. wrong-board re-stamp. `Unpin` restores to `@rk_home` (recreating a dead home via `rename-session`, no placeholder) then kills the pin-session; multi-unpin appends. `board.go` rewritten: kept `ComputeOrderKey`/`nextAppendKey`/`initialAppendKey`/validators/`BoardOption` (repurposed as the `@rk_board` session-var key); **deleted** `parseBoardValue`/`serializeBoardValue`/`setBoardValue`/`ListAllBoardEntries`/`RemoveAllByWindowID`/the server-option encoding. `ListBoardEntries`/`ListBoards`/`GetBoard` now derive from pin-sessions (no stale write-back). **Relay** (`api/relay.go`) attaches the PTY DIRECTLY to the `ResolveWindowSession` result (home or `_rk-pin-*`) — removed `newEphemeralRelayName`/`NewGroupedSession`/`SetSessionOwnerPID`/ephemeral-`SelectWindowInSession`/ephemeral-`KillSessionCtx`; `ResolveWindowSession` no longer filters `rk-relay-*`. **Deleted**: `cmd/rk/serve_sweep.go` (whole file — `sweepOrphanedRelaySessions`/`pidAlive`/`relayOwnerIsDead`) + its `serve.go` wiring; `RelaySessionPrefix`/`OwnerPIDOption`/`NewGroupedSession`/`SetSessionOwnerPID`/`GetSessionOwnerPID`/`ListRawSessionNames` from `tmux.go`. `parseSessions` now skips `_rk-pin-*` + `_rk-ctl`, NO LONGER skips `rk-relay-*`. **SSE** (`api/sse.go`): dropped the window-kill `board-changed {cleanup}` diff + `RemoveAllByWindowID` dep, `detectKilledWindowIDs`/`previousWindowIDs`, and the `broadcastBoardBootstrap`/`previousBoardJSON` first-poll bootstrap; only explicit pin/unpin/reorder `board-changed` events remain. `TmuxOps` lost `NewGroupedSession`/`SetSessionOwnerPID`. Accepted tradeoffs: multi-client active-window collisions on a shared home session; a pinned window disappears from its home session's sidebar until unpinned. Pins persist across rk restarts → NO restore-sweep. `_rk-ctl` anchor + `exit-empty off` backstop UNCHANGED (pin-session persistence relies on `exit-empty off`); `tmuxctl/` active-window derivation left untouched (investigate-only). Frontend `boards.ts`/`use-boards.ts` doc/contract updated to server-scoped pin-session derivation; response shape + component structure unchanged. | `260602-qn62-move-based-board-pin-sessions` |
