# Internals: Modules and Key Patterns

Detailed module descriptions and the behavioral patterns that make gritty work. The companion byte-level reference is [wire-protocol.md](wire-protocol.md); the tunnel supervisor's state machine is [tunnel-state-machine.md](tunnel-state-machine.md).

**Read this when** working inside `src/` -- it explains why each module is shaped the way it is and the invariants the patterns rely on.

**Update this in the same commit** as any behavioral change to the modules or patterns it documents.

## Modules

Sixteen modules behind a lib crate (`src/lib.rs` hosts `collect_env_vars()`, `relay_writer_channel()` + `spawn_channel_relay()`, `handshake()`, `get_or_create_device_id()`, `FORWARDED_ENV_KEYS`) with thin binary entry (`src/main.rs`):

- **`security`** -- Socket/dir creation with 0700/0600 perms, ownership validation, symlink rejection, `SO_PEERCRED`. **All socket binding and dir creation MUST go through this module.**
- **`config`** -- TOML config (`$XDG_CONFIG_HOME/gritty/config.toml`). `[defaults]` + `[host.<name>]`. Precedence: CLI > host > defaults > built-in. Host aliases: `canonical_host(typed)` resolves a typed host through `[host.*] aliases` to its canonical connection name (real names win: `local` and exact `[host.*]` keys never remap; an alias claimed by multiple hosts warns and stays literal; `canonical_host_quiet` for secondary resolutions that would duplicate the warning). `alias_destination(host)` returns the first alias -- the SSH-destination fallback threaded into `connect::resolve_destination` when no `.dest` sidecar exists.
- **`protocol`** -- `Frame` enum, `Encoder`/`Decoder`, wire `[type: u8][length: u32 BE][payload]`. `PROTOCOL_VERSION: u16`. `SessionEntry` for list metadata. `SvcRequest` enum for svc socket dispatch. Also hosts `IDLE_EVICT_TIMEOUT` (90s) + `IDLE_EVICT_SAFETY_MARGIN` (10s) as a shared contract: `server.rs` uses the timeout directly, `config.rs` clamps client heartbeat settings so `interval + timeout <= IDLE_EVICT_TIMEOUT - margin`. Full byte layouts in [wire-protocol.md](wire-protocol.md).
- **`daemon`** -- Accept loop on `ctl.sock`. Handshake, control frame, route. `HashMap<u32, SessionState>`. Hands off `Framed<UnixStream>` to session tasks via `mpsc`. Periodic **socket self-check** (every `SOCKET_CHECK_INTERVAL`, 5s; `GRITTY_SOCKET_CHECK_SECS` overrides for tests): stats `ctl.sock` and compares dev/ino against the bound socket's identity. If the socket vanished or was replaced (external cleanup: systemd `$XDG_RUNTIME_DIR` teardown, `/tmp` sweeps), the daemon re-binds at the same path via `security::bind_unix_listener` and rewrites its registration (`daemon.pid` + `daemon.info`) -- sessions survive. If the path cannot be reclaimed (live competing daemon = `AddrInUse`, or unrecreatable dir), it drains sessions and exits *without removing any files* (they may belong to the new owner). On an intact socket the check also repairs a missing/wrong pid registration (`ensure_registration`). **Linger reaper** (`reap_lingering`): at the top of every loop iteration (and so at least once per `SOCKET_CHECK_INTERVAL`), reaps any session that is unattached, has a non-zero `linger_secs`, and whose `max(last_heartbeat, created_at)` is older than that. Teardown is identical to `KillSession` (`handle.abort()` -> `ManagedChild::drop` -> `killpg(pgid, SIGHUP)`: ssh-hangup semantics, so `nohup`/`disown`/`setsid` survivors keep running). The `created_at` floor stops a `-d`-created or birth-window session being reaped before its first client.
- **`server`** -- Per-session: PTY, client relay, offset-indexed output `History`, forwarding (agent/URL/tunnel/port), file transfer, tail broadcast. `History` is an always-on trailing byte ring (default 1MB) plus a monotonic `total_out` counter; a reconnecting client resumes the stream by absolute offset (`plan_replay` + `send_reconnect_replay`). Per-session sockets: `agent-{id}.sock` + `svc-{id}.sock`. Client-side forward socket: `fwd-{host}-{id}.sock` (created by the client, used by `gritty lf`/`gritty rf` to request port forwards). `SessionMetadata` tracks two wall-clock timestamps: `last_activity` (touched by PTY output and client keystrokes -- the `Idle` column) and `last_heartbeat` (touched by attach/Ping/takeover/detach -- client presence, shown in the `Status` column for detached sessions); plus `linger_secs` (atomic, updated in place by `Frame::SetLinger` so `~K` doesn't have to round-trip through the daemon).
- **`connect`** (module, implements `tunnel-create` CLI) -- Self-backgrounding SSH tunnel. Monitor respawns on transient failure (backoff 1s to 60s, resets after 30s healthy); respawn preserves the original `foreground` flag and re-runs `ensure_remote_ready` so a rebooted remote gets its `gritty server` started again before SSH forwards bytes. Per-tunnel files: `.sock`, `.pid` (written immediately on lock acquisition), `.info` (protocol version + git hash + exe path, see `runinfo` module), `.lock`, `.dest`, `.ssh-opts` (the pre-merge CLI `-o` options, one per line, so `restart`/auto-start can replay them -- config `ssh-options` are re-resolved by `tunnel-create` and deliberately NOT stored here, else they would double on replay; see `tunnel_recreate_args`), `.log`, `.out`, `.remote-sock` (cache of the remote ctl socket path; survives teardown so subsequent connects can skip the ~2s `ensure_remote_ready` SSH-exec and spawn the tunnel directly, falling back to the full check only if a post-bind `probe_tunnel_alive` fails). `ConnectGuard` Drop cleans up. `ensure_remote_ready`'s stdout contract is 2-or-3 lines (socket path, version, optional `ERR:` tag) -- the third line surfaces the `gritty ls local` stderr so a stale remote daemon gives "protocol version mismatch" instead of "empty socket path". **The supervisor's full state machine (including app-layer probing, exit-code classification, `TunnelStatus` projection, and invariants the client observer relies on) is documented in [tunnel-state-machine.md](tunnel-state-machine.md) -- update it in the same commit as any change to `connect.rs` behavior.**
- **`net_watch`** -- `NetWatcher`: OS network path-change notifications (macOS `nw_path_monitor` via Network.framework; inert stub elsewhere). Purely advisory -- `changed()` wakes a `tokio::sync::Notify` so the client relay, client reconnect backoff, and tunnel supervisor can probe/retry immediately instead of waiting out a heartbeat or backoff sleep. `status()` returns the latest `PathStatus` (`Satisfied`/`Unsatisfied`/`Unknown`, from `nw_path_get_status`); the client reconnect loop gates on `Unsatisfied` to avoid burning attempts against a down interface. Correctness still rests on the wall-clock heartbeat and `ServerAliveInterval`.
- **`alt_screen`** -- `AltScreenTracker`: byte-scanning state machine that detects alternate screen mode (`?1049`, `?1047`, `?47`). Used by server for smart reconnect.
- **`runinfo`** -- `RunInfo` sidecar (`daemon.info` / `connect-{name}.info`): records protocol version, git short hash (`GRITTY_GIT_HASH` from `build.rs`), exe path, PID, and start time for long-lived processes (daemon, tunnel supervisor). `doctor` diffs it against the on-disk binary to detect a daemon or supervisor running stale code -- the only way to catch a same-protocol rebuild (the wire handshake can't see it, and the tunnel supervisor is a pure byte proxy that no handshake ever touches). `Staleness::Protocol` (handshake will fail, `doctor` fail) vs `Staleness::Build` (works but behind, `doctor` warn).
- **`procscan`** -- Process-table reconciliation, Linux only (`/proc`; inert stub elsewhere, `SUPPORTED = false`). `find_orphan_daemons()` finds `gritty server` processes whose on-disk registration no longer points at them: it joins each candidate's `/proc/<pid>/fd` socket inodes against `/proc/net/unix` to recover the socket paths it actually bound, then cross-checks the sibling `daemon.pid`. Identification is positive (cmdline must be a `gritty server` invocation *and* the registration mismatch is proven), so daemons using `--ctl-socket`/`GRITTY_SOCKET_DIR` overrides are handled correctly and nothing is ever flagged on a guess. `OrphanReason`: `SocketFileGone` / `RegistrationMissing` / `RegistrationStolen`. `confirm_and_reap(suspects, delay)` re-scans after `CONFIRM_DELAY` (`SOCKET_CHECK_INTERVAL` + 2s) and SIGKILLs only the survivors -- the delay spares self-healing daemons, and SIGKILL (never SIGTERM) prevents the orphan's shutdown handler from unlinking files that now belong to a newer daemon. Used by `doctor` (report) and `refresh` (reap).
- **`scrollback`** -- `ScrollbackBuffer`: tracks last 50 lines of PTY output, replayed as context for a *fresh* viewer (explicit `connect` / takeover) -- an auto-reconnect instead resumes by byte offset from `History`.
- **`table`** -- `print_table()` for tabular output.
- **`logging`** -- Tracing subscriber setup with `reload::Layer` for runtime log-level switching (SIGUSR1 cycles info/debug/trace) and `ReopenableWriter` for log-file rotation (SIGUSR2 reopens the file). `init_tracing()` configures file output (daemon mode) or stderr (foreground/client). Default filters via `choose_filter_spec()`: log files get `info` (forensics -- invocation audit lines, lifecycle, security events); stderr uses the caller's `stderr_default` (`warn` for client commands so telemetry stays off interactive terminals, `info` for `server -f` / `tunnel-create -f` which exist to watch the process). `-v` and `RUST_LOG` override both.
- **`naming`** -- Pure-function helpers for the client-prefixed session-name rule. `resolve_session_name(user_input, client_name)` applies the three-case rule (bare `-` -> `-`; contains `/` -> literal; otherwise `<client>/<input>`). `display_session_name(wire_name, client_name)` strips the ambient prefix for `ls` / picker rendering. `sanitize_client_name(s)` enforces the validation rules (non-empty, no `/`, no whitespace/control) and falls back to `"unknown"` with a warning. Called from `main.rs::run()` for every session-addressing command and from `config.rs::default_client_name()` for the hostname fallback. No protocol surface -- the server sees opaque names, so this feature did not bump the protocol version.
- **`client`** -- Raw mode, escape processor, idle heartbeat (Ping fires when the client has sent nothing for `heartbeat_interval` / 10s default; 60s inbound idle-timeout declares the link dead), auto-reconnect, forwarding relay. `last_activity` is tracked via `SystemTime` (see `wall_elapsed`), not `Instant`, because `Instant` is `CLOCK_MONOTONIC` on Linux and pauses during laptop suspend -- a wall-clock clock is what correctly reflects "no server frame in N seconds of real time" across a lid-close. Ping cadence keys off `last_outbound_at` (not `last_activity`) so steady inbound server output doesn't suppress probes -- the server uses client frames as liveness for its idle-evict, and inbound data doesn't prove the client can still send. `tail()` is read-only variant.
- **`commands`** (`src/commands/`) -- CLI command implementations dispatched from `main.rs::run()`. `session.rs`: connect/tail/rename/kill/prune/list/restart and two pickers sharing `Row`/`build_rows`/`PickerTermGuard`: the single-select connect picker (`tui_pick_session`) and the multi-select prune mark-picker (`tui_mark_sessions`, behind `prune --pick`; marks are keyed by session ID, not row index, and confirmed in-TUI). `prune` is pure client-side sugar -- `ListSessions`, filter locally via `prune_selection()` (or interactive marks), then `KillSession` per victim addressed by raw session ID, never by name: enumerated names must not pass through the typed-input namespace resolution in `kill_one`, and attached sessions are never selected. `util.rs`: `parse_target()` (config-aware: splits `host:session` and canonicalizes the host through `[host.*] aliases` -- the single chokepoint for every command; raw core `split_target()` only for pre-canonicalized strings), `resolve_ctl_path()`, `server_request()`/`server_request_any_version()`, auto-start, port-forward client, clipboard/URL/info/config helpers, duration formatting. `doctor.rs`: health checks (incl. orphaned-daemon report via `procscan`, and the unknown-file scan against the on-disk state inventory below -- `--clean` removes unrecognized files). `refresh.rs`: staleness assessment + selective restart, orphan reaping (`reap_orphans`), and the end-to-end protocol probe for remote hosts (`probe_socket_protocol` through the tunnel socket; a mismatch after the per-process checks pass means the remote *binary* is a different release, and refresh fails with `gritty bootstrap <host>` guidance). `transfer.rs`: `send`/`receive`.

## On-disk state inventory

Everything gritty writes outside process memory. The unifying property: **none of it is durable state** -- sessions, output history, and scrollback live only in daemon memory, so every file below is disposable runtime registration. Readers treat each as untrusted and reconstructible; the whole socket dir can be wiped externally and the system self-heals (daemon re-binds and re-registers) or degrades cleanly (tunnel respawns on next connect).

`doctor` audits the socket dir against this inventory and warns about anything matching no row; `gritty doctor --clean` removes such unknown files (never directories, never sockets something is actively serving). **The classifier is `is_known_artifact()` in `commands/doctor.rs` -- update it, its tests, and this table in the same commit as any new artifact.**

Socket dir (`$GRITTY_SOCKET_DIR`, default `$XDG_RUNTIME_DIR/gritty/` else `/tmp/gritty-$UID/`; dir 0700, files 0600, all creation through `security`):

| Artifact | Writer | Readers | Lifecycle / if deleted externally |
|---|---|---|---|
| `ctl.sock` | daemon | clients | Bound at startup; the 5s socket self-check re-binds if wiped (sessions survive); removed on graceful shutdown only while the daemon still owns the inode |
| `daemon.pid` | daemon | doctor, refresh, procscan | Written at startup, repaired by every self-check (`ensure_registration`); removed on shutdown. If deleted: rewritten within 5s |
| `daemon.info` | daemon (via `runinfo`) | doctor, refresh | Protocol version + git hash + exe of the *running* daemon; the only signal that catches a same-protocol rebuild. If deleted: rewritten within 5s |
| `daemon.log`, `daemon.out` | daemon | user | Append-only diagnostics; SIGUSR2 reopens for external rotation (rotation suffixes are recognized). If deleted: diagnostics lost, nothing breaks |
| `<socket>.bindlock` | `security` | `security` | flock companion serializing socket binds (TOCTOU guard); removed with its socket. A leftover with no socket is litter (doctor warns) |
| `connect-{name}.sock` | tunnel supervisor | clients | Local end of the SSH forward. Lifecycle in [tunnel-state-machine.md](tunnel-state-machine.md) |
| `connect-{name}.pid` | tunnel supervisor | disconnect, doctor | Written immediately on lock acquisition (before the expensive SSH steps), so even an early-failing supervisor is addressable |
| `connect-{name}.info` | tunnel supervisor (via `runinfo`) | doctor, refresh | Staleness signal for the supervisor -- a pure byte proxy no handshake ever touches, so this file is the *only* way to see it running old code |
| `connect-{name}.lock` | tunnel supervisor | everyone | flock = liveness probe + startup serialization + cleanup gate. Held on an inode, probed by path; `LockIdentity { dev, ino }` makes cleanup safe after external replacement. mtime/atime refreshed every probe tick + every respawn iteration so age-based `/tmp` sweepers (macOS `tmp_cleaner`, `dirhelper`) don't unlink it out from under a long-lived supervisor |
| `connect-{name}.dest` | tunnel supervisor | restart, auto-start, refresh | Original SSH destination, so restart works when no config alias exists |
| `connect-{name}.ssh-opts` | tunnel supervisor | restart, refresh | Pre-merge CLI `-o` options only (config options are re-resolved on replay, else they'd double) |
| `connect-{name}.remote-sock` | tunnel supervisor | next connect | Cache of the remote ctl socket path; deliberately survives teardown to skip the ~2s `ensure_remote_ready` SSH-exec. Invalidated on probe failure |
| `connect-{name}.log`, `.out` | tunnel supervisor | user, doctor hints | Supervisor tracing and SSH stderr; deliberately survive teardown for post-mortems |
| `agent-{id}.sock` | session (server.rs) | shell's `ssh` | Lazily bound while an `-A` client is attached; removed on detach/session end (see agent lazy-bind pattern below) |
| `svc-{id}.sock` | session (server.rs) | `gritty open`/`copy`/`info` inside the session | Bound at session start, removed at session end |
| `fwd-{host}-{id}.sock` | client | `gritty lf`/`rf` | The only client-side introspection surface; a connectable one = a live attached client on this machine |
| `gritty-open` | session (server.rs) | shell's `$BROWSER` | Symlink to the gritty binary, recreated at every shell spawn |

Outside the socket dir:

| Artifact | Location | Lifecycle / if deleted |
|---|---|---|
| `config.toml` | `$XDG_CONFIG_HOME/gritty/` | User-authored, never written by gritty; missing or invalid falls back to built-in defaults |
| `device_id` | `$XDG_STATE_HOME/gritty/` | Random u64, written once on first run, never updated. If deleted: regenerated, and sessions owned by the old id need an explicit (fresh) connect to adopt the new one |

Liveness and staleness are deliberately encoded more than once because each signal catches a case the others structurally cannot: the `.lock` flock is the O(1) probe, `procscan` finds processes that crashed before releasing it (Linux only); `.info` catches same-protocol rebuilds, the HelloAck version catches wire mismatches, and `refresh`'s end-to-end probe catches a remote *binary* from a different release.

## Key Patterns

- **Connection handoff**: Daemon transfers `Framed<UnixStream>` to session task via `mpsc`. Daemon exits the data path.
- **AsyncFd + try_io**: PTY master and stdin are raw fds in `AsyncFd`. `guard.try_io()` with would-block continuation.
- **Deferred shell spawn**: PTY allocated early (with initial window size from `NewSession` cols/rows when > 0), shell waits for first client's `Env` frame. Forwarded env keys are the shared `FORWARDED_ENV_KEYS` allowlist in `lib.rs` -- `TERM`/`COLORTERM` plus `LANG` and the `LC_*` locale categories (mirrors SSH `SendEnv LANG LC_*`); `collect_env_vars()` and the server env allowlist both use it so they cannot drift. Spawns login shell with CWD from `NewSession` (or `$HOME` if empty). First client feeds directly into relay (no outer-loop re-wait).
- **Offset-indexed history**: `History` is an always-on trailing byte ring of PTY output (default 1MB, `ring_buffer_cap`) plus a monotonic `total_out` counter -- populated whether or not a client is attached, in the connected relay, the detached-drain loop, and `force_tui_redraw`. `history` covers `[base(), total()]`. The client mirrors it with `rendered_offset` (counts `Data` payload bytes it wrote to stdout; obeys `Resume` frames). On reconnect the client sends `rendered_offset` in `Attach` and the server replays exactly `Data[rendered_offset..total]` -- a blip produces a byte-exact resume with zero redundant repaint; nothing is re-sent that the client already rendered.
- **Smart reconnect**: Server tracks alternate screen mode (`\x1b[?1049h`/`l`, `?47`, `?1047`) via `AltScreenTracker` in `alt_screen.rs`. The `Attach` frame carries the client's `cols`/`rows` (applied to the PTY via `TIOCSWINSZ` + `SIGWINCH` before any replay) plus `rendered_offset` + `line_dirty` + (from `attach_token == 0`) an `is_fresh` flag. `plan_replay()` is a pure function mapping these to one of four `ReplayPlan`s, executed by `send_reconnect_replay()` which always sends a `Resume` frame first (the client's new authoritative offset) then: **Clean** -- auto-reconnect within history: replay exactly `Data[offset..total]`, no divider, no chrome (the seamless common case); when `line_dirty` the server also clears the line and repaints its prefix via `Notice`. **Truncated** -- the client's offset fell out of the 1MB window: `Notice` truncation marker (`▸ N lost while disconnected`) + `Data[base..total]`. **Fresh** -- explicit `connect` / takeover: a dim `replay_divider` rule + the last 50 `ScrollbackBuffer` lines as `Notice` (`lines_and_partial()` includes the in-progress partial line so the current prompt shows up), offset jumps to `total`. **AltRedraw** -- alt-screen: a byte suffix can't reconstruct a TUI, so the server primes `\x1b[?1049h\x1b[H\x1b[2J` (as `Notice`) and does a SIGWINCH toggle (rows-1 then rows) via `force_tui_redraw()` -- which drains and forwards PTY output between the two ioctls so the TUI doesn't block in `write()` and actually observes the intermediate size; offset jumps to `total`. All server reconnect chrome goes out as `Notice` (not `Data`) so it never perturbs the client's offset accounting.
- **Delayed reconnect chrome**: the client paints no reconnect status line until the reconnect drags past `RECONNECT_CHROME_DELAY` (1s) -- a sub-second blip leaves the terminal completely untouched (no line reserved, no cursor moved) and the `Clean` resume is invisible. Once painted (`chrome_shown`), the status line animates in place; on success the client erases it and reclaims the row (`\r\x1b[K\x1b[A`), and `line_dirty` in the `Attach` frame tells the server to repaint the cursor's line.
- **Client takeover**: `client_rx.recv()` in relay select. New client causes `Detached` to old, then switch. Capability check (500ms deadline) warns if reconnecting client is missing `-A`/`-O` that the session expects.
- **Device-based ownership (`device_id`)**: Each machine has a persistent random `u64` stored in `$XDG_STATE_HOME/gritty/device_id` (generated once via `get_or_create_device_id()`). The Hello handshake carries the device_id; the server stores it per session as `owner_device_id` in `SessionMetadata`. Auto-reconnect (inside `client::run()`) sends `attach_token = device_id` (non-zero = ownership claim); the server compares the Hello's `device_id` against `owner_device_id` and rejects with `OwnerChanged` on mismatch. Explicit connect (from `commands/session.rs`) sends `attach_token = 0` (no ownership check; server adopts the new device_id). This prevents a flaky laptop A from silently stealing back a session that laptop B legitimately force-took-over while A was disconnected, and survives client restarts and reboots since the device_id is persistent. Server restart is detected separately via `server_id` in `HelloAck`.
- **Reconnect timings**: `RECONNECT_ATTEMPT_TIMEOUT` (15s) bounds a single connect + handshake + Attach cycle; generous enough for 300ms cellular RTT with retransmits. Liveness is strictly wall-clock-based: on each heartbeat tick (10s), if `wall_elapsed(last_activity, now)` exceeds `DEFAULT_HEARTBEAT_TIMEOUT` (60s) the client returns `Disconnected` and reconnects. No suspend-detection heuristics -- a closed-lid laptop observes the gap on the first tick after wake because `SystemTime` advances across suspend while `Instant` does not. The stdin arm also checks `link_is_stale()` before `timed_send`, so a keystroke after wake triggers immediate reconnect instead of stalling `SEND_TIMEOUT` against a dead socket. Between failed reconnect attempts the client uses `next_reconnect_delay(prev)` exponential backoff 1s..10s (resets on success) -- see `client::tests::reconnect_backoff_*`. Compare to the SSH tunnel supervisor in `connect.rs`, which uses 1s..60s with a 30s healthy reset. On macOS, `NetWatcher` short-circuits both: a path-change event during the active relay fires an immediate Ping (so a dead socket surfaces right away), and a path-change during backoff cuts the sleep short and resets backoff to the minimum. Additionally, for tunnel sockets the reconnect loop gates on `NetWatcher::status() == Unsatisfied`: while the OS reports no usable route (lid closed / wifi down) it shows `waiting for network` (a dim spinner line -- `reconnect_status_line`) and parks on `changed()` instead of burning 15s attempts; on the `Unsatisfied → Satisfied` edge it resets both backoff and the elapsed counter so the user sees `reconnecting 0s` from when the network actually returned. `local` sessions skip the gate (Unix socket needs no route); a keystroke punches through it (see the keystroke-forced-retry sub-bullet below). While `tunnel_supervisor_alive` the client holds its own backoff at 1s -- the supervisor is the rate-limiter, and the client's `connect()` is to a local Unix socket that costs nothing to poll. The tunnel supervisor does the same -- `probe_tunnel_alive` after a `NET_PROBE_DEBOUNCE` (500ms) quiet window following a path change (so a sub-second wifi blip doesn't kill an SSH whose TCP would have survived it), single-strike kill gated on `uptime >= SPAWN_GRACE` (5s, so a bursty nw_path event can't kill an infant ssh whose `-L` hasn't bound yet) and on `saw_unsatisfied` (so a pure-Satisfied noise burst doesn't trigger a probe at all), and backoff reset to 1s. On respawn, `ensure_remote_ready` is skipped when the tunnel was proven healthy within `SKIP_ENSURE_REMOTE_THRESHOLD` (60s wall-clock -- `last_healthy` is a `SystemTime` so suspend time counts), saving the ~2s SSH-exec round-trip on a transient blip. Both terminal reconnect verdicts are gated by a `PersistenceGate` (a streak timer with reset) rather than a one-shot check: `socket_gone` (ctl socket missing + no supervisor) past `SOCKET_GONE_GRACE` (3s), and `daemon_gone` (ctl socket present but `ECONNREFUSED` + no supervisor) past `DAEMON_GONE_GRACE` (5s). The `daemon_gone` grace exists because wake-from-suspend can momentarily read a free lock (a ghost-lock window) for a tunnel whose remote daemon is still alive -- a zero-grace verdict there killed every session in the field. The terminal message is tunnel-aware (`daemon_gone_message`): remote sessions point at `gritty connect`, never `gritty restart` (which would kill the live remote daemon).
  - **Keystroke-forced retry**: a keystroke during the backoff wait cuts the sleep short and forces an immediate attempt -- but only once the reconnect has dragged past `RECONNECT_CHROME_DELAY` (1s). Inside that window the outage is invisible (no status line painted) and bytes on stdin are input typed at the shell during a blip, so the select arm is disabled and the bytes stay in the kernel buffer for the relay to deliver after resume; the gate is time-based, not `chrome_shown`, so alt-screen (which never paints chrome) keeps the shortcut. Past the delay, stdin is drained to WouldBlock so one multi-byte key (arrow, paste) counts as one retry, not one per byte; Ctrl-C anywhere in the drained bytes still exits, and zero-byte spurious readiness is ignored. The keystroke punches through the `Unsatisfied` park for a single probe -- post-wake path status can lag reality, and the user banging a key is claiming the network is back -- with `was_offline` preserved so a failed probe re-parks and the satisfied-edge reset still fires. A key-forced attempt leaves the backoff *untouched* (a net event resets it to zero, a served sleep advances it): the keystroke served none of the sleep, so advancing would let a few quick taps walk the exponential to its cap and delay the next automatic attempt beyond the schedule an untouched keyboard would have gotten. The status line advertises `any key retries` and flips to `retrying now` for the duration of a key-forced attempt; the ack is painted only after every gate has passed (a paint at the keypress could promise an attempt the socket-gone grace window then swallows). The hint is interactive-only (`reconnect_status_line`'s `keys_retry` flag) -- tail mode's reconnect loop doesn't watch stdin.
- **Self-daemonizing**: Fork before tokio runtime. Parent waits on pipe for readiness. PID file at `socket_dir()/daemon.pid`; identity at `socket_dir()/daemon.info` (protocol version + git hash, written by `runinfo`, read by `doctor`).
- **Signal handlers (daemon)**: SIGTERM/SIGINT = shutdown. SIGUSR1 = cycle log level (info -> debug -> trace -> info) without restart. SIGUSR2 = reopen log file (for external logrotate). `kill -USR1 $(cat $SOCKET_DIR/daemon.pid)` to toggle debug logging on a running daemon.
- **Daemon socket self-heal + orphan reaping (the upgrade/teardown lifecycle)**: three layers keep "stray gritty processes I have to kill by hand" from existing. (1) The daemon's periodic socket self-check (see `daemon` module above) re-binds after an external wipe -- sessions survive -- or exits cleanly when the path is unrecoverable/taken; the lost-socket exit drains sessions but removes *no* files, since the path may now belong to a newer daemon. (2) `procscan` makes any remaining orphans (old-binary daemons that predate self-heal) visible by reconciling the process table against on-disk registrations; `doctor` reports them, `refresh` reaps them with SIGKILL after `CONFIRM_DELAY`. (3) `refresh <host>` ends with an end-to-end Hello/HelloAck probe through the tunnel, catching the one staleness no per-process check can see: the remote *binary* being a different release than the local one (each side's `.info` is only ever compared against its own on-disk binary). Cross-cutting invariants: orphans get SIGKILL (their SIGTERM path unlinks the new owner's files), and the reap confirm-delay must exceed `SOCKET_CHECK_INTERVAL` (so a self-healing daemon is never killed mid-recovery).
- **Lockfile-based liveness**: `flock()` on `connect-{name}.lock`. Non-blocking probe distinguishes live vs dead tunnels. The file is otherwise write-once, so the supervisor calls `refresh_lock_mtime` on every probe tick and every respawn-backoff iteration to keep its atime/mtime fresh -- macOS's midnight `tmp_cleaner` (and `dirhelper`'s 3-day threshold) otherwise unlinks it out from under a multi-day supervisor, after which every observer reads "no supervisor" and gives up. The flock is held on an *inode* but probed by *path*, so the supervisor snapshots `LockIdentity { dev, ino }` at startup: `ConnectGuard::drop` only unlinks sidecar files when that identity still matches `stat(lock_path)` (otherwise it's a ghost whose lock file was replaced out from under it), the monitor exits on the next probe tick if ownership is lost, and external GC (`disconnect`, `get_tunnel_info`) goes through `cleanup_if_unheld` which acquires the flock before touching anything. A setup failure *before* `ConnectGuard` exists (an early `?` out of `ensure_remote_ready`/`spawn_tunnel`/`wait_for_socket`) would drop `lock_fd` but leave the `.lock` file on disk -- an orphaned ghost lock that makes `is_lock_held` read false for other live supervisors. `LockFileBailGuard` (armed right after `try_acquire_lock`, disarmed once `ConnectGuard` takes over) removes that file on early bail, but only while `lock_still_owned`. All three ghost-exit sites (respawn loop, monitor probe tick, `ConnectGuard::drop`) log `held` vs `on_disk` `LockIdentity` so an inode divergence is visible in logs.
- **Agent socket lazy bind**: `SSH_AUTH_SOCK` is *always* exported (pointing at `agent-{id}.sock`) so the path is stable for the shell's whole life, but the listener is bound only while an `-A` client is attached. `AgentForwardState::enable()` (on the `AgentForward` frame) binds + spawns the acceptor; `disable()` (on detach/takeover/session-end) aborts the acceptor and `cleanup_socket()`s the path. Rationale: with no listener, a process using `SSH_AUTH_SOCK` (e.g. `ssh-add -l`) gets `ECONNREFUSED`/`ENOENT` and reports "cannot connect to agent" (exit 2) -- an honest "no agent" that lets login scripts start their own -- instead of a socket that accepts-then-closes (exit 1, which masquerades as a live agent). A later `-A` reattach rebinds the same path (`bind_unix_listener` clears a stale file). Known narrow race: the shell spawns on the `Env` frame but the bind happens when the `AgentForward` frame is processed just after, so an `-A` session whose login script runs `ssh-add` in that sub-ms window could see exit 2; eliminating it needs the `-A` intent delivered before shell spawn (fold into attach/`NewSession`, a protocol bump).
- **Multi-channel forwarding**: Agent, tunnel, and port forwarding use `channel_id: u32` + `relay_writer_channel()` / `spawn_channel_relay<R, W>()`. The writer channel is created and registered (via an `Accepted` event or direct map insert) BEFORE `spawn_channel_relay` is called so the reader task cannot emit `Data` for an unregistered channel. Server-side `PortForwardOpen`/`TunnelOpen` connect to `127.0.0.1` inline (not spawned) for the same reason: the biased select reads the peer's first `Data` frame next, and a spawned connect loses that race -- the browser's initial GET was being silently dropped. All forwarded `TcpStream`s set `TCP_NODELAY`. State cleared on disconnect/takeover.
- **Terminal guards**: `RawModeGuard` + `NonBlockGuard`. Drop order matters: `NonBlockGuard` must outlive `AsyncFd`.
- **Auto-start**: `connect` auto-starts server on failure (`local` runs `gritty server`, others run `gritty tunnel-create`). Other commands fail immediately.
- **Host routing**: `parse_target()` splits `host:session`. `resolve_ctl_path()`: `--ctl-socket` > `"local"` > connect socket. `"local"` reserved keyword.
- **Escape sequences**: `~.` detach, `~R` reconnect, `~#` status (client + server diagnostics via `DiagRequest`/`DiagResponse`), `~^Z` suspend, `~?` help, `~~` literal. 3-state machine (Normal/AfterNewline/AfterTilde). `--no-escape` disables.
- **Security**: `umask(0o077)`, sockets 0600, dirs 0700, `SO_PEERCRED` on all accepts, payloads <= 1MB, resize 1..=10000.
- **URL/OAuth**: Client calls `opener::open()`. OAuth tunnel: multi-channel reverse TCP with idle timeout (default 5s, configurable). Disable with `--no-oauth-redirect`.
- **BROWSER setup**: Server creates a `gritty-open` symlink (pointing to `current_exe()`) in the socket dir unconditionally at shell spawn and sets `BROWSER` to that path. The binary detects `argv[0] == "gritty-open"` and dispatches directly to the open logic, so `$BROWSER` is a single path with no spaces. On Linux, `current_exe()` reads `/proc/self/exe` which the kernel suffixes with a literal " (deleted)" once the binary file has been unlinked (typical after `cargo build` replaces the binary in place while the daemon keeps running). `strip_deleted_suffix()` removes that marker and the call site verifies the cleaned path exists, falling back to bare `"gritty"` (relying on `$PATH`) only if it doesn't -- without this, every post-rebuild shell got a permanently broken symlink.
- **Capability negotiation**: `Hello` and `HelloAck` carry a `capabilities: u32` bitfield. Negotiated capabilities = client & server (bitwise AND). `CAP_CLIPBOARD (0x01)` gates clipboard frame forwarding. Capabilities propagate from daemon `connection_handshake()` through `ClientConn::Active` to the session server, refreshed on each reconnect/takeover. Clipboard paste has a 5-second timeout -- if the client doesn't reply with `ClipboardData`, the pending paste is resolved with `None`.
- **Client-initiated port forwarding**: Port forwards are requested by the client via `PortForwardRequest` frames through the session connection, not through the svc socket. The `lf`/`rf` commands communicate with the client process through a client-side forward socket (`{socket_dir}/fwd-{host}-{id}.sock`). A compromised server cannot initiate port forwards.
- **Port forwarding is loopback-only**: All `TcpListener::bind` and `TcpStream::connect` in forwarding use `127.0.0.1`. No bind-address specification (unlike SSH `-L`/`-R`).
- **Client-side security gates**: URL opening gated by `forward_open` on the client (rate limited 2/30s). Clipboard is push-only -- server can push `ClipboardSet` (rate limited 5/30s) but `ClipboardGet` returns empty. Agent forwarding defaults to off (opt-in via `-A`). `TunnelListen` rate limited 2/30s, `AgentOpen` rate limited 10/30s. Audit logging at info/warn level for all security-sensitive operations.

## Core signatures

Update these alongside any change to the corresponding function/enum -- they are called from both the daemon and tests.

- **`daemon::run()` / `daemon::run_with_options()`** -- `run(ctl_path, ready_fd)` is the production entry (main.rs); it delegates to `run_with_options(ctl_path, ready_fd, DaemonOptions::default())`. `DaemonOptions { socket_check_interval }` controls the socket self-check cadence; `Default` is `SOCKET_CHECK_INTERVAL` (5s) unless `GRITTY_SOCKET_CHECK_SECS` is set (test/debug knob -- integration tests spawn the real binary and need to speed the check up or park it). Daemon tests use `run_with_options` with millisecond intervals.
- **`server::run()`** -- takes `(client_rx, metadata_slot, SessionConfig { agent_socket_path, svc_socket_path, session_id, session_name, command, ring_buffer_cap, oauth_tunnel_idle_timeout, initial_cols, initial_rows, cwd, initial_device_id, idle_evict_timeout, linger_secs })`. `SessionConfig` has `Default` (1MB history, 5s oauth timeout, 90s idle-evict, linger 0/never). Called by e2e tests + daemon; update both.
- **`client::run()`** -- takes `(framed, ClientConfig { session, session_id, ctl_path, env_vars, no_escape, forward_agent, forward_open, oauth_redirect, oauth_timeout, heartbeat_interval, heartbeat_timeout, client_name, expected_server_id, device_id })`. `device_id` is the persistent machine identifier from `get_or_create_device_id()`.
- **`ClientConn::Active`** -- struct variant `Active { framed, client_name, capabilities, cols, rows, rendered_offset, line_dirty, is_fresh }`. `client_name` propagated from `Attach`/`NewSession` frame to session metadata. `capabilities` is the negotiated bitfield from handshake. `cols`/`rows` carry the reconnecting client's current terminal size (0 = unknown, used for probe-only attaches and NewSession auto-attach) and are applied to the PTY before reconnect replay. `rendered_offset` + `line_dirty` come from the `Attach` frame; `is_fresh` is `attach_token == 0` (explicit connect). All three feed `plan_replay` / `send_reconnect_replay`. `ClientConn::Shutdown` is a unit variant sent by `daemon::shutdown()` on kill-server / SIGTERM; the session task responds by sending `Frame::ServerShutdown` to the attached client and broadcasting `TailEvent::Shutdown` to tail observers, then exiting.
