Next release · live on master
Unreleased
Everything that has landed since v2.2.0.
nix run github:juspay/kolu- Added
- 44
- Changed
- 17
- Fixed
- 37
- Heads-up
- 5
No release note matches that filter.
The Dock
-
AddedA dock row names the model its agent is running on1 PR
The dock answered who is driving and what they are doing, but never on what — telling an Opus session from a Haiku one meant opening the tile or the right panel. Each agent row’s second line now ends with the model its session reported, read off the same agent record as the status words beside it, so a fleet spread across several models reads at a glance — and a split’s entry is now the very same row (indicator, annotation, status words, model, timestamp, in its worktree’s colour, opened by a└and stepped in per hop), rather than a cut-down one-line variant, so a sub-agent on a different model reads at the same size and in the same columns as its parent. The model is a session fact, so the tag stays put while the agent works — a prompt or a tool result being the newest thing on disk no longer blanks it on Claude Code and OpenCode, which used to read it off the newest transcript entry — and a session that has not named a model yet shows nothing there rather than an “unknown”. -
AddedDrag the dock into your own arrangement1 PR
Repo sections and branch clusters are now rearrangeable by drag-and-drop: drag a repo’s header (the monogram + name) to move the whole section, and drag a branch cluster by any of its rows to move the cluster within its repo. Everything you never touched keeps its old structural behaviour — a new repo or branch still appends at the bottom, filters only hide rows, and ⌘1..9 stays stable. The arrangement is sticky, per host, and survives a reload; it’s written back in full on every drop so the next merge rule stays one. -
AddedXyne is a first-class agent
Runxynein any terminal and kolu knows it — the tile and dock row light up with the Xyne badge and the session title, the agent inspector shows its model, and sleeping or restarting kolu resumes the exact conversation withxyne --session <id>. Xyne’s on-disk transcript carries no live activity phases, so a Xyne tile shows the plain waiting state rather than the busy / needs-you motions until Xyne publishes a live signal. -
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 orpadi-tui create --parentcan 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 orpadi-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 tolsoffor 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’sparseIntreader — 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 (agedspawn-helperprocesses 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 passedrollup — 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 withoutrecursive. 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 yournode_modules— it leaves out whatever.gitignorehides. 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 entry —node_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 watchingnode_modulesis 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, andkoluitself — simply weren’t on the machine, sokolu mcphad 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_SOCKETis already set, so a plain.mcp.jsonnamingkoluconnects with no flags and no paths. Your ownPATHis otherwise untouched, and in bash and zsh kolu re-applies its entries after your dotfiles run, so aPATH=line in~/.bashrcor~/.zshrccan’t quietly remove them — in fish and other shells the tools are only placed in the terminal’s starting environment, so an absoluteset -x PATH …inconfig.fishwill 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 withssh-copy-id, runsshto 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.
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. -
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 ownnix.confdecides 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-timenix.confsetup (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 itstrusted-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 samenix.confblock, 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 error1 PR
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. Two things had to change for that to be worth reading: the card had to stop dropping the lines, and 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. -
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.
Port forwarding
-
AddedClick a port on any host, get the page2 PRs
A dev server on127.0.0.1:5173is 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. Anssh -Ltunnel 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 (⌘K→ Forward a port…, acceptinghost: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 alocalhostURL the agent printed — click a loopback link in the terminal (localhost,127.*,[::1], or0.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 on0.0.0.0yourself — so keep it to a network you trust.
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 setsautocomplete="off", so iPadOS Safari no longer pops password / AutoFill suggestions when a terminal gains focus (xtermjs#6057); and after a program clears scrollback withESC[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
-
ChangedAn agent now wears the same mark on its tile and on the dock1 PR
Every agent’s identity mark lived twice — one drawing for the tile’s title chrome, a different one for the dock pip — so the same agent could read as two different brands depending on where you looked. There is now one mark per agent, the dock’s 24×24 brand mark, and both surfaces draw it. The dock pips are unchanged; Claude Code, Grok and OpenCode tiles now show their dock mark rather than their old standalone icon, so those three look a little different. -
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/namewhen you set one, and its exact age. Sleep and wake resume the conversation it was in (pi -c, orpi --session <uuid>for the exact one), launch-shape flags such as--model/--provider/--thinking/--nameride 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 scratchPI_CODING_AGENT_DIRan 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. -
Addedkolu now follows your Oh My Pi sessions terminal by terminal1 PR
Run oh-my-pi (omp) — a fork of pi with its own storage and its own idea of how a session is found — in any terminal and its tile lights up like the other agents’: thinking while it generates, running tools during a call, waiting when its turn ends, and awaiting you when an approval prompt or anaskquestion is on screen (kolu reads those off the screen, as it does for Claude Code), painted with omp’s own mark — the three-stem glyph of its site favicon, so it never reads as pi’s π — on the dock, tile, and minimap.ompis the easiest agent to place: it records which session each terminal is writing to, so twoompwindows in one project each keep their own conversation and nothing has to guess. kolu follows that record — the tile shows the model, the running context size, and the session’s own title as it streams. Sleep and wake resume the exact conversation (omp -c, oromp --resume <path>for that one), launch-shape flags such as--model,--thinking,--approval-mode, and--profileare kept across a wake, and the command palette exports the session as a chat log or full transcript. -
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 acodexyou 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.mdxfile 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.mdand.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 rotatedbackups/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 reportedhttp://:::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 printshttp://[::]: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 samegh pr viewthe 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 createrequires exactly one of--toplevelor--parent <id>, and there is no default — neither flag, or both, is refused with the rule. A terminal you create with--parentis 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--parentwas 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--toplevelto any script that used a barepadi-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--hostno longer costs you a cold machine first. The linecreateprints 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 atkolu createand 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 itsPATH, 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 intokoluitself, at which point an agent in any terminal opened by a surviving daemon rankolu --help, saw three subcommands where the README promised eleven, and reasonably concluded the feature didn’t exist. The stale copy sat first onPATH, 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 remote host that fails to set up now tells you what actually failed1 PR
When a remote machine could not be set up, kolu’s card said Can’t reach this host, gave'nix build' exited with code 1as the reason, and suggested adding your user to the host’strusted-users— for a host that answered every time, whose real problem (a download refused with HTTP 403 inside the build) had scrolled out of the card’s output under a pile of “1 dependency failed” lines. Every part of that was a guess. kolu now reads Nix’s own tagged log instead of its free text, so the card’s detail line is Nix’s actual error plus the last thing the failed build printed (for exampleCannot build '…crate-humantime…' — cannot download … from any mirror), and that error is the last thing in the output under it. The headline says only what kolu knows: Kolu couldn’t set up this host when the host answered and setup kept failing, Connecting to this host kept stalling when a step went quiet too many times. A host is called unreachable only when ssh itself says so — never because a build’s own log mentioned a connection — and the status chip for a failed host reads failed, not unreachable. The guessedtrusted-usershint is gone, retry lines now say what each attempt ran into, and the canvas between attempts reads Reconnecting… with that output instead of a blank Connecting…. Separately, adding your user to a host’strusted-usersnow actually lets kolu copy the agent there: the copy used to ask for signature checks even from a trusted user, so a locally-built agent was refused and the host compiled it anyway. -
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 anotherProxyCommand, 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 withnix runcarries 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
-
AddedServers that detach from their terminal still show up in Ports1 PR
Some tools start a web server and let it go:odu’s web daemon, anything undersetsid, a double-forked dev server. The server leaves the terminal that started it and keeps serving, and kolu used to lose track of it — the Inspector’s Ports list skipped it, a click on the URL it printed said nothing is listening yet, and a door opened onto it by hand was closed again within seconds as if the server had died. kolu now reads every listener on the host. A server whose URL a terminal printed is listed under that terminal, marked detached, with its full command line. Everything else serving on the machine is one click away under a folded elsewhere on this host group, each row naming the terminal that serves it when one does. The printed-URL card finds the server wherever it listens, and only says nothing is listening when nothing on the whole host is. Doors onto a detached server now stay open for as long as it serves. -
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 tablekolu mcpserves 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 20reads 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), andkolu surface listprints the whole verb table without dialling anything, so a dead endpoint still lists.kolu surface --helpis 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 behindkolu surface <verb> --help. The introspection plane rides along —keysandgetover theterminals,urgency,daemonStatus,status,identityviews, each with a--followform 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;--jsonpurely 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-clipackage 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 — wherekolu snapshotflattens 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 writeskolu-screenshot.pngunless 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, sokolu snapshot | grep-style scripting keeps working the way it did.--lines Ncaptures 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 watchcould 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,awaitingreports 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 60sreports a state only once it has held that long, so an agent handed more work inside the window is never reported at all.--nag 5mre-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/3makes 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--nagstill 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-selfkeeps 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--ignoreprefix warns on stderr. Piped throughgrep/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 10semits 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 10sis 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--jsongives 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-tuifor what a terminal is (create it, wait for its agent, watch the roster) andkaval-tuifor 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 onkoluitself:kolu lsfor the roster,kolu create(which now REQUIRES a placement —--toplevelfor a tile of its own or--parent <id>for a split — plus--worktreefor a fresh branch and-- <command>to launch an agent in it),kolu send,kolu wait,kolu debrief,kolu snapshot,kolu history,kolu killandkolu watch. One--untilcovers both kinds of waiting —--until idle:2000for raw output going quiet on any terminal,--until awaiting,waitingfor 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 exactlykolu wait <id> --until awaiting,waiting --settled 15000 --snapshot 40, and those twowaitflags stand on their own:--settled <ms>adds “…and no output for this long” to any--untilcondition — 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 ascreenkey under--json. Asking for them together is not merely shorter than three separate commands: output can move in the gap between onewaitfinishing and the next starting, and the screen a separatekolu snapshotreads 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 (soid=$(kolu create --toplevel)works) with prose on stderr,--jsonis 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 createandkolu create --host boxare the same command, which the old tools rejected.padi-tuiandkaval-tuistill 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 thekolucommand itself. Runningkoluwith no arguments no longer starts the web server — it prints the list of subcommands and exits non-zero, so you pick one explicitly. Spell itkolu webto get the server, and update anything that launched the bare command: a systemd unit, a shell alias, anix runinvocation. The home-manager module already spells it out and needs nothing from you. Andkolu web --hostis nowkolu web --bind.--hostmeans “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-keyand--verboseare untouched. If you run kolu behind a proxy or on a LAN address,kolu web --bind 0.0.0.0 --port 7317is the new spelling.
MCP
-
AddedInstall the kolu skill and MCP server as one plugin1 PR
The exportedagent-plugin/package bundles the terminal-driving skill andkolu mcpfor Agent Plugins-compatible hosts. Claude Code users can install it from thejuspay/kolumarketplace; Oh My Pi users can load the directory with-e. Nix consumers can use the directory directly from a flake input. See the installation instructions. -
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 newscreen_imagetool 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 withlines.screen_textis 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_nextreports 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_opentakes 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), andnagMs(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).ignoreSelfmutes the terminal the MCP server is running inside (refused if the transport cannot identify the caller, rather than guessed), andignoreIdsmutes known terminals fail-open — a stale id costs nothing, and every new terminal is still watched, which is why this is the mute andidsis 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, ornag. These are the same knobs askolu 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_createtakes a requiredplacement—{"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 optionalparentIdfield 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 advertisesplacementas 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 createandpadi-tui createtake 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_createused to be the lesser sibling ofkolu 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 typekolu create --toplevel --repo … --worktree …into it, then fish the id back off the screen. The verb is now complete: passrepo+worktreeand 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, passrunand a first command line is typed at its shell prompt and submitted — the wholekolu 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, andrunis 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:cwdandworktreeexclude each other,repoonly means something withworktree— and over MCP,worktreerequiresrepo, 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 spawningkolu mcp— nothing about using kolu changes. Deciding whether to do that turns out to be harder than looking forkoluon 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. Andkolu mcpwill 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/detectpackage 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_outputSettledandwait_agentState— each take two new options, and both exist because of what happens between calls.settledMsadds “…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: Nhands 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 separatescreen_textafterwards can only ever show a screen that has already moved on. Together —wait_agentStatewithuntil: ["awaiting","waiting"],settledMs: 15000,screenTail: 40— they are the same protocolkolu debriefruns 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 mcpused 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 mcpcame 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 tokolu mcplooks like whenkolu mcpitself 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 toldkolu mcpthe 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, thatkolu mcpis 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. Pointingkolu mcpat another machine with--hostis 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_openstarts watching (everything, or a named few),watch_nextblocks until any watched terminal needs you and hands over everything that happened since you last asked, andwatch_closestops. 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-lesskolucommand,kolu mcpincluded, 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 spawningkolu mcpis a background service: there is nobody there to type anexport, 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 plainkolulooks ($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 thekoluyou 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-tuikept 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_SOCKETpicks 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 fromkolu mcpnow 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_sendInputrefuses 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 andwatch_next— so a host that shows one lists “Send input to a terminal” instead oflifecycle_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-mcpor@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 })andsurfaceCommands({ 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 — whileliststays at the top and answers for the whole bundle. One thing to re-read if you scriptkolu surface list --json: itsresourcesrows are now one per ADDRESS rather than one per member, so a collection appears twice —get terminalsfor an item andkeys terminalsfor 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 twolist_changednotifications — which is why bothlistChangedcapabilities 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 }becomescore: { 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 reachedclient.surface.xreachesclient.core.surface.x. -
AddedAn MCP endpoint's `instructions` can be composed from the roster a host actually gets1 PR
For anyone building on@kolu/surface-mcp; nothing about kolu itself changes.instructions— the paragraph a host reads atinitialize, before its agent calls anything — was a string fixed when the server was built. On a bundle whose siblings come and go that string could only ever describe the boot roster: a sentence a sibling owns (“thechatsibling shows your answer to a person”) is true only while that sibling is standing, and an endpoint that keeps saying it after the sibling has left is teaching the agent something false.instructionsnow also takes a function, read at eachinitialize— once per host connection, on the request path, never at construction — so the text is composed from the rows standing when a host asks, the same way the tool list already is, and a sentence leaves with its row for the reason a verb does. A string behaves exactly as before, and an empty result is noinstructionskey. One honest limit, which is the protocol’s: MCP haslist_changednotifications for tools and resources but none forinstructions, so a host already connected keeps what it was told until it reconnects — only the next host sees the move. -
AddedA surface app's listener now serves the generation that is live at each accept1 PR
For anyone serving a@kolu/surface-appwhose 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 readgroup,handlersandexposeonce, 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: anRpcServeris 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.implementRootedSurfacesserves 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,connectSurfacesgrowsconn.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,readoutandhealthkeep 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.redialre-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:narrowRowVocabpairs 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, andrecencyText(mode, at, now)says them — clock still yours, cadence still yours.resolveTerminalId(“any unique prefix”) andtailLineswere stranded in the daemon package a contract consumer exists not to install, and are now zero-import leaves on@kolu/padi-client.chunkPatternnames a split bundle the waybuildSurfaceClientemits 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_SIZEjoinsFONT_FAMILYinterminal-themes, where painting a terminal already looks. Two packaging fixes come with them:@kolu/detectandterminal-themesare declared vendorable, so the derived hydration closure can be adopted at all; andconsumer-closure.jsonnow carries the revision of every pinned graft, withconsumer.nixrefusing 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.cssimports 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-mcpcould only answer in words. A tool declaresrender— how its success value becomes content — and one whose answer is genuinely visual returns an MCP image block viaokImage, 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.renderis declared per tool rather than sniffed per result, so a handler cannot change its own content type call to call. kolu’s ownscreen_imageis the first consumer. -
FixedA subscription over a long link catches up in one round trip instead of one frame per round trip2 PRs
For anyone building on@kolu/surface, and for anyone reading a kolu surface from far away: a subscription’s messages are acknowledged one at a time — the server sends, waits to hear it arrived, then sends the next. That is what keeps a slow reader from being drowned, and it meant something nobody intended: the server was sending exactly one update per round trip, however many had piled up behind it. On a laptop, where a round trip is a tenth of a millisecond, this is invisible and always was. On a real link it is the only thing that matters — a producer publishing faster than the round trip can never be caught up with, so the reader falls further behind with every update and stays there, and a fatter pipe buys nothing at all because the wire is idle between messages. The case that found it: a chat panel in Canada watching an agent in India, where an answer the agent finished writing in ten seconds took eighty-two to finish arriving, painting five updates a second while the reader watched a paragraph that had been written a minute earlier. What goes out now is everything published since the last message went out, so a burst costs one round trip rather than one each, and a reader who has fallen behind catches up in a single message. Nothing about the protocol changes and nothing you write changes — the messages that were going to be sent are sent together. The buffer that does this is deliberately bounded: a reader who has genuinely stopped reading still backs a producer up, so whatever limit that producer declared for a backlog nobody is reading is still the one that decides. An earlier attempt at this (2199) left the buffer unbounded and quietly retired those limits — it was reverted the same day and is re-landed here with the bound and the tests that hold it. -
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 yourreadOneis 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 optionalholdersnow. 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/surfaceconsumers: 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:initanswers 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;stepanswers 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 yourstephas 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.serveSurfaceAppandserveOverUnixSocketnow take the sameexposeallowlist 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. Leaveexposeoff 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 (atailscale servelogin, 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.serveSurfaceAppnow takesupgradeHeaders— the header names this app wants — and hands their values to theserviceslayer 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 wholeCookieandAuthorizationof 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 isundefinedforever, and a name no request could carry — one wire header named twice, orset-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 plainstring[]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.checkUpgradeHeadersis exported so an app can fail the offending part loudly where it mints the list. One thing to change on upgrade: the connection handed toservicesno longer carries node’s raw request object — name the headers you need and read them offconnection.headers, with the direct peer atconnection.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-apprather 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.buildSurfaceClientnow 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: aimport()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 yourimport()deferred stays deferred, which is the whole point of having written it. One thing about that dist is now yours to say:assetPrefixmoves 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 usesserveSurfaceApp, 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 aSIGINT’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 carrystructuredContentbeside 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 singlevaluekey a non-object input is already wrapped in. A refusal can carry detail: fail with the newToolFailure(message, detail)and the agent gets anisErrorresult whosestructuredContentis 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-Errorfailure value as[object Object]. Andinstructionsis now passed through toinitialize— 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 optionaltitlefor hosts that render a display name. -
AddedAny @kolu/surface can now be driven from a shell2 PRs
The new sibling package@kolu/surface-cliprojects a@kolu/surfaceas command-line verbs, the way@kolu/surface-mcpprojects 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.--helpon 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--jsonasks 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) declaresendpoint.streaming: false, and thenwatchand--followare 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 wordnullto clear it, where it used to want the field’s own JSON (--desc '"the brass ones"'); a note whose text is literallynullgoes through--input, and the flag’s own help line says so.surfaceCommandshands 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.sshConnectortakes akeepalive: 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.