Next release · live on master
Unreleased
Everything that has landed since v2.2.0.
nix run github:juspay/kolu- Added
- 30
- Changed
- 12
- Fixed
- 21
- Heads-up
- 5
No release note matches that filter.
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
-
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. -
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 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
-
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
-
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. -
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.