Run multiple AI coding agents in parallel terminal tiles
kolu is an agent-agnostic terminal app on an infinite 2D canvas, tracking Claude Code, Codex, and OpenCode status.
Maintainer of this project? Claim this page to edit the listing.
1.0.0Add to Favorites
Why it matters
Manage many concurrent AI coding agent sessions across repositories and worktrees in a visual terminal workspace that tracks agent state, git context, and file references without locking you into any single model or CLI.
Outcomes
What it gets done
Launch Claude, OpenCode, Codex, or any agent CLI in isolated terminal tiles with automatic worktree branching
Track agent completion states with visual dock indicators and OS notifications when background agents finish
Navigate between terminals using MRU cycling, command palette search, or numbered shortcuts
Click linkified file references in terminal output to open code at specific lines in the side panel
Install
Add it to your toolbox
Run in your project directory:
curl -fsSL https://spark.entire.vc/get/juspay-kolu | bash Overview
Kolu
kolu is an agent-agnostic terminal app: real terminal tiles on an infinite 2D canvas, with a dock tracking Claude Code, Codex, and OpenCode session status live. Use it when running many concurrent terminals, especially multiple coding-agent sessions, and needing a spatial view of what's working, awaiting input, or idle.
What it does
kolu is a terminal app for scale: real xterm.js terminal tiles on an infinite, pannable and zoomable 2D canvas, with a left-edge dock that tracks every open terminal at a glance. It is explicitly agent-agnostic - the terminal itself is the universal interface, so claude, codex, opencode, or whatever CLI ships next week all work the same way, with no adapter or per-agent code needed, and you can always drop to a plain shell. The UI is populated by watching what you already do - cd events, running agents, saved sessions - rather than requiring manual configuration.
When to use - and when NOT to
Use it when running many terminals - especially many concurrent coding-agent sessions - and needing a spatial, at-a-glance way to track which ones are working, awaiting your input, or idle, across repos and worktrees. It detects and surfaces live status for three specific agent CLIs by name - Claude Code (via its session JSONL plus a rendered-screen scrape for prompts the JSONL can't reveal, like AskUserQuestion or a permission gate), Codex (via its threads SQLite DB and rollout JSONL), and OpenCode (via its own SQLite DB) - each showing thinking, tool-use, awaiting-input, or waiting states with distinct dock indicators. It is not a wrapper or chat UI around a single model; anything you would run in a shell works the same way. Requires Nix to install and run.
Inputs and outputs
Input: nix --refresh run github:juspay/kolu to serve locally, or with --host/--port flags to expose on a LAN. Output: a browser-based canvas where every terminal is a draggable, resizable tile, navigable via a left-edge dock (a compact icon rail or a detailed per-repo card list) and a command palette (Cmd/Ctrl+K) that searches terminals, switches themes, and runs actions.
nix --refresh run github:juspay/kolu # serve on 127.0.0.1:7681
A right-hand panel doubles as a Zed/VS Code-style file browser with a Code tab - full repo browsing, git-status-tinted file and folder decoration, rendered HTML/SVG/PDF/image/video artifact previews in a sandboxed iframe, and a Markdown Source-Rendered toggle with GitHub-Flavored Markdown and Obsidian-style wikilinks. Other named capabilities: GitHub PR and CI status surfaced on every navigator surface, 200+ switchable color schemes from iTerm2-Color-Schemes, paste and drag-drop file uploads that bracket-paste a path into the PTY, in-tab screen recording to a local .webm with mic and webcam picture-in-picture, and one-click export of a Claude Code, OpenCode, or Codex session transcript as a single self-contained offline HTML file.
Integrations
Detects and displays live status for Claude Code, Codex, and OpenCode specifically, each via that tool's own on-disk session state - JSONL transcripts, SQLite databases - rather than a shared protocol, so support for a new agent CLI is added per-integration rather than through a generic registry. Built as a pnpm monorepo with named packages for its reactive state layer (oRPC plus Zod), remote SSH and Nix-provisioned terminal hosting, and a safe Markdown and file-preview renderer stack.
Who it's for
Developers running many terminals at once - especially several coding-agent sessions across different repos and worktrees - who want a spatial canvas and a dock that surfaces which agent needs attention without switching tools or wrapping the CLI in a proprietary chat UI.
Source README
kolu
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.
Philosophy
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.
Usage
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 LAN
Open http://127.0.0.1:7681 (or the address you chose above).
Features
Terminals
- 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 - The right panel opens by default on desktop for new users, landing on the Code tab's All files repo browser - an editor-like (Zed/VSCode) first surface that always has a populated tree, rather than a diff view that reads empty on a clean tree. Code leads the tab row; toggle the whole panel with the chrome-bar inspector button or Cmd/Ctrl+Alt+B. The open/collapsed state, active tab, and code mode all persist, so anyone who tunes them keeps their choice. (On mobile the panel hosts as a bottom drawer that stays closed until you tap the chrome-sheet inspector toggle - but it still defaults to the same Code / All-files surface once opened.)
- 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
Navigation
- 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 kolu is in the background (a hidden tab or an installed PWA window that isn't focused) an 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+Shift+M maximize, Cmd+/ shortcuts help
Canvas workspace
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, or press Cmd+Shift+M, or run "Maximize terminal" from the command palette) 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. The dock stays mounted even on the empty canvas (desktop), where it collapses to its header so the
+new-terminal button is always a click away - the first terminal never depends on knowing a keyboard shortcut.- 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. In cards mode (and the mobile drawer) 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, and the "show all" shortcut surfaces only when something is actually hidden. In rail mode the 44 px strip can't hold the sentence, so it collapses to just the centered picker chip - and, when the window is actually hiding rows, a compact accent count stacked above it that doubles as the one-click "show all" recovery button (its accessible label spells out "N terminals hidden by the activity window - show all"). 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 glanceCanvas 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 - Code + Inspector tabs, file tree, HTML/SVG/PDF iframe + image + video 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
Git & GitHub
- 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 whose
origin/<default>simply isn't fetched yet falls back to the working-tree layer rather than erroring (the explicit Branch mode still surfaces that as an actionable "run git fetch" message), while a remote-less repo with nooriginat all has no base to compare and degrades to an empty Branch view instead of erroring on every change tick (#1244). 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. Video files (.mp4/.m4v/.webm/.mov/.ogv) render in a native<video controls>element; the route answers HTTP range requests (Accept-Ranges+206 Partial Content) so the player can seek and plays even in browsers (Safari) that refuse media a server can't range-serve. Image, video, and iframe previews all 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. An external link (one that resolves to a different host over http(s)) can't escape theallow-scripts-only sandbox on its own -target="_blank"is blocked and a plain click would replace the preview in-pane - so the in-iframe SDK traps the click and forwards the absolute URL, and the host opens it in a real browser tab (severed opener) while the preview stays put; the parent re-checks the scheme is http(s) before opening, sincepostMessageis reachable by any in-frame script - 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. Obsidian-style wikilinks ([[Note]],[[Note|alias]],[[Note#heading]]) render as a distinct violet, bracketed reference and resolve pathless across the whole repo -[[Architecture]]opensArchitecture.mdwherever it lives (.mdimplied - never a same-stemmedArchitecture.feature); an ambiguous basename pops a small disambiguation menu anchored to the link, a miss toasts, and the![[…]]embed form is left inert. 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
Claude Code Status
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.
Codex Status
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)
OpenCode Status
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
Theming
- 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
- Welcome for new users - the empty canvas shows three bird's-eye moments (pin kolu as an app · reach it remotely via Tailscale · run agents), above session restore and re-openable anytime via the command palette's Tutorial command. The "pin it" card is context-aware: over plain
http://(a LAN/Tailscale IP, where browsers can't install a PWA without a secure context) it points you to the Tailscale HTTPS fix instead of a dead Install button. Full guide at kolu.dev
Clipboard
- 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
Screen recording
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
Transcript export
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
Architecture
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 - plus an Obsidian-style [[wikilink]] inline extension (markedWikilink) that mints a distinct data-md-wikilink anchor the host resolves pathless across the repo (the document variant only) - 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 / video / iframe / markdown appliances (the markdown one wires @kolu/solid-markdown). Kolu's Code-tab plugs in pierre-source + markdown + image + video + an iframe wrapped with the artifact-sdk comment bridge, so right-panel/BrowseFileDispatcher is a thin fsReadFile→`F... |
Discussion
Questions & comments · 0
Sign In Sign in to leave a comment.