Architecture¶
Roost ships two native UIs — Swift + AppKit on macOS (Roost.app) and Rust + gtk4-rs on Linux (roost-linux) — that each embed the workspace + PTY supervisor in-process. External tooling (the roostctl CLI, Claude Code hooks) talks to a running UI via newline-delimited JSON over a Unix-domain socket; the wire format is documented in docs/reference/ipc.md. libghostty-vt is vendored once and linked directly into both UIs for in-process VT parsing and rendering.
For the durable design rationale (why two languages, why in-process, why local UDS) see Vision. For the legacy Go binary's architecture see Legacy → Architecture.
Stack¶
| Layer | macOS | Linux |
|---|---|---|
| Window + chrome | Swift + AppKit | Rust + gtk4-rs + libadwaita |
| Renderer | Core Graphics over libghostty-vt cell grid | Cairo + Pango over libghostty-vt cell grid |
| Terminal engine | libghostty-vt (vendored, shared archive) |
libghostty-vt (vendored, shared archive) |
| Workspace | mac/Sources/Roost/Workspace.swift (@MainActor) |
crates/roost-linux/src/daemon/state.rs |
| PTY supervisor | mac/Sources/Roost/PtySupervisor.swift (forkpty + DispatchSourceRead) |
crates/roost-linux/src/daemon/pty.rs (portable-pty + tokio tasks) |
| Persistence | state.json via tmp + fsync + replaceItemAt |
state.json via tmp + fsync + rename + parent-dir fsync |
| IPC server | mac/Sources/Roost/IPCServer.swift (Darwin sockets) |
crates/roost-ipc/src/server.rs (tokio UnixListener) |
| IPC wire types | mac/Sources/Roost/IPCMessages.swift (Codable) |
crates/roost-ipc/src/messages.rs (serde) |
| OSC scanning | mac/Sources/Roost/OscScanner.swift per TerminalView |
roost-osc crate per per-tab drain task |
| Single-instance | mac/Sources/Roost/SingleInstance.swift (flock via @_silgen_name) |
crates/roost-linux/src/single_instance.rs (fs2::FileExt::try_lock_exclusive) |
| Shell-integration CLI | roostctl (binary from crates/roost-cli) — same binary on both platforms |
(same) |
The UIs are written separately and idiomatic to their platform; only the JSON IPC wire format is shared between them (via the roost-ipc crate on the Rust side + its hand-mirrored Swift counterpart in IPCMessages.swift).
Repository layout¶
crates/
roost-ipc/ # JSON wire format, framing, client, server, paths, target picker
roost-vt/ # libghostty-vt FFI wrapper (--features ffi)
roost-osc/ # OSC scanner + state machine
roost-cli/ # roostctl binary
roost-linux/ # Linux UI (gtk4-rs) — embeds Workspace + PtySupervisor + IPC server
mac/
Sources/Roost/ # Swift Mac UI — embeds Workspace + PtySupervisor + IPC server
Resources/ # themes, Info.plist.template, Roost.entitlements
Tests/RoostTests/ # swift-testing test suite
scripts/bundle.sh # SwiftPM → .app bundle + embedded roostctl + ad-hoc codesign
docs/
reference/ipc.md # JSON IPC wire spec — canonical
archive/roost.proto # Historical reference (the pre-M7 gRPC schema)
third_party/ghostty/ # Vendored libghostty-vt build
cmd/, internal/, build/ # Legacy Go binary — see Legacy section, deleted in Phase 9
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 (tab.open, tab.write, events.subscribe, etc.) and event broadcasts. The renderer never sees the wire.
flowchart LR
CLI["roostctl notify"]
Hook["roostctl claude-hook"]
OSC["printf '\\033]9;…'"]
PTY["PTY supervisor<br/>(in-process)"]
Workspace["Workspace<br/>(in-process)"]
Scanner["OSC scanner<br/>(per TerminalView)"]
UI["UI event handler<br/>(main thread)"]
Indicator["per-tab indicator"]
Stripe["sidebar rollup stripe"]
Banner["desktop notification"]
IPC["JSON IPC server"]
CLI --> IPC
Hook --> IPC
IPC --> Workspace
OSC --> PTY --> Scanner --> Workspace
Workspace --> UI
UI --> Indicator
UI --> Stripe
UI --> Banner
The wire surface is small enough to inspect by hand:
Threading¶
Both UI toolkits (AppKit, GTK4) are single-threaded. Widget operations and libghostty-vt calls must run on the main thread.
| Layer | Thread |
|---|---|
| UI widgets, draw, input | Main thread only |
libghostty-vt terminal handle + vt_write |
Main thread only |
| PTY read (master fd) | DispatchSourceRead background queue (Mac) / dedicated tokio task (Linux) |
| PTY write | Main thread (LocalClient.writeTab) |
| OSC dispatch | Main thread (hopped from the read queue) |
| IPC accept loop | Detached Task (Mac) / tokio task (Linux) — never blocks main |
| IPC handler dispatch | Per-connection Task (Mac) / tokio task (Linux); mutations hop to main |
state.json writes |
Main thread (small; atomic via tmp + rename) |
The Mac PTY read path uses a dedicated pattern: the DispatchSourceRead closure is installed via a nonisolated static helper so Swift 6 doesn't infer @MainActor isolation on the closure body (which would trip dispatch_assert_queue(main) from the dispatch worker thread). Bytes bridge to the main actor through a Sendable AsyncStream<InternalEvent> that a drain Task { @MainActor in ... } consumes — see mac/Sources/Roost/PtySupervisor.swift for the comment block that walks through this.
Boundaries¶
- Each UI process owns its workspace, PTY supervisor, and IPC server. There is no separate daemon. State is in memory + the bundle-profile
state.jsonfile. libghostty-vtlives inside each UI for VT parsing + rendering.- OSC scanning lives in the UI (
OscScanner.swifton macOS,roost-osccrate on Linux) because OSC parsing walks the same byte stream the VT parser does. OSC events apply directly to the local workspace viaLocalClient.applyOSC. - Terminal query replies (the program asking the terminal for its colors, device attributes, etc.) split across two channels — embedder-synthesized OSC color replies vs. libghostty-answered device replies. See Terminal query replies for which is which and why.
- The IPC server is per-UI: external tooling (
roostctl, Claude hooks) talks to the bundle profile's socket (~/Library/Caches/Roost/roost.sockfor Mac,$XDG_RUNTIME_DIR/roost/roost.sockfor Linux).roostctl --target {mac,gtk}routes to the right one;roostctlwith no--targetauto-detects via a parallelconnect()probe of both candidate sockets. - Single-instance enforcement uses
flock(LOCK_EX | LOCK_NB)on a pidfile next to the socket. Second launches read the holder PID and exit 0.ROOST_ALLOW_MULTI=1bypasses for dev/test workflows. - The legacy Go
cmd/+internal/tree is unaffected by the Rust workspace and builds independently viabuild/build.shuntil the Phase 9 cutover.
See Vision → Decision log for the rationale behind each major choice.