Termio Source on GitHub

A terminal-first agentic development environment. Sessions live in a daemon, not in the window — close the laptop, reopen, the agent is where you left it. Local and remote are the same code path.


When agents write most of the code, the environment’s job changes. It isn’t to help you type — it’s to run several agents at once and tell you which one needs you.

Termio is that environment, and it’s a real terminal, because that’s where the agents already live. Claude Code, Codex, OpenCode, Amp, Cursor, Copilot, Kimi, Grok, Crush — Termio wires up each one’s hooks on first launch, then reports every session as working, idle, or needs you: a dot in the sidebar, a tray that rings when one is blocked, the same signal on your phone.

It’s a macOS app in Swift on top of libghostty, and a daemon in Rust.

Table of contents

The one rule the architecture is built on

A session lives in a host. Viewers only attach.

Everything else follows from that sentence. The shell’s PTY is owned by termiod, a long-lived daemon. termio.app is a client — it attaches, renders, and detaches. Quitting the app closes windows; it does not end sessions. Only Close Session (⌘W) does.

flowchart LR
    subgraph C["Clients — stateless"]
        direction TB
        A1["termio.app"]
        A2["Termio iOS"]
        A3["termiod CLI"]
    end

    subgraph P["Protocol — versioned"]
        direction TB
        P1["attach / detach"]
        P2["create / list"]
        P3["I/O · resize"]
        P4["status · roster"]
    end

    subgraph H["Host — authority"]
        direction TB
        H1["termiod"]
        H2["session table"]
        H3["owns every PTY"]
    end

    C -->|"transport: unix / ssh / wss"| P --> H --> Sh["shell / agent"]

The hard rules I hold this to, because each one is a thing that breaks if you let it slide:

  1. Host ≠ viewer. Closing every client leaves sessions alive.
  2. PTY only on the host. Clients never allocate the agent’s TTY.
  3. SSH is a pipe. Never ssh -tt host claude as the product path.
  4. No nested window manager. Tabs and splits belong to the native app.
  5. No invented crypto. Build the host and the protocol; borrow everything else.

Rule 3 is the one people argue with, so: ssh -tt host claude makes the connection the session owner. Drop the link and the agent dies with it. Making SSH a transport instead means the session outlives the connection — which is the entire feature.

Local is remote to localhost

There is no “remote mode.” There are devices, each running one termiod, and every UI is a client that attaches to the device owning the session. Local isn’t a special case; it’s the device whose transport happens to be a Unix socket.

flowchart LR
    W1([Browser]) -->|WSS| D1
    M1([iPhone]) -->|WSS| D1
    A1([Mac app]) -->|unix socket| D1["termiod on Mac"] --> S1["PTY → shell / agent"]

    W2([Browser]) -->|WSS| D2
    M2([iPhone]) -->|WSS| D2
    A2([Mac app]) -->|system SSH| D2["termiod on Linux VPS"] --> S2["PTY → shell / agent"]

Same protocol on every leg — only the pipe changes. Two consequences worth naming:

A client never reaches a session through another client. The phone is not a satellite of the Mac; iOS is a client of the same kind as the desktop app. This is what keeps one attach path instead of four.

Setting up a VPS is copying one binary. Settings ▸ Devices ▸ Set Up reads ~/.ssh/config (and never rewrites it), copies termiod into ~/.local/bin over SSH, starts it, and installs the agents’ hooks there. SSH disconnect detaches the client and never kills the session.

The protocol

A four-kind framed binary protocol. Deliberately small — the hot path is raw PTY bytes and shouldn’t be parsing JSON per keystroke.

[ kind:u8 ][ len:u32 big-endian ][ payload ]
KindPayloadPlane
CJSON object tagged by oplifecycle / control
Draw bytes, ≤ 64 KiB per frameterminal
Rrows:u16 BE · cols:u16 BEterminal
EJSON object tagged by evevents

Reads reject frames above 16 MiB. Unknown JSON ops and events are additive and ignored; unknown kinds close the channel after proto_error. That asymmetry is the whole versioning strategy — new features are new op strings that old clients skip, and only a genuinely corrupt stream is fatal.

Write-token arbitration is the subtle part. Multiple clients can attach to one session, but only one may write. Attaching takes the token only when nobody holds it; after that the token follows the device being used — a claim_writer demotes the previous writer without detaching them. Observers never claim. So picking up your phone mid-session moves control to the phone, and the Mac keeps rendering.

Status: four signals, one verdict

The headline feature is a dot in the sidebar that means this agent is blocked on you. Getting that right across a dozen different CLI agents is most of the engineering.

There is no single reliable signal, so the engine consumes four and arbitrates:

flowchart TD
    H["Hook<br/>termio agent report<br/><i>the agent's own words</i>"] --> E
    T["OSC 0/2<br/>live terminal title"] --> E
    P["OSC 9;4<br/>ConEmu-style progress"] --> E
    S["Screen regex<br/>per-agent declared rules"] --> E
    St["Screen-streak promotion<br/>+ stale-working sweep"] --> E

    E{"StatusEngine<br/>arbitrate"} --> V["Activity:<br/>Working · Attention · Idle<br/>+ StatusSource"]

    V --> C1["Sidebar dot"]
    V --> C2["Menu-bar tray"]
    V --> C3["Phone notification"]

The engine emits an enum and a source — never a sentence. Rendering (the tooltip wording, argv → glyph) stays client-side, because those are presentation and they differ per surface.

The StatusSource matters for one specific arbitration a client genuinely owns: a turn that ended reads done on a row nobody is looking at, and idle on the row you’re currently viewing — but only when the signal was derived from the screen. A hook that says done means done on every client, unconditionally.

This engine used to live in the Swift app, and moving it into the Rust daemon was the right call. The rules read a rendered screen, keystroke history, tick history and the client’s own selection — so two viewers running them independently disagree by construction, and a phone attached directly to a device runs none of them at all. One authority per session, or the dot means different things in different windows.

Durability

sequenceDiagram
    actor U as User
    participant App as termio.app
    participant D as termiod
    participant PTY as PTY
    participant Ag as Claude Code

    U->>App: start session
    App->>D: C create
    D->>PTY: allocate, spawn agent
    D-->>App: attach, stream D frames
    Ag-->>D: OSC title + hook reports
    D-->>App: E status events

    U->>App: ⌘Q — quit the app
    App-->>D: transport closes
    Note over D,Ag: session stays alive.<br/>PTY still owned, agent still working
    D->>D: ring buffer keeps recent output

    U->>App: reopen Termio
    App->>D: C attach — session id
    D-->>App: ring replay → exact screen restored
    D-->>App: E roster + status deltas

Reattach replays from a ring buffer, so you get the screen back rather than a blank prompt. A libghostty-based VT snapshot for full resync is designed but deliberately kept out of the per-keystroke path — correctness there is cheap to add later and expensive to pay for on every byte.

State and schema

There is no database. No accounts, no licensing service, no backend — Termio is free and local, and the only thing that would justify a database is state that outlives a machine. There isn’t any.

What there is instead is two state models, and keeping them separate is deliberate.

The daemon’s session table is in memory, keyed by session id, holding the PTY handle, the client roster, the writer token, and the status engine. It is authoritative and ephemeral: if termiod dies, those sessions are gone, and no amount of persistence would bring a dead PTY back.

The app’s state file is JSON on disk (state.json in the app support directory) — the sidebar tree and everything needed to re-attach after a relaunch:

erDiagram
    Snapshot ||--o{ Workspace : workspaces
    Snapshot ||--o{ Project : projects
    Snapshot ||--o{ ClosedDaemonSession : "closed, for undo"
    Workspace ||--o{ Session : "terminals — loose shells at $HOME"
    Workspace ||--o{ Session : "chats — loose agents at ~/.termio/chats"
    Project ||--o{ Session : sessions
    Project ||--o{ Worktree : worktrees
    Session }o--|| AgentPreset : runs

    Snapshot {
        uuid currentWorkspaceID
        uuid selectedSessionID
        node splitRoot "split-pane tree"
        map workspaceSelections "per-workspace focus"
        map inspectorLayouts "per-surface panel state"
    }
    Workspace {
        uuid id PK
        string name
        string deviceAlias "~/.ssh/config alias — bootstrap identity"
        string deviceID "host_id, filled by first hello_ok"
        bool createdAutomatically "only these may be swept when empty"
    }
    Project {
        uuid id PK
        uuid workspaceID FK
        string name
        string path "absolute, on the workspace's machine"
        string branch
        bool pinned
        map remoteCheckouts "device host_id → remote path"
    }
    Session {
        uuid id PK
        string title "app-composed placeholder"
        string givenTitle "set only by the act of naming"
        enum agent "AgentPreset — or plain shell"
        string worktreePath
        string resumeID "pins the agent conversation across relaunch"
        bool launched "resume vs start fresh"
        string sshHost "set → this is an SSH terminal"
        date createdAt
    }
    Worktree {
        string path PK
        string branch
    }

Five modelling decisions I’d defend:

title and givenTitle are separate fields, not a field plus a flag. They have different owners. title is the placeholder the app composes and freely rewrites — Terminal 3, Claude Code, a migration renumbering an old file. givenTitle is set only by the act of naming: the user renaming a row, or someone on the far box running termiod new -n build. A flag would have to be updated at every site that touches title, and the sites that forget leave a row insisting on a name nobody gave it. This used to be recovered by pattern-matching title, which is exactly why a remote row once stayed frozen at boxlit · ukvps while its local twin followed the agent’s topic — the label Termio composed was indistinguishable from one a user typed.

remoteCheckouts is keyed by device host_id, not by SSH alias. One machine commonly answers to several aliases — vps-lan, vps-wan, a tailnet name. Keyed by alias, the day you change networks the same clone becomes invisible and the repo looks un-cloned.

A workspace carries both deviceAlias and deviceID. The alias is the bootstrap identity — it exists before anything has connected, which is precisely when the workspace must exist. The host_id is what the alias turned out to reach, filled in by the first hello_ok. Two workspaces sharing a host_id are two names for one machine.

createdAutomatically is declared at creation, never inferred. It used to be read off deviceAlias — “named after a machine” standing in for “made automatically” — and then device stamping broke that proxy: a workspace the user named and filled with checkouts now carries its box’s alias too, and would have become sweepable. A workspace someone named is theirs, whichever machine it’s on.

sshKeyToInstall is deliberately outside CodingKeys — it never survives a relaunch. It’s a one-shot ssh-copy-id command that rides in the PTY precisely because the far side will ask for a password: the user types it once, into a real terminal, and Termio neither sees nor stores it. Restoring it on launch would re-run an install the user already completed.

And one scoping decision: loose chats spawn in ~/.termio/chats, not $HOME. Loose terminals spawn at $HOME because that’s what a shell is for. An autonomous agent turned loose in a home directory can read and write ~/.ssh and everything beside it.

Distribution

A notarized .dmg on Cloudflare R2 behind downloads.termio.sh, with Sparkle reading an appcast from the same bucket, published by the release workflow on each version tag. brew install --cask termio-sh/tap/termio for the people who’d rather. The marketing site is Next.js on Vercel.

No account, no license key, no server in the loop.

Stack

Swift 6 · SwiftUI + AppKit · libghostty (terminal emulation) · Rust (termiod) · system SSH as transport · Sparkle · swift-markdown · Next.js + Tailwind + shadcn/ui (landing) · Cloudflare R2 · GitHub Actions