kolu
Docs

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 daemonspadi 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.

The kolu stack, top to bottom: the PWA client in your browser talks over a websocket to kolu-server (the web shell), which binds padi (the per-host workspace daemon), which owns kaval (the PTY daemon) over a unix socket. The client and kolu-server are faces that come and go; padi and kaval are daemons that survive restarts. kaval holds the live PTYs — zsh, claude, nvim.FACEScome & goDAEMONSsurvivestate lives below the linewebsocketbind · unix socketunix socketPWA clientthe view in your browserkolu-serverthe web shellpadiworkspace daemon — rememberskavalPTY daemon — keeps shells aliveOWNS THE PTYS▸ zsh▸ claude▸ nvim
layerits one jobkilling it does not lose
PWA clientthe view in your browser — one surface consumer per paneReload the tab, switch devices: it re-subscribes. It holds no state to lose.
kolu-serverthe web shell — HTTP, the PWA, websockets; dials and binds padiRestart 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).
padithe per-host workspace daemon — session, awareness, restoreKill it and no terminal state is lost — records live in its folder on disk; PTYs survive in kaval. It re-seeds in seconds.
kavalthe PTY survivor — owns the shells and their live screensKill 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.

A deploy timeline. In the top lane, the running padi vX drains — saves its session and exits — and the new build padi vY starts and adopts the same kaval. In the bottom lane, kaval runs unbroken straight through the deploy, holding every live PTY, so shells and agents never stop.DEPLOYpadi · vXrunningdrain → respawnsave session · exit · PTYs untouchedpadi · vYthe new buildadoptskavalone process · never restarts hereyour shells + agents stream straight through — scrollback intact

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.

tmuxzellijkolu
Detach / close the clientsurvivessurvivessurvives — the client holds no state
Redeploy / restart the session-UI layern/a — no separate layern/a — no separate layerPTYs keep running; the deploy adopts them
Upgrade the binary under live sessionsattach breaks on a protocol-version bumpsessions orphaned on a contract-version changecompatible daemon + live PTYs adopted; skewed padi drains; build-skewed kaval stays alive and asks, while contract-skewed kaval is recycled
Machine rebootnothing native (a plugin re-runs commands)re-runs commands (built-in)re-spawns from padi’s saved session
Knows git · PR/CI · agent stateno — status-bar scripting onlyno — plugin scripting onlyyes — padi derives it
Persisted to disknone native; plugins add layout + cwdlayout + cwd + each pane’s commandrecords · session · restore target · agent memory
Reachterminal TUI · single hostterminal TUI · single hostbrowser 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.

// identity — durable, IS the daemon (must be named out loud)$KOLU_PADI_STATE_DIR or —state-root// production wrappers supply $HOME/.local/state/padi// address — config, wiped on boot$XDG_RUNTIME_DIR/padi-<digest>/sock where <digest> = hash(state-root path)
  • 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:

PatternSemanticsUsed for
Request / responsea one-shot call you awaitcreate a terminal, kill one, restart a daemon, add a host
Subscriptionthe server pushes values over the socket as a streamthe 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.

Two data-flow loops across the four layers. The terminal I/O loop runs both ways through client, kolu-server, padi, and kaval — keystrokes down to the PTY, the screen (a snapshot then live deltas) back up. The metadata loop flows the other way only: kaval feeds padi — its VT taps drive padi’s git, PR, and agent sensors, and its resize-excluded meaningful-output edge feeds padi’s live-activity fold — and padi pushes the result to the client.TERMINAL I/O — keystrokes ⇄ screenclientxterm.jskolu-serverweb shellpadisensors · foldkavalthe PTYmetadata — kaval → git · PR · agent · live activity (side channel)

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:

  • Facesclient (SolidJS + xterm.js + Tailwind), server (the Effect HTTP shell that serves the SPA, the preview route, and the websocket, and binds padi), kolu-cli (the kolu binary itself — the composition root that owns subcommand dispatch, the padi connect layer, and boots whichever face was asked for, plus the terminal verbs ls/create/send/wait/snapshot/ history/kill/watch, each a pure padi client, and the kolu surface face — surfaceFace.ts mounts @kolu/surface-cli’s projection of the same agent API as argv verbs), and kolu-mcp (the kolu mcp agent face — padiSurface re-exposed to coding agents over MCP, also with no kolu-server behind it; kolu tui is reserved for a later face).
  • Daemonspadi (the per-host workspace daemon that owns the terminal domain and serves padiSurface) and kaval (the standalone PTY daemon). Each ships a thin shell CLI — padi-tui, kaval-tui.
  • Reachabilityport-forward hides 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 frameworksurface (typed reactive Cell / 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 machineryxterm-kit hides xterm.js’s hazards (the pinned _core internals, in-place scrollback surgery, scroll-lock write buffering, mirror anchoring) behind a runtime-neutral core plus a /solid adapter, with the client and kaval as two consumers from day one. terminal-snapshot is 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, the screen_image MCP tool) draw the same picture from one layout decision.
  • Agent & forge integrations — one leaf each for claude-code, codex, grok, opencode, plus the agent-agnostic anyagent contract; github behind the forge-agnostic anyforge kernel; and git for pure git operations.
  • Shared UI leavestheme, solid-statepip (the status indicator), solid-pierre (file tree + diffs), solid-markdown, solid-fileview, solid-browser, serve-dir, and artifact-sdk (comments-on-files). Several are shared with a sibling app, drishti.
  • Contracts & small leavescommon (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.

The repo — juspay/koluevery package, with its full README, in one tree

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.