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; the remote-host fleet you added survives too (kolu-server persists its own small membership list as a field in its config store, distinct from the per-terminal state the daemons hold). |
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; skewed padi drains; build-skewed kaval stays alive and asks, while contract-skewed kaval is recycled |
| 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, drishti, and kolu’s remote-host canvas
reach a daemon on another machine. 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.
- Binding never silently defaults to production’s chair — a bare launch dies
with one line. Production wrappers supply
$HOME/.local/state/padi; a client never invents the path over the wire.
Talking over the wire
Everything the browser does flows over a single WebSocket (/rpc/ws) via
Effect RPC, framed as newline-delimited JSON. The
contract lives in one package both sides share, so calls are type-checked at
compile time and their payloads decoded by Effect
Schema 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 ndjson 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, agent state, and live output activity 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. Live output activity is the one fact produced a layer down: kaval owns it — the one process that sees every PTY byte and every resize — and emits a host-global, resize-excluded meaningful-output edge that padi folds into a short live-dot window and serves the same way.
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),server(the Effect HTTP shell that serves the SPA, the preview route, and the websocket, and binds padi),kolu-cli(thekolubinary itself — the composition root that owns subcommand dispatch, the padi connect layer, and boots whichever face was asked for, plus the terminal verbsls/create/send/wait/snapshot/history/kill/watch, each a pure padi client, and thekolu surfaceface —surfaceFace.tsmounts@kolu/surface-cli’s projection of the same agent API as argv verbs), andkolu-mcp(thekolu mcpagent face —padiSurfacere-exposed to coding agents over MCP, also with no kolu-server behind it;kolu tuiis reserved for a later face). - 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. - Reachability —
port-forwardhides ssh forward lifetime and local binding: one dedicated ssh connection per forward, so a forwarded port dies with the process that opened it rather than outliving it on a shared master. It is a library rather than server code because nothing in it knows about kolu: it takes a host and a port and returns a local one, so a forward’s lifetime is the calling process’s own. - The framework —
surface(typed reactiveCell/Collection/Stream/Event+ SolidJS hooks),surface-remote(the ssh mirror),surface-daemon(the durable-daemon spine, frozen identity/drain fragment, and mixed-version test kit), and its supervisor half (including the one control-core probe assembler used by socket and already-dialed transports). - Terminal machinery —
xterm-kithides xterm.js’s hazards (the pinned_coreinternals, in-place scrollback surgery, scroll-lock write buffering, mirror anchoring) behind a runtime-neutral core plus a/solidadapter, with the client and kaval as two consumers from day one.terminal-snapshotis the other two-consumer leaf: it turns a screen plus a theme into backend-free drawing instructions, so the browser’s copy-screenshot action and the daemon’s PNG (kolu screenshot, thescreen_imageMCP tool) draw the same picture from one layout decision. - 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 shared surface contract),terminal-vocab,terminal-protocol,transcript-core/transcript-html, and a set of zero-dependency utilities (log,nonempty,shell-quote,html-escape, …).
The Nix composition follows those boundaries too. The root flake contains only
the runnable kolu packages, their checks, and the development shells needed to
work on them. The website, Surface examples, Solid Browser example, and Odu CI
coordinator each have an independent subflake. They reuse one canonical
workspace source and pnpm closure, but none enters nix run .’s evaluation
graph. Remote provisioning uses a separate exact-source flake that exposes only
the packages named by Kolu’s agent manifest. @kolu/surface-remote
probes the target system, evaluates just the closure it needs, and gives one Nix
process ownership of evaluation, transfer, and remote realisation. That closure
is the daemon plus the client CLIs a terminal on that host must be able to
run (kaval-tui, padi-tui, kolu) — so an agent working on a remote machine
can drive its sibling terminals and reach the MCP server with nothing
installed there. Tool and daemon arrive together, so they are the same build by
construction. The manifest separates what the flake exposes from what it
proves: the prove step evaluates an attr by re-importing the composer, so it
covers the daemons a dial must resolve and cannot cover a closure that
transitively references the baked source itself.
Your other machines
The same surface contract runs over ssh: it’s how kolu --host — and the
kaval-tui / padi-tui previews beside it — 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 — ships as a first-class feature:
see Add another machine. That is separate from From
another device, 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.