A browser cockpit for coding agents. Bring your own CLI, run them anywhere.
Unlike agent command centers that wrap a single model behind their own chat UI, kolu stays out of the agent's way: the terminal is the universal interface, so claude, opencode, or whatever ships next week works out of the box — and you can drop to a plain shell whenever you want. It's an Agentic Development Environment (ADE) that treats terminals as the thesis, not the substrate.
Two principles shape what kolu is and isn't:
Agent-agnostic. The terminal is the universal interface. Kolu doesn't wrap a specific model or lock you into one CLI — claude, opencode, or whatever ships next week all work the same way, because they're just programs you run in a shell. There's no agent registry to update, no adapter to write, no vendor lock-in. Any new agent CLI picks up first-class features automatically: run it once in any kolu terminal and the next time you create a worktree, it appears in the sub-palette as a launch option — no configuration, no per-agent code. You can always drop to a plain shell without leaving the app.
Auto-detected, zero setup. Kolu populates its UI by watching what you already do — the repos you cd into, the agents you run, the sessions you save — not by asking you to configure it. Recent repos track cd events, branch / PR / CI status derive from the terminal's CWD, Claude Code state is read from the foreground pid, and recent agent CLIs come from preexec command marks emitted by kolu's shell integration. If kolu knows something, it's because the shell already told it. The surface grows with your workflow, not with a preferences pane.
Install Nix and then run:
nix run github:juspay/kolu # serve on 127.0.0.1:7681
nix run github:juspay/kolu -- --host 0.0.0.0 --port 8080 # expose on LANOpen http://127.0.0.1:7681 (or the address you chose above).
- Create, switch, and kill terminals — every terminal renders as a draggable tile on the canvas, with a floating two-level pill tree (repo → branches) for at-a-glance navigation
- Split terminals — Ctrl+` splits a bottom pane per terminal; Ctrl+Shift+` adds tabs, Ctrl+PageDown / Ctrl+PageUp cycles
- Font zoom (Cmd/Ctrl +/-), persisted per terminal across sessions
- WebGL rendering with canvas fallback, clickable URLs, Unicode 11, inline images (sixel, iTerm2, kitty)
- Lazy attach — late-joining clients receive serialized screen state (~4KB) instead of replaying raw buffer
- Mobile key bar — on coarse-pointer devices, a thin row above the terminal sends the keys soft keyboards lack (Esc, Tab, arrows, Ctrl+C) plus an IME-bypassing Enter for Android chat keyboards, with a haptic tick on every tap. Touch-swipe inside the terminal scrolls the scrollback buffer
- Command palette (Cmd/Ctrl+K) — search terminals, switch themes, run actions
- Agent-aware command palette — once you've run a known agent CLI (
claude,aider,opencode,codex,goose,gemini,cursor-agent) in any kolu terminal, it surfaces in two places: underNew terminal → <recent repo>as a sub-palette that creates the worktree and launches the agent in one step, and underDebug → Recent agentsas a prefill-into-active-terminal affordance. Prompt/message flag values (-p/--prompt/-m/--message) are stripped before storage so ephemeral prompt text never lands in the persisted MRU - Pill-tree pings — when an agent is waiting on you (or has finished with an unread completion), its branch pill in the floating tree pulses an alert dot so you can spot it without panning. Ctrl+Tab (or Alt+Tab) cycles terminals in MRU order: hold the modifier, press Tab to advance, release to commit
- Keyboard-driven — Cmd+T new terminal, Cmd+1…Cmd+9 jump, Cmd+Shift+[ / Cmd+Shift+] cycle, Cmd+/ shortcuts help
The desktop workspace is mode-less — every terminal renders as a draggable, resizable tile on an infinite 2D canvas. Per-terminal chrome (theme pill, agent indicator, screenshot, split toggle, find) lives on each tile's title bar. A transparent chrome bar floats at the top carrying logo, command palette, settings, and inspector toggle; the canvas grid reads through it. When a tile is maximized or the inspector panel is open, the chrome bar docks above so the two surfaces don't fight.
- Infinite pan & zoom — two-finger scroll / trackpad to pan, pinch or Ctrl+scroll to zoom. Hold Shift to force pan even with the cursor over a terminal tile (hand-tool style). No boundaries — the canvas extends freely in every direction via CSS
transform: translate() scale()(Figma/Excalidraw model) - Snap-to-grid — tiles snap to a 24px grid on drag and resize for tidy layouts
- Maximize a tile — double-click any tile's title bar (or click the maximize button) to fill the viewport; the maximized posture persists across reload via localStorage so you land back where you left off
- Floating pill tree — a two-level overlay (repo → branches) sits at the top of the canvas, ghosted at rest and behind any tile that overlaps it; hover pops it to full opacity. Branches sort by canvas x-position, so the tree reads left-to-right exactly as the tiles sit. Click a branch pill to pan and center its tile
- Pill border encodes state — each pill's border doubles as identity (repo color) and live status: a conic-gradient sweep while the agent is
thinking/tool_use, a breathing pulse whilewaiting, a static ring when the terminal is just active, and an inset glow when the active tile also has a working agent - Identity-collision suffix — when two terminals share the same repo+branch (or cwd, for non-git), the server assigns each a stable 4-char id suffix (
#a3f2) so the pill tree and tile chrome can disambiguate them at a glance - Keyboard navigation — Cmd/Ctrl+Shift+2 centers on the active tile
- Per-tile theming — title bars and pill swatches derive their colors from each terminal's theme for guaranteed contrast
- Mobile — the canvas, pan/zoom, and the floating pill tree are all disabled; the active tile fills the viewport and swipe-left/right cycles between terminals in pill-tree order. A pull-down chrome sheet at the top reveals the same logo + pill list + controls as a touch-sized drawer
- Auto-detected repo name, branch, and working directory (via OSC 7 +
.git/HEADwatcher) - GitHub PR detection — shows PR number, title, and CI check status (pass/pending/fail) on the tile chrome and inspector
- Per-repo color coding on the pill tree and tile chrome via golden-angle hue spacing
Detects Claude Code sessions running in any terminal and surfaces their state on the tile's chrome (and on its pill in the floating tree).
What we detect:
| State | Indicator | Meaning |
|---|---|---|
| Thinking | Pulsing accent dot | API call in flight — Claude is generating a response |
| Tool use | Pulsing yellow dot | Claude is executing tools or waiting for permission |
| Waiting | Dim dot | Claude finished responding, waiting for user input |
How it works: asks each terminal for its current foreground process pid via tcgetpgrp(fd) (exposed by node-pty's foregroundPid accessor), then checks whether ~/.claude/sessions/<fgpid>.json exists. If it does, that terminal is running claude-code — we tail the session's JSONL transcript to derive state from the last message. Cross-platform (Linux + macOS) since tcgetpgrp is POSIX. Each card also surfaces the session's display title (custom title › auto-generated summary › first prompt) via the Claude Agent SDK's getSessionInfo(), refreshed best-effort on each transcript change.
What we can't detect:
- Permission prompts vs tool execution — both show as "tool use" since the JSONL doesn't distinguish them
- Streaming progress — intermediate thinking tokens aren't tracked, only final state transitions
- Wrapped invocations — if claude-code is launched via a wrapper (e.g.
script -q out.log claude), the foreground pid is the wrapper, not claude itself, so the session lookup misses - Sub-agents — nested agent spawns appear as tool use, not as separate tracked sessions
Debugging detection: the command palette has a Debug → Show Claude transcript entry (visible only when the active terminal has a Claude session) that opens a side-by-side view of the server's state-change log next to the raw JSONL events from disk since monitoring began. Use it when state seems stuck or transitions feel missed.
Detects OpenCode sessions and shows their state alongside Claude Code on the tile chrome.
How it works: when the foreground process is opencode, the provider queries OpenCode's SQLite database directly at ~/.local/share/opencode/opencode.db to find the most recently updated session whose directory matches the terminal's CWD. State is derived from the latest message: a user message means the assistant is thinking; an assistant message with time.completed set and finish: "stop" means waiting; otherwise still thinking. Todo progress comes from a COUNT(*) over the todo table — much simpler than Claude Code's tool-call parsing since OpenCode stores todos as first-class rows with a status column. Live updates come from fs.watch on the SQLite WAL file (opencode.db-wal), which OpenCode writes to on every database mutation.
Why SQLite, not REST? The OpenCode TUI doesn't expose an HTTP server by default — that's a separate opencode serve mode. Reading the SQLite DB directly works against the actual TUI users run, with no port discovery and no extra processes. SQLite WAL mode allows concurrent readers while OpenCode is writing, so we can open the DB read-only without blocking it.
What we detect:
| State | Indicator | How |
|---|---|---|
| Thinking | Pulsing accent dot | Latest assistant message has no time.completed |
| Tool use | Spinning yellow | Thinking + any part with type: "tool" and state.status: "running" |
| Waiting | Dim dot | Latest assistant message has time.completed set and finish: "stop" |
What we can't detect (yet):
- Same-directory disambiguation — if multiple OpenCode sessions share a working directory, we pick the most recently updated one
- Non-default DB location — set
KOLU_OPENCODE_DBto override the path
- 200+ color schemes from iTerm2-Color-Schemes, switchable at runtime
- Live preview while browsing themes in the palette
- Shuffle theme — new terminals (and the active one on ⌘J) get a background perceptually distinct from every other open terminal (toggleable; on by default)
- Dark / light / system UI theme
- Ctrl+V pastes images into Claude Code via server-side clipboard shims
pnpm monorepo:
| Package | Stack |
|---|---|
packages/common/ |
oRPC contract + Zod schemas |
packages/server/ |
Hono + node-pty + @xterm/headless |
packages/client/ |
SolidJS + xterm.js + Tailwind CSS v4 |
packages/integrations/claude-code/ |
Claude Code detection — JSONL transcript tailing + Claude Agent SDK; exports a claudeCodeProvider AgentProvider |
packages/integrations/anyagent/ |
Agent-agnostic shared contract (AgentProvider interface, agentInfoEqual), types (Logger, TaskProgress), and agent CLI parsing |
packages/integrations/opencode/ |
OpenCode detection — reads OpenCode's SQLite database via Node's built-in node:sqlite; exports an opencodeProvider AgentProvider |
packages/terminal-themes/ |
Terminal color scheme catalog + perceptual-distance picker — themes checked-in as JSON |
packages/memorable-names/ |
ADJ-NOUN random name generator — word lists checked-in as JSON |
All traffic flows over a single WebSocket (/rpc/ws) via oRPC. The contract in packages/common/ is shared by both sides — types checked at compile time, payloads validated by Zod at runtime. Two communication patterns:
| Pattern | Semantics | Client integration | Used for |
|---|---|---|---|
| Request / response | one-shot RPC call | plain client.* calls |
terminal.create, terminal.kill, terminal.reorder |
| Subscription | server pushes values over WebSocket stream | createSubscription → SolidJS signal |
Terminal list, metadata, server state |
Subscriptions use createSubscription — a 150-line primitive that converts an AsyncIterable into a SolidJS signal via createStore + reconcile for fine-grained reactivity. Per-terminal subscriptions use SolidJS's mapArray for automatic lifecycle management.
Two loops drive the system — a terminal I/O loop (the hot path) and a metadata loop (side-channel enrichment). Both flow over the same WebSocket and land in SolidJS signals on the client via createSubscription.
flowchart TB
subgraph Client["Client (SolidJS)"]
User((User)):::user
Xterm["xterm.js\nrender + input"]:::client
Subs["createSubscription\nsignals"]:::cache
UI["UI components\npill tree · tile chrome · chrome bar · palette"]:::client
end
subgraph Server["Server (Hono)"]
PTY["node-pty\nshell process"]:::server
Headless["@xterm/headless\nscreen state"]:::server
Pub["Publisher\nper-terminal channels"]:::server
Providers["Metadata providers"]:::server
end
%% Terminal I/O loop
User -->|"keystroke"| Xterm
Xterm -->|"sendInput\n(request/response)"| PTY
PTY -->|"shell output"| Headless
PTY -->|"shell output"| Pub
Pub -->|"attach stream"| Xterm
%% Metadata loop
PTY -.->|"OSC 7\n(CWD change)"| Providers
Providers -.->|"metadata stream\n(subscription)"| Subs
Pub -.->|"activity stream\n(subscription)"| Subs
Subs -.-> UI
%% Terminal list (server-pushed on create/kill/reorder)
Pub -.->|"terminal list stream\n(subscription)"| Subs
%% User actions
UI -->|"create · kill · reorder\n(request/response)"| PTY
classDef user fill:#f4a261,stroke:#e76f51,color:#000
classDef client fill:#2a9d8f,stroke:#264653,color:#fff
classDef cache fill:#e76f51,stroke:#f4a261,color:#fff
classDef server fill:#264653,stroke:#2a9d8f,color:#fff
style Client fill:none,stroke:#2a9d8f,stroke-width:2px,color:#2a9d8f
style Server fill:none,stroke:#264653,stroke-width:2px,color:#264653
Terminal I/O (solid lines) — keystrokes go through sendInput RPC to node-pty; shell output flows back through the publisher as an attach stream to xterm.js. An @xterm/headless instance parses VT sequences server-side for screen-state snapshots1.
Metadata (dashed lines) — shell activity triggers a provider DAG: CWD changes (OSC 7) → git provider (.git/HEAD watcher) → GitHub provider (gh pr view polling). Agent detection uses a single generic orchestrator (meta/agent.ts) driven by per-agent AgentProvider instances from each integration package. Today two instances are registered: claudeCodeProvider (from kolu-claude-code) wakes on title events (OSC 2) and its own fs.watch on ~/.claude/sessions/; opencodeProvider (from kolu-opencode) queries OpenCode's SQLite database directly and watches its WAL file for live state updates. Adding a new agent CLI is one new AgentProvider and one line in startProviders — no server-side adapter file. All providers feed a single metadata channel streamed to the client as a subscription2. Separately, kolu's preexec hook emits an OSC 633;E command mark before each user command; the pty handler parses it, matches the first token against a known-agents allowlist, and pushes normalized invocations to a bounded recent-agents MRU published via the server-state stream — powering the agent-aware command palette entries without any /proc lookups or argv scraping.
User actions — command palette, pill tree, and tile chrome dispatch plain oRPC client calls (useTerminalCrud, useWorktreeOps). The server's live subscriptions push updated state to the client automatically. useTerminalMetadata uses SolidJS's mapArray to create per-terminal subscriptions that automatically tear down when terminals are removed3.
Persistence — sessions auto-save to ~/.config/kolu/state.json via conf, debounced at 500 ms4.
PartySocket handles WebSocket auto-reconnect; the stream namespace in packages/client/src/rpc/rpc.ts routes every async-iterator procedure through oRPC's ClientRetryPlugin so consumers transparently re-subscribe after a drop — every server-side streaming handler is already snapshot-then-deltas and the reducer in useTerminalMetadata.ts pattern-matches an ActivityStreamEvent discriminated union (snapshot replaces, delta appends) so re-subscribe resume is structural, not defensive. Transport events (connecting / connected / disconnected / reconnected / restarted) are exposed as a single ServerLifecycleEvent signal, and TransportOverlay pattern-matches it into one dim-backdrop card: disconnected shows "Reconnecting…" (the backdrop is pointer-events-none, so users can still scroll and read buffers underneath), and restarted swaps to "Server updated" with the Reload button inline in the card.
Packaged with Nix. The flake has zero inputs — nixpkgs and other sources are pinned via npins and imported with fetchTarball to keep nix develop fast (~2.6 s cold). Shared env vars are defined once in koluEnv and consumed by both the build and the devShell5.
Requires Nix with flakes enabled.
nix develop # enter devshell
just dev # run server + client with hot reload
just test # e2e tests (full nix build)just ci builds all flake outputs on x86_64-linux and aarch64-darwin in parallel, runs e2e tests, and posts GitHub commit statuses. See ci/ for details and reuse instructions.
just ci # full CI run
just ci::protect # set branch protection
just ci::_summary # check current statusA home-manager module runs kolu as a systemd user service:
{
imports = [ kolu.homeManagerModules.default ];
services.kolu = {
enable = true;
package = kolu.packages.${system}.default;
host = "127.0.0.1"; # default
port = 7681; # default
};
}See nix/home/example/ for a full configuration with a VM test.
If kolu grows unbounded (V8 heap climbing over hours), set services.kolu.diagnostics.dir to an absolute path. Each restart gets its own timestamped subdir there, with a baseline heap snapshot at T+5min, periodic "diag" stats lines (memory bands + terminals/publisherSize/claudeSessions/pendingSummaryFetches), and automatic near-OOM snapshots via V8's --heapsnapshot-near-heap-limit. kill -USR2 <pid> captures an on-demand snapshot into the same dir. Diff two snapshots offline with memlab to name the retainer. Unset = zero overhead; the code path is fully gated.
The marketing site and blog at https://kolu.dev live in website/ — Astro + Tailwind, its own zero-input flake, deployed to GitHub Pages via .github/workflows/pages.yml.
just website::dev # live preview with HMR
just website::nix-build # reproducible buildSee website/README.md for authoring posts and deploy details.
Named after கோலு, the tradition of arranging figures on tiered steps.
Footnotes
-
~4 KB serialized snapshot instead of replaying the full scrollback buffer. ↩
-
Git provider uses simple-git; GitHub provider derives combined CI status from
CheckRun+StatusContext. Agent providers implement the sharedAgentProvidercontract (anyagent):resolveSession(terminalState)→sessionKey(session)for dedup →createWatcher(session, onChange)for per-session state derivation, with an optionalsubscribeExternalChangeshook for out-of-band match triggers.claudeCodeProviderasks the pty fortcgetpgrp(fd)and stats~/.claude/sessions/<fgpid>.json, opts intofs.watchon~/.claude/sessions/as its external-change signal, then tails the matched session's JSONL transcript via anotherfs.watchfor state updates; the session display title comes from a fire-and-forgetgetSessionInfo()call piggybacking on the same transcript watcher.opencodeProvidermatches whenopencodeis the foreground process, queries~/.local/share/opencode/opencode.db(SQLite) for sessions in the terminal's CWD, and watches the WAL file (opencode.db-wal) for live state updates via Node's built-innode:sqlitemodule — it has no external-change subscription because title events cover every match transition. ↩ -
Local-only view state (active terminal, MRU order, attention flags) lives in SolidJS signals and stores inside singleton
useXxx.tsmodules — separate from server-derived subscription state. ↩ -
Schema is versioned with explicit migrations. Stores CWD, sort order, and parent relationships per terminal. ↩
-
koluEnvincludes font paths and clipboard shims. Terminal themes and word lists ship checked-in as JSON (seepackages/terminal-themes/andpackages/memorable-names/). The final derivation is a wrapper script that sets the environment and execstsx. ↩