kolu
Changelog

Release notes

Changelog.

What changed, why it matters, and the work behind it. Product notes, not a commit dump. Newest first.

Next release · live on master

Unreleased

Everything that has landed since v2.2.0.

68changes
nix run github:juspay/kolu
Added
30
Changed
12
Fixed
21
Heads-up
5
68 shown

Inside a Tile

  • ChangedTerminals run on the latest xterm.js2 PRs
    The terminal engine under every tile is now xterm.js 6.1.0-beta.302 (WebGL addon 0.20.0-beta.298), up from beta.289 / beta.288. What lands from upstream: the shared glyph atlas evicts and rebuilds when it would outgrow GPU texture capacity instead of overflowing (xtermjs#6043); clearing the atlas on one tile no longer leaves siblings painting stale glyphs from wiped atlas pages (xtermjs#6055); the hidden input field used for keyboard focus sets autocomplete="off", so iPadOS Safari no longer pops password / AutoFill suggestions when a terminal gains focus (xtermjs#6057); and after a program clears scrollback with ESC[3J, the viewport follows new output instead of staying pinned to the empty top of the buffer (xtermjs#6081).
  • ChangedA terminal's feed ending is no longer taken on faith1 PR
    Every layer between a terminal and your screen used to treat a byte feed that ended politely as proof the program had exited — a normal ending nothing questions and nothing retries. So if the feed for a still-running terminal was ever dropped without an error, the pane froze blank under a live title: the agent inside kept working and kept producing output, but the pane showed nothing, never said why, and never came back on its own. Now an ending is only believed if it can be confirmed, and re-opening the feed is itself the check: asking for a terminal that has really gone is answered with no such terminal, and that is the one ending that ends the pane. Anything else means the pane is still alive, so the host re-opens the feed a few times with a short pause between tries. If it keeps coming back empty, kolu says so — on screen and in the log — and keeps trying at a gentle cadence, rather than either spinning in silence or giving up. The browser holds the same line, so a pane that stops now either comes back on its own or tells you what happened.

Agent Detection

  • Addedkolu now knows what your Pi sessions are doing1 PR
    Run pi — the pi-mono coding agent — in any terminal and its tile lights up like Claude, Codex, Grok, and OpenCode’s: thinking while it generates, running tools during a tool call, waiting when its turn ends, painted with its own π mark on the dock, tile, and minimap. kolu reads pi’s own session files to get there, so detection needs no setup: the tile shows pi’s model and running context size as the turn streams, the session’s /name when you set one, and its exact age. Sleep and wake resume the conversation it was in (pi -c, or pi --session <uuid> for the exact one), launch-shape flags such as --model / --provider / --thinking / --name ride the recent-agents list, and the command palette’s transcript export renders pi sessions to HTML like the other agents’. Pi’s permission gates live in its TUI rather than on disk, so there is no awaiting-you signal to read yet — the one state that stays dark. And a pi pointed at a moved session store — pi --session-dir <dir>, PI_CODING_AGENT_SESSION_DIR, or the per-run scratch PI_CODING_AGENT_DIR an agent harness typically hands it — is followed to its real location (kolu reads the pi process’s own arguments and environment, exactly as pi resolves them), so harness-launched pi agents light up too.
  • FixedTwo agents in one repository no longer show up as the same agent twice1 PR
    Run Codex — or OpenCode — in two terminals of the same project and their dock rows would quietly converge: the same title, the same subtitle, the same status, and an alert meant for one lighting both. It usually took a while to appear, then never came apart: whatever either agent did next rewrote both rows, so the one thing the dock is for — telling you which agent needs you — stopped working. A Codex or OpenCode conversation records nothing on disk that ties it to the terminal it is running in; the only link is its working directory, which two agents in one repository share. kolu had been resolving that by taking the most recently updated conversation in the directory — an answer that is the same for every terminal in it, and that moved to whichever agent had most recently done something. It also meant a codex you ran outside kolu, in a directory kolu had terminals in, took over those rows the moment it did anything. Now “most recently updated here” is treated as a shortlist rather than an answer: each terminal is handed a conversation no other terminal already has, and once it has one of its own it keeps it until the agent in it exits. These agents write a conversation to disk only after the first exchange, so for the first moments the only thing in the directory is the previous run — a terminal shows that because there is nothing else to show, then moves onto its own the instant it appears; and a terminal whose neighbours hold everything shows no agent for a beat rather than borrowing one. One consequence of having no process id to go on: once a terminal has a conversation of its own, starting a fresh one from inside that same running Codex or OpenCode (its /new) is indistinguishable from someone else starting one in that directory, so the tile stays on the previous conversation until you exit the agent. Claude Code and Grok were never affected — both stamp a process id kolu matches against.
  • FixedA Grok tile no longer sticks on Thinking after the agent has finished1 PR
    Run two or more Grok sessions at once and one of them could freeze: the tile kept showing Thinking long after that Grok had finished its turn and gone quiet, and it stayed that way until you closed the terminal. Waiting on it from a script — padi-tui wait --until waiting — simply never returned. Grok keeps a file listing which of its sessions are live, and rewrites the whole list from its own point of view whenever a session starts or ends, which quietly erases the entries belonging to every other Grok running at that moment. kolu read a missing entry as “this agent is gone”, stopped following the session, and — seeing the Grok process still sitting in the foreground — held on to the last thing it had heard rather than wrongly clearing a live agent. Nothing ever put it right, even though the answer was on disk the whole time. That list is now only ever used to find a session, never to decide one has ended: once kolu has matched a terminal to a Grok session, only the process actually going away — or the terminal moving on to something else — ends it.

Sessions, Sleep & Wake

  • Fixed“Start fresh” asks before it throws a session away — and it is no longer the size of the card1 PR
    The restore card offers Start fresh below the Restore button — the one way to say no, I don’t want these back. It was a bare line of text, and it was also, invisibly, a button as wide as the card: the empty space to the left and right of the words was live, and a click anywhere on that line discarded the saved session immediately, with nothing asked and nothing kept. That is a lot of trust to place in where a cursor happens to be on the click that dismisses a card you were only reading, and in the incident behind this fix it cost someone a session of sixteen terminals — the click never landed on the words at all. Two things change. The button is now only as wide as its own label, so the blank card padding beside it is blank card padding again. And it asks: the first click opens a confirmation that says plainly what goes and how to get it back, and only the second discards. Separately, and regardless of how you get there, kolu now copies the saved session before it clears it — the same backup ring a restore already writes into — so a session you threw away comes back through Debug → Restore state from backup exactly the way one lost to a bug does. If that copy can’t be written at all (a full disk, a read-only state directory), Start fresh refuses rather than discarding something it could not save first, and says so.
  • FixedAn agent launched as a terminal's first command now survives restore1 PR
    Restore a session and some terminals could come back as plain shells — the terminal returned, but the Claude Code, Codex, or Grok conversation that had been living in it was never offered for resume, while its neighbours came back whole. Whether a terminal could resume hinged on kolu having heard the command that launched its agent, and in bash kolu listened for that through a hook that shell-history tools — atuin, and anything else built on bash-preexec — quietly take over at each new shell’s first prompt. The takeover swallowed exactly one command per shell: the first one. And the first command is precisely where agents get launched — including by kolu itself, whose restore types the resume command into a brand-new shell as its first line — so a terminal whose agent was the first thing run in it silently dropped out of every later restore. kolu now hears about bash commands through a channel nothing else competes for, so the first command counts like every other and an agent launched the moment a terminal opens resumes across restarts — with those history tools left entirely alone. zsh was never affected. One thing this release cannot do is repair the past: a terminal that already lost its launch line restores as a shell once more — relaunch the agent in it once, and it is remembered from then on.

The Code Tab

  • ChangedThe Code tab rides Pierre Diffs 1.31 PR
    The file and diff viewer in the Code tab now runs on Pierre Diffs 1.3.5, up from 1.2.12. Syntax highlighting no longer races the worker pool on first open; virtualized rows no longer flash a previous file’s header as you scroll; expanding collapsed diff context and live-updating a diff that’s already on screen both work as they should; and the viewport keeps its place when a file drops out of a multi-file view.
  • Fixed.mdx files open as rendered Markdown, not only Source1 PR
    Opening a .mdx file in the Code tab used to land you on the raw Source view only — no Source ⇄ Rendered toggle, no document layout. MDX is Markdown with JSX components, so it now takes the same Markdown preview as .md and .markdown: front-matter, headings, lists, and the rest render as a reading document. Component tags are not executed and are stripped by the sanitizer — use Source for the raw MDX.

Troubleshooting

  • AddedA bug in kolu's own interface shows you a fault card, not a blank page1 PR
    If something in kolu’s interface throws while the page is being drawn, what you used to get was a white tab: the screen went blank, nothing updated again, and the only trace of what happened sat in a browser console you had no reason to open. Now kolu catches it and says so — a card names what threw, verbatim (the text a bug report is made of, scrollable rather than summarised away), notes that your terminals keep running untouched (nothing kolu draws reaches them), and offers a Reload that lands on the latest deployed build — which matters, because a stale cached bundle can be the very thing that threw.
  • AddedYour state is backed up at every boot, and restorable from inside kolu2 PRs
    kolu now keeps a short history of its own state: at every boot — and once a day while running — the server’s config store and each host’s padi session file are snapshotted into a rotated backups/ ring beside the file (ten deep, unchanged restarts skipped, and never allowed to block a boot). The one act that deliberately destroys a session — Start fresh on the restore card — snapshots into the same ring before it clears anything, and refuses outright if that snapshot cannot be written. It exists for the day a bug overwrites good state with a bad value — the failure where an autosave persisted an emptied session over the real one and nothing on disk remembered the original. When that day comes, open Debug → Restore state from backup from the command palette, pick the store — the server’s own, or any host’s padi, including remote ones — and pick a snapshot: each row says what it holds (“14 terminals”, “preferences · 3 hosts”), so the good one is easy to spot even when the newest snapshots carry the corruption. Restoring a padi snapshot re-spawns its terminals beside your current ones with agents resumed; restoring the server store applies live, with no restart; and every restore snapshots the current state first, so a restore is itself undoable.
  • AddedDebug → Diagnostic info can prove a connection problem on its own2 PRs
    Debug → Diagnostic info gains a Wire section and a Copy diagnostics button — also on any host’s status popover — that puts everything this tab believes about its own connection into one block you can paste into a bug report. Two things in it were previously impossible to see from kolu at all: the last 20 connection attempts this tab has made, including the ones that failed before they were established (kolu keeps no record of those — the server never saw them, so nothing on your machine wrote them down); and each live subscription with when it last heard from your machine, marked parked when it last heard something older than the connection it is riding on. The block also lists every host exactly as this tab currently believes it — a view you previously had to assemble one host at a time from each one’s popover, and which never showed when this tab last heard about a host, stamped on every entry now. It also names the moment the server retired this tab — the terminal state a page bound to a replaced server ends in, which nothing outside the page could previously date. Everything in it is read from what the tab already holds — it asks your machine nothing, which is the point: it has to work in the case where the connection is the thing that is lying.
  • FixedThe startup log prints a URL you can actually open on an IPv6 bind1 PR
    Bind kolu to IPv6 — kolu web --bind :: — and the kolu listening line reported http://:::7314, which is not an address any browser will take, so the one thing that line is for (copy it, open it) did not work. It now prints http://[::]:7314.

Padi

  • AddedTalking to padi no longer means installing it1 PR
    This one is for anyone building a program that speaks to a kolu machine rather than using kolu; nothing about kolu itself changes. Padi is the daemon on each machine that knows everything about that machine’s terminals and serves it as one surface — and until now, a server that only wanted to read that surface had to install the daemon to do it. Packages arrive whole: a program that vendors padi installs what padi’s own manifest asks for, which is the terminal engine, the native pseudo-terminal binding, the screen renderer and a compile step — to get what is really a description of the surface and a function that opens a socket to it. The description and the function now live in a package of their own, @kolu/padi-client: the surface contract and the vocabulary it speaks, the dial that reaches a running padi, the rule for naming its socket, and the kit for watching terminals change state. Twelve of the packages it used to drag in are gone, and twenty-six of the third-party ones, the native pseudo-terminal binding among them. Padi itself is built on the same package, so there is one description of the surface and not two that have to be kept in step — and what genuinely needs the daemon stayed with it: reaching a padi on another machine over ssh, and finding the padis running on this one.
  • AddedA terminal's pull request now says whether it's approved and whether it's mergeable1 PR
    The PR kolu already looks up for each terminal — the same gh pr view the dock pip and the Inspector already ride — now also carries GitHub’s review decision (APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, or none) and merge state (CLEAN, BLOCKED, DIRTY, and the rest of gh’s own enum) from that same lookup, so a script or another app reading the terminal record can tell those two things without a second GitHub call. An older daemon that doesn’t send the fields still talks to a newer kolu: the fields default rather than crashing.
  • Heads-up`padi-tui create` now makes you say where the terminal goes1 PR
    padi-tui create requires exactly one of --toplevel or --parent <id>, and there is no default — neither flag, or both, is refused with the rule. A terminal you create with --parent is drawn inside that parent’s tile and the Dock reads the same edge as who works for whom, so it is the one thing about a new terminal only the caller knows. While --parent was optional, “I did not say” and “top level, please” were the same request, and a script that never mentioned placement got a row of unrelated tiles for a fleet of agents that were every one of them a split — with nothing failing to tell you. Add --toplevel to any script that used a bare padi-tui create; that is the whole migration, and the refusal itself says so. The check is instant and happens before anything connects, so a mistake over --host no longer costs you a cold machine first. The line create prints on stderr now always names the placement (— created 4bba · top-level), because top level is a decision rather than a silence. The same rule holds at kolu create and at the MCP tool.
  • FixedUpgrading kolu no longer leaves agents driving with the old build's tools1 PR
    Every terminal kolu opens carries kolu’s own command-line tools on its PATH, so an agent inside can drive its sibling terminals. Those tools come from the background daemon that opened the terminal — and that daemon deliberately survives kolu upgrades, which is what keeps your terminals alive across a deploy. The catch: it kept handing out the tools of the build it was born with. Nothing looked wrong — both builds report the same version number — until an upgrade moved the terminal verbs into kolu itself, at which point an agent in any terminal opened by a surviving daemon ran kolu --help, saw three subcommands where the README promised eleven, and reasonably concluded the feature didn’t exist. The stale copy sat first on PATH, silently shadowing the real one your shell gets. The daemon now writes down which toolchain it hands terminals, and kolu checks that note against its own on the way up: a daemon holding yesterday’s tools is asked to save its session and step aside — your terminals keep running through it — and a fresh daemon with the current tools takes over. Terminals opened from then on carry the build you actually installed, on the machine kolu runs on and on remote hosts alike.
  • FixedOne unreadable terminal record no longer costs you every terminal on the machine1 PR
    Your terminals keep running across a kolu restart, and kolu takes them back over on the way up. Each one has a saved record beside it — its theme, its notes, which agent conversation it would resume. If kolu met a record it could not read, it treated that as evidence the whole handover had gone wrong and did the drastic-but-safe-looking thing: it shut the machine’s terminal daemon down and started a clean one. That killed every terminal on that machine, including all the ones whose records were perfectly readable. What you got was a screenful of “Terminal attach failed unexpectedly: no PTY with id …”, an empty canvas, and a restore card that could not offer the terminals back either — the shrunken session had already been written over the full one. In the report behind this fix, one unreadable record out of eighteen took down all six running terminals.

    The way to end up with a record kolu cannot read is to go backwards: a session written by a newer kolu can name things an older one has no word for — an agent it has never heard of being the likeliest — so a rollback, or two different kolu versions taking turns on one machine, is enough. That now costs exactly the terminal it belongs to, and only its saved details: the shell keeps running and comes back as an ordinary live terminal with kolu’s defaults, every other terminal comes back whole with its own details intact, and the log names the record that could not be read. Nothing to do on your side, and nothing to clean up.
  • FixedA slow moment on a busy host no longer half-kills its daemon1 PR
    The background daemon on each host takes a readout every ten seconds — which terminal daemons are running, how many terminals they hold. During a big restore, when everything on the machine is at its busiest, that readout could time out — and if it was the daemon’s first, that one timeout killed the readout for the life of the daemon: it stayed running, still answering, still showing “connected” — but its picture of the host’s terminal daemons was frozen for good, and because the daemon had already recorded its one allowed fault, anything that went wrong later went unnoticed too. Only restarting things by hand brought it back. A routine reading that fails is now just that, first or not: the value goes stale, the failure is logged with the name of what could not be read, and the next reading ten seconds later picks it back up. The readout’s own time budget grew to a defensible five seconds — half the gap between readings — so a busy machine gets room to answer, and a daemon that still cannot answer inside that is genuinely stuck rather than merely loaded. And in the one case where a daemon really is broken beyond repair, it now stops instead of lingering: it saves your session, releases everything it holds, and exits, so the supervisor restarts it and your terminals come back in seconds — the outcome you would previously have had to reach for by hand.
  • ChangedAn unresponsive terminal daemon restarts itself — you no longer have to press the button1 PR
    A terminal daemon can stop answering without stopping: still running, still accepting connections, and replying to nothing. When that happens your workspace goes dark, and kolu 2.2.0 could see it within ten seconds and do nothing about it — it showed “kaval — your terminal daemon — stopped” with a Restart kaval button, and then waited for you. In the incident behind this release that meant a laptop that woke from sleep with a dead workspace and stayed that way until someone noticed and clicked. Now the host’s background daemon acts on what it can already see: three failed checks in a row — half a minute of a daemon that cannot answer three trivial questions — and it runs exactly what the button runs, saving your session first, stopping the stuck daemon, starting a fresh one, and offering your terminals back for restore. A single slow moment on a busy machine changes nothing; only a real stretch of silence does. If three restarts in a row do not bring the daemon back, kolu stops trying rather than restarting forever, and you get the card and the button as before — the card is now for the genuinely unrepairable rather than for every case. The daemon also watches itself from the outside now: it asks its own address a trivial question on the same cadence, and if it cannot answer itself three times running it exits, which is a state kolu has always handled — the host restarts it and your session comes back. When a restart lands, kolu says so once: “kaval was unresponsive — kolu restarted it; your session is ready to restore.”
  • FixedA dropped connection to your terminal daemon now mends itself — with every terminal still running1 PR
    Your terminals live inside a daemon on each machine, and kolu’s background daemon holds a single connection to it. If that connection dropped while the daemon itself was perfectly healthy — still running, still holding every terminal — nothing ever put it back: kolu went quiet about that machine and stayed that way, showing “kaval — your terminal daemon — stopped” over a workspace that was in fact entirely alive. In the report behind this fix that lasted 52 minutes, and the only button on offer was the one action that would have made the message true: Restart kaval stops the daemon, so the nine terminals and eight running agents sitting behind that card would have been killed in order to mend a connection. kolu now re-makes the connection by itself, about a second after it drops, and keeps trying — backing off to once every half-minute — for as long as it takes. It only re-connects to a daemon it has just checked is still answering, so the ordinary outcome is that it takes the running daemon back over instead of replacing it: your terminals and the agents inside them keep working throughout, there is nothing to restore afterwards, and you get one line — “lost the connection to kaval — kolu reconnected; your terminals kept running” — before the card clears itself. If the daemon exits in the moment between that check and the re-connect, or a re-connected daemon turns out to hold terminals kolu cannot account for, kolu ends up on a fresh daemon instead and says the other thing — “kaval was unresponsive — kolu restarted it; your session is ready to restore” — so the message always matches what actually happened to your session. A daemon that has genuinely stopped is a different case and is unaffected: it still goes through the ten-second health check, and the three-restart limit that keeps a failing daemon from being restarted forever.
  • FixedClosing a split terminal aims exactly one kill at it1 PR
    Closing a split — the × on a sub-terminal tab — asks the host to end that terminal. If two of those requests overlapped, which a quick double-click is enough to cause, both were carried out: the host looked up the terminal, found it still there, and started a full teardown for each, so it ended up sending two kills at the same process id. Process ids get reused, and between the first kill and the second the number can already belong to something else entirely — so the second kill was aimed at whatever now held it. Both requests also reported back that they had closed the terminal, which is the sort of disagreement that makes a bug elsewhere hard to read. Now the first close claims the terminal outright, before anything slow happens, and any request that overlaps it is answered exactly the way a second close a minute later already was: that terminal is already gone.

Remote hosts

  • FixedA failed remote-host retry no longer leaves its SSH connection behind1 PR
    A machine that could not be reached was always retried, which is what lets its terminals return after sleep or a network break. But when the SSH target used a jump host or another ProxyCommand, ending one failed attempt stopped only the outer SSH process: the proxy shell and the second SSH process stayed alive with nobody owning them. Every retry could leave another pair behind, until hundreds of old connection attempts were still sitting on the machine running kolu. An agent connection now starts as one owned process group, and every way that connection ends stops the whole group — the outer SSH process, its proxy, and anything the proxy started — with the same graceful-then-forced teardown already used by the provisioning commands. A host that is down still retries exactly as before; each finished attempt now actually finishes.
  • FixedComing back to a backgrounded tab no longer greets you with a false “kaval didn’t start”1 PR
    Leave kolu in a background tab for a while — a fullscreen game, a long call, a laptop that dozed — and the browser throttles that tab until its connection to your machine drops. kolu was right to show Disconnected from server · Reconnecting… while that lasted. What it should not have done is also decide, thirty seconds in, that your terminal daemon had failed to start, and paint the red kaval didn’t start card behind the reconnect notice — offering to restart a daemon that had never stopped, under a machine whose terminals were all still running. The card cleared itself when the tab reconnected, which made it alarming rather than merely wrong. kolu now treats a lost connection as exactly that: while it cannot see your machine it makes no claim about what is running there, and shows only the reconnect notice. A daemon that genuinely fails to start on a machine kolu can reach still says so, unchanged — and the timers behind that warning — both the per-attempt one and the half-hour last-resort backstop — now start fresh when the connection comes back, instead of counting the minutes your tab spent asleep. (On a link that keeps dropping faster than those timers run, that means no such warning appears at all: you get the reconnect notice, which is the true thing to say.) A warning you had already been shown stays put through a blip, too, so its Restart button never vanishes from under you. The rest of the screen stops guessing along with it: a host whose status kolu cannot currently see reads unknown with a muted dot, rather than the amber connecting… it used to borrow — that word belongs to a machine kolu is actually watching come up, and a machine it has simply lost sight of is very often perfectly fine. The Running daemons panel says the same thing in its own words instead of blaming the host, and the diagnostics you can copy from the Debug menu now name the last status kolu heard before it went blind — as well as a failed host’s actual reason, which that block had been quietly leaving out.
  • AddedConnecting to a machine stops depending on a download when your kolu already carries its agent1 PR
    A first connect to a remote machine has to get the agent it needs onto that machine — and when nothing anywhere has it yet, the machine ends up compiling the daemon over ssh. On a good day that is a pause. On a bad one — a fresh upgrade, where every machine needs the new agent on first contact — it is every host at once, each building from source on hardware you may not have picked for that. When kolu is installed as a service through the home-manager module, the version you installed already carries the agents for its own platform, so a host that matches your kolu’s architecture is served straight from what kolu is holding: nothing to fetch, and nothing there to compile. A host of a different architecture — a macOS kolu reaching a Linux box — is not covered by that: its agent still travels the binary cache route. And kolu still works out which agent a host needs when it dials, so this removes the fetch rather than every wait. Launching kolu with nix run carries nothing ahead of time, so the cache matters there too.
  • FixedA host whose live views stop updating no longer does it in silence1 PR
    What a host derives — what the Dock and the canvas show about it, rather than the bytes in its panes — could go stale and stay stale, while existing panes streamed happily and a browser reload changed nothing. Only restarting kolu brought it back, which is the tell: the part of the server that mirrors a host’s state into your browser had stopped mirroring it, and said nothing. It read “a piece of this stopped arriving” as a normal ending and noted it at a level production throws away, so it went on looking perfectly healthy with nothing anywhere to say why. That failure now lands in the log at error level, naming the host and the cause, instead of a debug line production filters out — so a frozen mirror can be seen and acted on instead of guessed at. Restarting kolu is still what clears it.
  • FixedA host's dot no longer shows green over a dead workspace1 PR
    Every host in the strip carries a status dot, and until now it reported one thing: whether kolu could reach that host’s background daemon. That is a real fact, and it is not the fact you are looking at the dot for — a host can be perfectly reachable while the terminal daemon behind it is dead, which is exactly what the incident behind this release looked like: a green dot over a workspace that would not run anything. Nothing lied; the dot simply answered a narrower question than the one it appears to answer. It now reports the whole chain instead of its first link. A host kolu can reach whose terminal daemon is stopped renders amber, labelled “kaval down”, with the daemon’s own verdict in the tooltip — and amber rather than red on purpose: the host is reachable, and kolu is already restarting the daemon for you. A daemon coming back up pulses and says “kaval starting”. A host kolu genuinely cannot reach still reads red and struck through, as before, and a host whose whole chain is healthy is still plain green with nothing added.
  • ChangedWaking your laptop retries its down remote hosts at once1 PR
    A remote host that was already retrying when you closed the lid — or that went unreachable while the laptop slept — could stay unreachable for up to a further minute after the machine was back, its panes not streaming. Nothing was broken: when a host goes quiet kolu retries it on a widening interval, up to a minute between tries, and a closed lid tells it nothing — so the wake landed wherever that timer happened to be, and your panes waited out a delay set before any of this happened. A browser reconnecting is the earliest possible sign the machine is awake, so it now brings every down host’s next attempt forward to right now: the same attempt, at the same point in the give-up rules, just no longer waiting on a timer that events have overtaken. Several tabs waking at once collapse to one attempt per host, a host kolu believes is up is left alone, and a host that has already given up still waits for you to ask. Those give-up rules changed in this release too, in your favour: kolu’s bounded give-up now counts only failures of the same kind, so a stretch of “host unreachable” — a laptop asleep overnight — no longer uses up the budget that exists for real rejections. A long sleep can no longer make kolu give up on a host it merely could not reach, and when it does give up, the card can only ever cite the count that actually tripped it.

Port forwarding

  • FixedA forward going down no longer takes the whole server with it1 PR
    Forwarded ports end on their own all the time — you stop the dev server behind one, or the network between kolu and a remote machine goes away. kolu is meant to notice, write a line saying which door went and why, and drop the row from the list. Instead, the first forward to go down killed the kolu server — every terminal on every machine went dark at once, and nothing brought them back without going to the machine kolu runs on and starting it again by hand. What made it hurt was where the fault sat: in the step that reports the loss, so noticing was itself the thing that did the damage, and no forward could ever end quietly. It needed no unusual bad luck — only a door that closed. In the report behind this fix a VPN key expired mid-afternoon, which took the link to every remote machine at the same moment, so the very first loss to be reported ended the server; it was three hours before anyone was there to start it again. Losses are now reported the way they were always supposed to be: a line in the log naming the door and the reason, the row leaving the Ports list, and everything else — every terminal, every other forward — carrying straight on.

The Dock

  • ChangedThe dock stops rearranging itself1 PR
    The dock used to reorder itself by what had been active most recently — so a background agent finishing a turn could rearrange a list you were reading, pull a whole repo section to the top, and slide rows out from under your cursor. Worse, ⌘/Ctrl + 1..9 targets that list, so the numbers meant something different every few minutes and were never worth learning. Rows are now ordered by structure — repo, then branch, then the order you created them — so a terminal stays where you last saw it until you close it, and a new one appends at the bottom. The numeric shortcuts are stable enough to commit to memory, and this holds everywhere the dock does: the desktop dock, the collapsed rail, and the phone drawer. Recency is still on every row — how long ago it moved, the pulse while an agent works, and the activity window that hides stale rows are all unchanged; it just stopped deciding positions. For a list ordered by what is warm, that is what the switcher’s Recent band is for.
  • AddedA pinned Needs-you strip surfaces blocked agents in one place1 PR
    Agents that are genuinely waiting on your input now appear in a small Needs you strip pinned to the top of the dock, each showing how long it has been waiting. This is how a blocked agent stays findable now that the list underneath never rearranges: the strip mirrors the row rather than moving it, so the terminal keeps its place, keeps its number, and nothing below it shifts when an agent starts or stops needing you. Clicking an entry jumps straight there. The strip renders nothing at all when nothing is blocked, and only the agent no longer needing you clears it — there is nothing to dismiss.

The Switcher

  • Added⌘⇧H then Enter hops back to the machine you came from2 PRs
    The three switcher chords now share one rule: each opens with the row you were last on that isn’t the one you are on now already highlighted, so the same two keystrokes take you back where you just were. “Last on” means where you went — a background agent printing output does not count as you having been there, so a busy terminal can no longer take the highlight off the one you came from and send ⌘K, Enter somewhere you have never been. The Recent list is still ordered by what is warmest, output included; it is only the highlight that follows your own trail. The one that gains it is the host list (⌘⇧H): it used to open on whichever machine happened to be first in the pool, so switching between two machines meant reading the list and picking every time. It now opens on the machine you switched away from, which makes ⌘⇧H, Enter a two-key hop between your last two machines — and pressing it again brings you back, exactly the way ⌘K, Enter already hopped between your last two terminals. The list itself still paints in pool order — a machine list that reshuffles under the cursor is not one you can learn — and the trail is per browser tab (surviving a reload of that tab), so two tabs parked on two machines each keep their own way back; until a tab has switched once, the highlight falls back to the first other machine. ⌘⇧K picks the rule up too: browsing terminals under their host headers, the highlight now skips the terminal you are already in rather than offering it as the default. Plain command lists have no recency and are unchanged, and typing in any scope still moves the highlight to the top match.

Terminal UI

  • Added`kolu surface` spells the agent API as shell verbs1 PR
    The tool table kolu mcp serves a coding agent is now callable from a script: kolu surface <verb> … mounts the same table as command-line verbs — kolu surface screen_text <id> --tail 20 reads a screen, kolu surface lifecycle_create --input '{…}' opens a terminal (the heaviest verbs take their whole input as one JSON flag, or --input - from stdin), and kolu surface list prints the whole verb table without dialling anything, so a dead endpoint still lists. kolu surface --help is a page written for a person, not an alphabetical dump of every agent’s paragraph-length description: a purpose line, the verbs grouped by what a supervisor does with them — drive a terminal, look at the screen, wait for the turn, watch the fleet — and one example each, with the full per-verb contract still behind kolu surface <verb> --help. The introspection plane rides along — keys and get over the terminals, urgency, daemonStatus, status, identity views, each with a --follow form that streams the answer as ndjson lines — and the shared endpoint flags apply as on every face: --socket, --state-root, --host, on either side of the verb name. stdout stays data (one JSON value for a read, ndjson lines for a --followed one; --json purely asks for the whole answer as one JSON value, never the input), stderr carries the failures, and the exit matrix is @kolu/surface-cli’s, not the native verbs’ 1/2/3: 1 is the daemon’s typed refusal, verbatim JSON on stderr that a script branches on, 2 a usage error, 3 the endpoint unreachable, named as you spelled it, 130 Ctrl-C. The projection is the @kolu/surface-cli package this release also ships — kolu mounts it as one face, and any other binary mounts it the same way.
  • Addedkolu screenshot saves a terminal as a picture1 PR
    kolu screenshot <id> writes a terminal’s screen to a PNG — themed, coloured, box drawing and all — where kolu snapshot flattens the same screen to plain text. Reach for it when what you want to keep or share is what the terminal looks like: a TUI mid-run, a rendered diff, the state of an agent you are supervising. It writes kolu-screenshot.png unless you name a path with -o, and -o - puts the raw bytes on stdout for a pipeline that wants them — otherwise stdout stays empty and the one-line summary goes to stderr, so kolu snapshot | grep-style scripting keeps working the way it did. --lines N captures the last N rows instead of the visible screen.
  • Added`kolu watch` can nag you about terminals nobody is dealing with3 PRs
    An agent terminal finishes and sits at an empty prompt. Nobody notices for hours — and every alert kolu had taps you once, when the terminal goes idle, so missing that one tap meant the terminal was silent forever. kolu watch could not be that alarm either: it relayed byte-level churn (an idle agent that repaints its prompt once a second flooded the feed), it only showed what changed (start it late and standing neglect was invisible), and it never repeated itself. Three flags fix all three. --states waiting,awaiting reports the agent’s own state rather than its output — an idle agent that keeps repainting is still idle, and kolu asks the agent, never the screen. --held-for 60s reports a state only once it has held that long, so an agent handed more work inside the window is never reported at all. --nag 5m re-reports it every five minutes for as long as it keeps holding, so a line you ignored comes back instead of being gone — and --nag 5m/3 makes even that finite: three reminders past the first report, then quiet about that terminal until the state changes — the count rides inside the interval it caps, so it can never be spelled without one (a bare --nag still repeats forever; a capped nag line says which reminder it is and how many follow, so a script can tell the last one from the others). And a watch now leads with the currently-matching set, so starting one late still shows you what is already standing. --ignore-self keeps the terminal you are running inside out of that feed (it reads $KAVAL_TERMINAL_ID, so there is nothing to configure and nothing to go stale), and --ignore <id> (repeatable) mutes known terminals fail-open: a stale id costs nothing, and every new terminal is still watched — the opposite of listing who to watch, which goes blind to a lane nobody added. Tailing the one terminal you also muted is refused rather than hanging silent, and a typo’d --ignore prefix warns on stderr. Piped through grep/awk/head, every line arrives as it happens and a consumer that stops reading — a pager you paused, a supervisor that got busy — slows the feed rather than killing it. --heartbeat 10s emits a timestamped alive line so a quiet pipe means nothing is matching, not that the stream died. Put together, kolu watch --states waiting,awaiting --held-for 60s --nag 5m --ignore-self --heartbeat 10s is the whole supervision loop in one backgrounded line, and it replaces the hand-rolled grep/awk pollers with state files people were writing instead. There is no new command and no new subscription to manage: the id argument still means the whole fleet when you omit it, one id still works as a debugging tail, and --json gives the same feed as NDJSON — filtered inside kolu, so a script re-implements none of the timing.

Agent fleets

  • Added`kolu` now manages terminals itself — one command instead of two side tools3 PRs
    Driving kolu terminals from a script or an agent used to mean two other commands: padi-tui for what a terminal is (create it, wait for its agent, watch the roster) and kaval-tui for what is in it (type into it, read its screen, wait for output to settle). Which one you needed depended on which of kolu’s two background daemons owned the answer — an internal split that nothing about the task told you about, and the canonical driving loop interleaved both. Those verbs are now on kolu itself: kolu ls for the roster, kolu create (which now REQUIRES a placement — --toplevel for a tile of its own or --parent <id> for a split — plus --worktree for a fresh branch and -- <command> to launch an agent in it), kolu send, kolu wait, kolu debrief, kolu snapshot, kolu history, kolu kill and kolu watch. One --until covers both kinds of waiting — --until idle:2000 for raw output going quiet on any terminal, --until awaiting,waiting for an agent’s turn actually ending. And because an agent’s turn ending is not the same as its work finishing — modern agents farm work out to background subagents, so the main loop reports its turn as over while a subagent is still running and still printing — kolu debrief <id> is the done-signal worth reaching for when you are driving another agent: it blocks until the turn is over and the terminal has gone quiet, then prints the last 40 screen lines on stdout so you can read what the worker believes happened before you decide anything (--quiet <ms>, default 15000, and --tail <N>, default 40, tune it). It is exactly kolu wait <id> --until awaiting,waiting --settled 15000 --snapshot 40, and those two wait flags stand on their own: --settled <ms> adds “…and no output for this long” to any --until condition — bytes moving keep the wait open, and an agent that goes back to work re-enters it — while --snapshot <N> makes the wait hand back the screen it settled on, as stdout in plain mode or as a screen key under --json. Asking for them together is not merely shorter than three separate commands: output can move in the gap between one wait finishing and the next starting, and the screen a separate kolu snapshot reads is not the screen the wait settled on — asked together they are judged against one live subscription, and a screen read the terminal moves under is discarded and retaken. Everything a driving loop relied on carries over unchanged: ids still accept any unique prefix, stdout is still data (so id=$(kolu create --toplevel) works) with prose on stderr, --json is still there for the read verbs, and the exit codes still mean the same things — 2 the wait timed out, 3 the terminal exited first, 130 interrupted. Flags no longer care where you put them: kolu --host box create and kolu create --host box are the same command, which the old tools rejected. padi-tui and kaval-tui still work and are unchanged; a later release retires them. Attaching interactively from the terminal is not part of this — the browser remains the way to sit inside a pane.
  • Heads-upBare `kolu` now lists its commands, and `kolu web` binds with `--bind`1 PR
    Two deliberate breaks, both on the kolu command itself. Running kolu with no arguments no longer starts the web server — it prints the list of subcommands and exits non-zero, so you pick one explicitly. Spell it kolu web to get the server, and update anything that launched the bare command: a systemd unit, a shell alias, a nix run invocation. The home-manager module already spells it out and needs nothing from you. And kolu web --host is now kolu web --bind. --host means “which machine’s terminals to talk to” on every other subcommand, and one name cannot mean two things — the CLI itself now refuses to build with that collision — so the web server’s address to listen on took the clearer name. --port, --tls, --tls-cert, --tls-key and --verbose are untouched. If you run kolu behind a proxy or on a LAN address, kolu web --bind 0.0.0.0 --port 7317 is the new spelling.

MCP

  • AddedAn agent can look at a terminal, not just read it1 PR
    The MCP face could only ever hand an agent a terminal’s characters. But a terminal says a great deal characters do not carry: colour is how a test run says pass from fail and how a diff says added from removed, box drawing is what makes a TUI a layout rather than a wall of punctuation, and a highlighted row is what says this one is selected. Flattened to text, all of it is gone — so an agent driving another agent through kolu was reading the transcript of a picture it could never see. The new screen_image tool renders a terminal’s screen as a PNG and hands it back as an image, so a model looks at the same thing you would: the terminal’s own theme, its colours, its frames, its spinner. Ask for the visible screen (the default), or the last N rows with lines. screen_text is still the right first read and has not changed — plain characters are far cheaper to think with and enough for did the command finish; reach for the picture when the answer is genuinely visual. It is the same renderer behind the screenshot you copy from a tile, so the agent’s picture and yours are the same picture. Wide CJK text and emoji come out as empty boxes — the fonts that cover them would add tens of megabytes to what every host downloads.
  • AddedA supervising agent can be told again about a terminal it ignored3 PRs
    watch_next reports a terminal once, when it settles — so an agent that was busy when the report landed lost that terminal entirely, and one that restarted came back to a queue that answered “nothing owed” while a worker had been idle for an hour. watch_open takes optional parameters that turn the subscription from a set of one-off edges into a level you cannot miss: states (which agent states to report — defaults to the two that need somebody), heldForMs (report a terminal only once it has held that state that long, so an agent handed more work inside the window is never reported), and nagMs (re-report it every interval it keeps holding — spell it as a string and a count after the slash caps it, "30m/3": three reminders past the first report, then quiet until the state changes). ignoreSelf mutes the terminal the MCP server is running inside (refused if the transport cannot identify the caller, rather than guessed), and ignoreIds mutes known terminals fail-open — a stale id costs nothing, and every new terminal is still watched, which is why this is the mute and ids is the exception. Opening a subscription that way also hands over the currently-matching set first, so reopening after a restart finds what is standing rather than only what happens next. Events from such a subscription say which of the three they are: snapshot, transition, or nag. These are the same knobs as kolu watch’s new flags, served by one implementation, so a shell and a coding agent cannot be told different things.
  • Heads-up`lifecycle_create` now requires a `placement`1 PR
    An agent opening a terminal over MCP must now say where it goes. lifecycle_create takes a required placement{"kind":"toplevel"} for a tile of its own, or {"kind":"child-of","parentId":"…"} to open it as a split inside that terminal — and a call that omits it is refused with a sentence naming both spellings, before anything is created. The old optional parentId field is gone rather than aliased: a call still sending it flat is refused too, instead of quietly landing a top-level terminal. This is a break for any agent harness that opened terminals without mentioning placement, and it is deliberate. A terminal with a parent is drawn inside that parent’s tile and the Dock reads the same edge as who works for whom — so an agent that never states it is not asking for top level, it just never thought about it, and a fleet of workers that should have been splits arrives as a row of unrelated tiles with nothing failing to tell you. Scripted callers are exactly the ones who never notice. The tool schema now advertises placement as its one required property, blurbed with the same sentence the refusal carries, so a model reading the tool definition learns the rule before it makes the call. kolu create and padi-tui create take the same rule as a --toplevel / --parent <id> flag pair.
  • AddedAn agent can create a terminal in a fresh worktree — one MCP call, no CLI detour1 PR
    lifecycle_create used to be the lesser sibling of kolu create: it could open a terminal in a directory, but it could not cut a git worktree — so an agent that wanted a worker in a fresh branch had to open a terminal just to type kolu create --toplevel --repo … --worktree … into it, then fish the id back off the screen. The verb is now complete: pass repo + worktree and the worktree is cut at <repo>/.worktrees/<name> (by the same daemon-side machinery every other kolu surface uses) with the terminal opening in it, pass run and a first command line is typed at its shell prompt and submitted — the whole kolu create --toplevel --repo … --worktree … -- <cmd> in one call whose answer carries the id, the worktree’s path and branch, and what was typed. Two things deliberately did not change. The terminal still always gets the rc-hooked shell with the daemon’s own environment — there is still no way to ask this face for a raw process with caller-supplied argv or env, and run is typed input at a prompt, exactly like the CLI’s -- <argv>. And a create that fails after the worktree or terminal already exists refuses with the survivors named as data (stopped-partway, listing what landed and how to remove it) rather than rolling anything back or reporting a bare failure over resources you now own. The directory rules are the CLI’s, refused as data too: cwd and worktree exclude each other, repo only means something with worktree — and over MCP, worktree requires repo, because the server may be fronting another machine where “your current directory” names a path on the wrong host.
  • AddedAn app can ask kolu whether this machine has a usable kolu, instead of guessing1 PR
    This one is for anyone building an app that hands its own coding agent kolu’s terminals by spawning kolu mcp — nothing about using kolu changes. Deciding whether to do that turns out to be harder than looking for kolu on the PATH, in two ways that both bit real apps. A kolu terminal puts its own bundled copy of kolu ahead of yours, and one of those was an older build that answered with the same version number while missing most of its verbs — so a path proves nothing. And kolu mcp will happily complete its handshake, list every tool and list every resource with no workspace behind it at all — so a successful connection proves nothing either; every call made afterwards simply fails. The new @kolu/detect package answers the real question by asking: it resolves the executable, starts it, and asks it to read a piece of information only a running workspace has. An answer is proof of both halves at once, and what comes back is the exact path that answered — the one to spawn, rather than the bare word, which could resolve to a different build a second time. A “no” says which no it was: nothing by that name, a file that would not start, a kolu that hung up, one that never replied, or one that replied with its own reason for refusing. Kolu deliberately stops there and reports rather than editorialises — whether a missing kolu is worth mentioning to a person depends on things kolu cannot see, so the wording stays with whoever draws the screen.
  • AddedAn agent over MCP can ask “is the turn really over, and what did it say?” in one call1 PR
    The two waiting tools — wait_outputSettled and wait_agentState — each take two new options, and both exist because of what happens between calls. settledMs adds “…and the output has been quiet this long” to the wait, as an and rather than a second wait: bytes still arriving keep it open, and an agent that goes back to work re-enters it. That is what tells a genuinely finished agent from one whose main loop ended its turn while a background subagent is still running and still printing — the second reads as done within milliseconds, and acting on it interrupts real work. screenTail: N hands back the last N screen lines on the signal itself, read while the wait is still watching, so nothing can move between the signal and the read; a separate screen_text afterwards can only ever show a screen that has already moved on. Together — wait_agentState with until: ["awaiting","waiting"], settledMs: 15000, screenTail: 40 — they are the same protocol kolu debrief runs from a shell, now available to an agent driving over MCP, which is the path most of them take first. Existing calls are unaffected: both options are optional, and a wait that doesn’t ask for a screen gets back exactly the frame it always did.
  • Changed`kolu mcp` exits when no padi is reachable1 PR
    kolu mcp used to complete the MCP handshake and advertise its full tool list even when nothing was listening for it — every tool then failed one by one, and a consumer that wanted to know “is kolu usable on this host?” had to reimplement padi socket discovery itself. The process now probes the dialled padi before the handshake: if none answers, it writes an honest line to stderr and exits non-zero. Spawn-and-check-exit is therefore a valid probe. Mid-session, a padi that restarts still heals without killing the MCP face (the restart announcement path is unchanged).
  • FixedRestarting kolu no longer makes your agent give up on MCP1 PR
    kolu’s background service restarts routinely — an upgrade does it — and the restart deliberately keeps your terminals running, so nothing else notices. kolu mcp came through it alive too, but the first thing your agent asked for afterwards always failed, and everything after that worked. The message it failed with talked about a closed stdio pipe and a peer process that had exited, which is what your agent’s connection to kolu mcp looks like when kolu mcp itself dies — so agents read it as exactly that, stopped calling, and spent the rest of the session driving terminals the long way round. One retry would have worked. The cause was that nothing told kolu mcp the restart had happened: it found out by sending a request into the old connection and watching it fail, which cost that request every time. kolu announces the restart now, so the stale connection is dropped the moment it dies and the next request opens a fresh one — a restart costs nothing, however long your agent was idle across it. If one ever does land mid-request, that request now fails saying the connection dropped, that kolu mcp is still running, and that retrying re-opens it. Nothing is queued or re-sent on your behalf — though note that a call cut off mid-flight has an unknown outcome rather than a safely-undone one: the work may have been done and only the reply lost, so a mutating tool is worth confirming against the terminal roster before you repeat it. Pointing kolu mcp at another machine with --host is not covered yet: that connection doesn’t carry the announcement through, so a remote restart still costs the first call after it.
  • AddedAn agent can subscribe once instead of watching each terminal1 PR
    An agent driving several terminals used to need one wait per terminal, kept re-armed by hand, and anything that happened in the gaps was invisible: a worker that finished while nobody was looking just waited, its report sitting on a screen no one read. There are now three tools that replace the whole re-arm-a-wait-per-worker dance: watch_open starts watching (everything, or a named few), watch_next blocks until any watched terminal needs you and hands over everything that happened since you last asked, and watch_close stops. The property that matters is what happens while your agent isn’t asking: those events are kept for it, so the time between two calls stops being a blind spot. The queue lives in kolu’s background service rather than in the MCP connection, so it also survives your agent restarting — reopen with the same name and you pick up where you left off, rather than starting empty and never learning what you missed. Each event says which terminal and why: it is asking for input, it finished its turn, or it is gone. That last one matters as much as the others — a terminal that stops existing is now reported, so a supervisor waiting on a worker that has exited (or whose id was retired by a restart of the terminal service) is told, instead of waiting for a report that can never come. A batch is kept until your agent confirms it arrived, so a reply lost to a timeout costs a repeat rather than a missed report. Two honest limits, both of which say so rather than going quiet: a queue holds the most recent 512 events, and a batch that overflowed comes back with a count of what was dropped; and restarting kolu’s background service itself clears subscriptions, after which the next call fails naming the subscription so your agent re-opens it. Silence is the one answer never given, because silence is exactly what a calm workspace looks like.
  • ChangedA flag-less `kolu mcp` picks this machine's kolu instead of refusing to choose1 PR
    Running a development or test kolu beside your everyday one is ordinary — and it used to stop every flag-less kolu command, kolu mcp included, with more than one padi daemon is running on this host — set $PADI_SOCKET to pick one. Reasonable when you are at a shell and can answer it. Fatal when the thing spawning kolu mcp is a background service: there is nobody there to type an export, so an agent session that should have had kolu’s tools silently got none — while the kolu it wanted was up and healthy the whole time. The choice was never really a choice. A development or test kolu is pinned to a state directory you named explicitly, so only one of the running daemons keeps its state where a plain kolu looks ($KOLU_PADI_STATE_DIR, else ~/.local/state/padi), and that one is now simply picked. A single running kolu is still used exactly as before, whatever its state directory. The refusal survives for the case that is a real tie — several kolus running and none of them the everyday one — and it now names the state directory it looked for, so you can tell kolu is down from none of these is yours, and lists what each candidate serves rather than sixteen characters of socket path. One deliberate non-shortcut: if your everyday kolu is running an older build than the kolu you invoked, it is still the one chosen, and you get the honest upgrade message — never a quiet switch to a development daemon you did not mean, which would drive another workspace’s terminals. padi-tui kept its own copy of these refusals and drifted from the shared one; it now uses the same words, which means it finally tells you that $PADI_SOCKET picks a daemon, and that no kolu is running when none is — a case its copy skipped, leaving it to fail against a socket nothing was listening on.
  • ChangedEvery MCP answer now arrives as data too, and a refused send says which rule it broke1 PR
    Tools have always answered your agent with a block of text it had to read back apart. Every answer from kolu mcp now also arrives as structured content — the same value, in the machine-readable field MCP defines for it — so an agent acts on a field instead of re-parsing prose. The half that was genuinely missing is the refusal: lifecycle_sendInput refuses four things on purpose (text and a key in one call, an unknown key name, an empty text, nothing at all), each recovered from differently, and telling them apart used to mean matching the English sentence. Each now names itself — text-and-key, key-refused (carrying the spelling that was rejected), text-refused, no-input — beside the same sentence as before, which is unchanged and still what the model reads. Five tools also carry a display name now — screen_text, lifecycle_sendInput, the two waits and watch_next — so a host that shows one lists “Send input to a terminal” instead of lifecycle_sendInput.

Notifications & Attention

  • FixedAttention alerts you had switched off stay off after upgrading1 PR
    An install that had switched Attention alerts off — the setting was called activity alerts until it was renamed — was silently switched back on by the settings upgrade: one step of the upgrade filled in today’s defaults, attention alerts on among them, and a later step took that filled-in value as evidence your choice had already been carried over, and dropped the remembered one. The remembered choice now always wins, so an install coming here straight from kolu 1.0.0 keeps it however old it is. One honest limit: an install that already went through the buggy upgrade — anything that has already run kolu 2.0.0 or later — was flipped back then, and kolu does not put it right for you. If the upgrade happened to leave a backup beside your config, the old value is in it — but nothing reads it back, and not every install got one: if alerts came back on for you, switch them off once more and the setting now sticks.

Surface

  • Heads-upAn MCP endpoint and a CLI can now serve a whole set of surfaces, and follow it as it changes1 PR
    For anyone building on @kolu/surface-mcp or @kolu/surface-cli; nothing about kolu itself changes. Everywhere else the framework composes on one shape — a bare core surface beside a keyed set of siblings, the roster changing while the process runs: that is what serving a bundle takes, what a browser connection takes, and what a per-face allowlist takes. The two projecting faces did not. Each took one surface, one allowlist and one client, resolved once at construction, and never told a host the list could change. So an app whose surface really is a set — one per tenant, per plugin, per open document — had to put something in between: a hand-curated flat surface that copies members out of each one, plus its own book of which member currently exists, rewritten by hand every time the roster moved. Both faces take the bundle now. serveSurfaceAsMcp({ core, surfaces, client, tools }) and surfaceCommands({ core, surfaces, verbs, … }) each take one allowlist per surface, checked against that surface’s own spec, and compose the names by prefix rather than by merge: a sibling’s key is a segment of its URIs and of its tool names (surface://collections/tenantA/rows, tenantA_ops_run), the core keeps the bare spellings it always had, and two siblings exposing the same member key are disjoint by construction rather than because a merge happened to keep both. On a shell the same composition is spelled in argv’s own separator — the sibling key is the first word (app tenantA ops_run), the way a subcommand already reads — while list stays at the top and answers for the whole bundle. One thing to re-read if you script kolu surface list --json: its resources rows are now one per ADDRESS rather than one per member, so a collection appears twice — get terminals for an item and keys terminals for the key set — with the member itself kept as its own field. The old rows named something no caller could type. Hand-authored verbs can now belong to a sibling and leave with it, taking its segment exactly as a generated name does — a sibling’s verb word is relative to its row, and putting the row in front of it is what composition is for — and every verb is handed the client of the thing it was declared on. And the MCP face follows the roster in place: reroster(surfaces) replaces the sibling map whole, re-runs every refusal the first boot was held to (so a roster the gate would refuse leaves the endpoint exactly as it was), ends any subscription the new roster cannot serve, and sends the two list_changed notifications — which is why both listChanged capabilities are advertised from the first handshake. A call to a name a past roster served and this one does not is refused by name — and says which of the two retirements it was, the sibling having left or the sibling still standing and no longer exposing it — because an agent holding a tool list from before the move made a reasonable call against a name that was real, and “unknown” tells it to doubt itself instead of to re-read the list. Upgrading: a single-surface face is the degenerate bundle. { surface, expose } becomes core: { surface, expose }, and the client factory hands back { core: client } instead of the bare client — a bundle-root verb receives that bundle, so a handler that reached client.surface.x reaches client.core.surface.x.
  • AddedA surface app's listener now serves the generation that is live at each accept1 PR
    For anyone serving a @kolu/surface-app whose set of sibling surfaces can change while the port is bound — a plugin host that turns a surface on without restarting. The listener used to read group, handlers and expose once, when the port bound, and build every later connection over that snapshot: a sibling that arrived after listen was invisible to every socket the listener would ever accept, silently, and the only way out was to hand-roll the accept loop. Those three are now one generation: pass { group, handlers } and it is the generation written at the call, or { live: () => ({ group, handlers, expose }) } and it is re-read at each accept, as a pair. A socket accepted after a mount is indistinguishable from one accepted on a boot that already had that sibling. A connection that was already open keeps the generation it was built over until the client redials, which is the honest half of the wire: an RpcServer is baked at construction, and a drop still reaches it. Existing callers that pass values do not change. The unix-socket listener takes the same source.
  • AddedA served bundle can gain and lose a sibling surface while it is running2 PRs
    For anyone building on @kolu/surface; nothing about kolu itself changes. A composed wire — an app’s own root surface with other surfaces mounted beside it as siblings — was assembled once, at boot, and that was that. An app whose set of siblings is a runtime fact (a plugin host, where enabling a plugin should make its surface appear) had only one move: re-implement the whole map. That quietly forks every surviving sibling — new stores, new channels, new handlers — so a browser already connected keeps talking to the previous copy and its writes land somewhere nobody reads. implementRootedSurfaces serves that shape properly: mount(key, surface, deps) adds a sibling live and hands back the registration’s own undo, and mounting walks only the arriving surface, so every survivor keeps the state and the running sources it already had. Dropping one reaches connections that are already open: a call at a dropped member is refused by name, and a live subscription on it fails loudly rather than hanging on a producer nobody drives any more — and a key that is dropped and re-used later can never route a stale connection into the new sibling’s members. The root’s own tags do not move, whatever the roster does, so an MCP client’s URIs and every tag a consumer wrote address the same words as before. On the browser side, connectSurfaces grows conn.redial(surfaces), and it follows the roster in place. A new wire is dialled underneath — Effect RPC fixes a wire’s member set when the wire is dialled, at both ends, so that part is unavoidable — but the connection your page holds is not replaced: clients, core, transport, link, readout and health keep their identity, a sibling that arrives appears on the client map you already hold, one that leaves has its own client refuse in words on the next call, and every standing subscription re-opens itself against the new wire. So a page does not rebuild its tree to follow a roster change, and the local state a rebuild used to discard — a half-typed editor, an open pane, a scroll position — simply stays. redial re-uses every option that connection was dialled with, keeps the root, raises anything the new roster earns before it dials, and brings the new wire up before giving up the old one, so a failed redial leaves the working wire alone and the connection on its current roster.
  • AddedThe pieces a repo consuming kolu was copying by hand are exported now1 PR
    Nothing about kolu itself changes; this is for a repo that builds on kolu’s packages. Building on the Dock row and the padi contract meant re-typing pieces kolu already knew — and the copies drifted, silently, in the places copies do. The row’s closed-set guards shipped without their defaults, so a consumer wrote "idle" and "shell" itself and then had no way to know a fallback had fired: narrowRowVocab pairs each guard with kolu’s own answer and hands back the wire’s word beside the mark it drew. The recency cell’s words were the app’s, so a second spelling of them said “7m” where the Dock says “5m ago” and rendered an empty violet pill where the Dock renders a dash: the compact-duration ladder and both phrases now live in @kolu/terminal-vocab/duration, and recencyText(mode, at, now) says them — clock still yours, cadence still yours. resolveTerminalId (“any unique prefix”) and tailLines were stranded in the daemon package a contract consumer exists not to install, and are now zero-import leaves on @kolu/padi-client. chunkPattern names a split bundle the way buildSurfaceClient emits it, off the same template the builder reads, because a hand-built copy of that rule drifts quietly — it stops matching and the caller concludes the page never asked. DEFAULT_FONT_SIZE joins FONT_FAMILY in terminal-themes, where painting a terminal already looks. Two packaging fixes come with them: @kolu/detect and terminal-themes are declared vendorable, so the derived hydration closure can be adopted at all; and consumer-closure.json now carries the revision of every pinned graft, with consumer.nix refusing a consumer whose own pin disagrees — which retires the per-repo shell script that was holding those two pins in step by reading a file kolu never promised to keep. Finally, @kolu/solid-dockrow/all.css imports its own prerequisites: the stylesheet order that used to be a README instruction — and whose violation renders a row with no layout at all — is an invariant now, and a consumer’s whole styling contract is two lines.
  • AddedA tool can answer with a picture1 PR
    An MCP tool served through @kolu/surface-mcp could only answer in words. A tool declares render — how its success value becomes content — and one whose answer is genuinely visual returns an MCP image block via okImage, so the model looks at the picture instead of a base64 string it cannot see. The bytes travel exactly once, and a structured arm that repeats the payload is documented as the mistake it is: a second copy is a megabyte spent out of the model’s context for a reader that does not exist. render is declared per tool rather than sniffed per result, so a handler cannot change its own content type call to call. kolu’s own screen_image is the first consumer.
  • AddedA surface can now be told when the last reader of a key lets go1 PR
    For anyone serving a collection with @kolu/surface: the framework has always told you when a reader opens a key — a per-key read is a subscription, and your readOne is where you hear one arrive. It never told you when the last one let go. So a server that keeps something expensive alive per key — a file body held in memory for whoever has that page open, a live log being followed for whoever is watching that node — had to guess: keep the sixteen most recent, or keep everything until the run ends. A guess like that is wrong in both directions at once, and the wrong direction that hurts is a page going silent while someone is still reading it. Collections take an optional holders now. A reader holds the key for the lifetime of its subscription, and the framework already knows exactly what that lifetime is: the subscription’s own scope, which closes when the tab navigates away, the socket drops, the process is torn down, or a one-shot reader takes its answer and leaves. Two readers are two holds; whichever leaves first releases only its own. Nothing new crosses the wire — deliberately, because a “I’m done with this key” message a reader had to send is a promise a closed tab cannot keep, and closed tabs are exactly the case. Collections that don’t ask for it are served the identical stream they were, with no wrapper in the way.
  • ChangedA collection's live updates now cost what the update is, not what the collection is1 PR
    For anyone building on @kolu/surface: a collection can opt into a batched feed — one message per tick saying which entries changed and which went away, instead of one message per key. The server has always sent exactly that. The client half then threw the message away: it copied the whole dictionary of entries, folded the change into the copy, and handed the app only the dictionary — which a second pass then walked end to end to rediscover the very entries the message had just named. Two passes over everything, for a change that named three things. Now the client applies the message where it points: an entry the message names is written, an entry it does not name is not touched, and only the parts of your UI reading a changed entry wake up. Measured on a 2,000-entry collection with 200 rows on screen, a tick that changes one entry is 937x cheaper; a tick where five entries appear and five go away — the steady state of something like a process table — 91x; a tick that changes a hundred 22x; and a tick that genuinely changes all two thousand still 1.7x. The gain scales with how much of the collection a tick actually touches. A reconnect, which arrives as a fresh full snapshot, stays a visual no-op: entries whose content is unchanged do not repaint.
  • AddedAn app can now read the change message itself, not just the result2 PRs
    Also for @kolu/surface consumers: if what you keep is not a dictionary of entries — a search index, a set of patched documents, a running total — the dictionary the client maintains is not your answer, and rebuilding “what changed” out of it means redoing work the message already did. .fold({ init, step }) on a batched collection hands you the message: init answers a full snapshot (the first one, every reconnect, and the one you are seeded with if you register late) — always handing you the entries the client itself holds, the very objects a per-key read returns, so what you are seeded with never depends on when you registered; step answers each change message, and what you return is your own value, read as a plain accessor. The change message reaches you verbatim — including a removal of a key you never saw, which the server really does produce when an entry is born and dies inside one tick, so your step has to tolerate it. The hook behind it, useCollectionDeltas, is public now too, for the case that watches a collection outside the connection-health gate.
  • AddedA surface can open a verb to one of its faces and not another1 PR
    For anyone building on @kolu/surface: one surface is usually served over several connections at once — a browser tab, a local unix socket, an MCP adapter a coding agent drives — and those do not carry the same trust. Only the agent-facing one could be curated; the other two served whatever the surface had. So a verb was reachable from everywhere or from nowhere, and an app that wanted its writes to live on the surface had to keep a second, hand-written path beside it purely to keep an idle browser tab out of them. serveSurfaceApp and serveOverUnixSocket now take the same expose allowlist the MCP adapter takes, applied per face: name what a face may reach, and everything else is refused there while another face still serves it. Naming a primitive grants its reads and withholds its writes, which is the ordinary shape — the tab reads the cell, the socket writes it. Leave expose off and a face serves the whole surface exactly as it did before; declare one and a mistyped key is a compile error where the map is written, while a policy built against the wrong (or only part of the) surface stops the server as the face binds rather than quietly narrowing it to nothing. The probes a client needs to stay connected are never gated. Reference.
  • AddedA connection can now be told who is on the other end of it3 PRs
    For anyone building an app on @kolu/surface-app; nothing about kolu itself changes. A surface app’s live wire is one WebSocket, so its one request is the upgrade — which makes a header a reverse proxy stamps there (a tailscale serve login, a forwarded address) the only claim about who is calling that a connection can carry. The listener owns that upgrade and passed none of it on, so an app that wanted the viewer’s identity had to keep a plain HTTP endpoint beside the wire and have each tab fetch it separately. serveSurfaceApp now takes upgradeHeaders — the header names this app wants — and hands their values to the services layer it already builds per connection, so every call on that socket is served by a stack that knows whose it is. It is an allowlist and it is empty by default: the listener holds the whole Cookie and Authorization of every upgrade, and naming a header is the app saying it trusts the proxy that writes it, which is a claim only the app can make — sound only where that proxy owns the header, meaning it strips or overwrites any copy a client sent, because a client dialling the listener directly can send one too. Reading a name the allowlist does not carry is a compile error rather than a value that is undefined forever, and a name no request could carry — one wire header named twice, or set-cookie, whose commas no joined string survives — is refused rather than read as a header the proxy never sends. A repeated header is the one comma-joined string node already folded (1.1.1.1, 2.2.2.2), not a list this seam joins. The list also does not have to be fixed when the port binds. An app that turns its identity part on at runtime does not know its headers at startup, and a serve that came up without that part used to answer its own procedures the moment it was switched on while every socket — open and new — stayed anonymous until a restart. Pass a thunk (upgradeHeaders: (): ReadonlyArray<"Tailscale-User-Login"> => identity().headers — annotated, because the names are the keys and a plain string[] makes every header read compile again) and each accept reads the list that is live then, so a connection accepted after the switch is indistinguishable from one accepted on a boot that already had it. A fixed array is still checked once, at the bind, and a bad name there stops the server — that array is your composition root, so there is nothing else to blame. A live list that cannot be served belongs to whichever part offered it, so it refuses itself rather than the socket: the connection is served with no named headers, reading as nobody, and the fault is reported on the listener’s event sink instead of taking every other connection down with it. That covers a bad name and a thunk that simply threw alike, because what decides it is the blast radius rather than the cause. checkUpgradeHeaders is exported so an app can fail the offending part loudly where it mints the list. One thing to change on upgrade: the connection handed to services no longer carries node’s raw request object — name the headers you need and read them off connection.headers, with the direct peer at connection.remoteAddress. Reference.
  • ChangedAn app built with the surface toolkit now ships a dist its own server can fully serve3 PRs
    This one is for anyone building an app on @kolu/surface-app rather than using kolu; nothing about kolu itself changes. The server half has always offered a compressed copy of a page’s assets when the browser asks for one — it just needed that copy to exist on disk, and the build half never wrote it. So every app that wanted the saving bolted its own compression step onto the end of the build, each wrote the two encodings it thought of, and none wrote the third one the server actually prefers — meaning the preferred encoding was never once served, by anyone. buildSurfaceClient now writes all of them itself, from the same list the server reads, so there is no second step to remember and no way for the two to disagree. It also clears the previous build out of the asset directory instead of letting it pile up, and skips recompressing anything that did not change, so a rebuild costs what changed rather than what accumulated. And it builds with code splitting on: a import() in your own code becomes a separately fetched chunk, where before it was quietly folded back into the main bundle — deferred in when it ran, but not in what got downloaded — which is why apps that wanted a heavy module off the first paint had to run a second build of their own. Splitting a bundle also has a cost of its own, and the build now pays it back: splitting leaves the main file importing a smaller shared one, which the browser cannot even ask for until the main file has arrived and been read — so a first visit waited out two trips to the server where one would do. The page now names those shared files up front, and the browser fetches them alongside the main one. Only the files needed immediately are named: anything your import() deferred stays deferred, which is the whole point of having written it. One thing about that dist is now yours to say: assetPrefix moves the hashed asset directory somewhere other than /assets/. It matters if the root of your app’s URL space is not the app’s to spend — an app serving somebody else’s folder of files there has pages of their own under /assets/, and a request for a hashed asset the build did not produce is refused outright rather than handed the page shell, so those files had no page at all. The server half has always taken that prefix; the build half had no way to write there, which made the setting unusable. Pass the same string to both and there is nothing left to keep in step — the prefix is the directory the build writes into, so the page’s links and the files on disk cannot drift apart. Say nothing and you get /assets/, exactly as before.
  • FixedAn oversize message is refused at the wire instead of being buffered first1 PR
    Everything a kolu tab says to the server rides one WebSocket, and a single message on it has a published ceiling of 16 MiB — anything larger is refused and the connection reconnects, which is why a dropped file travels in pieces rather than whole. kolu’s own server never told its WebSocket layer about that ceiling, so it used that library’s default of 100 MiB: a message six times larger than kolu could ever act on was read into memory in full before the layer above it rejected it. It is now refused as it arrives. Nothing you could do in the app reached that size, so this is a limit on what a misbehaving or hostile client can make the server hold, not a change to anything that worked. The cause was that kolu’s server hand-wrote its own listener rather than using the framework’s: it now uses serveSurfaceApp, which reads the ceiling from the one constant that publishes it, so no app can set a different one. Library consumers get one behaviour change with it: the listener closes its listening socket last during shutdown, which is what stops a SIGINT’d server hanging forever under Bun.
  • ChangedThe wire underneath kolu was rebuilt on Effect3 PRs
    Everything kolu, kaval and padi say to each other travels over @kolu/surface, and that framework has been rebuilt on Effect — pinned at 4.0.0-rc.112, which is the version to install alongside it if you build on the surface packages yourself: Effect RPC in place of oRPC for the transport, and Effect Schema in place of zod for every wire type. This is plumbing you should never see. What it buys is a wire that can carry a typed failure end to end — a procedure now declares its errors as classes rather than a map of magic codes, and far more procedures declare their errors at all, so many failures that used to reach you as “internal server error” and a sentence to read now arrive as the thing the server actually raised, intact across every hop. Cancellation stopped being a signal each call site had to remember to thread and became a property of the call itself, which is how a whole class of leak stops being easy to write. The payload shapes that carry anything stored, saved or scripted were pinned byte-for-byte against the old ones before the swap, so nothing you have stored, saved or scripted changed shape. The one new property you could in principle bump into is a ceiling on any single message, so a file dropped onto a terminal now travels in bounded pieces and is reassembled on the other side rather than crossing whole — the 50 MB limit on a drop is unchanged, the size limit is checked against the whole file as the pieces land, and an upload that crosses that limit is deleted rather than left half-written on disk for an agent to read as if it were complete. The rest of kolu has now followed the wire. The daemons’ timers, retries and races, the browser’s async work, the HTTP server that hands you the app, the command-line tools, the reactive graph the daemons publish from — all of it runs on the same foundation instead of hand-rolled promises, abort signals and cleanup flags, and the second web framework that used to sit under the HTTP half is gone from kolu’s own code. The new reactive engine runs each update pass on the writer’s own stack, so every callback kolu hands it is bracketed: one live view failing to take an update is logged and skipped while the rest of that round’s views keep flowing. One real fix surfaced on the way: a file descriptor opened for a video could outlive a seek you abandoned before the first byte arrived; cancelling now closes it, because cleanup is what the primitive means rather than what a call site remembered. One diagnostic sharpening rides along, and it is a wording change rather than a behaviour one: when kolu drops a connection to a machine whose far end has stopped answering a keep-alive, the line it writes in that machine’s connection log used to name a socket that had timed out waiting to open and a program that had exited — neither of which had happened on a link that was hours old and serving requests seconds earlier. It read as indistinguishable from a machine that is genuinely off, which is the one thing that line has to get right. It now names the unanswered keep-alive instead. kaval-tui attach, which prints the same death to a terminal rather than to a log, goes one further and says the daemon may still be running and merely too busy. What kolu does is unchanged either way — it drops the connection and reconnects, exactly as before.
  • Heads-upDaemons from before this release must be restarted — and kolu handles it1 PR
    A version handshake happens inside the protocol being spoken, so two processes that no longer speak the same protocol cannot negotiate at all: they cannot even tell each other what version they are. That makes this release a flag day for the long-lived daemons — kaval (which owns your terminals) and padi (which owns each host) — and kolu is built to meet one rather than hang on it. When it finds a daemon left running from before the upgrade, it recognises the situation from the wire itself: either the old daemon speaks first in a framing this build cannot read, or — far more likely, and the case actually measured against the real previous release — it sits waiting for a greeting that will never come and simply says nothing. Eight seconds of silence from a process that has accepted the connection is not slowness; a daemon of this build answers the protocol’s own ping — which kolu’s side of the protocol sends every five seconds — below its handlers, even when it is busy. Either way kolu names it as a different protocol epoch rather than mistaking it for a version it could compare. What happens next is each daemon’s own declared policy: kaval is restarted, because its terminals do not survive a broken wire anyway; padi is taken over — stopped and replaced by this build’s daemon, with nothing for you to run by hand. It is asked to stop first, with a generous window for its own shutdown to finish (a host holding gigabytes of terminal scrollback has taken minutes to tear down), and only forced after that window passes; either way your session is seeded back from disk, because padi saves continuously and takes a final snapshot on its way out. A remote host converges the same way and just as hands-off: the daemon settles itself over ssh before it serves — recognising a from-before-the-upgrade daemon there, stopping it, and starting a current one in its place, keeping your terminals, with no login and no manual restart — and kolu no longer attaches to a host that has not said it is ready, so one that cannot settle reads “This host runs a kolu from before the protocol change” with what to do about it. Before this release such a host sat in a non-terminal still retrying state that kolu retried indefinitely, with a connect log indistinguishable from a machine that was simply switched off. It never acts on a process it has not proven is its own: the gate file must be kolu’s, the pid it names must check out, and that pid is re-checked in the instant before the signal — so a stranger holding the socket, or a daemon that was replaced while kolu was deciding, is refused and reported rather than signalled. The log line names what was stopped, why it was provably kolu’s, which signal ended it and how long the wait took. In practice the daemon contract versions move to 7.0 (kaval) and 5.1 (padi) — the major digit names the epoch, so two mutually unreadable builds can never report the same version string.
  • FixedA bulk add to a collection no longer storms its keys stream1 PR
    For anyone serving a collection with @kolu/surface: every key added used to broadcast the collection’s entire key list to every subscriber, and re-read the whole backing store to compose it — so adding two thousand entries in one go meant two thousand broadcasts of a list that grew to two thousand names, roughly two million names on the wire where two thousand would do, with a full store read behind each one. Membership changes that land together now flush as one broadcast carrying the final list, read from the store once. Each broadcast is still the complete list, arriving on the very next turn, so nothing about how a subscriber reads the stream changes — there are simply no longer thousands of intermediate re-sends to fold away. Single adds and removes behave exactly as before, and a value update on an existing key still broadcasts nothing.
  • AddedAn MCP server built on @kolu/surface-mcp can answer with data, refuse with data, and introduce itself1 PR
    Three additions for anyone re-exposing a surface to agents with @kolu/surface-mcp, all of them additive. Tool results carry structuredContent beside the prose — the same value twice, so the caller acts on the object while the model reads the sentence; a non-object answer travels under the same single value key a non-object input is already wrapped in. A refusal can carry detail: fail with the new ToolFailure(message, detail) and the agent gets an isError result whose structuredContent is your object — “these three children are not done” as a list rather than a sentence to parse. Every other failure stays message-only on purpose, since structuring whatever an error happens to hold would publish a stack trace into the agent’s data channel; what did improve is how those read, because a tagged error with an empty message used to reach the host as nothing at all, and a non-Error failure value as [object Object]. And instructions is now passed through to initialize — how an embedding app teaches an agent its domain before the first call, and a field the MCP SDK answers inside its own protocol layer, so no consumer could previously reach it. Bespoke tools also take an optional title for hosts that render a display name.
  • AddedAny @kolu/surface can now be driven from a shell2 PRs
    The new sibling package @kolu/surface-cli projects a @kolu/surface as command-line verbs, the way @kolu/surface-mcp projects it as MCP tools — the same surface, the same default-deny allowlist, the same hand-authored verb table, and the same flat verb names, so a verb cannot mean one thing to an agent and another to a terminal. Procedures become verbs (app git_commit --message "…"), cells and collections become readable members (app get load, app keys nodes, app watch nodes), and every verb also takes its whole input as JSON (--input '{…}', or --input - from stdin) for the cases a shell would fight you over. --help on the parent is a page a person can read rather than an alphabetical dump — a purpose line, the verbs grouped by what they do, an example each — with the layout here and the wording yours. What it gives a script is the part that is usually hand-rolled: stdout is data (one JSON value for a read, one compact ndjson line per frame when streamed, indented only on a terminal), stderr is prose, and the exit code distinguishes the four things that can happen — the verb refused (1), your command was wrong and never left the process (2), nothing is serving the endpoint (3), you interrupted it (130). A verb can carry a one-line summary for a human, and --json asks for the answer whole instead — the flag is the only thing that decides, so a command prints the same thing in a pipe, in a CI log and in front of a person. A host whose transport cannot push (a request/response door) declares endpoint.streaming: false, and then watch and --follow are simply not there, rather than parsing and always failing. A field that can be CLEARED — a <scalar> | null, which is how a removable field is usually spelled — takes the plain value a person would type and the word null to clear it, where it used to want the field’s own JSON (--desc '"the brass ones"'); a note whose text is literally null goes through --input, and the flag’s own help line says so. surfaceCommands hands back command values rather than running a program, so your binary mounts them beside its own faces and keeps its own run edge. See exposing a surface to a terminal.
  • AddedA remote dial can now say how long a dead ssh may go unnoticed1 PR
    For anyone dialling a host with @kolu/surface-remote; nothing about kolu itself changes. Every ssh this package opens declared its transport dead after about thirty seconds without an answer, and that number was baked in — one constant, no way for a caller to say otherwise. Thirty seconds is the right answer while someone is watching a host. It is a poor one for an unattended dial, whose ssh gets torn down because a loaded box took forty seconds to answer a keepalive probe, and the redial destroys work rather than repairing anything. sshConnector takes a keepalive: sshKeepalive(intervalS, countMax) now: how often ssh probes an idle connection and how many unanswered probes it tolerates, so a dial states its own limit (sshKeepalive(30, 10) waits five minutes before calling the transport dead) and the cost is symmetric and stated — a genuinely dead host keeps that dial parked for exactly as long before the session retries. It reaches every ssh a dial spawns, including the one Nix forks for a remote store, which no argv of ours can reach; and a policy that is not two positive whole numbers, or that adds up to more than an hour, is refused where you wrote it rather than quietly rounded into one you did not ask for. Leave it off and every existing dial behaves exactly as it did. Read it narrowly: it bounds one thing — how long a dead or half-open ssh takes to be noticed instead of parking forever on a half-open socket. It is the loosest of four independent limits on how long a link may be quiet and it moves none of the others, so a raised policy does not let a connected lane ride out a blip. The reference page lists all four, with their values, and which of them are tunable. One thing worth knowing if you run two policies against one host: the commands of a dial share a single multiplexed ssh connection, and OpenSSH takes the keepalive from whichever process opened it — so the shared connection is now kept separately per policy, and a five-minute dial can never silently inherit a thirty-second one. The visible cost is one extra warm connection per host per policy in use.

Release

v2.2.0

24changes

The release where splits grew up and ports opened doors. Every split — including a split of a split — now has its own row in the Dock, paints on the canvas, renders correctly the moment it opens, and resumes with the rest of your session. A Ports section in the Inspector shows what a terminal is serving, and clicking a port opens a door to it from any host — loopback, remote, either way you get the page. Remote hosts pull their weight: an agent on a remote box can drive kolu with nothing installed there, provisioning copies binaries instead of compiling, and a host that can’t connect or build now tells you exactly why. And kolu speaks one attention vocabulary everywhere it can reach you — violet means an agent needs you, amber means finished work you haven’t seen.

nix run github:juspay/kolu/v2.2.0
Added
6
Changed
4
Fixed
14

The Dock

  • AddedEvery split has a row in the Dock3 PRs
    Every split now appears as an indented entry beneath its parent terminal — including a plain shell, not only an agent. Click it and the keyboard lands in that pane; the parent stays highlighted as the active tile while the split highlights as the focused terminal. Every sub-entry carries the same status indicator as a top-level row — the shell identity glyph and activity motion for a plain shell, the agent brand mark and violet/amber attention wash when an agent is present — so shell and agent splits cannot drift. The old split-count chip is gone because the entries themselves are the inventory. A split of a split gets its own entry too, indented one notch further under the split it belongs to — and a split whose agent has only just started no longer blanks the Dock for a frame while its first activity timestamp catches up.
  • ChangedRepo identity you can tell apart — on the dock and beyond4 PRs
    With many repos open, the dock’s section cues — a 3px spine, a 7px swatch, and a 9% pastel header wash — all dissolved into one pale list. Each repo is now a card: thicker spine, solid monogram tile (first letter of the repo), a stronger sticky header wash, and a little air between sections. That monogram is a shared identity atom — the same letter, fill formula, and fleet hue also lead the command palette (monogram · repo name · branch colour · spine), session-restore groups, the inspector’s Work chips (repo and branch both match the dock), and the tile title bar (name stays the fleet pastel on the dark chrome, not app light-mode ink); the minimap tints each rect with a touch of repo colour. Attention (violet needs-you, amber unread) stays the loudest interrupt; host palette headers pick up a soft host-hue wash without competing.

The Canvas

  • ChangedTiles land, finish, work, and sleep like things in a place1 PR
    The canvas already painted agent state as motion on the border; that language is now a fuller place cue, not only a state pip. A new tile lands with a short brightness settle (once, on first open — not every maximize restore). A working agent wears the same double frame as the active tile (solid border + outer moat), with a calm rotating highlight on the outer rail instead of crawling dashes. Needs you is still the comet. A just-finished agent exhales into a soft held ring instead of sharing that comet, so a post-turn lull no longer reads as “blocked on you” from across the room. A sleeping tile folds — desaturated and dimmed on the whole card, not only the title bar. And New terminal now shows a live Will create card — monogram, fleet hue, worktree name, agent mark — so you see the dock row before you spawn it. Reduced motion keeps the static rings and skips the entrances and spins.

Inside a Tile

  • FixedA split of a split is visible on the canvas1 PR
    An agent or padi-tui create --parent can hang a terminal under another split — and that nested terminal used to be fully alive (roster, agent state, MCP) while never painting on the canvas, because the tile only ever showed one hop of children. The canvas now flattens every descendant of a tile into that tile’s existing tab strip, so a grandchild looks like any other split. The Dock shows the same terminals as the true tree — a split of a split is indented one notch further, under the split it belongs to. Closing a middle split re-homes its children under the root tile rather than dropping them. Nested parent links are preserved as-is — nothing rewrites saved workspaces. And a split whose parent is gone stays findable: the canvas, the Dock and the terminal switcher now agree on what counts as a tile, so a live terminal can no longer be missing from one of them.
  • FixedSplits render correctly the moment you open them1 PR
    Opening a split that was hidden when the page loaded could show a stale, garbled screen — long lines broken in half, the live prompt scrolled out of sight — and nudging the divider a pixel was the only thing that fixed it. It showed up most with a full-screen program: an agent, htop, btop. A terminal screen is drawn for one specific width and height, and a hidden pane has no size that can be measured, so it was being handed a screen drawn for the real size and painting it at a guessed 80×24. Revealing the pane then reflowed that damage rather than redrawing it, and nothing ever asked the program to repaint. A pane now waits until it has really been measured before asking for anything, and asks for the screen at its own size — so what arrives is always drawn for the pane that shows it. The same fix removes a long-standing wrap-artifact caveat when attaching from the command line.
  • FixedMany busy agents stop melting the browser tab1 PR
    A canvas full of agents used to keep every tile painting at full rate — each PTY chunk scheduled an xterm parse and a paint frame on the main thread, so Chrome Task Manager climbed with the Dock. Unfocused tiles now batch their terminal updates for a short window and land one paint instead of every intermediate frame; the tile you are looking at still updates live. Measured under multi-tile load: main-thread busy dropped substantially (about 30% on a same-recipe re-run; more under heavier flood), and the per-frame paint storm is gone.

Theming

  • AddedShuffle behaviour: Colourful1 PR
    Shuffle behaviour gains Colourful⌘⇧J and shuffled new terminals stay on saturated schemes and skip grey / monochrome tints, without forcing light or dark. Auto / Dark / Light / Random are unchanged.
  • FixedTerminals opened by agents follow your theme settings1 PR
    New terminal theme now governs every terminal, not just the ones you open yourself. A terminal created by an agent — through the MCP server or padi-tui create — used to arrive on the built-in default theme no matter what Inherit or Shuffle said, because the choice was made in the browser at the moment you pressed ⌘T, and nothing else went through that code. The decision now lives with the daemon that creates the terminal, so an agent-opened terminal copies the active terminal’s theme under Inherit and draws a properly distinct tint under Shuffle, exactly like yours — on your own machine and on remote hosts alike. Two smaller things follow. With Auto shuffle, a terminal created while no browser window is open follows the light/dark mode your browser last reported — the mode of the tab you were most recently in front of. And a split no longer has an unseen theme of its own picked for it: it renders with its parent tile’s theme, as it always visibly did, and hidden panes no longer count as neighbours when Shuffle spreads new tints apart.

Sessions, Sleep & Wake

  • FixedSplit agents resume with the rest of the session1 PR
    An agent running in a split (or any parented terminal) is resumed on session restore just like a top-level one. The restore card lists every saved terminal as its own row — no more collapsed “+N splits” line — and the host, not the browser, decides who is resumable, so a client filter can never drop a whole class of terminals again. A terminal that fails to restore keeps its resume token and surfaces a real error instead of being silently erased.

Padi

  • FixedA daemon that cannot be started now says who is in the way1 PR
    When kolu finds its daemon socket already taken by a stray process, it identifies the holder before doing anything about it — and if the operating system will not say who that is, kolu now says that, instead of reporting an unidentifiable process and leaving you to guess. The three possible answers are kept apart: nothing holds the socket, something holds it that this machine will not name, or the lookup itself could not run. Previously all three looked the same, which on macOS could turn a slow lookup into a misleading “nobody is here”. macOS boots also stop shelling out to lsof for this, which was the slowest step in the check.
  • FixedRestart a daemon across an upgrade without bricking1 PR
    The on-disk pid gate that says “this kaval / padi is already running” now carries the process start time as well as the pid, so a recycled pid after a crash or reboot is never mistaken for the old daemon. Readers stay pid-first tolerant: a gate written by yesterday’s one-field binary still yields a pid, and today’s two-field write still yields a pid to yesterday’s parseInt reader — so Restart never silently no-ops across an upgrade or a rollback. Start times compare with a ±2 s tolerance so clock skew does not invent a pid-reuse.
  • FixedA shutting-down kaval reaps every terminal it owns — deterministically1 PR
    Each terminal kaval spawns lives in its own process session, so nothing the operating system does when kaval dies is guaranteed to take the terminal along — and on macOS, a terminal killed in the first instants after its spawn could dodge the polite hang-up kaval sent on shutdown and sit orphaned for days (aged spawn-helper processes were found accumulating on a CI machine exactly this way). Shutdown now kills each terminal outright instead of asking nicely — the same choice the explicit close-a-terminal path has always made — so a daemon that goes away leaves nothing behind, on any platform.
  • FixedDeleting a workspace no longer strands its daemons1 PR
    padi and kaval deliberately outlive the kolu that spawned them — that is what keeps your terminals alive across restarts. But when the workspace itself was deleted (removing a git worktree that ran a dev kolu, or an ephemeral test dir), the daemons anchored to it had nothing left to serve and no way to notice: they sat idle forever, each holding a socket, its shells, and a few hundred MB — machines accumulated a dozen-plus ghost daemons this way. Every daemon now watches the directory that is its reason to exist and shuts itself down cleanly, within seconds, once that directory is verifiably gone — no cleanup command to remember, nothing to run by hand. And a kolu still running when its workspace vanishes now exits with a message naming what was deleted, instead of silently respawning a daemon into a recreated empty directory.

The Right Panel

  • AddedSee what a terminal is serving1 PR
    When something in a terminal starts listening on a TCP port, a Ports section appears in the Inspector listing it — the number and the program holding it — usually within a second. No more scrolling back through a wall of agent output hunting for a URL that went by ten minutes ago. It is not a guess at printed text: kolu reads the machine’s own socket table every few seconds (and immediately whenever the terminal produces output), and attributes each listener to the terminal whose processes hold it. So a port appears because something genuinely is listening, and it disappears when that stops. Splits count as the same terminal, which matters because a dev server is usually where you put it — in the split. A port bound to all interfaces on the machine running kolu already answers at the address you are viewing kolu at, so it simply opens in a new tab; a loopback-only port, or one on a remote host, needs a door — see Port forwarding below.
  • ChangedThe Inspector now leads with what needs you1 PR
    The Inspector used to be eight equally-grey label-and-value sections you read top to bottom, which meant the one fact you check constantly — is this agent waiting on me? — was a small grey pill in the middle of the panel, styled exactly like the name of your colour theme. It now opens with the agent’s status: what it’s working on, and a state chip in the colour that state wears everywhere else in kolu (violet when it’s your turn, rust while it’s working), so you read the answer off the colour before reading a word. Branch, repo, pull request and CI collapse into one row of chips beneath it — CI as a single ✓ 9 passed rollup — with the working directory shown once instead of twice. Everything you only occasionally need now folds away: the per-check list, the repo’s root and worktree paths, and the whole terminal-CLI section. The check list is the one that folds intelligently — nine passing checks stay behind their rollup, but a check that fails or is still running expands the list on its own with the exceptions sorted to the top, so you never open a list to find good news and never miss bad news because it was hidden. The Attach section is rebuilt around the shape its commands actually have: pick a pane and a verb (attach · snapshot · send) and copy the one command line, rather than scrolling six near-identical ones that repeated the same long socket path. And the Compose box rests as a single line, growing when you click into it, instead of opening as an empty six-row box at the top of the panel.

The Code Tab

  • FixedGit status stops going quietly stale1 PR
    Under enough load, the Code tab could stop noticing that files had changed — permanently. The tree kept rendering, the file list stayed correct, and the git status decorations simply froze at whatever they said when you opened the tab: edit a file and nothing turned modified, and no amount of waiting or further editing brought it back. Only closing and reopening the repo could. The cause was a file watcher that had been born deaf. kolu rebuilds a repo’s watcher whenever the last thing reading it goes away and something new arrives — and the watching library shares one handle per directory behind the scenes, doing its setup and teardown work on background threads. Retire the old handle and open a new one before that retirement has finished and the two can land in the wrong order: the new watcher attaches to nothing, the old one takes the real watches with it as it goes, and what is left reports itself as installed while receiving no events at all. On an idle machine the teardown finishes first and nothing goes wrong, which is why this hid so well — it needed a busy machine to line the two up. A repo’s watcher calls now run strictly one after another, so the rebuilt watcher is always the one holding the watches.
  • FixedSwitching hosts no longer freezes the Code tab file tree1 PR
    Switching the active host could leave the Code tab’s file tree stuck on the previous host’s files (the preview pane still switched correctly), with a toast about removing a non-empty directory without recursive. The tree merges tracked files with an optional gitignored overlay from two independent listings that can be briefly out of step on a host switch — and a collapsed ignored directory like .claude/ could sit beside tracked files under it. Overlay dirs shadowed by tracked children are dropped at merge, the file tree normalizes and removes directory keys safely, and a failed tree update recovers instead of wedging forever.
  • AddedSee your gitignored files when you actually want them2 PRs
    The Code tab’s All files tree shows your project rather than your node_modules — it leaves out whatever .gitignore hides. Which is right almost always, and wrong exactly when the file you need to look at is a build artifact, a local config, or a generated log. An eye toggle beside the filter box now brings those files back as dimmed rows, so you can see them without losing track of which ones git is ignoring. A directory that is ignored in its entirety arrives as one collapsed entrynode_modules/ is a single dimmed row, never forty thousand files — and expanding that row reads the one level beneath it from disk, so you can walk down into a build directory a click at a time instead of opening a folder onto nothing. Each directory you open costs one directory read; the ones you never open cost nothing. An ignored file listed in its own right opens and renders exactly like a tracked one. The toggle remembers itself per browser and appears only in All files, since the diff views list changed files where an ignored path can’t turn up. One honest limitation: nothing about an ignored file updates live. kolu deliberately doesn’t watch inside ignored directories — recursively watching node_modules is the thing that exhausts a machine’s file-watch budget — so neither the dimmed rows nor an open ignored file’s contents repaint when they change on disk; collapse a directory and open it again to re-read what is inside it, and reselect a file to re-read its contents.

Remote hosts

  • AddedAgents on a remote host can drive kolu, with nothing installed there1 PR
    Open a terminal on a remote machine, start a coding agent in it, and it can now drive its sibling terminals and use kolu’s MCP server straight away. Before, none of that reached a remote box: kolu’s command-line tools — kaval-tui, padi-tui, and kolu itself — simply weren’t on the machine, so kolu mcp had nothing to run and an agent working there was cut off from the loop that agents on your own machine have always had. Those tools now travel with the daemon in the closure kolu already copies when it first connects, so there is still nothing to install by hand — and because they arrive together, the tools are always the same build as the daemon they talk to, which is the one way this could have gone quietly wrong. Inside such a terminal $PADI_SOCKET is already set, so a plain .mcp.json naming kolu connects with no flags and no paths. Your own PATH is otherwise untouched, and in bash and zsh kolu re-applies its entries after your dotfiles run, so a PATH= line in ~/.bashrc or ~/.zshrc can’t quietly remove them — in fish and other shells the tools are only placed in the terminal’s starting environment, so an absolute set -x PATH … in config.fish will still drop them. Terminals on your own machine work the same way, from the same build.
  • FixedA host that can't connect now says why, instead of connecting forever1 PR
    Adding a machine that asks for an ssh password, one whose host key you have never accepted, or one with no Nix installed used to leave the canvas on “Connecting to remote:…” indefinitely, and then on a card saying kolu was still retrying. None of them were going to succeed: kolu dials non-interactively and can never type a password or answer a trust prompt, and it builds the remote agent with the host’s own Nix — so every retry failed exactly like the first, with the real reason buried in the connect log. All three now stop in a few seconds on a card that names which one it is and what to do about it: set up key-based access with ssh-copy-id, run ssh to the host once in a terminal to review and accept its key, or install Nix there. They are told apart rather than lumped together, because the remedies are different — and the Nix card names the case that trips people up most, where Nix is installed but only reachable from a login shell, not from the non-interactive session kolu uses. ssh’s own words show underneath, and Reconnect is there for once you’ve fixed it. Anything genuinely transient — a machine that is asleep, unreachable, or still building — keeps retrying exactly as before.
  • FixedBinaries instead of compiles — locally, and on every remote host2 PRs
    Every kolu commit is pre-built and pushed to a public binary cache — yet a fresh install often compiled kolu from source anyway, downloading a whole Rust toolchain to build kolu’s process-facts helper, because Nix silently ignores a cache a flake declares unless the user accepted it and is trusted by the machine’s Nix daemon. And a machine added as a remote host compiled the agent no matter what: the remote’s own nix.conf decides where its binaries may come from, and no flake setting reaches that decision. Two fixes. The Quickstart’s new binary-cache section shows the one-time nix.conf setup (plain and NixOS spellings) that makes installs download instead of compile. And provisioning itself now fetches the agent’s binaries into your kolu machine’s store and copies them to the remote host — cross-architecture included, since fetching and copying binaries execute nothing — so a host that accepts the copy never compiles. It lands when the host trusts it: your ssh user is one of its trusted-users, or it trusts the cache’s signing key. When it doesn’t, kolu names that in the connect log and the host builds the agent itself, exactly as before — the remedy is the same nix.conf block, applied there. And where the cache genuinely has nothing (a commit built moments ago), provisioning says so and builds from source.
  • FixedWhen a host fails to build, the card shows the build error2 PRs
    A machine that failed while kolu was provisioning it gave you one line — 'nix build' exited with code 1 — under copy telling you to check the host was up and reachable over ssh. Sometimes that was exactly wrong: the host was fine, ssh was fine, and the build had failed on a type error. The output that said so had been captured all along — kolu shows it live, scrolling, for the entire time provisioning is working — and was then thrown away at the moment the connection gave up, which is the moment you needed it. Those last lines now stay on the failure card, so the reason is on screen instead of an afternoon away. Three things had to change for that to be worth reading: the card had to stop dropping the lines; the build had to stop erasing them (the progress display kolu’s own builds use was drawing over the compiler’s error before anything could record it); and the lines had to stop being losable. They now travel with the failure itself rather than alongside it, so a browser whose connection to kolu has gone quiet — a laptop that slept, a tab left open on hotel wifi — still shows you the error that ended the host, instead of the reason with the evidence silently missing.

Notifications & Attention

  • ChangedOne attention vocabulary — and the count you can click1 PR
    An agent can ask a question and then wait hours without that being legible: the mark for “blocked on you” was drawn at just over half strength — the same dim violet as an agent that had merely finished — while every repo section header wore a pill-shaped number that looked exactly like a notification badge but only counted how many terminals the repo held. Six fake badges, one real signal, and the real one rendered quieter than the fake ones. kolu now speaks one attention vocabulary everywhere it can reach you — dock row, repo header, host tab, phone host row: violet means an agent is blocked on you, amber means work finished that you haven’t opened, and a plain rust count means agents are simply working. Blocked-on-you is now drawn at full strength and its row floats to the top of its repo section, tinted violet, with a chip in place of the timestamp counting how long it has been waiting — so a question left overnight reads “14h” instead of blending into the list. The two counts you can act on are the only ones shaped like a pill, and clicking one now takes you there — to the next agent waiting on you, cycling through them on repeat clicks — while the plain number beside a repo name went back to being plain text. Clicking never dismisses: the needs-you count reads what is actually waiting, so only answering the agent clears it. Each host tab now carries that machine’s whole summary rather than a single count, including the machine you are already on — being on a host was never proof you could see a terminal buried in its dock, which is exactly how a question went unanswered overnight. The rust number counts anything happening, which is a plainer promise than it sounds: kolu had grown four different answers to “is something happening in this terminal” — one for the count, one for the mark’s colour, one for whether the mark moves, one on the wire — so a tab could read 2 above three visibly-working terminals, or count nothing at all beside a mark that was still spinning. There is one answer now, and every count and every moving mark is that same answer asked about a different scope. Two things follow that you will see: an agent whose turn just ended still counts while its last output is landing (its mark is still moving, so its machine says so), and a plain shell running a build counts too — it has no agent to ask, and its output is the only evidence there is.

Port forwarding

  • AddedClick a port on any host, get the page2 PRs
    A dev server on 127.0.0.1:5173 is invisible from the laptop you are looking at, because loopback never leaves the machine it is on — and a port on a remote host is further away still. kolu now opens a door for both: click the port and it makes that port answer on the machine serving this page, then opens the tab. An ssh -L tunnel when the port is on a remote host, a plain TCP relay when it is on kolu’s own machine and merely on loopback — kolu reads the bind address and picks, rather than guessing. The row then carries a ⇄ :<port> badge naming where it answers, alongside a copy button and a to close the door — one Ports list, one row per port, rather than the same port listed twice. A door you opened by hand, or one whose server has since died, trails that list under “also forwarded on this host”, because a door belongs to the machine rather than to any one terminal. Wherever a forwarded port appears, kolu names the terminal serving it, and that name is a link — click it and you land on the agent that started the thing. The host tab’s dropdown carries the same rows, and the tab’s connection dot wears a thin teal ring while doors are open there. Doors open lazily — only for ports you actually click — and they close themselves when the scanner sees the listener die. One you ask for by hand (⌘KForward a port…, accepting host:port) stays until you cancel it, since kolu has no listener to watch on your behalf there. A restarted dev server gets its old door back, so links you saved keep working. The same door also answers a localhost URL the agent printed — click a loopback link in the terminal (localhost, 127.*, [::1], or 0.0.0.0) and a live card at the cursor offers forward & open when the scanner already sees that port, path preserved, or says nothing is listening yet instead of inventing a forward from text; ⌘/Ctrl-click opens the raw URL with no card. Everything dies with the kolu server — guaranteed by the kernel rather than a timer — so a deploy never leaves an orphaned door open. One case is reported honestly rather than papered over: a port bound to a single interface of a remote machine answers at that address and by no door kolu can open, so its row says so instead of offering a button that would fail. A forwarded port is unauthenticated on every interface of the machine running kolu — the same exposure as starting the dev server on 0.0.0.0 yourself — so keep it to a network you trust.

Release

v2.0.0

93changes

The release where kolu stopped being one process on one machine. Your terminals moved out of kolu into kaval, a PTY daemon that outlives it, and your workspace into padi — so updating or restarting kolu no longer costs you a shell, a scrollback, or an agent mid-thought. Point kolu at another box over ssh and the whole canvas becomes that machine, one click away in a host tab bar, with no reload. Terminals you’re done with sleep and wake with their conversation intact; every agent — Grok Build included — wears its live state on the tile, in the dock, and in your notifications; and kaval-tui, padi-tui, and kolu mcp let one agent drive another.

nix run github:juspay/kolu/v2.0.0
Added
34
Changed
16
Fixed
40
Heads-up
3

Remote Hosts

  • AddedWork across several machines — remote hosts on the canvas22 PRs
    kolu is no longer tied to the machine it runs on. Point it at another over ssh and its whole canvas becomes that machine — terminals, the Code tab, paste/upload — provisioned onto the host with Nix and durable across both the ssh link and a kolu-server restart. A host strip in the chrome bar is a real tab barlocal is a Home icon plus the machine hostname kolu runs on, remotes show the ssh target, each tab has its own identity colour, a live awaiting-you count, and an always-on connection pip (green / amber / red); click the pip for the diagnostics popover (padi/kaval detail, remove-with-confirm) without hover-opening on switch; click one — or press ⌘⇧H, or hit + to add a machine anytime — and the canvas switches with no reload, with a switch-back that repaints instantly from warm state. The add-host form includes a Remote hosts — Learn more → link to the docs on desktop and mobile, without the old Alpha badge or early-feature warning. The machines you add survive a restart, a fresh connect streams its real Nix build — nix’s own verbose per-path lines in the connect tail, plus an elapsed timer that keeps climbing every second even when a single large fetch is quiet — so a slow first connect never reads as a hang, and a boot that genuinely gets stuck now ends honestly instead of sitting at “Connecting…” forever, and the escape card tells the truth about who can fix it: while a remote machine is still setting itself up over ssh the card stays non-terminal — it says kolu is still retrying (naming the phase) and its verb is Retry connection, which recycles the server-side connection a browser reload can’t touch, with a half-hour on-screen campaign backstop so a setup that never connects still surfaces the escape even as the phase flaps (that window is measured from when the tab started watching, so a reload starts it fresh); when instead the stall is client-side delivery — the host list or your saved session never arriving, or a connected remote’s agent never reporting — the card names what didn’t come and offers Reload. Either way Switch to local is offered on a remote machine, and a machine that’s legitimately still copying or building its agent gets a generously longer deadline, so a real first connect is never cut short. (A local machine whose own agent never starts keeps its existing “kaval didn’t start” card with Restart.) An agent that needs you on a background machine rolls up a badge, a notification, and one-click focus of the exact terminal. Scrolling up a just-switched terminal backfills its true history from the host. Your local machine is the always-present first tab, the whole thing works from a phone (a host row in the pull-down sheet), and the Code tab reads the connected machine’s disk — previews and the running-daemons list included.

Kaval

  • AddedYour terminals now live in kaval, a daemon that outlives kolu14 PRs
    kolu no longer owns your shells inside its own process — it starts kaval (Tamil kāval — watch/guard), a standalone PTY daemon that owns your terminals and serves them over a local socket, so they survive a kolu update or restart untouched: shells, scrollback, and running agents stay exactly where they were, with only a brief “N terminals reattached” note to show for it. If kaval ever stops, the canvas says so plainly and offers a one-click Restart kaval that captures your session first; the chrome bar’s kaval column shows the daemon’s live health and flags an ⬆ update when a newer build is ready. And when the host’s kaval is too old to speak this kolu’s wire contract, kolu names that as its own daemon state rather than a dead-end failure: the canvas card and the kaval dialog say which version the host’s kaval speaks and which your kolu needs, the plain Restart — which could only respawn the same outdated binary — gives way to a single Update & restart kaval (behind a confirm) that re-provisions the host’s build and starts a correct-version kaval, and the same chrome that flags a newer build carries the incompatible verdict. Even an unclean kaval crash — a SIGKILL or OOM — now brings your agents back resumed on their exact prior session, not as bare shells, and kaval keeps a bounded per-terminal scrollback mirror so a long-lived workspace can’t run it out of memory. And when a kaval itself shuts down for good, it reaps the shell and agent processes inside its terminals on the way out — nothing is left orphaned on the machine. And if a stray process is ever left holding kaval’s socket without its lockfile — the orphaned-daemon state that used to wedge the canvas until someone killed the process by hand — kolu now identifies that holder over the socket and recycles it on its own, so the incompatible-daemon card converges on a single Update & restart instead of looping forever; a process that turns out not to be a kaval is named and left untouched, never killed. And when a host’s channel goes dark — an ssh link that timed out, a daemon still connecting — the kaval dialog and its chip stay honest: the contract badge, socket path, build, and running-daemons list read unknown / unavailable rather than a frozen last-good value, and an unreachable host is named as unreachable, never mislabelled “connecting”.
  • AddedDrive your terminals from any shell — kaval-tui17 PRs
    kaval-tui is kaval’s terminal client — everything you can do to a terminal, from a script. create spawns one (it appears as a kolu tile instantly), attach takes it over full-screen, snapshot --viewport reads the live screen and history the older scrollback, send types into it (text or a --key, submitting as its own step; --file for a byte-exact payload), wait --until idle blocks until its agent goes quiet, and kill ends it. It reaches a remote machine over ssh (--host), takes short ids (any unique prefix, with a Copy terminal ID action in-app), and inside any kolu terminal two env vars — $KAVAL_SOCKET (the daemon that owns it) and $KAVAL_TERMINAL_ID (its own id) — let a process drive its siblings or itself with zero guessing. It’s the write half of one agent driving another; combining text and a submit --key in one call is a deliberate hard error, because observing the settle then submitting is what dodges the paste-race. A created terminal gets a clean, composed environment — the canonical vars a shell needs (HOME, PATH, SHELL, TERM, …), not a wholesale copy of the launching shell’s — so an orchestrating agent’s private identity vars can’t ride in and cost a spawned agent its saved conversation; add a specific var back with --env K=V.

Padi

  • AddedYour session lives in padi, the workspace daemon4 PRs
    kolu’s per-machine workspace — your terminals’ layout, saved session, repo context, restore state, and agent awareness — now lives in its own long-lived process, padi, while kolu-server thins to the web shell that connects your browser to it. So restarting or redeploying kolu-server no longer loses your session: padi keeps the registry warm and your canvas re-attaches already-whole, with no restore-card flash on a normal reload (the card shows only after a real restart). A deploy that changes padi’s own code drains the old daemon and brings the new build up with your live terminals untouched. padi’s state-root is never a silent default — binding requires KOLU_PADI_STATE_DIR or --state-root (production wrappers still supply ~/.local/state/padi; a bare launch dies with one clear line instead of sharing production’s chair).
  • AddedDrive an agent by its real state — padi-tui6 PRs
    padi-tui is padi’s shell client — kaval-tui’s sibling, over the workspace daemon. status/watch show what each terminal is in (repo·branch · PR · agent · foreground, with a for live output), and wait --until <buckets> blocks until a terminal’s agent reaches working/awaiting/waiting — the precise done-signal for one agent driving another, read off real agent state rather than guessed from silence. create spawns a terminal, a split (--parent), or a worktree’d agent live on the canvas — and a split you create this way now opens exactly like one you made in the browser. It reaches a remote padi over ssh (--host), and inside a kolu terminal $PADI_SOCKET makes every verb flag-less.

Sessions, Sleep & Wake

  • AddedPut a terminal to sleep and wake it with the conversation intact5 PRs
    A terminal you’re done with for now can be put to sleep (☾): its PTY, agent, and GPU context are released while the tile stays on the canvas, moonlit and dormant, still showing the directory, branch, and PR it was on. Wake it and kolu re-spawns it in place and resumes the agent on its exact prior conversation (claude --resume <id>, and the equivalents for Codex and OpenCode) — unless you’d quit the agent to a bare shell first, in which case waking lands you at that shell rather than silently relaunching something. Sleeping tiles survive a full restart, and the dock’s Filters row can hide sleeping (or merely stale) terminals so the list stays to what’s awake.
  • FixedReconnecting no longer makes kolu's memory briefly spike1 PR
    When the connection to kolu dropped and came back — a reload, a Wi-Fi roam, a laptop waking — the server re-attached to every open terminal at once, and with many open it could briefly balloon its memory by gigabytes before settling. That burst now reuses a single screen snapshot per terminal instead of rebuilding one per attach, and skips the work entirely for attaches whose tab has already gone. Nothing changes on screen.

Agent Detection

  • FixedAn agent run as a terminal's root command now shows in the dock1 PR
    Start an agent as a terminal’s own process — kaval-tui create -- codex …, the agent as the root with no shell wrapping it — and kolu now tracks it on the tile and in the dock like any other, instead of leaving it invisible. A shell-less terminal sends none of the command marks kolu reads, so it never learned what such a terminal was running; and a second check that asks “is the shell sitting idle?” misread the agent-as-root as an idle prompt and dropped the one clue it had. This bit hardest an agent whose program name differs from what you typed — an npm-installed CLI that runs as node — which could only be recognized by the command it was launched with. kolu now remembers that command from the moment the terminal is spawned and reads a command-rooted terminal’s busy foreground correctly. (Agents that write an on-disk session keyed by process — Claude Code — were already found this way; this closes the gap for the rest.)
  • AddedGrok Build is a first-class agent3 PRs
    Run grok (xAI’s Grok Build) in any kolu terminal and it gets the same treatment as Claude Code, Codex, and OpenCode — a Grok icon and live state on the tile (thinking · tools · waiting · awaiting you), dock ranking, and sleep/restore that resumes the session. It exports the same HTML transcript the others do, and the tile shows its context-window token count. Detection reads Grok’s on-disk session files under ~/.grok — no config step. Launch shape is remembered the way the other agents’ is: flags such as --no-alt-screen (inline TUI instead of the alternate screen) stay on the recent agents list and on sleep/wake resume, not dropped as unknown.
  • AddedSee which terminal needs you — agent state on the tile border1 PR
    With several terminals open it was hard to tell at a glance which is thinking, which is waiting on you, and which just pinged you while you were looking elsewhere. Now each live tile wears its agent’s run-state as motion on its border, in the tile’s own repo colour: a working terminal runs a calm marching-ants outline; one that needs you sweeps a comet whose speed is the urgency — fastest for a fresh “your turn”, slowing as the wait goes stale; and a missed alert throbs loudest until you focus it. Idle tiles stay dark, so a sweep of the eye reads the whole fleet’s state without reading anything. The tile you’re in is marked by a crisp outline just off its edge, and reduced-motion freezes every aura to a static ring.
  • AddedSee how long an agent has been running — in the Inspector and on the tile2 PRs
    The right-panel Inspector’s Agent section, and the tile title bar beside the context-token count, now carry a Running for row — the time elapsed since the current session’s first message, ticking up live (12s5m2h3d). It reads each agent’s own start time straight from its on-disk state (Claude Code’s transcript, Codex’s thread id, OpenCode’s first message), so the clock measures the conversation’s age and keeps counting across a resume. Hover either readout for the absolute start time (Started Apr 26, 2026, 1:40 PM); both stay hidden until the session’s start resolves.
  • FixedThe dock again flags Claude Code questions waiting for you1 PR
    When Claude Code asks a multiple-choice question, kolu marks that terminal as awaiting you so it surfaces in the dock — but a Claude Code update added an n to add notes hint to the question’s footer that slipped between the two words kolu watched for and silently broke the detection. kolu now recognizes the new footer (and tolerates future hints landing there), so a pending question lights up the dock again instead of looking idle.
  • FixedRe-running a recent agent with quoted arguments no longer mangles them1 PR
    Launching a previously-run agent from the New terminal → recent agents list — or having a restored session auto-resume one — used to strip the quotes off its arguments, so claude --settings '{"ultracode": true}' came back as --settings {"ultracode": true} and the shell split the JSON apart. kolu now re-quotes any argument carrying spaces, JSON, or other shell-significant characters before storing and replaying it, so a recent agent runs exactly the way you first launched it.
  • FixedYour agent's prompt no longer leaks into the recent-agents list1 PR
    The recent agents list — in the command palette and the New terminal flow — is meant to remember how you launched an agent (claude --dangerously-skip-permissions), not the one-off prompt you typed after it. But a launch like claude --dangerously-skip-permissions 'You are BOOT1. Read BRIEF-BOOT1.md…' kept the whole prompt, showing it verbatim in the list. kolu now drops the trailing prompt the way it always intended, so the list stays to the launch shape and your prompt text isn’t left sitting in it.
  • FixedAgent state no longer gets stuck on a fast turn1 PR
    On a very quick turn — a sub-second reply from a small local model, or any fast completion — a tile’s agent state could freeze on the wrong reading and never catch up: the pill stayed thinking long after the turn had finished, and never reconciled to waiting. kolu watches each agent’s on-disk session file for changes, but the operating system is allowed to quietly drop the file-change notification for the last write of a fast turn — after which nothing re-read the file, so the stale state stuck (macOS was the most exposed). kolu now backs every agent-session watch with a steady low-rate poll that re-checks the file on its own, so a dropped notification self-heals within about a second instead of stranding the indicator. Covers Claude Code, Codex, OpenCode, and Grok.

Notifications

  • FixedFinished alerts wait for real quiet — and don't re-chime forever on idle terminals1 PR
    When an agent marks its turn done while background sub-agents are still typing, kolu used to chime finished immediately. The finished signal now waits until the terminal has been actually quiet for a few seconds (the same host-side meaningful-output doorbell the live ring already uses, with a longer window). Once that finished signal has fired for the turn, idle TUI redraws (statusline clocks, context meters) no longer un-finish and re-chime every few seconds — finished sticks until the agent truly runs again. Asking-for-you is still immediate; pure resize repaints still never count as work.
  • FixedA question that lands right after a finished turn now still chimes1 PR
    When an agent stops to ask you something — a question or a permission prompt — kolu plays its sound and shows a backgrounded-tab notification. But if the agent had just finished its previous turn when the prompt arrived, that chime was sometimes swallowed and only the dock badge lit up; whether you heard it came down to timing. Now a live gate reliably rings even when it lands on the heels of a finished turn, and a question kolu catches cleanly announces itself as “needs your input” rather than a bare “finished” — a brief flicker between states as the prompt settles still rings only once (that one ring may keep the earlier “finished” wording, but it fires).
  • ChangedAgent attention now reaches you the same way on every host1 PR
    Agent attention is now consistent across every host: an agent that finishes or starts waiting on you on a background machine reaches you exactly the way the active machine’s does — a sound, a backgrounded-tab popup, and a quiet amber mark on that host’s tab — instead of the thinner ping background hosts used to get. An agent that’s waiting for your input additionally counts toward the app-icon badge (which tallies agents blocked on you, across every host). And the Activity alerts setting is now Attention alerts to match.

The Right Panel

  • AddedThe right panel — Compose a prompt, and the whole terminal CLI4 PRs
    The tile’s right panel gained a Compose box: write a prompt in peace and Send it (or ⌘/Ctrl+Enter) straight into the terminal’s input line — the in-app twin of kaval-tui send (it inserts, you press Enter). Its Inspector now documents the whole terminal CLI — an attach/snapshot/send card per pane and split, a reference for the rest of kaval-tui and padi-tui, and a Drive one agent from another callout — and whether the panel is open, and which tab it’s on, is remembered per terminal across session restore.

The Dock

  • ChangedLive rings track real host output — resize can't fake them, and background terminals light too1 PR
    The dock’s green live ring (and the matching tile-title cue) no longer counts bytes only in the browser attach of a tile you’re looking at. It mirrors a host-wide signal from kaval, the process that owns every PTY: real shell output lights the ring, a pure resize/reveal repaint does not, and a terminal on a background tile can still show live. Switching to a quiet tile no longer flashes the ring for a beat.
  • AddedResize the dock in maximized mode1 PR
    In maximized mode the dock is a real sidebar beside the canvas — and you can now drag its right edge to resize it, just like the right panel. Grab the edge (the cursor turns to a resize arrow), drag to the width you want, and it reflows live; double-click the edge to snap back to the default. The width persists per device, so a wide monitor and a laptop each keep their own. The narrow rail stays a fixed strip, and the floating dock in tiled mode is unchanged.
  • ChangedThe dock shows every terminal by default1 PR
    The activity-window filter used to default to 24h, so any terminal you hadn’t touched in a day was already tucked behind a N hidden · show all. It now defaults to All — nothing is hidden until you narrow the window. Time-based decay (4h / 12h / 24h / 48h) is still one click away and still persists per machine, but the default is to show what you have rather than hide it.
  • ChangedTell repos apart at a glance in the dock1 PR
    When you keep terminals across several repos, the dock groups its rows by repo — but the only repo cue was a tiny label on a grey band that scrolled out of sight. Each repo section now carries a continuous repo-coloured spine down its left edge and a faintly repo-tinted header that stays pinned while you scroll that repo’s rows. The per-branch row colours are unchanged, and the same treatment rides the mobile drawer.
  • ChangedOne status indicator per dock row — live output, agent state, and unread alert, merged3 PRs
    A dock row used to carry up to three separate signals jostling for space: a green dot for moving bytes right now, the agent-state pip, and a loud badge that replaced the pip with an unread alert when an agent finished in the background. They’re now one indicator: the agent-state pip is the core, a thin green ring sweeps around it while the terminal produces output, and a small amber corner badge appears when a notification you haven’t seen has fired — so a single glance reads overall activity, and a pending alert no longer hides the live state. Switching to a quiet terminal no longer briefly flashes its ring.
  • ChangedA just-finished agent no longer sits in the dock's needs-you row1 PR
    The dock sorts terminals by what needs you — but a terminal whose agent had just finished its turn (the brief waiting lull before it settles) was floating up into the awaiting-you row, alongside agents genuinely blocked on your input. Now only an agent actually waiting on you rides that row; a just-finished agent drops to the quieter idle group.
  • FixedA busy terminal's 'last activity' no longer freezes in the dock1 PR
    The dock’s per-terminal recency — the “Nh ago” chip, the activity-window filter, the switcher’s order — had stopped moving for a terminal running one long-lived agent session. It only advanced when the agent’s identity changed, so a terminal churning away in the same week-old session could read “24h ago” while actively producing output. Recency now also advances on the terminal’s output — throttled to at most once a minute, so it never re-introduces the write chatter that made kolu drop per-tick recency in the first place. A genuinely idle terminal still shows its true last-active time.
  • FixedNo more error spam — or a false auth warning — on repos that aren't on github.com2 PRs
    A terminal in a repo hosted anywhere but github.com — Codeberg, a self-hosted Forgejo/Gitea, GitLab, even a local repo with no remote — used to spit an error-level line into the log on every poll, re-log a “gh: not authenticated” warning every 30 seconds, and flash a scary “gh: unknown error” popover on its tile. Nothing was wrong: kolu was asking GitHub’s gh tool about a repo that isn’t on GitHub. kolu now asks gh only about github.com remotes; everything else simply shows no PR, exactly like a branch with none — quiet, no popover, no warning. (Real PR support for Forgejo/Codeberg is still planned; this stops the false alarm first.)
  • FixedSwitching branches no longer flashes the previous branch's PR1 PR
    When you switched a terminal to a different branch (or left the repo) while its PR was still being looked up, the tile could briefly show the old branch’s pull request — number, title, CI status — until the new lookup finished. kolu now discards a stale in-flight lookup whose branch no longer matches, so the PR pill only ever reflects the branch you’re actually on.

The Canvas

  • AddedDouble-click the empty canvas to open a new terminal1 PR
    Double-click any empty spot on the canvas and the command palette opens pre-drilled into the New terminal group — the same menu the dock’s + button reaches, so you can pick a repo, name a worktree, or start in the current directory without first finding the button. A double-click on a tile (its title bar still maximizes) or the minimap is left alone.
  • AddedNew terminals inherit the size of the one you're working in1 PR
    Every new terminal used to open at the same fixed default size, so if you’d widened one to suit your work each new one came up small. A new tile now opens at the size of the terminal you’re currently in — the active one, not whichever was created last — and it picks up a size you’ve just dragged even before the change has finished saving. The first terminal still opens at the default; only the size carries over, not where the tile lands.
  • FixedFlinging the canvas no longer hitches, even on a slow machine1 PR
    Panning or zooming the canvas used to update every tile’s position on every scroll tick — and a fast fling fires those far quicker than the screen can repaint, so most of that work was thrown away, and with many tiles open on a slow machine the waste piled up enough to briefly freeze the canvas mid-gesture. kolu now batches each gesture’s movement and applies it once per frame instead of once per tick, so the canvas keeps up no matter how fast you flick, landing in exactly the same place as before.
  • FixedA canvas full of agents stays smooth1 PR
    The dock’s per-terminal state animations — the orbiting glow on a working chip, the breath on one awaiting you, the pulse on a card’s border — were drawn by animating box-shadow and background, which the browser repaints on the main thread every frame. With many terminals open a busy rail was doing tens of thousands of repaints a second while you weren’t even touching it — enough to spin a fan, and on some Linux/Wayland setups enough to wedge the compositor. The animations now run entirely on the compositor, so they cost almost nothing no matter how many terminals are live, while looking exactly the same. prefers-reduced-motion still freezes them.
  • FixedSwitching terminals in maximized mode no longer re-renders them1 PR
    With a terminal maximized, switching to another — from the dock, Cmd/Ctrl+19, or cycling — used to make both terminals visibly re-render on every switch and round-trip a resize to the shell, because kolu kept the inactive terminals at their small canvas size while the maximized one filled the screen. Hidden terminals in maximized mode now match the maximized terminal’s size exactly, so switching is a pure show/hide — no reflow, no repaint, no shell resize.
  • FixedText selection in a zoomed canvas terminal tracks the pointer1 PR
    Dragging to select text in a terminal tile while the canvas was zoomed landed off the mouse — the selection started at an offset that grew the more you zoomed, making text out of an interactive TUI a guessing game. Canvas tiles scale with a CSS transform, but the terminal measured the pointer against its un-zoomed cell grid. kolu now inverts the tile’s scale before the terminal reads the pointer, so selection, link hovering, and mouse input forwarded to TUI apps all land on the cell under your cursor at any zoom.
  • FixedThe minimap's zoom controls stay reachable when terminals stack into a tall, narrow column1 PR
    The minimap’s strip of zoom / arrange / activity-window controls used to narrow along with the overview when tiles stacked into a tall, narrow column, until the buttons were clipped off the right edge and unclickable. The panel now never narrows below the width its controls need: the overview still shrinks to fit, while the control strip keeps every button reachable.
  • FixedClosing every terminal at once no longer briefly marks a departed one 'active'1 PR
    When all of your top-level terminals left in the same instant — closing the last of them, or a batch that all exited together — kolu’s focus hand-off picked the “survivor” to focus by skipping only the one terminal it happened to be processing, never the rest of the departing batch. So instead of settling on no active terminal when nothing remained, it landed focus on another terminal that was also leaving, and echoed that just-departed id to the server as the active one. kolu now clamps focus past every terminal leaving in that frame: an emptied canvas correctly has no active terminal, and a batch where some tiles survive still hands focus to one that’s actually still there.
  • FixedReduced motion actually stops the canvas tile auras1 PR
    A tile’s animated border — the marching ants when it’s working, the comet when it needs you — is meant to freeze to a static ring when your system asks for reduced motion. A CSS specificity slip meant the freeze rule never won the cascade, so the auras kept moving regardless. The override now matches the animating selectors’ specificity, so requesting reduced motion holds every tile aura still, as intended.

Inside a Tile

  • AddedPer-terminal font zoom in canvas mode1 PR
    Pressing Cmd/Ctrl +/- to change a terminal’s font size now zooms only the focused tile. Previously every open terminal zoomed at once, so two tiles could never be aligned to the same font size if one was zoomed before the other opened.
  • AddedSee which pane has focus in a split terminal1 PR
    Split a tile into two stacked terminals and, until now, nothing showed which one your keystrokes were going to. The pane that does not have focus now recedes (dims slightly) so the focused one reads as the foreground, and the cue follows focus as you click between them. It shows only in the tile you’re working in and only while the split is open.
  • FixedTerminals no longer silently freeze1 PR
    A WebSocket that died without notice — a laptop sleeping, Wi-Fi roaming, or a network dropping an idle connection — used to leave a terminal frozen until you reloaded the page. A heartbeat now detects the dead connection and reconnects on its own, so live output resumes without a reload.
  • FixedTerminal output no longer freezes until you press a key1 PR
    A terminal could silently stop painting new output — an agent seemingly stuck for minutes while its work continued — until any keypress made everything appear at once. The cause: a viewport scroll you never made (a touch artifact, a TUI leaving its alternate screen, a scroll tick delivered late after backgrounding the tab) engaged the scroll-lock with nothing to release it. The lock now engages only for scrolls you actually make; any other scroll snaps back to the bottom and output keeps flowing. Returning to the tab also releases a lock left engaged while you were away.
  • FixedOutput that arrived while kolu was in the background appears the moment you return — no keypress needed1 PR
    Switch to another window while an agent keeps working — exactly what running a fleet of background subagents looks like — and the terminal could stop painting: new output piled up unseen and only snapped into view when you came back and pressed a key. When a window is occluded the browser stops producing animation frames, and xterm schedules every repaint on one, so a paint queued while you were away never ran until an input event forced a frame. kolu now forces an immediate repaint the instant the window regains focus (with a short watchdog for a stall when focus never changed).
  • FixedA terminal flooding output no longer freezes when kolu sheds its slow view1 PR
    kolu’s terminal daemon caps how much un-drawn output it buffers per connected view, and when one falls far behind it drops that view rather than growing memory without bound, expecting a re-attach. But the drop looked exactly like the shell exiting, so the terminal froze — scrollback stuck, no new output — as if the process had died (it hadn’t). The daemon now marks that drop distinctly from a real exit, so the terminal re-attaches on its own and repaints from a fresh snapshot the moment it’s shed. You lose only the sliver of scrollback the cap discarded; the live screen comes right back.
  • FixedSwitching to a terminal no longer shows garbled text1 PR
    On a busy workspace — many terminals, splits, maximized mode — switching to a tile could paint the whole terminal in the wrong glyphs (every character substituted — backgroundtackground), clearing only when you selected the text or reloaded. The cause: a tile hidden with display:none missed the resize events that keep its GPU glyph atlas fresh, and on show re-fit to the same size — a no-op that skipped the atlas rebuild — so every glyph drew from a stale slot. kolu now rebuilds the atlas whenever a tile becomes visible again, regardless of whether its size changed. Most visible on Linux and, recently, macOS under load.
  • FixedWorking across many terminals no longer crashes the GPU on AMD Linux3 PRs
    On Linux with an AMD GPU, running kolu across several terminals in Chrome/Chromium could slowly exhaust GPU memory until the graphics driver reset — blanking terminals, and on some setups taking down the whole desktop. The cause: kolu kept the hardware (WebGL) renderer on only the most-recently-used terminals, so switching among more of them destroyed and recreated a GPU context on every focus change, and Chrome on AMD doesn’t reclaim that memory until garbage collection — so the churn outran cleanup until the GPU faulted. kolu now keeps the renderer on your whole working set at once, up to a safe cap below the browser’s per-tab context limit, so ordinary switching no longer churns contexts and the leak is gone. It also cured the subtler tells of the same churn — text that subtly resized as you clicked between tiles, and tiles that intermittently rendered corrupted on long sessions. Firefox was never affected.
  • FixedClicking a long, wrapped URL no longer opens a clipped address1 PR
    When a URL was long enough to wrap across several terminal lines, resizing the terminal — zooming a tile, resizing the window, rearranging the canvas — could truncate it, so clicking opened a broken address with the middle missing. kolu now preserves the wrapped line’s full contents through a resize, on both the live terminal and the restored scrollback, so a wrapped link always opens the whole URL.
  • FixedClosing a terminal no longer logs a spurious 'Terminal not found' error1 PR
    Closing a terminal — especially via Close and remove worktree — could spit an error-level Terminal <id> not found line into the server log even when you hadn’t typed a thing. A terminal sends its own little background messages (a focus-change blip), and one could arrive a split-second after the close had already removed the terminal. kolu now recognizes a keystroke or resize landing for an already-closed terminal as the expected race it is and drops it quietly; a request for a terminal that genuinely never existed still surfaces loudly.
  • FixedA tile's title bar no longer lags the dock on PR and agent state1 PR
    A canvas tile’s title bar shows the terminal’s pull request, agent state, and running process — but it could keep showing stale values while the dock row for the same terminal was already current (a PR that just resolved, an agent that changed state). The header read those facts from a snapshot that only refreshed when the branch or directory changed, so an update on a settled branch never reached it until something unrelated nudged it. The title bar now reads the same live source the dock does, so the two can no longer disagree.

The Code Tab

  • AddedRhai files are syntax-highlighted in the Code tab1 PR
    Open a .rhai source file or diff and kolu now recognizes the language and highlights its syntax instead of showing plain text. The grammar comes from the official Rhai language server and stays lazy-loaded with the rest of the Code tab’s language support.
  • AddedRead a footnote without leaving your place in the Markdown preview1 PR
    The Code tab’s Markdown preview renders GitHub-style footnotes, and reading one meant scrolling all the way down and back. Now a footnote marker is a button: click or tap it and its definition opens in a small popover anchored right under the marker. Dismiss it by clicking elsewhere, pressing Escape, scrolling, or clicking the marker again; a see all ↓ link drops to the bottom list. The bottom “Footnotes” section is unchanged — the popover reads its content from there.
  • AddedPDFs open inside the Code tab1 PR
    Click a .pdf in the Code tab and kolu opens it with the browser’s native PDF viewer, so pages, zoom, and downloads work in-pane instead of showing a broken sandbox preview.
  • AddedMarkdown previews show a file's YAML front-matter1 PR
    A Markdown file that opens with a --- YAML front-matter block now renders that metadata as a tidy table at the top of the preview — keys beside their values, the way GitHub shows it — instead of dropping it or letting the --- fences misrender. Front-matter that can’t be tabulated (malformed YAML, or a block that isn’t a mapping) is shown raw as a code block, so it stays visible and fixable.
  • AddedClick a folder in terminal output to reveal it in the Code tab1 PR
    A path/to/file reference in terminal output was already clickable. Now a folder reference (packages/client/, or any slash path naming a directory) is clickable too: instead of a “File reference not found” toast, clicking it switches the Code tab to the All files tree, expands that folder and its parents, and scrolls it into view — so you can jump straight from a path in a build log to browsing that directory.
  • AddedSee your git changes in the Code tab — live, and on the machine you're viewing2 PRs
    The Code tab’s git views got two upgrades since 1.0. The Local (uncommitted) / Branch (vs base) / All files picker is now a segmented control instead of a dropdown, and the Local and Branch segments carry a live change-count badge — you can see you have, say, 3 uncommitted files and 12 changes versus your branch base without switching into either view. And the changed-files list and its diffs now repaint live the instant a file changes on disk — a payload-free repo-change pulse re-running git status, no polling — through a watcher loop that works over a remote link too, so your git status and diffs stay live even when the Code tab is pointed at another machine, not just this one.
  • ChangedToggling a Markdown file between Source and Rendered is instant and keeps your place1 PR
    The Code tab’s Source ⇄ Rendered toggle used to throw the inactive view away and rebuild the other from scratch — re-parsing, re-sanitizing, and re-highlighting the whole document, so on a large file every flip meant a visible hitch and a reset scroll position. kolu now keeps both views alive once opened and simply shows or hides them, so toggling is instant and your scroll position survives the round-trip. The view still refreshes when the file’s contents actually change.
  • ChangedBig diffs no longer hitch the Code tab while they syntax-highlight1 PR
    Opening a large diff or file used to colour the syntax on the main thread, so a 50k-line lockfile could briefly freeze scrolling while it tokenized. That work now runs in a background worker pool: the code paints immediately, the colours stream in a beat later, and the UI never blocks. The highlighter also moved to Shiki’s lighter JS engine. Diffs and files look exactly the same — they just don’t stutter.
  • FixedThe Code tab no longer flickers between two files in a runaway loop1 PR
    Clicking around files in the Code tab while an agent was working in that terminal could suddenly latch into a runaway loop — the selection flickered between two files ~60–120 times a second, pegging a CPU core, and only stopped when you switched terminals. Pierre (the file-tree engine) sometimes re-emits its selection on its own, with no user input; the app forwarded each of those echoes back as if you’d clicked, and then re-applied it into the tree — the two ends driving each other in a frame-rate loop. The Code tab now writes a selection back only when a real click or keypress caused it, so an autonomous re-emit can’t start the loop — while a genuine click still updates the preview immediately. Affected both All files and Local/Branch views.
  • FixedNo more error spam in fresh repos1 PR
    Opening a terminal in a freshly git init’d repository (no remote, no commits) no longer logs a repeated “no base branch found” error on every change. The Code tab’s Branch view now shows an empty diff there instead of failing — while repos that simply haven’t fetched their remote still get the actionable “run git fetch” prompt.
  • FixedNo more scary error on upgrade1 PR
    Upgrading from an older build no longer logs an EVENT_ITERATOR_VALIDATION_FAILED error at launch. Your saved Code-tab layout now carries forward cleanly instead of needing the preferences file deleted by hand.
  • FixedAn HTML preview stops jumping to the top while you read it1 PR
    Reading a rendered HTML file in the Code tab, scrolled halfway down, the preview would reload and snap back to the top at seemingly random moments — even though the file’s text never changed. The trigger was another terminal doing git work in the same repo (a branch checkout during a PR loop, most often on a remote machine): a checkout re-writes files with the same bytes but a new modification time, and the preview keyed its cache-buster on that time. The preview now keys on a hash of the file’s actual bytes, so a same-content rewrite leaves your scroll position exactly where it was.
  • FixedCode tab remembers the file you picked, not the one you clicked in the terminal1 PR
    After you clicked a path:line reference in the terminal to preview a file, then manually picked a different file in the tree, switching to another terminal and back used to snap the preview back to the terminal-clicked file — quietly discarding your manual pick. The clicked-file request lingered and re-fired on every switch; kolu now treats it as consumed once handled, so your last selection survives the round-trip.
  • FixedCode tab keeps your tree/preview split1 PR
    Resizing the split between the file tree and the preview in the Code tab now sticks. Previously, switching to another terminal (or any moment the Code tab left the screen, such as a terminal outside a git repo) could silently overwrite the saved split with a garbage value, so coming back brought the panes up at the wrong size.

Mobile

  • AddedUnfolded foldables and tablets get a two-pane layout1 PR
    On a wide touch screen — a Galaxy Z Fold unfolded, an iPad, an Android tablet — kolu used to fall back to the mouse-driven desktop canvas, clumsy to drive with a finger, because the layout was chosen on viewport width alone. kolu now treats a roomy finger-driven device as its own form factor with a two-pane layout: a persistent terminal rail down one side, the active terminal filling the rest, with the same touch-sized controls as the phone. Phones keep the single fullscreen tile; a real mouse still gets the canvas. The split keys off the pointer, not just the width.
  • FixedMobile key bar types into the focused split1 PR
    On a phone or tablet, the on-screen helper bar’s keys — Esc, Tab, the arrows, Ctrl-C, /, Enter — were always sent to a tile’s main terminal, even when you’d split that tile and were working in the split. They now reach whichever terminal actually has focus, so the helper keys land where your typed characters already do.

Quickstart

  • AddedThe kolu binary grows subcommands — `kolu web` names today's server1 PR
    kolu web now names what bare kolu has always done — the two spellings accept the same flags and boot the same server, so nothing you run today changes. kolu tui (a terminal-side canvas) is reserved: it exits with a clear not-shipped-yet message pointing at the plan, instead of silently booting the web server. kolu --help now lists the subcommands.
  • Changedkolu opens far faster on first load — the app bundle is served compressed1 PR
    Opening kolu fresh — a first visit, a cache-miss, or the first load after a deploy — used to download the whole ~2.6 MB JavaScript bundle uncompressed. The server now serves build-time-compressed assets with the right Content-Encoding, so that bundle arrives as 571 kB instead of 2.56 MB — 4.6× smaller. The difference shows up most where it hurt worst: opening kolu from your phone or over Tailscale. Repeat opens were already instant and are unchanged.

MCP

  • Added`kolu mcp` — drive your terminals from a coding agent1 PR
    kolu mcp connects a coding agent — Claude Code, Codex, any MCP host — to this host’s terminals over MCP (stdio), no browser involved. One claude mcp add kolu -- kolu mcp and the agent can create terminals, type into them, wait for output to settle or for another agent to finish or need input, read the screen, and watch which terminals need a human — the same live state the browser canvas shows. --host user@box reaches a machine over ssh. What an agent may touch is a deliberate, reviewed allowlist: creating, driving, and observing terminals are in; administering the background service, session policy, and canvas arrangement are not. See the MCP page.

Clipboard & Files

  • AddedDrag and drop video files onto a terminal1 PR
    Drag-and-drop upload now accepts video.mp4, .m4v, .webm, .mov, and .ogv — alongside the text, code, structured-data, document, and image types it already took. Drop a screen recording onto a terminal and, like any dropped file, it’s saved server-side and its path is bracketed-pasted into the PTY for the agent to pick up. The accepted set mirrors the video formats kolu previews in the Code tab, so what you can drop and what you can play back stay in lockstep.
  • ChangedUpload larger files to agents — up to 50 MB1 PR
    The per-file drag-and-drop / paste upload cap is now 50 MB, up from 10 MB. That leaves room for a useful app screen recording while keeping the whole-file upload path bounded. The same limit applies to every allowed file type and to pasted clipboard images, with matching checks in the browser and padi.

Keyboard Shortcuts

  • Added⌘K finds terminals and hosts too2 PRs
    The command palette is now a single search box for everything: type a branch, repo, command, or machine name at the root and jump there (nested command leaves included). Top-level terminals on every connected host (including plain shells; splits stay under their parent) rank above hosts above commands. Empty root opens with Recent, Hosts, a Terminals section, then the usual command sections. Recent follows a client-side visit trail (when you activate a tile), ranked by max(visit, server activity) so a visit competes with background server activity rather than always losing to it — including across hosts, with host chips on foreign rows; Ctrl+Tab uses the same trail on the active host (survives reload). Recent hides the active terminal, so ⌘K then Enter jumps to the previous one. The palette is width-capped; kind tags (term/host/cmd) appear only when you type a cross-kind search. Rows reuse Dock state pips and Dock identity (intent headline when set); cross-host hits switch + activate in one step. ⌘⇧K opens Terminals as a flat multi-host list under host headers; the dock search icon opens Terminals › this host; ⌘⇧H opens the same Hosts rows as root. Hover only follows a real pointer move. See The Switcher.
  • AddedMaximize a tile without the mouse1 PR
    Toggle a tile between the freeform canvas and maximized view straight from the keyboard with Cmd/Ctrl+Shift+M, or run “Maximize terminal” from the command palette. The command’s label flips to “Restore canvas” once you’re maximized, so it always names what the next click does.
  • AddedCmd/Ctrl+F finds in whatever you're focused on1 PR
    Find-in-terminal is now confined to the terminal — the one place kolu’s own search is needed, since terminal output is drawn on a canvas the browser can’t search. Press Cmd/Ctrl+F anywhere else — the Code tab’s viewer or diff, rendered Markdown, an .html preview, the palette, the workspace switcher — and it hands off to your browser’s own find-in-page, which reaches inside the preview frame too. With focus in a terminal the chord still opens kolu’s find bar.

Theming

  • ChangedChoose how new terminals get their theme — inherit or shuffle — and steer the shuffle1 PR
    The Settings “Shuffle theme” on/off toggle is now two settings. New terminal theme picks the strategy for a fresh terminal: Inherit copies the active terminal’s theme, or Shuffle (default) auto-picks a background perceptually distinct from your other open terminals. Shuffle behaviour then steers every shuffle: Auto (default) keeps picks in the app’s current light or dark family so a tile can’t come up as a jarring bright panel in a dark workspace; Dark / Light force one family; Random spans the whole catalogue. Your old setting carries over automatically.
  • FixedA terminal on the default theme keeps its theme pill when it loses focus1 PR
    Each tile’s title bar carries a small theme pill. A tile on the default theme (Tomorrow Night, none explicitly chosen) used to drop that pill the moment it lost focus, so in a canvas of several such terminals only the focused one showed a pill and it appeared to “follow” focus. Every tile now shows its effective theme name whether focused or not, so the pill stays put.

Power Features

  • AddedExport an agent session as a chat log or a full transcript1 PR
    The command palette’s Export agent session as HTML action now asks what kind of document to make: a lightweight chat log that keeps only the Human / AI conversation, a full transcript that keeps tools, results, reasoning, edits, and subtasks collapsed, or both. Both share the same high-contrast design — a patterned page, hard role colours, dark code blocks, and ↑/↓ controls for jumping between prompts. The chat log stays small by omitting hidden tool payloads.
  • FixedThe workspace switcher re-floats a terminal when a new agent starts after the last one finished1 PR
    The recency clock that floats your most-recently-active terminals to the top of the switcher had a blind spot: once an agent in a terminal finished, starting a new agent there didn’t bump its recency, so the terminal you’d just put back to work stayed buried. It also reset every terminal’s recency to “just now” on a server restart, scrambling the order after a redeploy. A precise signal now replaces the over-broad rule — a new agent identity bumps recency on kolu’s own clock, while re-observing the same session on restart or wake does not.

Surface

  • AddedProcedures can declare their error union — typed failures end to end1 PR
    A defineSurface procedure can now declare errors: { CODE: { data } } (oRPC contract-level typed errors): the handler mints the declared code via typed opts.errors constructors, every hop preserves it — a SurfaceMap’s keyed proxy and mirrorRemoteSurface’s forwarders included — and the client narrows the rejection with isDefinedError/safe to a typed { code, data } instead of parsing message prose or receiving an opaque INTERNAL_SERVER_ERROR. Undeclared throws still cross as INTERNAL_SERVER_ERROR — the fail-fast channel is untouched; declare exactly what a caller can act on. The daemon-supervisor’s endpoint also gained the honest verdict this exists for: a proven contract skew now reports as its own incompatible state carrying both versions, never collapsed into dead.
  • AddedAgents can drive any @kolu/surface over MCP1 PR
    The new sibling package @kolu/surface-mcp re-exposes any @kolu/surface spec to an MCP host — mapping each cell/stream/event to a subscribable resource and each procedure to a tool under a default-deny allowlist — so a coding agent can read and drive a live surface as structured tool calls. See exposing a surface to agents.
  • ChangedClosing a unix-socket listener now disconnects its connected clients1 PR
    Closing a serveOverUnixSocket listener used to only stop new connections — clients already attached kept being served by the “closed” host, their subscriptions and timers running until the process died. close() now runs the full teardown: stop accepting, disconnect every established peer (each connection’s subscriptions finalize and timers clear), remove the socket file. A daemon shutting down through the standard skeleton drops its clients cleanly instead of leaving them attached to a ghost.
  • ChangedAn abandoned remote session no longer keeps its host process alive1 PR
    The same cure on the client side: a makeSession session whose consumer dropped it without destroy() used to pin its host’s event loop with a ref’d reconnect timer — a process redialing a gone endpoint forever, immortal, respawning ssh children on every attempt. The session’s internal timers are now unref()’d, so a session never keeps an otherwise-finished process running by itself; a held session in a living process reconnects exactly as before, and the one timeout that settles a caller’s pending pin() still always fires. One consequence to know: a wait derived purely from session state (a pump parked on ClientCursor.next() across a reconnect gap) is not a process hold either — a process with nothing else left exits instead of waiting forever.
  • ChangedA daemon bin now exits when its tenure ends1 PR
    The same cure at the daemon layer: a binary built on daemonMain used to map the resolved DaemonExit to a process exit code itself — and a live resource or timer (a PTY child, a sampler interval) silently kept a finished daemon alive if a bin forgot that homework. The new daemonProcessMain({ name, run }) owns the process’s life instead: run the daemon to completion, exit with the right code, narrate and exit 1 on a crash — a lingering daemon is unspellable for a bin that adopts it. daemonMain’s contract is unchanged — it still resolves a value and never exits, so tests drive the lifecycle in-process — and it now arms its shutdown triggers before announcing readiness, so a supervisor that signals on “ready” can no longer catch the daemon unarmed. The kaval and padi binaries use the shared bin half.
  • Heads-upA remote agent on localhost spawns with a composed env, never the caller's1 PR
    The sshConnector / dialAgentOnce localhost arm used to spawn the agent binary with no env override, so Node inherited the caller’s whole process.env — the one spawn path an ssh boundary doesn’t scrub. Both now take a required localEnv: the complete env a "localhost" dial spawns with, so a locally-hosted agent can never silently inherit ambient identity vars or secrets. Omitting it is a compile error, not a review catch. surface-remote stays policy-free — the consumer composes the env (kolu from a fixed allowlist, kolu-pty’s composeSpawnEnv); a real ssh host ignores it (that child is the LOCAL ssh client, which legitimately inherits for SSH_AUTH_SOCK / ~/.ssh). Migration: add localEnv to every sshConnector / dialAgentOnce call.

Troubleshooting

  • AddedSee every process's health in the chrome bar6 PRs
    The chrome bar’s identity rail shows kolu’s live processes — kolu-server, this browser, and the kaval and padi daemons — each with its build, a health dot, uptime, and live memory, so a process creeping upward is visible at a glance. The memory reading stays honest: a padi that’s mid-reconnect reads (absent) rather than freezing its last figure, so a number on screen always reflects a live process. It prints the shared deploy commit once (a muted ≡ when the client matches the server, spelled out only when a stale bundle disagrees), and each daemon’s details open on click — memory, socket, and a lifetime row that reads forever for a production daemon or bound-to-run for a test one.
  • FixedReturning to kolu no longer flashes 'Disconnected' for a moment1 PR
    Switching back to the kolu app — or opening the lid after your laptop slept — could flash a brief “Disconnected from server” overlay before everything carried on. The connection was usually fine; the liveness watchdog mistook “the computer was asleep” for “the server went silent” — while a tab is frozen its timers pause but the wall clock keeps moving, so on resume an overdue health-check fired and forced a needless reconnect. The watchdog now compares real elapsed time against running time and ignores a check a sleep interrupted, and the “Disconnected” overlay waits a beat before appearing, so a sub-second reconnect never flashes the alarm.
  • FixedThe 'App updated' prompt no longer loops forever1 PR
    The “App updated — Reload to apply” card could reappear immediately after every click, leaving the client stuck a build behind no matter how many times you clicked. The cause: kolu baked the build version into a JavaScript bundle the browser caches for a year, so a deploy that changed only docs rewrote that file’s contents without changing its name — and your browser kept serving the year-cached old copy. kolu now carries the build version in the always-fresh page shell instead of the cached bundle, so a normal Reload always lands the deployed build and the card clears for good.
  • FixedThe packaged kolu honours KOLU_STATE_DIR again1 PR
    The Nix-built (production) kolu silently ignored KOLU_STATE_DIR, always storing your session, preferences, and activity feed under ~/.config/kolu — so running a second instance on one machine meant the two clobbered each other’s saved state, and the only way to relocate it was to hijack $HOME (which also moves your ssh and git config). The packaged wrapper now honours an inherited KOLU_STATE_DIR, while still defaulting to ~/.config/kolu when you don’t set one — so a second production instance can keep its own state directory.
  • Fixedkolu no longer freezes in the browser when a git command hangs1 PR
    Open kolu in a few worktrees and the whole web UI could go unresponsive — the page stops answering, terminals freeze — until someone restarts the server by hand. The cause: kolu resolved each repo’s git directory with a blocking git rev-parse on the server’s single work loop, so if that call ever wedged (a stuck filesystem, a contended repo) it froze the entire server for as long as it hung. kolu now does that lookup off the work loop with a hard time limit, so a slow or stuck git command can never freeze the server.

Before you update

  • Heads-upCross-site pages can no longer drive kolu's terminal RPC1 PR
    kolu’s terminal control plane is unauthenticated, so any web page you visited could quietly reach your local kolu and drive it — create terminals, type into them, kill them all — without you knowing. Both its transports (a WebSocket and plain HTTP) were reachable cross-site (a WebSocket upgrade needs no CORS preflight; a cross-site form can POST into an HTTP procedure), and binding to localhost didn’t help because the malicious page runs in your own browser. kolu now checks the browser’s Origin on every request, on both transports, and rejects cross-site ones; the kolu UI talking to itself and non-browser clients (a CLI, curl) are unaffected. If you front kolu with a reverse proxy or tailscale serve whose address differs from what kolu sees, allow it through with the new KOLU_ALLOWED_ORIGINS env var.
  • Heads-upGitHub Enterprise repos no longer show a PR pill (for now)1 PR
    To stop kolu mistaking non-GitHub repos for GitHub ones, kolu now treats only github.com as GitHub — it no longer guesses that an arbitrary remote host might be a GitHub server. The trade-off: a repo on a GitHub Enterprise host (your company’s own github.example.com) no longer shows its PR pill, even when gh is authenticated for it. This was a deliberate scope call — recognizing arbitrary Enterprise hosts needs per-host configuration, tracked alongside the broader multi-forge work. Everything on github.com is unaffected.

Release

v1.0.0

11changes

The first release worth a number. kolu is a terminal workspace built for running many coding agents at once — real xterm.js terminals as tiles on an infinite canvas, a dock that tells you which one needs you, and repo, branch, PR, and agent state read straight off the terminal with zero setup. Agent-agnostic by design: claude, codex, opencode, and whatever ships next week all work the same way.

nix run github:juspay/kolu/v1.0.0
Added
9
Heads-up
2

Added

  • Infinite canvas workspace — every terminal is a draggable, resizable tile on a 2D plane. Pan, pinch-zoom, snap-to-grid, and double-click to maximize; the posture persists across reloads.
  • The dock — a two-level (rail / cards) left-edge navigator with per-repo color and agent-state pips: a violet “your turn” disk when an agent needs you, a spinning teal ring while it works, sorted by pure recency.
  • Agent-agnostic by designclaude, codex, opencode, aider, goose, gemini, and whatever ships next week all work the same way; run one once and it surfaces in the worktree-naming flow and the command palette automatically. No registry, no adapters.
  • Command palette (Cmd/Ctrl+K) — search terminals, switch themes, run actions, and drive workspace search across 20+ metadata fields.
  • Dock pings & completion toasts — when a background agent finishes, the dock pulses, a toast names the terminal with a Switch action, and a backgrounded tab gets an OS notification.
  • Git & GitHub, auto-detected — repo, branch, and CWD from OSC 7; live PR merge-state and CI checks on every navigator surface; per-repo hue coding; whole-tree git-status colors in the Code tab.
  • Rich Code tab — syntax-highlighted browsing, rendered Markdown with a source toggle, and sandboxed inline preview of agent-generated .html / .svg / .pdf / image artifacts, all live-reloading.
  • Terminals that keep up — WebGL rendering with canvas fallback, clickable path:line file references, inline images (sixel / iTerm2 / kitty), splits and tabs, and per-terminal font zoom.
  • Mobile — single-tile view with swipe navigation, a two-row soft-key bar for the keys touch keyboards lack, and a bottom-drawer inspector.

Heads-up

  • kolu ships only as a Nix flake — there are no installers. Install Nix, then nix run github:juspay/kolu. The same command updates it; add --refresh to bust the flake cache.
  • A home-manager module runs kolu as a systemd user service on Linux and a launchd agent on macOS.