---
description: System architecture, component responsibilities, data flow — backend libraries (incl. in-memory metrics + prstatus collectors), API endpoints, SSE hub + PR-status join, tmux/sessions enrichment, control-mode subscription
---
# run-kit Architecture

## System Overview

run-kit is a web-based agent orchestration dashboard. In production, a single Go binary runs as a daemon in a dedicated tmux session:

1. **Go backend** (`app/backend/`, default port 3000) — single binary serving REST API, SSE, WebSocket terminal relay, and SPA static files on one port. Cobra CLI with subcommands: `serve` (default, with `-d`/`--restart`/`--stop` daemon flags), `update` (alias: `upgrade`), `doctor`, `status`, `context`, `init-conf`, `riff`. Version info via `--version`/`-v` global flag (Cobra built-in)

In development, `just dev` runs two concurrent processes:
- Vite dev server (`:RK_PORT`, default 3000) — HMR, proxies `/api/*`, `/relay/*`, and `/proxy/*` to Go backend
- Go backend (`:RK_PORT+1`, default 3001) — API, WebSocket relay, SPA static serving

Configuration via env vars: `.env` (committed) defines `RK_PORT` and `RK_HOST`, `.env.local` (gitignored) for overrides. Go backend and Vite both read `RK_PORT`/`RK_HOST` directly — no intermediate `BACKEND_*` vars. In dev mode, `dev.sh` is the translation layer: it passes `RK_PORT+1` to the Go backend and `RK_PORT` to Vite. `dev.sh` accepts `--port` for ad-hoc overrides. Tmux config defaults to `~/.rk/tmux.conf` (scaffolded by `rk init-conf`), overridable via `RK_TMUX_CONF` env var. The config is embedded in the binary via `go:embed` and written to disk by `init-conf`. All consumers use `tmux.ConfigPath()` getter — no direct env var reads elsewhere. User extensions go in `~/.rk/tmux.d/*.conf` — a drop-in directory sourced via `source-file -q` at the end of the default config. Files load in lexicographic order (numeric prefixes control precedence). The `-q` flag silences errors when the directory is empty or missing.

run-kit connects to **one tmux server at a time**, determined by the URL path segment. The URL hierarchy is `/$server/$window` (since `260529-jad6-window-api-stability`) — the server name is always in the URL, making URLs fully shareable and self-contained. The window segment is the stable tmux **window ID** (`@N`, since `260529-chgz-window-id-routing`), not the mutable window index. `$session` was **dropped from the route shape** by `260529-jad6` — `@N` is server-global so the session is redundant in the URL; it is derived server-side / from the active window's SSE snapshot wherever a session name is needed for display. Old 3-segment `/$server/$session/$window` URLs are a hard break (no redirect shim). The active server is sent as a `?server=` query parameter on every API request (SSE, REST, WebSocket) by the frontend's `withServer()` helper. The backend is stateless — it defaults to the `default` tmux server when the parameter is absent. Server discovery scans `/tmp/tmux-{uid}/` for socket files. The frontend persists the last-used server in localStorage (`runkit-server`) as a convenience for the server list page, but the URL is always the source of truth. Server lifecycle: create (via server list page or command palette), kill (`tmux kill-server`), switch (navigate to `/$newserver`).

The pane-boards view (`/board/$name`) is a deliberate exception: it is a root-level peer to `/$server` (NOT under `/$server/...`) because boards aggregate windows across multiple tmux servers. It does not render the server-scoped AppShell — see `## Boards Feature` below.

## Repository Structure

Repository layout:

```
app/
  backend/            # Go module — backend
    cmd/rk/           # Cobra CLI entry point
      main.go         # Calls execute()
      root.go         # Root command, version var, --version flag, subcommand registration
      serve.go        # serve subcommand — HTTP server (default when no args)
      upgrade.go      # update subcommand (alias: upgrade) — Homebrew or local update
      doctor.go       # doctor subcommand — runtime dependency checks
      status.go       # status subcommand — tmux session summary
    internal/         # validate, config, tmux, sessions, settings, metrics
    api/              # HTTP handlers — one file per resource domain
      router.go       # chi router, CORS/logger/recovery middleware, route registration
      health.go       # GET /api/health
      sessions.go     # GET /api/sessions, POST /api/sessions, POST .../kill
      windows.go      # create (session-scoped) + window-targeting handlers keyed by /api/windows/{windowId}
      directories.go  # GET /api/directories
      upload.go       # POST /api/sessions/:session/upload
      sse.go          # GET /api/sessions/stream (hub singleton)
      relay.go        # WS /relay/{windowId} (resolves owning session via display-message)
      proxy.go        # /proxy/{port}/* — reverse proxy for iframe windows
      tmux_config.go  # POST /api/tmux/reload-config
      servers.go      # GET /api/servers, POST /api/servers, POST /api/servers/kill
      keybindings.go  # GET /api/keybindings (curated tmux keybindings via list-keys + whitelist)
      settings.go     # GET/POST /api/settings/theme, GET/POST /api/settings/server-color
      spa.go          # SPA static serving — dual-mode (embedded FS or filesystem)
    frontend/         # Embedded frontend assets
      embed.go        # //go:embed all:dist → embed.FS
      dist/           # Build output copied here at build time (.gitkeep for dev)
    go.mod, go.sum
  frontend/           # Vite + React SPA — single-view UI
config/
  tmux.conf           # tmux config for the runkit server (status bar, keybindings, sources tmux.d/*.conf)
VERSION             # Semver source of truth (e.g., 0.1.0) — injected via ldflags
scripts/
  build.sh          # Production build: frontend → copy dist → copy tmux.conf → go build with ldflags
  release.sh        # Bump VERSION, commit, tag v{version}, push (triggers CI)
.github/
  workflows/
    release.yml     # CI: v* tag → cross-compile 4 targets → GitHub Release with tarballs → update Homebrew tap
  formula-template.rb # Homebrew formula template with placeholders (version, SHA256s)
fab/                # Fab-kit project config + changes
docs/               # Memory files
justfile            # Task runner (dev, verify, test, build, release commands)
```

## Data Model

**No database.** State derived at request time from:
- **tmux server** — `tmux list-sessions`, `tmux list-windows` via `internal/tmux/tmux.go`. Project roots derived from window 0's `pane_current_path`. Read-path functions (`ListSessions`, `ListWindows`, `ListServers`) accept `context.Context` from HTTP handlers for request cancellation propagation. Mutation functions (Create/Kill/Rename) use `context.Background()` — user-initiated actions complete regardless of client disconnect
- **`run-kit.yaml`** — per-project config file at the git root of a session's working directory. Currently stores `session_color: N` (ANSI palette index 0-15). Read best-effort by `config.ReadSessionColor()` — missing file or missing key means no color, never an error. Written by `config.WriteSessionColor()` — clearing removes the key (deletes the file if it becomes empty, preserves other keys if present). Git root resolved via `config.FindGitRoot()` (walks up from `pane_current_path` looking for `.git`). Gitignored by convention. Not server config (env vars handle that per constitution) — this is per-project user preferences
- **tmux `@color` user option** — per-window color stored as a tmux user option (`#{@color}` in `ListWindows()` format string). Ephemeral — survives session lifetime but not tmux server restarts. Set via `tmux set-option -w -t {windowID} @color {N}` (window ID is a self-contained `-t` target — no `session:index` string), cleared via `tmux set-option -wu`. Parsed into `WindowInfo.Color *int` (nil when unset)
- **tmux `@rk_session_order` server-scoped user option** — sidebar session render order stored as a JSON-encoded array of session names on the tmux *server* (not per-window). Survives the same lifetime as the tmux server (lost on tmux server death, NOT on rk-go restart per Constitution VI). Read by `tmux.GetSessionOrder(ctx, server)` — treats unset / no-server / `failed to connect` as empty slice; surfaces other errors (including JSON decode) as wrapped Go errors. Written by `tmux.SetSessionOrder(ctx, server, order)` — `nil` slice normalized to `[]` so round-trips are lossless. Both functions go through `tmuxExecRawServer` with `context.WithTimeout(ctx, TmuxTimeout)` (10s). The constant `tmux.SessionOrderOption = "@rk_session_order"` is the canonical option name
- **fab pane map** — `internal/sessions` invokes the `fab` router on PATH via `exec.CommandContext(ctx, "fab", "pane", "map", "--json", "--all-sessions")` with `cmd.Dir = repoRoot` so the router can resolve the project's `fab_version` from `fab/project/config.yaml`. Returns per-window fab state (change name, pipeline stage, agent state, idle duration). `fab pane map` emits only `session` + `window_index` per entry, so `dedupEntries` collapses split-pane collisions into a `session:index`-keyed map first (priority Change > AgentState > first-seen — a change-bound pane always wins, otherwise a pane with populated `AgentState` wins, otherwise the first-seen entry is preserved). `FetchSessions` then re-keys the enrichment by the window's stable **window ID** (`@N`) before joining: it builds an `index→windowID` translation from the live `WindowInfo` snapshot (`enrichByWindowID[window.WindowID] = entry`) so the join is index-shift-safe — a reorder that swaps indices 0↔1 never misattributes enrichment (since `260529-chgz-window-id-routing`). After the fab-kit v1.5.0 `_agents` schema refactor, `agentState`/`agentIdleDuration` populate independently of `fabChange`/`fabStage`, so discussion-mode agent panes (populated `AgentState`, nil `Change`) win over bare sibling panes in split windows. Results cached at package level with 5s TTL (`fetchPaneMapCached`) — double-check pattern prevents thundering herd, stale cache preserved on fetch error for graceful degradation

### Performance Caching

Two in-memory caches reduce subprocess spawning on the SSE hot path (justified per code-quality.md performance carve-out — not general-purpose caching):

| Cache | Location | TTL | Key | Purpose |
|-------|----------|-----|-----|---------|
| SSE session cache | `sseHub.cache` (`api/sse.go`) | 500ms | server name | Skip `FetchSessions()` subprocess calls when data is fresh |
| Pane-map cache | package-level (`internal/sessions`) | 5s | (single entry) | Skip `fab pane map` subprocess calls between SSE ticks |

Both caches use `sync.RWMutex` for thread safety. The SSE hub's existing JSON diff-check (`previousJSON`) remains as a separate concern — the session cache prevents redundant subprocess spawning, while the diff prevents redundant SSE broadcasts

## Backend Libraries (Go Modules)

Packages in `app/backend/internal/`:

| Package | Responsibility |
|---------|---------------|
| `internal/tmux` | All tmux operations via `os/exec.CommandContext` with argument slices + `context.WithTimeout` (10s). Commands target the dedicated `runkit` server via `-L runkit` prefix (built by `runkitPrefix()`); config path defaults to `~/.rk/tmux.conf`, overridable via `RK_TMUX_CONF` env var, resolved to absolute path at init. `ConfigPath()` getter exposes the resolved path. `OriginalTMUX` package-level var captures `$TMUX` before `init()` strips it (via `os.Unsetenv("TMUX")`) — used by `cmd/rk/context.go` to restore `$TMUX` in child process environments when querying the pane's own tmux server. `DefaultConfigBytes()` returns the embedded `internal/tmux/tmux.conf` content (via `go:embed`). Read-path functions accept `context.Context` for request cancellation: `ListSessions(ctx, server)`, `ListWindows(ctx, session, server)`, `ListServers(ctx)`. `ListServers` probes sockets in parallel using a bounded goroutine pool (cap 10, semaphore channel) — N dead sockets cost ~2s instead of 2*N seconds. Mutation functions (`CreateSession`, `KillSession`, `RenameSession`, `CreateWindow`, `CreateWindowWithOptions`, `KillWindow`, `MoveWindow`, `MoveWindowToSession`, `RenameWindow`, `SendKeys`, `SelectWindow`, `SelectWindowInSession`, `SplitWindow`, `KillActivePane`, `SetWindowColor`, `UnsetWindowColor`, `SetWindowOptions`) use `withTimeout()` / `context.Background()`. `KillPane(paneID)` was **removed** by `260529-jad6-window-api-stability` — it had zero call sites (the only pane id the API produces, `/split`'s `pane_id`, is never sent to a kill endpoint; `/close-pane` kills via the window id through `KillActivePane`), so it was dropped from `tmux.go`, the `TmuxOps` interface, the `prodTmuxOps` wrapper, and the mock to tighten the interface surface (§IV). `MoveWindow` now emits its full bubble-swap sequence as a **single `\;`-chained `tmuxExecServer` invocation** (resolving the source index exactly once via `resolveWindowSessionIndex`) so no other mutation can interleave mid-reorder — concurrent kill/move observes only the pre- or post-reorder layout, never a partially-swapped intermediate (the former per-step loop of separate `swap-window` subprocesses was replaced; the former standalone `SwapWindow` wrapper had already been folded in when reorder migrated to window-ID source addressing). Insert-before / sentinel / single-step / no-op semantics are unchanged; the destination remains a positional index. `ListSessions(ctx, server)` queries the specified tmux server, returning `SessionInfo` structs with a `Name` field. `ReloadConfig(server)` hot-reloads the tmux config via `source-file` on the specified server. `ListWindows()` includes `isActiveWindow` flag from `#{window_active}`, `PaneCommand` from `#{pane_current_command}`, and raw `ActivityTimestamp` from `#{window_activity}`. After calling `list-windows`, `ListWindows()` issues a second `list-panes -s -t <session>` call to populate `Panes []PaneInfo` on each `WindowInfo`; pane failure is non-fatal (windows returned with empty `Panes`). `WorktreePath` continues to be sourced from `#{pane_current_path}` in the `list-windows` format string — `list-panes` is additive. `PaneInfo` struct: `PaneID string json:"paneId"`, `PaneIndex int json:"paneIndex"`, `Cwd string json:"cwd"`, `Command string json:"command"`, `IsActive bool json:"isActive"`. Package-level `paneFormat` var joins `#{window_index}`, `#{pane_id}`, `#{pane_index}`, `#{pane_current_path}`, `#{pane_current_command}`, `#{pane_active}` with `listDelim`. `parsePanes(lines []string) map[int][]PaneInfo` parses 6-field tab-delimited `list-panes` output, grouping by window index (field 0); malformed lines silently skipped; returns nil on empty/all-malformed input. `WindowInfo` struct uses `FabChange`/`FabStage` fields, plus `AgentState`/`AgentIdleDuration` (populated by pane-map enrichment in sessions package), plus `Color *int json:"color,omitempty"` (ANSI palette index from tmux `@color` user option, nil when unset), plus `Panes []PaneInfo json:"panes,omitempty"`, plus `RkType string json:"rkType,omitempty"` and `RkUrl string json:"rkUrl,omitempty"` for iframe window metadata (populated from tmux user-defined options `@rk_type` and `@rk_url` via `ListWindows` format string), plus six PR-status fields (`260610-596o-pr-status-sidebar`): `PrURL *string json:"prUrl,omitempty"` and `PrNumber *int json:"prNumber,omitempty"` (from `fab pane map` via the sessions pane-map enrichment — filesystem, cheap), and `PrState string json:"prState,omitempty"` / `PrChecks string json:"prChecks,omitempty"` / `PrReview string json:"prReview,omitempty"` / `PrIsDraft bool json:"prIsDraft,omitempty"` (attached at SSE-assembly time by the hub from the `internal/prstatus` collector snapshot, only for change-bound windows — see § PR-Status SSE Join). Both PR layers are populated outside this package; `tmux.go` only declares the fields. `EnsureConfig()` writes the embedded default config to `~/.rk/tmux.conf` if absent and creates `~/.rk/tmux.d/` (called at serve startup; directory creation runs even when config already exists). `ListKeys(server)` runs `tmux list-keys` on a server and returns raw output lines (returns nil on "no server running"). The `-f configPath` flag is scoped to `CreateSession` and `ReloadConfig` only via `configArgs()` — not passed on every command. Both `tmuxExec` and `tmuxExecDefault` capture stderr in error messages for diagnostics. Window-*targeting* functions take a `windowID string` (`@N`) passed directly as the `-t` target — `KillWindow(windowID, server)`, `RenameWindow(windowID, name, server)`, `SelectWindow(windowID, server)`, `SelectWindowInSession(session, windowID, server)`, `SplitWindow(windowID, horizontal, cwd, server)`, `SendKeys(windowID, keys, server)`, `KillActivePane(windowID, server)`, `SetWindowColor`/`UnsetWindowColor(windowID, …)`, `SetWindowOption(ctx, windowID, server, option, value)`/`UnsetWindowOption(ctx, windowID, server, option)` — no `session:index` target string is constructed (since `260529-chgz-window-id-routing`). `SelectWindowInSession` is the *session-scoped* select (`select-window -t <session>:@N`) used by both the relay (scoped to the resolved real owning session since `260602-qn62` — the per-WebSocket ephemeral it previously scoped to is gone) and, since `260529-jad6`, the REST `/select` handler — a bare `select-window -t @N` is ambiguous inside a tmux session group (members share window membership but keep independent active-window state) so the scoped form is mandatory for correctness. Positional ops keep an index destination: `MoveWindow(windowID, dstIndex, server)` resolves the source's current index from the ID (via `resolveWindowSessionIndex`) then chains the bubble-swap sequence to the target slot as one atomic `\;`-chained invocation; `MoveWindowToSession(windowID, dstSession, server)` runs `move-window -s <windowID> -t <dstSession>:`. `ResolveWindowSession(ctx, server, windowID)` runs a targeted `display-message -t <windowID> -p '#{session_name}'` (used by the relay, REST `/select`, and `ProjectRoot`). **Chained `set-option` primitive (since `260529-jad6`)**: `WindowOptionOp{Key string; Value *string}` is a single set-or-unset op (non-nil Value sets, nil unsets); `appendOptionOps(args, target, ops)` appends each op's `set-option -w [-u] [-t <target>] <key> [<value>]` argv, prefixing a `;` chain separator before all but the first appended op (when `target` is empty, the `-t` qualifier is omitted — used by `CreateWindowWithOptions`, where the preceding `new-window` already scopes the chained set-options to the new window). `SetWindowOptions(ctx, windowID, server, ops []WindowOptionOp)` runs the whole batch as a single `\;`-chained `tmuxExecServer` invocation (atomic — the SSE poll never observes a half-applied state; empty ops issue no tmux call). `CreateWindowWithOptions(session, name, cwd, server, ops []WindowOptionOp)` creates a window and atomically sets its user-defined options via that same `appendOptionOps` primitive in one chained command (prevents SSE from seeing the window before metadata is set; no separate inline option-map construction path). `ListWindows` format string includes `#{@color}` as field 8, `#{@rk_type}` and `#{@rk_url}` as fields 9-10; `parseWindows` extracts them (empty string when unset). **Active-window seam helpers (since `260530-v6hm-active-window-event-derivation`)**: `ListSessionGroups(ctx, server) (map[$sid]group, error)` runs `list-sessions -F '#{session_id}<delim>#{session_name}<delim>#{session_group}'` and parses via `parseSessionGroups` into a `$sid`→group map (ungrouped sessions, which report an empty `#{session_group}`, fall back to their own name as the group key); `rk-relay-*`/`_rk-ctl` are NOT filtered here — they must resolve to their base session's group so an event fired against an ephemeral updates the right user-facing group. `ListActiveWindowsByGroup(ctx, server) (map[group]@wid, error)` runs `list-windows -a -F '#{session_group}<delim>#{session_name}<delim>#{window_id}<delim>#{window_active}'` and parses via `parseActiveWindowsByGroup` into a group→active-`@wid` snapshot for the Tier-1 re-seed; because `list-windows -a` reports `window_active=1` for *every* group member, only the leader row (`session_name == session_group`) is honored per group (ungrouped: own name; leaderless group: first active row as best-effort). Both follow the `tmuxExecServer` + `context.WithTimeout(ctx, TmuxTimeout)` + pure-parse-split convention (matching `parseWindows`/`parsePanes`), return `nil` (no error) when the server is not running, and never mutate sessions (§VI) |
| `internal/sessions` | `FetchSessions(ctx, server, provider)` lists sessions and fetches windows for all of them in parallel for the requested tmux server `server` (passing `server` to `ListWindows`), then enriches with fab state and applies the two-tier active-window derivation. The `provider` (`ActiveWindowProvider` interface, satisfied by the tmuxctl `Supervisor`; injected via `prodSessionFetcher` in `api/router.go`) supplies the event-tracked active `@wid` per group — a `nil` provider degrades to exactly today's base-pointer behavior. The pure helper `applyActiveWindow(windows, trackedWid)` enforces the single-highlight invariant. Full design in § Active-Window Event Derivation (since `260530-v6hm-active-window-event-derivation`). `ProjectSession` struct has `Name`, `SessionColor *int json:"sessionColor,omitempty"`, and `Windows` fields. `SessionColor` is read from `run-kit.yaml` at the git root (via `config.FindGitRoot` + `config.ReadSessionColor`) during session assembly — nil when no config or no `session_color` key. Per-window enrichment model: pane-map returns per-pane fab state. `dedupEntries` builds a `session:index`-keyed map (because `fab pane map` emits only `session` + `window_index`), then `FetchSessions` translates each entry to the window's stable **window ID** via the live `WindowInfo` snapshot and joins by `WindowID` — so a reorder that shifts indices does not misattribute fab/agent state (since `260529-chgz-window-id-routing`). `ProjectRoot(ctx, windowID, server)` likewise identifies the target window by `WindowID` (resolving the owning session via `tmux.ResolveWindowSession` first). `paneMapEntry` struct uses `*string` for nullable JSON fields (change, stage, agent_state, agent_idle_duration), plus `PrURL *string` (`json:"pr_url"`) and `PrNumber *int` (`json:"pr_number"`) sourced from `fab pane map` (released in fab 2.1.2) — these are assigned onto the window in the same `enrichByWindowID` join (`sd.windows[j].PrURL = entry.PrURL`, `.PrNumber = entry.PrNumber`) alongside the change/stage/agent fields, so they are index-shift-safe by the same WindowID keying (`260610-596o-pr-status-sidebar`). `fetchPaneMapCached(server, repoRoot)` wraps `fetchPaneMap(server, repoRoot)` with 5s TTL cache (package-level `sync.RWMutex`, double-check pattern). On cache miss, calls `fetchPaneMap(server, repoRoot)` which invokes the `fab` router on PATH (`fab pane map --json --all-sessions`) with `cmd.Dir = repoRoot` and a 10s context timeout; the router resolves the project's `fab_version` from `fab/project/config.yaml`. The extracted `dedupEntries(entries) map[string]paneMapEntry` helper collapses split-pane collisions with priority Change > AgentState > first-seen, so discussion-mode windows (agent running with no active change) can populate `AgentState` independently of `Change` and still win over bare sibling panes after the fab-kit v1.5.0 `_agents` schema refactor. `findRepoRoot(dir)` walks up from `dir` looking for `fab/project/config.yaml` (the fab-project identity marker, per `fab preflight`) and returns that directory, or `""` if not found. Stale cache entry preserved on fetch error for graceful degradation; if pane-map fails on cold start, all windows get empty fab fields |
| `internal/validate` | Input validation for names/paths + tilde expansion with `$HOME` security boundary + filename sanitization for uploads |
| `internal/config` | Server config (port, host) — reads `RK_PORT` and `RK_HOST` env vars with defaults (3000, 127.0.0.1). Also provides `run-kit.yaml` operations via `internal/config/runkit_yaml.go`: `FindGitRoot(dir string) string` walks up from dir to find `.git` (returns `""` if not found), `ReadSessionColor(projectRoot string) *int` reads `session_color` from `run-kit.yaml` (best-effort, nil on any error), `WriteSessionColor(projectRoot string, color *int) error` writes/clears the key (deletes file if empty, preserves other keys). Uses simple line-by-line YAML parsing via `splitYAMLLine` — no yaml.v3 dependency |
| `internal/daemon` | Daemon lifecycle management — `IsRunning()`, `Start()`, `Stop()`, `Restart()`, plus `StartWithBinary(binPath)` / `RestartWithBinary(binPath)` (used by `cmd/rk/upgrade.go` after `brew upgrade` when the running process's `os.Executable()` path is stale), plus `InnerServePID() (int, error)` returning the inner serve's PID via tmux `pane_pid` (single `tmux -L rk-daemon list-panes -t =rk-daemon:=serve -F '#{pane_pid}'` query through the new `runTmuxOutput` helper; consumed by `cmd/rk/daemon*.go` for `--force` self-recognition and by `rk daemon status` for port-owner classification). Manages `rk serve` in a dedicated tmux server (socket `rk-daemon`, session `rk-daemon`, legacy session `rk` consulted by `Stop`/`Restart` for transparent upgrade, window `serve`). All tmux calls go through `runTmux`/`runTmuxOutput` which use `exec.CommandContext` with a 5s `cmdTimeout` per command (each probe/C-c send/kill gets its own fresh context) and prepend `-L rk-daemon`. `Stop()`'s overall wait for graceful shutdown is NOT bounded by that per-command 5s — it uses a separate, independent `stopGracePeriod` (12s, a package `var` test seam) grace-deadline `time.NewTimer`, sized to exceed the inner serve's ~10s worst-case shutdown (supervisor stop 5s + `server.Shutdown` 5s, sequential in `cmd/rk/serve.go`); each liveness poll within that wait runs under its own fresh `cmdTimeout` context via the `sessionExists` helper, so no probe inherits a near-expired deadline. A session that has already vanished by kill-time (C-c worked, exit just took longer than the poll budget) is treated as a successful stop (`Stop()` returns nil after a fresh-context re-probe, and re-confirms after a `kill-session` error) — only a session still alive after a failed kill surfaces an error. Added port-based liveness probe via `net.DialTimeout` (400ms, with loopback substitution for wildcard hosts) and stale-socket reaping via `runTmux(ctx, "kill-server")` before session creation; supports daemon startup logging tee via `RK_DAEMON_LOG` env var (exported as `daemon.LogEnvVar`). Used by `cmd/rk/daemon*.go` (CLI surface), `cmd/rk/serve.go` (`LogEnvVar` consumption in `setupSlog`), and `cmd/rk/upgrade.go` (auto-restart after update) |
| `internal/metrics` | Host-level system metrics collector reading Linux procfs. `Collector` struct with `sync.RWMutex`-protected `MetricsSnapshot`. `NewCollector(pollInterval)` creates collector, caches hostname via `os.Hostname()`, initializes 60-entry CPU ring buffer with zeros. `Start(ctx)` launches background poll goroutine (exits on `ctx.Done()`). `Snapshot()` returns a deep-copied `MetricsSnapshot` under `RLock`. Collects: CPU usage from `/proc/stat` (delta-based, ring buffer), memory from `/proc/meminfo` (`MemTotal` - `MemAvailable`), load from `/proc/loadavg`, disk via `syscall.Statfs("/")`, uptime from `/proc/uptime`. CPU core count from `/proc/stat` `cpu\d+` line count. All procfs readers return zero values on non-Linux (graceful degradation, no crash). `MetricsSnapshot` struct contains `Hostname`, `CPU` (samples `[]float64`, current, cores), `Memory` (used/total bytes), `Load` (avg1/5/15, cpus), `Disk` (used/total bytes), `UptimeSecs` |
| `internal/prstatus` | In-memory, periodically-refreshed cache of the current user's open PR statuses, modeled on `internal/metrics.Collector` (`260610-596o-pr-status-sidebar`). `Collector` holds `byNumber map[int]PRStatus` under a `sync.RWMutex` plus an `interval`. `NewCollector(interval)` (defaults `interval`, `ghExec`, `available` seams), `Start(ctx)` (runs `refresh` immediately to warm the cache, then ticks at `interval`; exits on `ctx.Done()`), `Snapshot()` (deep copy under `RLock` — callers read lock-free), `RefreshNow(ctx)` (on-demand, used by the POST endpoint; best-effort — errors swallowed), and the private `refresh(ctx)`. **One batched fetch**: `refresh` makes a SINGLE `gh api graphql` call querying `viewer.pullRequests(first: 100, states: OPEN, orderBy: UPDATED_AT DESC)` returning `number/url/state/isDraft/reviewDecision` + the latest commit's `statusCheckRollup.state` — one cross-repo call, O(1) in PR count. Runs through the injectable `ghExec func(ctx) ([]byte, error)` field (default `defaultGhExec`) via `exec.CommandContext` + explicit arg slice (`gh api graphql -f query=… -F limit=100`) + 10s `ghTimeout` (no shell string, no user input in argv — Constitution §I). The intake's literal `gh search prs --author @me` was REJECTED at apply time (it errors — `gh search prs` does not expose `statusCheckRollup`/`reviewDecision` JSON fields); GraphQL `viewer.pullRequests` is the implemented form serving the same one-call/O(1)/cross-repo/full-status intent. **gh-availability guard**: the injectable `available func(ctx) bool` field (default `ghAvailable`, which checks `exec.LookPath("gh")` then `gh auth status`) gates the fetch — gh absent OR unauthenticated is a silent no-op (last-good map kept), matching the `command -v rk` fail-silent posture. **Wholesale REPLACE = cleanup**: a successful refresh builds a fresh `map[int]PRStatus` and replaces `byNumber` wholesale under `Lock`; a PR that merged/closed (no longer `OPEN`) simply drops out next cycle — this IS the cleanup mechanism, so there is no eviction logic or window-lifecycle hook. **Stale-while-revalidate**: a `gh` call error (network blip) or a JSON parse failure leaves the last-good map untouched (mirrors `metrics.Collector` / `fetchPaneMapCached` degradation). **Enum collapse** (pure helpers): `mapState(state, isDraft)` → `open\|merged\|closed` (`MERGED`→merged, `CLOSED`→closed, else open — draft surfaced separately via `IsDraft`); `mapChecks(rollupState)` → `pass\|fail\|pending\|none` (`SUCCESS`→pass, `FAILURE`/`ERROR`→fail, `PENDING`/`EXPECTED`→pending, ""/unknown→none — GitHub's `statusCheckRollup.state` is already a pre-collapsed enum, so no per-check iteration); `mapReview(decision)` → `approved\|changes_requested\|review_required\|none`. `PRStatus` struct: `Number int`, `URL string`, `State string`, `IsDraft bool`, `Checks string`, `ReviewDecision string`, `FetchedAt time.Time`. No database, no disk, no tmux option — in-memory only (Constitution §II). Wired into the SSE hub in `api/router.go`; cadence const `prStatusPollInterval = 90 * time.Second` lives in `api/sse.go` |
| `internal/settings` | Global user settings persisted at `~/.rk/settings.yaml`. `Settings` struct with `Theme string` field. `Default()` returns `Settings{Theme: "system"}`. `Load()` reads the settings file, returns `Default()` if missing. `Save(s Settings)` writes the file, creating `~/.rk/` (mode 0755) if absent. Uses simple `key: value` text parsing (not yaml.v3) — one field, no heavy dependency needed |

### External Go Dependencies

| Module | Purpose |
|--------|---------|
| `github.com/go-chi/chi/v5` | HTTP router with middleware chaining (CORS, logging, recovery) |
| `github.com/go-chi/cors` | CORS middleware (permissive by default for multi-client API) |
| `github.com/gorilla/websocket` | WebSocket handling for terminal relay |
| `github.com/creack/pty` | PTY allocation (replaces node-pty, no native module compilation) |
| `github.com/spf13/cobra` | CLI framework — subcommand management (serve, update, doctor, status) + built-in `--version` flag |
| `gopkg.in/yaml.v3` | YAML parsing (legacy dependency — no longer imported after `internal/fab` removal, candidate for `go mod tidy`). `internal/settings` and `internal/config/runkit_yaml.go` both deliberately use simple text parsing to avoid re-importing this |

## API Layer

All endpoints served by the single Go binary on one port. POST-only mutations with path-based intent (no multiplexed action field). **Uniform HTTP verb (constitution §IX, enforced since `260529-jad6`)**: every mutating endpoint uses `POST`; there are zero `PUT`/`PATCH`/`DELETE` route registrations and the CORS `AllowedMethods` allowlist is exactly `[GET, POST, OPTIONS]`. `260529-jad6` migrated the five remaining `.Put(` routes to `POST`: `sessions/order`, `settings/theme`, `settings/server-color` (verb-only), plus `windows/{id}/url` and `windows/{id}/type` (folded into `POST /options`, removed rather than migrated). Endpoint semantics that would conventionally map to other verbs (e.g. partial update on `/options`) are expressed via the path and a documented body contract.

**Window addressing (since `260529-chgz-window-id-routing`)**: routes that act on a *specific existing window* are keyed by the stable tmux **window ID** under `/api/windows/{windowId}/...` (kill, move, move-to-session, rename, options, keys, select, split, close-pane). The `{windowId}` path param is validated via `validate.ValidateWindowID` (`^@[0-9]+$`) by the `parseWindowID(r) (string, bool)` helper in `api/windows.go`, which delegates to the shared `decodeWindowID(r) (string, bool)` helper (since `260529-jad6` — `url.PathUnescape` + `ValidateWindowID` in exactly one place; `handleRelay` calls the same helper, eliminating the copy-paste drift that caused bug #205). `400` on malformed ID before any tmux call. A window ID is a server-global, self-contained tmux target, so the session name drops out of these paths. Routes that *create* a window or enumerate a session's windows stay session-scoped (`POST /api/sessions/{session}/windows`, session enumeration) — there is no window ID at create time.

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/health` | GET | Returns `200 {"status":"ok","hostname":"..."}` for health checks. Hostname computed once at startup via `os.Hostname()`, stored in `Server` struct; falls back to `""` on error |
| `/api/sessions` | GET | Returns `ProjectSession[]` — one per tmux session, with auto-detected fab enrichment (`fabChange`/`fabStage` on windows) |
| `/api/sessions` | POST | Create session — JSON body `{"name":"...","cwd":"..."}`. Returns `201 {"ok":true}` |
| `/api/sessions/order` | GET | Returns `{"order":[...]}` — JSON array of session names representing the user-defined sidebar render order on `?server=`. Reads tmux server-scoped user-option `@rk_session_order`. Unset/no-server cases return `200 {"order":[]}` (not 404); other tmux failures (e.g., malformed JSON in stored option) return `500` with error body |
| `/api/sessions/order` | POST | Persists the sidebar session order — JSON body `{"order":[...]}`. (Migrated PUT→POST by `260529-jad6` per §IX; body unchanged.) Each name validated via `validate.ValidateName`. JSON-encoded value written to tmux user-option `@rk_session_order` via `set-option -s` (server-scoped). After a successful write, broadcasts `event: session-order` (payload `{server, order}`) synchronously to all SSE clients on that server. Stale names (not currently a live session) are accepted — frontend's `orderedSessions` sort tolerates them. Returns `400` on malformed JSON or invalid name; `200 {"ok":true}` on success |
| `/api/sessions/:session/kill` | POST | Kill session — `:session` validated via `validate.ValidateName()`. Returns `200 {"ok":true}` |
| `/api/sessions/:session/windows` | POST | Create window — JSON body `{"name":"...","cwd":"..."}`. **Session-scoped** (the window does not exist yet, so there is no window ID to key on). Returns `201 {"ok":true}` |
| `/api/windows/{windowId}/kill` | POST | Kill window — `{windowId}` validated via `validate.ValidateWindowID` (`^@[0-9]+$`); `400` on malformed ID, no tmux call. Returns `200 {"ok":true}` |
| `/api/windows/{windowId}/move` | POST | Reorder window within its session — JSON body `{"targetIndex": N}`. Source addressed by stable `{windowId}`; destination remains positional. Backend resolves the source's current index from the ID, then bubble-swaps to the target position via `tmux swap-window` (insert-before semantics). Returns `200 {"ok":true}` |
| `/api/windows/{windowId}/move-to-session` | POST | Move window to another session — JSON body `{"targetSession":"..."}`. Runs `tmux move-window -s {windowId} -t {targetSession}:` (window ID source, session destination; tmux preserves the ID and auto-assigns the index). Returns `200 {"ok":true}` |
| `/api/sessions/:session/color` | POST | Set or clear session color — JSON body `{"color": N}` (0-15) to set, `{"color": null}` to clear. Writes `session_color` to `run-kit.yaml` at the session's git root (derived from window 0's `WorktreePath` via `config.FindGitRoot`). Validates session name and color range. **Session-scoped** (per-project file, not a window op). Returns `200 {"ok":true}` |
| `/api/windows/{windowId}/options` | POST | **Unified partial-merge window-option setter** (since `260529-jad6` — replaced the three separate `POST /color`, `PUT /url`, `PUT /type` routes). JSON body `{"options": {"@color":"5","@rk_url":"https://…","@rk_type":null}}` decoded as `map[string]*string`: a present key with a string sets it, an explicit `null` unsets it, an absent key is left untouched (partial-merge — never full-replace, which avoided the "client clears a field it didn't echo" bug class). **Key allowlist** `{@color, @rk_url, @rk_type}` — any other key → `400`, never passed to tmux (§I — bounds the option-name surface). Per-key validation preserved from the old handlers: `@color` must be a string parseable as int `0–15` (the old `Color *int` contract, now carried as a string); `@rk_url` non-empty after trim; `@rk_type` non-empty sets verbatim (no enum validation, preserving `handleWindowTypeUpdate`), empty/`null` unsets. **Validate-all-before-any-tmux**: if any key fails validation the endpoint returns `400` and issues zero tmux calls (no partial application). The whole merge then executes as **one atomic `\;`-chained tmux invocation** via `tmux.SetWindowOptions` (the shared `appendOptionOps` primitive). Returns `200 {"ok":true}` |
| `/api/windows/{windowId}/rename` | POST | Rename window — JSON body `{"name":"..."}`. Returns `200 {"ok":true}` |
| `/api/windows/{windowId}/keys` | POST | Send keys — JSON body `{"keys":"..."}` (non-empty after trim). Returns `200 {"ok":true}` |
| `/api/windows/{windowId}/select` | POST | Select (activate) the window. **Session-scoped (since `260529-jad6`)**: `handleWindowSelect` resolves the owning session for `{windowId}` server-side via `ResolveWindowSession`, then issues `tmux select-window -t <session>:{windowId}` via `SelectWindowInSession` — NOT a bare `select-window -t {windowId}`. The scoped target disambiguates inside tmux session groups (group members keep independent active-window state, so a bare target may activate the window on the wrong member, causing the wrong-window-highlight symptom). The session is derived server-side; the client never supplies it. A stale `{windowId}` whose session cannot be resolved surfaces a non-2xx error and issues no select. Returns `200 {"ok":true}` |
| `/api/windows/{windowId}/split` | POST | Split the window's active pane — JSON body `{"horizontal": bool}`. Returns `200 {"ok":true,"paneId":"%N"}` |
| `/api/windows/{windowId}/close-pane` | POST | Kill the window's active pane. Returns `200 {"ok":true}` |
| `/api/directories` | GET | Server-side directory listing for autocomplete — `?prefix=~/code/wvr` returns matching dirs under `$HOME` |
| `/api/sessions/:session/upload` | POST | File upload — session from URL path (not form field). Multipart with `file` field, optional `window` field (defaults to `"0"`). Resolves project root via `ListWindows`, writes to `.uploads/{timestamp}-{name}`, auto-manages `.gitignore`. 50MB limit. Returns `200 {"ok":true,"path":"..."}` |
| `/api/sessions/stream` | GET | SSE — hub singleton polls tmux every 2.5s, fans out full snapshots to all connected clients on change. Three event types: `event: sessions` (per-server, deduplicated), `event: metrics` (server-independent, every tick), and `event: session-order` (per-server, eager — broadcast synchronously on POST to `/api/sessions/order`, plus once per server on first poll as a bootstrap so the order survives an rk-go restart that left tmux running). Session-order payload shape: `{"server":"<name>","order":[...]}`. Deduplicates polling across browser tabs. 30-minute lifetime cap per connection. Hub clients map is `map[string][]*sseClient` keyed by server name for O(1) server-scoped broadcast. `sync.RWMutex` — `RLock()` for read-only operations in `poll()` (collecting server keys, counting clients), `Lock()` for writes (broadcasting, updating `previousJSON`/`previousOrderJSON`). Channel buffer 32. Drop logging via `slog.Warn` with per-client boolean debounce (resets on successful send). Hub holds a `*metrics.Collector` reference; on each tick calls `Snapshot()` and broadcasts `event: metrics` with JSON payload to all clients regardless of server. The hub also holds a `SessionOrderFetcher` (defaults to `prodSessionOrderFetcher{}` calling `tmux.GetSessionOrder`) used for the bootstrap; tests substitute via `hub.orderFetcher = stub`. New clients receive whichever cached snapshots are populated at connect time (`sessions`, `session-order`, `metrics`), in that order. On a warm hub these arrive immediately. On a cold hub (rk-go just started, no client has connected for that server yet, no PUT has fired), the cache for that server is empty and the first client gets only what's already populated; the missing entries arrive on the first poll tick — for `session-order` specifically, the once-per-server `poll()` bootstrap reads tmux and broadcasts. Cached payloads kept in `previousJSON` / `previousOrderJSON` / `cachedMetricsJSON` |
| `/api/tmux/reload-config` | POST | Reload tmux config — server from `?server=` param (default `"default"`). Runs `source-file` on the specified server. Returns `200 {"status":"ok"}` |
| `/api/pr-status/refresh` | POST | On-demand refresh of the in-memory PR-status collector — `handlePRStatusRefresh` (`api/pr_status.go`) calls `s.prStatus.RefreshNow(r.Context())` and returns `200 {"ok":true}` (`260610-596o-pr-status-sidebar`). Server-independent (the collector is global, not per-`?server=`). Nil-collector safe (no-op when no collector is wired, e.g. the test router). Best-effort: the collector's `gh` call carries its own 10s timeout and a failure is swallowed (stale-while-revalidate), so the handler always returns ok; the refreshed statuses arrive via the next SSE `sessions` payload. POST per Constitution §IX; the CORS `AllowedMethods` allowlist stays `[GET, POST, OPTIONS]` |
| `/api/servers` | GET | List available tmux servers — scans socket dir, returns JSON array of `{name, sessionCount}` objects sorted alphabetically. Session counts computed by concurrent fan-out over `tmux.ListSessions(ctx, server)` (WaitGroup + mutex-protected results map); per-server `ListSessions` errors are logged at warn and yield `sessionCount: 0` (no 5xx surfaces). Empty discovery returns `[]` (not `null`) |
| `/api/servers` | POST | Create tmux server — JSON body `{"name":"..."}`. Creates session "0" in `$HOME`. Returns `201 {"ok":true}` |
| `/api/servers/kill` | POST | Kill tmux server — JSON body `{"name":"..."}`. Returns `200 {"ok":true}` |
| `/api/keybindings` | GET | Curated tmux keybindings — runs `tmux list-keys` on `?server=`, filters through a whitelist map, returns JSON array `[{key, table, command, label}]`. Returns `[]` if server not running or on error |
| `/api/settings/theme` | GET | Returns `{"theme": "..."}` — reads via `settings.Load()`. Returns `"system"` when no settings file exists. Not per-server (no `?server=` param) |
| `/api/settings/theme` | POST | Accepts `{"theme": "..."}` — writes via `settings.Save()`. (Migrated PUT→POST by `260529-jad6` per §IX; body/response unchanged.) Returns `{"status": "ok"}` on success, `400` if theme is empty. Not per-server |
| `/api/settings/server-color` | GET/POST | Per-server color preference. `POST` accepts `{"server":"...","color":N}` (migrated PUT→POST by `260529-jad6` per §IX; body unchanged). Not per-`?server=` |
| `/api/boards` | GET | Lists pane boards summarized across every server returned by `tmux.ListServers(ctx)` (each board's pins are server-scoped; the list is the union of board names). Derived from `_rk-pin-*` session `@rk_board` vars (since `260602-qn62`). Returns `200 [{name, pinCount}]` (alphabetical by `name`); `[]` when empty (never `null`). Not per-server (no `?server=` query) |
| `/api/boards/{name}` | GET | Returns the board's entries (one per `_rk-pin-*` session carrying this `@rk_board`) joined with live tmux window data: `[{server, windowId, session, windowIndex, windowName, orderKey, panes}]`, sorted by `orderKey`. **Response field shape unchanged** by `260602-qn62` — `session` is now the `_rk-pin-*` pin-session, transparent to the frontend. `{name}` validated against `^[A-Za-z0-9_-]{1,32}$` (`400` on invalid). A killed pinned window's session just drops out of the live read (no stale write-back). `200 []` when no entries match the name |
| `/api/boards/{name}/pin` | POST | Pin a window to a board. Body `{"server":"...","windowId":"@1234"}`. Validates `name`, `server` (via `validate.ValidateName`), and `windowId` (`^@\d+$`). Returns `404` when the named window does not exist on the named server. Idempotent — re-pinning the same window to the same board is a no-op. Returns `201 {"ok":true}` on success and broadcasts `event: board-changed { change: "pin" }` on the source server's SSE stream |
| `/api/boards/{name}/unpin` | POST | Unpin a window from a board. Body `{"server":"...","windowId":"@1234"}`. Returns `200 {"ok":true}`. Tolerant of "entry not present" (also returns `200`). Empty board cannot exist — when this removes the last entry, the board vanishes from `GET /api/boards`. Broadcasts `event: board-changed { change: "unpin" }` |
| `/api/boards/{name}/reorder` | POST | Reorder a pin within a board. Body `{"server":"...","windowId":"@1234","before":"@5678"\|null,"after":"@9abc"\|null}`. The new `orderKey` is computed server-side via `tmux.ComputeOrderKey(beforeKey, afterKey)`; the frontend never generates keys. Returns `200 {"ok":true,"newOrderKey":"bm"}` and broadcasts `event: board-changed { change: "reorder", orderKey }` |
| `/proxy/{port}/*` | * | Reverse proxy — forwards to `http://127.0.0.1:{port}/{path}`. Port validated (1-65535, `400` on invalid). Per-port `ReverseProxy` instances cached via `sync.Map`. Handles WebSocket upgrade transparently. `ModifyResponse` rewrites `localhost:{port}` and `127.0.0.1:{port}` references in HTML responses (`text/html` content-type) to `/proxy/{port}` paths; handles gzip-compressed responses. Non-HTML responses pass through unchanged |

### Frontend API Client

`app/frontend/src/api/client.ts` — typed fetch wrappers for all endpoints using POST-only mutations with path-based intent. Uses relative URLs — works with both Vite proxy in dev and same-origin in production. Exports individual functions per endpoint:

| Function | Method | Path |
|----------|--------|------|
| `getHealth()` | GET | `/api/health` |
| `getSessions()` | GET | `/api/sessions` |
| `createSession(name, cwd?)` | POST | `/api/sessions` |
| `killSession(session)` | POST | `/api/sessions/:session/kill` |
| `createWindow(server, session, name, cwd?, rkType?, rkUrl?)` | POST | `/api/sessions/:session/windows` (session-scoped — no window ID yet) |
| `killWindow(server, windowId)` | POST | `/api/windows/{windowId}/kill` |
| `moveWindow(server, windowId, targetIndex)` | POST | `/api/windows/{windowId}/move` (source by ID, body `{targetIndex}`) |
| `moveWindowToSession(server, windowId, targetSession)` | POST | `/api/windows/{windowId}/move-to-session` |
| `renameWindow(server, windowId, name)` | POST | `/api/windows/{windowId}/rename` |
| `setWindowOptions(server, windowId, options)` | POST | `/api/windows/{windowId}/options` — partial-merge; `options` is `Record<string, string \| null>` (since `260529-jad6`) |
| `updateWindowUrl(server, windowId, url)` | POST | delegates to `setWindowOptions` → `/api/windows/{windowId}/options` (`{"@rk_url": url}`) |
| `updateWindowType(server, windowId, rkType)` | POST | delegates to `setWindowOptions` → `/api/windows/{windowId}/options` (`{"@rk_type": rkType === "" ? null : rkType}`) |
| `sendKeys(server, windowId, keys)` | POST | `/api/windows/{windowId}/keys` |
| `getDirectories(prefix)` | GET | `/api/directories?prefix=...` |
| `selectWindow(server, windowId)` | POST | `/api/windows/{windowId}/select?server=...` |
| `splitWindow(server, windowId, horizontal, cwd?)` | POST | `/api/windows/{windowId}/split` |
| `closePane(server, windowId)` | POST | `/api/windows/{windowId}/close-pane` |
| `reloadTmuxConfig()` | POST | `/api/tmux/reload-config?server=...` |
| `uploadFile(session, file, window?)` | POST | `/api/sessions/:session/upload?server=...` |
| `listServers()` | GET | `/api/servers` — returns `Promise<ServerInfo[]>` where `ServerInfo = { name: string; sessionCount: number }`. `ServerInfo` is exported alongside `listServers` from `app/frontend/src/api/client.ts`. All consumers (`SessionProvider`, `Sidebar`, `ServerPanel`, `ServerListPage`, `app.tsx` palette actions) read `.name` from each entry |
| `createServer(name)` | POST | `/api/servers` |
| `killServer(name)` | POST | `/api/servers/kill` |
| `getKeybindings()` | GET | `/api/keybindings?server=...` |
| `getThemePreference()` | GET | `/api/settings/theme` |
| `setThemePreference(theme)` | POST | `/api/settings/theme` (migrated PUT→POST by `260529-jad6`) |
| `setServerColor(server, color)` | POST | `/api/settings/server-color` (migrated PUT→POST by `260529-jad6`) |
| `setWindowColor(server, windowId, color)` | POST | delegates to `setWindowOptions` → `/api/windows/{windowId}/options` (`{"@color": color === null ? null : String(color)}`; since `260529-jad6`) |
| `setSessionColor(session, color)` | POST | `/api/sessions/:session/color` |
| `setSessionOrder(server, order)` | POST | `/api/sessions/order` (migrated PUT→POST by `260529-jad6`) |
| `refreshPrStatus()` | POST | `/api/pr-status/refresh` — kicks the (otherwise 90s-cadence) batched `gh` fetch; refreshed statuses arrive via the SSE `sessions` stream. Best-effort; server-independent (no `?server=`). Triggered by clicking a `PrStatusLine` (`260610-596o-pr-status-sidebar`) |

All API functions (except `listServers`, `createServer`, `killServer`, `getDirectories`, `getHealth`, `getThemePreference`, `setThemePreference`, `refreshPrStatus`) append `?server={active}` via a module-level `setServerGetter()` mechanism. The `SessionProvider` sets the getter on mount. No multiplexed `action` field — each mutation is a separate function with its own URL path.

**Request deduplication**: A module-level `deduplicatedFetch` wrapper maintains a `Map<string, Promise<Response>>` of in-flight GET requests. When a GET to a URL already has an in-flight promise, the existing promise is returned (with `Response.clone()` so each caller can independently consume the body). POST/PUT requests always make fresh `fetch()` calls. Promises are cleaned up via `.finally()` on resolve or reject. All GET functions (`getHealth`, `getSessions`, `getDirectories`, `listServers`, `getKeybindings`, `getThemePreference`) use `deduplicatedFetch`; all mutation functions use plain `fetch`. Deduplication key is the full URL string (after `withServer()` appends `?server=`), so requests scoped to different tmux servers are not incorrectly merged.

## Terminal Relay

WebSocket endpoint at `/relay/{windowId}?server=runkit|default` on the same port as the API — no separate relay port. Uses `gorilla/websocket` for WebSocket handling and `creack/pty` for PTY allocation. Implementation in `app/backend/api/relay.go`. The `server` query param determines which tmux server to attach to (defaults to `runkit`). The `{windowId}` is validated via `validate.ValidateWindowID` before the WS upgrade — a malformed ID responds `400` (no upgrade, no tmux call).

Per connection (since `260602-qn62-move-based-board-pin-sessions`, the relay attaches **directly** — no ephemeral):
1. **Validates the window ID** via the shared `decodeWindowID(r)` helper (`url.PathUnescape` + `validate.ValidateWindowID`) — a malformed ID is a `400` before the WS upgrade or any tmux call (the same helper the REST path uses, so they cannot drift — bug #205)
2. **Resolves the owning session from the window ID** via `s.tmux.ResolveWindowSession(ctx, server, windowID)` (5s timeout) — the result is the ONE session that owns the window: a normal home session OR a board pin-session (`_rk-pin-*`). Since the move-based model removes window sharing, an O(1) targeted `display-message -t <windowID> -p '#{session_name}'` resolves that single owning session unambiguously (restored from the prior O(n) `list-windows -a` scan by `260609-enic-restore-display-message-resolve-window` — a relay-connect latency regression). If resolution fails or returns empty (unknown window ID), the relay closes the WebSocket with code `4004` ("Window not found")
3. **Selects the window** on its real session via a direct bare `s.tmux.SelectWindow(windowID, server)` (`select-window -t @N`) so the attach renders the right window. For a pin-session this is a no-op (sole window always active); for a home session it mutates the real session's single active-window pointer — the accepted multi-client collision tradeoff
4. Waits up to 5s for the first `{"type":"resize"}` message to start the PTY at the correct dimensions (falls back to 80×24)
5. Best-effort `tmux.ReloadConfig(server)` so true-color terminal-overrides are active even on a foreign server, then spawns `tmux [-L server] attach-session -t <session>` via `creack/pty` (`forceTERM` sets `TERM=xterm-256color`). Attaches **directly to the resolved owning session** — no ephemeral, no defer-kill; the session is durable and owned by tmux
6. Relays I/O between WebSocket and pty (goroutine for pty→WS, main loop for WS→pty); handles resize via `pty.Setsize`
7. On disconnect: `sync.Once` cleanup cancels context, closes PTY, kills the attach process. PTY reader goroutine calls `cleanup()` (not `conn.Close()`) on read failure — eliminates concurrent WebSocket close race. WebSocket closed only by `defer conn.Close()` on the main goroutine

**Why no ephemeral** (the `260602-qn62` simplification): the old per-WebSocket grouped ephemeral (`rk-relay-*`) existed only to give each connection its own active-window pointer (a tmux session has exactly ONE, shared across attachments) so board panes pointing at different windows of one 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 purpose and was deleted: `newEphemeralRelayName`, `tmux.NewGroupedSession`, the `@rk_owner_pid` stamp (`SetSessionOwnerPID`/`GetSessionOwnerPID`/`OwnerPIDOption`), the ephemeral `defer KillSessionCtx`, the scoped `SelectWindowInSession`-on-ephemeral, `RelaySessionPrefix`, and `ListRawSessionNames` are all gone. **Accepted tradeoffs**: multi-client navigation now mutates a shared home session's active window (two tabs / a navigating viewer yank each other); a no-op for pin-sessions. See `tmux-sessions.md` § Terminal Relay and § Pin Sessions.

**No startup sweep** (since `260602-qn62`): `cmd/rk/serve_sweep.go` (`sweepOrphanedRelaySessions`/`pidAlive`/`relayOwnerIsDead`) is **deleted** and unwired from `serveCmd.RunE`. Relay ephemerals no longer exist, and board pin-sessions (`_rk-pin-*`) are PERSISTENT across rk restarts (a valid state, not an orphan — Constitution VI), so there is no in-server session class to reap. The `_rk-ctl` anchor + `exit-empty off` backstop are untouched.

Client-side WebSocket reconnection: exponential backoff (1s, 2s, 4s, 8s, 16s, max 30s) on unexpected close. Shows `[reconnecting...]` in terminal. Re-sends resize on successful reconnect. Skips reconnect on component unmount. On close code `4004` (session/window not found): shows `[session not found]` and navigates to `/` instead of reconnecting. Terminal page connects via `ws://${location.host}/relay/${encodeURIComponent(windowId)}?server={runkit|default}` — same host, window ID (`@N`) in the path, server param from session metadata. The session name no longer appears in the relay URL.

## Daemon Lifecycle

The Go binary manages its own daemon lifecycle via the top-level `rk daemon` subcommand tree (sibling of `rk serve`, not nested under it). The daemon runs in a dedicated tmux server (socket `rk-daemon`, session `rk-daemon`, window `serve`) — separate from the `runkit` server used for agent sessions. The session name matches the socket name so the combined exact-match target (`=rk-daemon:=serve`) cannot collide with user-facing sessions via tmux prefix matching. `LegacySessionName = "rk"` is consulted only by `Stop`/`Restart` (via `runningSessionCtx`, which checks `SessionName` first then falls back to the legacy name) so a daemon started by a pre-rename binary can still be stopped cleanly during the first upgrade. Daemon helpers live in `internal/daemon/`; the CLI surface lives in `app/backend/cmd/rk/daemon*.go` (one file per subcommand: `daemon.go` parent, `daemon_start.go`, `daemon_stop.go`, `daemon_restart.go`, `daemon_status.go`, plus `daemon_portowner.go` for the shared port-owner helper).

- `rk daemon start [--force]` — start daemon (errors if already running)
- `rk daemon stop [--force]` — graceful shutdown via C-c to the tmux pane; prints `rk daemon not running` and exits 0 when no session exists
- `rk daemon restart [--force]` — idempotent: stop existing daemon (C-c → wait up to `stopGracePeriod` 12s for graceful exit, an independent grace-deadline timer sized to cover the inner serve's ~10s worst-case shutdown — see `internal/daemon` row; a session that vanished on its own counts as a clean stop) then start new one; starts fresh if no daemon running
- `rk daemon status [--json]` — read-only report of daemon state (running / not running, socket, session, window, target) AND port owner (free / held by the daemon / held by another process). `--json` emits a structured machine-readable form (`daemon.{running,socket,session,window,target,pid}` + `port.{host,port,state,holder_pid,holder_command}`)
- `rk update` — auto-restarts daemon after successful `brew upgrade` using `RestartWithBinary(brewBinPath)` where `brewBinPath` is `<brewPrefix>/bin/rk` derived from the resolved Cellar path (resolves to the new Cellar version post-upgrade, avoiding stale `os.Executable()` path from the running process). Operates at the Go API layer, not the CLI surface

The pre-`260528-5zvv` flags `rk serve -d`, `rk serve --restart`, and `rk serve --stop` were removed in a hard break — no deprecation forwarders. Invoking them now surfaces cobra's standard `unknown flag` error. `rk serve` is foreground-only.

**Naming collision note**: `rk daemon status` is distinct from `rk status` (the top-level tmux session summary in `cmd/rk/status.go`). The collision is sibling-namespaced — cobra disambiguates cleanly — but `rk daemon status --help` includes a one-line note pointing this out so users typing `rk status` expecting daemon info land at the right command.

**`--force` semantics** — consistently "override the safety check that prevents claiming the port from a non-rk-daemon owner":

- `rk daemon start --force` — lazy / on-error. The happy path is identical to the non-forced path (`daemon.Start()` → port-probe). Only when `Start()` returns the port-in-use error does the command look up the port owner, refuse to kill if the holder identifies as the rk daemon (`daemon.InnerServePID()` match), otherwise SIGTERM the owner with graceful-then-forceful escalation, and retry `Start()`.
- `rk daemon stop --force` — stop the daemon first (if running), then regardless of step 1's outcome probe the port. If still held by a non-daemon process, SIGTERM it. If the port is free at probe time, exit 0 silently (no `Killed port owner` line). If the holder identifies as the rk daemon itself, exit non-zero with a "manual investigation needed" message.
- `rk daemon restart --force` — stop the daemon if running, then proactively probe the port BEFORE the start step. Any non-daemon holder is SIGTERMed (with graceful-then-forceful escalation) before `daemon.Start()` is called.

`rk daemon status` is read-only and does NOT accept `--force` — passing it produces cobra's `unknown flag` error.

Binary resolution: `Start()` uses `os.Executable()` + `filepath.EvalSymlinks()` (works for the standard `rk daemon start` / `restart` paths where the running binary is valid). `StartWithBinary(path)` accepts an explicit binary path and resolves its symlinks (used by `update` after `brew upgrade` deletes the old Cellar directory). Both delegate to shared `startSession(exe)` for tmux session creation.

Detection: `tmux -L rk-daemon has-session -t =rk-daemon` (anchored `=` prefix forces exact-match — see PR #196). `runningSessionCtx` consults `=rk-daemon` first, then falls back to `=rk` for transparent stop/restart of pre-rename daemons. No polling loop, no signal files, no supervisor script.

**Port-based liveness probe**: Before `Start()`/`StartWithBinary()` create the tmux session — and AFTER the `IsRunning()` check passes — they call `guardPortAvailable()`, which loads `config.Load()` and runs `net.DialTimeout("tcp", host:port, 400ms)` against `RK_HOST:RK_PORT`. Wildcard/empty hosts (`""`, `"0.0.0.0"`, `"::"`, `"[::]"`, `"[::1]"`) substitute `127.0.0.1` via `probeHost()` — dialing `0.0.0.0` itself is platform-inconsistent and not a reliable liveness signal. If the dial succeeds (something is listening), `Start` returns an error containing the substrings `already serving on`, `not under the rk-daemon`, and `RK_PORT` so scripts can pattern-match. Cobra `RunE` propagates the non-zero exit. This closes the split-brain detection gap where a foreground `rk serve` was invisible to the tmux-only `IsRunning()` check.

**Stale-socket reaping**: When `IsRunning()` is false AND the port probe passes, `Start`/`StartWithBinary` call `reapStaleDaemonSocket(ctx)` which invokes `runTmux(ctx, "kill-server")` — scoped strictly to `serverSocket = "rk-daemon"` (never the agent-session `runkit` server). Idempotent: tmux's "no server running" surface on cold start is the common case. Errors are logged at `slog.Debug` and never block startup; if anything is genuinely broken, the subsequent `startSession` surfaces it. This self-heals the orphaned-socket state that arose when a previous inner serve died on a port collision before this fix shipped.

**Startup logging via `RK_DAEMON_LOG`**: `startSession` resolves `os.UserCacheDir()/rk/daemon.log` (e.g. `~/.cache/rk/daemon.log` on Linux, `~/Library/Caches/rk/daemon.log` on macOS) via `resolveDaemonLogPath()` and passes it to the inner `tmux new-session` via `-e RK_DAEMON_LOG=<path>`. The serve startup's `setupSlog(level)` reads the env var, `os.MkdirAll(filepath.Dir(p), 0o755)`, opens the file with `O_APPEND|O_CREATE|O_WRONLY` (mode 0o644), and wraps `io.MultiWriter(os.Stderr, file)` so slog output is teed to both the tmux pane (preserves `tmux attach` debugging) and a durable log file. Open failures fall back to stderr-only with one `slog.Warn("daemon log unavailable", ...)`; `UserCacheDir()` failure in `startSession` spawns the inner serve without the env var (single `slog.Warn` upstream). Logging is best-effort — never blocks daemon creation. Constitution §VI compliance: the log is a passive append-only artifact, no supervisor, no signal file, no liveness derivation from contents.

The exported constant `daemon.LogEnvVar = "RK_DAEMON_LOG"` is the single source of truth (consumed by both `internal/daemon/startSession` and `cmd/rk/serve.go/setupSlog`).

**Port-owner lookup (`daemon_portowner.go`)**: shared helper used by all four `rk daemon` subcommands. `findPortOwner(ctx, host, port) (*PortOwner, error)` queries who is listening on the configured TCP port via `lsof -ti:<port>` (primary), falling back to `ss -tlnp '( sport = :<port> )'` when `lsof` is unavailable or errors. Both queries are port-only — the `host` argument is pass-through for display (both tools correctly cover loopback and wildcard binds). Holder command is resolved via `/proc/<pid>/comm` on Linux, `ps -p <pid> -o comm=` on macOS. Returns `(nil, nil)` when nothing is listening; `(nil, error)` only when both tools fail. The companion `terminateOwner(ctx, owner)` mirrors `daemon.Stop`'s graceful escalation: `syscall.Kill(pid, syscall.SIGTERM)` → 200ms polling for exit via `syscall.Kill(pid, 0)` up to a 5s grace period → `syscall.Kill(pid, syscall.SIGKILL)` on timeout. Signal delivery uses `syscall.Kill` directly — never a shell `kill` invocation. All subprocess calls (`lsof`, `ss`, `ps`) use `exec.CommandContext` with a 5s `context.WithTimeout` per Constitution §I. `var findPortOwner = findPortOwnerImpl` is overridable in tests so CLI tests drive `--force` paths without spawning the real tools.

**Holder-identity check (`daemon.InnerServePID()`)**: small helper in `internal/daemon` that returns the PID of the `rk serve` process running inside the daemon tmux pane, derived from tmux's `pane_pid` format spec via `tmux -L rk-daemon list-panes -t =rk-daemon:=serve -F '#{pane_pid}'` (a new `runTmuxOutput` internal captures stdout where `runTmux` would have discarded it). Returns `(0, error)` when the daemon session is absent or the tmux query fails. Used by every `--force` code path and by `rk daemon status` to recognize the daemon as the port owner — when `owner.PID == innerServePID`, the port is classified as held by us (no signal sent in `--force`; `held-by-daemon` state in `status`). The CLI layer wires this through a package-level `var innerServePIDFn = daemon.InnerServePID` so tests can drive the self-recognition branch.

## tmux Control-Mode Subscription (`internal/tmuxctl`)

Package `app/backend/internal/tmuxctl/` owns the long-running `tmux -CC` control-mode subscriptions that drive sub-500ms SSE freshness. It is the **only sanctioned bypass** of the `internal/tmux/` boundary — every other tmux interaction goes through the request-response helpers in `internal/tmux/tmux.go` with their 10s `context.WithTimeout`. The bypass exists because a single long-lived subscription is structurally incompatible with the `exec.CommandContext`-plus-timeout shape of `internal/tmux/`. The package's `doc.go` documents the boundary explicitly.

### Core types

- **`Client`** (`client.go`) — one PTY-backed `tmux -CC` subscription per tmux server (identified by socket name). Runs `tmux [-L <socket>] -CC attach-session -t =<bootstrap> -r` via `creack/pty.Start` (the `-r` flag puts the connection in read-only mode — see Design Decision below). The `=`-anchored target prevents prefix-match collisions, matching the convention established in PR #196. A single goroutine reads lines via `bufio.Scanner` and dispatches them through `ParseLine`. Public surface:
  - `Open(ctx, socket, sink) (*Client, error)` — non-blocking; errors only on initial PTY allocation or process start failure.
  - `Close() error` — idempotent; cancels the Client's context, kills the subprocess, terminates the reconnect loop.
  - `Generation() int64` — atomic per-server counter incremented on every handled notification.
  - `Wait(after int64) <-chan struct{}` — returns a channel closed once generation > after. Single-use; callers re-call after each consumed event.
- **`Supervisor`** (`supervisor.go`) — owns `map[socket]*Client` guarded by `sync.Mutex`, plus an `fsnotify.Watcher` on the watch directory. Resolves the watch dir from `TMUX_TMPDIR` if set, else `/tmp/tmux-<euid>/`; missing directories are created via `os.MkdirAll(dir, 0o700)` and watched as sockets appear. `Start(ctx)` enumerates existing sockets synchronously (so the SSE hub never races an empty Client map for sockets that already exist on disk) and then runs an fsnotify event loop. Re-creating a socket of the same name closes the prior Client before opening a new one. `Stop(ctx)` closes every Client and the watcher; bounded 5s in production. `Get(socket) *Client` is the lookup used by the SSE bridge.
- **`EventSink`** (`event_sink.go`) — consumer interface with 8 callbacks (`OnSessionWindowChanged`, `OnWindowAdd`, `OnWindowClose`, `OnWindowRenamed`, `OnSessionsChanged`, `OnLayoutChange`, `OnConnectionLost`, `OnConnectionEstablished`). All callbacks dispatch from a single goroutine — the Client's read loop. Sink implementations MUST NOT block. The production sink in `api/tmuxctl_bridge.go` (`hubSink`) is active since `260530-v6hm-active-window-event-derivation`: it records per-group active-window state into a per-Client `ActiveWindowTracker` (see § SSE Hub Bridge). The SSE hub still observes *change* via the per-server generation counter (that path is untouched); the sink's tracking is an additive side effect that supplies the Tier-1 active-window signal to the fetch path. `NoOpSink` is exported for tests, PTY-unavailable scenarios, and as the degraded sink a `nil` SinkFactory produces.
- **`ParseLine(line string) Event`** (`parser.go`) — pure function (no I/O, no time dependence). Recognises the six relevant tmux 3.6a notifications (`%session-window-changed`, `%window-add`, `%window-close`, `%window-renamed`, `%sessions-changed`, `%layout-change`) and framing markers (`%begin`/`%end`/`%error`). `%output` and `%unlinked-window-*` are silently dropped. Unknown notification names return `UnknownEvent{Raw}` and log `slog.Debug` exactly once per unique name (surfaces protocol additions without log noise). Malformed input returns `MalformedEvent{Raw}` — never panics.

### Reconnect FSM

When the PTY read loop encounters EOF or any error other than a Close-driven context cancellation, the Client calls `sink.OnConnectionLost()` exactly once, tears down the subprocess (`cmd.Process.Kill()` if still running, `cmd.Wait()` to reap), waits a backoff, then retries `pty.Start`. The backoff sequence is `initialBackoff = 250ms` → `500ms` → `1s` → `2s` → `5s` → `5s` … capped at `maxBackoff = 5 * time.Second`. The backoff resets to `initialBackoff` on the first non-`%begin` event after a successful reconnect (see `client.go` `handleEvent`). Reconnect retries continue indefinitely until `Close()` is called. Test injection points: `dialFn` and `sleepFn` are swappable so the FSM can be exercised with stubbed I/O and a fake clock.

### Anchor session (`_rk-ctl`)

`tmux -CC` requires an attached session to emit notifications. `resolveBootstrap` (in `client.go`) **always** creates a hidden anchor session named `_rk-ctl` via `tmux [-L <socket>] new-session -d -s _rk-ctl` — unconditionally on every observed server, not only when the server is empty (changed by `260602-a1wo-prevent-exit-empty-server-death`; the anchor is now a permanent **session floor** that keeps the server's session count above zero so tmux's `exit-empty` cannot reap it — see § Constitution Alignment §VI below and `tmux-sessions.md` § `_rk-ctl` Anchor). The anchor is tagged with the server-scoped user-option `@rk_ctl_keepalive 1` via `set-option -t =_rk-ctl @rk_ctl_keepalive 1` (constant `tmuxctl.AnchorKeepaliveOption = "@rk_ctl_keepalive"`). The tag is a defensive marker only — v1 has no runtime consumer, but it allows future code to identify the anchor without depending on the literal session name. A "duplicate session" error from `new-session` (concurrent `rk serve` created the anchor first) is treated as benign by `isDuplicateSessionError` and the Client proceeds to attach. **Floor vs. attach target are decoupled**: the anchor is always created (the floor), but the control-mode attach target still prefers the first existing real session (`firstSessionName`, which now skips `_rk-ctl` so the always-present anchor is not picked), falling back to `_rk-ctl` only when no real session exists. The anchor is hidden from user-facing surfaces via `tmux.ControlAnchorSessionName` — see `tmux-sessions.md` § `_rk-ctl` Anchor for the filtering chokepoints, the `exit-empty off` backstop, and the explicit-kill-only lifetime contract.

### SSE Hub Bridge (`api/tmuxctl_bridge.go`)

The `api` package owns the bridge between `tmuxctl.Supervisor` and the SSE hub. Three artefacts:

- **`WindowChangeSubscriber`** (interface defined in `api/sse.go`) — the interface the hub uses to receive per-server change notifications. Methods: `Generation(server string) int64` and `Wait(server string, after int64) <-chan struct{}`. Generation semantics mirror `tmuxctl.Client`: every observed notification increments the counter; `Wait(after)` returns a channel closed once `generation > after`.
- **`supervisorSubscriber`** — the production adapter. Forwards `Generation` and `Wait` calls to `Supervisor.Get(server).Generation()` / `.Wait(after)`. When no Client exists for a server (PTY-unavailable host, socket not yet allocated), `Wait` returns a **never-closing** channel via `neverChan()` so `selectFirst` falls through to the safety-net timer rather than busy-looping `FetchSessions`. Exported via `NewSupervisorSubscriber(sup)`.
- **`hubSink`** — the production EventSink, one per Client, built by the `SinkFactory` returned from `NewHubSinkFactory()` (the `Supervisor` calls the factory per socket, passing that socket's own `ActiveWindowTracker` — see § Active-Window Event Derivation below; `NewHubSink()` was removed by `260530-v6hm`). Beyond the generation-counter path (which the Client bumps itself, before invoking the sink, so the hub still observes *change* via `Wait`), `hubSink` records active-window state into its tracker: `OnSessionWindowChanged(sid, wid)` resolves `sid`→group via the tracker's cached map (O(1), no subprocess) and records `wid` as that group's active window (latest-event-wins; an unresolved `sid` is dropped for tracking, never errors/blocks); `OnSessionsChanged` refreshes the `$sid`→group map; `OnConnectionEstablished` re-seeds the tracker (refresh map + seed Tier 1 from current `#{window_active}` per group). The two re-seed callbacks issue read-only tmux queries that could block, so they are **offloaded to a goroutine** (the read loop returns immediately — honoring the EventSink "MUST NOT block" contract); the tracker's concurrency-safety makes a slightly-delayed refresh racing an event harmless. The read-only query seams (`listSessionGroups`/`listActiveByGroup`) are injectable struct fields so tests stub them without spawning tmux. The remaining callbacks (`OnWindowAdd`/`Close`/`Renamed`/`OnLayoutChange`/`OnConnectionLost`) stay no-ops.

`Server.SetWindowChangeSubscriber(sub)` materialises the lazy SSE hub (`initSSEHub`) and stores the subscriber. Safe to call before any SSE client connects. `Server.SetActiveWindowProvider(provider)` (since `260530-v6hm`) injects the Tier-1 `ActiveWindowProvider` (the Supervisor) into the production `prodSessionFetcher` so the REST + SSE fetch paths derive `isActiveWindow` from control-mode events; it is a no-op when the fetcher is a test-injected fake, keeping the seam optional.

### Active-Window Event Derivation (`260530-v6hm-active-window-event-derivation`)

The active-window highlight is **event-derived per session group** rather than read from the base session's `#{window_active}` pointer alone. The base pointer goes stale whenever an attachment moves the active window while another group member keeps an independent active-window pointer — historically the per-WebSocket `rk-relay-*` ephemeral, and still `rk riff` / an external `new-window`; reading the base pointer produced the wrong-highlight bug. This whole derivation subsystem (`internal/tmuxctl/*`) was scoped *investigate-only, NOT deletable* by `260602-qn62-move-based-board-pin-sessions` (it is driven by the `_rk-ctl` anchor and serves the SESSIONS highlight independent of boards) and is **left untouched** — note that `260602-qn62` removed the `rk-relay-*` ephemeral, so the relay is no longer one of the stale-pointer sources, but the subsystem's internal comments still reference relays (out of scope for that change). The fix corrects the *read signal* — it does not change any window creator.

**Tracker** (`internal/tmuxctl/active_window.go`) — `ActiveWindowTracker` is a concurrency-safe, in-memory (Constitution §II) store for ONE server, owned by one `Client` (the read-loop goroutine is the sole writer; the fetch path reads). Two `sync.RWMutex`-guarded maps: `byGroup` (group name → active `@wid`, latest-event-wins via `Set`, bulk re-seeded via `SeedGroups`) and `sidGroup` (`$sid` → group, resolved via `ResolveGroup`, refreshed wholesale via `ReplaceSidGroups`). `Get`/`Snapshot` are the read accessors.

**Ownership/wiring** — `NewSupervisor(factory SinkFactory)` takes a `SinkFactory` (a `nil` factory degrades every Client to `NoOpSink` — control-mode observed only via the generation counter, no tracking). `Supervisor.openSocket` allocates a fresh tracker per socket and binds the sink to it (a close-then-reopen gets a new tracker so stale state never leaks across the gap — `OnConnectionEstablished` re-seeds it). `Supervisor.ActiveWindow(server, group) (wid, ok)` is the server-keyed read accessor — a server with no Client, or a group with no tracked entry, returns `("", false)` so the caller falls back to Tier 2. The Supervisor satisfies `sessions.ActiveWindowProvider` directly. Wired in `cmd/rk/serve.go`: `tmuxctl.NewSupervisor(api.NewHubSinkFactory())`, then after `Start` both `SetWindowChangeSubscriber(NewSupervisorSubscriber(supervisor))` and `SetActiveWindowProvider(supervisor)`.

**Two-tier derivation** (`internal/sessions/sessions.go`) — `FetchSessions(ctx, server, provider)` threads an `ActiveWindowProvider` (nil ⇒ Tier-2-only, exactly today's behavior). Per session it queries `provider.ActiveWindow(server, sessionName)` — the user-facing session name IS the group key after `parseSessions` filtering. **Tier 1 (authoritative):** when a tracked `@wid` exists, the pure helper `applyActiveWindow(windows, trackedWid)` marks exactly the matching window active and clears the rest, overriding the parsed `#{window_active}` flag. **Tier 2 (fallback):** no tracked entry ⇒ the base-pointer flag from `parseWindows` stands. Tier 1, once set, is authoritative — the base pointer never overrides it; external clients (iTerm/Ghostty) also emit `%session-window-changed`, so "latest event wins" subsumes them with no override path. `applyActiveWindow` also enforces the single-highlight-per-session invariant and falls back to Tier 2 for a session when the tracked `@wid` matches no live window (e.g. closed between event and fetch) rather than highlighting nothing. The fetcher reaches `FetchSessions` via `prodSessionFetcher` (holding the provider) in `api/router.go`. Frontend auto-follow needs no change — the existing `app.tsx` URL-writeback effect navigates once the corrected `isActiveWindow` arrives via SSE; board panes pin explicit window IDs and never read it.

### Event-Driven SSE Loop

`app/backend/api/sse.go` refactored its per-server snapshot loop from a 2.5s ticker to an event-driven `select` with a 12s safety-net backstop. Three constants govern the cadence:

| Constant | Value | Role |
|----------|-------|------|
| `safetyPollInterval` | `12 * time.Second` | Backstop when a `WindowChangeSubscriber` is wired (production). Heals missed events across reconnect gaps; primary freshness driver is the Client's `Wait` channel. |
| `legacyPollInterval` | `2500 * time.Millisecond` | Used when no subscriber is wired (PTY-unavailable startup, unit tests). The snapshot-rebuild cadence is then the only freshness guarantee. |
| `metricsPollInterval` | `2500 * time.Millisecond` | Cadence for `metrics.Collector` host CPU/memory polling. Deliberately decoupled from the SSE intervals so metrics sampling frequency is not coupled to the SSE event/safety cadences. |
| `prStatusPollInterval` | `90 * time.Second` | Cadence for `prstatus.Collector`'s single batched `gh` call (`260610-596o-pr-status-sidebar`). Deliberately slow (~40 calls/hr vs. the 5000/hr authenticated limit) — the SSE hot path reads the cached snapshot, never `gh`, so PR-status freshness is decoupled from the SSE cadence; on-demand `POST /api/pr-status/refresh` covers the "I want it now" case. |

`sseHub.safetyIntervalEffective()` picks `safetyPollInterval` when `subscriber != nil`, else `legacyPollInterval` — preserves correctness for the PTY-unavailable case and for tests exercising the hub without a control-mode driver.

The loop body (`poll` + `waitForNext` + `selectFirst`) blocks on a `select` over (a) one `subscriber.Wait(server, perServerGen[server])` channel per active server and (b) `time.NewTimer(safetyIntervalEffective())`. When a subscriber fires, the winning server's prior generation is refreshed and the server is marked event-driven so the next iteration invalidates its 500ms `cache` entry and observes the post-mutation tmux state immediately. Non-winning servers are peeked non-blockingly so their backlog isn't replayed unnecessarily. `selectFirst` reads `timer.C` in the outer select to avoid the goroutine leak that would otherwise occur if a subscriber wins the race and the timer goroutine blocked on `timer.C` forever (`Stop` does not deliver on `C`). Heartbeat is wall-clock-based (`sseHeartbeatPeriod = 15 * time.Second`) — wall-clock cadence prevents the slower safety interval from starving heartbeats.

### No Killed-Window Board Cleanup Diff (removed)

Since `260602-qn62-move-based-board-pin-sessions`, the SSE hub no longer detects killed windows to clean up board membership. The old `detectKilledWindowIDs(prev, current) []string` pure function, the per-server `sseHub.previousWindowIDs` tracking, the per-tick window-kill cleanup loop, and the `BoardEntriesFetcher.RemoveAllByWindowID` dependency are all **deleted**. In the move-based model board membership is derived live from `_rk-pin-*` sessions, so a killed pinned window's pin-session simply drops out of the next `ListBoardEntries` read — the frontend's refetch on the next session-list change picks it up. There is also no `@rk_board` first-poll bootstrap broadcast (`broadcastBoardBootstrap`/`boardBootstrapPayload`/`previousBoardJSON` removed) and no cached-board send on `addClient`. `BoardEntriesFetcher` is now a one-method interface (`ListBoardEntries`) kept only so tests can stub the tmux dependency; `broadcastBoardChanged` (pin/unpin/reorder) remains and the `"cleanup"`/`"bootstrap"` change strings are gone.

### PR-Status SSE Join (`260610-596o-pr-status-sidebar`)

The SSE hub attaches live PR status to change-bound windows on each poll via a PURE in-memory read — the 2.5s hot path makes ZERO network/`gh` calls (the `gh` cost lives entirely on the collector's 90s background tick + the on-demand `POST /api/pr-status/refresh`).

**Seam + wiring**: `PRStatusSnapshotter` (one method, `Snapshot() map[int]prstatus.PRStatus`, in `api/sse.go`) is implemented by `*prstatus.Collector` and held on the hub (`sseHub.prStatus`). `newSSEHub(fetcher, mc, pc)` takes it as a third arg; `NewRouterAndServer` constructs `pc := prstatus.NewCollector(prStatusPollInterval); pc.Start(ctx)` next to the metrics collector, stores it on `Server.prStatus`, and `Server.initSSEHub` passes it through — but as a true nil interface when `s.prStatus` is nil (the explicit `var pc PRStatusSnapshotter; if s.prStatus != nil { pc = s.prStatus }` wrap avoids the typed-nil-interface trap so the hub's `h.prStatus == nil` check actually fires). A nil `prStatus` degrades gracefully (no PR fields attached) — used by tests and unwired routers.

**`attachPRStatus(sess []sessions.ProjectSession)`**: called in `poll()` AFTER the 500ms session-cache write (so the cached snapshot slice stays free of mutable PR fields conceptually, though it operates on the same slice by reference). For every window it FIRST resets the four display fields (`PrState/PrChecks/PrReview/PrIsDraft` → zero) so a window that lost its PR (merged/closed → dropped from the snapshot) clears cleanly even on a cached result slice; THEN, only when the **change-bound gate** holds (`FabChange != "" && PrNumber != nil`), it looks `*PrNumber` up in `Snapshot()` and copies `State/Checks/ReviewDecision/IsDraft` onto the window. A scratch window (PrNumber set but empty FabChange) gets nothing attached. No-op when `h.prStatus == nil`. `PrURL`/`PrNumber` are NOT touched here — they come from the pane-map enrichment in `internal/sessions` (Layer 1), upstream of the hub.

### `rk serve` Wiring

`cmd/rk/serve.go` instantiates the Supervisor after `tmux.EnsureConfig()` but BEFORE the `server.ListenAndServe()` goroutine, so the SSE hub never races an empty Client map for sockets that already exist on disk. (Since `260602-qn62-move-based-board-pin-sessions` there is no longer a `sweepOrphanedRelaySessions(ctx)` step between them — relay ephemerals are gone and pin-sessions are persistent, so `serve_sweep.go` was deleted and the startup ordering dropped to `EnsureConfig → Supervisor.Start → ListenAndServe`.) The Supervisor is constructed with `api.NewHubSinkFactory()` (since `260530-v6hm` — the per-socket tracker-bound sink factory; was `api.NewHubSink()`) and started synchronously; per-socket `Open` failures (PTY unavailable, etc.) are logged inside the Supervisor and never block startup. Once `Start` returns, `apiServer.SetWindowChangeSubscriber(api.NewSupervisorSubscriber(supervisor))` wires the change-notification bridge and `apiServer.SetActiveWindowProvider(supervisor)` wires the Tier-1 active-window read path (see § Active-Window Event Derivation) — if `Start` itself fails (rare; usually fsnotify allocation), a single `slog.Warn` falls back to safety-net-only mode without aborting the server.

On SIGINT/SIGTERM, the shutdown handler runs `supervisor.Stop(ctx5s)` BEFORE `server.Shutdown(ctx)`. Stop errors are logged at `slog.Warn` and never block shutdown — matches PR #197's daemon-log graceful-degradation pattern. The 5s bounded teardown matches the existing `tmux.TmuxTimeout = 10s` shape (control-mode subprocesses are expected to close cleanly within half that bound).

### Constitution Alignment

- **§I (Security First)** — all subprocess calls in `tmuxctl/` use `exec.CommandContext` with explicit argument slices; no shell strings. The Client's `tmux -CC` invocation has no overall timeout because the connection is long-lived (per-call timeouts are not appropriate); the context is cancelled by `Close()` and propagates through the goroutine.
- **§II (No Database)** — no file is written under `~/.run-kit/`, `~/.cache/rk/`, or anywhere else. The Supervisor's `map[socket]*Client` mirrors the kernel-observable filesystem view of tmux sockets; nothing persists across `rk serve` lifetimes. The only persistent artefact in this neighbourhood is `~/.cache/rk/daemon.log` from PR #197, unchanged by this package.
- **§VI (Tmux Sessions Survive Server Restarts)** — `tmuxctl` observes tmux state changes and pushes them to SSE clients; it never mutates *user* sessions (it creates/reads only the `_rk-ctl` anchor and sets the server-scoped `exit-empty off` option — never touching any window, pane, or user session). Since `260602-a1wo-prevent-exit-empty-server-death` the `_rk-ctl` anchor is created **on every observed server** (including the user's `default` with live sessions), as a permanent session floor — NOT, as before, only on servers with zero pre-existing sessions. This is itself the deliberate Constitution VI *prevention*, not a violation: the floor + the server-global `set-option -g exit-empty off` (set imperatively in `productionDial` before the anchor on every dial/reconnect, plus baked into the embedded conf) together stop tmux's default `exit-empty on` from reaping a server when its session count momentarily hits zero — the recurring failure (`runWork`/`utils`/`kit`, ≥3x) that killed live agent sessions. (Pre-`260602-qn62` this happened when the last real session closed and the remaining `rk-relay-*` ephemerals drained to zero; since `qn62` removed those ephemerals, the same backstop now also keeps an *empty home session* alive after its only window is pinned away into a `_rk-pin-*` session — pin-session persistence relies on `exit-empty off`.) The anchor is harmless on a server with real sessions: it is filtered from every user-facing list (`tmux.ControlAnchorSessionName` chokepoint in `parseSessions`), ungrouped, and the control-mode connection attaches read-only (`-r`). Managed servers now die only via explicit `kill-server` / `rk reaper`; empty anchor-only servers persist by design (no auto-reaping, no cross-process state per §II).
- **No new network surface** — the Client is a local subprocess attached via PTY. `ss -tlnp` still shows only the existing HTTP listener.

## SPA Static Serving

Dual-mode SPA serving in `app/backend/api/spa.go`. `hasEmbeddedAssets()` checks if the `embed.FS` contains real build output (more than `.gitkeep`):

- **Production** (`mountEmbeddedSPA`): serves from `embed.FS` via `fs.Sub(build.Frontend, "frontend")` + `http.FS`. SPA fallback rewrites to `index.html`.
- **Development** (`mountFilesystemSPA`): serves from `app/frontend/dist/` on the local filesystem. Path traversal prevented (resolved path must stay within SPA directory).

Both modes: any request not matching `/api/*`, `/relay/*`, or `/proxy/*` serves `index.html` for client-side routing. In development, Vite handles SPA fallback natively.

## Chrome Architecture

**Lazy-loaded components**: `CommandPalette`, `ThemeSelector`, and `CreateSessionDialog` are loaded via `React.lazy()` with dynamic `import()` (not static imports). Each uses `.then(m => ({ default: m.X }))` to re-wrap named exports for `React.lazy()` compatibility. Render sites wrapped in per-component `<Suspense fallback={null}>` boundaries. These components are overlays/dialogs that render conditionally — lazy loading keeps them out of the initial bundle for faster first paint.

**Shell wrapper (CSS Grid topology)** — `<Shell>` (`app/frontend/src/components/shell/shell.tsx`) is the shared chrome wrapper used by both `AppShell` and `BoardPage`. The shell is a single CSS Grid root carrying `height: var(--app-height, 100vh)` (preserves iOS keyboard handling via `useVisualViewport`). Desktop topology (`>= 640px`):

```
grid-template-areas:
  "sidebar topbar"
  "sidebar content"
  "sidebar bottombar"
grid-template-rows:    auto 1fr auto
grid-template-columns: ${sidebarWidth}px 1fr   (when sidebarOpen)
grid-template-columns: 0 1fr                   (when !sidebarOpen)
transition: grid-template-columns 150ms ease-out
```

Sidebar is full-height — it spans all three rows and owns the entire left column. Topbar/content/bottombar stack in the right column. Sidebar collapse zeroes the first grid column (no 48px rail) — the topbar/content/bottombar cells extend their left edge to page.left automatically; no JS-side conditional rendering or component mutation. The drag-resize handle is hidden when `sidebarOpen === false` (the only re-open affordance is the hamburger). Drag-handle wiring is AppShell-only — `BoardPage` does not need a drag-handle (the dimension lives in `ChromeContext` and is shared).

Mobile topology (`< 640px`): single column, `grid-template-areas: "topbar" / "content" / "bottombar"`. Sidebar renders as an overlay child of the Shell positioned via `gridRow: "2/4"` (spans content + bottombar, leaves the topbar visible). Backdrop and `<aside>` use `position: absolute` (NOT `fixed inset-0`) so the overlay starts below the topbar — matches the existing project convention recorded in `fab/project/context.md`. The aside carries `role="dialog" aria-modal="true"`. Sidebar overlay open/close is driven by the same `sidebarOpen` state — `drawerOpen` is removed from `ChromeContext`.

**Hamburger placement** — the hamburger icon is statically rendered at `TopBar.left` in both `sidebarOpen` states. Visual "relocation" on collapse is a side effect of the grid column collapsing; there is no component-level relocation logic. When `sidebarOpen === true`, the hamburger sits at the sidebar.right / topbar.left seam (pixel-identical to "attached to sidebar"). When `sidebarOpen === false`, it sits at page.left because the topbar grid cell has extended to page.left.

**Keyboard toggle** — `Cmd+\` (macOS) / `Ctrl+\` (Linux/Windows) toggles `sidebarOpen` from any route that mounts `<Shell>`. The chord is registered in the `KeyboardShortcuts` modal under "Toggle sidebar" per Constitution V (Keyboard-First). The chord is suppressed when focus is inside a real text input/textarea/contenteditable, but NOT when focus is on xterm.js's helper textarea (recognized by `target.closest('.xterm') != null`) — without the xterm exception, the most common focus state would silently swallow the toggle.

**Sidebar width persistence** — width state lives in `ChromeContext` (`sidebarWidth`). The dispatch surface is split: `setSidebarWidth(width)` updates in-memory state only (called per-pointermove during drag), and `persistSidebarWidth(width)` writes `localStorage["runkit-sidebar-width"]` (called once at drag-end). This explicit split avoids localStorage thrash on every pointer event. Callers like the AppShell drag handler track the last-seen width in a ref (`dragLastWidthRef`) and call `persistSidebarWidth` once in `handleEnd`.

**Routing branches inside the `content` grid area**:
- AppShell (`/$server/$window`): `<TerminalClient>` (terminal window) | `<IframeWindow>` (iframe window) | `<Dashboard>` (no window param). The iframe-banner row above `TerminalClient` (when `currentWindow.rkUrl` exists) remains inside `content`.
- BoardPage (`/board/$name`): `DesktopRow` / `MobileCarousel` body (the existing horizontal-scroll panes), flush with `sidebar.right` (no left gutter).

No `max-w-4xl` constraint — all zones span full width.

**ThemeProvider** (`app/frontend/src/contexts/theme-context.tsx`) — outermost provider. Split into ThemeStateContext (preference + resolved theme) and ThemeActionsContext (setTheme). Three modes: system, light, dark. Preference persisted to `localStorage` key `runkit-theme`. System mode listens to `matchMedia("(prefers-color-scheme: dark)")` change events. Theme applied via `data-theme` attribute on `<html>`. Blocking inline script in `index.html` `<head>` reads localStorage and sets `data-theme` before first paint (no-flicker initialization).

**ChromeProvider** (`app/frontend/src/contexts/chrome-context.tsx`) — split into two React contexts: `ChromeStateContext` (read-only state: `currentSession`, `currentWindow`, `sidebarOpen`, `sidebarWidth`, `isConnected`, `fixedWidth`) and `ChromeDispatchContext` (stable setter functions: `setCurrentSession`, `setCurrentWindow`, `setSidebarOpen` — persists to `localStorage["runkit-sidebar-open"]`, `setSidebarWidth` — in-memory only, `persistSidebarWidth(width)` — writes localStorage on drag-end, `setIsConnected`, `toggleFixedWidth`). `drawerOpen`/`setDrawerOpen` were removed when the rotated shell collapsed the desktop-vs-mobile sidebar split into a single `sidebarOpen` boolean (the rendering form differs per breakpoint, the state does not). Chrome derives its content from the current selection — no slot injection. `setLine2Left`, `setLine2Right`, `setBottomBar` removed; top bar and bottom bar read the selection directly. `Breadcrumb` type includes optional `dropdownItems` for session/window switching.

**FocusedTerminalContext** (`app/frontend/src/contexts/focused-terminal-context.tsx`) — tracks the currently-focused terminal across the app so the shell-level `BottomBar` can target the right WebSocket regardless of route. Mounted in `RootWrapper` between `SessionProvider` and `OptimisticProvider`; provider tree order is `ThemeProvider > ChromeProvider > RootWrapper(SessionProvider > OptimisticProvider > FocusedTerminalProvider > Outlet)`. Shape:

```ts
type FocusedTerminal = {
  wsRef: React.RefObject<WebSocket | null>;
  server: string;
  session: string;
  windowIndex: string;
} | null;

type FocusedTerminalContextValue = {
  focused: FocusedTerminal;
  setFocused: (t: FocusedTerminal) => void;
  composeOpen: boolean;
  setComposeOpen: (open: boolean) => void;
};
```

Producers: `TerminalClient` registers on mount when its `registerFocus` prop is `true` (single-terminal route default) and clears on unmount. `BoardPane` registers when its `isFocused` prop becomes true (and on initial pane mount); it manages the registration itself and passes `registerFocus={false}` to its inner `TerminalClient` to avoid producer contention. `BoardPane` does NOT clear on focus loss — the next pane to gain focus overwrites.

Consumers: the shell-level `BottomBar` reads `focused?.wsRef` (replacing the removed `wsRef` prop); the focused pane's `TerminalClient` reads `composeOpen` and gates `<ComposeBuffer>` rendering on `isFocused && composeOpen` (board route) or `composeOpen` (single-terminal route). `composeOpen` persists across route navigation by design — the user dismisses explicitly via Escape. The compose target is frozen at open-time via `compose-buffer.tsx`'s existing wsRef snapshot on mount; cycling focus while compose is open does NOT retarget. When `focused === null` (e.g., Dashboard route with no terminal), BottomBar input handlers natural-no-op via the existing `wsRef.current?.readyState !== OPEN` guard.

**SessionProvider** (`app/frontend/src/contexts/session-context.tsx`) — root-mounted multi-server React Context. One `SessionProvider` instance lives above `<Outlet />` in `RootWrapper` and is shared by every route (`/`, `/$server/...`, `/board/$name`); it does NOT remount on server-route switches. State is keyed per server: `sessionsByServer: Map<string, ProjectSession[]>`, `sessionOrderByServer: Map<string, string[]>`, `isConnectedByServer: Map<string, boolean>`, `metricsByServer: Map<string, MetricsSnapshot | null>`. `currentServer: string | null` is dispatched from the matched route via `useMatches()` — `params.server` for `/$server/...`, `null` for `/board/$name` and `/`. Consumers read the per-server slice they need via `ctx.sessionsByServer.get(name)`; there is no transitional accessor — all consumers consume the keyed shape directly. Provider tree order: `ThemeProvider > ChromeProvider > RootWrapper(SessionProvider > OptimisticProvider > Outlet)`. The provider mirrors `currentServer`'s connection state into `ChromeProvider` so existing chrome consumers (top-bar connection dot) keep working unchanged. Persists most-recently active server to `localStorage` key `runkit-server` (single value, not split per server).

**Per-server EventSource pool** — the provider owns one `EventSource` per attached server, all connecting to `/api/sessions/stream?server=<name>`. The current server is auto-attached; non-current servers attach lazily via the `attachServer(name)` API exposed on context. Each connection updates only its own slice in the per-server maps. When a server disappears from `/api/servers`, its `EventSource` is closed and its entries are removed from every per-server map. A 3-second disconnect debounce runs per server — an `onerror` on one server does not flip another server's `isConnectedByServer` value. In practice today, `useBoards` and `useWindowPins` call `attachServer` for every known server when the sidebar mounts, so the lazy-attach guard mainly bounds open connections in routes where neither hook is mounted.

**`subscribeBoardChange`** (`session-context.tsx`) — context API that lets any consumer register a `(server: string) => void` handler and receive cross-server `board-changed` SSE events through the existing pool. `useBoards` and `useWindowPins` use it to react to per-server board mutations without opening their own `EventSource` instances — the multi-server pool already streams `board-changed`, so multiplexing through one subscriber API keeps the connection count bounded.

**Kill-redirect gate (`currentWindowEverSeen`)** — The "session/window gone" redirect in `app/frontend/src/app.tsx` (around the `useEffect` that calls `computeKillRedirect` from `app/frontend/src/lib/navigation.ts`) MUST NOT fire for a freshly-navigated URL whose (session, window) pair has not yet been observed as valid in the SSE `sessions` stream. `computeKillRedirect` accepts a `currentWindowEverSeen?: boolean` param (defaults to `true` for legacy callers and unit tests) and short-circuits to `null` when false — bypassing both the "session gone → dashboard" and "window gone → nearest sibling" branches. The caller maintains two refs: `currentWindowEverSeenRef` (boolean, flipped `true` whenever the memoized `currentWindow` is non-null) and `lastObservedUrlKeyRef` (string, `${server}|${session}|${window}`). Any URL-key change resets the seen flag to `false`. This gates two transient SSE states that are not proof the URL is unreachable: (a) a stale cached first payload that replays an older server snapshot before delivering current data, and (b) a session whose `windows` array is briefly empty while tmux enumeration propagates into the next poll tick. Without the gate, either state makes `currentSession` or `currentWindow` null on the first SSE event after navigation, the redirect fires, and `TerminalClient` unmounts mid-initialization — surfacing as an e2e flake where `.xterm-screen` briefly appears then disappears. Genuine kills from another client still redirect normally, because the URL key only resets on navigation, not on SSE payload changes: once the seen flag is `true`, a subsequent payload that drops the window triggers the redirect as before.

**Window state (Zustand)** — window optimistic state is managed by a Zustand store at `app/frontend/src/store/window-store.ts`, keyed by the immutable tmux `windowId` (`@N`). `zustand` is a production dependency in `app/frontend/package.json`. The store tracks `killed` and `pendingName` overrides per window ID, and ghost windows awaiting SSE confirmation. `AppShell` drives `setWindowsForSession` from SSE `rawSessions`. `OptimisticContext` retains session and server optimistic state only (name-based keys, not index-based). See ui-patterns.md "Zustand Window Store" section for full shape and action surface.

**`WindowInfo` type** (`app/frontend/src/types.ts`) — includes `windowId: string` (non-optional) as the immutable tmux `@N` identifier, alongside `index: number` (tmux ordering, display-only, not used as a store key). Backend populates `windowId` from `#{window_id}` in the `ListWindows()` format string. `color?: number` — optional ANSI palette index (0-15) from tmux `@color` user option. `panes?: PaneInfo[]` is an optional field (undefined when absent — existing fixtures omit it without type error). `PaneInfo` type: `{ paneId: string; paneIndex: number; cwd: string; command: string; isActive: boolean }`. `rkType?: string` — `"iframe"` or undefined; determines frontend renderer (terminal vs iframe). `rkUrl?: string` — URL for iframe windows; populated from tmux `@rk_url` user-defined option. Both fields use `omitempty` on backend / optional on frontend — terminal windows omit them. `WindowEntry` in `window-store.ts` gains `panes: PaneInfo[]` (non-optional, defaults to `[]` when absent from incoming `WindowInfo`); `setWindowsForSession` syncs panes from incoming data on every SSE tick.

**`MetricsSnapshot` type** (`app/frontend/src/types.ts`) — mirrors the backend `metrics.MetricsSnapshot` struct. Fields: `hostname` (string), `cpu` (`{ samples: number[], current: number, cores: number }`), `memory` (`{ used: number, total: number }` bytes), `load` (`{ avg1, avg5, avg15: number, cpus: number }`), `disk` (`{ used: number, total: number }` bytes), `uptime` (number, seconds). Consumed by `HostPanel` via `SessionProvider` context.

**TopBarChrome** (`app/frontend/src/components/top-bar-chrome.tsx`) — reads from ChromeProvider. Single line (`border-b border-border`). Left: hamburger icon (☰, animates to ✕ when `sidebarOpen` is true — driven by `sidebarOpen` alone, not the removed `drawerOpen`) + breadcrumbs/info per `mode` prop. Right (desktop): logo (decorative) + "Run Kit" text + green dot (no text label) + FixedWidthToggle (route-agnostic — renders unconditionally, formerly gated on `currentWindow`) + ThemeToggle (cycles system → light → dark) + >_ compose. Right (mobile): ⋯ + >_. `onOpenCompose` callback received as prop.

**TopBar `mode` prop** — `mode: "terminal" | "board" | "root"` (default `"terminal"`) controls the left/center region. AppShell passes `mode="terminal"` (today's `☰ session / window` breadcrumbs) for terminal routes and `mode="root"` for the dashboard route (`☰ Dashboard`). BoardPage passes `mode="board"` with `boardName`, `paneCount`, `serverCount`: the left section renders `☰ Board ▸ {name} ▾` (the existing `BoardSwitcherDropdown`, now imported by TopBar in board mode) followed by an inline-info span `{N} pane[s] · {M} server[s] · ⌘[⌘] cycle` (singular/plural correct, `text-xs text-text-secondary`), hidden on `< 640px` via `hidden sm:inline`. Right-section chrome is unchanged across modes.

**BoardPage adopts Shell** — `BoardPage` (`app/frontend/src/components/board/board-page.tsx`) wraps its body in `<Shell>` like AppShell; the bespoke `h-screen w-screen flex` root, the inline mini-header (`Board ▸ {name} ▾`), and the per-route mini-sidebar wrapper are gone. `BoardSwitcherDropdown` moved to/imported by `TopBar` in board mode. BoardPage gets a `<BottomBar>` for the first time — the board route had none pre-change. The `<BottomBar />` invocation is byte-identical across AppShell and BoardPage: the same three callbacks (`onOpenCompose`, `onFocusTerminal`, `onScrollLockChange`) are wired identically. `onOpenCompose` calls `setComposeOpen(true)` from `FocusedTerminalContext`; `onFocusTerminal` invokes a ref-tracked `focusFocusedPaneRef.current()` that re-focuses the currently-focused board pane; `onScrollLockChange` plumbs through `DesktopRow`/`MobileCarousel` → `BoardPane` → `TerminalClient.scrollLocked`.

**Sidebar** (`app/frontend/src/components/sidebar/`) — session/window tree with bottom-aligned collapsible panels. The same `<Sidebar>` instance renders for both AppShell (`/$server/...`) and BoardPage (`/board/$name`); BoardPage no longer ships its own mini-sidebar. Sessions are grouped per server — one `CollapsiblePanel`-style group per server in `servers`, each fed by its slice from `sessionsByServer`. The `currentServer` group carries a visual marker; on board routes (`currentServer === null`) no group is marked. Desktop: drag-resizable (default 220px, min 160, max 400, persisted to `localStorage`), collapsible via `☰`. Mobile (< 768px): drawer overlay from the left, triggered by `☰`. Layout: ServerPanel -> Boards section -> per-server session groups (`flex-1 overflow-y-auto`) -> WindowPanel (collapsible, `storageKey="runkit-panel-window"`) -> HostPanel (collapsible, `storageKey="runkit-panel-host"`). The Sidebar reads multi-server state from context (`sessionsByServer`, `sessionOrderByServer`, `isConnectedByServer`, `metricsByServer`); `currentServer` is passed in as a prop (AppShell reads it from context and forwards; BoardPage passes `null`). Action callbacks (create/kill, resize start, select) come in as props.

**BottomBar** (`app/frontend/src/components/bottom-bar.tsx`) — rendered at shell level via `<Shell>`'s `bottombar` grid area on every route that mounts `<Shell>` (single-terminal, board, dashboard). Width tracks the right grid column (page width minus sidebar). Single row of `<kbd>` buttons: modifier toggles (Ctrl/Alt with sticky armed state — Cmd removed), Esc, Tab, arrow keys, Fn dropdown (F1-F12, PgUp/PgDn, Home/End). No compose button (moved to top bar). Buttons: 36px desktop (`min-h-[36px] min-w-[36px]`), 44px touch (`coarse:min-h-[44px] coarse:min-w-[36px]`). Modifier state managed by `useModifierState` hook (Ctrl/Alt only). The `wsRef` prop was removed from `BottomBarProps` — input handlers read `focused?.wsRef` from `FocusedTerminalContext` and send ANSI escape sequences through it. When `focused === null` (Dashboard route, no terminal), input handlers natural-no-op via the existing `wsRef.current?.readyState !== OPEN` guard — no error, no toast.

**ComposeBuffer** (`app/frontend/src/components/compose-buffer.tsx`) — native `<textarea>` overlay triggered by the compose button or file upload. Supports iOS dictation, autocorrect, paste, multiline. Send button (or Cmd/Ctrl+Enter) transmits entire text as a single WebSocket message. Terminal dims (`opacity-50`) while compose is open. Escape dismisses without sending. Accepts optional `initialText` prop for pre-populating with uploaded file paths; appends on subsequent updates while open.

**iOS Keyboard Support** — `useVisualViewport` hook (`app/frontend/src/hooks/use-visual-viewport.ts`) listens to both `resize` and `scroll` events on `window.visualViewport`, setting `--app-height` CSS custom property from `visualViewport.height`. In the single-view model (fullbleed always on), `globals.css` applies `position: fixed; inset: 0; height: var(--app-height, 100vh)` to the app shell. The bottom bar stays pinned above the keyboard; the terminal shrinks via `flex-1` and xterm refits via `ResizeObserver`.

**iOS Touch Scroll Prevention** — Fullbleed is always active in the single-view model. The `fullbleed` class is added to `<html>` by `useVisualViewport` on mount (also present as a static default in `index.html` for FOUC prevention). `globals.css` applies `overflow: hidden` and `overscroll-behavior: none` to both `html` and `body` via `html.fullbleed` selectors, preventing iOS Safari elastic bounce scrolling. The terminal container div uses `touch-none` (`touch-action: none`) so the browser yields touch gestures to xterm.js for scrollback handling.

**Browser title** — `useBrowserTitle` hook sets `document.title` dynamically based on hostname (fetched once from `GET /api/health` on app init) and route params. Dashboard: `RunKit — {hostname}`. Terminal: `{session}/{window} — {hostname}`. Omits hostname suffix when empty. Static `<title>RunKit</title>` in `index.html` remains as pre-hydration fallback.

Single-view model: there are no page transitions or per-page chrome injection. The chrome reads the current selection and renders directly.

## CLI Subcommands (Cobra)

`app/backend/cmd/rk/` uses `spf13/cobra` for CLI management. `root.go` declares `var version = "dev"` (overridden by ldflags) and registers all subcommands. `main.go` just calls `execute()`. Version info via `--version`/`-v` global flag (Cobra built-in, not a subcommand).

| Subcommand | File | Behavior |
|------------|------|----------|
| *(none)* | `root.go` | Defaults to `serve` (backwards compat) |
| `serve` | `serve.go` | Start HTTP server (foreground-only) — loads config from env vars, chi router, graceful shutdown via SIGINT/SIGTERM. No lifecycle flags (the pre-`260528-5zvv` `-d`/`--restart`/`--stop` flags were removed in a hard break — use `rk daemon ...` instead). Still imports `internal/daemon` for `daemon.LogEnvVar` consumed by `setupSlog`'s tee-to-file logic |
| `daemon` | `daemon.go`, `daemon_start.go`, `daemon_stop.go`, `daemon_restart.go`, `daemon_status.go`, `daemon_portowner.go` | Top-level command tree (sibling of `serve`, not nested) managing the background daemon. Subcommands: `start [--force]`, `stop [--force]`, `restart [--force]`, `status [--json]`. `--force` overrides the port-claim safety check by SIGTERMing a non-daemon port owner (graceful-then-forceful via `terminateOwner`); refuses to kill itself when the owner PID matches `daemon.InnerServePID()`. `status` is read-only and rejects `--force`. Port-owner lookup via `lsof -ti:<port>` → `ss -tlnp` fallback in shared `daemon_portowner.go` (port-only query, host pass-through for display, `exec.CommandContext` 5s timeout). Subcommand vars (`daemonStartCmd`, etc.) injectable in tests via package-level `var findPortOwner = findPortOwnerImpl` and `var innerServePIDFn = daemon.InnerServePID`. See `## Daemon Lifecycle` for full semantics |
| `update` | `upgrade.go` | Alias: `upgrade`. Detect Homebrew install (`os.Executable()` path contains `/Cellar/rk/`). If Homebrew: `brew update --quiet`, check latest version via `brew info --json=v2`, skip if already up to date, else `brew upgrade sahil87/tap/rk` + auto-restart daemon via `daemon.RestartWithBinary(brewBinPath)` where `brewBinPath` is derived from the Cellar path (`<brewPrefix>/bin/rk`), not stale `os.Executable()`. Non-Homebrew: print reinstall instructions. **`--skip-brew-update` flag** (local cobra bool, default `false`; since `260531-ckwv-skip-brew-update-flag`): gates ONLY the internal `brew update --quiet` tap-metadata refresh; the `brew info` version check, up-to-date short-circuit, `brew upgrade`, and daemon restart all still run. Default (flag absent) behavior is byte-for-byte unchanged. The three brew call sites and the daemon restart route through package-level seam vars (`runBrewFn`, `restartDaemonFn`, `resolveExeFn`) matching the `innerServePIDFn` idiom so `upgrade_test.go` can stub them without spawning `brew` or restarting a real daemon |
| `doctor` | `doctor.go` | Check runtime dependencies only — `exec.LookPath("tmux")`. Exit 1 if any check fails |
| `status` | `status.go` | List tmux sessions with window counts via `internal/tmux.ListSessions()` + `ListWindows()`. No server required |
| `context` | `context.go` | Agent-optimized environment info — prints Environment (dynamic tmux pane context), Capabilities (terminal windows, iframe windows, proxy, categorized CLI commands), and Conventions (tmux user options, window lifecycle, SSE reactivity) sections as plain text. Uses `exec.CommandContext` (5s timeout) for tmux queries targeting the pane's own server via `tmux.OriginalTMUX` (restores `$TMUX` in child env since `internal/tmux` init strips it). Server URL via `config.Load()`. Gracefully degrades outside tmux. CLI Commands section lists `rk riff` under **Workflow** |
| `init-conf` | `initconf.go` | Scaffold default tmux.conf to `~/.rk/tmux.conf` from embedded config and create `~/.rk/tmux.d/` drop-in directory. `--force` to overwrite config (tmux.d contents untouched) |
| `riff` | `riff.go` | Create a worktree, open a new tmux window in it, and launch a Claude Code session. Repeatable pane flags: `--skill <skill>` (claude pane), `--cmd <cmd>` (shell pane) — argv order sets pane order; bare forms allowed (blank claude / bare shell). Layout flags: `--layout <name>` accepts 6 canonical tmux layouts + 6 shortforms (`a`/`t`/`h`/`v`/`deck-h`/`deck-v`); default `auto` picks by pane count. Preset flags: positional `rk riff <preset>` or `--preset <name>` resolve config-defined presets (`riff.presets.<name>` in `fab/project/config.yaml`); `--list-presets` prints them and exits. Fan-out: `--fan-out N` spawns N identical windows in parallel, each with its own `wt` worktree. `-- <wt-flags...>` passthrough to `wt create`. Preconditions: `$TMUX` set + `wt` on PATH (fast-fail, exit 2). Launcher resolved via `internal/fabconfig.ReadSpawnCommand` (falls back to `claude --dangerously-skip-permissions`). All subprocess calls via `exec.CommandContext` (wt: 30s, tmux: 10s). `tmux.OriginalTMUX` restored in child env so tmux targets the user's current server. Local exit-code wrapper returns 2 (precondition) / 3 (subprocess) without modifying `main.execute()`. See `rk-riff.md` for full reference |
| `help-dump` | `help_dump.go` | **Hidden build tooling** (`Hidden: true`) — not in user-facing help, self-excludes from its own dump. Emits the CLI help tree as JSON to stdout, or to a file when an optional `[output-path]` arg is given (`cobra.MaximumNArgs(1)`, stdlib `os.WriteFile` 0o644, no subprocess — pure in-process Cobra introspection per §I). `captureNode` walks `rootCmd.Commands()` recursively to full depth, dropping `completion`, `help`, and any `Hidden` node at every level (a hidden parent's subtree is dropped entirely) via `includeInDump`; leaves serialize `"commands":[]` (non-nil slice), never `null`. Per-node fields `name`/`path`/`short`/`usage`/`text` come from `cmd.Name()`/`CommandPath()`/`Short`/`UseLine()`/`UsageString()` (raw, newlines preserved). The **frozen JSON contract** is `{tool:"rk", version, captured_at (RFC3339 UTC), schema_version:1 (const), root:node}`, mirroring `sahil87/shll.ai` `help/wt.json`. `version` comes from `displayVersion()` (ldflags `-X main.version`, never hardcoded); `captured_at` via the injectable `nowUTC` package var + the pure `buildDump(root, version, now)` builder (test seam — no global time freeze). shll.ai **pulls** this dump by running `rk help-dump` itself on a schedule and capturing the output — the in-repo release-pipeline publish step that used to push it was retired (`260603-iak3`, see `## Release Flow & CI/CD`); `help-dump` remains the single contract surface shll.ai depends on |

## Embedded Frontend Assets

`app/backend/build/embed.go` exposes `//go:embed all:frontend` as `build.Frontend` (`embed.FS`). During development, `frontend/` contains only `.gitkeep` (empty FS). Production builds copy `app/frontend/dist/` into `app/backend/build/frontend/` before `go build`.

`api/spa.go` uses dual-mode serving:
- **Production**: `hasEmbeddedAssets()` detects real build output (more than `.gitkeep`), serves from `embed.FS` via `mountEmbeddedSPA()`
- **Development**: Falls back to filesystem-based serving from `app/frontend/dist/` via `mountFilesystemSPA()` (Vite dev server handles frontend)

Both modes include SPA fallback (serve `index.html` for non-matching paths) and API/relay route guards.

## Version Management

- **Source of truth**: `VERSION` file at repo root — plain semver string (e.g., `0.1.0`), no `v` prefix
- **Build injection**: `scripts/build.sh` reads `VERSION` and passes `-X main.version=$(cat VERSION)` via ldflags
- **Go variable**: `var version = "dev"` in `root.go` — defaults to `"dev"` for local `go build` without ldflags
- **Used by**: `run-kit version` (display), `run-kit update` (shows current version, compares with latest via `brew info --json=v2`)

## Build Pipeline

`scripts/build.sh` encapsulates the full production build (frontend-first for embed):

1. `cd app/frontend && pnpm build` — produces `app/frontend/dist/` (with vendor chunk splitting: `xterm` chunk for `@xterm/xterm`, `@xterm/addon-fit`, `@xterm/addon-web-links`; `router` chunk for `@tanstack/react-router`; plus lazy-loaded component chunks for `CommandPalette`, `ThemeSelector`, `CreateSessionDialog`). `zustand` is bundled as a production dependency.
2. Copy `app/frontend/dist/` → `app/backend/build/frontend/` (Go embed cannot reference `../` paths)
3. Read version from `VERSION` file
4. `CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o ../../dist/run-kit ./cmd/run-kit`

Output: `dist/run-kit` — single static binary with embedded frontend assets, tmux config, and baked-in version.

`justfile` recipes: `build` delegates to `scripts/build.sh`, `release` delegates to `scripts/release.sh`.

## PWA Layer

`vite-plugin-pwa` (devDependency) adds Progressive Web App support to the Vite build. Configured in `app/frontend/vite.config.ts` with `registerType: "autoUpdate"`.

**Manifest**: Generated from the `VitePWA` plugin config in `vite.config.ts` — no separate `manifest.json` file. Specifies `display: "standalone"`, `start_url: "/"`, dark theme colors (`background_color` and `theme_color` set to `#0f1117`), and an icon array (192px, 512px, 512px maskable). The plugin injects a `<link rel="manifest">` tag into the built `index.html` automatically.

**Service worker**: Workbox-generated (`sw.js`). Precaches all static assets (JS, CSS, HTML) from the Vite build output. Runtime caching rules:
- `NetworkOnly` for URL patterns matching `/api/` and `/relay/` — SSE streams and WebSocket connections always hit the server
- `navigateFallback: "/index.html"` — ensures TanStack Router client-side routes work when served from cache (e.g., homescreen launch to `/{session}/{window}`)

Updates are silent (`registerType: "autoUpdate"`) — the service worker detects new builds and caches updated assets without user interaction or reload prompts.

**Build output**: `app/frontend/dist/` gains `sw.js` and `manifest.webmanifest` alongside the existing Vite output. These files are copied into `app/backend/build/frontend/` by `scripts/build.sh` and served by the Go backend via the existing SPA static serving — no backend changes required.

**Icons**: Canonical SVG mark at `app/frontend/public/icon.svg` (hexagonal cube, transparent background). Generated variants in `app/frontend/public/generated-icons/`: `favicon.svg` (copy of icon.svg), `icon-192.png`, `icon-512.png`, `icon-512-maskable.png` (PNGs have solid `#0f1117` dark background with 20% padding, maskable has 40%). Generated by `scripts/generate-icons.sh` (uses `sharp` Node library). Run via `just icons`.

## Release Flow & CI/CD

**Release script** (`scripts/release.sh`): accepts bump level (`patch`/`minor`/`major`), increments `VERSION` file semver, commits with message `v{version}`, creates git tag `v{version}`, pushes commit + tag. Tag push triggers CI.

**GitHub Actions** (`.github/workflows/release.yml`): triggers on `v*` tag push. Steps: checkout → setup Go (from `go.mod`) → setup Node 20 + pnpm → install frontend deps → build frontend → copy dist to backend → cross-compile 4 targets → create GitHub Release with tarballs → update Homebrew tap.

Cross-compile targets: `darwin/arm64`, `darwin/amd64`, `linux/arm64`, `linux/amd64`. Each target built with `CGO_ENABLED=0` and ldflags. Output: `rk-{os}-{arch}.tar.gz` tarballs uploaded to GitHub Release via `softprops/action-gh-release`.

**Help-tree publish to shll.ai — RETIRED (push → pull, `260603-iak3-teardown-shllai-publish-ci`):** the release job no longer publishes the help tree. The former **final** CI step (`Publish help tree to shll.ai`, deliberately placed after the GitHub Release and Homebrew tap) ran `dist/rk-linux-amd64/rk help-dump help/run-kit.json` (the versioned linux/amd64 artifact, so the dumped `version` matched the release) then opened a best-effort auto-merge PR into `sahil87/shll.ai` via the `SHLLAI_TOKEN` cross-repo write secret — that entire step was removed. shll.ai inverted its integration model from **push** (each toolkit CLI produced its help JSON in CI and PR'd it into shll.ai) to **pull** (shll.ai now runs `rk help-dump` itself on a schedule and captures the output), making the push path redundant work and a redundant attack surface (dual writers to `help/run-kit.json` could race). The `SHLLAI_TOKEN` reference was removed from CI along with the step; the GitHub repo secret itself is an operator follow-up to delete (least-privilege, no remaining consumer). The hidden `rk help-dump` subcommand is **preserved unchanged** as the single contract surface shll.ai pulls from (see `## CLI Subcommands`). After removal, the `release` job's final step is **Update Homebrew tap**. (Historical: the push step was added in `260602-a36m-help-dump-shll-ai` and hardened in `260602-2dt9-fix-shllai-help-publish`.)

**Homebrew tap update** (now the **final** step in the `release` job): computes SHA256 for all 4 tarballs, clones `sahil87/homebrew-tap` via the `HOMEBREW_TAP_TOKEN` secret (exported as `TAP_TOKEN`), generates `Formula/rk.rb` from `.github/formula-template.rb` (placeholder substitution via `sed`), commits and pushes.

## Homebrew Distribution

**Formula**: `Formula/rk.rb` in `sahil87/homebrew-tap` (generated by CI from `.github/formula-template.rb`). Public repo — plain URL downloads, no auth token needed. Platform detection via `on_macos`/`on_linux` + `on_arm`/`on_intel`. Version and SHA256 values substituted by CI.

Install flow: `brew tap sahil87/tap && brew install rk`. Update: `rk update`.

## Boards Feature

Pane boards are named collections of pinned tmux windows rendered as a horizontal pane dashboard at `/board/$name`. Since `260602-qn62-move-based-board-pin-sessions` the model is **move-based and server-scoped**: a pinned window is physically `move-window`'d into its own single-window session `_rk-pin-<id>`, and a board is the **set of pin-sessions sharing an `@rk_board` SESSION var** (not a tmux session itself, and no longer a `@rk_board` server-option encoding). Boards are server-scoped because `move-window` can't cross tmux servers — the board *list* is summarized across reachable servers, but there is no cross-server window union. A board exists only while ≥1 pin carries its name (no empty boards, no registry). See `tmux-sessions.md` § Pin Sessions and § `@rk_board` for the full pin/unpin/reorder contract, the three session vars (`@rk_board`/`@rk_home`/`@rk_board_order`), order keys, and the direct-attach relay.

### Route Placement

| Route | View | Layout |
|-------|------|--------|
| `/board/$name` | Cross-server pane dashboard | Wrapped in the shared `<Shell>` grid (same wrapper as AppShell). Sidebar grid area = unified `<Sidebar currentServer={null}>`; topbar = `<TopBar mode="board">`; content = `DesktopRow`/`MobileCarousel`; bottombar = shared `<BottomBar>` (new on this route) |

`/board/$name` is registered as a child of `rootRoute` in `app/frontend/src/router.tsx`, peer to `serverLayoutRoute`. It does NOT mount AppShell — boards aggregate windows across servers, so AppShell's single-server chrome semantics (server-scoped breadcrumbs, server-scoped command-palette actions) do not apply. As of `260509-17m3-rotated-shell-layout`, BoardPage shares the AppShell `<Shell>` grid wrapper and renders `<TopBar mode="board">` + `<BottomBar>` in the shell's named areas — the route reuses the chrome topology while routing different breadcrumb / pane-area content into it. BoardPage retains its own `<CommandPalette>` mount because board-route-only entries (Switch / Leave Board View / Cycle Pane Focus) need a registration site, and the AppShell palette doesn't mount on `/board/<name>`. `SessionProvider` lives at the root and exposes per-server state, so the board route renders `<Sidebar currentServer={null} ...>` directly with no special-case. The Sidebar's per-server session groups + Boards section serve discovery and entry from any route. See ui-patterns.md § Boards View for the layout.

### Backend Surface

| Layer | File | Responsibility |
|-------|------|---------------|
| Storage | `app/backend/internal/tmux/board.go` | move-based `Pin`/`Unpin`/`Reorder`, `pinEntry`, `ListBoardEntries`/`ListBoards`/`GetBoard` (derived from `_rk-pin-*` session vars), `ComputeOrderKey`/`nextAppendKey` (lowercase-`a..z` fractional indexing), `BoardOption`/`HomeOption`/`BoardOrderOption` session-var keys, validators (`ValidBoardName`, `ValidWindowID`, `ValidOrderKey`). The old `@rk_board` server-option parser/serializer (`parseBoardValue`/`serializeBoardValue`/`setBoardValue`), cross-server `ListAllBoardEntries`, and `RemoveAllByWindowID` are **deleted** (since `260602-qn62`). Pin-session naming (`PinSessionPrefix`/`PinSessionName`/`WindowIDFromPinSession`/`ListPinSessionNames`) lives in `tmux.go` |
| HTTP handlers | `app/backend/api/boards.go` | `handleBoardsList`, `handleBoardGet`, `handleBoardPin`, `handleBoardUnpin`, `handleBoardReorder` — register in `router.go` `buildRouter()`; each emits its own `board-changed` SSE event |
| Interface | `app/backend/api/router.go` `TmuxOps` | `ListBoards`, `GetBoard`, `ListBoardEntries`, `PinBoard`→`tmux.Pin`, `UnpinBoard`→`tmux.Unpin`, `ReorderBoard` (uses `lookupNeighbourKeys` + `ComputeOrderKey` then `tmux.Reorder`). `260602-qn62` removed `NewGroupedSession` and `SetSessionOwnerPID` from the interface + `prodTmuxOps` + `mockTmuxOps` |
| SSE | `app/backend/api/sse.go` | `BoardEntriesFetcher` (now a one-method `ListBoardEntries` interface for test stubbing), `broadcastBoardChanged(server, payload)`, `event: board-changed` (kebab-case; `change` ∈ `pin`/`unpin`/`reorder` only). Since `260602-qn62`: NO `previousBoardJSON` cache, NO bootstrap-on-first-poll, NO cached-snapshot replay, NO window-kill `cleanup` diff — a killed pinned window's pin-session just disappears from the next live `ListBoardEntries` read |

### Constitution Alignment

- **II (No Database)** — pin state lives only in tmux: the `_rk-pin-*` session structure + its `@rk_board`/`@rk_home`/`@rk_board_order` session vars (a deliberate shift since `260602-qn62` from "a tiny derived server-option value" to "tmux session structure IS the record"). Browser `localStorage` holds only view-local pane widths
- **IV (Minimal Surface Area)** — adds exactly one route (`/board/$name`); `260602-qn62` net-reduced backend surface (deleted the relay ephemeral subsystem, the `@rk_board` encoding, the startup sweep, and the SSE board-cleanup/bootstrap) while keeping one rendering path (`TerminalClient`/`/relay/{windowId}`)
- **V (Keyboard-First)** — pin/unpin/switch/cycle reachable via Cmd+K palette; pane focus cycle via Cmd+[/]
- **VI (Tmux Sessions Survive Server Restarts)** — pin state persists with the tmux server across rk-go restarts, so there is NO restore-sweep (since `260602-qn62`); `tmux kill-server` loses the boards on that server (expected and acceptable). An emptied home session (its only window pinned away) persists via the `exit-empty off` backstop (`260602-a1wo`) so unpin can restore the window to it
- **VII (Convention Over Configuration)** — no new env vars, no new config files; reuses the established `@rk_*` user-option pattern

## Design Decisions

- **Go backend + Vite SPA over Next.js monolith** — decouples frontend and backend for independent iteration. Go backend is a stable, long-lived API that outlives any individual frontend. Multi-client API support (web, mobile, CLI) without split API surface
- **Single port architecture** — Go serves API, WebSocket relay, and SPA static files on one port. The two-port split (Next.js :3000, relay :3001) was a Node.js artifact — separate processes required separate ports. Go serves everything in one binary
- **chi over stdlib ServeMux** — chi for middleware chaining (CORS, logging, recovery). Go 1.22+ ServeMux has pattern matching but lacks ergonomic middleware composition
- **TanStack Router over React Router** — type-safe params and search params, built-in loader pattern. Single route `/:session/:window` in the new frontend
- **Vite proxy in dev (not CORS)** — single browser URL, no CORS config needed. WebSocket upgrade works transparently. Go includes chi CORS middleware for production/non-browser clients
- **SPA fallback in Go** — Go serves standalone; TLS termination handled externally via Tailscale Serve when needed
- **SSE (not WebSocket) for session state** — simpler, server-push only, naturally resilient. Module-level hub deduplicates polling across tabs (one `FetchSessions()` per server per interval). Clients indexed by server name (`map[string][]*sseClient`) — broadcast iterates only the target server's slice. `sync.RWMutex` with `RLock` for read-only poll operations, `Lock` for writes. SSE data includes `isActiveWindow` per window, enabling UI sync when users switch tmux/byobu windows via terminal shortcuts
- **Full snapshots (not diffs)** — small payload (<100 sessions), simple client logic
- **Independent panes per browser client** — no cursor fights, agent pane untouched. The relay pty follows tmux window switches natively (runs `tmux -L runkit attach-session`)
- **Every tmux session is a project** — no config, no "Other" bucket. Project root derived from window 0's `pane_current_path`
- **Config via env vars (not YAML)** — `.env` committed with defaults (`RK_PORT`, `RK_HOST`), `.env.local` for overrides, loaded via `.envrc` (direnv). Go and Vite read `RK_PORT`/`RK_HOST` directly. `dev.sh` translates `RK_PORT+1` for the backend subprocess. No relay port — single port serves everything
- **Dedicated tmux server (`-L runkit`)** — run-kit sessions live on a named tmux server `runkit` with its own config (`internal/tmux/tmux.conf` loaded via `-f`). The default tmux server is also queried for session discovery (read-only display of external sessions). This replaces byobu integration: `CreateSession()` uses plain `tmux new-session` on the runkit server (no byobu dependency). The `runkit` server provides isolation, a custom status bar matching run-kit's dark theme, and byobu-style F-key keybindings (F2/F3/F4). `SessionInfo` carries a `Name` field; `ProjectSession` has `Name` and `Windows` fields. The caller passes the target server name to each tmux operation
- **Multi-server session enumeration** — `ListSessions(ctx, server)` queries the specified tmux server. The API layer calls it once per server (e.g., `runkit` and `default`), merging results. Session-group filtering applies to both servers. See `docs/memory/run-kit/tmux-sessions.md`
- **Derived chrome (not slot injection)** — Single-view model means only one chrome state (terminal-focused). Top bar and bottom bar derive content from the current session:window selection. No `setLine2Left`/`setLine2Right`/`setBottomBar` setters. Split React Context preserved for performance (state vs dispatch).
- **Root-mounted multi-server SessionProvider with per-server EventSource pool** — One `SessionProvider` instance lives at the root above `<Outlet />` (in `RootWrapper`) and is shared by every route. State is keyed per server (`Map<string, T>`); `currentServer: string | null` is dispatched from the matched route via `useMatches()`. The pool opens one `EventSource` per server lazily — current server eager, others via `attachServer(name)`. Provider survives server-route switches (no remount, no state rebuild) and lets the Board route render the same unified `<Sidebar>` as AppShell with `currentServer === null`. Cross-server `board-changed` events are multiplexed through `subscribeBoardChange` so cross-server hooks (`useBoards`, `useWindowPins`) don't open separate connections. Rejected: per-route providers bridged via a sibling cross-server context — adds two contexts, double-fetches `/api/servers`, doesn't solve BoardPage sidebar reuse cleanly. Rejected: `Record<string, T>` instead of `Map<string, T>` — Maps give clear "server unknown" vs "server known with empty value" semantics. (`260508-dc0t-multiserver-session-provider`)
- **Single-view layout (sidebar + terminal/dashboard) replaces three pages** — Dashboard and Project page functionality subsumed by the sidebar + Dashboard view. Terminal is the main content on `/:session/:window`; Dashboard renders on `/`. No page transitions.
- **Dashboard as inline component in app.tsx (not separate route layout)** — The Dashboard renders in the terminal area via a conditional branch (`sessionName && windowIndex ? <TerminalClient/> : <Dashboard/>`). This reuses the existing chrome skeleton (top bar, sidebar) and avoids duplicating the app shell layout. Rejected: separate route with dedicated layout — would require lifting sidebar/chrome into a shared layout component and restructuring the route tree.
- **POST-only API client with path-based intent** — Each mutation is a separate function with its own URL (e.g., `killSession(session)` → `POST /api/sessions/:session/kill`). No multiplexed `action` field in request bodies. Formalized as constitution §IX (Uniform HTTP Verb) and brought into full conformance by `260529-jad6` (the last five `.Put(` routes migrated to `POST`; CORS `AllowedMethods` → `[GET, POST, OPTIONS]`). Semantics that would conventionally map to `PUT`/`PATCH` (partial update) live in the path + body contract instead of the verb.
- **Shared chained-`set-option` primitive + "validate-all-then-one-atomic-invocation" partial-merge** (`260529-jad6`) — `WindowOptionOp{Key; Value *string}` + `appendOptionOps(args, target, ops)` is a single reusable primitive for emitting any sequence of `set-option -w` set/unset ops as one `\;`-chained tmux invocation. Both `SetWindowOptions` (the `POST /options` endpoint) and `CreateWindowWithOptions` (window creation) consume it — there is no separate inline option-map construction path. The `POST /options` handler pairs this with a strict ordering contract: **all keys are validated (allowlist + per-key rules) before any tmux subprocess is spawned**, so a single invalid key aborts with `400` and zero tmux calls (no partial application), and the merge that does run is one atomic chained invocation the SSE poll can never observe half-applied. This validate-all-then-atomic pattern is the reusable shape for future multi-key tmux mutations. *Why*: partial-merge (present key sets, `null` unsets, absent untouched) avoids the "client clears a field it didn't echo" bug class that a full-replace PUT re-introduces; the key allowlist (`@color`/`@rk_url`/`@rk_type`) bounds the option-name surface passed to `tmux set-option` (§I). *Rejected*: a generic action-dispatcher for all window verbs (weaker per-action validation, fuzzier §I audit surface); PATCH/PUT full-replace (violates §IX / re-introduces the cleared-field bug); passing through arbitrary `@`-prefixed keys (unvalidated surface).
- **Session as server-derived disambiguation context for REST `/select`** (`260529-jad6`) — `handleWindowSelect` resolves the owning session server-side (`ResolveWindowSession`) and issues a session-scoped `select-window -t <session>:@N` (`SelectWindowInSession`) rather than a bare `select-window -t @N`. *Why*: a bare window-id target is ambiguous inside a tmux session group (members keep independent active-window state, so tmux may activate the window on the wrong member — the wrong-window-highlight symptom, live since #198 made tmux the sidebar's source of truth). The session is derived only where tmux semantics require it; the client never sends it (consistent with dropping `$session` from the URL). *Rejected*: a bare-target select with a client-supplied session (puts the session back in the client-facing identity).
- **Sidebar + drawer pattern on mobile** — Desktop sidebar is drag-resizable (default 220px, min 160, max 400, localStorage persist), collapsible. Mobile (< 768px) uses a left-side drawer overlay triggered by `☰`. Preserves session/window tree layout across breakpoints.
- **Prebuilt binaries over build-from-source Homebrew formula** — ship cross-compiled binaries via GitHub Release. Zero build dependencies on user's machine (no Go, Node.js, pnpm). Faster install. Rejected: build-from-source formula (like tu) which requires all build tools. (`260317-ukyz-homebrew-deployment`)
- **`embed.FS` with copy step over restructuring repo** — copy `app/frontend/dist/` into `app/backend/build/frontend/` at build time because Go's `//go:embed` cannot reference files outside the package directory. Simple build-time operation. Rejected: colocating frontend output with Go source (breaks dev workflow). (`260317-ukyz-homebrew-deployment`)
- **VERSION file + ldflags over Go constant** — version sourced from `VERSION` file, injected via `-X main.version=...`. Shell scripts can read/write plain text. No code changes for version bumps. Rejected: Go constant (requires code change per release). (`260317-ukyz-homebrew-deployment`)
- **Cobra over custom CLI parsing** — industry standard, auto-generates help, 5 subcommands is the sweet spot. Rejected: stdlib `flag` (no subcommand support), custom parsing (unnecessary). (`260317-ukyz-homebrew-deployment`)
- **SPA handler dual-mode (embedded FS vs filesystem)** — `hasEmbeddedAssets()` checks whether `frontend/` contains more than `.gitkeep`. Production uses `embed.FS`, dev uses filesystem (Vite handles frontend). Automatic detection, no build tags or env vars needed. (`260317-ukyz-homebrew-deployment`)
- **Active window sync — tmux truth, URL as bookmark** — tmux is the sole source of truth for "current window" per server. The URL is a resumable bookmark used only on mount (and reload); after mount it is derived state, rewritten by an SSE-driven `navigate({ replace: true })` whenever the server-reported `isActiveWindow` differs from the URL. Mount-time alignment fires exactly one `selectWindow` to align tmux to a deep-linked URL (gated by `hasAlignedToUrlRef`). All clients on the same server converge ("yank" semantics) — cross-server tabs stay independent. Sidebar clicks are pure mutations (`selectWindow` only); no client-side timer or debounce. The previous 3-second `userNavTimestampRef` debounce was deleted entirely — it was protecting client-owned selection state that no longer exists. Push channel is tmux control mode (`tmux -CC`) via the new `internal/tmuxctl/` package; primary latency target sub-500ms, with a 12-second safety-net poll as backstop. Boards get a "free win" as a side effect — board cleanup now tracks control-mode latency rather than the prior 2.5s poll cadence. Rejected: per-tab current window (reintroduces a second writer, doesn't match the dashboard model). Rejected: hooks + `rk notify` + lockfile (incompatible with PR #197's Constitution §II Non-Goal). Supersedes the 2026-03-07 `history.replaceState` SSE-polling decision. (`260528-nvlp-active-window-sync`)
  - *Refined by `260530-v6hm-active-window-event-derivation`*: the `isActiveWindow` that drives this writeback is now **event-derived per session group** (two-tier: event-tracked `@wid` authoritative, base `#{window_active}` cold-start/reconnect fallback), not read from the base session pointer alone. The base pointer goes stale when an `rk-relay-*` ephemeral / `rk riff` / external `new-window` moves the active window (group members keep independent pointers), which is why the highlight previously lagged. The fix is purely in the derivation/read path — no creator changed, the ephemeral model is untouched, and the frontend writeback needed no edit. See § Active-Window Event Derivation.
- **Sticky modifier state via useRef + forceUpdate** — `useModifierState` uses a ref for the authoritative state and a counter state to trigger re-renders. Ensures `consume()` reads the latest value atomically without stale closure issues.
- **Compose buffer as native textarea (not xterm input)** — xterm renders to `<canvas>`, blocking OS-level input features. The compose buffer provides a real `<textarea>` where dictation, autocorrect, paste, and IME all work. Text sent as a single WebSocket message.
- **Armed modifiers bridge to physical keyboard** — When bottom-bar modifiers (Ctrl/Alt) are armed, a capture-phase `keydown` listener intercepts physical keypresses, translates them to terminal escape sequences (Ctrl+letter → control characters, Alt → ESC prefix), and sends via WebSocket. Prevents xterm from receiving the unmodified key. Ignores real Cmd/Ctrl/Alt held by the OS.
- **File upload via server filesystem (not terminal binary injection)** — Browser uploads file to `POST /api/sessions/:session/upload`, server writes to `.uploads/` in project root, path auto-inserted into compose buffer. Works because run-kit server and tmux are always co-located; the browser is the remote part. Session identified by URL param (consistent with other session-scoped endpoints, replaces legacy form field approach)
- **Handler files split by resource domain (not monolithic routes.go)** — Each handler file owns one resource: `sessions.go`, `windows.go`, `directories.go`, `upload.go`, `sse.go`, `relay.go`, `spa.go`, `health.go`. `router.go` owns middleware, dependency interfaces, and route registration only. (`260312-r4t9-go-backend-api`)
- **Dependency injection via interfaces for handler testability** — `Server` struct holds `SessionFetcher` and `TmuxOps` interfaces, plus `hostname` (computed via `os.Hostname()` in `NewRouter()`, empty string on failure) and `*metrics.Collector` (created and started in `NewRouter()` with the server context). `TmuxOps` interface includes `SetWindowOptions(ctx, windowID, server, ops []tmux.WindowOptionOp)` and `CreateWindowWithOptions(session, name, cwd, server, ops []tmux.WindowOptionOp)` for the batched/chained window-option operations (since `260529-jad6` — the singular `SetWindowOption`/`UnsetWindowOption` and the dedicated `SetWindowColor`/`UnsetWindowColor` remain on the interface but lost their production callers; window-targeting methods take `windowID string` since `260529-chgz-window-id-routing`; create stays session-scoped). `260529-jad6` also removed `KillPane(paneID)` from the interface (zero call sites) and added `SelectWindowInSession` for the session-scoped REST `/select`. `NewRouter()` wires production implementations; `NewTestRouter()` accepts mocks (including hostname). Enables `httptest.NewRecorder` tests without live tmux. (`260312-r4t9-go-backend-api`)
- **Hostname via health endpoint (not a dedicated endpoint)** — hostname is an OS-level value computed once at startup and stored in `Server` struct. Exposed via `/api/health` response (adding a field to an existing endpoint) rather than a new `GET /api/hostname` route. Minimal surface area, consistent with the single-port architecture. (`260320-uq0k-hostname-browser-title`)
- **Per-window fab enrichment via `fab pane map` (replaces per-session file reading)** — Single `fab pane map --json --all-sessions` subprocess call per SSE tick replaces per-session `.fab-status.yaml` + `.fab-runtime.yaml` file reads. Provides per-window resolution (each worktree window shows its own change/stage) instead of per-session (all windows inherited session-level state). Decouples from internal file formats. `internal/fab` package deleted entirely. The subprocess invokes the `fab` router on PATH (not a hardcoded kit binary path) with `cmd.Dir = repoRoot` so the router can resolve the project's `fab_version` from `fab/project/config.yaml`. (`260313-3vlx-pane-map-enrichment` introduced pane-map enrichment via the then-in-repo `fab-go` binary; `260417-5nok-fix-sidebar-fab-state` migrated to the router after the binary was removed from the repo. Supersedes `260312-r4t9-go-backend-api` and `260313-txna-rich-sidebar-window-status` decisions)
- **Backend settings file over localStorage-only** — `~/.rk/settings.yaml` via `internal/settings/` package + `GET/PUT /api/settings/theme` endpoints. Survives browser cache clears, works across devices accessing the same server. localStorage kept as synchronous cache for instant reads before API responds on page load. Simple `key: value` text parsing (not yaml.v3) — one field currently, avoids re-adding a heavyweight dependency. Settings are global (not per-server, no `?server=` param). (`260323-7wys-ansi-palette-theme-rework`)
- **Full ANSI palette over minimal color set** — 22 canonical terminal colors per theme enables three consumers from one source: CSS (8 derived colors via `deriveUIColors`), xterm.js (full 22-color theme via `deriveXtermTheme`), and tmux (auto-themed via ANSI colour indices in static tmux.conf). Rejected: 8-color model with separate xterm colors (duplicative, inconsistent). (`260323-7wys-ansi-palette-theme-rework`)
- **Static tmux.conf with ANSI indices over runtime `tmux set -g`** — tmux.conf uses `colour0`-`colour15` indices. Changing xterm.js palette auto-themes tmux chrome (status bar, pane borders, pane-border-format). Zero backend involvement for theme switching. Rejected: runtime tmux commands (complex, fragile, requires backend communication). (`260323-7wys-ansi-palette-theme-rework`)
- **`run-kit.yaml` at git root for per-project preferences (not env vars or server config)** — `config.FindGitRoot()` walks up from `pane_current_path` to locate the git root, then reads/writes `run-kit.yaml` there. Session color is a per-project user preference, not server configuration (which uses env vars per constitution). Previous `run-kit.yaml` (removed 2026-03-13) stored port/host config — this reintroduction serves a different purpose. File is gitignored by convention. Simple line-by-line parsing (no yaml.v3 dependency). (`260416-jn4h-session-window-color-tinting`)
- **Dual storage: `run-kit.yaml` for session color + tmux `@color` for window color** — Session colors need persistence across tmux restarts (file-based). Window colors are ephemeral and tmux-native (user options survive session lifetime but not server restart). Rejected: all in `run-kit.yaml` (would require tracking per-window state in a file, complex synchronization with tmux window lifecycle). (`260416-jn4h-session-window-color-tinting`)
- **Separate SSE event type for metrics (`event: metrics`) over embedding in sessions payload** — Metrics are server-wide (one payload for all clients), while sessions are per-tmux-server and deduplicated. CPU samples change every tick (dedup logically impossible). Separation avoids inflating session payloads and allows independent evolution. Rejected: embedding in sessions payload (sent N times per server, complicates dedup). (`260411-z63r-sidebar-host-window-panels`)
- **Server-side ring buffer with full history per tick over client-side accumulation** — New SSE clients immediately get complete 60-sample CPU sparkline history. No reconnection logic needed to rebuild history. At 60 float64s, ~500 bytes per tick is negligible. Rejected: client-side accumulation from deltas (reconnection complexity, frontend state management). (`260411-z63r-sidebar-host-window-panels`)
- **Path-based reverse proxy over subdomain routing** — `/proxy/{port}/*` keeps all traffic on one origin, avoiding CORS complexity and DNS configuration. Per-port `ReverseProxy` instances cached in `sync.Map` (read-heavy, single-digit entry count). `ModifyResponse` rewrites localhost/127.0.0.1 URLs in HTML `src`/`href`/`action` attributes to proxy paths; handles gzip decompression. JS-constructed URLs are not rewritten (acceptable v1 gap). Rejected: subdomain routing (requires DNS/hosts setup), service worker injection (fragile). (`260416-6b0h-iframe-proxy-windows`)
- **Tmux user-defined options for iframe state over database/config file** — `@rk_type` and `@rk_url` as tmux window options preserve constitution principles II (no database) and VI (sessions survive restarts). State is co-located with the tmux window lifecycle — kill the window, lose the metadata. Reactive via existing SSE polling (no new transport). Rejected: database/config file (violates constitution), window name encoding (fragile). (`260416-6b0h-iframe-proxy-windows`)
- **Extend existing create endpoint with optional rkType/rkUrl over new endpoint** — Adding optional `rkType`/`rkUrl` to `POST /api/sessions/{session}/windows` reuses the existing creation flow. When `rkType` is present, uses `CreateWindowWithOptions` for atomic `\;`-chained tmux command (prevents SSE seeing window before metadata set). When absent, behavior unchanged (standard terminal window). Rejected: separate `POST .../windows/iframe` endpoint (unnecessary surface area). (`260416-6b0h-iframe-proxy-windows`)
- **procfs direct reading over external monitoring tools** — `internal/metrics` reads `/proc/stat`, `/proc/meminfo`, `/proc/loadavg`, `/proc/uptime` directly. No dependency on `top`, `htop`, or monitoring agents. `syscall.Statfs` for disk. All readers return zero values on non-Linux (macOS dev graceful degradation). No new Go dependencies. (`260411-z63r-sidebar-host-window-panels`)
- **In-memory PR-status collector with wholesale-rebuild cleanup + network-isolated three-layer split** — `internal/prstatus.Collector` (mirroring `internal/metrics.Collector`) makes ONE batched `gh api graphql viewer.pullRequests(states: OPEN)` call on a slow 90s background tick, REPLACES its `byNumber` map wholesale each refresh, and serves a deep-copied `Snapshot()`. Three layers on separate cadences keep the 2.5s SSE hot path network-free: (1) `PrURL`/`PrNumber` flow from `fab pane map` (filesystem, cheap) via the existing sessions enrichment join; (2) the collector fetches live status behind the 90s tick + on-demand POST; (3) the SSE hub joins status onto change-bound windows via a pure `Snapshot()` read. *Why wholesale rebuild*: a merged/closed PR (absent from `states: OPEN`) and a killed window simply drop out next cycle — cleanup for free, no eviction logic or window-lifecycle hook (the deciding factor when "how does cleanup happen?" was raised in design). *Why GraphQL not `gh search prs`*: the intake's literal `gh search prs --author @me` argv ERRORS — that subcommand doesn't expose `statusCheckRollup`/`reviewDecision` JSON fields; `gh api graphql` is the standard cross-repo batched mechanism and serves the identical one-call/O(1)/full-status intent (apply-time correction, plan Assumptions #8). *Why in-memory not tmux-option*: re-derivable in one call (Constitution §II — no DB/disk/tmux-option); restart-survival has little value for a 90s cache and a tmux-option would add a two-writer race + manual pruning. *Why injectable `ghExec`/`available` seams*: matches the codebase's exec-seam test pattern, lets table tests stub `gh` output deterministically. *Rejected*: per-PR `gh pr view` polling (doesn't scale — 10 tasks = 10 calls), webhook receiver writing `.status.yaml` (overkill, contradicts fab-kit's poll-free design). gh absent/unauth is a silent no-op (matches `command -v rk`). (`260610-596o-pr-status-sidebar`)
- **`currentWindowEverSeen` gate on kill-redirect over unconditional "gone → redirect"** — The redirect in `app.tsx` that fires when `currentSession` or `currentWindow` is null treats every SSE payload as authoritative by default, but the first payload after a navigation is not: the SSE hub can replay a cached snapshot before delivering fresh data, and tmux window-list enumeration can lag by one poll tick when a session was just created. Unconditionally redirecting on either transient state tears down `TerminalClient` mid-mount. The gate (a ref keyed on `${server}|${session}|${window}`, flipped true the first time `currentWindow` resolves non-null, reset on URL change) defers the redirect until the URL target has been observed as valid at least once. Rejected: inspecting SSE payload identity or delaying `isConnected` (both push complexity into the hub layer and would not distinguish real kills from stale catch-up). Rejected: adding a fixed startup debounce (arbitrary delay, races with fast tests). The gate preserves fast-redirect behavior for genuine kills (seen flag stays true after first valid observation) while eliminating the startup race. (`260417-g9uo-mobile-tap-focus-regression`)
- **CSS Grid topology for the rotated shell over nested flex** — The shell rotation makes the sidebar a column that spans all three rows (topbar, content, bottombar). Expressing "sidebar full-height; right column is a 3-row stack" with nested flex requires either a sibling sidebar with explicit height management plus a flex-col right column, or a 2-row layout with sidebar nested inside row 1 — neither matches the visual model and both diverge as future cells are added. Grid encodes the topology directly via `grid-template-areas: "sidebar topbar" "sidebar content" "sidebar bottombar"`; collapse becomes a one-line column-width change (`${sidebarWidth}px 1fr` ↔ `0 1fr`) animatable as a single `grid-template-columns` transition. Modern browsers (Safari 14+, Chrome 100+) animate `grid-template-columns` reliably. (`260509-17m3-rotated-shell-layout`)
- **Sidebar collapses to 0px, not a 48px rail** — Maximum content width when collapsed, accepting a small visual layout shift on toggle (the hamburger appears to "move" from sidebar-adjacent to page-adjacent, though structurally it always lives at TopBar.left — see next decision). Rejected: a 48px icon rail (VS Code/Linear/Slack convention) — avoids the visual relocation but consumes content width for a usability gain that diverges from the user's stated preference for max content width. (`260509-17m3-rotated-shell-layout`)
- **Hamburger statically rendered at TopBar.left** — Makes the visual relocation on collapse a free side effect of the grid column collapsing — no component-level relocation logic, no conditional mount/unmount, no animation orchestration. The hamburger's home is always TopBar.left; the column is what changes. Rejected: state-conditional placement (hamburger inside sidebar when open, inside topbar when closed) — adds a state-dependent component-tree mutation that complicates focus management and adds animation responsibility. (`260509-17m3-rotated-shell-layout`)
- **Single `sidebarOpen` boolean over `sidebarOpen + drawerOpen`** — The pre-rotation distinction between "sidebar open on desktop" and "drawer open on mobile" was a rendering-form difference, not a state difference. The user's intent ("show me the sidebar") is one boolean; the renderer picks the form (grid column vs absolute overlay) by viewport width. Collapsing the two booleans simplifies persistence (one localStorage key, `runkit-sidebar-open`), removes a class of "they're out of sync" bugs, and makes the keyboard chord a single state mutation. Rejected: keep two booleans — preserves a historical artifact at the cost of doubled persistence/reset logic and a confusing surface for new contributors. (`260509-17m3-rotated-shell-layout`)
- **`<Shell>` as a shared wrapper over per-route shell components** — Both AppShell and BoardPage need the identical 3-row × 2-column grid, sidebar collapse, mobile overlay, and visual-viewport handling. A single `<Shell>` (`src/components/shell/shell.tsx`) lets both routes consume a uniform topology by wrapping children in `style={{ gridArea: "sidebar" | "topbar" | "content" | "bottombar" }}` elements; collapse animation, mobile overlay, and the topbar/content/bottombar geometry stay in one place. Rejected: per-route shell components — guaranteed to diverge over time (different collapse timings, drift in mobile overlay handling), defeats the rotation's "one topology, many routes" goal. Pattern note: future routes that need the same chrome should adopt `<Shell>` rather than reimplementing the grid. (`260509-17m3-rotated-shell-layout`)
- **`FocusedTerminalContext` as a separate, dedicated context (not a slice of ChromeContext)** — When BottomBar moved up to shell-level, it needed a way to target the focused terminal regardless of route. Folding the focused-terminal target into ChromeContext would mix UI state (sidebar open, fixed-width) with terminal-WS state and force every Chrome consumer to re-render whenever focus changes. A small dedicated context with its own provider (`FocusedTerminalProvider` mounted in `RootWrapper`) keeps the dependency narrow: only BottomBar and the ComposeBuffer-rendering TerminalClient subscribe. Compose state (`composeOpen` / `setComposeOpen`) was lifted to the same context so the shell-level BottomBar can open compose for the focused pane on the board route — alternative was lifting `<ComposeBuffer>` itself to shell level, which would have required rewiring the file-upload chain (`composeFiles`/`composeInitialText`/`useFileUpload`) across 3-4 components. Frozen-target semantics are preserved because `compose-buffer.tsx` already snapshots `wsRef` on mount; only the focused pane's `TerminalClient` renders ComposeBuffer at any time. Pattern note: future multi-input UIs that need a single shared input target should follow this "lift focused-input target to context" pattern rather than prop-drilling. (`260509-17m3-rotated-shell-layout`)
- **Read-only setter pair for in-memory + persisted state (`setSidebarWidth` + `persistSidebarWidth`)** — Drag-resize fires per-pointermove (potentially 60+ events per second). Persisting on every event would write localStorage 60 times per second per drag — unnecessary I/O and cache thrash. Splitting the dispatch into `setSidebarWidth(width)` (in-memory state) and `persistSidebarWidth(width)` (localStorage write) lets the drag handler call the cheap one in the loop and the expensive one once at drag-end. Convention note: future state with the same shape (live in-memory updates + final commit to persistence) should follow this two-setter pattern rather than debouncing inside the dispatch (which couples state lifecycle to a timing heuristic). (`260509-17m3-rotated-shell-layout`)
- **Mobile sidebar overlay positioned via grid (`gridRow: "2/4"`), not `fixed inset-0`** — The spec's literal phrasing was `fixed inset-y-0 left-0 z-50`, but the project convention recorded in `fab/project/context.md` and the existing `mobile-layout.spec.ts` assertion (`sidebarBox.y > 0`) requires the mobile overlay to leave the topbar visible — the topbar has the hamburger which closes the overlay, so covering it would be a usability regression. The implementation uses a grid-row-spanning child with `position: absolute` (not `fixed`) so the overlay anchors below the topbar inside the Shell. Pragmatic resolution: spec-wording "fixed-position overlay with backdrop" is satisfied — the overlay is fixed-position-style (absolute inside the Shell that itself is full-viewport), it has a backdrop, it carries `role="dialog" aria-modal="true"`. (`260509-17m3-rotated-shell-layout`)
- **Known follow-up — `sidebarChildren` prop duplication on `<Shell>`** — Both `AppShell` and `BoardPage` build the sidebar JSX once into a local `sidebarElement`, then pass it as `<Shell sidebarChildren={...}>` (used on mobile overlay) AND wrap it in a desktop-branch `<aside style={{gridArea:"sidebar"}}>{sidebarElement}</aside>` (used on desktop). This duplicates a small bit of JSX at every Shell consumer. Considered alternatives: a render-prop or a single named "sidebar" slot owned by Shell. The desktop branch in AppShell also includes the drag-handle aside wrapper, which BoardPage does NOT need — collapsing the duplication requires either (a) Shell knowing about the drag handle (small abstraction leak) or (b) exposing a separate "desktopChrome" slot. Captured here as a deferred refactor — not in 17m3's scope. (`260509-17m3-rotated-shell-layout`)

## Testing

### Go Unit Tests

Go `testing` package with table-driven tests. Test files co-located with source using `_test.go` suffix. Test scripts: `go test ./...` from `app/backend/`.

Current Go test coverage (`app/backend/`):
- **Internal packages**: `internal/validate` (input validation + tilde expansion + filename sanitization), `internal/config` (env var reading, port validation, defaults), `internal/tmux` (listSessions parsing + byobu filtering, listWindows activity computation), `internal/sessions` (pane-map JSON parsing, per-window fab field join, graceful degradation on pane-map failure, `fab` router not on PATH via `TestFetchPaneMapFabNotOnPath`, end-to-end subprocess invocation via `TestFetchPaneMapIntegration` gated on `exec.LookPath("fab")`)
- **CLI command tests**: `cmd/run-kit/root_test.go` (no-args defaults to serve), `cmd/run-kit/version_test.go` (version output format), `cmd/run-kit/doctor_test.go` (LookPath-based dependency check)
- **Handler integration tests**: `api/health_test.go`, `api/sessions_test.go`, `api/windows_test.go`, `api/directories_test.go`, `api/upload_test.go`, `api/sse_test.go`, `api/spa_test.go` — all use `httptest.NewRecorder` with the chi router and mock `SessionFetcher`/`TmuxOps` interfaces for tmux isolation. Cover response shapes, validation errors, URL param parsing, content-type enforcement. `api/relay.go` has no unit test (requires live tmux + PTY)

### Frontend Unit Tests (app/frontend/)

Vitest with jsdom environment. Config at `app/frontend/vitest.config.ts`. MSW mocks all API endpoints and the SSE stream (`app/frontend/tests/msw/handlers.ts`). Test files co-located with source using `.test.{ts,tsx}` suffix.

Test coverage includes: sidebar (expand/collapse, window selection, kill session, activity dots, duration display, info popover), breadcrumb dropdowns (open/close, selection), drawer (open via hamburger, close on selection), command palette (Cmd+K), modifier state, touch targets (44px on `coarse`), API client (correct URL construction for each endpoint).

### Playwright E2E Tests (app/frontend/tests/e2e/)

Thin suite (3-5 tests) for API round-trip validation. Config at `app/frontend/playwright.config.ts`. Self-managed tmux sessions in `beforeAll`/`afterAll` hooks.

E2E test coverage: create/kill session via UI, SSE stream delivers real data, sidebar navigation, sidebar window sync (external window creation/rename reflected within 5000ms, kill-then-create at same index does not suppress new window via stale optimistic context).

## Security

- All subprocess calls use `os/exec.CommandContext` with argument slices (never `sh -c` or shell strings)
- All `exec.CommandContext` calls include timeout via `context.WithTimeout` (10s tmux, 30s build). Terminal relay attach uses `context.WithCancel` (long-lived, cancelled on disconnect)
- User input validated via `internal/validate` before reaching any subprocess
- Directory listing restricted to `$HOME` via `ExpandTilde()` — rejects `..` traversal, absolute paths outside home, and `~username` syntax. Symlinks under `$HOME` are not resolved (accepted risk for local dev tool)
- File uploads: filename sanitized via `SanitizeFilename()` (strips path separators, null bytes, leading dots, collapses dot sequences); 50MB size limit enforced server-side via `http.MaxBytesReader`; writes via `os.Create` (not subprocess)
- CORS: permissive by default (`*` origin) for multi-client API flexibility. TLS handled by Tailscale Serve in production

## Changelog

| Date | Change | Reference |
|------|--------|-----------|
| 2026-03-02 | Initial architecture — greenfield v1 | `260302-fl88-web-agent-dashboard` |
| 2026-03-03 | Removed `run-kit.yaml` config — derive project state from tmux | `260303-yohq-drop-config-derive-from-tmux` |
| 2026-03-03 | Added `killSession` API action — kills entire tmux session | `260303-vag8-unified-top-bar` |
| 2026-03-03 | Configurable port/host binding via `config.ts` + `run-kit.yaml` | `260303-q8a9-configurable-port-host` |
| 2026-03-03 | Relay port via server component prop (replaced build-time env var) | — |
| 2026-03-03 | Filter byobu session-group copies from `listSessions()` | — |
| 2026-03-05 | Added Vitest testing infrastructure with validate, config, and command-palette tests | `260303-07iq-setup-vitest` |
| 2026-03-05 | Added feature tests for tmux.ts, use-keyboard-nav.ts, and api/sessions POST handler | `260305-vq7h-feature-tests-tmux-keyboard-api` |
| 2026-03-05 | Added `/api/directories` endpoint, `createSession` CWD support, `expandTilde` security boundary | `260305-zkem-session-folder-picker` |
| 2026-03-06 | Chrome architecture — layout-owned flex-col skeleton, ChromeProvider context, TopBarChrome, icon breadcrumbs, always-visible kill buttons | `260305-emla-fixed-chrome-architecture` |
| 2026-03-06 | Bottom bar (modifier toggles, arrow keys, Fn dropdown, compose buffer), iOS keyboard support via visualViewport, `i` key compose toggle | `260305-fjh1-bottom-bar-compose-buffer` |
| 2026-03-06 | Performance: parallel session enrichment, SSE pub/sub singleton, split ChromeContext, layout-level SessionProvider, ResizeObserver debounce, useModifierState memoization, WS reconnection | `260306-0ahl-perf-sse-chrome-sessions` |
| 2026-03-07 | iOS touch scroll prevention — fullbleed class toggle on html, touch-none on terminal container | `260307-8n60-fix-ios-terminal-touch-scroll` |
| 2026-03-07 | File upload: `/api/upload` endpoint, clipboard paste/drag-drop/file picker triggers, compose buffer integration, `.uploads/` auto-gitignore | `260307-kqio-image-upload-claude-terminal` |
| 2026-03-07 | iOS keyboard viewport overlap fix — visualViewport scroll listener, fixed positioning in fullbleed mode | `260307-f3o9-ios-keyboard-viewport-overlap` |
| 2026-03-07 | Sync byobu active tab — `isActiveWindow` on `WindowInfo`, breadcrumb/URL/action sync via SSE + `history.replaceState` | `260307-f3li-sync-byobu-active-tab` |
| 2026-03-07 | Breadcrumb type extended with `dropdownItems` for project/window switching dropdowns | `260307-uzsa-navbar-breadcrumb-dropdowns` |
| 2026-03-07 | Playwright E2E tests — chrome stability, breadcrumbs, bottom bar, compose buffer, kill button, mobile viewport | `260305-r7zs-playwright-e2e-design-spec` |
| 2026-03-10 | **Go backend + Vite SPA split** — replaced Next.js monolith with Go backend + Vite React SPA. Single-port architecture (API, SSE, WebSocket relay, SPA static serving on one Go binary). chi router, gorilla/websocket, creack/pty. TanStack Router for client-side routing. Typed API client module. Go table-driven tests ported from Vitest. E2E tests updated for Go + Vite dev servers. | `260310-8xaq-go-backend-vite-spa-split` |
| 2026-03-12 | **Go backend API at `app/backend/`** — handler files split by resource domain (sessions.go, windows.go, etc.). POST-only mutations with path-based intent. `internal/fab` rewritten to read `.fab-status.yaml` directly (no subprocess). Per-session fab enrichment model. `WindowInfo` fields changed: `FabChange`/`FabStage` replace `FabStage`/`FabProgress`. Upload endpoint session from URL path. Handler integration tests via `httptest.NewRecorder` + mock interfaces. SPA serves from `app/frontend/dist/`. | `260312-r4t9-go-backend-api` |
| 2026-03-12 | **Vite/React frontend at `app/frontend/`** — single-view UI (sidebar + terminal, one route `/:session/:window`), POST-only API client with path-based intent, ChromeProvider derives from selection (no slot injection), sidebar with session/window tree + mobile drawer, MSW-backed Vitest, Playwright E2E at `app/frontend/tests/e2e/` | `260312-ux92-vite-react-frontend` |
| 2026-03-12 | **Cleanup old implementation** — removed legacy backend and frontend directories, `e2e/`, root `playwright.config.ts`. Updated `pnpm-workspace.yaml` to `["app/frontend"]`. Removed legacy test sections and stale path references from memory. | `260312-n11e-cleanup-old-implementation` |
| 2026-03-12 | **UI chrome layout refinements** — bottom bar moved inside terminal column (width tracks terminal, not viewport). Sidebar drag-resizable (default 220px, min 160, max 400, localStorage persist). Top bar `border-b`, bottom bar `border-t`. Breadcrumbs simplified to `☰ {logo} ❯ session ❯ window`. `[+ Session]` button added to top bar line 2. | `260312-y4ci-ui-chrome-layout-refinements` |
| 2026-03-13 | **Rich sidebar window status** — Backend: `internal/tmux` adds `PaneCommand` + `ActivityTimestamp` to `WindowInfo` via 6-field tmux format string. New `internal/fab/runtime.go` reads `.fab-runtime.yaml` for agent idle state. `internal/sessions` enriches with runtime state (cached per project root via `sync.Map`). Frontend: sidebar window rows gain activity dot ring, idle duration, info popover. Top bar Line 2 enriched with paneCommand, duration, fab change ID+slug. Shared helpers in `lib/format.ts`. | `260313-txna-rich-sidebar-window-status` |
| 2026-03-13 | **Env var config** — replaced `run-kit.yaml` with `.env`/`.env.local` (direnv). Go and Vite read `RK_PORT`/`RK_HOST` directly. Dev mode: Vite on `RK_PORT`, Go on `PORT+1` (translated by `dev.sh`). Prod: Go on `RK_PORT`. All entry points accept `--port`. Removed CLI flag parsing and YAML config from Go. | — |
| 2026-03-13 | **Removed single-key shortcuts** — deleted `useKeyboardNav` (j/k/Enter sidebar nav), `useAppShortcuts` (c/r/Esc Esc), sidebar `focusedIndex` prop and focus ring styling. Cmd+K is now the sole keyboard shortcut. Palette actions no longer display shortcut hints. | `260313-3brm-remove-single-key-shortcuts` |
| 2026-03-14 | **Relay session validation** — relay handler validates session/window exist before attaching PTY. Returns WebSocket close code `4004` for missing session or window (distinct from `4001` PTY failure). Frontend handles `4004` by navigating to `/` instead of reconnecting. Prevents infinite reconnect loops when navigating to a non-existent tmux session. | — |
| 2026-03-14 | **Pane-map enrichment** — replaced per-session `.fab-status.yaml` + `.fab-runtime.yaml` file reading with single `fab-go pane-map --json --all-sessions` subprocess call. Per-window fab state (change, stage, agent state, idle duration) instead of per-session. Deleted `internal/fab/` package (4 files). `internal/sessions` simplified: removed `enrichSession()`, `hasFabKit()`, `runtimeCache sync.Map`. New `fetchPaneMap(repoRoot)` + map join. | `260313-3vlx-pane-map-enrichment` |
| 2026-03-14 | ~~**Byobu session creation**~~ — Superseded by dedicated tmux server (`260318-0gjh-dedicated-tmux-server`). `CreateSession()` now uses plain `tmux new-session` on the `runkit` server with its own config (`internal/tmux/tmux.conf`). | — |
| 2026-03-14 | **Top bar & bottom bar refresh** — Top bar: hamburger icon (☰→✕ animation) replaces logo as toggle, `/` separator replaces `❯`, session/window names are dropdown triggers, session name max 7ch. Right section: logo (decorative) + "Run Kit" + green dot (no text) + toggle + ⌘K + >_ compose. Mobile: ⋯ + >_. Bottom bar: removed Cmd modifier and compose button, sizes increased to 36px/44px. `onOpenCompose` moved from BottomBarProps to TopBarProps. | `260314-9raw-top-bar-bottom-bar-refresh` |
| 2026-03-15 | **Dashboard view** — `/` renders `Dashboard` component inline in `app.tsx` terminal area (not a separate route layout). Expandable session cards with window cards, stats line, create buttons. Bottom bar conditionally rendered (terminal pages only). Top bar adapts: shows "Dashboard" text on `/`, breadcrumbs on `/:session/:window`. Sidebar session name click navigates to first window; chevron toggles expand/collapse. All kill operations and stale URL detection redirect to `/`. Auto-redirect from `/` to first session removed. | `260313-ll1j-dashboard-project-page-views` |
| 2026-03-15 | **Per-region scroll behavior** — Dashboard restructured: pinned stats line (`shrink-0`) + scrollable card area (`flex-1 min-h-0 overflow-y-auto`). `useVisualViewport` hook manages `fullbleed` class lifecycle on `<html>` (add on mount, remove on cleanup). Static `fullbleed` in `index.html` serves as FOUC prevention. | `260315-lnrb-dashboard-scroll-behavior` |
| 2026-03-17 | **Homebrew deployment system** — Cobra CLI with subcommands. `embed.FS` bundles frontend into single binary. `VERSION` file + ldflags injection. `scripts/build.sh` (frontend-first build), `scripts/release.sh` (semver bump + tag). GitHub Actions CI/CD cross-compiles 4 targets (darwin/linux x arm64/amd64) on `v*` tag push, creates GitHub Release with tarballs. SPA handler dual-mode: embedded FS (production) or filesystem (dev). | `260317-ukyz-homebrew-deployment` |
| 2026-03-18 | **Light theme support** — ThemeProvider context (outermost, split pattern) with three modes (system/light/dark). CSS `data-theme` attribute on `<html>` switches color tokens via `globals.css` selectors. Blocking inline script in `index.html` for no-flicker init. xterm terminal theme updates live. Theme switcher in command palette. Provider tree: `ThemeProvider > ChromeProvider > SessionProvider > AppShell`. | `260318-eseg-add-light-theme-support` |
| 2026-03-18 | **Dedicated tmux server** — All run-kit sessions live on a named tmux server `runkit` (via `tmux -L runkit`). `internal/tmux` commands prefixed with `-L runkit` and optional `-f` from `RK_TMUX_CONF` env var. `ListSessions()` queries both runkit and default servers, returning `SessionInfo` with `Server` field. `ListWindows()` accepts server parameter. `CreateSession()` uses plain `tmux new-session` on runkit server (byobu dependency removed, `sync.OnceValue` detection deleted). `ProjectSession` type gains `Server` field. Relay attaches to runkit server. New `internal/tmux/tmux.conf` with dark-themed status bar and F2/F3/F4 keybindings. Frontend: `ProjectSession` type gains `server` field, sidebar shows `↗` marker for default-server sessions. | `260318-0gjh-dedicated-tmux-server` |
| 2026-03-20 | **Multi-server relay + config reload** — `RK_TMUX_CONF` resolved to absolute path at init (fixes CWD-dependent config loading). Relay and select-window endpoints accept `?server=` query param to route to runkit or default tmux server (fixes default-server sessions not connecting). `SelectWindowOnServer()` added. `ReloadConfig(server)` hot-reloads tmux config via `source-file`. New `POST /api/tmux/reload-config` endpoint + `reloadTmuxConfig(server)` client function + "Reload tmux config" command palette action (targets current session's server). `tmuxExec`/`tmuxExecDefault` capture stderr in error messages. `TerminalClient` accepts `server` prop. | `260318-0gjh-dedicated-tmux-server` |
| 2026-03-20 | **Hostname in browser title** — `Server` struct gains `hostname` field (computed once via `os.Hostname()` in `NewRouter()`, empty string fallback). `/api/health` response extended to `{"status":"ok","hostname":"..."}`. New `getHealth()` API client function. `useBrowserTitle` hook sets `document.title` dynamically: `RunKit — {hostname}` on Dashboard, `{session}/{window} — {hostname}` on terminal pages. Hostname suffix omitted when empty. | `260320-uq0k-hostname-browser-title` |
| 2026-03-20 | **PWA compliance** — `vite-plugin-pwa` with autoUpdate, manifest from plugin config (standalone display, dark theme colors), Workbox service worker (precache static, NetworkOnly for API/WebSocket), iOS meta tags, theme-color sync. No backend changes. | `260320-j9a2-pwa-compliance` |
| 2026-03-20 | **Release pipeline + update command** — `release.sh` rewritten (fab-kit/tu style). CI generates `Formula/rk.rb` in `sahil87/homebrew-tap` from `.github/formula-template.rb` (sed placeholder substitution for version + SHA256s) via `BUILD_TOKEN` secret. Public repo — plain URL downloads, no auth token needed. `upgrade` subcommand renamed to `update` (alias: `upgrade`): checks latest via `brew info --json=v2 sahil87/tap/rk`, skips if up-to-date, shows version transition. | — |
| 2026-03-20 | **Single-active-server model** — Replaced dual-server merge with single-server-at-a-time. Backend stateless: `?server=` on every request, defaults to `"default"`. Unified `tmuxExec`/`tmuxExecDefault` into `tmuxExecServer`. All tmux functions accept `server` param. `serverFromRequest()` validates names. New `ListServers()` (socket scan), `KillServer()`. SSE hub polls per-server. Removed `SessionInfo.Server`, `ProjectSession.Server`. New `GET/POST /api/servers`, `POST /api/servers/kill`. Frontend: `SessionProvider` manages server state (localStorage `runkit-server`), sidebar server dropdown, palette commands (Create/Kill/Switch server). `setServerGetter()` mechanism appends `?server=` to all API calls. | `260320-1335-tmux-server-switcher` |
| 2026-03-20 | **Daemon lifecycle** — Replaced `supervisor.sh` (polling loop + `.restart-requested` signal file) with CLI-driven daemon management. New `internal/daemon/` package with `IsRunning`/`Start`/`Stop`/`Restart` helpers using `exec.CommandContext`. `run-kit serve` gains `-d` (start daemon, errors if running), `--restart` (idempotent stop+start), `--stop` (graceful C-c) flags — mutually exclusive. Daemon runs in dedicated tmux server `rk-daemon` (session `rk`, window `serve`), separate from agent `runkit` server. `run-kit update` auto-restarts daemon after successful `brew upgrade`. Justfile `up`/`down`/`restart` recipes updated. Constitution Self-Improvement Safety section rewritten. | `260320-hkm8-daemon-lifecycle-serve` |
| 2026-03-20 | **UI polish, tmux config, keyboard shortcuts** — Embed restructured from `app/backend/frontend/` to `app/backend/build/` (package `build`, `//go:embed all:frontend`). Breadcrumb left-aligned (removed `justify-center`). `EnsureConfig()` auto-creates `~/.run-kit/tmux.conf` on serve startup. `-f` config flag scoped to `CreateSession`/`ReloadConfig` via `configArgs()`. Enhanced `internal/tmux/tmux.conf`: `escape-time 0`, `history-limit 50000`, `renumber-windows on`, `base-index 1`, explicit `prefix C-s`, pane splits (`prefix+\|`/`prefix+-`), pane navigation (`S-F3`/`S-F4`), `F8` rename, `S-F7` copy-mode. Sidebar server dropdown gains `+ tmux server` action. Hostname in bottom bar (hidden on mobile). Aligned sidebar footer and bottom bar heights (`h-[48px]`). Server label changed to "tmux server:". Kill server handles socket teardown gracefully. New `GET /api/keybindings` endpoint — runs `tmux list-keys`, filters via whitelist map, returns `[{key, table, command, label}]`. New "Keyboard Shortcuts" command palette action opens modal showing curated bindings grouped by key table (prefix vs root). | `260320-9ldy-ui-polish-tmux-config-embed` |
| 2026-03-23 | **ANSI palette theme rework** — New `internal/settings/` package for backend settings persistence at `~/.rk/settings.yaml` (simple key:value parsing, no yaml.v3). New `GET/PUT /api/settings/theme` endpoints in `api/settings.go`. Frontend: `getThemePreference()`/`setThemePreference()` API client functions (not per-server). ThemeProvider uses API as canonical source with localStorage as synchronous cache. tmux.conf reworked from hardcoded hex to ANSI `colour{N}` indices for auto-theming via xterm.js palette. | `260323-7wys-ansi-palette-theme-rework` |
| 2026-03-27 | **Bundle & loading performance** — Lazy-load `CommandPalette`, `ThemeSelector`, `CreateSessionDialog` via `React.lazy()` + `<Suspense fallback={null}>` (named export re-wrapping). Vite `manualChunks` splits xterm family and TanStack Router into separate vendor chunks. API request deduplication via `deduplicatedFetch` wrapper with in-flight `Map<string, Promise<Response>>` — GET-only, URL-keyed, `.finally()` cleanup, `Response.clone()` for multi-consumer safety. | `260327-uyj5-perf-bundle-loading` |
| 2026-03-28 | **tmux prefix key change** — Changed tmux prefix from `C-b` to `C-s` in `configs/tmux/default.conf` and `configs/tmux/simple.conf` to avoid conflict with Claude Code's `Ctrl+B` shortcuts. Added `unbind C-b` and `bind-key C-s send-prefix`. Frontend keyboard shortcuts label updated from `Ctrl+B, ` to `Ctrl+S, `. `byobu.conf` (`C-a`) and `poweruser.conf` (`C-s`) unchanged. | `260328-d7s5-change-tmux-prefix` |
| 2026-03-28 | **Multi-file tmux config sourcing** — `~/.rk/tmux.d/` drop-in directory for user extensions. `configs/tmux/default.conf` appends `source-file -q ~/.rk/tmux.d/*.conf` (lexicographic order, `-q` silences empty/missing dir). `EnsureConfig()`, `ForceWriteConfig()`, and `rk init-conf` all create the directory idempotently. `EnsureConfig()` creates `tmux.d/` even when config already exists. `ReloadConfig()` unchanged — transitive sourcing picks up new drop-ins. | `260328-wxrh-source-rk-tmux-configs` |
| 2026-04-04 | **Window move & reorder** — New `SwapWindow(session, srcIndex, dstIndex, server)` in `internal/tmux` wrapping `tmux swap-window`. New `POST /api/sessions/:session/windows/:index/move` endpoint (`handleWindowMove` in `windows.go`) with `{"targetIndex": N}` body. `TmuxOps` interface extended with `SwapWindow`. New `moveWindow(session, index, targetIndex)` client function. CmdK "Window: Move Left/Right" actions (excluded at boundary). Sidebar drag-and-drop window reordering via native HTML5 DnD (same-session only). | `260404-29qz-window-move-reorder` |
| 2026-04-05 | **Left panel window sync fix + E2E** — `use-optimistic-action.ts` fix: `onSettled`/`onRollback` moved before `mountedRef` guard so they always fire, preventing stale `killed` entries when the sidebar component unmounts before the kill API resolves. New E2E test `sidebar-window-sync.spec.ts` (3 scenarios: external window creation, rename, kill-then-create at same index). | `260405-2a2k-left-panel-window-sync` |
| 2026-04-05 | **Sidebar window state Zustand** — Window optimistic state migrated from index-based `OptimisticContext` to Zustand store (`app/frontend/src/store/window-store.ts`). `zustand` added as production dependency in `app/frontend/package.json`. `WindowInfo` type gains `windowId: string` (non-optional); backend adds `#{window_id}` as first field in `ListWindows()` format string (7 fields total, field 0 = `WindowID`). `OptimisticContext` slimmed to session/server scope. `MergedWindow` moved to `window-store.ts`. Ghost reconciliation uses `snapshotWindowIds` set-difference. Frontend unit tests: `window-store.test.ts` added, `optimistic-context.test.tsx` stripped of window test cases. | `260405-x3yt-sidebar-window-state-zustand` |
| 2026-04-16 | **Iframe proxy windows** — Reverse proxy at `/proxy/{port}/*` in `api/proxy.go` forwards to `localhost:{port}` with per-port `ReverseProxy` caching (`sync.Map`) and HTML response rewriting (localhost/127.0.0.1 URLs to proxy paths, gzip-aware). Tmux user-defined options `@rk_type` and `@rk_url` store iframe window metadata — included in `ListWindows` format string (fields 8-9), `WindowInfo` gains `RkType`/`RkUrl` fields. New `SetWindowOption` and `CreateWindowWithOptions` in `internal/tmux`. `handleWindowCreate` extended with optional `rkType`/`rkUrl` (atomic `\;`-chained tmux command). New `PUT /api/sessions/{session}/windows/{index}/url` endpoint. Frontend: `IframeWindow` component (URL bar + iframe), rendering branch in `app.tsx` (`rkType === "iframe"` -> IframeWindow, else TerminalClient). "Window: New Iframe Window" command palette action with name/URL dialog. `updateWindowUrl` and extended `createWindow` in API client. Vite dev proxy rule for `/proxy`. SSE reactivity automatic (no new transport). | `260416-6b0h-iframe-proxy-windows` |
| 2026-04-11 | **Sidebar Host & Window panels + metrics collector** — New `internal/metrics/` package reads procfs for CPU (ring buffer 60 samples), memory, load, disk, uptime; `Collector` runs background goroutine at `ssePollInterval`. SSE hub gains `event: metrics` event type broadcast to all clients every tick (server-independent, not deduplicated). `Server` struct holds `*metrics.Collector`, created and started in `NewRouter()`. Frontend: `SessionProvider` listens for `metrics` SSE event, exposes `MetricsSnapshot | null` via context. New sidebar components: `CollapsiblePanel` (reusable), `WindowPanel` (refactored from StatusPanel), `HostPanel` (5-line metrics display with braille sparkline, gauge bar, load percentages, disk+uptime). New utility modules: `lib/sparkline.ts` (braille 8-level mapping), `lib/gauge.ts` (block gauge + color thresholds + byte formatting). Hostname removed from bottom bar — now in HostPanel exclusively. | `260411-z63r-sidebar-host-window-panels` |
| 2026-04-16 | **`rk context` — agent discovery** — New Cobra subcommand `context` in `cmd/rk/context.go`. Outputs agent-optimized plain text with Environment (dynamic tmux pane queries), Capabilities (terminal windows, iframe windows with exact `tmux set-option` commands, proxy URL, categorized CLI commands), and Conventions (tmux user options, window lifecycle, SSE reactivity) sections. Uses raw `exec.CommandContext` (5s timeout) for tmux queries — deliberately bypasses `internal/tmux` execution functions because they add `-L <server>` targeting. `tmux.OriginalTMUX` var captures `$TMUX` before `init()` strips it; restored in child process env via `cmd.Env`. Server URL via `config.Load()`. Gracefully degrades outside tmux. 6 Go tests. | `260416-0gz9-rk-context-agent-discovery` |
| 2026-04-16 | **Session and window color tinting** — New `internal/config/runkit_yaml.go`: `FindGitRoot()` (walk-up `.git` search), `ReadSessionColor()`/`WriteSessionColor()` for `run-kit.yaml` at git root (simple line parser, no yaml.v3). `ProjectSession.SessionColor *int` populated during `FetchSessions()` enrichment. `WindowInfo.Color *int` from `#{@color}` in `ListWindows()` format string (8 fields total). `SetWindowColor()`/`UnsetWindowColor()` in `internal/tmux`. New endpoints: `POST /api/sessions/:session/color` (`handleSessionColor` in `sessions.go`), `POST /api/sessions/:session/windows/:index/color` (`handleWindowColor` in `windows.go`). Frontend: `setWindowColor()`/`setSessionColor()` API client functions. `computeRowTints()` and `PICKER_ANSI_INDICES` in `themes.ts`. `SwatchPopover` component (lazy-loaded). CmdK "Session: Set Color" / "Window: Set Color" actions. `types.ts`: `ProjectSession.sessionColor?: number`, `WindowInfo.color?: number`. | `260416-jn4h-session-window-color-tinting` |
| 2026-04-17 | **`rk riff` — worktree + tmux window + Claude launcher** — New Cobra subcommand `riff` in `cmd/rk/riff.go` unifies the `riff`/`riffs` shell functions into a first-class `rk` command. Wraps `wt create --non-interactive --worktree-open skip [passthrough...]`, parses the `Path:` line, then runs `tmux new-window -c <path> "<launcher> '<cmd>'"` (optionally followed by `tmux split-window -h -c <path> "<setup>; exec zsh"` when `--split` is non-empty). Launcher resolved via new `internal/fabconfig/` package (`ReadSpawnCommand(repoRoot)` — best-effort `yaml.v3` read of `agent.spawn_command` from `fab/project/config.yaml`, falls back to `claude --dangerously-skip-permissions`). Repo-root discovery reuses `config.FindGitRoot`. Preconditions fast-fail: `$TMUX` set (via `tmux.OriginalTMUX`, since `internal/tmux` init strips `$TMUX` from the process env) then `wt` on PATH; local `exitCodeError` wrapper maps 2 (precondition) / 3 (subprocess) without touching `main.execute()`. `exec.CommandContext` timeouts: wt 30s, tmux 10s. Single-quote escaping helper (`escapeSingleQuotes`) keeps `--cmd` values safe in the tmux shell string. `cmd/rk/context.go` CLI Commands section lists `rk riff` under **Workflow**. 13 new tests (8 for `parseWorktreePath`, 6 for `escapeSingleQuotes`, 5 for `resolveLauncher`, plus fabconfig coverage). | `260416-r1j6-add-riff-command` |
| 2026-04-17 | **Fix sidebar fab state display** — replaced broken hardcoded `fab/.kit/bin/fab-go` path lookup with `fab` router invocation on PATH (`fab pane map --json --all-sessions` with `cmd.Dir = repoRoot`); changed `findRepoRoot` marker from deleted `fab/.kit/bin/fab-go` to `fab/project/config.yaml` (fab-project identity marker per `fab preflight`); added `TestFetchPaneMapIntegration` (gated via `exec.LookPath("fab")`); renamed `TestFetchPaneMapNonexistentBinary` → `TestFetchPaneMapFabNotOnPath`. | `260417-5nok-fix-sidebar-fab-state` |
| 2026-04-17 | **Kill-redirect SSE-replay gate** — `computeKillRedirect` (`app/frontend/src/lib/navigation.ts`) gains `currentWindowEverSeen?: boolean` param (defaults to `true`). When false, all "gone" branches short-circuit to `null`, deferring the session-dashboard / nearest-sibling redirect until the URL target has been observed as valid at least once. `app.tsx` tracks the seen state via `currentWindowEverSeenRef` (flipped true whenever memoized `currentWindow` is non-null) plus `lastObservedUrlKeyRef` (string `${server}|${session}|${window}`, resets the seen flag on URL change). Eliminates a startup race where a stale-cached first SSE payload or a session with briefly-empty `windows` array (while tmux enumeration propagates) redirected to the dashboard and unmounted `TerminalClient` mid-init — surfaced as `mobile-touch-scroll.spec.ts` flakes where `.xterm-screen` briefly appeared then disappeared. Two new unit tests in `navigation.test.ts` for the stale-SSE and empty-windows-transient cases. No changes to `TerminalClient` or SSE hub. | `260417-g9uo-mobile-tap-focus-regression` |
| 2026-04-18 | **Server panel tile grid** — `/api/servers` response changed from `[]string` to `[]ServerInfo` (`{name, sessionCount}`). Session counts computed server-side via concurrent `tmux.ListSessions(ctx, server)` fan-out (`sync.WaitGroup` + mutex-protected results map); per-server errors logged and yield `sessionCount: 0` (no 5xx). Response sorted alphabetically. `TmuxOps` interface gains `ListSessions(ctx, server)`. Frontend `listServers()` returns `Promise<ServerInfo[]>`; `ServerInfo` exported from `app/frontend/src/api/client.ts`. All consumers (`SessionProvider`, `Sidebar`, `ServerPanel`, `ServerListPage`, `app.tsx` palette) read `.name`. `ServerListPage` cards show `{sessionCount} sess`. | `260417-jpkl-server-panel-tile-grid` |
| 2026-04-19 | **Adapt to fab-kit v1.5.0 `_agents` schema** — pane-map dedup rule extended with rule-2 (`Change > AgentState > first-seen`) so discussion-mode agent panes (populated `AgentState`, nil `Change`) win over bare sibling panes in split windows. Dedup logic extracted from `fetchPaneMap` into standalone `dedupEntries(entries) map[string]paneMapEntry` helper in `app/backend/internal/sessions/sessions.go`. Agent state (`agentState`/`agentIdleDuration`) now resolves independently of change state (`fabChange`/`fabStage`) — either can populate or be empty on any given window. Two new tests: `TestPaneMapDedupPrefersAgentState` (both iteration orders) and `TestPaneMapDedupChangeStillWinsOverAgent` (rule-1 preservation). No frontend changes required — existing branching in `status-panel.tsx` and `format.ts` already treats the two fields independently. | `260419-afyv-adapt-fab-pane-map-agents-schema` |
| 2026-04-20 | **Visual Display Recipe canonical in `rk context`** — New `### Visual Display Recipe` subsection in `cmd/rk/context.go` `writeCapabilities` output, placed between `### Proxy` and `### CLI Commands`. Documents the 4-step iframe-display flow: generate HTML → loopback `python3 -m http.server --bind 127.0.0.1` → iframe window with **relative** `@rk_url /proxy/<port>/<filename>` → fail-silent on missing prerequisites. Relative path is deliberate — the run-kit frontend resolves it against the user's browser origin, so the recipe works identically direct or behind a reverse proxy (matches the existing pattern in `app/frontend/src/components/iframe-window.tsx:119`). Two new Go tests (`TestVisualDisplayRecipeOrdering`, `TestVisualDisplayRecipeNoAbsoluteProxyURL`) plus 3 assertions in `TestContextCapabilitiesSections`. Cross-repo coordination: fab-kit `src/kit/skills/_preamble.md` `### Visual Display Recipe` collapsed from inline 4-step prose (which had drifted to an absolute `{server_url}/proxy/...` form) to a pointer at `rk context` output — eliminates the drift class. fab-kit commit local only; propagates to `.claude/skills/_preamble/SKILL.md` deployed copies on next `fab sync` after fab-kit release. | `260419-l1ja-fix-rk-iframe-relative-urls` |
| 2026-05-07 | **Pane boards** — Cross-server pane dashboard at `/board/$name` (root-level peer to `/$server`, not under server). New `internal/tmux/board.go` (parse/serialize `@rk_board`, fractional indexing, lazy stale cleanup, idempotent `Pin`/`Unpin`/`Reorder`, `RemoveAllByWindowID`). New `api/boards.go` with five endpoints (`GET /api/boards`, `GET /api/boards/{name}`, `POST /api/boards/{name}/{pin,unpin,reorder}`); routes registered in `router.go`; `TmuxOps` extended; `mockTmuxOps` updated. `sseHub` extended with `BoardEntriesFetcher` interface, `previousBoardJSON` cache, `broadcastBoardChanged`, `event: board-changed` (kebab-case), once-per-server bootstrap on first poll, cached-snapshot replay to new clients, and window-kill eager cleanup that fires one `cleanup` event per affected board. Frontend: `app/frontend/src/api/boards.ts` (`listBoards`/`getBoard` via `deduplicatedFetch`, `pinWindow`/`unpinWindow`/`reorderPin` mutations with `server` first-arg per the routing contract); `useBoards()` + `useBoardEntries(name)` hooks in `app/frontend/src/hooks/use-boards.ts` (50ms-debounced cross-server SSE re-fetch, error tolerance preserves last good value); `usePinActions`, `usePaneWidths`, `useIsMobile`, `useActiveBoardName`, `useWindowPins` companion hooks; `BoardPage`/`BoardPane`/`BoardHeader` mini-layout (own sidebar + topbar + `<CommandPalette>` mount); `/board/$name` route in `router.tsx` peer to `serverLayoutRoute`. Sidebar `BoardsSection` (above Sessions, hidden when zero boards), pin icon + popover on `WindowRow`, active-board accent border on pinned rows when current route is `/board/<name>`. `boardActions` `PaletteAction[]` in `app.tsx` between `windowActions` and `viewActions` (Switch/Pin/Unpin Current Window only on AppShell; Switch + Leave + Cycle Pane Focus on the BoardPage palette mount; Reorder Pane deferred to v1.1). Pane widths persisted per-board to `localStorage["runkit:board-widths:<name>"]`; pin state persists in tmux. Mobile: single-pane swipe carousel below `min-width: 640px`. | `260507-4vuv-pane-boards` |
| 2026-05-09 | **Relay grouped sessions for board panes** — `app/backend/api/relay.go` now creates a per-WebSocket ephemeral grouped session (`rk-relay-<8 hex>` from `crypto/rand`, via the new `tmux.NewGroupedSession` wrapper around `tmux new-session -d -s <ephemeral> -t <real>`) and attaches to the ephemeral rather than the real session. `defer s.tmux.KillSessionCtx(context.Background(), server, ephemeral)` reaps the ephemeral on every exit path (fresh `context.Background()` because `r.Context()` is cancelled at cleanup time). Fixes the central board bug — N panes targeting different windows of one tmux session now show distinct content because grouped sessions share window membership but maintain independent active-window state — and as a side effect fixes a latent two-tabs-same-session focus-stealing bug. New `tmux.NewGroupedSession`, `tmux.KillSessionCtx`, `tmux.RelaySessionPrefix = "rk-relay-"`, and `tmux.ListRawSessionNames` (unfiltered list-sessions for housekeeping callers); `parseSessions` filters `rk-relay-*` so ephemerals never leak into user-facing session lists at the single chokepoint. `TmuxOps` interface gains `NewGroupedSession` and `KillSessionCtx`; `mockTmuxOps` updated. Startup sweep `sweepOrphanedRelaySessions(ctx)` in `app/backend/cmd/rk/serve_sweep.go` reaps orphan ephemerals across every server returned by `tmux.ListServers(ctx)` before HTTP bind (synchronous, 30s bounded, per-server failures logged and skipped). New tests: `TestNewGroupedSession_*`, `parseSessions` filter cases, `app/backend/api/relay_test.go` (two-relay-distinct-output, missing-session-4004, ephemeral-cleanup-on-close), `boards-same-session-multi-pane.spec.ts` Playwright e2e + companion `.spec.md`. WebSocket URL contract `/relay/{session}/{window}?server={server}` unchanged — ephemeral name is purely backend-internal. | `260508-hdjr-relay-grouped-sessions-board-panes` |
| 2026-05-07 | **Server-persisted sidebar session order** — Sidebar session reorder now lives in tmux server-scoped user-option `@rk_session_order` (JSON-encoded array of session names), not localStorage, so the order syncs across all browser tabs/devices connected to the same tmux server. New `tmux.GetSessionOrder(ctx, server)` / `tmux.SetSessionOrder(ctx, server, order)` wrappers in `internal/tmux/tmux.go` (both apply `context.WithTimeout(ctx, TmuxTimeout)` and route through `tmuxExecRawServer`, which now also captures stderr in error returns so callers can pattern-match on tmux's "invalid option" / "no server running" diagnostics — `CapturePane` was the only other caller; transparent change). New endpoints `GET /api/sessions/order` (returns `{order:[...]}` — empty on unset/no-server) and `PUT /api/sessions/order` (validates each name via `validate.ValidateName`, persists, then synchronously broadcasts a new `event: session-order` SSE frame). SSE hub (`api/sse.go`) gains `previousOrderJSON` cache, `broadcastSessionOrder(server, order)` method, an `addClient` cached-snapshot send (positioned between `sessions` and `metrics`), and a once-per-server bootstrap in `poll()` that reads tmux via an injected `SessionOrderFetcher` interface (production wires `prodSessionOrderFetcher{}`; tests substitute a stub). `TmuxOps` interface gains the two methods; `mockTmuxOps` updated accordingly. Frontend: new `getSessionOrder` / `setSessionOrder` API client methods (first-arg `server` per the routing contract), `SessionContext` extended with a `sessionOrder: string[]` slice populated from the SSE event, sidebar (`sidebar/index.tsx`) implements drag-and-drop reorder with custom MIME `application/x-session-reorder` (so it doesn't collide with the existing window-drag-to-session feature), 250ms trailing-debounced PUT (one HTTP call per drag op), and a `localOrder` override that survives until the next SSE-delivered `sessionOrder` change (so the UI doesn't snap back to the pre-PUT order during the round-trip). `SessionRow` gains optional `draggable` / `isDragSource` / `onDragStart` / `onDragEnd` props (`opacity-50` while dragging; ghost/optimistic sessions are NOT draggable). `StandaloneSessionContextProvider` added for tests so existing `Sidebar` tests don't need to wrap with the EventSource-opening `SessionProvider`. New tests: `TestGetSessionOrder_*` / `TestSetSessionOrder_*` (5 tmux integration tests, gated on `command -v tmux`); `TestSessionOrder_GET_*` / `TestSessionOrder_PUT_*` (8 handler tests including `_triggersBroadcast` end-to-end via direct hub injection); `TestSSE_BroadcastSessionOrderReachesMatchingClients` / `_SessionOrderCachedOnConnect` / `_HubBootstrapReadsOrderOnFirstPoll`; `client.test.ts` extended; `session-context.test.tsx` (4 SSE-handler tests); Playwright e2e `session-reorder.spec.ts` (PUT + reload + assert order survives). Constitution alignment: I (security — names validated, args slice never shell-interpolated), II (no DB — option lives in tmux), III (process timeouts — explicit `context.WithTimeout(ctx, TmuxTimeout)` on every new tmux call), VI (tmux survives rk-go restart — bootstrap closes the cache-cold-start gap), VII (convention over configuration — uses existing `@rk_*` user-option pattern, existing `withServer` first-arg client convention). | `260507-lvon-session-order-tmux-persist` |
| 2026-05-09 | **Rotated shell layout (CSS Grid topology)** — Shell wrapper at `app/frontend/src/components/shell/shell.tsx` shared by `AppShell` and `BoardPage`. Desktop grid `"sidebar topbar" / "sidebar content" / "sidebar bottombar"` (sidebar full-height, spans 3 rows); columns `${sidebarWidth}px 1fr` ↔ `0 1fr` with `grid-template-columns 150ms ease-out`. Mobile (`< 640px`) collapses to single-column grid; sidebar renders as a grid-row-spanning overlay (`gridRow: "2/4"`, `position: absolute` not `fixed`) with backdrop, `role="dialog" aria-modal="true"` — preserves topbar visibility per existing project convention. Hamburger statically rendered at `TopBar.left` in both states (no component-level relocation; visual movement is a side effect of the column collapse). `Cmd+\` / `Ctrl+\` toggles `sidebarOpen` (registered in `KeyboardShortcuts` modal); chord suppressed on real text inputs but NOT on xterm helper textareas. `ChromeContext` shape changed: `sidebarOpen`/`sidebarWidth` lifted from per-route state; `drawerOpen`/`setDrawerOpen` removed (single boolean covers both desktop column and mobile overlay). New dispatch surface `setSidebarWidth` (in-memory, per-pointermove) + `persistSidebarWidth` (one localStorage write at drag-end) splits live updates from persistence. New `FocusedTerminalContext` (`app/frontend/src/contexts/focused-terminal-context.tsx`) tracks the focused terminal across routes — `TerminalClient` registers on mount via `registerFocus` prop, `BoardPane` registers on focus events (click, cycle, initial pane). `BottomBar` moves up to shell-level (rendered once per route by both AppShell and BoardPage); `wsRef` prop removed in favor of `focused?.wsRef` from context. BoardPage gets a BottomBar for the first time, byte-identical to AppShell's invocation. `composeOpen` lifted to `FocusedTerminalContext` so the shell-level BottomBar can open compose for the focused pane on any route; the focused pane's `TerminalClient` gates `<ComposeBuffer>` rendering on `isFocused && composeOpen`. Compose target frozen at open-time via existing `wsRef` snapshot. `TopBar` accepts `mode: "terminal" \| "board" \| "root"` (default `"terminal"`); board mode renders `Board ▸ {name} ▾` breadcrumb (the existing `BoardSwitcherDropdown`, now imported by TopBar) + inline info `{N} pane[s] · {M} server[s] · ⌘[⌘] cycle` (`hidden sm:inline`). `FixedWidthToggle` lifted out of the `currentWindow &&` block — now route-agnostic. BoardPage's bespoke `h-screen w-screen flex` root and inline mini-header are gone. Provider tree order is now `ThemeProvider > ChromeProvider > RootWrapper(SessionProvider > OptimisticProvider > FocusedTerminalProvider > Outlet)`. Frontend-only — backend untouched. | `260509-17m3-rotated-shell-layout` |
| 2026-05-28 | **Active window sync via tmux control mode** — New package `app/backend/internal/tmuxctl/` owns long-running `tmux -CC -L <socket> attach-session -t =<bootstrap> -r` subscriptions, one Client per tmux server. `Client.Open/Close/Generation/Wait` plus pure `ParseLine` for control-mode notifications (`%session-window-changed`, `%window-add`, `%window-close`, `%window-renamed`, `%sessions-changed`, `%layout-change`) and framing markers. Reconnect FSM with 250ms → 500ms → 1s → 2s → 5s capped backoff and reset on first non-`%begin` event after reconnect. `Supervisor` owns `map[socket]*Client` guarded by `sync.Mutex`, fsnotify-watches `$TMUX_TMPDIR` or `/tmp/tmux-<euid>/` (created with `os.MkdirAll(dir, 0o700)` if missing), and Open/Close-on-create/remove. Hidden `_rk-ctl` anchor session is created via `tmux new-session -d -s _rk-ctl` when a tmux server has zero existing sessions (`tmux -CC` requires an attached session); tagged with server-scoped user-option `@rk_ctl_keepalive 1` (defensive marker, no v1 consumer). New exported `tmux.ControlAnchorSessionName = "_rk-ctl"` filters the anchor from `parseSessions` (single chokepoint mirroring the `rk-relay-*` skip) and from `sweepOrphanedRelaySessions` (defense-in-depth guard). SSE loop in `api/sse.go` refactored to be event-driven via `select` over per-server `subscriber.Wait(after)` channels + safety-net `time.NewTimer(safetyIntervalEffective())`. Three constants: `safetyPollInterval = 12 * time.Second` (production cadence when a subscriber is wired), `legacyPollInterval = 2500 * time.Millisecond` (no-subscriber fallback for PTY-unavailable hosts and tests), `metricsPollInterval = 2500 * time.Millisecond` (decoupled from SSE intervals). New `WindowChangeSubscriber` interface in `api/sse.go`; production adapter `supervisorSubscriber` (`api/tmuxctl_bridge.go`) forwards `Generation`/`Wait` to `Supervisor.Get(server)` and returns a never-closing channel via `neverChan()` when no Client exists for a server (avoids busy-looping when control-mode is unavailable). `hubSink` shipped here as a no-op `tmuxctl.EventSink` (the hub observed change via the Client's generation counter) — *superseded by `260530-v6hm-active-window-event-derivation`, which made `hubSink` record per-group active-window state into a per-Client `ActiveWindowTracker`; the generation-counter path is unchanged*; `Server.SetWindowChangeSubscriber(sub)` lazy-initialises the hub and stores the subscriber. `detectKilledWindowIDs(prev, current)` extracted as a pure function in `api/sse.go` and called from the snapshot-build site (no longer inline in the poll loop); `previousWindowIDs[server]` initialised on the first snapshot per server so cold-start emits no synthetic `board-changed { cleanup }` events. `selectFirst` reads `timer.C` in the outer select to avoid a goroutine-leak where the timer would otherwise block on `Stop`-without-drain. Heartbeat moved to wall-clock cadence (`sseHeartbeatPeriod = 15s`) so the slower safety interval doesn't starve heartbeats. `cmd/rk/serve.go` orders startup as `tmux.EnsureConfig → sweepOrphanedRelaySessions → tmuxctl.NewSupervisor + Start → server.ListenAndServe`; sweep before subscribe so the `_rk-ctl` anchor isn't observed as an orphan. Graceful shutdown stops the Supervisor with a 5s bounded context BEFORE `server.Shutdown`; stop errors logged at `slog.Warn` and never block shutdown. New dep: `github.com/fsnotify/fsnotify`. Constitution alignment: §I (`exec.CommandContext` with argument slices; no shell strings), §II (no persistent state — Supervisor map mirrors kernel-observable sockets), §VI (passive observation, never mutates user sessions), no new network surface. | `260528-nvlp-active-window-sync` |
| 2026-05-28 | **`rk daemon` CLI restructure** — Daemon lifecycle moved from flag-on-serve (`rk serve -d`/`--restart`/`--stop`) to a top-level `rk daemon` subcommand tree (sibling of `rk serve`, not nested). New files in `app/backend/cmd/rk/`: `daemon.go` (parent), `daemon_start.go`, `daemon_stop.go`, `daemon_restart.go`, `daemon_status.go`, `daemon_portowner.go` (shared `lsof`→`ss` fallback port-owner helper with `exec.CommandContext` 5s timeouts + `syscall.Kill` SIGTERM→5s poll→SIGKILL escalation, no shell strings). `--force` on `start`/`stop`/`restart` overrides the port-claim safety check by SIGTERMing a non-daemon port owner; `start --force` is lazy (only triggers on the port-in-use error path), `stop --force` probes the port after stopping, `restart --force` proactively clears non-daemon holders between stop and start. All three refuse to kill the daemon itself when the holder PID matches the new `daemon.InnerServePID()` helper (tmux `pane_pid` query via `tmux -L rk-daemon list-panes -t =rk-daemon:=serve -F '#{pane_pid}'` through the new `runTmuxOutput` capturing variant of `runTmux`). `rk daemon status [--json]` is read-only — reports daemon state (running / not running, socket, session, window, target) AND port owner (`free` / `held-by-daemon` / `held-by-other`); rejects `--force` via cobra's unknown-flag error. JSON form: `{daemon: {running, socket?, session?, window?, target?, pid?}, port: {host, port, state, holder_pid?, holder_command?}}`. Hard break on the three removed flags — no deprecation forwarders; `rk serve -d` etc. surface cobra's `unknown flag` error. `cmd/rk/serve.go` retains the `internal/daemon` import only for `daemon.LogEnvVar` (PR #197 single-source-of-truth). Naming collision with the existing `rk status` (tmux session summary) is documented in `rk daemon status --help`. `internal/daemon` public API preserved verbatim except for the additive `InnerServePID()` export; `upgrade.go`'s brew-upgrade auto-restart flow (`daemon.RestartWithBinary`) is untouched. Tests: `cmd/rk/daemon_test.go` (cobra subcommand registration, JSON shape, `--force` self-kill refusal via injected stubs, `findPortOwnerImpl` integration gated on `lsof` availability); `internal/daemon/daemon_test.go` extended with `InnerServePID` no-session and running-session cases. Constitution touchpoints: §I (all subprocess via `exec.CommandContext` + `context.WithTimeout`, signal delivery via `syscall.Kill`), §III (existing `Start`/`Stop`/`Restart`/`IsRunning` re-used unchanged), §IV (net flag-surface reduction — three flags removed, one verb with four children added). | `260528-5zvv-rk-daemon-cli-restructure` |
| 2026-05-09 | **Multi-server SessionProvider + unified Sidebar** — `SessionProvider` (`app/frontend/src/contexts/session-context.tsx`) promoted from a single-server, layout-mounted provider into a root-mounted multi-server provider. State shape switched to per-server keyed `Map`s: `sessionsByServer`, `sessionOrderByServer`, `isConnectedByServer`, `metricsByServer`, plus `currentServer: string \| null` dispatched from the matched route via `useMatches()` (`params.server` for `/$server/...`, `null` for `/board/$name` and `/`). One `EventSource` per server in the pool — current server eager, others via `attachServer(name)`; per-server 3-second disconnect debounce; pool entries closed and map keys removed when `/api/servers` drops a server. `subscribeBoardChange` API multiplexes cross-server `board-changed` events through the same pool so `useBoards` and `useWindowPins` consume them without opening their own connections. Provider mounts in `RootWrapper` above `<Outlet />`; `ServerShell` becomes a thin layout (its `useMatches()` server extraction is consumed only inside AppShell). Sidebar (`app/frontend/src/components/sidebar/index.tsx`) renders one `ServerGroup` per server in `servers` (each a `CollapsiblePanel`-style collapsible) with the current server's group default-open and others default-collapsed; toggles persist per-server in `localStorage["runkit-panel-sessions-{server}"]` (best-effort migration of the legacy `runkit-panel-sessions` key into the current server's slot on first read when `currentServer` is non-null). Current-server header carries the Server panel's selected-tile shade (driven by `serverColors` + `rowTints`); no marker on board routes. Cross-server window navigation: clicking a session/window in a non-current server's tree calls `navigate({ to: "/$server/$session/$window", params: { server: thatServer, ... } })`. Cross-server window drag-and-drop is rejected with toast `"Moving windows across tmux servers isn't supported yet"` (within-server reorder/move unchanged). BoardPage (`app/frontend/src/components/board/board-page.tsx`) drops its mini-sidebar (former `<aside>` lines 202-234) and renders the unified `<Sidebar currentServer={null} ...>`; the `← Sessions` back-link is removed (sessions are reachable via the unified sidebar's per-server groups or the board top-bar `▾` switcher). Provider tree order is now `ThemeProvider > ChromeProvider > RootWrapper(SessionProvider > OptimisticProvider > Outlet)`. All 9 `useSessionContext` consumers consume the keyed shape directly — no transitional accessor remains. Tests: `session-context.test.tsx` rewritten with multi-instance `MockEventSource` (keyed by URL) for per-server SSE isolation and lazy attach assertions; `sidebar.test.tsx` updated to the new prop surface and per-server group expectations; new `multi-server-sidebar.spec.ts` Playwright e2e + companion `.spec.md` spawns a second `rk-e2e-multi-{digits}` tmux server and asserts both render in the sidebar with cross-server navigation. Backend untouched (already per-server). | `260508-dc0t-multiserver-session-provider` |
| 2026-05-29 | **Window-ID routing (index→`@N` addressing migration)** — Window *addressing identity* migrated from the mutable tmux window index to the stable window ID (`@N`) across URL, HTTP API, WS relay, and tmux targets; the index is retained only for positional reorder/move, and sessions are still identified by name. URL: `/$server/$session/$windowId` (router percent-encodes `@`). New `validate.ValidateWindowID(id, label)` (`^@[0-9]+$`) — stricter than `ValidateName`, the security boundary for the new path param (constitution §I). HTTP: window-targeting routes moved to `/api/windows/{windowId}/...` (kill, move, move-to-session, rename, color, url PUT, **new** type PUT, keys, select, split, close-pane); create stays `POST /api/sessions/{session}/windows`. `api/windows.go` `parseWindowIndex` → `parseWindowID(r) (string, bool)` (`400` on malformed before any tmux call). `TmuxOps` + `internal/tmux` window methods take `windowID string` (no session:index target string); new `ResolveWindowSession(ctx, server, windowID)` via `display-message -p '#{session_name}'`; `MoveWindow(windowID, dstIndex, server)` resolves the source index internally then bubble-swaps; `MoveWindowToSession(windowID, dstSession, server)` runs `move-window -s <windowID> -t <dstSession>:` (tmux preserves the ID across both). WS relay route `/relay/{windowId}?server=...` resolves the owning session from the ID before creating the grouped ephemeral (malformed → `400` pre-upgrade; unknown → close `4004`); grouped-session mechanism otherwise unchanged. `internal/sessions`: pane-map enrichment + `dedupEntries` re-keyed from `session:index` to windowID (the index→ID translation built from the live `WindowInfo` snapshot, since `fab pane map` emits only session+window_index); `ProjectRoot(ctx, windowID, server)`. Frontend `api/client.ts` window-targeting fns take `windowId: string`, build `/api/windows/${encodeURIComponent(windowId)}/...`, keep `server` first; relay WS URL uses windowId. Old index-based bookmarked URLs are a **hard break** (constitution §II). Tmux-authoritative selection model (260528-nvlp) preserved — only the compared identifier changed (windowId vs `String(index)`). | `260529-chgz-window-id-routing` |
| 2026-05-30 | **Window-state API stability remediation** — completes the index→`@N` convergence and brings the API into §IX conformance. **REST `/select` session-scoped**: `handleWindowSelect` resolves the owning session server-side (`ResolveWindowSession`) and issues `select-window -t <session>:@N` via the new `SelectWindowInSession` TmuxOps method — never a bare `select-window -t @N` (group-member disambiguation; the client never sends the session). **Single `decodeWindowID(r)` helper** (`api/windows.go`) does `url.PathUnescape` + `ValidateWindowID` in one place; `parseWindowID` and `handleRelay` both delegate to it (kills the copy-paste drift that caused #205); the misleading chi-v5 `RawPath` comment was corrected. **`MoveWindow` atomic** — resolves the source index once then emits the full bubble-swap sequence as one `\;`-chained `tmuxExecServer` invocation (no concurrent mutation can interleave mid-reorder); insert-before / sentinel / single-step / no-op semantics and the positional index destination unchanged. **`KillPane(paneID)` removed** from `tmux.go`, the `TmuxOps` interface, `prodTmuxOps`, and the mock (zero call sites; §IV); `KillActivePane`'s silent-success contract documented as the canonical pane-kill behavior. **Unified `POST /api/windows/{windowId}/options`** replaces the three separate `POST /color`, `PUT /url`, `PUT /type` routes — partial-merge body `{"options": map[string]*string}` (present key sets, `null` unsets, absent untouched), key allowlist `{@color, @rk_url, @rk_type}`, per-key validation preserved, validate-all-before-any-tmux, one atomic `\;`-chained invocation via the new `tmux.SetWindowOptions` + `appendOptionOps`/`WindowOptionOp` primitive; `handleWindowCreate`/`CreateWindowWithOptions` reuse the same primitive (no inline option-map path). **Uniform POST (§IX)** — the five `.Put(` routes migrated: `sessions/order`, `settings/theme`, `settings/server-color` (verb-only), `windows/{id}/url`+`windows/{id}/type` folded into `/options`; CORS `AllowedMethods` → `[GET, POST, OPTIONS]`; frontend client (`setWindowOptions`, `setWindowColor`/`updateWindowUrl`/`updateWindowType` delegating to it, `setSessionOrder`/`setThemePreference`/`setServerColor`) all POST. **Frontend identity on `@N` alone** — `pendingClickRef` holds `{windowId}` only and `urlMatchesPending` compares `pending.windowId === windowParam` (selection survives session rename / cross-session move). **Route shape `/$server/$window`** — `$session` dropped from the TanStack route + `parseParams`; `app.tsx` derives the session name from the active window's SSE snapshot, all `navigate` sites target the 2-segment shape, mount-time alignment + URL writeback compare/write window id only; old 3-segment URLs are a hard break (no redirect shim, constitution §II). | `260529-jad6-window-api-stability` |
| 2026-05-30 | **Active-window event-driven derivation** — the sidebar/URL active-window highlight is now derived from tmux control-mode `%session-window-changed` events per session group, fixing the stale-base-pointer wrong-highlight bug (an `rk-relay-*` ephemeral / `rk riff` / external `new-window` moves a group member's active window without moving the base session pointer the read path used). Fix is purely in the *derivation/read path* — no window creator changed, the ephemeral relay model untouched, frontend unchanged. New `internal/tmuxctl/active_window.go` `ActiveWindowTracker` (per-server, per-Client, in-memory §II; `sync.RWMutex` over `byGroup` group→`@wid` + `sidGroup` `$sid`→group; `Set`/`Get`/`ResolveGroup`/`ReplaceSidGroups`/`SeedGroups`/`Snapshot`). `api/tmuxctl_bridge.go`: `NewHubSink()` **removed**, replaced by `NewHubSinkFactory()` returning a per-socket tracker-bound `*hubSink`; `OnSessionWindowChanged` resolves `$sid`→group (O(1) cached map, unknown-sid tolerated) and records `@wid` (latest-event-wins); `OnSessionsChanged` refreshes the map; `OnConnectionEstablished` re-seeds (map + Tier-1 from current `#{window_active}` per group, because tmux does NOT replay `%session-window-changed` on a fresh `-CC` attach) — both re-seed callbacks offloaded to a goroutine to honor the EventSink non-blocking contract; injectable `listSessionGroups`/`listActiveByGroup` query seams for testing. `internal/tmuxctl/supervisor.go`: `NewSupervisor(factory SinkFactory)` (`Supervisor.sink` field removed; `nil` factory ⇒ `NoOpSink`); one tracker per socket allocated in `openSocket`; new `Supervisor.ActiveWindow(server, group) (wid, ok)` server-keyed read accessor. New tmux helpers `ListSessionGroups`/`parseSessionGroups` (`$sid`→group, ungrouped→name fallback) and `ListActiveWindowsByGroup`/`parseActiveWindowsByGroup` (group→active-`@wid`; honors only the leader row since `list-windows -a` reports `window_active=1` per group member) — `tmuxExecServer` + `context.WithTimeout` + pure-parse-split convention (§I, §VI). Two-tier derivation in `internal/sessions/sessions.go`: `FetchSessions(ctx, server, provider)` gains an `ActiveWindowProvider` (satisfied by `Supervisor`, injected via `prodSessionFetcher` in `api/router.go` + `Server.SetActiveWindowProvider`); pure `applyActiveWindow(windows, trackedWid)` — Tier 1 (tracked `@wid` authoritative, single-highlight enforced, stale-wid falls back to Tier 2) over Tier 2 (base `#{window_active}`); nil provider degrades to Tier-2-only. Wired in `cmd/rk/serve.go`: `NewSupervisor(NewHubSinkFactory())` then `SetActiveWindowProvider(supervisor)` alongside `SetWindowChangeSubscriber`. Tests: `active_window_test.go` (`-race` concurrent read/write), `tmuxctl_bridge_test.go` (event handling + stubbed re-seed), `internal/sessions/active_window_test.go` (`applyActiveWindow` tiers + single-highlight + stale-wid), `tmux_test.go` (new parsers). Frontend verified no-change (existing `app.tsx` writeback follows once `isActiveWindow` is corrected; boards pin explicit IDs). | `260530-v6hm-active-window-event-derivation` |
| 2026-06-02 | **`exit-empty` server-death prevention (Constitution VI).** The `_rk-ctl` control-mode anchor is now created **unconditionally** on every server the tmuxctl Supervisor observes — a permanent session floor that keeps the count above zero so tmux's default `exit-empty on` can't reap the server. Previously `resolveBootstrap` created it only on servers empty at first connect, so servers with real sessions got no floor and collapsed once their last real session closed (relays-only → next disconnect → zero → whole-server death; killed live agent sessions, ≥3×). The floor is decoupled from the attach target (still prefers a real session; `firstSessionName` skips the anchor). `tmux.SetExitEmptyOff` sets `exit-empty off` server-globally, imperatively in `productionDial` before the anchor on every dial/reconnect (ordering closes the restart-window sliver); embedded conf sets it too. **Corrects the prior §VI compliance claim in this file**: tmuxctl now *does* create a session on servers with live user sessions (incl. `default`) — but it remains harmless (filtered from UIs via `ControlAnchorSessionName`, ungrouped, read-only `-r` attach), and `exit-empty off` is the deliberate §VI *prevention*, not a violation. Lifetime is explicit-kill-only (server dies only via `kill-server`/`rk reaper`; anchor-only servers persist by design; no cross-process state, §II). Test/e2e sockets still excluded by `isTmuxSocketCandidate`→`IsTestServerName` (no resurrection). | `260602-a1wo-prevent-exit-empty-server-death` |
| 2026-06-02 | **Build-time help-dump — emit rk CLI help tree as `help/run-kit.json`** — New hidden Cobra subcommand `help-dump` in `cmd/rk/help_dump.go` (`Hidden: true`, registered in `root.go` `init()`). `captureNode` walks `rootCmd.Commands()` recursively to full depth, dropping `completion`, `help`, and any `Hidden` node at every level via `includeInDump` (self-excludes `help-dump`); leaves serialize `"commands":[]` (non-nil slice, never `null`). Per-node `name`/`path`/`short`/`usage`/`text` from `cmd.Name()`/`CommandPath()`/`Short`/`UseLine()`/`UsageString()` (raw `text`, newlines preserved). Frozen top-level contract `{tool:"rk", version, captured_at (RFC3339 UTC), schema_version:1, root:node}`, mirroring `sahil87/shll.ai` `help/wt.json`. `version` from `displayVersion()` (ldflags, never hardcoded); `captured_at` via injectable package-level `nowUTC` var + pure `buildDump(root, version, now)` builder (test seam, no global time freeze). Writes to `[output-path]` arg via `os.WriteFile` (0o644) else stdout — pure in-process introspection, no subprocess/shell-string (§I Security First). `release.yml` gains a **Publish help tree to shll.ai** step placed LAST (after the GitHub Release + Homebrew tap, so a fatal in-repo failure can't preempt the published release): `dist/rk-linux-amd64/rk help-dump help/run-kit.json` (versioned artifact) + `jq empty` validation (in-repo, fails the job on bad JSON), then a best-effort auto-merge PR (not a direct push) into `sahil87/shll.ai` via `SHLLAI_TOKEN` mirroring the Homebrew-tap token-clone pattern — skips when unchanged, and logs `::warning::` (PR left open) rather than failing the release when shll.ai is unreachable / auto-merge is disabled. rk's slice of a 7-tool "Command reference" rollout; the shll.ai site-side consumer is a separate repo (out of scope). Tests: `cmd/rk/help_dump_test.go` (shape, filtering via synthetic + real tree, self-exclusion, full-depth recursion, leaf `[]` not `null`, version-from-`displayVersion()`, injected/parseable `captured_at`). | `260602-a36m-help-dump-shll-ai` |
| 2026-06-02 | **Move-based server-scoped boards (pin sessions); relay ephemerals removed.** Boards switched from per-WebSocket `rk-relay-*` ephemeral isolation + `@rk_board` server-option encoding to a move-based pin-session model. A pinned window is `move-window`'d into its own single-window session `_rk-pin-<id>` (helpers `PinSessionName`/`WindowIDFromPinSession` in `tmux.go`); a board = the set of pin-sessions sharing an `@rk_board` SESSION var (server-scoped), with `@rk_home` (restore) + `@rk_board_order` (fractional `ComputeOrderKey`). `board.go` rewritten: pin-session-derived `ListBoardEntries`/`ListBoards`/`GetBoard` (no write-back), move-based `Pin`/`Unpin`/`Reorder` (Pin stamps 3 vars with `context.Background()`-rooted rollback + double-fault guard; idempotent incl. wrong-board re-stamp; Unpin restores to `@rk_home` or recreates a dead home via `rename-session`, no placeholder); kept `ComputeOrderKey`/`nextAppendKey`/validators/`BoardOption` (now the session-var key); deleted `parseBoardValue`/`serializeBoardValue`/`setBoardValue`/`ListAllBoardEntries`/`RemoveAllByWindowID`. **Relay** (`api/relay.go`) attaches the PTY directly to `ResolveWindowSession`'s result (home or `_rk-pin-*`) — removed the ephemeral allocation, `@rk_owner_pid` stamp, ephemeral select/kill; `ResolveWindowSession` no longer filters `rk-relay-*`. **Deleted**: `cmd/rk/serve_sweep.go` + its `serve.go` wiring; `RelaySessionPrefix`/`OwnerPIDOption`/`NewGroupedSession`/`SetSessionOwnerPID`/`GetSessionOwnerPID`/`ListRawSessionNames` from `tmux.go`; `TmuxOps.NewGroupedSession`/`SetSessionOwnerPID`. `parseSessions` skips `_rk-pin-*`+`_rk-ctl`, NO LONGER `rk-relay-*`. **SSE** (`api/sse.go`): dropped `detectKilledWindowIDs`/`previousWindowIDs`/window-kill `board-changed {cleanup}` diff + `RemoveAllByWindowID` dep, `broadcastBoardBootstrap`/`previousBoardJSON`/first-poll bootstrap/cached-board send; only pin/unpin/reorder `board-changed` events remain (`BoardEntriesFetcher` slimmed to one method). 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` (`260602-a1wo`) 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; `BoardEntry` shape + `board-pane.tsx`/`board-page.tsx` structure unchanged. | `260602-qn62-move-based-board-pin-sessions` |
| 2026-06-10 | **Live PR status in sidebar (three network-isolated layers).** New `internal/prstatus` package: `Collector` (mirrors `internal/metrics.Collector`) holds `byNumber map[int]PRStatus` under `sync.RWMutex` + `interval`; `NewCollector`/`Start(ctx)` (warm-on-start then ticks)/`Snapshot()` (deep copy under RLock)/`RefreshNow(ctx)`/private `refresh(ctx)`. `refresh` makes ONE `gh api graphql viewer.pullRequests(first:100, states:OPEN)` call via the injectable `ghExec` seam (`exec.CommandContext` + arg slice + 10s `ghTimeout`, no shell string — §I), gated by the injectable `available` seam (`ghAvailable` = `LookPath("gh")` + `gh auth status`; absent/unauth → silent no-op). Successful refresh REPLACES `byNumber` wholesale (merged/closed PRs drop out next cycle = cleanup, no eviction logic); a `gh`/parse error keeps the last-good map (stale-while-revalidate). Pure enum-collapse helpers `mapState`/`mapChecks`/`mapReview` map GitHub's `state`+`isDraft`/`statusCheckRollup.state`/`reviewDecision` to `open\|merged\|closed` / `pass\|fail\|pending\|none` / `approved\|changes_requested\|review_required\|none`. In-memory only — no DB/disk/tmux-option (§II). **Apply-time correction**: the intake's literal `gh search prs --author @me` argv was replaced by `gh api graphql` because `gh search prs` doesn't expose the rollup/review JSON fields (errors). **Layer 1** (`internal/sessions`): `paneMapEntry` gains `PrURL`/`PrNumber` (`pr_url`/`pr_number` from `fab pane map`, released fab 2.1.2), assigned onto the window in the `enrichByWindowID` join. `tmux.WindowInfo` gains `PrURL *string`/`PrNumber *int` (Layer 1) + `PrState`/`PrChecks`/`PrReview string` + `PrIsDraft bool` (Layer 3), all with `omitempty` json tags. **Layer 3** (`api/sse.go`): new one-method `PRStatusSnapshotter` seam (`Snapshot() map[int]prstatus.PRStatus`, implemented by `*prstatus.Collector`), held on the hub via `newSSEHub(fetcher, mc, pc)`; `attachPRStatus(sess)` runs in `poll()` after the cache write as a PURE in-memory read (zero network on the 2.5s hot path), reset-first (zeroes the four display fields, safe to mutate the 500ms-cached slice) then attaches only under the change-bound gate (`FabChange != "" && PrNumber != nil`). `api/router.go` constructs/starts `pc := prstatus.NewCollector(prStatusPollInterval)` next to the metrics collector, stores it on `Server.prStatus`, and passes a true-nil interface through `initSSEHub` when unset (avoids the typed-nil trap). New const `prStatusPollInterval = 90 * time.Second` (`api/sse.go`). New `POST /api/pr-status/refresh` (`api/pr_status.go` → `handlePRStatusRefresh` → `s.prStatus.RefreshNow(ctx)` → `200 {"ok":true}`, nil-collector safe; §IX). Frontend: `WindowInfo` (`src/types.ts`) gains the six PR fields with literal-union types; `refreshPrStatus()` POST wrapper (`src/api/client.ts`); shared `PrStatusLine` component (`src/components/pr-status-line.tsx`) reused by sidebar `WindowRow` + dashboard cards; dashboard card refactored from `<button>` to `<div>` wrapper so the PR `<a>` link sits outside the button (valid HTML). Tests: `prstatus_test.go` (wholesale rebuild, stale-while-revalidate, gh-absent no-op, enum collapse), `sessions_test.go` (pane-map PR enrichment by WindowID), `pr_status_test.go`, `pr-status-line.test.tsx` + `window-row.test.tsx` (gate + link), Playwright `pr-status-sidebar.spec.ts` + sibling `.spec.md`. External runtime dep: `gh` CLI (optional — fails silently if absent/unauth; ~40 calls/hr vs. 5000/hr limit). | `260610-596o-pr-status-sidebar` |
| 2026-06-03 | **Teardown shll.ai help-tree publish CI (push → pull migration).** Removed the `Publish help tree to shll.ai` step from `.github/workflows/release.yml` (the deliberately-last step, ~95 lines incl. its leading "Placed LAST…" comment): producer CI (`rk help-dump help/run-kit.json` + `jq empty`), PR-opening logic (`publish_to_shllai()` token-clone + `gh pr create`), auto-merge wiring (`gh pr merge --auto --squash`), and all `SHLLAI_TOKEN` usage (`env:` + every `$SHLLAI_TOKEN`) deleted in one cut. shll.ai moved from a **push** model (each toolkit CLI PR'd its help JSON into shll.ai) to a **pull** model (shll.ai now runs `rk help-dump` itself on a schedule and captures the output), so the push transport was redundant and a redundant cross-repo write attack surface (dual writers to `help/run-kit.json` could race). The hidden `rk help-dump` subcommand (`cmd/rk/help_dump.go`) and its tests are **preserved unchanged** — it is now the single contract surface shll.ai pulls from. The `release` job's final step is now **Update Homebrew tap**; the GitHub release/tap path is unaffected (the removed step was last + best-effort). No code references to `secrets.SHLLAI_TOKEN` remain; deleting the orphaned GitHub repo secret is an out-of-tree operator follow-up (least-privilege). Reduces attack surface per Constitution §I. | `260603-iak3-teardown-shllai-publish-ci` |
