architecture
How kolu is built
kolu splits the terminal problem across daemons, and each one survives a different failure. Close the browser tab, restart the web server, redeploy the whole thing — your shells, and the agents running in them, keep going. Here’s the stack that makes that true, and the one contract that ties it together.
The stack
Four layers, each with one job. The top two are faces — they come and go. The bottom two are daemons — padi holds the workspace record, and kaval owns the live PTYs. The arrows are transports: a websocket to the browser, a unix socket between the daemons. Kill any layer and everything below it keeps running.
| layer | its one job | killing it does not lose |
|---|---|---|
PWA client |
the view in your browser — one surface consumer per pane | Reload the tab, switch devices: it re-subscribes. It holds no state to lose. |
kolu-server |
the web shell — HTTP, the PWA, websockets; dials and binds padi | Restart it and no terminal metadata is lost — padi’s registry stays warm. |
padi |
the per-host workspace daemon — session, awareness, restore | Kill it and no terminal state is lost — records live in its folder on disk; PTYs survive in kaval. It re-seeds in seconds. |
kaval |
the PTY survivor — owns the shells and their live screens | Kill everything above it and the live shells and scrollback keep running — they live in kaval’s memory. |
A deploy adopts what’s running
Upgrading kolu doesn’t kill your daemons. A new build adopts the compatible ones — and their live PTYs — rather than reaping and restarting them. Restore is the path for a daemon that’s actually dead, never for one that’s still running. So the agents in your terminals survive the upgrade: they never stopped.
Compared to tmux and zellij
tmux and zellij fold the PTYs, the layout, and the session into one server process. kolu splits them — kaval owns the PTYs, padi owns the workspace — so the layer you restart or redeploy isn’t the layer holding your shells.
| tmux | zellij | kolu | |
|---|---|---|---|
| Detach / close the client | survives | survives | survives — the client holds no state |
| Redeploy / restart the session-UI layer | n/a — no separate layer | n/a — no separate layer | PTYs keep running; the deploy adopts them |
| Upgrade the binary under live sessions | attach breaks on a protocol-version bump | sessions orphaned on a contract-version change | compatible daemon + live PTYs adopted; a skewed one is drained, not killed |
| Machine reboot | nothing native (a plugin re-runs commands) | re-runs commands (built-in) | re-spawns from padi’s saved session |
| Knows git · PR/CI · agent state | no — status-bar scripting only | no — plugin scripting only | yes — padi derives it |
| Persisted to disk | none native; plugins add layout + cwd | layout + cwd + each pane’s command | records · session · restore target · agent memory |
| Reach | terminal TUI · single host | terminal TUI · single host | browser PWA + kaval-tui; kaval-tui reaches remote hosts over ssh today |
One contract — @kolu/surface
Announcing @kolu/surfaceread the story behind the one contract every layer talks over
@kolu/surface — the docsthe framework's own four-quadrant docs: declare a surface, serve it, daemonize it, and mirror it across an ssh fleet
Every layer talks over the same thing: a typed reactive surface. You declare what a daemon exposes — a handful of primitives plus the verbs to change them — and get a client that stays in sync. Four primitives cover almost everything:
Cell<T>
what’s the current X? — one value, snapshot then live deltas — the session, the version.
Collection<K,T>
what’s the current X per key? — many keyed rows, each independently observable — the terminals registry.
Stream<I,T>
what’s the live view for input I? — computed over outside state (fs, git), snapshot + deltas, never stored — a file preview.
Event<I,T>
has X happened yet? — point-in-time, no snapshot, no current value — a terminal exited.
Alongside them sit procedures — the imperative verbs (create a terminal, kill
one, restore a session) you call and await. That’s the whole contract, and it’s
served over any transport: in-process with no wire at all, over a local unix
socket, over a browser websocket, or over ssh. That ssh transport is already
live — it’s how kaval-tui --host and drishti reach a daemon on another
machine; what’s still coming is kolu itself using it to bind a padi on a remote
host. Swap the transport; the client code doesn’t change. That’s exactly how
padi grew up: it ran in-process inside kolu-server, then moved out into its own
process behind a unix socket, and the client never noticed.
The @kolu/surface docs teach this end to end — your first surface, make it a daemon (the pattern kaval and padi are built on), and across the hosts (the ssh mirror drishti runs).
Identity is a folder, not a port
A daemon is its data folder. padi’s identity is its state-root — a durable directory on the host that holds its session, its memory, its activity feed. The socket it listens on is named by a digest of that folder and lives in a scratch dir that’s wiped on every boot. The port is config; the folder is the daemon.
- A stale lock can never make a dead daemon look alive — its socket lives in the boot-wiped dir, gone the moment the machine restarts.
- A dev or test kolu gets its own state-root — so its own padi, its own kaval. It cannot touch your production terminals, ever.
- The host decides its own default state-root. A client never computes it and never sends it over the wire — so two ways into one machine can’t split its terminals across two daemons.
Talking over the wire
Everything the browser does flows over a single WebSocket (/rpc/ws) via
oRPC. The contract lives in one package both sides share, so
calls are type-checked at compile time and their payloads validated by
Zod at runtime. Two patterns cover all of it:
| Pattern | Semantics | Used for |
|---|---|---|
| Request / response | a one-shot call you await | create a terminal, kill one, restart a daemon, add a host |
| Subscription | the server pushes values over the socket as a stream | the terminal list, per-terminal metadata, server state |
A subscription becomes a fine-grained SolidJS signal on the client, so a single
row changing repaints only that row. The shell CLIs are the one exception to the
websocket: kaval-tui and padi-tui reach their daemon over
its local unix socket — same oRPC framing, a different transport.
Two loops of data flow
Two loops drive the system, both over that one websocket. The terminal I/O loop is the hot path — your keystrokes and the screen. The metadata loop is side-channel enrichment — the git, PR, and agent state that light up the dock.
Terminal I/O (solid) — a keystroke goes over sendInput to the PTY that
kaval owns; kolu-server proxies the browser’s socket to padi,
which drives kaval. Shell output flows back as an attach stream — a screen-state
snapshot followed by live deltas, partitioned so nothing is lost or double-painted.
kaval’s headless mirror parses the VT sequences server-side, so a late-joining
client gets ~4 KB of serialized screen state instead of replaying the raw buffer.
Metadata (dashed) — every per-terminal sensor lives in padi’s awareness producer. A CWD change (OSC 7) wakes the git sensor (branch, remote), which wakes the PR sensor (open PR + CI checks); agent detection tails each agent’s session store. padi folds those observations into each terminal’s record and serves it on one collection the client reads directly — no client-side join. Adding a new agent is one adapter here; adding a new forge is one adapter here. See Agent detection for the agent half.
The packages
kolu is a pnpm monorepo. The volatility boundaries — which hard problem each piece hides — decide where code lives, so a capability that hides transport, or GPU context, or persistence is its own package with the dependency arrow pointing out. The map, grouped:
- Faces —
client(SolidJS + xterm.js + Tailwind) andserver(the Hono web shell that serves the SPA, the preview route, and the websocket, and binds padi). - Daemons —
padi(the per-host workspace daemon that owns the terminal domain and servespadiSurface) andkaval(the standalone PTY daemon). Each ships a thin shell CLI —padi-tui,kaval-tui. - The framework —
surface(typed reactiveCell/Collection/Stream/Event+ SolidJS hooks),surface-remote(the ssh mirror),surface-daemon(the durable-daemon spine), and its supervisor half. - Agent & forge integrations — one leaf each for
claude-code,codex,grok,opencode, plus the agent-agnosticanyagentcontract;githubbehind the forge-agnosticanyforgekernel; andgitfor pure git operations. - Shared UI leaves —
theme,solid-statepip(the status indicator),solid-pierre(file tree + diffs),solid-markdown,solid-fileview,solid-browser,serve-dir, andartifact-sdk(comments-on-files). Several are shared with a sibling app, drishti. - Contracts & small leaves —
common(the oRPC contract),terminal-vocab,terminal-protocol,transcript-core/transcript-html, and a set of zero-dependency utilities (log,nonempty,shell-quote,html-escape, …).
Next — your other machines
The same surface contract already runs over ssh: it’s how
kaval-tui --host drives a daemon on another machine with nothing
pre-installed there. Pointing kolu’s browser at a padi on a remote host — a host
switcher, remote agents surfacing in your dock — is the next phase. Coming, not
here yet. That is separate from Remote Access, which serves
today’s browser UI over private HTTPS.
Explore
Start with the site pages for the named programs; the atlas links go deeper into the working notes behind each piece.