# Xenomorphic daemon / UI split — design Status: design agreed by user 2026-09-08. Not yet implemented. Related notes: - `notes/daemon-contract.md` — normative phase 1 wire contract. Implementers code against that file; this one holds the rationale. - `notes/daemon-remote-tailscale.md` — deferred remote/Tailscale design plus a pickup checklist. ## Decisions taken 1. **Daemon is a detached process the app spawns itself.** No service registration, no launchd, no systemd unit. No install step and no permission prompt. Survives app quit, app crash, app relaunch. Dies at logout or reboot. Accepted. 2. **No on-disk byte log.** In-memory only. After daemon death the byte history is gone. Recovery is respawn: `PaneRecord.restart` + `restartArgv` + `spawnCwd` relaunch the programs, panes come back with empty screens. Accepted. 3. **The daemon owns the session structure.** workspaces → tabs → split tree → panes live in the daemon, because a client cannot know which panes to request or how to lay them out otherwise. 4. **Daemon classes mirror the current QObjects 1:1** in both data and behavior. See "AppModel relocation". 5. **WebSockets only, one transport.** `QWebSocketServer`, local now and remote later, so remote stays additive. See "Protocol". 6. **Transparent auth (local).** Per-boot token file (0600) sent in the handshake header. No prompts. 7. **Log trim policy settled.** Trim only at parser-ground record boundaries; 10 MB cap; below-floor attach returns the real floor. 8. **Windows support revoked (2026-09-08).** POSIX only: macOS + Linux. The ConPTY branch was already dead code, since `flake.nix:10` lists only `x86_64-linux`, `aarch64-linux`, `aarch64-darwin`. Leave the `#ifdef _WIN32` branches in place; removing them is a separate cleanup decision. 9. **`PtyClient` mirrors `Pty`'s shape; no shared base class (2026-09-08).** The two classes never coexist in one process, so no interface is needed. Supersedes the earlier "extract an interface" plan. 10. **`PtyClient::close()` means detach, never kill (2026-09-08).** The single most load-bearing change in the split. Today the kill is implicit in `~TerminalQuickItem`, which is exactly why shells die at quit. If `close()` forwarded to a daemon `kill`, the daemon would buy nothing. Killing becomes an explicit `/ctl` message sent only from the user-initiated close path. Normative detail in `notes/daemon-contract.md`. 11. **Remote/Tailscale deferred (2026-09-08).** Daemon binds `127.0.0.1` only. Local-only auth. The settled remote design lives in `notes/daemon-remote-tailscale.md`, kept cheap by the WebSockets-only choice: picking it up adds a listener plus an authorization check, not a new transport or codec. ## Central principle Terminal state is a pure function of the PTY byte stream plus the resize history. Therefore the daemon needs no terminal semantics. It stores bytes and replays them. Every client keeps its own local `GhosttyTerminal`, renderer, selection, kitty graphics, and glyph atlas, all unchanged. This is why the byte log beats ghostty's snapshot API (`ghostty_snapshot_encode_alloc`, snapshot.h:284-517): - snapshot.h defines no kitty graphics record tag, so a 256 MB kitty store (TerminalQuickItem.cpp:294-295) would be lost. - encode requires `GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES` set before the input that produced a mid-sequence state (snapshot.h:267-269, terminal.h:1797-1799). Not set today, so encode would return `GHOSTTY_INVALID_VALUE` on any pane caught mid escape sequence. - format v1 has no compatibility guarantee (snapshot.h:112-113). - replaying the real stream costs nothing extra and loses nothing. ## Why shells die today `TerminalQuickItem` owns `m_pty` as a QObject child (TerminalQuickItem.cpp:63). App quit destroys the item, `~Pty` closes `m_masterFd`. Closing the master makes the kernel SIGHUP the slave's foreground process group and the shell exits. Whoever holds the master fd decides whether shells live. Note: the source contains no `setsid()`, but `forkpty()` (Pty.cpp:516) performs it in the child per its documented contract. The shell is already a session leader with the pty slave as controlling terminal. [inference from documented forkpty(3) behavior, libc source not read] Corollary that matters for the daemon: because the shell is already a session leader with the pty as its controlling terminal, moving the master fd to another process does not change the shell's session or job control. The daemon can spawn panes exactly as the app does today and `tcgetpgrp` (Pty.h:68-72) keeps working. ## Process layout ``` xenomorphicd QtCore + QtNetwork + QtWebSockets only. No QtGui, no QtQuick, no libghostty-vt. owns: Pty instances, master fds, shell children, pids, per-pane ordered byte log, session structure, SessionStore (session.json) xenomorphic unchanged renderer. owns: GhosttyTerminal, GhosttyRenderState, selection, kitty, glyph atlas, shaders, QML, controllers, XenoSettings future client same codec, same WebSocket transport, remote URL. Deferred: see notes/daemon-remote-tailscale.md ``` Daemon links no ghostty. Verified feasible: ghostty is a pure C library and the daemon does not need it. Daemon links `qtwebsockets`. ## Protocol (settled 2026-09-08) ### Connection topology One control connection per client, always open. Plus one data connection per pane, opened **lazily**: a client streams only the panes it actually displays. A phone showing one pane opens one data connection. Desktop with four visible panes opens four. Background panes are never attached, so there is no cold-replay storm and no head-of-line blocking between panes. Attach and detach are connect and close. No protocol churn on tab switch or split. ### Verified constraint: QWebSocketServer is TCP-only From `qtwebsockets-6.11.2/lib/QtWebSockets.framework/Versions/A/Headers/qwebsocketserver.h`: - `listen(const QHostAddress&, quint16 port)` (line 50). Host address and port only. - `handleConnection(QTcpSocket *socket)` (line 112). Takes a `QTcpSocket`, so `QLocalSocket` is not usable. - `setSocketDescriptor(qintptr)` (line 77) exists, but QtWebSockets wraps the fd in a `QTcpSocket`, which is AF_INET only. An AF_UNIX fd will not work. QtWebSockets 6.11.2 is in nixpkgs and matches the project's Qt 6.11.1, so availability is not the issue. Choosing WebSockets forces TCP loopback locally and loses Unix domain sockets. ### Decision (revised 2026-09-08): WebSockets only, one transport Centralize on `QWebSocketServer` for both local and remote. No `QLocalSocket` path, no second adapter. What this costs and why it is acceptable: `QWebSocketServer` is TCP-only (verified above), so local clients connect over TCP loopback instead of a Unix socket, and the free 0600 filesystem gate is gone. Authentication must therefore be explicit. See "Transparent authentication" below. What this buys: - One transport adapter, one codec, one connection lifecycle to test. - Frame boundaries come free. WebSocket frames are already delimited, so the control plane drops newline framing entirely: one JSON object per text frame. The DevDriver's newline delimiter existed only because TCP is a byte stream (DevDriver.cpp:27-40); WebSocket removes that need. - Forward-looking: when remote lands, it runs the identical code path. A remote client is not a special case, it is the same client pointed at a different URL. This is the main reason to accept WebSockets locally even though remote is deferred. - Keepalive ping and close codes come from the library. TLS only matters for the deferred remote listener. Performance note that favors this choice on the hot path: RFC 6455 requires client-to-server frames to be masked and forbids servers from masking. The XOR masking cost therefore lands on keystrokes, which are tiny. PTY output, the high-volume direction, travels server-to-client unmasked. ### Verified QWebSocket API surface relied upon From `qtwebsockets-6.11.2/.../Headers/qwebsocket.h`: - `open(const QNetworkRequest&)` (line 108) and `request()` (line 71): handshake headers work on both client and server side, so the token can ride the WebSocket handshake natively. No in-band auth message needed. - `peerAddress()` (line 51): the peer IP. Unused locally, needed by the deferred Tailscale whois gate. - `setPauseMode` / `pauseMode` / `resume` (lines 50, 64, 63) and `bytesToWrite()` (line 89): real backpressure. This is what keeps a slow client from blocking the pty reader thread. - `sendBinaryMessage` / `sendTextMessage` (lines 79-78), `binaryMessageReceived` / `textMessageReceived` (lines 130-129): the two planes map directly onto binary and text frames. - `setOutgoingFrameSize` (line 98), `setMaxAllowedIncomingFrameSize` (line 91), `setMaxAllowedIncomingMessageSize` (line 93): frame and message limits are configurable, so the 10 MB log cap and 16 KB pty chunks can be sized deliberately. - `close(CloseCode, reason)` (lines 103-104) with the full code set in `qwebsocketprotocol.h:35-49`: `CloseCodePolicyViolated` (1008) for auth failure, `CloseCodeTooMuchData` (1009) for a client that exceeds limits, `CloseCodeBadOperation` (1011) for protocol violations. Close codes are worth using properly. They let a client distinguish "you are not authorized" from "you fell behind and must re-seek" without an extra in-band error message. ### Transparent authentication Local-only for now. One mechanism, automatic, no prompt. **Local clients: per-boot token file.** The daemon generates a random token at start and writes it with its listen port to `${XDG_CONFIG_HOME:-$HOME/.config}/xenomorphic/daemon.json` at mode 0600. Clients read that file, then send the token in the handshake. Rejected alternatives and why: - *Peer credentials (`SO_PEERCRED`)*: does not work at all here. It is a Unix-domain-socket facility, and WebSocket is TCP. Dead on arrival. - *OS keychain*: prompts on macOS, and needs three platform integrations. Contradicts "transparent". - *Static long-lived token*: a token that never rotates leaks permanently. Per-boot rotation bounds the damage. Security analysis, stated plainly. With a Unix socket at 0600 the kernel gates the *connection* by file permission. With a token file at 0600 the daemon gates it after *accept*. The trust boundary is the same in both cases: can this process read a 0600 file in your home directory? A process running as your uid can already read your files and spawn your shells, so it gains nothing. A process running as another uid can neither connect (port is bound to loopback only, and it cannot read the token) nor read the token. The daemon must bind `127.0.0.1` for local, never `0.0.0.0`. Optimization: the app spawns the daemon, so the first client can receive the token over the spawn pipe or argv and skip the file read entirely. The file is still required, because a relaunched app must discover an already-running daemon. **Remote clients: deferred.** The daemon binds `127.0.0.1` only and never `0.0.0.0`. There is no remote listener, so no remote authorization path is needed now. The settled remote design (Tailscale whois on accept, macOS localapi socket paths, App Store TCP+token fallback, TLS via Tailscale node cert, fail closed) is recorded in `notes/daemon-remote-tailscale.md` with a pickup checklist. It stays cheap because WebSockets is already the only transport: picking it up adds a listener plus an authorization gate on accept, not a new transport or codec. ### Control plane One JSON object per **text** frame. No newline delimiter: WebSocket frames are already delimited, so the DevDriver's newline framing (DevDriver.cpp:27-40) is unnecessary here and is dropped. Authentication rides the handshake via `QNetworkRequest` headers (qwebsocket.h:108), not an in-band `auth` message. A client that is not authorized never gets a usable control connection; it is closed with `CloseCodePolicyViolated` during or immediately after the handshake. Client → daemon: `spawn(spec) -> paneId`, `kill(paneId)`, `resize(paneId,cols,rows)` (controlling client only), `structure()`, `mutate(op)`. Daemon → client: `exit(paneId,code)`, `cwd(paneId,path)`, `structureChanged(...)` notifications. ### Data plane A data connection opens with the pane and start sequence in the handshake URL, so attach needs no in-band message either: `ws://127.0.0.1:PORT/pane/p7?fromSeq=12000` (The deferred remote form is `wss://host/pane/p7?fromSeq=12000`; only the scheme and the authorization mechanism differ. See `notes/daemon-remote-tailscale.md`.) The daemon answers with one text ack (confirming the real floor if `fromSeq` was below it), then the connection carries only **binary** frames for the rest of its life. Binary frame fields: **stream id (u32), seq, tag, payload length, payload**. Stream id is a compact u32 the daemon assigns at attach time, not the pane's string id. A string in every frame would be wasteful. The u32 is redundant on a per-pane connection today. Pay the four bytes anyway: it makes a future multiplexed single-connection transport a pure transport change with no codec change. Tags: `BYTES`, `RESIZE`, `EXIT`. That is the whole data plane. Sizing: `setOutgoingFrameSize` (qwebsocket.h:98) should be tuned to the pty chunk size, which is 16 KB today (Pty reader thread buffer). `setMaxAllowedIncomingFrameSize`/`setMaxAllowedIncomingMessageSize` (qwebsocket.h:91-93) bound what a client can send, so a malformed or hostile frame cannot allocate unbounded memory. ### Resize must ride the data stream (ordering rule, cannot bend) Reflow on resize is a computation the client VT performs. It is not bytes in the stream. A client that learns sizes out of band while replaying bytes reaches a different grid than the original. So `RESIZE` frames sit in the data stream, in order, interleaved with `BYTES`. A replaying client reconstructs correct state, then applies its own final resize and reflows locally. Two distinct directions: - Client → daemon on control: make the pty this size. Controlling client only. - Daemon → client on the data stream: the pty became this size at this point in history. Every client receives this. `EXIT` also belongs on the data stream, since it is the ordered end of that pane's history. Control separately notifies the mirror so structure updates. ### The log is the source of truth, sockets are hints The daemon reads the pty, appends to the in-memory log, then tries to write to each attached client. The daemon never blocks the pty reader thread on a slow client. Backpressure uses the verified `QWebSocket` API: check `bytesToWrite()` (qwebsocket.h:89) before writing, and when a client exceeds its cap call `pauseMode`/`resume` (qwebsocket.h:64,63) or close it outright with `CloseCodeTooMuchData` (1009) and let it re-attach from its last seq. Lossless, because the log still holds everything. Backpressure comes free: an invisible pane is not attached, so it consumes no socket bandwidth. A visible pane that falls behind resyncs by seeking. The pty reader thread (16 KB chunks, Pty.cpp) and the daemon's WebSocket writer must not contend. Keep the log append and the fan-out on the daemon's event loop, fed by the reader thread's queued signal, exactly as `readyRead` feeds `vt_write` today (TerminalQuickItem.cpp:548-571). **Reattach is a seek.** `attach(paneId, fromSeq=N)` returns exactly the missed records. Cold start replays from the log head. Replay is progressive: the UI's VT is renderable at any point mid-stream, so the client paints as bytes arrive and never waits for a state dump. **Below-floor attach.** When `fromSeq` is below the trimmed log floor, the daemon replies with the real floor and the client accepts partial history with older scrollback lost. This is where the agreed trim policy lands. ## UI-side change is small `TerminalQuickItem` holds one `Pty* m_pty` (TerminalQuickItem.h:313) and connects exactly two signals (TerminalQuickItem.cpp:80-81): `readyRead(QByteArray)` and `processExited(int)`. **Settled (user, 2026-09-08): the UI uses a `PtyClient` class with the same shape as `Pty`** — the same nine methods and two signals (Pty.h:29-136) — that transparently talks to the daemon's `Pty` over the wire protocol. The goal is to minimize client changes. No shared abstract base class is extracted. `Pty` and `PtyClient` never coexist in one process: the daemon compiles `Pty`, the UI compiles `PtyClient`, and nothing must hold a pointer to both. So no virtual dispatch on `write()` (called on every keystroke), and `Pty` stays `final` (Pty.h:29). `PtyClient` matches `Pty`'s surface by convention, not inheritance. An earlier draft of this note proposed extracting a 9-method interface. That is superseded by the `PtyClient` approach. The 160 KB of renderer code does not change. The full `PtyClient` design — including the `close()`-means-detach requirement, the paneId injection gap, the synchronous-accessor problem, and lazy auth — is normative in `notes/daemon-contract.md` under "PtyClient: the UI-side pty". ## AppModel relocation QML cannot bind to QObjects in another process. So the daemon holds the authoritative structure, and the UI keeps its existing QObject tree as a wire-driven mirror. **Settled requirement (user, 2026-09-08): the daemon's classes must mirror the current QObjects 1:1 in both data and behavior.** The daemon's `Workspace` / `XenoTab` / `SplitNode` / `SplitContainerNode` / `PaneNode` / `AppModel` carry the same fields, the same tagged-sum `kind()` + exhaustive `visit()`, the same `NodeLocation` path index, the same `ActivePath` shape, and the same mutation methods with the same guards (`insertChild`, `takeChild`, `setRatios`, `movePaneBeside`, `swapPanes`, `movePaneToTab`, `movePaneToNewTab`). The difference is only that the daemon's copies are plain structs/classes rather than QObjects, so they carry no signals and no parent-child ownership. Why 1:1 matters: - All AppModel manipulation logic moves to the daemon by changing the storage substrate, not by rewriting the algorithms. The guards and the structural rules are copied, not reinvented. - The UI mirror and the daemon core stay diffable against each other. A divergence bug is visible by comparing two nearly identical classes instead of two different designs. - `tst_appmodel.cpp` (43.6 KB) and `tst_splittree.cpp` retarget at the daemon core with minimal edits, because the API under test is the same API they test today. What moves: mutations, guards, the path index, id generation, session serialization. What stays in the UI: the QObject wrappers QML binds to, `PanePool`, controllers, `XenoSettings`. Rejected alternative: daemon owns QObjects and the UI builds a generic proxy for QML to bind through. That would require rewriting every QML binding over `containerNode.children`/`.ratios`/`.axis`, `modelData.isContainer`, `Workspace.kindWord`. Far larger, no benefit. **Single writer (agreed).** The daemon is authoritative. Structure mutations round-trip and the mirror applies on notification. No optimistic local edits. A local socket round trip is microseconds, structure changes are rare next to byte traffic, and divergence between two clients would be far worse. **Audit needed:** any QML or controller code that mutates structure and reads the tree back in the same frame. Mission-control drag and split operations are the likely candidates. ## What moves where | Item | Owner after split | |---|---| | `Pty`, master fds, shell children, pids | daemon | | per-pane byte log + seq numbering | daemon (memory only) | | workspaces → tabs → split tree → pane ids | daemon | | `PaneRecord` spawn fields (`spawnCwd`, `spawnCommand`, `spawnInitialCommands`, `spawnTitle`, `restart`, `restartArgv`) | daemon | | pane cwd (`foregroundCwd` via lsof, Pty.h:74-79) | daemon | | `SessionStore` / session.json | daemon | | `GhosttyTerminal`, `GhosttyRenderState` | UI, per client | | renderer, glyph atlas, shaders, selection, kitty | UI, per client | | `PanePool` (live `TerminalQuickItem` re-parenting) | UI | | `XenoSettings` (font, sidebar) | UI | | `XenoSettings.processRestartMatches` | UI, but pushed to daemon, since the daemon now owns foreground pids at quit time | | `Commands` registry + controllers | UI (Quick-dependent) | | `ActivePath` (focus/selection) | UI, per client. Each client has its own focus | `ActivePath` stays per-client deliberately. Two clients viewing the same session should not fight over which pane has focus. ## Platform POSIX only: macOS + Linux. Windows support is revoked (decision 8). The ConPTY branch in `Pty.cpp`/`Pty.h` (`#ifdef _WIN32`) is dead code that the build never compiled, since `flake.nix:10` lists only `x86_64-linux`, `aarch64-linux`, `aarch64-darwin`. Leave it in place during the split; do not touch it. Removing it is a separate cleanup decision, not part of this work, and the daemon inherits the POSIX path only. Daemon needs QtCore + QtNetwork + QtWebSockets, runs headless on macOS and Linux. Nix: add a `xenomorphicd` derivation beside `app.nix`. Omit `wrapQtAppsHook` (no Qt plugins to resolve) and omit the `${ghostty}/lib` rpath (daemon does not link ghostty). Add `qt6.qtwebsockets` to both the daemon and the app buildInputs. Auth is the local per-boot token file. The daemon binds `127.0.0.1` and never `0.0.0.0`. Remote auth is deferred (see `notes/daemon-remote-tailscale.md`). ## Phases 1. **Byte broker.** Move `Pty` into a new `xenomorphicd` binary with the in-memory seq log + `QWebSocketServer` (local, token auth, `127.0.0.1`). Add `support/DaemonLink` (owns `/ctl`, lazy auth, cached in AppModel) and `terminal/PtyClient` (same shape as `Pty`, owns its `/pane/` data connection). App auto-spawns the detached daemon on launch and reconnects to a running one. Delivers: shells survive closing the app. 2. **Structure in the daemon.** Move AppModel logic to a shared plain-data core mirroring the QObjects 1:1. UI AppModel becomes a wire-driven mirror. `SessionStore` and structure notifications cross the wire. Delivers: any client sees the real layout. 3. **Remote (deferred).** Not scheduled. Design and pickup checklist are in `notes/daemon-remote-tailscale.md`. Because phase 1 standardized on WebSockets, this is additive: a second listener binding plus a Tailscale whois authorization gate on accept, no new transport or codec. Sequencing note: phase 1 alone delivers the main ask and is small. Phase 2 is the large refactor. Doing phase 2 first would block the simple win behind it. Phase 3 is explicitly out of scope for now. ## Known risks **Extra hop on the hot path.** Today `readyRead` is a queued cross-thread signal into `vt_write` (TerminalQuickItem.cpp:548-571). Adding a socket hop costs tens of microseconds per 16 KB chunk. `lastChunkReadUs` (Pty.h:85) already instruments this, so measure the regression. **Trim boundary must be parser-safe (concrete mechanism).** Trimming at an arbitrary byte makes a cold attach below floor start mid escape sequence, which corrupts the client's VT. The daemon has no VT, so it cannot ask one whether it is at ground. Resolution: the daemon tracks a tiny byte-scanner state, not a terminal. It only needs "am I inside a CSI / OSC / APC / DCS sequence, and am I inside a UTF-8 multibyte char". Roughly fifty lines. It trims only at points where the scanner reports ground, and only at record boundaries so a `BYTES` payload is never split. ghostty's standalone `osc.h` is precedent for shipping a sub-parser without the terminal. Cap the log at the same 10 MB the client VT already allows (TerminalQuickItem.cpp:287), since the log holds the same bytes the scrollback holds, not extra memory. On overflow a cold attach gets the recent tail only, and the daemon replies with the real floor. **Two clients on one pane at different sizes (deferred).** One pty has one `TIOCSWINSZ`. Only becomes reachable once a second client can attach, which is the deferred remote work. Working rule: the controlling client sets the size, others render the true pty grid and scroll or scale. Decide when remote lands; see `notes/daemon-remote-tailscale.md`. **Titles cross the boundary.** Titles arrive from the UI's VT via `TITLE_CHANGED` (TerminalQuickItem.cpp:320-321), and that stays true after the split: each client runs its own VT, so it derives its own title. No daemon work needed. Only the deferred remote client, which may want titles for panes it has not attached to, would need a small OSC sniffer in the daemon. ghostty ships a standalone `osc.h` for exactly that. **Renderer must not move.** `TerminalSceneRenderer` reads `GhosttyRenderState` by direct pointer on the render thread under `m_stateMutex` (TerminalQuickItem.cpp:756-783, 2661). render.h documents this as intentional shared-memory access for a renderer thread sharing a lock with an IO thread. There is no serialization seam. This rules out "daemon owns the VT and ships frames" permanently.