Easy Terminal
Native macOS app that runs Codex, Claude, and shells as terminal panes in one window. Bundles its own PTY daemon. Pasted images become file paths agents read.
The Problem
Running multiple coding agents at once — Codex in one window, Claude in another, plus a shell or two — means juggling separate terminal windows, losing sessions when a window closes, and burning attention on window management instead of the work. Most terminal multiplexers are heavyweight Electron apps or fragile across multi-hour sessions. The agents have their own rough edges: Claude Code’s clipboard handling drops pasted images, so design references never make it into context.
What I Built
A native macOS app that runs Codex, Claude, and shells as terminal panes in a single window. Sessions persist and stay bounded across long runs. Paste or drop an image or PDF in a pane and the agent reads it by file path — side-stepping the clipboard bug.
Status: 0.1.0 developer build. A historical pre-Adaptive daemon soak passed four hours with four concurrent agents (resident memory near 3 MB, no zombies, no crashes); the current integrated app/daemon build and focused test suites pass. It isn’t released yet — current symmetric performance gates, Developer ID signing, notarization, and install/upgrade smoke remain open.
What it does
- Runs several agents in one window. Claude, Codex, and shells render as terminal views, so full-screen programs like vim and top work the same as a standalone terminal.
- Turns pasted images into file paths. Cmd-V on an image or PDF in a pane saves the file to a temp location and injects the path into the agent, bypassing the clipboard bug.
- Reports agent status at a glance. A status-forward sidebar shows which agent is working, blocked, or done across workspaces, driven by live daemon state.
- Bundles its own daemon. The app ships a small Apache-2.0 PTY daemon (
ptyd), spawns it on a private socket, and owns its lifecycle. - Offers two layouts. A split view (default) and a workspace view with a tree, multi-pane grid, and agent context inspector. Toggle with Shift-Cmd-U.
Architecture
Easy Terminal is a two-process system: a native SwiftUI client and a headless Rust daemon, joined by a newline-delimited JSON socket on a private, per-app Unix socket. The client owns all terminal emulation; the daemon owns process supervision, raw byte streaming, and persistence. Nothing in the stack calls a model — the section below states that explicitly, because for a dev tool it’s an architecture fact, not an omission.
- Client (Swift + SwiftUI + SwiftTerm) — the only terminal emulator in the stack. One
SwiftTermview per pane renders the daemon’s raw PTY byte stream directly, so full-screen programs (vim, top) behave exactly as in a standalone terminal. An optional native “Adaptive” projection renders beside it when a pane’s session carries typed events. - Daemon (
ptyd, Rust, Apache-2.0, std-only, no async runtime, ~23,000 lines acrossmain.rs/pane.rs/protocol.rs/etc.) — supervises every pane’s child process viaportable-pty, owns raw scrollback and resize, persists a typed-event ledger, and exposes the socket as the sole IPC contract with the Swift client. The app bundles and owns this daemon’s lifecycle on a private socket; a separate CLI-driven instance runs on its own socket for scripted use. - Detection (
screen-extractorcrate, Apache-2.0, ~2,200 lines, zero runtime dependencies) — a from-scratch VT grid emulator, co-located in the same Cargo workspace, that reconstructs each pane’s viewport from a side copy of the raw bytes (never the render path) fordetect.rs’s agent-state matchers. - Coordination (
terminal-rally-pointbinary) — a second binary built from the same workspace that subscribes to a.rallyledger via kernel file events (inotify/kqueue, through a ~150-linelibcshim chosen over thenotifycrate’s 7+ transitive dependencies) and executes/acknowledges cross-agent directives by logical agent id. Depends onrally-protocol, pinned to a specific commit of the separate Agent Rally Point repo. - CLI (
et) — a shell wrapper that resolves and drives the bundledptydbinary, giving both GUI launch (et,et app) and daemon-driven pane/agent/workspace operations (et pane,et agent,et workspace) one front door.et serverruns its own independent daemon instance (~/.config/ptyd/ptyd.sock), distinct from the GUI app’s private per-process daemon. - Mobile pairing (optional,
mobileCargo feature) — TLS 1.3-only transport (rustls+ring+rcgen) for a phone to attach to a running pane over a QR-code pairing flow. Compiled out by default: a plain build opens no network port. - MCP server (optional,
mcpCargo feature) —ptyd-mcp, a standalone binary on the official Rust MCP SDK (rmcp). It is the only place in the codebase that pulls an async runtime (tokio), and it only talks to the runningptydas a client over the same Unix socket the Swift app uses — it never links into the daemon’s core render path.
Models — which, where, why: none. Easy Terminal hosts AI coding agents (Claude Code, Codex) as external CLI child processes inside terminal panes; it never calls a model itself. Its one classification task — agent-state detection (idle / working / blocked / unknown) — is deterministic pattern matching in detect.rs: per-agent matchers keyed on the literal glyphs and status phrases each CLI draws, derived from captured real claude/codex output fixtures. That’s a rule-based lookup table, not a trained or inferred classifier.
Tools & infra — which, why:
- SwiftTerm (MIT) — terminal rendering view, one instance per pane; the only VT emulator on the render path.
portable-pty(MIT) — cross-platform PTY spawn/resize (openpty+ winsize ioctls, Windows ConPTY), genuinely impractical to hand-roll.screen-extractor(Apache-2.0, zero deps) — VT grid reconstruction for detection only, never rendering; kept dependency-free by design.libc— a hand-rolled inotify (Linux) / kqueue (macOS) file-watch shim, chosen over thenotifycrate to avoid 7+ transitive dependencies; already a transitive dep viaportable-pty, so this adds zero packages.rally-protocol— git dependency pinned to an exact Agent Rally Point commit; the wire contract for theterminal-rally-pointcoordination binary.rustls+ring+rcgen(feature-gated) — TLS 1.3-only transport and certificate generation for mobile pairing; off by default.rmcp+tokio(feature-gated) — official Rust MCP SDK, powering the standaloneptyd-mcpclient binary; the sole async-runtime dependency in the project.- JSON socket protocol, newline-delimited — the entire IPC surface between the Swift client and the Rust daemon, framed by
wire.rson the daemon side. - JSONL append-only ledger — typed-event persistence with temp-then-rename durability, survives restart independent of raw scrollback.
xcodebuild+XcodeGen+ Cargo workspace — build tooling;build.shwraps both toolchains into one Release.app.- Hosting: none. Easy Terminal is 100% local — no server, database, or cloud service anywhere in the stack.
flowchart LR
A["App launch"] --> B{"Private socket<br/>already bound?"}
B -->|yes| C["Adopt running ptyd"]
B -->|stale process| D["Reap stale process"] --> E["Spawn ptyd"]
B -->|no daemon| E
C --> F["Pane opened"]
E --> F
F --> G["portable-pty spawns<br/>claude / codex / shell"]
G --> H["Raw PTY bytes"]
H --> I["SwiftTerm view<br/>(client, per pane)"]
H -.side copy, never render path.-> J["screen-extractor<br/>VT grid reconstruction"]
J --> K["detect.rs<br/>per-agent glyph/phrase matchers"]
K --> L["Sidebar status:<br/>idle / working / blocked"]
G -.paste image or PDF.-> M["temp file + path<br/>injected into agent input"]
N["Typed producer<br/>(CLI today; automatic producers open)"] -.etevent.append.-> O[("ptyd JSONL ledger<br/>sequenced, sync-persisted")]
O -.etevent.replay, cursor.-> P["ETStreamViewModel"]
P --> Q["Adaptive renderer<br/>(Markdown, diffs, charts, tables)"]
R["Phone, QR pairing"] -.TLS 1.3, mobile feature only.-> E
S[".rally ledger"] -.kernel file events.-> T["terminal-rally-point<br/>directives + receipts"]
How it works
- On launch,
DaemonControllerprobes the app’s private Unix socket: a healthyptydgets adopted, a stale process gets reaped, otherwise the app spawns a freshptyd server— never a blind duplicate daemon. - Opening a pane sends a socket verb to
ptyd;pane.rs, the daemon’s workspace/tab/pane registry, spawns the target process —claude,codex, or a shell — throughportable-pty. - The daemon streams that process’s raw PTY bytes back over the socket; a
SwiftTermview, one per pane, renders them directly. This is the only terminal emulation in the stack — the daemon does none. - A side copy of the same raw bytes (never the render path) feeds
screen-extractor, which reconstructs the pane’s VT grid from scratch — dual screen buffers, scroll regions, wide/combining chars, deferred autowrap. detect.rsclassifies that reconstructed viewport into idle / working / blocked / unknown using per-agent matchers keyed on the literal glyphs and status phrases each CLI draws, derived from capturedclaude/codexoutput fixtures — a rule-based lookup, not a model. The result drives the sidebar’s live status dots.- Pasting or dropping an image or PDF into a pane saves it to a temp file and injects the file path into the agent’s input stream, sidestepping the terminal clipboard’s inability to carry binary image data.
- Optionally, a typed producer calls
etevent.appendto attach structured content (Markdown, plans, diffs, charts, tables) to the pane’s session; the daemon validates identity and session, assigns a sequence number, and persists it synchronously to the JSONL ledger before acknowledging. etevent.replayserves that ledger to the client from an exclusiveafter_seqcursor;ETStreamViewModelvalidates, dedupes, and incrementally projects it into the “Adaptive” renderer, toggleable alongside the always-available Terminal view. Today only the CLI produces this contract by hand — automatic Claude/Codex/ACP producers remain open work.terminal-rally-point, a separate binary from the same workspace, subscribes to a.rallyledger via kernel file events (inotify/kqueue through a hand-rolledlibcshim) and executes/acknowledges cross-agent coordination directives by logical agent id.- A phone can pair over TLS 1.3, via a QR code the app displays, to view or attach to a running pane. This transport compiles only under the opt-in
mobileCargo feature — a default build opens no network port. - The
etCLI wraps the sameptydbinary for scripted use, butet serverruns a second, independent daemon instance on its own socket — separate from the GUI app’s private per-process daemon, with its own pane list.
Tech stack
Swift + SwiftUI on macOS for the client, with SwiftTerm (MIT) as the only terminal-emulation view. Rust for the daemon side: ptyd (std-only, no async runtime) plus its own screen-extractor crate (zero runtime dependencies), both in one Cargo workspace so an app revision identifies the exact daemon source it ships. portable-pty handles cross-platform PTY spawn/resize; libc backs a hand-rolled inotify/kqueue file-watch shim in place of the heavier notify crate. rally-protocol is a git dependency pinned to a specific Agent Rally Point commit, powering the terminal-rally-point coordination binary. Two Cargo features are opt-in and compiled out by default: mobile (rustls + ring + rcgen for TLS 1.3 phone pairing) and mcp (rmcp, the official Rust MCP SDK, plus tokio — the project’s only async runtime, confined to the standalone ptyd-mcp binary). Build tooling is xcodebuild + XcodeGen for Swift and Cargo for Rust, unified by build.sh. No servers, databases, or cloud services — Easy Terminal runs entirely on the local machine.
Design Decisions
Independent by constraint. Dropping heavyweight dependencies shrinks the binary and reduces attack surface — image decode is client-side, memory-safe Swift. SwiftTerm (pure Swift) carries terminal emulation rather than a months-long from-scratch VT emulator.
Stability is a hard requirement. One SwiftTerm view per pane. Scrollback bounded at 500 lines, event log rotates. Attachment cache capped at 256 MB and on-disk scrollback at 256 MB — both least-recently-used eviction. Sixel off, image cache capped. On close: SIGTERM, exit-code check, file descriptors held under 50, no zombies. The target is several concurrent multi-hour agent sessions with zero leaks.
Calm Precision throughout. Aurora Deep tokens, macOS HIG, status communicated through color and weight rather than badges, left-border accent selection rather than pills.
Build & Distribution
Three build paths exist for three different audiences:
| What you run | Tool | Config | Output |
|---|---|---|---|
./build.sh / et | command-line xcodebuild | Release | ./build/ (in-repo) |
| build-loop agent runs | build.sh with ET_AGENT_ID | Release | ./build-<id>/ (transient) |
| Cmd-R in Xcode.app | Xcode IDE | Debug | ~/Library/Developer/Xcode/DerivedData/... |
Convention: Xcode GUI for dev (Debug); build.sh + dist/ for shipping (Release). The .dmg ships ad-hoc-signed; right-click → Open is needed on first launch until notarization lands.
Credits and Licensing
Easy Terminal, the ptyd daemon, and the screen-extractor crate are Apache-2.0. Terminal rendering uses SwiftTerm (MIT). The daemon’s dependencies are Apache, MIT, or ISC licensed (portable-pty, serde, rustls with ring).