kolu is a terminal app built for scale: real xterm.js tiles on an infinite 2D canvas, with a dock that never loses one — for claude, codex, opencode, or anything you run in a shell, especially many at once.
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. Kolu 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:
The same command runs kolu and updates it — --refresh busts Nix's flake cache so you always pull the latest commit:
nix --refresh run github:juspay/kolu # serve on 127.0.0.1:7681
nix --refresh 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 the left-edge dock as the canonical at-a-glance navigator (rail / cards levels) and the command palette as the canonical search surface
- Split terminals — Ctrl+` splits a bottom pane per terminal; Ctrl+Shift+` adds tabs, Ctrl+PageDown / Ctrl+PageUp cycles. Open splits surface as an inline
▭ Nchip on the parent's dock row so the count is visible at a glance, not just on the active tile's title bar - Font zoom (Cmd/Ctrl +/-), persisted per terminal across sessions
- WebGL rendering with canvas fallback, clickable URLs, Unicode 11, inline images (sixel, iTerm2, kitty)
- Clickable file references — terminal output that contains
path/to/file.ts:42(with optional:color-endline range) is linkified; clicking opens the file in the right panel's Code tab at that line - Lazy attach — late-joining clients receive serialized screen state (~4KB) instead of replaying raw buffer
- Mobile key bar — on coarse-pointer devices, a two-row bar 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. The twelve soft keys lay out in a six-column grid — two rows of six — so every key is visible at once, with no horizontal scrolling. Two sticky modifiers (Ctrl, Alt) arm one-shot: tap to arm, then the next character — typed on the soft keyboard or sent from the bar — folds into the chord (e.g. Ctrl+R, Alt+F) and disarms. Touch-swipe inside the terminal scrolls the scrollback buffer
- Command palette (Cmd/Ctrl+K) — search terminals, switch themes, run actions
- Worktree-naming flow — drilling into
New terminal → <recent repo>opens a leaf with the worktree name pre-filled (random ADJ-NOUN, auto-selected) and an agent picker below; type a custom name and hit Enter to land in a freshly-branched worktree, or pick an agent to launch it in one step. The typed name becomes the branch name and surfaces verbatim on the dock row, so worktrees stay identifiable at a glance - 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: as a row in the worktree-naming leaf above (so the same Enter creates the worktree and launches the agent), and as the root-levelRecent agentsgroup under the Active Terminal section as 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 - Dock pings — when an agent finishes in the background with an unread completion, the dock row's state pip switches to a loud pulsing violet disk with a halo so you can spot it without panning. Once you activate the row the urgency drops — the pip falls back to a quiet dim dot if the agent's still technically awaiting, or whatever its current bucket says — so a row you've already glanced at doesn't keep yanking the eye. A simultaneous toast names the terminal that finished and carries a Switch action that pans the canvas to it; when the tab is backgrounded the OS notification does the same on click. 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, a build/commit identity rail (an srv connection + version readout whose commit links to its GitHub source, beside a client column carrying the browser bundle's own build commit — when the two clean commits disagree, the signature of a cached old bundle against a freshly deployed server, the rail flags the mismatch with a ≠ srv badge and a reload hint; a pty column lands once the pty-host is a separate process), command palette, settings, and the maximize / dock / inspector toggles; the canvas grid reads through it. The left edge hosts the dock — the canonical live-terminal navigator. When the inspector panel opens, the chrome bar shrinks its right edge to the panel's left so the controls cluster stops short of the panel. When a tile is maximized, the chrome bar docks above and the dock renders as a flush left sidebar so the maximized terminal reflows next to it.
-
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 on the tile, or the maximize toggle in the chrome bar) to fill the viewport; the maximized posture persists across reload via localStorage so you land back where you left off
-
Dock — two-level navigator — the left-edge dock is the canonical live-terminal surface, with two progressive levels of detail (#903):
- Rail — a 44 px-wide strip of 32 px chips, one per terminal. Each chip carries two glyphs (first alpha char of the repo + the intent's lead grapheme, falling back to the first alpha char of the branch tail) so two terminals in the same repo stay distinguishable —
Kfis kolu/feat-dock-bare,Kris kolu/remote-talk, and an intent like🛟 FLOAT/rightchips asK🛟so the recognition cue cards mode shows survives at rail width. Repo color tints the chip's bg and ring; bucket state animates the ring (breath inalertfor awaiting, accent spin-glow for working); active wears a 2 px accent halo; unread shows a pulsing alert badge at the corner. Tiny tinted repo dividers between adjacent chips from different repos carry the cards-mode section-header color into the rail. - Cards (default) — rows grouped by repo. Each repo gets a banded section header (uppercase name + repo-color swatch + row count); rows below stack as
state · branch · pips · timelines. The first-column state pip encodes one thing: how urgent is this row? Shape carries the answer — a filled violet disk with a pulsing halo for needs attention (unread fresh background transition), a quiet dim violet dot for awaiting that you've already seen and let linger, a hollow spinning teal ring for working, a tiny muted dot for idle, empty for none. The violet "your turn" hue sits deliberately outside the warm/pending family (the CI-checks pip stays amber) so an agent waiting on you never reads as a build still churning. Different shapes (not just different colors) so the distinction survives reduced color sensitivity and peripheral glance. The same state pip now also leads the terminal title bar's branch/intent line (beside the same intent context it sits next to on the dock row) — tracking the live agent state (spinning ring = working, dot = awaiting), and, like the title's agent-kind indicator, showing nothing once no agent is attached — and labels each agent-state column header in the workspace switcher. One shape vocabulary, reused verbatim, so a working or awaiting agent reads identically across dock, title, and switcher; the dock's extra idle/parked triage states (which fold in recency/staleness) stay dock-only. Agent kind (Claude / Codex / OpenCode) is not surfaced on the dock row — that identity lives on the terminal title bar where there's room. PR pip is a link to the PR with the live checks verdict + per-check list in its tooltip; the sub-terminal chip surfaces when there are nested terminals. The active row gets a quiet highlight (bg-accent/15+ 3 px accent left-edge stripe); row geometry stays constant so the dock never reflows when the active terminal changes. Pip columns share a CSS subgrid across each section, so a column whose rows all lack a pip collapses to 0 width — branch labels get every pixel the icons aren't using.
Workspace search lives in the unified command palette (#912): the dock's search-icon button (and Cmd+Shift+K) opens the palette pre-drilled into the "Search workspaces" group. The group renders its own body inside the palette — a repo-facet sidebar plus agent-state columns (
Idle,Awaiting you,Working,No agent) — each column header labelled with its agent-state pip (the spinning ring onWorking, a quiet dot onAwaiting/Idle) — with theIdlecolumn sub-grouped by age (4–12h,12–24h,24–48h,48h+) the same way the minimap window picker shows it. The palette input drives an AND-token query across 20+ metadata fields; the body filters the visible cards live.Ordering is pure recency at every layer — the repo whose newest row just changed floats up, and within a repo the rows sort the same way. The bucket no longer promotes a row's position; "needs attention" is carried by the pip's pulse and color, not by where the row sits in the list. Rows are still clustered by branch/intent label so two terminals on the same branch stay adjacent — the cluster headline uses the same recency key, so clustering keeps siblings together without re-ordering the whole list. Click any dock row or palette workspace card to focus and center its tile. In maximized-tile mode the dock renders as an opaque flush-left sidebar and the maximized terminal reflows next to it (#904); in tiled mode the dock floats over the canvas as an equally opaque card so tiles never bleed through the seams.
- Rail — a 44 px-wide strip of 32 px chips, one per terminal. Each chip carries two glyphs (first alpha char of the repo + the intent's lead grapheme, falling back to the first alpha char of the branch tail) so two terminals in the same repo stay distinguishable —
-
Activity window — hard filter, not a dim — a single per-device choice (
All / 4h / 12h / 24h / 48h, default24h) governs the dock cutoff. Rows past the window disappear from the dock entirely. The dock's bottom strip carries both the disclosure and the picker inline ("6 hidden by 4h window — show all"); when nothing is parked it still reads0 hidden by 4h windowso the control stays reachable. The "show all" shortcut surfaces only when something is actually hidden. The minimap's matching pill drives the same shared signal (via the sameActivityWindowChip), so tightening one tightens the other. The canvas tile fades, the hidden terminal stops counting toward the OS/PWA dock badge, and any fresh agent transition surfaces it automatically. -
Minimap heatmap — the canvas minimap dots any tile whose agent is currently
waiting(alert color) orthinking/tool_use(accent color), suppressed once the tile falls outside the activity window, so you can scan a 20-tile workspace for "who needs me" or "who is making progress" without opening the switcher. Tiles outside the window collapse to small ghost markers so visual weight shifts onto what's still in play. -
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 dock and tile chrome can disambiguate them at a glance -
Canvas navigation — the command palette can center the active tile when panning has moved it out of view, or arrange the canvas by repo to cluster each repo's tiles into a square-ish island while preserving every tile's current size. Arranging is a one-shot, explicit action: a new terminal opens at the viewport without rearranging anything, so the tiles you've already placed stay exactly where you put them — clustering happens only when you run "Arrange canvas by repo"
-
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 desktop dock are disabled; the active tile fills the viewport and swipe-left/right cycles between terminals in compact switcher order. Switching or revealing a tile never raises the soft keyboard — it appears only when you tap the terminal, so it stays out of the way while you navigate. A pull-down chrome sheet at the top reveals the same logo + vertical switcher list + controls as a touch-sized drawer. The right panel — Inspector + Code tabs, file tree, HTML/SVG/PDF iframe + image preview — hosts as a bottom drawer instead of a side split, mounting the same
RightPanel→CodeTabsubtree as desktop; tap the inspector toggle in the chrome sheet (or apath:linelink in terminal output) to open it
- Auto-detected repo name, branch, and working directory (via OSC 7 +
.git/HEADwatcher) - GitHub PR detection — shows the merge-state icon (open / merged / closed), CI check status dot (pass/pending/fail),
#N, and PR title on the tile chrome, the inspector, the dock row, and the workspace-switcher card so the merge state is visible from every navigator surface without focusing the tile - Per-repo color coding on the dock, tile chrome, canvas tile border, and minimap via golden-angle hue spacing — the same hue echoes across every surface so a repo reads as one identity at a glance
- Git-status indicators across the Code tab's All files view — every file in the full-repo browse tree carries the same change color the Local and Branch modes show (modified / added / untracked / renamed / deleted), so you can see what's touched without leaving whole-repo browsing, and every ancestor folder of a change is tinted in the modified color (not just Pierre's faint roll-up dot) so a changed subtree reads at a glance. The decoration overlays the working-tree status (primary) on the branch-vs-base status (fallback), so an uncommitted edit wins over a change committed earlier on the branch, while a file the branch changed but you haven't touched still shows via the branch layer. The branch layer is best-effort: a repo with no
origin/<default>simply falls back to the working-tree layer rather than erroring (the explicit Branch mode still surfaces that as an actionable message). The folder tint rides a small shadow-root style injected into the Pierre tree, since Pierre exposes no theme variable for it - Inline preview of agent-generated
.html/.svg/.pdfartifacts — selecting one in the Code tab's browse mode renders it in a sandboxed iframe (sandbox="allow-scripts", noallow-same-origin— page scripts run in an opaque origin and can't touch Kolu's cookies/localStorage; cross-originfetch()from inside is blocked, which is fine for static artifacts) served from a per-terminal route under the terminal's repo root. Raster images (.png/.jpg/.gif/.webp/.ico) are served by the same route but presented with a plain<img>centered on a checkerboard, so transparency reads against the dark panel — no script sandbox needed since image bytes can't execute. Both live-reload when the file changes (mtime bump on the URL) via the samefsReadFilesubscription path as text. Clicking an<a>link between previewed HTML files follows through: the in-iframe@kolu/artifact-sdkreports the loaded document's path back out (the opaque-origin sandbox blocks the parent from reading it directly), so the file tree selection moves to the linked file as you navigate - Rendered Markdown with a Source ⇄ Rendered toggle — opening a
.md/.markdownfile in the Code tab's browse mode renders it as a reading document (via@kolu/solid-markdown), not raw source. Full GitHub-Flavored Markdown — headings (with anchor ids), tables, task lists, strikethrough, autolinks, footnotes, and> [!NOTE]-style alerts — plus the inline HTML a README leans on (<details>,<kbd>,<p align>wrappers, definition lists, figures, images), all sanitized through DOMPurify against a tight Markdown-only allowlist — scripts, inlinestyle/class, SVG/MathML, and form controls are all stripped, so a previewed document can neither script nor restyle the app. A leading YAML front-matter block is dropped rather than rendered as a stray heading. External anchors are forced to open in a new tab with a severed opener, while in-page anchors (TOC jumps, footnote refs) scroll within the preview without touching the app's URL, and a repo-relative link ([doc](docs/guide.md)) opens the target file right in the Code tab — resolved against the document's directory, GitHub-style — instead of navigating the app origin to a broken route. The styling follows the app's light/dark preference automatically (it paints withcurrentColor+ the app's accent, so it adapts to either palette), and a repo-relative image resolves against the document's directory and loads from the per-terminal file route — degrading to a labelled chip only when it genuinely can't be resolved. A small segmented toggle in the file header flips to the syntax-highlighted source (for exact syntax, copy, or line-anchored comments) and back; rendered is the default. Markdown stays a text file on the wire (fsReadFilekind:"text") — it renders client-side from its owncontent, so the toggle is offered because the file has both a source and a rendered form. The same toggle host (@kolu/solid-fileview) will later light up for HTML/SVG - Comments on any file — select text in a source file, a branch diff, a rendered HTML artifact, or the rendered Markdown preview and a floating "+ Comment" pill appears next to the selection; click it to attach a free-text note. Comments accumulate in a tray at the bottom of the Code tab across every file in the worktree's repo; "Copy to clipboard" flushes the queue as a plain Markdown list ready to paste into the agent's prompt. Anchors use the W3C TextQuoteSelector model (quote + ±32-char context), so the agent receiving the payload can re-locate the position by grep even after the file is edited. The same anchor model — and the same pure
extractQuote/findQuotefunctions — powers every surface: the parent-side source/diff view (Pierre'sCodeView), the light-DOM Markdown preview (anchored against the preview's own subtree), and the in-iframe HTML annotator served by@kolu/artifact-sdk. A comment on the rendered Markdown preview carries no source line — a rendered line isn't a source line and the quote ("Hello Doc") needn't appear verbatim in source ("# Hello Doc") — so it anchors by quote alone (still grep-locatable); a comment in the source view additionally carries a line range. Comments persist per repo vialocalStorage(keyed bygit repoRoot, so they survive worktree switches) and render in place via the CSS Custom Highlight API where supported
Detects Claude Code sessions running in any terminal and surfaces their state on the tile's chrome and in the dock.
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 |
| Running in background | Spinning working ring | Claude ended its turn while an observable dynamic workflow it launched is still running — it is busy-waiting on that task, not awaiting you, so it buckets as working rather than awaiting and never falsely pings. A detached Bash command or background Task/Agent has no run journal to observe and settles as waiting instead |
| Awaiting input | Pulsing alert | Claude is blocked on you — an AskUserQuestion prompt or a tool-permission gate (Write/Edit/Bash/WebFetch approval). The JSONL tail doesn't reveal the block while it's pending — for AskUserQuestion the prompt is buffered off-disk; for a permission gate the tool call is on disk (so the tail reads tool use) but the approval decision is screen-only — so kolu recognizes them on the rendered screen instead (see below) and buckets it as awaiting so the dock pings you. (ExitPlanMode is a deliberate follow-up — its prompt carries no equivalent on-screen marker.) |
| Waiting | Dim dot | Claude finished responding — or the turn was interrupted with Esc — and is idle, 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. The tail is also scanned for a dynamic Workflow the agent launched (one carrying a Run ID, so it has an observable run journal) that hasn't yet reported a terminal status via a queue-operation completion; when such a run is outstanding and its journal is still observably live (written within a couple of minutes, non-terminal), a bare end-of-turn is promoted from waiting to running in background so a busy-waiting agent doesn't read as needing you. A detached Bash command or background Task/Agent is deliberately not promoted — kolu has no journal to confirm it's still alive, and its launch marker outlives the process, so reading it as working would spin the ring forever after a Claude restart. An interrupted turn (Esc) appends a trailing user entry carrying an explicit interrupt marker ([Request interrupted by user], or an errored tool_result for a mid-tool-call Esc); that marker reads as waiting (idle), not thinking — so the dock settles instead of animating a phantom spinner that persists across claude -c. Cross-platform (Linux + macOS) since tcgetpgrp is POSIX. One state the JSONL tail can't carry is awaiting_user: for AskUserQuestion / ExitPlanMode Claude's SDK buffers the assistant message in memory and flushes the transcript only after you answer (the tail reads the prior entry — often thinking, sometimes waiting); for a tool-permission gate the tool call is on disk (the tail reads tool use) but the approval decision is screen-only. kolu recovers it by scraping the rendered screen — whenever a session sits in a pollable JSONL-derived state (thinking, tool use, or waiting), the server polls that terminal's VT-resolved screen text (@xterm/headless mirror, read server-side via getScreenText, so it works for background tiles, mobile, and remote/SSH PTYs alike) on a ~1 s clock and matches a small set of framework-rendered markers (captured live from claude-code v2.1.162): AskUserQuestion's … to navigate · Esc to cancel footer (covering both the single-select and multi-select/tabbed shapes), the edit-family permission gate's full Esc to cancel · Tab to amend footer, and the other permission gates' (Bash/WebFetch/…) numbered <n>. Yes, and don't ask again for … remember-option line. Each is chrome the framework draws, not model-supplied text, so it survives option-label churn — and each is anchored on its full surrounding structure (whole footer / whole numbered option line), so neither a look-alike menu nor the bare words in ordinary output false-promote: Claude's own /fork agent list uses ↑/↓ to select ("select", not "navigate"), the /model and folder-trust pickers end in "Esc to cancel" but not via "to navigate", and the slash menu has none. The surface is deliberately a handful of high-confidence markers we grow over time; ExitPlanMode (whose prompt has no equivalent footer) and the hook-based path are follow-ups. A match promotes the active pollable state → awaiting input; when the prompt clears, the same poll republishes the raw JSONL-derived state (the JSONL watcher's change gate would otherwise silently drop the structurally-identical settle-back, so the watcher itself can't demote it — the poll self-demotes instead). The signature is pure and centralized in claude-code/src/screen.ts, and the scrape is just another source for the already-wired awaiting_user state — no client change. 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. The tile chrome also shows a running token count (compact, e.g. 47K) summed from the latest assistant entry's message.usage — input_tokens + cache_creation_input_tokens + cache_read_input_tokens. Raw count only; window size isn't inferable from the JSONL (1M beta strips its suffix, so a % would lie), and the raw number is the useful signal anyway.
What we can't detect:
ExitPlanMode(plan-approval) —AskUserQuestionand the tool-permission gates are surfaced as awaiting input (via the screen scrape above, #905), but the plan-approval dialog has a distinct on-screen shape (Ready to code?, no arrow-nav footer) and would need its own, more volatile literal added toscreen.ts— a deliberate follow-up, kept out for now to keep the detection surface small and high-confidence- 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 — individual nested agent spawns aren't tracked as separate sessions. When the parent ends its turn to wait on a launched dynamic workflow, though, Kolu surfaces that workflow's fan-out — its name and live sub-agent count, read from the run journal at
<session>/workflows/<runId>.json— on the tile chrome and inspector while the parent isrunning in background
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 Codex TUI sessions and surfaces their state alongside Claude Code and OpenCode.
How it works: when the foreground process is codex, the provider queries Codex's threads SQLite DB (highest-numbered ~/.codex/state_<N>.sqlite, auto-discovered on startup) to find the most recently updated non-archived cli thread whose cwd matches the terminal's CWD. Thread metadata (title, model) comes straight from indexed columns. The running context-token count and the agent state (thinking / tool*use / waiting) both come from tailing the per-thread rollout JSONL at threads.rollout_path: state from pattern-matching task_started / task_complete / function_call / function_call_output events, and contextTokens from reading info.last_token_usage.input_tokens on the latest token_count event — the same number Codex's own /status command displays. Live updates come from fs.watch on the SQLite WAL file (state*<N>.sqlite-wal); Codex writes the WAL and appends the JSONL in the same cycle (verified: nanosecond-identical mtimes), so one signal covers both sources.
Why last_token_usage.input_tokens alone, not a sum? Claude-code sums three input-side fields (input_tokens + cache_creation + cache_read) because Anthropic's schema makes them disjoint buckets. OpenAI's schema — which Codex emits — is different: input_tokens is already the full prompt, and cached_input_tokens is a breakdown of what portion was a cache hit, not an additional count. Adding them double-counts every cache re-read. The field as-is is what /status reports and what gives users an accurate read on context pressure.
Why not threads.tokens_used? That column holds the session-lifetime cumulative total (total_token_usage.total_tokens summed across every turn). For long-running sessions it climbs into tens of millions — misleading as "how close am I to exhausting the 258 K context window."
Why both SQLite and JSONL? SQLite alone gives us title and model cheaply from indexed columns — no file read, no parsing. JSONL alone would force re-parsing the entire file on every update to recover title. Neither source alone carries both the event stream that drives state AND the per-turn token usage AND the columns that drive metadata — combining them specializes by purpose.
What we detect:
| State | Indicator | How |
|---|---|---|
| Thinking | Pulsing accent dot | Latest lifecycle event is task_started, with no open function_call scoped to the current turn |
| Tool use | Spinning yellow | Latest lifecycle event is task_started, with at least one open function_call that isn't a known awaiting-user tool |
| Awaiting input | Pulsing alert | Every open function_call names a known awaiting-user tool — request_user_input (Plan mode), request_permissions (all modes), or request_plugin_install. Codex is blocked on the user, not running compute |
| Waiting | Dim dot | Latest lifecycle event is task_complete |
Open-call tracking is scoped per-turn: a function_call with no matching _output that straddles a task_started boundary (user aborted a prior tool-using turn) does not pin the next turn to tool_use.
What we can't detect (yet):
- Task-progress checklist — Codex has no TodoWrite equivalent (
task_started/task_completeare per-turn lifecycle, not user-visible checklists), so thetaskProgressfield stays permanently null - Column-level schema changes — the filename version is auto-discovered, but if upstream renames or removes a depended-on column in the
threadstable (e.g.rollout_path,cwd,source,archived), session detection silently returns zero matches. SetKOLU_CODEX_DBto pin a known-good DB while a fix ships - Same-directory disambiguation — if multiple Codex threads share a cwd, we pick the most recently updated one (same heuristic as OpenCode)
- Sub-agent threads — Codex's spawned sub-agents get
source = '{"subagent":…}'rows in the same table; we filter them out (they have no foreground terminal to bind to)
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. The tile chrome also shows the running token count from the latest assistant message's tokens.total (pre-summed by OpenCode — we pass it through). 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 + at least one part with state.status: "running" whose tool field is neither question nor plan_exit |
| Awaiting input | Pulsing alert | Thinking + every running part's tool is question (structured prompt) or plan_exit (plan-mode approval gate) — blocked on a human reply |
| 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
- Installed PWA chrome color derives from the server hostname, so app windows from different machines are easier to distinguish
- Ctrl+V pastes images into any agent that accepts paste-as-file-path (Claude Code, codex, …) — the server saves the browser's clipboard image and bracketed-pastes its path into the PTY
- Drag-and-drop files onto a terminal — the file uploads to the server (10 MB cap, curated extension allowlist covering text, code, structured data, common docs, and images) and its path is bracketed-pasted into the PTY just like a clipboard image, so agents that accept paste-as-file-path pick it up automatically. Disallowed types and oversize drops are rejected client-side with a toast and never hit the wire
Record the Kolu tab — whole canvas or a single maximized terminal — with microphone and optional webcam PiP, straight to a local .webm file. Chromium-only (uses the File System Access API).
- One-click setup popover — mic picker with live 8-segment RMS level meter, webcam toggle + device picker + circular preview, all in a compact popover anchored to the chrome-bar record button
- Streaming to disk — chunks flow from
MediaRecorderinto aFileSystemWritableFileStreamcontinuously, so memory stays flat regardless of recording length. A partial.webmsurvives a browser crash (recoverable with ffmpeg) - Duration-fix pass — Chrome's
MediaRecorderomits the WebMSegmentInfo.Durationheader in streaming mode, which makes players show a ~1 second duration. At stop, the saved file is read back, patched viafix-webm-duration, and rewritten - Pause / resume — ⌘⇧. toggles; the segmented recording capsule in the chrome bar shifts red → amber and the breathing halo suppresses while paused
- Webcam PiP overlay — when enabled, a circular mirrored
<video>pins to the bottom-right above maximized tiles but below the chrome bar, and is baked into the recording by the tab-capture stream (no offscreen compositing) - Browser picker collapses —
getDisplayMedia({ preferCurrentTab: true, selfBrowserSurface: "include" })turns the multi-surface picker into a single "Share this tab" confirmation
Command-palette entry "Export agent session as HTML" (visible only when the active terminal has an agent session) saves the current Claude Code, OpenCode, or Codex transcript as a single self-contained .html file — no external assets, no server upload, opens in any browser offline.
- Vendor-neutral IR — each integration loader normalizes its session storage (Claude's JSONL, OpenCode's SQLite, Codex's rollout) into the same
TranscriptEventunion inkolu-transcript-core. The renderer dispatches onevent.kind, never on the agent — adding a new vendor's quirky tool name is a loader-side change with zero renderer churn - Typed
ToolInputunion — every Claude Code built-in tool (34 entries: Edit/Bash/Skill/TaskCreate/WebSearch/PowerShell/…) and every OpenCode built-in tool (13 entries: edit/bash/todowrite/lsp/question/…) maps to a kind the renderer can specialise on. Anything we haven't modelled lands honestly inkind: "unknown"with the originaltoolNamepreserved — the document never lies about what a tool was - Code surfaces through Pierre —
Edit,Write, fenced markdown code, and Codexapply_patchall flow through@pierre/diffs's SSR. Each chunk hydrates into a<diffs-container>custom element; a tiny inlined bootstrap shares Pierre's ~43KB core stylesheet across every chunk viaadoptedStyleSheetsso the per-event payload stays small - Warm-parchment editorial layout — serif prose, mono code, role-tinted gutters, sticky dock for hide-tools / hide-reasoning / theme cycle,
j/kto step between prompts, Reddit-style indent for nested subtask blocks - Theme follows the toggle — manual dark/light flip flows through to Pierre's shiki tokens via
color-scheme: inherit !importanton<diffs-container>, so the whole document (chrome + code) flips together instead of Pierre staying in the system-preferred mode
pnpm monorepo:
| Package | Stack |
|---|---|
packages/common/ |
oRPC contract + Zod schemas + cell descriptors |
packages/surface/ |
Reactive state framework — typed Cell<T>, Collection<K,T>, Stream<I,T>, Event<I,T> over oRPC streams; SolidJS hooks (useCell, useCollection, useStream, useEvent) |
packages/surface-nix-host/ |
Runs a typed @kolu/surface agent on a remote machine over ssh, with Nix as the provisioning mechanism — host-session lifecycle, nix copy provisioning, remote-collection mirroring, and reconnect handling. Backs the future RemoteTerminalBackend (terminals on SSH hosts) |
packages/solid-pierre/ |
Solid-native wrappers around @pierre/trees and @pierre/diffs; encapsulates Pierre's imperative mount/render lifecycle behind <FileTree> and <CodeView> with required onError props. <CodeView> (Pierre's 1.2.x advanced-mode viewport) hosts files and/or diffs in one virtualized scroll — windowed-rendering is unconditional, so single-file callers ride the same path as future multi-file ones |
packages/solid-markdown/ |
App-agnostic safe Markdown → SolidJS renderer: marked (GFM + marked-footnote / marked-alert / marked-gfm-heading-id, plus a front-matter strip) parses to HTML (render.ts, kept DOM-free + unit-tested, purely structural — it emits marked's default anchors/images and applies no link/image policy), then DOMPurify sanitizes it (sanitize.ts) against a tight Markdown-only allowlist — namespacing every id so a document's anchors can't collide with the app's, and applying an injected resolveImageSrc so a repo-relative image loads from the host's file route — (no inline style/class, SVG/MathML, or form controls) and owns the whole link policy (safeHref allowlist; a repo-relative href is tagged for in-app interception so the host opens it in the Code tab, a genuine external href gets new-tab/severed-opener) over every anchor, markdown- or inline-HTML-sourced alike — so real inline HTML renders (not escaped) while scripts/styles/frames are stripped and links can't hijack the tab. The raw-HTML + image surface is scoped per variant: only document (the full-pane preview) gets it; the inline / compact intent variants keep the stricter scope (no raw block HTML or images), since those are clickable UI rows rendering user/agent text. The output drops into a themed .kolu-md container (markdown.css, currentColor + color-mix so it follows light/dark with no theme prop). One <Markdown> component with inline / compact / document variants bundling parse mode + styling scale. Kolu's intent surface consumes it, and the document variant backs the Code-tab's Markdown rendered-appliance (@kolu/solid-fileview/renderers/markdown) that powers the Source ⇄ Rendered preview |
packages/solid-fileview/ |
App-agnostic file-preview "outlet". <FileView> owns the Source ⇄ Rendered toggle, mode-availability logic (offered iff a file has both a source and a rendered form), and the renderer-registry pick — the core has no rendering dependencies of its own: the source view and every rendered form are injected Renderer values, and each concrete renderer is a separate /renderers sub-path appliance. Ships generic image / iframe / markdown appliances (the markdown one wires @kolu/solid-markdown). Kolu's Code-tab plugs in pierre-source + markdown + image + an iframe wrapped with the artifact-sdk comment bridge, so right-panel/BrowseFileDispatcher is a thin fsReadFile→FileView adapter rather than bespoke render wiring; .md now shows the toggle (rendered by default) |
packages/artifact-sdk/ |
Self-contained comments-on-files toolkit. Three exports: ./core (pure W3C TextQuoteSelector functions used by both runtimes), ./client (parent-side iframe ↔ parent bridge + core re-exports), ./server (one-line mountArtifactSdk(app, ...) that wires the SDK bundle route and an HTML-decoration middleware — esbuild bundles the in-iframe script at server startup, hash-keyed for cache busting). The host server never imports HTML rewriting logic |
packages/server/ |
Hono; owns @kolu/pty-host in-process but consumes it through the typed ptyHostSurface contract (an in-process link), and runs the per-terminal provider DAG fresh in kolu-server against its taps |
packages/pty-host/ |
The multi-client PTY-owner primitive + its wire contract — node-pty + @xterm/headless screen mirror + VT-derived taps (OSC 7 cwd, OSC 0/2 title, OSC 633 command-run, foreground, exit), each fanned out to many consumers via a bounded Channel. Owns only the PTY; race-free attach (snapshot+deltas). ptyHostSurface is the typed contract for consuming it — in-process today, the identical shape rides a socket / ssh later |
packages/integrations/pty/ |
Shell-environment prep for PTY spawning — Nix-devshell env filtering, kolu identity vars, and the per-PTY wrapper rc-file that replays user dotfiles and injects kolu's OSC hooks. Callers compose these and hand the result to @kolu/pty-host's spawn; Node-stdlib only |
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/codex/ |
Codex detection — reads the highest-numbered ~/.codex/state_<N>.sqlite for thread metadata and tails the matched rollout JSONL for state; exports a codexProvider |
packages/integrations/opencode/ |
OpenCode detection — reads OpenCode's SQLite database via Node's built-in node:sqlite; exports an opencodeProvider AgentProvider |
packages/integrations/git/ |
Pure git operations — simple-git wrapper: repo resolution, worktree lifecycle, diff review, path security; schemas re-exported by kolu-common |
packages/integrations/github/ |
GitHub PR schemas + pure helpers (deriveCheckStatus, classifyGhError, prResultEqual); server wraps with gh pr view spawn via KOLU_GH_BIN |
packages/integrations/io/ |
Filesystem & I/O primitives — refcounted shared fs.watch keyed by directory (createDirFilenameWatcher); its only dependency is the types-only @kolu/log leaf, so any package can adopt it without runtime coupling |
packages/shared/ |
kolu-shared — generic utilities for code that watches live external state on disk (filesystem, SQLite): the Logger type re-export, readTailLines, and SQLite helpers. No agent-specific concepts; used by kolu-git and the agent integrations |
packages/transcript-core/ |
Vendor-neutral transcript IR (Transcript, TranscriptEvent, typed ToolInput union) + structural transforms; per-agent loaders normalize into this shape |
packages/transcript-html/ |
Static-export renderer — marked for prose, @pierre/diffs SSR for shiki-tokenized code/diffs, Preact JSX for chrome; emits one self-contained .html |
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 |
packages/log/ |
Structured-logging contract (Logger) — a zero-runtime-dependency, zero-kolu-*-dependency leaf, so even packages that refuse a kolu-shared dep import one canonical type instead of re-declaring it (kolu-shared, kolu-io, kolu-transcript-core all defer here) |
packages/html-escape/ |
escapeHtml — a zero-dependency leaf, so app-agnostic appliances (transcript-html, the scrollback PDF export) reach it without dragging the kolu-common domain contract into their dependency tree |
packages/nonempty/ |
NonEmpty<T> — a zero-dependency leaf giving a list known at the type level to have at least one element, with a nonEmpty() smart constructor that returns null on empty so callers narrow at the type system instead of a runtime length check |
packages/tests/ |
End-to-end test harness — Cucumber feature files + Playwright step definitions exercised by just test |
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; the client consumes it through @kolu/surface's hooks (useCell / useCollection / useStream / useEvent) rather than directly. 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\ndock · tile chrome · chrome bar · palette"]:::client
end
subgraph Server["Server (Hono)"]
subgraph PtyHost["@kolu/pty-host (in-process today; socket / ssh later — same ptyHostSurface contract)"]
Host["node-pty + @xterm/headless mirror\n+ VT taps"]:::server
end
Providers["Provider DAG\ngit · PR · agent-detect\n(runs in kolu-server)"]:::server
Backend["LocalTerminalBackend\nterminalMetadata · activity · sessions"]:::server
end
%% Terminal I/O loop (through the ptyHostSurface contract)
User -->|"keystroke"| Xterm
Xterm -->|"sendInput\n(request/response)"| Host
Host -->|"attach stream\n(snapshot + deltas)"| Xterm
%% Metadata loop
Host -.->|"tap streams\n(cwd · title · cmd · foreground)"| Providers
Providers -.->|"metadata + activity\n(direct writes)"| Backend
Backend -.->|"metadata · activity · terminal-list\n(subscriptions)"| Subs
Subs -.-> UI
%% User actions
UI -->|"create · kill · reorder\n(request/response)"| Backend
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
style PtyHost fill:none,stroke:#e9c46a,stroke-width:1px,color:#264653
Terminal I/O (solid lines) — keystrokes go through sendInput RPC to the PTY owned by @kolu/pty-host, which kolu-server drives over the ptyHostSurface contract; shell output flows back as an attach stream — a screen-state snapshot followed by the live delta stream, partitioned race-free at a single point so nothing is lost or double-painted — to xterm.js. The host's @xterm/headless mirror parses VT sequences server-side for those snapshots1, and fans the output out to every attached client through a bounded Channel.
Metadata (dashed lines) — every per-terminal observer lives in the provider DAG at packages/server/src/terminalBackend/providers.ts, parameterized over ProviderHooks + ProviderChannels so the host is the only thing that varies. The DAG runs in kolu-server itself (LocalTerminalBackend, the concrete TerminalBackend for "this kolu process"), fed @kolu/pty-host's raw VT taps over the ptyHostSurface contract — an in-process identity link today (directLink over servePtyHost's router — a direct client, no wire), so the exact same consumer code runs unchanged when pty-host later moves behind a socket or ssh. The backend constructs a ProviderRecord {pid, meta, currentAgent} — never a PtyHandle, so the DAG has zero synchronous dependency on the host (the live foreground reads it once read off the handle now arrive on a foreground tap) — wires ProviderHooks that write straight to the terminalMetadata collection + activity feed, and calls startProviders. The terminals:dirty autosave fires only for the server-persisted half (the metadata.ts write-fence: writing a live-only field through updateServerMetadata is a compile error). Decoupling the DAG from the handle — reading taps, not a PtyHandle — is what lets it run unchanged whether pty-host is in-process, behind a socket, or on an ssh host; Local- and RemoteTerminalBackend then differ only in which transport builds the pty-host client. The DAG itself: CWD changes (OSC 7) → git provider (.git/HEAD watcher) → GitHub provider (gh pr view polling). Agent detection uses a single generic orchestrator (startAgentProvider in providers.ts) driven by per-agent AgentProvider instances from each integration package. Today three instances are registered: claudeCodeProvider (from kolu-claude-code) wakes on title events (OSC 2) and its own fs.watch on ~/.claude/sessions/; codexProvider (from kolu-codex) queries the highest-numbered ~/.codex/state_<N>.sqlite for thread metadata and tails the matched rollout JSONL for state transitions; 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 write to kolu-server's terminalMetadata collection, pushed to the client as a subscription2. Separately, kolu's preexec hook emits an OSC 633;E command mark before each user command; @kolu/pty-host surfaces it on its command-run tap, the backend bridges that onto a per-terminal commandRun channel, and the agent-command tracker (in providers.ts) subscribes to match the first token against a known-agents allowlist and fan out to both (a) a recentAgent signal into kolu-server's bounded recent-agents MRU for the agent-aware command palette and (b) a per-terminal stash keyed by terminal id, so codex/opencode session detection still matches when the agent is an interpreter shim (e.g. npm-installed codex, whose kernel-level process name is node). No /proc lookups or argv scraping. The TerminalBackend interface itself lives in kolu-common/terminalBackend — LocalTerminalBackend is one concrete shape; a future RemoteTerminalBackend (for terminals running on SSH hosts) is the other, and every call site downstream goes through getTerminalBackendFor(location) so neither asks "which kind?".
User actions — the unified command palette (Cmd+K for commands; Cmd+Shift+K or the dock's search-icon button to drill into "Search workspaces") 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; @kolu/surface/solid's surfaceClient (wired up in packages/client/src/wire.ts) installs oRPC's ClientRetryPlugin so every Cell/Collection/Stream/Event hook (and the streamCall escape hatch for raw streams like terminal attach) transparently re-subscribes 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 in packages/client/src/rpc/rpc.ts. TransportOverlay renders one dim-backdrop card (pointer-events-none, so users can still scroll and read buffers underneath): disconnected shows "Reconnecting…". Applying a new client build is a separate concern owned by packages/client/src/pwa.ts. kolu deliberately uses no service worker — it can't work offline (no live WebSocket means no app), content-hashed assets are already immutable-cached by the HTTP cache, and a precaching worker only ever served stale builds across deploys (the saga in docs/cache-bug.md). Freshness is instead structural at the HTTP layer: the server serves the SPA shell (index.html) no-store and every hashed asset immutable (packages/server/src/cacheControl.ts), so the one document that names the bundle is always re-fetched and a normal reload can never replay a stale shell. TransportOverlay's "App updated" card is driven by updateReady() — a server restart (restarted, transient) or the durable signal that the running bundle's baked-in commit differs from the server's (clientIsStale, the comparison behind the chrome bar's ≠ srv badge, surfaced on desktop and mobile) — and its Reload button is a plain location.reload() that always lands on the fresh bundle. Earlier builds did register a Workbox worker; retireServiceWorker() (run on every load) plus a self-destructing public/sw.js (which the browser's own update check installs to unregister a worker even while it still controls the page) retire any such worker with no user action.
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)Bug fixes, build/CI fixes, doc tweaks, and behavior-preserving refactors are welcome as direct PRs. New user-facing features need a merged proposal first — an Atlas note in the right category with status: proposed. See CONTRIBUTING.md for the full split and the proposal frontmatter.
The pipeline (defined in ci/mod.just) is driven by juspay/justci: it builds all flake outputs on x86_64-linux and aarch64-darwin, runs e2e tests, boots the packaged binary against /api/health as a runtime smoke, and posts GitHub commit statuses per (recipe, platform) pair. Non-local platforms run over SSH against hosts listed in ~/.config/justci/hosts.json.
The DAG is shaped to keep the critical path short: e2e, smoke, and home-manager each build the one store path they need (.#koluBin, .#default, kolu-via-override) rather than depending on the full devour-flake nix node, so the ~2-min e2e suite runs concurrently with the big build instead of after it (Nix store locking dedups the shared drv). nix still runs as a gate, so typecheck/website/packaging coverage is unchanged. See docs/ci-workflow-ralph-report.md for the critical-path model.
Workspace typechecking runs as a flake check (checks.x86_64-linux.typecheck), so the all-outputs build is the type gate. Note that nix build .#default on its own does not typecheck — the client is bundled by Vite and the server runs under tsx, both transpile-only — so a green app build is not a type-proof.
Only the runner posts GitHub commit statuses; the just shortcuts below stay entirely local.
nix run github:juspay/justci -- run # multi-platform fanout + commit statuses (strict by default)
nix run github:juspay/justci -- run --progress json # + live NDJSON per-node feed on stdout (for agents/tools driving CI in the background)
just ci # local single-platform pipeline, no statuses
just ci::e2e # one recipe, no statuses--progress json streams one line per node transition the instant it happens ({node, recipe, platform, status, exit_code?, log?}), so a tool driving CI in the background surfaces a failing recipe immediately — while sibling lanes keep running — instead of waiting for the run to finish. This is how /do reacts to failures fast; see .agency/do.md.
A home-manager module runs kolu as a systemd user service on Linux and as a launchd LaunchAgent on macOS:
{
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 — a NixOS VM test exercises the systemd path on Linux, and a standalone home-manager activation build exercises the launchd path on Darwin.
On macOS, the LaunchAgent writes stdout to ~/Library/Logs/kolu.out.log and stderr to ~/Library/Logs/kolu.err.log, so crashes and startup failures leave service logs alongside other user logs.
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 optionalexternalChanges: { isPresent, install }pair for out-of-band match triggers —installfires at most once per process, lazily, the first time any terminal's state reportsisPresenttrue, so a user who has never run the agent pays zero watcher cost. A second optional pair,screenScrape: { isPollable, promote }, lets a provider promote its watcher-derived state from a scrape of the rendered screen when the host supplies areadScreenTexthook: whileisPollable(info)holds,startAgentProviderpolls the screen on its own ~1 s clock and republishespromote(info, screenText)— used byclaudeCodeProviderfor the bufferedAskUserQuestion/ExitPlanModeprompts (#905), and inactive for any provider (or host) that doesn't opt in, so codex/opencode and a screen-less host pay nothing.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.codexProvidermatches when either the foreground process basename iscodexor the preexec stash namescodex(interpreter-shim fallback), queries Codex's threads SQLite DB (highest-numbered~/.codex/state_<N>.sqlite) filtered tosource = 'cli'for the cwd's latest thread, reads mutable metadata (title, model) from indexed columns, and tails the thread's rollout JSONL (threads.rollout_path) to derive state fromtask_started/task_completeboundaries and openfunction_callcall_ids — one sharedfs.watchon the SQLite WAL file doubles as both the external-change signal and the per-session refresh trigger, since Codex writes the WAL and appends the JSONL atomically.opencodeProvidermatches via the same dual signal (foreground basename or preexec stash), 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 the pinnedghbinary. 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. ↩