Roost architecture & principles¶
This is Roost's living architecture and the principles behind it —
the north star every PR is measured against. The Rust + Swift port this
doc originally planned is complete and merged to main (cutover
2026-05-23); the migration's phased history is summarized in
Migration history and detailed in
plans/. The legacy Go + GTK4 implementation is
described in spec.md and the legacy
architecture.md, retained until the
GODELETE step removes the Go code.
The product¶
A single-window, cross-platform terminal multiplexer: a sidebar of
projects, tabs per project, one terminal per tab. The differentiator is
the multi-project workspace with notification routing for AI coding
agents (Claude Code, Codex, …). It ships as two native UIs that each
embed the workspace + PTY supervisor in-process — Swift + AppKit on
macOS (Roost.app), Rust + gtk4-rs on Linux (roost-linux).
libghostty-vt is vendored once and linked into both for in-process VT
parsing and rendering. There is no daemon.
The command core (north star)¶
Every way to drive Roost — mouse/clicks, hotkeys, the roostctl CLI,
and Lua scripts — converges on one core: the workspace operation
set (open/close/focus tab, create/rename/delete/reorder project,
set-state, notify, dump, … plus a few view ops like screenshot /
open-palette). Each surface is a thin adapter onto that core, and the
UI is a reaction to the core's events — never its own source of
truth.
roostctl (CLI) ─┐
Lua scripts ────┤──▶ IPC handler ──┐
├─▶ workspace op set ──emit──▶ events ──▶ UI re-renders
mouse / clicks ─┐ │ (THE CORE)
hotkeys ────────┤──▶ UI dispatch ───┘
- CLI + Lua are out-of-process → reach the core over the IPC socket (the handler is their adapter; Lua sits on top of the same op set).
- Clicks + hotkeys are in-process → call the same op set directly (their adapter is the UI command / keybind handler).
- A hotkey (
Cmd+Shift+T), aroostctlcall, and a Lua script all invoke the same command — e.g. "run action" or "open tab".
One contract, two implementations. There is no shared codebase
core — Swift and Rust can't share one. There is one shared contract
— the IPC op set in crates/roost-ipc —
implemented by Swift Workspace + AppKit and Rust Workspace +
GTK. "Same interface" means same op contract + behavioral parity,
which the cross-platform E2E suite (test-automation.md)
exists to enforce. Per platform: identical command surface,
platform-specific guts (forkpty vs portable-pty, Core Graphics vs
Cairo).
Two seams connect the surfaces to the core:
- surfaces → core (commands in): CLI/Lua via IPC, UI/hotkeys
direct. Every UI/hotkey action should route through the op set, not
carry divergent local logic. Convergence is an ongoing invariant —
each place the UI keeps its own truth instead of reacting to a core
event is a bug to retire (e.g. the dropped
active.changedthe Mac UI used to ignore). - core → UI (view reach-back: screenshot / dump / activate): GTK's
one
UiRequestchannel, Mac's oneUiBridgeseam — a single registered path from the IPC handler to the main thread, not an ad-hoc channel per op.
Why this is the north star. It buys the three things Roost optimizes for at once:
- Testability — tests drive the same op set users do and assert on its events/state. No test-only backdoors that can drift from reality.
- Programmability — the op set is the public surface; Lua actions and the launcher are first-class clients of it, same as the CLI.
- Clean architecture — one place owns each mutation; the UI is a pure projection of core state; adding a capability is "add an op + thin adapters", not bespoke logic per surface.
Every decision below — and every new feature — is measured against it: does it route through the one op set, keep the UI reactive, and stay at parity across both implementations?
Why this shape¶
Native Mac UI. AppKit gives the right trackpad, menu, accessibility,
and notarization story for macOS. A Swift .app bundle drops the
Homebrew GTK4 dependency entirely and makes signing + DMG distribution a
standard Apple workflow.
No daemon. Each UI is a single process that owns its PTY supervisor,
workspace state, and IPC server. There is no separate roost-core
binary to spawn, supervise, or lifecycle. An earlier design had a gRPC
daemon owning state for thin clients; the realistic deployment is one
user, one UI process, with roostctl and Claude hooks as occasional
control-plane callers. Collapsing the daemon into the UI removed the
cross-process serialization, the gRPC bindings (tonic, prost,
grpc-swift), SQLite, and the entire proto/ directory — ~4,400 LOC of
plumbing.
JSON IPC, not gRPC. With control-plane traffic only (no streaming
PTY bytes), the wire surface is small enough that a hand-rolled
newline-delimited JSON framing protocol over a Unix socket is simpler
than HTTP/2 + protobuf. Frame cap is 16 MiB, the schema is one Markdown
file, hand-debuggable with nc -U. Dropping protobuf evolution rules is
acceptable because the only producers + consumers are versioned together
in this repo.
Rust core (Linux UI + IPC + CLI). Memory-safe, ergonomic FFI in both
directions, mature async (tokio), small static binaries. The
systems-level work — PTY lifecycle, OSC parsing, IPC framing — is
exactly what Rust is good at.
gtk4-rs over Go + GTK on Linux. Same toolchain as the rest of the
Rust workspace means one Cargo build, no cgo, no gotk4 binding
gotchas.
Two languages, not one. Swift on Linux would still bind GTK4 (no
AppKit on Linux), so "uniform language" doesn't unify the UI code — it
just adds the Swift runtime to Linux bundles and trades gotk4 for the
less-mature SwiftGTK. The thing worth unifying is the JSON IPC wire
format + the PTY/workspace state machines, and those are mirrored
idiomatically in each language without sharing source.
Architecture¶
flowchart LR
subgraph MacApp["Roost.app (Swift + AppKit)"]
macView["Cell renderer<br/>(AppKit + Core Graphics)"]
macVT["libghostty-vt<br/>(in-process VT parse)"]
macWS["Workspace + PtySupervisor<br/>(@MainActor)"]
macIPC["JSON IPC server<br/>(Darwin sockets)"]
macView --- macVT --- macWS
macWS --- macIPC
end
subgraph LinuxApp["roost-linux (Rust + gtk4-rs)"]
linuxView["Cell renderer<br/>(Cairo + Pango)"]
linuxVT["libghostty-vt<br/>(in-process VT parse)"]
linuxWS["Workspace + PtySupervisor<br/>(tokio + portable-pty)"]
linuxIPC["JSON IPC server<br/>(tokio UnixListener)"]
linuxView --- linuxVT --- linuxWS
linuxWS --- linuxIPC
end
subgraph External["External tooling"]
roostctl["roostctl CLI"]
claude["Claude Code hooks"]
end
roostctl -- "UDS<br/>JSON IPC" --- macIPC
roostctl -- "UDS<br/>JSON IPC" --- linuxIPC
claude -- "UDS<br/>JSON IPC" --- macIPC
claude -- "UDS<br/>JSON IPC" --- linuxIPC
Hot path. PTY bytes flow kernel → master fd → in-process drain task
→ libghostty-vt vt_write → renderer. Everything is in the same process;
the IPC socket carries only control messages and event broadcasts, never
PTY content.
Why the renderer stays out of the IPC. Putting cell deltas or
rendered frames over a socket means every redraw is a context switch and
a serialization cost. With the workspace in-process, PTY bytes never
cross any process boundary — the hot path is one kernel read() per
chunk, then everything else is in-process memory.
Non-goals¶
- No web/Electron UI. Native renderers only.
- No Windows. macOS and Linux exclusively.
- No multi-window. One window per Roost instance, projects in the sidebar, tabs in the projects.
- No split-pane. One terminal per tab.
- No remote / network IPC. Unix domain socket only. The JSON wire format is local IPC, not a public API.
- No rendered output over the wire. PTY bytes only ever live in-process.
- No shared UI code between Mac and Linux beyond the JSON wire format. Each UI is idiomatic to its platform.
- No core rewrites in third languages. Rust for the Linux UI + CLI + IPC + supporting crates; Swift for the Mac UI. Lua is an embedded scripting surface for user automation (see DL-12), not a third implementation language.
Decision log¶
Short ADR-style entries. Each captures a live decision so it is not relitigated by accident.
DL-1: Swift + AppKit on Mac, not Rust¶
Swift owns the macOS native experience: HIG, AppKit lifecycle, accessibility, notarization, App Store-adjacent tooling. A Rust UI on Mac would either depend on a cross-platform toolkit (loses native feel) or hand-roll Cocoa bindings (multiplies cost). The JSON IPC boundary makes mixing languages costless.
DL-2: JSON IPC, not gRPC (revised 2026-05-23)¶
Original DL-2 chose protobuf + gRPC because the architecture had a
daemon serving streaming PTY bytes to thin remote-style clients. That
premise dissolved in the inline-core refactor: each UI now owns its PTY
supervisor in-process, so the IPC surface is small (a few dozen control
ops + an event subscription), strictly local, and small enough to
inspect by hand. Newline-delimited JSON over a Unix socket with a 16 MiB
frame cap costs ~600 lines (crates/roost-ipc) vs. a multi-crate tonic +
prost dependency. The wire spec lives in ipc.md.
The pre-rewrite proto schema is preserved at
docs/archive/roost.proto for reference.
DL-3: Unix domain socket, not TCP¶
Roost is a local desktop app. UDS gets filesystem permissions for free, no port allocation, no exposure to network attackers, lower latency. If a future need warrants remote access, that is a separate proxy concern, not a contract change.
DL-4: Each UI owns its own workspace (revised 2026-05-23)¶
The pre-rewrite design had a shared roost-core daemon owning workspace
state in SQLite; UIs were thin gRPC clients. The realistic deployment is
one user, one UI process, plus occasional control-plane callers.
Collapsing the workspace into each UI removed the gRPC pump, SQLite,
cross-process serialization, and the "cross-client convergence" rabbit
hole. State persistence is a small state.json (atomic tmp + rename;
write-through, fsync on clean exit) carrying projects + next_id + each
project's tab layout (title + cwd + position) + active selection (see
DL-7).
DL-5: Two languages, not Rust everywhere¶
Considered and rejected: Rust + gtk4-rs on Mac. This still requires hand-rolled AppKit/macOS integration for menus, dock, notifications, and notarization metadata. Net effort is higher than just using Swift, and the Mac feel is worse. The unification benefit does not pay for itself when the AppKit surface is the larger half of a Mac terminal's native experience.
DL-6: gtk4-rs does not need a pangoextra workaround¶
gtk4-rs calls pango_cairo_context_set_font_options directly via raw
FFI and does not have the gotk4 cairo.FontOptions record-struct
mismatch that forces the Go binary's internal/pangoextra workaround.
The workaround dies with the Go code in GODELETE.
DL-7: Tabs persist as layout, not live state (revised 2026-05-24)¶
state.json stores each project's tab layout — {title, cwd,
position} per tab, plus the active project + active tab position. On
relaunch each project re-opens its prior tabs as fresh shells in
their saved directories. What is not restored: the live process and
scrollback ("preserving" those was always a fiction since the daemon-era
StreamPty re-spawned the shell on attach anyway). The tabs array +
active_* fields are additive + defaulted, so a file written by one
build (or the other UI) still loads in the other. Coupled with the
last-tab cascade (closing a project's last tab closes the project;
emptying the workspace quits), relaunch is predictable: same projects +
tabs, same directories, fresh shells.
DL-8: OSC routing is the differentiator¶
The OSC scanner (crates/roost-osc, mac/Sources/Roost/OscScanner.swift)
plus the per-tab set_hook_active suppression rule is subtle and is what
makes Roost useful to anyone running multiple AI coding agents in
parallel. It is treated as a first-class slice, not bundled into
structural feature parity.
DL-9: CLI is roostctl¶
crates/roost-cli ships a roostctl binary. The Mac bundle embeds it
under Contents/Resources/bin/roostctl so claude install invoked from
inside Roost.app writes hook paths that point at the bundled location.
DL-10: Ghostty SHA pinned in third_party/ghostty/build.sh¶
third_party/ghostty/build.sh pins the libghostty-vt commit for the
Rust + Swift builds. (The legacy build/build.sh pins the same commit
for the Go cgo build and is retired with the Go code in GODELETE; until
then, bumps move both in lockstep.)
DL-11: One command core, thin per-surface adapters (2026-05-26)¶
Every input surface — UI clicks, hotkeys, roostctl, Lua — routes
through the same workspace operation set; the UI is a reaction to the
core's events, not a parallel source of truth. Adding a capability means
adding an op + thin adapters, not per-surface logic. This is the
north star; it is the test applied to
every change, because it is what buys testability, programmability, and a
clean architecture simultaneously. Concretely it forbids: UI state that
diverges from the workspace, ops reachable from one surface but not
another without reason, and the two implementations drifting out of
behavioral parity.
DL-12: pytest drives the tests; Lua is a user-scripting surface (2026-05-26)¶
Two distinct roles, one shared core:
- Tests are driven by pytest over the IPC op set (plus
roostctl/shell for simple cases) — mature fixtures, parametrization over both UIs, and reporting, with the affordances that actually kill flakiness (roostctl wait,tab.dump) living in the app, not the runner. See test-automation.md. - Lua is an embedded user-scripting surface for the Cmd+Shift+T launcher and complex user-authored multi-step actions — a first-class client of the same op set, deliberately scoped, not the test mechanism. We add Lua where it earns user-facing programmability and avoid over-investing it as test infrastructure.
Both remain thin adapters onto the one command core (DL-11): a Lua action and a pytest step invoke the same ops.
Migration history¶
The Rust + Swift port is complete (cutover to main 2026-05-23). The
phased plan that built it — direction-setter → FFI spikes → (interim
gRPC daemon) → Mac UI → Linux UI → inline-core refactor (daemon → JSON
IPC, roostctl, delete roost-core/roost-proto/roost-common/
roost-smoke) → bundling → cutover — is recorded phase-by-phase in
plans/. The one remaining step is GODELETE: removing
the legacy Go code (cmd/, internal/, go.mod, build/,
go-legacy.yml) once Rust/Swift parity is confirmed — see
plans/GODELETE.md.
Relationship to existing docs¶
| Document | Role |
|---|---|
docs/development/vision.md (this file) |
The living architecture + principles. Every PR is measured against the north star + decision log here. |
docs/development/test-automation.md |
The testing + scripting plan that operationalizes the north star (pytest E2E, Lua scripting, CI). |
docs/reference/ipc.md |
JSON IPC wire format spec — the canonical command contract. |
docs/reference/architecture.md |
Package layout + threading contract for the in-process implementation. |
docs/development/spec.md |
Original design spec for the legacy Go + GTK4 implementation. Historical; retained until GODELETE. |
docs/archive/roost.proto |
Historical reference for the pre-rewrite gRPC contract. |
CLAUDE.md |
Project conventions enforced by review; mirrors the principles here. |
After GODELETE, spec.md and the legacy architecture diagrams move to
docs/historical/ with a one-line note at the top.