Changelog¶
The full release history, generated from the repo-root CHANGELOG.md.
Changelog¶
All notable changes to this project will be documented in this file.
The format follows Keep a Changelog. Releases are
cut with /release-workflows:release vX.Y.Z — it curates the section below, commits, tags
vX.Y.Z, and pushes; the tag triggers .github/workflows/release.yml, which
builds the DMG + .debs and publishes to the apt repo. Bump
[workspace.package].version in Cargo.toml to match before tagging (the
release workflow asserts they agree).
v0.0.26 — 2026-10-19¶
The Mac-host release (#643): Roost-Iced can install and upgrade
roost-session on an Apple-silicon Mac reached over SSH, the same as a
Linux box, and the release now publishes a darwin-arm64 session binary
for Linux clients to download. A request sent right after tab.open
also no longer races the tab's attach. The Swift app is unchanged. There
is no session-protocol bump. To update a Mac host, only the client
needs this release: a Mac client sends its own bundled roost-session.
Added¶
- Roost-Iced can install and upgrade
roost-sessionon a Mac over SSH. An Apple-silicon Mac host now gets the same Install Update…, install-on-connect and restart flows as a Linux box, where it used to be refused with "roost-session is built for Linux only today". A Mac client sends its own bundledroost-session; a Linux client downloads the newroost-session-<version>-darwin-arm64release asset, checksum-verified. The install lands in~/.local/binon the Mac, independent of any Roost-Iced.app installed there. Intel Macs have no build and are refused before anything is offered.
Fixed¶
- A request right after
tab.openno longer races the window's attach. In Roost-Iced,tab dump,tab send-fileor a test-mode op sent as soon asroostctl tab open(or anytab.openclient) answered could getnot-found: tab N has no live terminalfor up to 25 ms, until the window's next attach retry. The window now attaches a waiting local tab whose terminal is ready before it answers a request. A narrower window, a tab listed before its PTY exists, is tracked in #640.tab.capture_pty_inputanswersnot-enabledonly whenROOST_TEST_MODE=1is missing, andnot-foundfor an unknown or not-yet-attached tab, where it used to answernot-enabledfor all three (#637).
v0.0.25 — 2026-10-15¶
The Agents panel release (#629): Roost-Iced's sidebar lists every agent on every host in one panel under the projects, with a divider you drag and a sort you pick, in place of the agent rows nested under each project. Jumping to a tab from the agents or notifications palette, Jump to Unread or a notification banner no longer opens a collapsed sidebar, and a sidebar width drag now survives a toast expiring or a quit. The Swift app is unchanged. There is no session-protocol bump.
Added¶
- Sidebar Agents panel — Roost-Iced's sidebar lists every agent on
every host in one panel below the projects, in place of the agent rows
nested under each project. A row shows the agent's state dot, its name
and its age on one line and its status on the next — after its host
and project when the panel isn't grouped — elided to the sidebar's
width rather than wrapped, and the ages keep counting while an agent
is working or waiting. Project rows keep their rail and notification
dot and gain an agent count, and a disconnected host's agents stay
listed, dimmed. Toggle Sidebar Agents (
show-sidebar-agents) now shows and hides the panel. Drag the divider between the projects and the panel to resize it; its height is remembered across relaunches. The divider is always there, and the panel keeps its height whether or not any agents are running, so nothing moves when the first one starts or the last one exits. Agents panel sort — newagent-panel-sort = grouped|priority|recentconfig key (defaultgrouped; an unknown value warns and falls back), cycled by the new unboundcycle_agent_panel_sortaction, the palette's "Cycle Agent Panel Sort" and the panel header's sort word, which write the choice back toconfig.conf.app.sidebar_dumpgainsagent_panel(IPC). Roost-Iced only; the Swift app is unchanged.
Changed¶
- Jumping to a tab no longer opens a collapsed sidebar — in Roost-Iced, choosing an agent in the agents palette, a row in the notifications palette, Jump to Unread or clicking a notification banner now focuses the tab and leaves a collapsed sidebar collapsed, as the Cmd-1..9 shortcuts and new tabs already did. The Swift app is unchanged.
Fixed¶
- A sidebar drag survives a toast going away, and quitting. In Roost-Iced, if a toast expired or was cleared while you were dragging the sidebar's edge, or you quit before releasing, the dragged width was never saved. The next relaunch restored the old width, and after a toast expiry the sidebar stayed pinned at the dragged width until the next drag. Both now keep the width the drag reached, and the same holds for the new Agents panel divider.
v0.0.24 — 2026-10-10¶
The agent-indicator release (#619, #620): an agent that another agent
runs, such as a codex exec review started from a Claude Code turn, no
longer takes the tab from it, and the agent reports Roost drops are now
visible on the tab and in roostctl doctor. Roost-Iced also gains Next /
Previous Project keybinds. The Swift app is unchanged. There is no
session-protocol bump. Session tabs get the ownership fix once their
roost-session is on 0.0.24: Install Update, then Restart Session.
Added¶
- Next / Previous Project —
cycle_project_nextandcycle_project_prevstep through the sidebar's projects in Roost-Iced:Cmd-Shift-→/Cmd-Shift-←on macOS,Alt-Shift-→/Alt-Shift-←on Linux. Like tab cycling, they stop at the first and last project rather than wrapping, and they skip projects with no tabs. Both are in the command palette ("Next Project", "Previous Project") and, on macOS, the File menu. The Swift app is unchanged. See Keybindings. - Dropped agent reports are visible. A
tab.agent_reportthe server refuses used to vanish without a trace. Each tab now counts the reports it dropped and keeps the latest, with its reason (not_ownerornested_claim, and whether the tab had an owner then), onTab.dropped_reports. The server logs the first drop of each(source, session, reason), remembering the last 16 per tab, androostctl doctorshows the record and flags a report dropped while no agent owned the tab: if that agent is still running, it has lost the tab until its next session start. SeeTab.dropped_reports.
Fixed¶
- An agent another agent runs no longer steals its tab. A
codex execreview (or any agent CLI) started from inside a Claude Code turn inherits the tab'sROOST_TAB_ID. ItsSessionStartused to evict Claude as the tab's owner and itsSessionEndthen released the tab, so the sidebar row disappeared while Claude kept working. Claude's later status and its "Turn complete" notification were dropped too, and the inner agent's own notification fired in their place. Roost now ignores a claim from a different agent while the owner is mid-turn under a foreground process; a new agent started after the old one exits to the prompt still takes the tab. Needs shell integration that marks command starts (zsh, or bash 4.4 or newer). Roost-Iced androost-sessiononly; the Swift app is unchanged. See Agent Hooks → Ownership. - Test suites no longer exec a script they just wrote (#615, #618).
A sibling test forking in the same process could hold the script's
write descriptor, so the exec failed with
Text file busy. The iced, CLI, agent and agent-install suites now run committed fixtures, and the agent-install fallback tests also prove the hook actually ran.
v0.0.23 — 2026-10-07¶
The session-update release (plan 076): Roost-Iced now shows each host's
session version in its right-click menu, and updating a session is two
separate, confirmed steps, Install Update and Restart Session, under one
rule: never downgrade. The Swift app is unchanged. There is no
session-protocol bump. A running 0.0.22 roost-session keeps working, and
the menu offers to move it to 0.0.23 when you're ready.
Added¶
- Session versions and separate Install / Restart actions (plan 076) —
in Roost-Iced, every host's right-click menu now shows its session
version in a header (
Session 0.0.21 · 0.0.22 available,up to date,0.0.22 installed · restart to use it,newer than this Roost,needed to connect), and project and tab menus open the host's rows with a header naming the host. Install Update… (SSH only) installs the matchingroost-sessionwithout restarting, so the running session and your programs keep going; Restart Session… (every transport with a usable build) restarts onto the newest usable build and says which one it will run and which tabs it will end. The band gains aneeds updatepill for a session that cannot connect andupdate Roostfor one newer than this Roost. Both actions re-check the session and the binary on confirm and abort with "the session changed; check again" if either moved. - Never downgrade — a restart never runs a build older than the running session or one this Roost cannot connect to, and an install is refused when this Roost is older than the session or the host already has a newer build. A session newer than this Roost now shows "Update this Roost to connect" instead of offering to install an older build.
- Dev marker — builds not produced by the release workflow (local
builds, prerelease tags) report
devand a short git sha insession.identify; the menu showsdev a1b2c3d. Sessions started before this release report neither, so a pre-076 dev session reads as a release build until it is restarted. host.updateandhost.restartops, andhost.statusupdate— UI-socket ops ({id, confirm: true}, noroostctlverbs) that run the same paths as the menu rows;host.statusgains an optionalupdateobject (state, session and client builds, the restart offer, and the latest action'srunning/done/failedoutcome). Seeipc.mdand the host sessions guide.
Changed¶
- Restart works over SSH, and Update is for sessions that cannot
connect — the one all-or-nothing "update and restart" is split in two.
host:update:<id>(Update roost-session…) is now offered only when the session cannot connect over SSH; reduced fidelity opens Install Update… or Restart Session… depending on where the stale build is. Roost never probes an SSH host in the background, so another machine's install shows as "available" until you start an action.
Fixed¶
- Readable session header in the macOS right-click menu (#613) — the host block's header was drawn in AppKit's faint section-header gray. It's now a label in the system's secondary text color, still skipped by the arrow keys, and capped at the menu's width so a long host label is truncated rather than widening the menu.
v0.0.22 — 2026-10-04¶
The Mac-gap release before v0.1.0 (plans 073 and 074): Roost-Iced gains
what a Swift-app user would miss, and the Swift app is unchanged. It brings
the macOS selection pasteboard and middle-click paste, pasting copied files,
Settings, Help and Full Screen in the menu bar, right-click menus, the system
accent, Option as Meta, Mac-sized text and glyphs with a bundled JetBrains
Mono, Secure Keyboard Entry, selection auto-scroll, a remembered window frame
on macOS, and the Wayland raise on a notification click. If you never set
font-family, the terminal font may change (see Changed). There is no
session-protocol bump: a running roost-session picks up its side
(password-prompt detection) when it restarts.
Added¶
-
Pasting copied files (plan 073, #576) — in Roost-Iced, ⌘V / Ctrl+Shift+V with files on the clipboard (copied in Finder or a file manager) does what dropping those files on the tab does: a local tab gets their shell-escaped paths, and a host tab uploads them and gets the host paths. Any file type, not only images. On macOS the files are read before the text, because Finder's text for a copied file is only its name. On Linux the text still comes first, so a file manager that also copies its paths as text keeps pasting that text; a clipboard that carries only
text/uri-listnow pastes the files. A test-modeclipboard.write_filesop seeds such a clipboard (seeipc.md). -
iced: Settings…, Help, Full Screen and Agent Hooks… in the macOS menu bar, and three new actions (#577) —
open_config(⌘, on macOS) opensconfig.confin your default text editor, creating it first if it is missing;open_docsopens the documentation site;toggle_fullscreen(⌃⌘F on macOS) flips full screen, and the View item reads "Enter Full Screen" or "Exit Full Screen" to match the window. The macOS menu gains Settings… (Roost menu), Agent Hooks… (View) and a Help menu. On Linux the three actions are unbound and reachable from the command palette ("Open Settings File", "Open Documentation", "Toggle Full Screen"). -
macos-option-as-altsends Option as Meta in Roost-Iced on macOS (#341) —false | true | left | right, Ghostty's key and values, defaultfalse(today's behavior). With it set, ⌥B sendsESC binstead of typing∫, so readline's ⌥B and ⌥F word jumps work, andleftorrightkeeps the other Option for typing characters. The dead-key chords (⌥E, ⌥U, ⌥N, ⌥I, ⌥`) still start accents (#582). Linux ignores the key. Seeconfig.md#macos-option-as-alt. -
The iced chrome follows the macOS accent color (plan 073) — the active tab and project fills, the notification dots, the drag outline, the focus ring, the confirm button and the palette's match highlight take the system accent, resolved for the dark chrome as the Swift app does, and follow a change in System Settings while Roost runs. A new
chrome-accent = system | #rrggbbkey pins a color instead; on Linuxsystemkeeps Roost's blue (#578). -
Right-click menus in Roost-Iced (plan 073, #338) — right-click (or Control-click on macOS, where the menu is the native one) a tab, a project or a host's band in the sidebar. A tab offers Rename…, New Tab Here (a new tab in that tab's directory), Copy Path and Close Tab. A project offers New Tab, Rename…, Copy Path, Open in Finder / Open in File Manager when its folder is on this machine, and Close Project…, which still asks first. A host's band offers that host's own command-palette entries — Connect, Disconnect, Stop Session (which asks first), and Update or Restart when its session is out of date — and a project on a host lists them below its own; Remove Host stays in the palette. Test-mode
app.context_menu_*ops list and run a row's menu (seeipc.md). -
Secure Keyboard Entry on macOS (plan 074, #593) — Roost-Iced turns on macOS Secure Keyboard Entry, as Terminal.app, iTerm2 and Ghostty do. It is automatic at password prompts (
macos-auto-secure-input, default on), including asudoprompt in a host-session tab. A remembered toggle lives in Roost › Secure Keyboard Entry, the command palette and the newtoggle_secure_inputaction (macos-secure-keyboard-entry), and a lock at the right end of the tab band shows while it is on (macos-secure-input-indication). It is off whenever Roost is not the active app. On Linux the action does nothing. -
Tabs report a password prompt (plan 074, #594) — a tab is at a password prompt when its PTY is in line mode with echo off. Whichever process owns the PTY samples it every 200 ms (the UI, or
roost-sessionfor host tabs), and it appears as apassword_inputfield ontab.listand atab.password_inputevent. No session protocol bump. -
Selection auto-scroll (plan 074, #342) — in Roost-Iced, holding a selection drag past the top or bottom of the terminal scrolls history and keeps extending the selection, so a copy can span more than one screen. A drag released outside the window keeps the end it reached. The test-mode
tab.dispatch_mouse_eventgains an optionalovershoot. -
Roost-Iced remembers its window frame on macOS (plan 074, #595) — it reopens at the size and position it was closed at, moved back onto a visible screen if that display is gone, as Ghostty does. Linux is unchanged.
-
The theme's
cursor-textcolor (plan 074) — the glyph under a block cursor takes the theme'scursor-textcolor, as in Ghostty. On the 14 bundled themes whosecursor-textdiffers from their background, that glyph changes color; the other 11 that define it look as before. Whencursor-textequals the cursor color the glyph keeps the background. A malformed theme color is now ignored instead of risking a panic. -
Clicking a notification raises the window on Wayland (plan 074, #351) — Roost-Iced spends the notification server's activation token through
xdg_activation_v1, so the click raises the window as well as focusing the tab. X11 and macOS are unchanged. New test-mode opsapp.notification_activateandapp.last_activation. -
app.window_metricsreports cell geometry and window focus (plan 074, #605) —terminal_left,terminal_padding,cell_widthandcell_height, pluswindow_focusedandnative_focus_losses(the OS's focus losses only, neverapp.set_window_focus's), all additive. -
A Mac real-input test harness (plan 074, #189) — real CGEvent and Accessibility input against Roost-Iced, through a TCC anchor app (
make e2e-iced-real-input-mac). It covers Option as Meta by side, Shift+Enter, the native context popup, full screen, Secure Keyboard Entry, selection auto-scroll, the remembered window frame and SGR mouse clicks, and runs as a blocking step on GitHub's hosted macOS runner. Seetest-automation.md.
Changed¶
-
JetBrains Mono is bundled as the iced UI's terminal font (#580) — the default
font-family(JetBrains Mono, Monospace) now renders JetBrains Mono on every machine, installed or not, and a family that does not resolve falls back to it too, instead of to whatever monospace font the system has. If you never setfont-familyand JetBrains Mono was not installed, the terminal font changes after this update; setfont-familyto keep the font you had. A font you configure that is installed resolves as before. Seefonts.md. -
Roost-Iced on macOS sizes text like a Mac (#581) — a point is now one pixel, as in the Swift app and other macOS terminals, so the text shrinks: a
font-size = 13setting goes from 17.3 px to 13 px. Cells are sized as the Swift app sizes them (8×18 for JetBrains Mono at 13 px), so the samefont-sizegives both apps the same cell. The macOS defaultfont-sizebecomes 14, the Swift app's. Linux is unchanged: 96 dpi (13pt ≈ 17.3 px), the same cells, and a default of -
See
fonts.md. -
Glyphs look like macOS text, and blending matches AppKit and Ghostty (#581) — on macOS, glyphs are unhinted, closer to native macOS text. On both OSes, text antialiasing and translucent fills (a dragged tab, the host scrim, dimmed host sections) blend in display space, as AppKit and Ghostty do, so the wgpu and software renderers agree. Solid colors, including the now-opaque selection and block cursor, are unchanged.
Fixed¶
-
Select-to-copy and middle-click paste did nothing in Roost-Iced on macOS (plan 073, #575) — the selection clipboard had no macOS backing, so under the default
copy-on-select = truea drag-selection was written nowhere, OSC 52's selection target was dropped, and the middle-click paste was compiled out. The selection now lives on the same private pasteboard the Swift app uses (ai.stridelabs.Roost.selection), so a selection made in either app middle-click-pastes in the other, and ⌘V still pastes the system clipboard. -
The block cursor hid the character under it; selections and bold text ignored the theme (#579) — in Roost-Iced, a focused block cursor was a translucent tint under an unchanged character. It is now a solid block in the cursor color with the character drawn in the background color, as in the Swift app and Ghostty, and it covers both columns of a wide character. A selection is a solid
selection-backgroundwith its text in the theme'sselection-foreground, as Ghostty draws it, instead of a translucent tint. Bold text with no color of its own takes the theme'sbold-color, as the Swift app does. The bundled themes' bold color matches their foreground, so that shows only with a theme of your own or after a script recolors the default foreground (OSC 10). -
Right-click New Tab on a project opens where ⌘T would (plan 074, #589) — in Roost-Iced it opens in the directory of the project's remembered tab instead of the project's saved cwd. A project with no tabs keeps the saved cwd.
-
A mouse button could stay held in a mouse-tracking app (plan 074, #587) — in vim, htop and the like, switching tabs (host tabs included) or losing window focus now releases the button. A click right after a font-size change keeps its drag and release, and a click caught behind a tab switch, a context menu or an opening overlay no longer leaves the button stuck down.
-
A setting toggled just before quitting is no longer lost (plan 074) — a clean exit drains queued
config.confwrites, bounded at 2 s. -
A hover no longer moves a held selection drag (#606) — a pointer motion with no button held reached an in-progress drag in Roost-Iced, moving its end to the hovered cell and stopping its auto-scroll. A real drag's motions always carry their button, so this showed up in drags driven through
tab.dispatch_mouse_event, where a stray hover could break them. -
test_device_queries.pyruns in the iced lanes (plan 074, #596) — it ran only in the Swifte2e-macsweep, although its docstring said iced ran it too. It is now in the X11, Wayland and macOS iced lists.
v0.0.21 — 2026-09-27¶
The session-parity release: a tab on a roost-session behaves more like an
in-process one from your seat. A new tab starts in the foreground job's
directory, keys typed while it opens land in it, the local session's unshown
tabs follow the window's size, a returning tab never replays at an older
width, and roostctl in a local session tab reaches its window. Both apps
remember each project's last-viewed tab across relaunch, and the window says
when its local session can't start and offers a way out. There is no
session-protocol bump: a running roost-session picks up its side of these
fixes when it restarts.
Added¶
tab.opentakescwd_from_tab, to open a tab where another one is (plan 070) — the server starts the new tab in the named tab's working directory: its foreground job's cwd (#534, below), then its direct PTY child's, then its OSC 7-tracked cwd, each only if it is a directory on the server's own machine. A resolved cwd replacescwd; nothing resolving is not an error, andcwdis used as sent. Unset, the request is the bytes it always was. Served by sessions, Roost-Iced's UI socket and the Swift Mac app; servers that predate the field answerunknown-field.roostctl tab open --cwd-from-taband--herespell it (#536, below). Seeipc.md#tabopen.- A session-backed project remembers the tab you last viewed in it
(#547) — on the local session a fresh Roost-Iced install runs its tabs
on, and on every saved host: a project click, ⌘1–9, the fallback when
the shown project's last tab closes, and a relaunch now land on the tab
you were last looking at there, as an in-process project does, instead
of on the tab the session last opened. The memory is the window's own,
saved per host in
state.json; nothing is sent to the session. After the session restarts, the remembered tab is found by its position in the project. The Swift Mac app keeps the new field intact when it rewrites a sharedstate.json. - The Swift Mac app's
tab.openacceptsactivateandcwd_from_tab(#532, #551) —activate: falseopens the tab without selecting it or its project, and the reply'sis_activenow says whether the tab was selected instead of alwaystrue;cwd_from_tabresolves as the plan 070 entry above describes. Both decode as the Rust endpoints do, sotab open --no-activateworks againstRoost.appinstead of answeringunknown-field. app.notice_dumpreads what the window is telling you (plan 072) — a new read-only op on Roost-Iced's UI socket that returns the notice drawn in the terminal area (today, "The session on … ended." over a frame whose session stopped, with its button) and the bottom-right line (the five-second toast, or a standing failure to save), as the strings on screen. Nothing on screen changes. The Swift Mac app answersunknown-op. Seeipc.md#appnotice_dump.- The window says when its local session can't start, and offers the
way out (#520) — under
local-backend = session(a fresh Roost-Iced install's default), a session that cannot be started no longer leaves an empty window with only a band line to explain it. The terminal area says "Roost couldn't start its local session.", names the reason and the log that has the rest, and offers Try again and Use in-process tabs, which raises the usual confirm card. Nothing switches on its own. The band's reason is cut to one line with an ellipsis instead of wrapping over the sidebar, and a local-backend switch that succeeds reports it in the normal text colour rather than the error red.app.notice_dumpreports the notice aslocal_session_cannot_start. - Keys typed while a new tab opens land in it (#562) — in Roost-Iced,
what you type right after Alt+T (⌘T on macOS), the tab strip's "+", the
palette's New Tab, ⌘N or "+ New Project" is kept until the new tab can
take it, then typed there, instead of the first few keys going to the
tab you left — which on a session-backed or remote project split a
command between the two. If the tab doesn't open, or you move to
another tab first, the bottom line says how many keys weren't sent.
A paste in that moment is refused with "the new tab isn't ready yet".
A test-mode
app.key_eventop presses a key in the window (ipc.md#appkey_event); the Swift Mac app, which already kept these keys, answers itunknown-op. roostctlinside a local session tab talks to its window (#561) — a tab on the local session a fresh Roost-Iced install runs its tabs on gets the session's socket asROOST_SOCKET, so until nowroostctlthere reached the session:palette,screenshot,host,agent setandtab send-filefailed, andtab focus,tab openandopen --focusexited 0 without moving the window. It now sends the command to the window that owns the session, as from a plain terminal —rpc,identifyanddoctorincluded. The hooks,events,waitandtab promptstay on the session, so they work with the window closed.--socketor--targetkeeps a command on the socket it names; with no window running, or on an in-process or remote tab, nothing changes; two windows on one session exit 2usage. Finding the window costs oneidentifyper running window, at most 500 ms each, and only in a local session tab. Seecli.md#inside-a-session-tab.roostctl tab opengets a spelling forcwd_from_tab(#536) —--cwd-from-tab <id>starts the new tab where that tab's working directory is;--heredoes the same for$ROOST_TAB_ID, accepted only with no explicit--socket/--targetnaming another target (inside a local session tab too, which still reaches the window that owns it). Omitted, the wire field stays absent, notnull; a host tab (h<host>.<id>) is refused, and a server that predates the field answersunknown-field, same as--no-activate. Seecli.md#tab-open-close-send-resize-reorder-dump.
Fixed¶
- A local session that dies on start is named, and no longer retried
forever (#520) — when
roost-sessionexits before it is ready, the local band now reads "roost-session exited early (status N)", or "(signal N)" for a crash, instead of "no session is running", androostctl host statusshows the exit and the last 4 KiB it wrote to stderr; Roost logs it once and stops dialling until ↻ Reconnect. The Use in-process local tabs row and its confirm card no longer claim the session "keeps running" when it isn't, and switching away from a session that never started forgets its deadlocalhosthost. A switch back to in-process no longer leaves its journal behind for the next launch to warn about.roost-session start's own output is unchanged. - The window was blank after the local session came back (#525) —
under
local-backend = session, when the session a fresh Roost-Iced install runs its tabs on was stopped or killed and then reconnected, the window selected nothing until a row was clicked. It now lands on the tab you were last viewing, found by its position in the project, as a relaunch does. - A new tab opened from a nested shell started in the outer shell's
directory (#534) — ⌘T / Alt+T, the launcher, and
tab.open'scwd_from_tabread the directory of the tab's foreground job instead of the shell Roost started, so afterbash,nix developor another interactive shellcds, the new tab opens there. A foreground program that changes its own directory moves it too (gitunder its pager reads as the repository root). A tmux pane is still not seen: the job is the tmux client, which stays where it started. A job that can't be read (sudo -s) or whose directory is gone falls back to the shell's directory, then to the OSC 7-tracked one, as before. Roost-Iced in-process, the Swift Mac app, and everyroost-session; a session already running picks this up when it restarts. See cwd tracking. - Mac: a tab whose shell exited at once could leave its output reader on a reused descriptor (#557) — the Swift Mac app closed a tab's PTY while its reader could still run, so, rarely, the next tab or socket given the same descriptor number could lose bytes to it. The PTY now closes only once its reader has stopped.
- A directory name with a control byte cut the tab title short and
leaked live escape sequences to the terminal, and a hand-rolled
__roost_title/__roost_osc7/__roost_marksin~/.bashrcor~/.zshrcwas silently overwritten by the Mac app's shell integration (#554, #193) —__roost_titlenow replaces each control byte in the title with?, in all four shipped shell-integration copies (Rust bash/zsh, Mac bash/zsh); a title needs no%-encoding, unlike OSC 7's cwd payload. The Mac copies now also skip redefining a function the user already defined under the same name, matching the Rust copies — closing #193 on macOS too. - A failed open, close or upload on the local session's slot could say
"the host" instead of "the local session" (#555) — a
tab.open,project.create,tab.close,project.deleterefusal, or a failed paste upload, on the slot a fresh Roost-Iced install runs its local tabs on, now reads as local, matching the title and the other banners plan 071 already localized. A real, saved host's wording is unchanged. - The agents palette tagged the local session's rows "localhost" (#567) — its rows now carry no host label, like the in-process source. A real host the user actually named "localhost" under in-process mode keeps its own label.
- A write that raced a clean exit's final flush could still land on
disk afterward (#553) —
Workspace::flushnow holds its write and the freeze that follows it under the same lock, so a write already past the freeze check and waiting on that lock sees the freeze the instant it gets in, instead of a window where it could land right after "the last write". The settle-in-Dropparts of #553 are unchanged and stay open. - A new tab on a session-backed project could take the window away
from a tab you clicked while it opened, and one that never appeared
was dropped silently (#549) — on the local session and on saved
hosts, a new tab or project waits for the session to list it before
the window selects it. A tab you focus in the meantime, by click, key,
palette or
tab.focus, now keeps the window, whether the focus comes before or after the session answers. A new tab or project still not listed after 10 seconds now says so on the status bar — "the new tab never appeared on the local session", or the host's name in place of the local session — even when the window is otherwise idle. - A tab opened through the UI socket on the local session was never
selected in the window (#548) — under
local-backend = session,tab.openandroostctl tab open/openfrom outside a Roost tab now select the new tab in the window, as in-process does, unlessactivate: false/--no-activatewas passed.tab.focuson a tab opened that way, such as--focussends, waits for the window to list it instead of answeringnot-found. Aroostctlrun inside a session tab dials the session and still moves only the session's own active tab. - A new tab on a session-backed project opened in the project's
directory instead of the active tab's (#530) — ⌘T / Alt+T, the tab
bar's "+", the macOS menu's New Tab, the palette's New Tab and launcher
rows, on a project whose tabs run on a
roost-session: every local tab on a fresh Roost-Iced install, and every saved host. The new tab now follows the active tab the way an in-process one does — the UI names the active tab (cwd_from_tab), and the session reads that tab's cwd natively on its own machine, then falls back to its OSC 7-tracked cwd. A session older than the field, still running after an upgrade, refuses it; the UI asks once more without it, so until that session restarts the new tab opens in the active tab's tracked cwd. See cwd tracking. - An in-process Roost-Iced whose startup failed after its first shell
spawned hung forever instead of exiting (#531) — under
local-backend = in-process, an error after the workspace was hydrated (a UI socket path too long to bind, say) left the process running with no window and no message. Every startup step after the runtime exists now shares one error path: it hangs up the shells already spawned, then exits non-zero with the error printed. - An event stream ended by a session stop or a local-backend switch
could close without its final envelope (#542) — rarely, and mostly
under load, an
events.subscribestream got a bare EOF instead ofsession.stoppingorstream.ended. The server fires every close, then ends the streams' feeds. A close that landed while a stream was already mid-wakeup lost that race to its own feed ending. That stream now gets its envelope too, best-effort like every other close. Seeevents.subscribe. - iced: a startup error after logging starts reached stderr only, never
the log file (#538) — the state-lock failures and a bind error such
as
bind Iced IPC serverreturned straight out ofmainwithout going throughtracing, soroost.loghad no record of why the process exited.mainnow logs the full error chain once before returning it. - An in-process Roost-Iced quit with a job running in a tab never
finished (#522) — under
local-backend = in-process, quitting while something likesleep 300ran in a tab's foreground left the process running with no window, and a SIGTERM did the same. Quit now hangs up every in-process shell, kills one that ignores the hangup, and waits at most a second on a terminal something else still holds open, so one SIGTERM ends it. A process the shell started that ignores SIGHUP can still outlive the quit. Session-backed tabs are untouched: they belong to theroost-session. - A tab asked to start in a directory that isn't there started at
$HOMEwhile its row claimed the missing path (#541) — ⌘T on a session-backed project whose active tab's directory had been deleted,roostctl tab open --cwd /nope, and restoring a saved tab whose directory is gone. On Roost-Iced and on a session, a cwd that is not a directory now counts as empty wherever a tab opens: the tab starts in the project's directory, else at$HOME, and its row says so. A directory that exists but can't be entered still fails the open. Aroost-sessionstarted by an older Roost, still running after an upgrade, keeps the old behavior until it restarts. Seeipc.md#tabopen. - A directory whose name holds a
%or a control character lost its tracked cwd, or came back as a different path (#535) — Roost's bash and zsh integration sent the raw$PWDover OSC 7, so after acdintoa%zzor100%the header subtitle andtab.listkept the previous directory,100%2Fdonecame back as100/done, and an ESC or BEL byte in a name cut the report short. The integration now sends%as%25and every control byte as%XX(on both apps, including macOS's bash 3.2), and both apps' decoders keep a%that doesn't start a valid escape instead of dropping the update. Aroost-sessionthat was already running keeps the old behavior until it restarts. - Opening a tab expanded a collapsed sidebar (#543) — on a session-backed project (a fresh install's local tabs, or a saved host), a new tab landing revealed the sidebar even though the in-process backend never does. Only creating a project (⌘N, "+ New Project", or connecting to create on a host) reveals it now; opening a tab, a launcher row, and the launch/connect seed on a host no longer touch it. Jumping to a notification and renaming a project still reveal it, as before.
- The local session's tab read as a remote host, in the title and in
status banners (#544) — a fresh install's local tabs, running on a
roost-session, put a(localhost)suffix on the window title and said "that host is not accepting operations" when it was unreachable. Both now read as local: the title carries no host suffix for the session's own tabs (a real host, and alocalhosthost underlocal-backend = in-process, keep theirs), the unreachable banner says "the local session is not connected", and a failed rename or reorder names the local session instead of "the host". The wire repliesroostctland other tools see are unchanged. - A new session tab's first frame was a hardcoded dark color instead of
the active theme (#545) — before a session-backed tab's hydration
landed (⌘T / Alt+T on a local session tab or a saved host), the
pre-attach frame always painted
TerminalSnapshot::blank's fixed background, foreground and selection colors and never a cursor, clashing with every non-default theme. It now starts from the active theme's colors instead, still with no cursor drawn. - A new Roost-Iced tab started at 100×32 whatever the window's size
(#546) — a program that reads its size as it starts (a launcher row,
a TUI's first frame,
stty size) saw 100 columns by 32 rows until the tab was shown and resized. Every tab the window opens — ⌘T, ⌘N, launcher rows, a relaunch's restored tabs, and the tabs a backend switch copies or restores — now spawns at the grid the window has room for, beside the sidebar as it is (collapsed or not), on in-process, session and saved-host projects alike. Tabs opened elsewhere keep their own starting size: a session's own first and restored tabs (120×40) androostctl tab open(80×24). - Palette providers got no active tab or cwd on a session-backed
project (#533) — under
local-backend = session,provider_contextread the in-process workspace, which holds nothing while the local band's tabs run on the session: every provider saw an emptyROOST_ACTIVE_CWDand noROOST_ACTIVE_TAB_ID/ROOST_ACTIVE_PROJECT_ID, and spawned with no cwd at all. It now reads the slot's own listing when the window is showing the slot's own tab, so a provider gets that tab's real ids, title and tracked cwd, and spawns there while that is still a directory. A remote host's selection is unchanged. See Extending Roost. - The macOS Window menu listed nothing on a session-backed project
(#550) — it read the in-process workspace's project/tab rows, which
under
local-backend = sessionare empty, or a stale layout with no live tabs, since the local band's tabs run on the session. It now picks rows by backend mode: the slot's own mirror undersession, the in-process workspace otherwise — a remote host's rows still never appear, matchingin-process. The rebuild also moved to run after the window's selection settles each reconcile, so a relaunch or an in-flight project creation doesn't leave a stale checkmark. - The Mac app started a tab asked for a directory that isn't there in
Roost.app's own launch directory (#541) —roostctl tab open --cwd /nope, ⌘T from a tab whose directory had been deleted, and restoring a saved tab whose directory is gone. A cwd that is not a directory now falls back to the project's directory, else$HOME, and the tab's row says where the shell started, as on Roost-Iced. A directory that exists but can't be entered now fails the shell's start and the tab closes, as on Linux, instead of the shell running on in the launch directory. - Mac: ⌘T and
cwd_from_tabcould pick different directories (#556) — ⌘T, the launcher and providers now resolve the new-tab cwd through the sameLocalClient.inheritedCwd(tabID:)ruletab.open'scwd_from_tabuses, instead of a second rule with no directory check. A stale project cwd with no live directory to inherit now falls through to$HOME, as intended, instead of landing in a directory that no longer exists. ⌘1–9 and the Window menu no longer reveal a hidden sidebar when switching projects, matching Roost-Iced and 071's ruling that opening a tab never expands it. - Coming back to a session tab after a window resize or a font-size
change could replay what it missed at the old width (#564) — on the
local session and on saved hosts, a tab you leave keeps its screen, and
coming back replays only the output written while you were away. That
output was written for the width the tab had when you left, so after
the window, the sidebar or the font changed the width, cursor-placed
text and right-aligned prompts landed in the wrong columns, on the
primary screen and in scrollback, until something redrew them. A tab
whose grid changed while you were on it, while you were away, or while
it was still reconnecting, now comes back from a fresh copy of the
session's screen. The window's half of the fix works against every
roost-session. The session's half — it also refuses to replay across a resize another window made — reaches aroost-sessionthat was already running only after it restarts. - Session tabs the window wasn't showing kept the size they started at,
and a font-size change never reached the shown one's shell (#563) —
on the local session, a tab you weren't looking at ran at 80×24 when
roostctl tab openmade it, or 120×40 when the session did, until you showed it, so programs in it drew for a terminal that wasn't there androostctl tab dumpread them at that width. After a font-size change the window drew the shown tab at its new size while the shell in it kept the old one, on the local session and on saved hosts alike. Every local-session tab now follows the window's grid, shown or not: when a window or sidebar resize settles, on a font-size change, when the window connects to the session, and when a tab it opened orroostctlopened through it appears. Background programs there now get a resize signal at those moments, as in-process tabs always have. A font-size change now reaches the shown tab's shell on every host. The background tabs of a remote host still keep their size until shown (#568). - An in-process project forgot the tab you last viewed in it across a
relaunch (#565) — under
local-backend = in-process, Roost-Iced reopened every project on its last tab, and the Mac app kept the memory only while it ran, so after a relaunch a project click, ⌘1–9 and the fallback when the shown project's last tab closes landed on some other tab than the one you were last looking at. On both apps, each project's last-viewed tab is now saved instate.jsonby its position and restored with the layout; if that tab fails to reopen, the project stays on the tab the restore ended on rather than the one after it. On the Mac, that fallback also landed on the neighbouring project's first tab even without a relaunch; it now lands on the one it remembers, as on Roost-Iced. Aroost-sessionrestores its own projects' memory the same way; one started by an older Roost, still running after an upgrade, does so only after it restarts. - Restoring a tab whose directory can't be entered deleted its project
(#559) — on both apps, a saved tab whose directory still exists but
that the shell can no longer enter (mode 000, or a permission change
since the last run) failed to start at a relaunch and closed, and when
it was its project's only tab the project was deleted with it, for
good and without a word. A restored tab now starts in its project's
directory instead, else in
$HOME. Opening a tab in such a directory still fails, as before. Aroost-sessionrestores its own layout the same way; one started by an older Roost, still running after an upgrade, does so only after it restarts.
v0.0.20 — 2026-09-20¶
The agent-control release: roostctl open, rpc, events, tab prompt
and tab report give an agent a first-class surface over the same op set
the UI drives; five coding agents get the tab dot, and Roost asks before it
wires their hooks. The wire is pinned by a checked-in JSON Schema
(roostctl schema, no socket needed) and one source of every error code,
the session protocol reaches its final generation-6 contract with
ROOST_LEASE retired and events.subscribe resumable, and local tabs can
now run on a roost-session you switch to and from while the app is up.
Added¶
-
iced: a toast when startup agent-hook setup leaves an agent unwired — a problem is anything that leaves an allowed, present agent unwired: every
ensureerror, plus a skip forUnparseable,UnexpectedShapeorForeignFile(NotPresent/NotAllowed/ModeOffstay silent — the user's own choices, not problems). The toast readsAgent hooks: couldn't set up <agents> — run roostctl agent status, alongside the existing wiring announcement when there is one, shown once per launch while the problem persists; fixing the file or dropping the agent fromagent-hookssilences it. See Agent Hooks. -
roostctl tab promptsubmits a prompt and waits for the turn it starts, race-free (plan 067) — the verb closes the gap between atab sendand a separatewait --state running: it subscribes and fences the event stream first, writes the prompt then\ras a second write on that same connection, waits behind an activity gate (--activity-timeout, 5s default) for the tab to reachrunning, then waits for it to settle in one of--until(idle/needs_inputby default).--timeoutis required unless--no-timeout— a turn's length is not something the verb can guess, so it has no default. Exits 4stalledif nothing starts within the gate; the gate is temporal, not causal, and a tab whose agent reports nothing to Roost — no hooks, and not a direct reporter like craze — never reachesrunningand stalls by design. No event stream (the Mac app today) exits 1unsupportedrather than polling. Seecli.md#tab-prompt. -
roostctl tab reportreports an agent-hook event directly, for an agent that installs no hook — a verb overtab.agent_report:--source,--session-id, one of--claim/--preserve/--release,--lifecycle,--if(sent only when given),--attention,--severity,--title,--body,--detail, and repeated--metadata KEY=VALUE. Refuses client-side, exit 2, a reserved or empty source,--attentionwith no title or body, and malformed--metadata; an accepted report prints the returned lifecycle and a refused one prints who owns the tab — both exit 0, since the op itself succeeded. craze, the first agent with no hook of its own, is the worked example in Agent Hooks. -
A machine-readable JSON Schema for the whole wire, checked in and pinned (
docs/reference/api/roost-ipc.schema.json) — every op's params and result, every event's data, draft 2020-12, generated from the same wire types the server encodes and decodes.roostctl schemaprints it byte-for-byte with no socket and no running Roost, so a script or agent can check a field's shape offline before sending it (roostctl schema | jq '.ops["tab.agent_report"].params'). A required-list patch keeps the schema honest where the derive would otherwise mark a field optional that the server always writes, and the handshake's required fields mirror its decoder line for line.examples/ipc-consumerbuilds against it--locked. Seeipc.md's "Machine-readable schema" andcli.md#schema. -
openandtab opentake--no-activate, so opening a tab no longer has to steal the user's selection (#503) —tab.opengainsactivate: Option<bool>(unset is byte-identical to before);Some(false)appends the new tab to its project without touchingactive_project_id,active_tab_id,active_tabs_by_project, or the UI's selection, including on an empty workspace's default project. A server that rejects the field (the Mac app, an older Roost) answersunknown-fieldverbatim. The skill's open recipe and rules now use it. -
A mutating op refused because a local-backend switch is in flight now answers a dedicated
busycode —{"code":"busy","message":"a local-backend switch is in progress"}, the one refusal worth retrying: pollidentifyuntillocal_backend_switchis absent, then retry, re-reading ids first, since a switch replays the workspace under new ones. See "busy, and retrying across a mixed-version fleet". -
roost_ipc::codesis now the one source of every wire error code —codes::ALLlists all 28 the workspace produces or a client type names; every producer across every crate and every consumer (ServerCode, the doc catalogue inipc.md) is scanned to prove it names its code from there, not a literal string, so a code cannot drift out of the catalogue unnoticed. -
A switchable local backend: local tabs can now run on a session instead of in-process (#426) — a new
local-backend = in-process | sessionconfig key, and two command-palette rows, Use a session for local tabs / Use in-process local tabs, that move your local projects and tabs between the two in place, live, with no relaunch. Forward replays your current layout onto a localroost-sessiondaemon (starting it if needed), preserving project and tab order, cwds, and any titles you set by hand, then ends the in-process shells and writes the key. Reverse is deliberately No-Replay: it flips the key back without copying anything — the session keeps running exactly as it was, one click away under aLOCALHOSTband, and the in-process band comes back from its own persisted state (seeded fresh if empty). A genuinely fresh install — nostate.jsonand noconfig.confon disk yet — starts onsessionfrom the first launch, key written automatically; every existing setup keeps defaulting toin-processthis release. A hand-edited key found sitting over a populated in-process workspace migrates automatically at the next launch, using the same sequence, once the session connects. The sidebar's local band is now presence-derived rather than hardcoded: it shows whichever local backends actually exist, andapp.sidebar_dumpgrows asectionsarray reporting the dot, saved host id, reconnect offer, and fidelity pill for each band on the wire. Closing a host's last project — on any host, including the one your local tabs are switched onto — now forgets that host (never stops its session) and remembers it as a recent for one-row re-adding; emptying every local project and host closes the window, the same rule that has always governed closing the last one when everything was in-process.roostctland every shell hook keep working unchanged undersession: a bare tab or project id on the UI socket is forwarded to the session transparently (identify.local_backend/local_session_socket/local_backend_switchreport the mode, the session's socket, and any switch in progress). See the host sessions guide,config.md, andipc.mdfor the full behavior. roostctl host statusprints why a reconnect rung is armed (#401) — while a rung is armed, the band's own line (and so the human form's rollup) is the countdown,reconnecting in 8s (3/10), which is exactly the stretch where the cause was otherwise unreadable: the classified failure has been on the wire asretry.reasonsince #399, but--jsonwas the only way to see it. An indentedarmed because: <reason>line now follows thereason/detaillines and precedes the reduced-fidelity line. It prints nothing when the rung carries no classified family — ordinary rather than a gap, since the first rung of an outage is usually a bare connection drop with nothing to classify, so its absence means "not known yet", not "no reason". It's ssh-only in origin: alocalhostretry is the connection task's own backoff and carries no family. Seedocs/reference/cli.mdand the host sessions guide.- A libghostty build skew connects instead of demanding a restart
(#420) — a host session and the Roost connecting to it used to have
to pin the same libghostty build, because the only attach payload
was libghostty's own binary snapshot. Upgrade Roost while a
roost-sessionfrom before the upgrade is still running and the host went amber, listed no tabs, and offered one remedy: restart the session, ending every shell on it. There is now a second payload kind,vt— a plain VT byte stream the client replays into its own terminal, so no build has to match — and a session serves it besideghostty-snapshot, picked automatically when the binary format cannot be. A skewed host therefore just connects: no dot, no dialog, tabs render, you type into them.ghostty-snapshotstays first in every preference list, so nothing changes when the builds agree. Amber "needs restart" now means the narrower pair of things it should — a protocol mismatch, or a session too old to servevtat all. It is honestly a lower-fidelity connection, and the tradeoffs are worth reading before relying on it: link hover and click stop working on such a tab, attaching over a full-screen program leaves the shell underneath blank when it exits, wrapped lines replay as separate lines, and a coloured status bar that has scrolled into history loses its fill.roostctl host status --jsonreportspayload_kindonce a tab has attached, which is the only way to tell from outside. One gap stated plainly: the "Update roost-session on ‹host›" offer is raised only from the state a fallback now avoids, so a remote SSH host onvthas no in-app path to update its daemon — update the binary over ssh and restart the session there by hand, orroostctl session stopover ssh and Connect again to get the ordinary install offer, until an on-demand action lands (#447). See the host sessions guide for the full list and the recovery routes, and ipc.md for the wire contract. tab.dumpcan return scrollback, not just the viewport (#421) — the op every test, script androostctl tab dumpreads a terminal through has always answered with the visible grid alone, so anything that scrolled off was unreachable even though all three terminals retain 2000 rows of it. It now takes an optionalscrollbackrow count and answers withscrollback_rows(how much history exists above what it is showing) plusscrollback_text(that many rows of it).roostctl tab dump --tab 5 --scrollback 200prints the history and then the viewport with no separator between them, so an existing| grepkeeps working over a much larger window. The history is anchored on the viewport that was dumped, so its last row is always the one directly above the first visible row — true on a tab the user has scrolled up, not just at the live bottom. Asking for more than exists is clamped rather than refused, which makes--scrollback 1000000a legitimate way to say "all of it". Served identically on a host session's socket, a UI socket, and by both the Linux and macOS UIs. No protocol bump — the request key is new, so a Roost or session predating it answersunknown-fieldto the flag rather than quietly ignoring it, which is refusal enough.-
server_urlmetadata key makes an opencode session drivable (#439) — a bareopencodebinds no socket (its TUI talks to its server in-process), so nothing could act on the session roost already reported. Roost's opencode plugin runs inside that server process, so it now puts a loopback listener (127.0.0.1, port 0) in front of the in-process app and reports the address asmetadata["server_url"]on the owning tab; when opencode is already listening itself (serve,web,--port, and the rest) it reports that address and serves nothing — the two are told apart by whether the client opencode hands the plugin carries an in-processfetch, which opencode supplies only when there is no real listener, not by parsing the command line. The value is recorded only when it parses as a token-free loopback base URL, and the listener demands the same Basic credentials opencode does whenOPENCODE_SERVER_PASSWORDis set.ROOST_OPENCODE_NO_SERVER=1opts out of the listener roost creates — it does not suppress reporting a server you started yourself.roostctl doctorprints the value rather than its length, because a loopback URL is not a secret and printing it is what makes the handshake debuggable. No protocol bump —metadatais the no-bump extension channel. Note the widened surface (a bareopencodein a roost tab is now reachable by any same-host process) and the mid-session limitation (the key rides every forwarded event and merges onto ownership the tab already has, so a plugin that loads mid-session only has to wait when the tab is unowned — with no claim to ride and nothing to merge onto, the key lands at the nextsession.created); both are in the Agent Hooks guide. -
events.subscribecan resume instead of re-snapshotting (#422) — a phone dropping in and out of a flaky link used to pay atab.listsnapshot and a re-fence on every stall; a host session now keeps a bounded ring of committed batches behind its revision fence, andevents.subscribetakes two new optional request fields,from_revision(what the client already has) andsession_id(the incarnation it fenced against — required alongsidefrom_revision, since revisions restart per process and an unnamed fence could otherwise be served a different session's history that still passes the client's own gap check). A resume outside the retained window answersreplay-expirednaming how far back it can still go; ahead of the session,revision-ahead; against a restarted session,session-mismatch— all three, plusinvalid-paramfor a barefrom_revision, land on the ack before the stream ever spawns, so the connection stays usable for a plain re-subscribe.tab.effect(bells, clipboard writes) is never replayed — only live clients see it — whilenotification.firedis. No protocol bump:from_revisionandsession_idare new optional request fields, and a session predating them answersunknown-fieldto either — refusal enough with no capability channel needed. gx.remotemetadata key surfaces a gx session's remote lane (#425) — when gx's remote lane is up it stamps its loopback base URL (gxRemote, e.g.http://127.0.0.1:2421) onto every hook payload; roost now forwards a validated token-free loopback URL asmetadata["gx.remote"]on the owning tab, androostctl doctorprints it. It's a discovery hint, not liveness — gx never clears its own announcement and roost's metadata has no delete channel, so the key can outlive the lane; see the Agent Hooks guide for the naming convention and the consumer contract a client still has to follow before using it.- Remote host sections drag-reorder too (#398) — dragging a project row
or a tab pill inside a connected host's section now reorders it exactly
like the LOCAL section always could; the drop routes over that host's own
connection as
project.reorder/tab.reorderrather than mutating the local workspace, so the new order lands on — and persists on — the session itself, across a disconnect/reconnect or a relaunch. Both reorder ops also accept the host-qualifiedh<incarnation>.<id>spelling on the UI socket now, the same formtab.focusandtab.dumpalready use, so the drag isn't the only way to drive it;app.sidebar_dumpgained an additivehostsarray reporting every saved host's own project/tab order, each entry carrying that host's connection state — a disconnected section lists the rows it retained, a never-connected one lists none. A refusal that crosses from the session onto the UI socket with no code the UI socket already speaks (a latchedsession.stop, say) now reports as the newhost-unavailable, with the session's own sentence kept in the message. - Five coding agents get the tab dot — and Roost asks before it wires
any of them (plan 046, reshaped by plan 064) — Claude Code, Codex,
grok (and its fork gx), cursor-agent, and OpenCode all drive the
running/needs-input/idle/failed indicator, the sidebar rollup, and the
desktop banner, on local tabs and on host-session tabs alike, once
wired: Roost merges one hook entry per event into each present agent's
own config file (
~/.claude/settings.json,~/.codex/hooks.json+config.toml,$GROK_HOME/hooks/roost.json,~/.cursor/hooks.json, an OpenCode plugin) beside whatever else is already there, pointed at a$ROOST_AGENT_HOOKenvironment variable every tab gets rather than a baked-in absolute path — so the exact same entry works after a relocated install, on a second machine, or over a host session, and is inert (drains stdin, answers{}) on a machine where Roost isn't running — every reference in it is spelled${NAME:-}, because grok checks a hook's variables itself before it spawns a shell and would otherwise draw a red "hook not executed" row per tool call in any non-Roost terminal; codex'sSessionEnd/Interruptentries carry its 3 s cap so it stops warning about clamping them on every launch. Plan 064 replaced how any of that gets turned on.agent-hooksis now three states — a list of agent names,off, or absent, which means nobody has answered yet and Roost writes nothing into any agent's config and removes nothing until they do. A consent dialog (iced's Agent Hooks… card, the Mac Agent Hooks… sheet — both also a command-palette row / Mac View-menu item, the default-unboundagent_hooksaction) opens once per process on first run when the key is unanswered and at least one supported agent is installed, and reopens on demand from the palette; picking agents there, or runningroostctl agent set <list|off>, is what actually wires anything — never before that "yes".agent-hooks-skipis gone; the retiredauto/on/true/yesspellings, and any value mixing a reserved word with a name, now parse back as unanswered (with a warning) instead of silently turning wiring on. Names normalise — trimmed, lowercased, de-duplicated, reordered intoclaude, codex, grok, cursor, opencode— and a name this build has no adapter for wires nothing, warns once, and is kept in the key, written back unchanged, so a newer Roost's answer survives an older one reading the same file (roostctl agent statuslists it asunknown to this build). One consequence worth flagging: a targetedroostctl agent uninstallthat removes the last name this build knows keeps the unknown ones rather than erasing them —uninstall claudeagainstclaude, gemini, withgeminiunknown here, writesgeminialone rather thanoff. That value parses back as unanswered, so the consent dialog raises again next launch: deliberate (anoffnext to a name it can't wire would be a second kind of data loss), but a user will notice it.roostctl agent ensure/install/uninstall/statusare still the manual controls;agent set <list|off>dials the running UI (setting the key here and raising every connected non-localhost host in the same call),--localwrites this machine's key with nothing running, andensure --startup— what a UI launch runs — wires and refreshes what the key names but never removes. The host side reverses plan 046's rule entirely. A connecting client now only ever raises the host's ownagent-hookskey — set union, never removing — and, deliberately, that includes a host whose key is explicitlyoffor still unanswered; a client whose own key isoffor unanswered sends no frame at all, so it can't lower anything either. Lowering a host is done on that machine (roostctl agent ensure/uninstall, or its own dialog) and holds until a more permissive client connects again — there is no more "last writer wins" on this key. A name the answering end does not recognise is reported asskipped/unknownon bothsession.set_agent_hooksandagent.set_hooksrather than refusing the whole call, so a list naming only agents the other end predates succeeds having written nothing. This reshapessession.set_agent_hooksinto a pure raise — part of the session-protocol-6 contract described below. Riding along: the long-standing defect where approving a Claude permission prompt left the dot orange until the whole turn ended is fixed (Claude now hearsPreToolUse/PostToolUse, so the dot returns to blue when the approved tool finishes);roostctl doctorgains a per-agentAgentssection (wired version, ownership, codex's hook-trust hash, a warning if the retired~/.config/roost/claude-settings.jsonis still around); and the agents palette (Cmd-Shift-O/Alt-Shift-O) now names which agent owns each row instead of justproject · tab. See the Agent Hooks guide andconfig.mdfor the full behavior, including the guarantee about what a merge does and doesn't touch. - The
vtfallback now says so, and a remote host can fix itself in-app (#447) — a libghostty build skew used to connect silently (#420): the dot stayed green, and the only way to notice was to pollroostctl host status --jsonafter attaching a tab. A reduced-fidelity connection now grows areduced fidelityindicator on the sidebar's host band the moment the connection is up — before any tab has attached — and the firstvtattach on it prints a one-time status-bar sentence naming what is off (links, the alternate screen, soft wrapping). On an ssh orlocalhosthost that indicator, a row underneath it, and a command-palette verb (host:update:<id>/host:restart:<id>) all open the fix directly: over ssh, the bootstrap consent card with the fidelity reason on it — no more "needs a restart" prompt first, and no more manual-only route, closing the one gap #420 left open; onlocalhost, the restart confirmation. A Unix-socket target still gets no in-app offer — Roost cannot reach in over a bare forwarded socket — and the indicator there is informational text only.roostctl host statusreports the newconnect.reduced_fidelityfield (readable before any attach, unlikepayload_kind) and prints the fidelity line in its human form. See the host sessions guide. - A reconnect to a session you already hold a checkpoint for resumes
instead of re-snapshotting (#442) — every reconnect, automatic or
manual, used to subscribe fresh and pull a whole-workspace
tab.list, purging every row keyed on the dead connection until the new one landed — the host's sidebar section rendered empty for the whole prologue, seconds over a flaky ssh link. A reconnect to the same session now calls the resumableevents.subscribe {from_revision, session_id}(#422) from the last revision it applied, taking notab.list, and the sidebar keeps drawing the carried rows throughConnectingso the section never blanks. Any of the three refusals (replay-expired,revision-ahead,session-mismatch) — a gap too old, a session that outran the checkpoint, or a restarted session — falls back to the ordinary fresh subscribe-and-snapshot on a new dial and is never fatal to the connection.roostctl host statusreportsconnect.resumedandconnect.from_revision, and the newtabscount, so a caller can see the no-flicker behavior directly instead of scraping the sidebar. - An agent control surface:
roostctl open,rpc,events, and a skill that drives it all (plan 066) —project.ensurefinds a project by its exact name or creates it, atomically on the server, so two callers racing the same name converge on one project instead of each creating their own (#221);roostctl opencomposes it withtab.openin one call, printing{"project","tab","created"}with or without--json.identifynow namesops— the operations this socket serves right now, worked out per request so it follows the livelocal_backendand test mode — andinstance_id, 16 hex digits minted once at launch so a client can tell a restarted UI from the one it last talked to; a session'ssession.identifygrew the sameopsfield.events.subscribeis now served live by a UI socket running its tabs in-process, not just by a host session — the same batches, the same gap rule, fenced against a second connection'stab.listrather than a session's replay ring, and ended withstream.endedwhen a local-backend switch is about to retire the workspace it streams.roostctl waitfollows that stream instead of polling wherever one exists (falling back to polling only against a server with none, such as the Mac app), and grows--no-timeoutfor an unbounded wait;roostctl eventsis the new verb that prints the stream directly, one JSON line per event, for a script that wants to watch rather than block.roostctl rpc <op> [params]calls any operation by name for whatever a named verb doesn't cover yet. And a Claude Code skill — installable vianpx skills add charliek/roostor, for Claude Code,/plugin marketplace add charliek/roostthen/plugin install roost@roost— teaches an agent the rules this surface implies (the target policy, the exit-code table, when to focus a tab);roostctl skillprints the copy built into the binary,--jsonincluded, so the skill that matches your installedroostctlis always one command away.roostctl --target sessionreaches a host session explicitly (#475) — never by auto-detect orROOST_BUNDLE_PROFILE— and an op a session does not serve answers with the server's own error. See the new Automation and Agent Skill guides, andipc.md— which now carries a contract section for every operation, a gap a test now enforces.
Removed¶
roostctl session autostart install|uninstalland theautostart=line onsession statusare gone (#454) — added this cycle (#424) and removed before ever shipping in a release. Roost ships no supervisor artifact: a session comes up on demand from the client that connects — the localhost launch ladder, the SSH bootstrap ladder, orroostctl session start— and the Host Sessions guide keeps a hand-writtensystemd --userunit / launchd recipe for anyone who wants reboot survival anyway.- The dead
Workspace::pty_replaced/Workspace.ptyReplacedprimitive is gone on both platforms (#424) — a no-op for users. It existed to reset agent ownership when a PTY was replaced in place, for a hard-restart-keeping-the-tab-id respawn (#170) that was never built; on both platforms today a PTY exit closes the tab outright, and the primitive's only caller left was its own unit test. The Mac's call site ran immediately beforetab.closedand emitted one now-removedagent_report.changedcommit that nothing observed, since the row is dropped a commit later regardless. #170's hard-restart, if it ever ships, rebuilds the same reset inside its own respawn commit. ROOST_LEASEis gone (#468) — every child a tab spawns used to have a driver lease token injected into its environment, androostctlread the same variable to fill in a write it was sending, printing a hint naming it when a session refused the write for lacking one. There is no lease left to hold, so nothing sets the variable and nothing reads it: a shell profile or script that still exports it is exporting dead weight, harmless but pointless, and should drop it.
Changed¶
-
The switch-in-flight refusal's code is now
busy, nothost-unavailable— the UI's forward arm,register_in_process_stream, andCloseReason::error_code(BackendSwitch)used to answerhost-unavailablewith a message startingbusy:, which a client was told never to match on; all three now answer the dedicatedbusycode.host-unavailablekeeps one meaning: the local session is not connected. An older server'shost-unavailableplus a presentidentify.local_backend_switchis that server's spelling ofbusy— see the retry rule above. -
A call that never answers under
wait --timeout 0or--no-timeoutnow exits 1connectionafter 30s, instead of hanging forever —roostctl events's setup calls (subscribe, the second dial, identity, the fencingtab.list) are bound by the same ceiling. -
Session protocol 2 → 6: the final generation-6 contract — every wire change since v0.0.19's protocol 2 folds into one entry describing the contract as it now stands, not the history of getting here (the generation-by-generation bump history stays in
docs/reference/ipc-compatibility.md). The interactive lease host sessions grew across R1–R17 is retired outright:session.connectand everything it minted are gone, opening a control connection and sendingsession.identifyis the whole handshake, and every same-UID connection is symmetric — no owner, no lease, no foreground, nobody deposed by a second window or a phone attaching (#453, #468). Theleasefield is gone from every op that carried one —tab.write,events.subscribe,session.set_theme,session.set_agent_hooks,session.put_file— a session refuses any of them withunknown-fieldif a client still sends it, and refusessession.connectitself withunknown-op.events.subscribe's ack now always carriessession_id, naming the incarnation the subscribe landed on (see the #458 fix below).session.identifyno longer reports afeaturesarray — the integer is the whole negotiation now, and a capability channel returns only with a second real consumer.tab.effectopened from a closed two-value enum into a string list, so a future effect (#188, #364) costs nothing to add: a client with no handler for one logs and ignores it instead of failing to decode. Notifications fan out the way effects already did —session.set_focusis gone (unknown-op); a session firesnotification.firedat every viewer unconditionally and each client decides for itself whether it's the one looking; a tab carries anotification_generationthat a client's owntab.clear_notificationnames back, so a stale acknowledgement can never erase a notification nobody has actually seen (#474).session.set_agent_hooksis a pure raise now: it carries only the agents a client's ownagent-hookskey allows and can only widen the host's, never narrow it —modeandskipare gone — and a name the host doesn't recognise no longer fails the whole call, coming backskipped/unknownwhile the known names are still wired (#486).session.put_file(plan 047), documented here for the first time, lands one client-supplied file per frame in a private per-connection directory and answers with a paste-safe path fortab.send_file. And the attach ticket is gone:tab.attachanswersunknown-op, and a data connection instead states its tab, the session idsession.identifyreported, and its terms on its own first line — the session accepts, resumes, or refuses right there (#473; see the handshake for the accepted and rejected shapes). None of this is eased in with a shim, by decision — a session or client stuck on protocol 2 through 5 is refused loudly rather than half-supported. Concretely: aroost-sessionfrom v0.0.19 still speaks protocol 2, so it lands exactly where a libghostty build skew Roost can't cover already lands — the existing compatibility gate's amber "needs restart" state, offering to fix it in place: the update offer on a host reached over SSH, the restart offer onlocalhost. Restarting (by either route, or by hand) is the only way forward, and it costs what a restart always has — every shell on that session comes back only as layout (title, cwd, position), never as the process or scrollback that was running. Riding along in the same cycle:config.conf's four writers (iced, the Mac app,roostctl, and a connecting host raisingagent-hooks) now serialize through oneconfig.lockbeside the resolved file, closing a lost-update race, and overlappingagent.set_hooksapplies land in request order rather than lock-acquisition order (#487, #490); a session whose state directory goes unwritable now reports it — a liveworkspace.durability_changedevent and a standingpersist_erroronidentify/session.identify— instead of silently remembering nothing (#481); an ssh host whose far side is merely restarting now reconnects on its own instead of settling on the first "no session" answer (#387); and the Mac socket refuses non-canonical ids ("+4","04") the way iced and a session already did, and now emits the bytes ghostty expects forctrl+[,ctrl+i, andctrl+minstead of nothing at all (#402, #343). - A second window, or a phone, can type into a tab you already have open — nobody is deposed (#453) — attaching to a tab and writing into it used to need the session's one interactive lease, so picking up a session from a second device meant deposing whatever had it first, which froze the first device's terminal grid. Typing and attaching are now open to every same-UID client at once: nothing is deposed, nothing freezes, and nothing reconnects because another client showed up. A tab is sized by whichever client last interacted with it (typed, resized, or attached), not by a single owner, so two clients typing at different sizes will flip the grid between them by design.
tab.reorder/project.reordernarrow the ids they accept — the new host-qualified ref parser requires the canonical integer spelling, so non-canonical forms like"+4"or"04", which the old codec silently accepted, are now refused. Every first-party caller already writes the canonical form; this should only bite a caller that relied on the old laxness.- Claude's post-turn nag no longer banners a session you already saw
finish (plan 046). The
idle_promptnotification Claude fires ~60 seconds after a turn goes quiet used to raise an info banner unconditionally, including for a turn that had already reportedfinished(orfailed, orwaiting) by other means. It's now guarded to only fire from aworkingtab, so it can move a genuinely-still-open turn to finished but can no longer re-announce, or overwrite, a state you already saw. Cursor'sstop(fired up to three times per turn) and grok/gx's own idle nag are guarded the same way, for the same reason. roostctl claude installmerges directly into~/.claude/settings.jsoninstead of writing a separate settings file and a shell alias (plan 046) — it's now a bare alias of the newroostctl agent install claude. It no longer writes~/.config/roost/claude-settings.jsonor prints analias claude='claude --settings ...'snippet, and it exits 0 rather than 1 when Claude is already wired (there is no--forceany more). If you're on the old alias,roostctl doctor's newagent.claude.legacy_settingscheck names the two cleanup steps — see the Agent Hooks guide.- A mutating verb refuses without
--tabinstead of guessing the active tab, androostctlspeaks one error contract (plan 066) —notify,set-title,tab set-state,tab clear-notification,tab close,tab send,tab resize, andtab focusused to fall back to the UI's active tab — whatever a person last clicked — when neither--tabnorROOST_TAB_IDwas given, which is no answer for a command that writes to a tab. They now refuse before dialling anything, exit 2usage, and nameroostctl tab listas the way to find an id. Every verb now also speaks one error shape: a failure printsroostctl: <code>: <message>on stderr (never anyhow's bareError: …), or{"error":{"code","message"}}under the now-global--jsonflag, andcodeis the stable part scripts branch on — see the exit-code table.tab list --jsonagainst an in-process UI now carries arevision, the fence a client pairs withevents.subscribeto know which batches it has already seen. roostctl waitexits 4 on timeout, not 1 (plan 066) — a script that treated any non-zerowaitas "timed out" keeps working, but one that matched exit 1 must now match 4 (timeout). Exit 1 is left for a real failure (connection, a server refusal), so "the condition never held" and "the connection failed" no longer share a code. Awaitthat loses its event stream across a local-backend switch or a restart also exits 1connection, because tab ids do not carry across.
Fixed¶
- Closing the tab the window was showing left the window on nothing
under
local-backend = session(plan 069) — the fresh-install default for Roost-Iced. Every close of the viewed tab —exitortab.close, whether or not a sibling survived, on the local slot or a remote host — dropped the selection: blank pane, no highlighted row,identifyreportingactive_project=0 active_tab=0, until the user clicked a row. The client's own selection fell back to "the local workspace's own selection, which never went anywhere", an assumption plan 063 had made false — undersessionthat workspace holds no live tabs at all.
The same change settles where the selection lands, one rule on
every surface (iced, the Swift Mac app, and a session's own
active.changed), replacing two nobody would have chosen — the
leftmost sibling tab wherever the closed one sat, and the lowest
project id, which made a reordered sidebar look arbitrary:
* the tab's project survives → the nearest surviving tab to the
**right**, else the nearest to the **left**;
* the project is gone → the nearest surviving project **above** it in
the sidebar, else the nearest **below**, crossing host bands and
skipping rows that cannot be shown;
* nothing survives → the window closes and relaunch re-seeds, both
exactly as before.
A tab the user is moved onto still keeps its pending notification:
being landed on is not an acknowledgement. One known gap is unchanged
and deliberate: in the legacy in-process mode, closing the last
local project while a host band still lists projects still leaves
the window blank until a row is clicked — a different code path, and
not where the investment goes.
- A raw UI-socket tab.dump of a slot tab the window showed and then
left answered from a stale, frozen client-side copy instead of the
session (#515) — the engine's tab.dump now forwards to the session
when the tab is on the connected session's slot and the window's own
terminal for it isn't streaming (detached, requesting, hydrating, or
ended) — the #511 fall-through that never ran for a tab like this. No
wire change.
- roostctl wait, tab prompt and events now hold off on an older
server's host-unavailable refusal of a local-backend switch in
flight, not just busy (#517) — a shared classifier treats
host-unavailable as a switch when identify still names one;
events gained its own hold-off loop (it previously failed hard on any
refusal, including a switch) and prints a one-line notice on stderr
while waiting so stdout stays JSON lines. A switch that settles between
the refusal and the identify re-read still exits 1 — accepted
residue.
- The nine local-only --tab verbs (notify, set-title, doctor,
and tab set-state/clear-notification/close/send/resize/report)
refuse a host tab ref (h1.7) with a designed message instead of
clap's raw "invalid digit" error — matching the message wait and
events already give for the same mistake. Every roostctl flag now
has help text; a tree-walk test that checks for it found 35 without
any.
- Roost-Iced's bundled roost.bash/roost.zsh no longer overwrite a
user-defined __roost_* shell-integration function (#193) —
__roost_osc7/__roost_title/__roost_marks (bash) and their zsh
equivalents are now defined only when the user hasn't already defined
one; the hook registration (PROMPT_COMMAND, add-zsh-hook) still
runs, so it's the user's own function that gets called. The Swift
Roost.app's shell resources are unchanged this release, so #193
stays open there.
- iced: a Super-held control chord no longer reaches the PTY as a
plain ctrl+<key>, with Super silently dropped (#494) —
encode_press no longer recovers a control chord when logo() is
held, matching the Swift encoder's existing refusal to recover a
Command chord. ctrl+cmd+<key> on macOS iced changes with it,
intentionally; a configured ctrl+super+<key> keybind still resolves.
- A mutation sent on the in-process UI socket during a local-backend
switch could land in the workspace the switch was about to copy and hide
(#501) — an admission gate now takes a read guard, re-loads the route
under it, and refuses a mutating op busy while a switch is published,
then holds the guard until the reply is built; the drain that takes the
write guard for the snapshot now waits for every admitted mutation to
finish first. Reads are never gated, and a handler with no local route
(the session daemon) is never gated either.
-
roostctl waitcould hang forever against a server that accepted and never answered (#502) — every socket step (the dial,identify, both legs ofevents::open, eachtab.dump, the poll calls and sleep) is now bounded by oneBound: the time remaining under--timeout, or a 30s ceiling under--timeout 0/--no-timeout. The overall deadline elapsing still exits 4timeout; a call past the ceiling exits 1connectioninstead of hanging.roostctl events's setup calls are bound by the same ceiling. -
tab.dumpof a tab the window doesn't hold a terminal for answerednot-foundunderlocal-backend = session(#511) — the engine'stab.dumparm now asks the window first and, when the tab is on the connected session's slot but the window never built a terminal for it, forwards the request to the session and returns its reply verbatim. A staleexpected_host— a reconnect since replaced the slot — answershost-unavailablerather than forwarding to the wrong incarnation;tab.dump_resolvedis unchanged and still distinguishes an attached tab from one the window has not built a terminal for. -
A tab's teardown could signal a recycled pid (#470) —
terminate_child's immediate SIGHUP was a rawkill(2)with no liveness gate at all, while its own SIGKILL watchdog was gated on areapedflag and justified itself by that gate. The fix is a per-child reap latch: the reap task observes the exit withwaitid(WNOWAIT), which leaves the child a zombie so the kernel does not release the pid, and only then reaps under the latch, while every signaller runs itskill(2)under that same latch and only while the child is unreaped. The window was narrow and reaching it needed a specific race, so this is hardening ahead of R9 moving every local tab onto the session path rather than a bug anyone was hitting daily. Two details of the shape: awaitidthat cannot answer degrades to a 20ms poll rather than to a blockedclose(), and only a terminal exit status is accepted, because a traced child reports its ptrace stops through the same call. - Cancelling a host connection attempt mid-snapshot leaked its event
pump (#472) — dropping a
tokio::task::AbortHandleaborts nothing, so the attempt future's cancellation left the pump running and the session-side socket subscribed, with nothing left to ever drain it. The pump is now owned by a guard that aborts on drop, covering every exit from the prologue by construction. The symptom was a session accumulating subscribed connections that nobody drains, across repeated connect/cancel cycles. - A subscribe that lands on a different incarnation than the one just
identified is refused before it can mix the two sessions' state
(#458) —
session.identifyandevents.subscribeare two separate connections, dialed moments apart; if the session had restarted, or the socket had moved onto a fresh incarnation, in that gap, a client could identify session A and have its subscribe answered by session B — folding B's tabs and revisions onto rows the client believed were A's, with nothing on the wire to say so.events.subscribe's ack now always carriessession_id, and every prologue and resume compares it against identify's before the event pump ever spawns: a mismatch fails the attempt outright, rather than snapshotting the wrong session, and the reconnect ladder retries — identifying fresh against whichever session actually answers next. - A second client focusing a different tab no longer un-mutes the first client's tab (#468) — focus used to be one slot per session: whichever client claimed it last decided the single tab whose notifications were suppressed, so a second window or a phone focusing tab 2 silently un-muted tab 1 for the client still looking at it there. Focus is now a per-connection statement of what that connection is looking at, and a tab is muted while any connection is viewing it — closing a connection, or that connection moving off the tab, is the only way its share of the mute goes away. A session with no viewers at all — headless, nobody attached yet — mutes nothing.
- The app in
/Applicationsnow carries its own notarization ticket (#405) — the release pipeline stapled only the DMG, so a user who draggedRoost.app(orRoost-Iced.app) out of the image and first launched it offline, or behind a firewall that can't reach Apple, got the "cannot check it for malicious software" dialog on an app that was properly notarized.notarize.shnow also notarizes + staples the app bundle itself before it's packaged into the DMG, so the ticket travels with the app regardless of where it's launched from. This is a release-pipeline change and takes effect starting with the next release. -
A tab closed while it was still being opened no longer leaves a shell running with no tab (#417) —
tab.openput the row in the workspace and published it before the PTY supervisor reserved the tab id, so atab.closearriving in that window removed the row and found nothing to tear down — no live session, no pending reservation — and the opener then went on to promote a live PTY for a row nobody could see. The orphaned shell survived until the app's shutdown sweep, or under aroost-sessiondaemon until someone stopped the session. Both openers now go through one spawn sequence that re-reads the row after the PTY is promoted and hangs the child up when it has gone, reporting the samenot-foundany other "tab is gone" path does. Hitting it needs two things acting on one tab at once — the UI androostctl, or two clients on one host session — so it was rare but silent, which is the bad combination. -
A
tab.resizeno longer blanks the terminal's cell metrics, so in-band size reports stop reading0x0(#463) —tab.resizestates a grid and nothing else, and the engine was filling the pixel half in with zeros rather than leaving it alone, so the next mode-2048 size report told the program running in the tab that its window was zero pixels across. Programs that lay themselves out from that — image viewers, anything sizing to the reported pixel box — got it wrong. The metrics now stay where the last client that actually had a viewport left them. Atab.resizenaming the grid a client is already at is a no-op again, which is what the IPC reference always promised. -
Two SSH tunnels to one host in the same process no longer destroy each other (#449) — opening a tunnel swept away this process's older scratch directories for that host id with no liveness check, on the reasoning that one process holds one tunnel per host at a time. A caller that opened two therefore lost the first: its socket and its control master went out from under it. The sweep now skips a directory this process is still holding, and keeps probing another process's leftovers exactly as before. A probe was deliberately not used for the same-process case: a directory is created before its socket is bound, so a probe would read "not bound yet" as "dead" and reclaim a tunnel its owner was about to bind into — and probing a live tunnel is not free, since every accepted connection spawns an
sshand bumps the tunnel's failure generation. -
grok/gx: a continued turn's
stopHookActive: trueStop fires keep the tabworking, and a failed turn's banner carries the reason (#423) — gx'sStophook is a gate: when a blocking Stop hook continues the turn,Stopfires again withstopHookActive: true. Each such fire now keeps the tabworking(instead of re-reportingfinishedwith another "Turn complete" banner) until gx's ownidle_promptNotification settles it. The very first fire of a turn still reportsfinisheduntil the next tool event or gated fire — a passive observer cannot tell whether a gate will block it (see the agents guide). Separately,StopFailurebanners now read gx'serrorDetails(the adapter was readingerror_details, a key gx never emits) instead of always falling back to the bare classified error name. tab.closeno longer answersnot-foundfor a tab it just closed (#416) — the op sent the child its hang-up first and removed the workspace row second, and the exit path that hang-up starts also removes the row; when it got there first the op failed withtab N not found. The row now leaves the workspace before the PTY is torn down, the orderproject.deletealways used.roostctl tab closewas the only surface that showed it — both UIs already treated the race as a closed tab — andattach_stream_test::exit_during_attachwas the flake it produced on CI.- A tab whose child stops reading can no longer park the engine's PTY
writer (#409) — on Linux a master
write(2)blocked on a full slave input buffer was never released, not by the child dying nor by the tab closing, and it held a runtime worker until the process exited; quitting could hang on it. The master is non-blocking now and the writer waits on the reactor, ending on the slave's hang-up; the reader waits inpoll(2)for the same reason. macOS was never bitten (that kernel releases the write), but both UIs run the one path. - A
localhosthost can now start its session withROOST_STATE_DIRset (#397) — a spawned daemon used to inherit the launcher's own state dir wholesale and refuse to start against the lock the UI already held. It now gets its own dir nested under the launcher's (<value>/session), so Connect onlocalhostworks under the same seam the test harness always launches under.roostctl session startderives the same way under the same seam and now prints the derived path on stderr; a directroost-session startunder the seam is unaffected and still uses it verbatim. host.status'sretrysays why a rung is armed, not just when (#399) — a retrying ssh connection used to overwrite its classified failure with the armed-rung line, soreasonsaidreconnecting in 8s (3/10)and never why until the ladder gave up. The retry block now carries an additivereasonalongside the rung's numbers, present whenever the drop that armed it was classified.- Tab now moves through the Add Host dialog — previously Tab did nothing there; it now cycles Name → Target → Add & Connect → Cancel and back, Shift+Tab reverses, and Enter or Space activates whichever button the ring is on.
- The Mac e2e lane no longer runs a stale
Roost.app(#493) — the harness bundled the Swift app only whenmac/build/Roost.appwas missing, so a local run could exercise a bundle from an older checkout while a greene2e-macsaid nothing about the Swift sources actually in the tree (CI was unaffected — it always bundles first). The harness now bundles unconditionally before the first Mac launch in a test process, reuses that bundle for the rest of it, and warnsstale Roost.app: sources are newer than the bundleif the binary's mtime is somehow still older than the newest source undermac/.ROOST_MAC_NO_BUNDLE=1skips the rebuild and refuses outright when there's no bundle to reuse. - A momentarily full host op queue no longer detaches the tab (#500) — a host connection whose op queue was full for even one send used to read that the same way as the worker being gone entirely, and detached the tab for good. A full queue is now retried on the existing backoff ladder — up to six times, 250ms to 5s — and only a seventh refusal, or the worker actually being gone, detaches it; reaching a live connection resets the count.
- An agent-hooks apply that fails after its key write says so, and
names the key (#491) —
set_hooks/raisewrite theagent-hookskey toconfig.conffirst, under the config lock, so the user's answer is durable even if the reconcile that follows fails. AnErrfrom either used to say nothing about that: the running iced UI kept its oldagent_hooksvalue in memory while disk already held the new one, androostctl agent set/ a session's reply gave no hint the key had changed. The error now names the key and says it's already on disk, and the iced UI's in-memory value is updated to match before the failure is shown, so what the UI reports and what's on disk never disagree. roostctl screenshotunder the GL fallback renderer is now documented as unreliable (#496) — the GLES/GL fallback (as opposed to Vulkan/Metal) can render geometry with no text and can hand back a frozen frame, which was undocumented and easy to mistake for a real bug when triaging a screenshot.CLAUDE.mdandtools/screenshot/README.mdnow name theSelected: AdapterInfo { … backend: … }UI-log line that says which renderer is live, and point attools/wayland/weston-run.shfor a capture you can trust.
Documentation¶
- Roost-Iced is now the recommended macOS build for driving Roost from
an agent —
installation.md/README point Mac users who drive Roost from an agent (the skill,open,wait,events,tab prompt) at Roost-Iced, since the SwiftRoost.apphas noproject.ensure, refuses--no-activate, and serves no event stream; a direction note (alsovision.md's decision log, DL-28) says Roost will either sunset the Swift app in favor of iced or rebuild it as a thin layer over the same Rust core the agent surface already lives in.first-run.mddocuments Roost-Iced's fresh-installsessiondefault and the agent-hooks consent card.agents.mdgains a "When a tab stays running" section — what clears a stuck lifecycle (the shell's own OSC 133 prompt marks), which shells emit them, the manualroostctl tab set-stateclear, and fish 4.9.3's own behavior — plus Claude'sagent_needs_input/elicitation_dialogblocked signals. Every doc that said "the macOS app" meaning the Swift build now says so explicitly.skills/roost/SKILL.mdpicked up the same disambiguation, plus thebusy-retry rule now covering an older server'shost-unavailablerefusal of a switch in flight.
Internal¶
- The close-cancels-a-pending-spawn branch is now under test, through a
promotion seam (#469) — no user-visible change.
spawn'sCancelledarm, hit when aclose()lands between thependingreservation and the promotion, had no test anywhere, and the existing race mirror inpty_shutdown_test.rscannot reach it: a round where every racer promotes before the sweep passes without exercising the branch. A privatespawn_with(..., before_promote)gives a test a hook that runs after the child exists and before the promotion re-checkspending, outside every lock; production passes an empty closure, and there is no#[cfg(test)]anywhere in the spawn path. HostConnSetkeeps oneHostEntryper saved host, and threeAppguards are now unit-tested (#383, #386) — no user-visible change.HostConnSetused to carry six parallelHashMaps keyed on a saved host's id (conns,retained,generations,ssh,outages,bootstrap_notes); it's oneHashMap<String, HostEntry>now, so a host's lifecycle has one clearing path per operation instead of six chances to forget one. And the three guards that decide whether a reconnect fires, whether a landed tunnel is dialed, and whether a saved-host connect proceeds while the app is quitting — previously reachable only throughApp::bootstrap, which measures fonts, builds a tokio runtime, and binds the IPC socket, so they had e2e coverage or none — are lifted onto free functions overExitState,HostConnSet, andBootstrapsInFlightin a newapp/host_lifecycle.rs. Each now has a unit test that fails when the guard is deleted.
Known issues¶
- #351 — on Wayland, clicking a notification selects the tab but cannot raise the window (upstream winit).
v0.0.19 — 2026-09-04¶
The host-sessions release: roost-session ships in Roost-Iced.app and
the .deb (plus standalone roost-session-<v>-linux-{amd64,arm64}
assets), a saved SSH host reconnects itself after a drop, a connection's
state is readable on the wire (host.status) and from roostctl host
status, a daemon that cannot start says so once instead of retrying
forever, and the desktop-notification path is honest about authorization
on macOS.
Added¶
- macOS gets local host sessions too (HS-4b, plan 041) —
roost-sessionnow ships insideRoost-Iced.appitself (Contents/MacOS/roost-session, individually signed and codesign-verified, with a symlinked copy beside the bundledroostctlso its own sibling-binary lookup resolves it too), so the Mac build offers the samelocalhostsurface Linux has had since HS-2: a fresh install's palette shows Connect Host: localhost (saves it and starts the daemon if nothing's listening yet), and a savedlocalhosthost auto-reconnects — connect-if-present, never spawning on its own — the moment Roost launches. Quit Roost-Iced and alocalhosthost's projects and tabs keep running inroost-session; relaunch and they're still there, exactly like any other host — an ordinary local tab still ends with the app, unchanged. Reaching a remote machine as an SSH host is still Linux-only — Roost can't install or startroost-sessionon a Mac for you yet — and the SwiftRoost.appstill has none of this feature, permanently. For a Mac that should come back up after its own reboot, the guide now has a launchd LaunchAgent recipe —KeepAlive: {SuccessfulExit: false}so a deliberate Stop Session stays stopped instead of launchd resurrecting it. See the Host Sessions guide for the full shape. - A saved SSH host reconnects itself after a drop (HS-4 slice 1, plan
040) — once a host has actually connected, Roost now treats a
mid-session drop the way it always treated
localhost: the sidebar dot goes grey and the band showsreconnecting in Ns (k/10)while it retries on its own, starting at a 1-second delay, doubling with jitter, capped at 30 seconds between tries. Ten attempts without success and it settles —reconnect gave up after 10 tries— with ↻ Reconnect, which never left the screen, as the recovery. No new setting: the choice is the Connect you already made. Launch is unchanged — a saved SSH host still never auto-connects when Roost opens, and auto-reconnect still never spawns a session on the far side, so a rebooted remote with nothing listening settles on "no session" immediately rather than retrying (runningroost-sessionas a lingeringsystemd --userunit is the fix for a host that should survive its own reboots). Not every drop gets a ladder: a changed host key is never retried (retrying a possible machine-in-the-middle in a loop would be a bug, not a feature), an unknown host key or a refused login need a person to do something Roost can't do non-interactively, and a session that's genuinely gone settles rather than spins. An explicit Disconnect clears any scheduled retry, a laptop sleeping through a drop spends no attempts while it's closed, and a resume that looks like a long sleep restarts the ladder at its base delay instead of burning attempts while the radio reassociates. Riding along: six previously-unbounded stderr drains in the SSH transport (#379) are now bounded, and a drain that couldn't finish reading in time is treated as unclassifiable rather than silently downgraded into a retryable failure — the fix that keeps the ladder from ever turning a changed host key into a security hole. See the Host Sessions guide anddocs/development/host-sessions.mdfor the shape. - Roost can install and upgrade
roost-sessionon a remote host itself (HS-3 bootstrap slice, plan 039) — the SSH transport (plan 038, below) still neededroost-sessionalready installed, at the exact right build, and started; this closes that gap. On an attended connect that fails because it's missing, not running, or on a stale build, Roost probes the far side over one job-privatesshControlMaster— an eight-rung candidate ladder (~/.local/bin, the non-interactivePATH,/usr/bin, then the common brew/nix locations) shared by the probe and the connect path itself, so anything the probe finds is something a connect can already reach — and, only with explicit consent, installs or updates it. The consent card names exactly what will happen (install/update/start), where, and where the bytes are actually coming from — this Roost's own binary, a checksum-verified release download, or an override — before anything is touched; Cancel mutates nothing. An install streams to a.tmp.$$file reserved against symlink/overwrite tricks, verifies the staged binary identifies as the client's exact build before it ever replaces the destination, and backs up any incumbent so a failure after that point restores it rather than leaving the host with nothing working. An upgrade runs install → stop (lease-free, no takeover) → wait for the old process to actually finish exiting → start → verify the new session's identity → reconnect — deliberately not "stop then install", so a failed install never touches a running session. The install rule requires an exact match onapp_version,session_protocol, andlibghostty_build— stricter than the runtime attach gate, which still accepts an already-running adjacent-version session unchanged.roostctlgains no install/upgrade surface of its own, and an IPC-originated connect never raises the consent dialog — this is an attended, in-app flow only, never something a script or a machine can trigger. The NotFound and NoSession troubleshooting rows and the remote "needs restart" dialog now describe these in-app offers instead of pointing at a manualsshsession. See the Host Sessions guide anddocs/development/host-sessions.mdfor the shape, including the trust chain's honest limits (checksum verification proves the bytes match the release, not the release's own integrity — artifact signing is future work). - Host sessions reach a remote machine over SSH directly (HS-3 transport
slice, plan 038) — a saved host's target can now be an SSH destination
(
workbox,user@host,ssh://user@host:port— only thessh://spelling carries an explicit port) as well as a Unix socket path, in both the palette's Add Host… dialog (its Target field) androostctl host add --target. Roost drives its ownssh— onessh -T … exec roost-session client-bridgeper accepted connection (control, events, each attached tab), multiplexed over a private, per-hostControlMasterso a long-lived events stream can never block the next tab's exec behind it. The generatedssh_configincludes the user's own~/.ssh/configfirst, so their own keepalives win, falling back to aServerAliveInterval/ConnectTimeoutpair only where the user has none; every invocation runsBatchMode=yes— key/agent auth only, this slice, no password or interactive 2FA prompt. A failed connection is classified into one of six families — changed host key (never offers to accept it), unknown host key, auth refused, no session running,roost-sessionnot found (the non-interactive-PATH gotcha), or an opaque transport failure — each with copy written for a user to act on, surfacing in the sidebar band asdisconnected — <reason>and, for an attended attempt, a toast.--verify(host addand the dialog's "Add & Connect") probes an SSH target with a one-shot, mux-lesssshexec before saving, so a typo or an unreachable host is caught immediately and nothing is left running either way.ROOST_SSH_BINoverrides thesshbinary. See the Host Sessions guide anddocs/development/host-sessions.mdfor the shape, anddocs/reference/ipc.mdfor the wire (byte-identical over SSH — the far side is a pure byte pump). The bootstrap half of this milestone (auto-detect/install/verify a remoteroost-sessionbinary) is not part of this slice — see the HS-3 bootstrap entry above, which shipped it separately. session.set_focuscloses the HS-2 attention gap — a session now learns the client's real focus (window focus + which tab is selected), pushed right aftersession.connectand on every edge that moves it, so the focus-suppression rule applies to the tab a client is actually looking at instead of whichever tab a headless session'swindow_focused = truedefault happened to pick. The stated focus is deliberately short-lived — a new lease, the reporting connection closing, or the lease's last connection closing all revert the session to "nobody is looking" — so a stale claim can never linger past the client that made it. A session one release older answersunknown-opand keeps HS-2's behavior. Seedocs/reference/ipc.md. (Retired at protocol 6 — see the Unreleased protocol entry.)- The iced UI attaches to host sessions (HS-2, #374) — closing Roost
no longer has to kill what is running in it. Save a
roost-sessionsocket as a host (roostctl host addor the palette's Add Host… dialog, which validates by dialingsession.identifybefore saving) and the sidebar grows a per-host section beside your local projects — a connection dot (green connected, amber connecting/needs a restart, grey disconnected), a right-aligned agent rollup, and the same project/tab rows a local workspace renders, agent surfaces and all: the agents palette, the notification inbox, and desktop banners are host-blind. Attaching is on-focus — switching to a host's tab dials that session's data plane, hydrates a client-side terminal from its snapshot (never blanking mid-retry), and detaches when you switch away, so client memory and connections stay bounded at one per host. Disconnect (closing the window, or the palette's Disconnect Host) leaves the session's shells running, dimmed but listed, with an inline "↻ Reconnect"; Stop Session actually ends them. A second window connecting takes over — the displaced window keeps its last frame on screen, dimmed, under a banner with a Reconnect here button. On localhost (Linux only — no packagedroost-sessionon macOS yet, so the surface there is Add Host pointed at a remote machine's forwarded socket) a saved host auto-reconnects on launch if the session is already running, never spawning one silently; an explicit Connect spawns it if needed. A build or protocol mismatch — the feature's most common failure mode, hit on every upgrade — puts the host inneeds-restartand raises a dialog instead of a corrupt screen: Restart session composessession.stop+ relaunch + reconnect client-side (every tab reopens as a fresh shell in its directory; running programs end), and a mismatched remote host, which this client cannot restart, gets a docs pointer instead of a dead button. Creation follows context:⌘N/Alt-Nand the + New Project button create on the selected project's host,⌘⇧N/Alt-Shift-Nopens a New Project on… host picker, and⌘T/Alt-Tnever lands a tab on a different host than its project. Every palette verb has aroostctl host(add,list,remove,connect,disconnect) equivalent driving the identical op, so a script can never diverge from a click. Two small additive server changes ship alongside: a session now forwards a tab's bell and OSC 52 clipboard writes to whichever client holds the lease (tab.effectevents, 256 KiB clipboard cap), andsession.set_themeseeds a session's terminals with the connecting client's palette so query replies match what the client actually renders. See the Host Sessions guide anddocs/reference/ipc.mdfor the wire. - Host sessions can be attached to: server terminals and a binary
attach stream (HS-1b, #363) — every tab a
roost-sessionspawns now has an authoritative libghostty terminal behind it, fed synchronously by a per-tab task over a bounded channel (so a runaway child feels backpressure instead of the session growing without bound). Three things follow. A program that asks the terminal who it is (DA/DA2), where the cursor is (DSR), or what a palette entry holds now gets an answer with nobody attached — it used to hang or time out.tab.dumpandtab.dump_resolvedare served headlessly from that terminal, through the same densifier the iced UI paints with, so the two cannot drift. And a client can attach:tab.attachhands out a single-use ticket, a second connection presents it, and the session streams the tab's whole terminal as a snapshot followed by live output — length-prefixed binary frames, 1 MiB cap,EXITalways last. A client that drops and comes back can resume from where it left off out of a per-tab replay ring, scoped by a random per-process epoch so a restarted session can never silently hand back a stale stream.session.connectmints an interactive lease — the authority to drive a session, as opposed to read it — andtakeover: truecloses every connection the previous holder had, telling each one why: events connections get a terminalsession.stoppingenvelope, data connections anERRORframe.session.stoplabels its teardown the same way instead of closing bare, closing the last of HS-1a's three documented deviations. Breaking change:events.subscribeon a session socket now requires that lease and answersconnect-requiredwithout one;session_protocolis2. Nothing about a UI socket changes — it answersunknown-opfor the new ops and never sees a binary frame. Seedocs/reference/ipc.mdfor the wire. roost-session, an opt-in headless host-session daemon (HS-1a, #363) — a newcrates/roost-sessionbinary that owns a workspace + PTY supervisor with no UI attached, for a session left running with nobody watching (e.g. a remote host). Start/stop/inspect it withroostctl session start|stop|status; the Linux.debnow ships/usr/bin/roost-sessionalongsideroostctl. It daemonizes with a readiness handshake (startonly exits 0 once a session actually answers), installsumask 0077so everything it creates lands owner-only, enforces a same-UID peer check on its socket (no UI socket does), and hydrates its saved tab layout headlessly (fresh shells, per-tab OSC drains — titles/cwd/notifications keep working with no terminal or renderer present).SIGTERM/SIGINTconverge on the same graceful-shutdown path assession.stop(self-dialing the socket; a broken socket falls back to flush-and-exit without the reap report): every in-flight mutating op is let finish, the layout is flushed, then every PTY is hung up (escalating toSIGKILL), and the reply carries a reap report ({reaped, killed, abandoned}). The wire addssession.identify,session.stop, andevents.subscribeon this socket only — UI sockets answerunknown-op/not-implementedfor these, unchanged. (events.subscribeshipped leaseless here and is lease-gated by the HS-1b entry above — both land in this release, so there is no leaseless form to write against.) A newroostctl --socketreaches a running session for any other op; a dedicatedmake e2e-sessionpytest lane (tools/roosttest/test_session.py) is now a required CI gate. Seedocs/reference/ipc.mdfor the full contract.- Terminal snapshot encode + decode in
roost-vt—Terminal::snapshot()serializes a live terminal to libghostty's snapshot byte stream, andSnapshotDecoderrestores one: buffered in a single call, or progressively, handing back a renderable, typeable terminal as soon as the stream's READY marker arrives and prepending scrollback pages as more bytes land. The wrapper owns its buffering and enforces its own size caps, so incomplete or over-limit input is refused before it reaches libghostty (corrupt payloads remain libghostty's per-record CRC check to reject). Groundwork for host sessions (#363); nothing in either UI consumes it yet. host.statusreports every saved host's connection state (#382) — a new read op returns, for every saved host, its live state, retry schedule, and the sidebar band's own rollup text, soroostctl host status [--id] [--json]can answer "what is this host doing right now" without rendering the window.roostctl host listalso grows a best-effortstate=column, backed by the same op.- A missing-daemon E2E lane (#392) —
make e2e-host-missing-daemon(and its CI variant) proves that alocalhosthost with no reachableroost-sessionbinary settles once, with the right reason, instead of spinning forever. tools/session/dev-session.shbuilds a matchingroost-sessionfor a remote (#393) — one script buildsroost-sessionon a target's own architecture in a shed, fetches the artifact, cross-checks it against the remote'suname -m, and launches Roost against it viaROOST_SESSION_INSTALL_BIN, closing the loop from "I have a dev build on this Mac" to "a Linux remote runs the matching daemon."- A live SSH-reconnect harness ships in-repo (
tools/session/live/, #384) —mutate.sh selftestandlive.sh's lanes exercise auto-reconnect against a realsshd, asserting onroostctl host statusinstead of scraping log lines. Not a CI lane — see thelinux-testskill for how to run it fromroost-dev.
Removed¶
- The GTK UI is gone —
crates/roost-linux(gtk4-rs + libadwaita) has been removed from the repo. Linux ships the iced UI, which v0.0.18 already made/usr/bin/roost; nothing about an installed package changes. The hidden alias desktop entry that keeps pre-rename launcher pins working stays.
Changed¶
- A saved host's
targetstring is classified differently (host-sessions HS-3, plan 038) — now that a target can mean an SSH destination as well as a socket path, the rules changed to keep the two unambiguous: the string is trimmed before it's read; a target with no/in it (a bare word likeroost.sock, or a relative filename) used to resolve as a same-directory socket path and now reads as an SSH hostname instead — spell a relative socket path./roost.sockto keep the old meaning; and an empty target (after trimming) is now refused outright rather than saved as a host nothing could ever reach. Absolute paths,~-paths, and explicit.//../-paths are unaffected. roostctl --target linuxreplaces--target gtk, andROOST_BUNDLE_PROFILE=linuxreplaces=gtk. There is nogtkalias: a staleROOST_BUNDLE_PROFILE=gtkmakesroostctlfail loudly withexpected `mac`, `linux`, or `iced`, and a UI started with it logs a warning and falls back to its default profile. Tab environments are unaffected — the UI injectsROOST_SOCKET. On Linux, all on-disk paths are unchanged: an installed package's socket,state.json, both locks, and log directory resolve exactly as before. (The macOS dev-mode paths for this profile — never shipped — move fromRoost-gtktoRoost-linux.)identifyandroostctl doctorreportapp_label: Roost-linuxfor the packaged Linux profile, where they used to reportRoost-gtk. The label is cosmetic — no path or app id is derived from it on Linux.- libghostty-vt bumped to ghostty main tip (#333) — pin
c74f6d56(2026-04-25) →f2d5758f6(2026-08-26), zig 0.15.2 → 0.16.0. Scrollback now genuinely honors the configured line limit: the old pin treated the configured value as a byte cap internally, silently capping history at a few hundred rows regardless of the configured count (a 2000-line setting never actually delivered 2000 rows); it does now. Three new libghostty error codes (IO_ERROR,LIMIT_EXCEEDED,REJECTED) are mapped intoroost-vt'sErrorenum.
Fixed¶
- macOS notification authorization is re-read on focus gain, on both
UIs (#355) — granting or revoking notification permission in System
Settings used to need a relaunch to take effect; the authorization
state was read once at launch and cached for the life of the process.
Both the Swift app and Roost-Iced now re-read it whenever the window
regains focus — the moment a user comes back from System Settings —
so the next notification honors the change with no relaunch.
app.notification_status, the test-gated IPC op for reading this state, is now served by both UIs with the same{backend, reason, authorized}shape. - Focusing a tab acknowledges its notification, in both cores
(#369) —
tab.focusmoved the selection but left the tab's pending-attention badge standing, so a hook- orroostctl-driven focus (not just a click) left the sidebar lying. The core now clears the notification and emitstab.notificationwithhas_pending: falseright afteractive.changed, in the same batch, which also retires the tab's inbox row and its project's rollup — all three derive from the same bit. Focusing an already-active badged tab acknowledges it too. tab.openwith nocwdresolves correctly on iced (#266) — a bareroostctl tab openlanded the new shell in the UI process's own working directory instead of the project's; Mac already resolved this, and the shared Rust engine's IPC handler now does too. The fix lives inWorkspace::open_tab, the one path every caller reaches, so title derivation and agent git metrics — both keyed off tab cwd — stop drifting from where the shell actually runs.- The mac release job now proves the Sparkle keypair matches before
building (#356) — it previously checked only that the public key
wasn't the known throwaway string. It now derives the release
secret's public half (through both base64 layers, comparing the
trailing 32 bytes directly for a 96-byte legacy key) and checks it
against the committed
SUPublicEDKeyin the Info.plist template — the same real derive-and-compare the iced job has done since #349, with no prerelease exemption. A mismatched pair used to ship a DMG that could never self-update, with nothing else noticing. bundle.sh's nested signatures can no longer fail silently underROOST_ALLOW_UNSIGNED=1(#391) — mirroring the guardbundle-iced.shalready had, a tolerated failure signing the nestedroostctlor Sparkle framework now blocks the outerRoost.appsignature instead of producing a bundle that looks signed but isn't, and a permanent post-bundle verification step catches a broken or partial seal the pre-sign guard alone could miss.- A
localhostsession that can't start settles once, with the reason (#392) — with theroost-sessionbinary unreachable, the band used to redial forever, re-burying the spawn ladder's actionable message under a bareio error: No such fileon every retry. It now settles once instead, and the reason travels with it:detailon the wire and under the row inroostctl host statusnames which rung failed. - A wrong-architecture
ROOST_SESSION_INSTALL_BINis refused before it streams (#393) — pointing the install seam at a binary built for the other architecture used to fail late, at the remote's staged verify, with nothing more than the remote shell'sExec format error. It's now refused up front, naming both architectures.
v0.0.18 — 2026-08-25¶
Linux switches to the iced UI, and macOS gains an experimental iced build
alongside the Swift app. The .deb now ships the Rust + iced UI as
/usr/bin/roost in place of the GTK app — same socket, same state.json,
same log directory, so roostctl and the Claude hooks keep working and
existing installs upgrade with no migration step. On macOS the Swift app
remains the product; a second, opt-in Roost-Iced.dmg ships beside it with
its own bundle id, its own Sparkle feed, and its own update key, so the two
can be installed and run side by side.
Features¶
- The Linux package is the iced UI (#314, #315) —
/usr/bin/roostis now the iced binary, built with thelinux-packagefeature so it resolves the productionroostprofile the GTK package already owned. GTK4 and libadwaita are no longer runtime dependencies. The GTK UI stays in the repo, built and tested in CI, as the development/parity implementation. - Roost-Iced for macOS, experimental and opt-in (#345, #347, #349) — a
Developer-ID signed, notarized
Roost-Iced-<version>.dmgwith a native menu bar, Dock badge, Sparkle updater, and desktop notifications. It carries a separate appcast (appcast-iced.xml) and a separate signing key, so the two macOS apps can never offer each other's updates. - macOS notifications for the iced build (#349) — banners route through
UNUserNotificationCenter; clicking one focuses the tab and reveals the sidebar. One live banner per tab, replaced in place rather than stacked, matching the Linux behavior (the Swift app stacks). - Linux notification click-to-focus (#352) — the iced UI speaks
org.freedesktop.Notificationsdirectly, so the click that activates a banner is received on the same connection that sent it. Banners no longer expire before they can be clicked, and are withdrawn on click or tab close. - The shipped Linux identity is
ai.stridelabs.Roost(#337) — window class, desktop entry, and app id all match the Mac app. A hidden alias desktop entry keeps launcher pins created before the rename working. - Terminal parity work across the iced UI — IME/dead-key input (#313), sprite and box-drawing rendering (#310), selection and copy semantics (#330), sidebar resize (#294), project lifecycle (#290), tab-strip behavior (#291), and a kitty-keyboard/cursor-shape pass (#348).
app.notification_status(#349) — a test-mode op reporting the macOS notification backend's availability and authorization, so the gated paths are assertable end to end.
Fixes¶
- Crash robustness (#308) — panics in either Rust UI now produce a crash report instead of a silent exit, and a malformed font can no longer abort the process (#298).
- Rendering keeps up under load (#296, #306, #307) — the engine tracks dirty rows and pushes frames rather than polling, cutting redundant redraws in both Rust UIs.
- Single-instance locking and PTY exit handling (#331, #332) — a second launch surfaces the running window instead of racing it, and a tab whose process exits is reaped deterministically.
- Wayland dependency closure (#327) — the
.debdeclares what a Wayland session actually needs, so a clean-machine install launches.
Release process¶
- The release publishes four assets — two
.debs,Roost-<version>.dmg, andRoost-Iced-<version>.dmg— and stays a draft until all four are present and correctly sized (#329, #349). A missing or truncated artifact leaves the release invisible rather than half-published. - Two Sparkle feeds —
docs/appcast.xmlfor the Swift app anddocs/appcast-iced.xmlfor the iced build, each signed with its own key and published in sequence so the two bot pushes cannot race. - The packaged binary is smoke-tested before publication (#317) — the real
.debis installed and launched, and its dependency closure verified in a clean container.
Documentation¶
- Docs site migrated from Material for MkDocs to
Zensical, the successor from the same team. Material
entered maintenance mode in November 2025 and warns on every build that MkDocs
2.0 will remove the plugin and theming systems with no migration path.
mkdocs.ymlis replaced by a nativezensical.toml; content is unchanged aside from a handful of links that--strictnow validates. The look comes from the shared stridelabs-docs-theme package rather than per-repo config, and fonts are self-hosted, so the site no longer requests anything from Google Fonts. Verified against the pre-migration build: identical 27-page set and all 374 heading anchors preserved.
v0.0.17 — 2026-07-31¶
Agents move out of the palette and into the sidebar. Every running agent now appears as a row under its project — lifecycle dot, name, elapsed time — with the active one highlighted, so "what is running, and how long has it been waiting?" is answerable at rest instead of by opening a palette.
Features¶
- Agents in the project sidebar, both UIs (#270) — one indented row per agent-owned tab under its project: a lifecycle-coloured dot, the agent's name, and a right-aligned elapsed time, with the full status as tooltip. Rows reuse the agent palette's own filter, name fallback, status vocabulary and ordering, so the two surfaces cannot disagree about what is most urgent. Clicking a row jumps to that tab and switches the project with it.
- The active agent and the selected project are visible at once (#270) — the
row whose tab is active carries its own subtle highlight, derived from the
active tab rather than from widget selection, because neither
NSOutlineViewnorGtkListBoxcan express two selections. It tracks focus however it moves: tab click, palette jump, notification, ortab.focusover IPC. show-sidebar-agentstoggle (#270) — visible by default; flip it with ⌘⇧A / Alt⇧A, the "Toggle Sidebar Agents" palette row, the Mac View menu, or the config key. Toggling writes back toconfig.conf, so it survives a restart.app.sidebar_dump(#270) — a read-only, ungated op reporting the sidebar's last-rendered rows on both UIs, so a refresh the UI failed to run surfaces as a failing assertion instead of staying invisible. Documented inipc.md.
Fixes¶
- The agent's own title marker is stripped from row names (#270) — Claude
Code prefixes its window title with
✳, so a renamed session rendered with two status glyphs, its own and ours. Leading marker glyphs are now dropped structurally, so a second adapter's marker needs no code change; ASCII titles (paths,[wip] name) are untouched. - The agent dot aligns with the project label (#270) on both UIs, and the row highlight with the project's selection pill, so agents read as a nested list rather than an inset block.
- GTK: the rollup stripe is scoped to the project row (#270) — it ran the
full height of the row, which since this release also contains the agent rows,
double-counting what the per-agent dots already say. It now matches Mac, and
survives selection (the rules clearing the row background used the
backgroundshorthand, which also resets the stripe'sbackground-image). - GTK: a cancelled press on an agent row no longer hijacks the next click (#270) — a press nudged past the click threshold but short of the drag threshold left a stale jump target that redirected the project's next activation, including one arriving by keyboard.
- Both UIs:
app.sidebar_dumpreports every project (#270), including one whose creation event has not yet reached the UI, rather than dropping it. - Mac: the sidebar flattens for the duration of a project drag (#270) —
without it, AppKit proposes child-relative drops once a project is expanded and
validateDroprejects them, turning most of that project's rows into a dead drop zone. GTK flattens for the same reason. - Mac: the agent row's activation control carries an accessibility label (#270) — it is transparent and titleless, so VoiceOver announced an unlabelled button with no way to tell which agent it focused.
Tooling & tests¶
- Sidebar↔palette parity is now pinned (#270) — the two surfaces share pure builders, but nothing asserted they agree; a dual-target e2e test now checks membership, per-project ordering, and per-row content.
- Dot-layout pixel guards (#270) — the four lifecycle colours and the dots' shared left edge, asserted from real screenshots on both targets. Deliberately narrow to stay non-flaky: only solid fills we own, never golden images, text metrics, or the Mac selection pill (which follows the system accent colour).
- The e2e harness no longer writes to a tracked fixture (#270) — the seed config is copied into each session's throwaway state dir, so a test that toggles a config key cannot mutate the repo or race the other target.
v0.0.16 — 2026-07-30¶
The agent release. The overloaded two-field agent model is replaced by four
independent axes with a derived display state, Claude hook handling moves into a
pure adapter crate, roostctl doctor makes the whole integration legible, and
the first agent-UX surface ships on both UIs: an agent palette (⌘⇧O / Alt⇧O)
listing every agent-owned tab with live status and git metrics.
Features¶
- Agent state model — four axes instead of one field (#259) — shell state
(OSC 133), agent lifecycle, attention, and ownership are now independent, and
the displayed state is derived, never written.
tab.statestays a closed four-value enum on the wire (failedprojects ontoneeds_input), andhook_activeis derived from ownership. Documented inAGENT_ROADMAP.md. roost-agent, a pure Claude hook adapter (#259) — hook JSON in,tab.agent_reportops out; no I/O, no clap, no socket.roostctl claude-hookroutes through it, so a second agent is a new module plus aninstallsubcommand — zero Swift, zero GTK. One canonical hook-event vocabulary (CLAUDE_HOOK_EVENTS+canonical_hook_event()) replaces three copies with drifting alias policy.roostctl doctor— read-only agent-integration diagnostics (#260) — 26 checks across five sections, each scoped (process / ui / tab) so a process fact never judges a tab fact. Names which axis is empty rather than leaving you staring at an absent dot; distinguishes missing / not-a-socket / stale sockets, and detects a pre-#259 server exactly via raw-key presence. Never repairs, installs, or mutates.- Doctor's two-axis status model and summary view (#263) — checks carry
ok/warn/fail/skipped; observations carry no status. Default output is one line per section,-vgives the full report, with color. - Agent palette — ⌘⇧O / Alt⇧O switcher (#265, roadmap D3) — lists every
agent-owned tab with a status dot, project, agent name, status text, elapsed
age, and async git metrics; up/down/enter to jump, escape to close, rows
refresh live while open. Pure UI work on the plan-002 state model — no new IPC
op;
PaletteItemViewgains an optional, additiveagentpayload. Shipped at parity on GTK and Mac, with git metrics gathered via short-timeout--no-optional-locksgit subprocesses behind a session-scoped cache. - Per-segment colors on the palette's git-metrics column (#269) — the ahead/behind/insertions/deletions segments are colored individually on both UIs instead of rendering as one dim run.
Fixes¶
idle_promptno longer flips a finished session to "Waiting for input" (#269) — Claude fires an idle nag ~60s after a turn ends; it was classified as blocking, so a finished session went gray and then orange, indistinguishable from one genuinely stuck on a permission prompt. The blocking set is nowpermission_prompt | agent_needs_input | elicitation_dialog. Adapter-only, so both UIs got it for free.- Mac: tab and project positions are allocated from
max + 1, notcount(#262, #263) — closing a tab from the middle made the next one reuse a live position, and closing from the front could render a brand-new tab to the left of one that predates it. That's the real cause of "the third tab asked for input but the second one turned orange" — the agent model was correct; the row it sat next to wasn't. Rust had this fix since #86; Swift never got it. Loading astate.jsonwith colliding positions now repairs them on both UIs, and four position-only sorts gained anidtiebreak (they sorted unordered dictionary values). - The project rollup no longer hides trusted agent state (#259) — a project whose only blocked tab was an agent showed no stripe.
- Raw OSC 9/99/777 suppression during an agent session (#259) — documented but never implemented.
- One notification focus predicate (#259) — banner, unread badge, and inbox row disagreed with each other and with the docs.
roostctl: payloadless claude-hook invocations keep working (#259), and format characters in doctor output are escaped, closing a padding-aim vector (#263).- Footer hint bar removed from the palettes (#269), mechanism and all.
Tooling & tests¶
- Dual-target e2e coverage for the agent stack — full lifecycle through the
real
roostctl claude-hookbinary (#259),roostctl doctorend to end (#260), tab ordering (#263), and 15 agent-palette cases driving production IPC — exact status strings, rank order, effective-vs-raw lifecycle via OSC 133 marks, subagent immunity, live refresh, and git metrics against a throwaway repo (#265). - Palette e2e seeds settle on explicit ready shell states rather than "not unknown", via a fed OSC 133 mark under test mode (#265, #269).
make checklints at CI parity (#259).- Release assets are labeled by target, not filename (#257) — "Linux — ARM
64-bit (.deb)" instead of
roost_0.0.15_arm64.deb; the download still lands under the real name, so apt is unaffected.
v0.0.15 — 2026-07-26¶
Kitty-mode keyboard input now carries shifted text correctly on both UIs — you
can type capitals and ? into Kitty-aware TUIs again — plus a round of terminal
conformance work (DEC 2031, device-query replies, DECTCEM).
Features¶
- Device queries answered via
write_ptyon both UIs (#247, #248) — the terminal replies to DA/DSR-style queries through libghostty's reply buffer (set_write_pty_bufferinroost-vt), so programs that probe the terminal get an answer instead of a timeout. - DEC 2031: apps are notified on runtime theme switch (#248) — flipping the system/app theme while a TUI is running tells it to re-read its colors.
Fixes¶
- Kitty keyboard mode preserves shifted text (Mac + GTK, #254) — the
terminals now tell libghostty which modifiers were consumed to produce
printable text, so Strix and other Kitty-aware TUIs receive capitals and
shifted punctuation normally while Shift+Enter stays distinguishable.
Previously Shift+G reached the app as
CSI 103;2u("g" + Shift) rather than the text "G", and no capital or shifted punctuation could be typed. On Mac this also covers Option-produced text (Option+2 → "™"), matching Ghostty's defaultmacos-option-as-alt = false: under Kitty mode Option+key now sends the composed character rather than an Alt chord, as Roost already did outside Kitty mode. Linux is unchanged there — GDK reports the real per-layout consumed modifiers, and Alt isn't one of them. - GTK reports the base-layout key in CSI-u sequences (#254) — Kitty CSI-u
and fixterms identify a key by its unshifted codepoint, which GTK derived
from the shifted keyval. Ctrl+? encoded as
CSI 63;6uwhere Ghostty and the Mac UI sendCSI 47;6u, and Ctrl+! kept a Shift that fixterms drops. Now looked up from the keymap, closing a Mac/Linux parity gap. - Hidden cursor is never drawn — DECTCEM is respected (#246, #248).
- GTK: left-edge drag-selection no longer stolen by the paned resize handle (#251).
Tooling & tests¶
roost-linux's own tests now run in CI (#254) —rust-testexcludes the crate (no GTK toolchain), so its ~380 unit tests compiled but executed nowhere;gtk-buildruns them now. That immediately caught a PTY smoke test whose capture stopped at the first 4 KB chunk, becauseExitandBytesare independent producers on one broadcast channel andExitcan arrive first. The same assumption in the UI is tracked as #255.- Rust toolchain 1.85.0 → 1.97.1 (#253).
v0.0.14 — 2026-06-30¶
Drag files onto the terminal to attach them, a round of GTK chrome work bringing it to Mac parity, and the macOS app is now Developer ID signed + notarized.
Features¶
- Drag a file onto the terminal to insert its path (Mac + GTK, #245) —
dragging a file (e.g. a screenshot) from Finder / the file manager onto a
terminal tab inserts its shell-escaped path through the same bracketed-paste
path as ⌘V, so Claude Code / Codex resolve it as an image and attach it
(
[Image #N]). Mirrors Ghostty's drop behavior; the Mac (Swift) and GTK implementations share byte-identical escaping. Clipboard paste is unchanged. - GTK chrome brought to Mac parity (#231, #236, #244) — chrome colors, a
minimal
AdwHeaderBartitle, custom Mac-style tab pills, the tab strip beside the sidebar, and the active tab kept scrolled into view. - macOS sidebar: flush-left, gapped per-project running rail (#235).
Fixes¶
- GTK palette focus-walk crash (#234) — terminal focus grabs now route through a rooted-widget guard (clippy-enforced), with focus-ownership invariants and a rate-limited glib log writer to bound the warning-storm blast radius. Fixes the palette focus crash and the log storms around it.
- macOS app is now Developer ID signed + notarized (#240) — gated on the full secret set; ad-hoc signing remains the fallback when secrets are absent.
- release: retry
hdiutil createon transient "Resource busy" (#243).
Tooling & tests¶
- Wayland pointer-drag guard + CI hardening following #236 (#237); a
popos-testskill for native Pop!_OS COSMIC testing plus shed-based Linux-testing-on-a-Mac tooling; and an output-only readiness sentinel that fixes the dominante2e-macflake (test_env_injected). Drag-drop's pure-value Mac tests use XCTest to dodge a swift-testing-runner SIGTRAP under Xcode 26.x.
v0.0.13 — 2026-06-26¶
A microphone/camera permission fix for the macOS app, plus a round of GTK tab-focus and selection-sync fixes.
Features¶
- Microphone, camera & AppleScript access for programs run in a tab (macOS,
#232) — Roost.app now declares the capture entitlements (
device.audio-input,device.camera,automation.apple-events) plus the paired Info.plist usage strings, so a program launched in a Roost tab (Claude Code's/voice, audio recorders,osascript) gets a proper "Roost would like to access the microphone" prompt instead of failing silently. macOS attributes the request to Roost as the TCC responsible app; granting once covers programs in that session. The broad personal-information entitlements (contacts/calendars/ photos/location) are deliberately excluded, and the bundledroostctlhelper is signed narrowly so it never inherits the capture grants. Note: under the current ad-hoc signing the grant resets on each app update until Developer ID signing lands (#83).
Fixes¶
- GTK terminal focus now matches the Mac UI (#225) — clicking a tab or switching projects reliably refocuses the terminal, and focus is re-asserted when the window is re-activated.
- GTK tab/project clicks sync the workspace core (#228, #229) — selecting a
tab via the AdwTabView (and renaming a background tab) now updates the core's
active selection, so
identify, restore, and notification routing reflect the tab you actually selected. - Fixed two GTK crashes — a segfault when right-clicking an AdwTabView tab (context menu), and a crash when tearing down the command-palette overlay.
- Dropped GTK
Alt+digitAdwTabView shortcuts that collided withSwitchProject.
Tests + CI¶
- e2e-gtk now runs the real-click terminal-focus regression (non-gating).
v0.0.12 — 2026-06-15¶
Added¶
- Five new dark themes —
Monokai Pro,Monokai Remastered,Synthwave,Material Ocean, andOxocarbon. The bundled set is now 26 themes, all dark. - Configurable link-open modifier (GTK) — hold a modifier and click an
OSC 8 hyperlink or
https://…text to open it in your browser. Defaults to Cmd on macOS and Alt on Linux; override withlink-modifier = ctrl|alt|superinconfig.conf. Linux users who prefer the conventional Ctrl+click setlink-modifier = ctrl.
Changed¶
- Linux default keybindings are now Alt-centric — on Linux,
Altis the single app modifier (the role Cmd plays on macOS), leavingCtrlto the shell. Moved:new_tabCtrl+T→Alt+T,close_tabCtrl+W→Alt+W, tab cycleCtrl+Shift+[/]→Alt+Shift+[/],jump_to_unreadCtrl+Shift+U→Alt+Shift+U, and font zoomCtrl+±/Ctrl+0→Alt+±/Alt+0. Unchanged:Ctrl+1‑9(switch tab) and theCtrl+Shift+C/Vcopy/paste alternates stay onCtrl; all project, palette, and clipboard actions were already onAlt. Every binding is overridable — restore a prior chord via config, e.g.keybind = ctrl+t = new_tab. - Bundled themes are now dark-only — removed the two light themes (
Atom One Light,Ayu Light) and the near-duplicateTokyoNight Night(identical toTokyoNightas rendered by Roost). - GTK projects sidebar refined to match the Mac UI (#220).
Fixed¶
- Clickable links from Claude Code and other tools (#224) — Roost now
advertises OSC 8 hyperlink support (
FORCE_HYPERLINK=1) to programs running in the terminal on both the Mac and GTK UIs, so Claude Code's PR-footer links (and anysupports-hyperlinks-gated output) render as clickable links.
v0.0.11 — 2026-06-07¶
Theme expansion and a big internal cleanup. The bundled theme set doubles to 24, the GTK command palette keeps its highlighted row in view, the macOS projects sidebar gets a readable selection color, and the legacy Go + GTK4 implementation is retired.
Features¶
- 17 more bundled themes — 24 total (#218) — adds
0x96f,Atom,Atom One Light,Ayu Light,Ayu Mirage,Nord,Rose Pine,Solarized Dark Patched,Catppuccin Frappe/Macchiato,TokyoNight Storm/Night,Gruvbox Dark,One Half Dark,GitHub Dark Default,Everforest Dark Hard, andKanagawa Wave(byte-identical to Ghostty's set) across both UIs. Pick one withtheme = NAME; browse them in the command palette's Select Theme…. - Readable colored selection in the macOS projects sidebar (#216) — the selected project row uses a legible accent fill instead of the washed-out default.
Fixes¶
- GTK palette keeps the highlighted row in view (#218) — arrowing past the last visible row, and opening the Select Theme… / Select Font… pickers pre-positioned on the active theme/font, now scroll the highlight into view instead of leaving it clipped or off-screen.
Internal¶
- GODELETE — removed the legacy Go + GTK4 implementation (#218) — now that
the Swift (macOS) and Rust + gtk4-rs (Linux) UIs are at parity, the original Go
prototype (
cmd/,internal/,build/,go.mod, the Go CI) is gone. Its working snapshot and full migration history are archived separately. Bundled theme files moved into the Rust crate (crates/roost-linux/src/resources/themes/), kept byte-identical to the macOS bundle copy by athemes-parityCI job.
Tests + CI¶
- Hardened
test_env_injectedagainst a shell-startup race (#217). - Bumped GitHub Actions to current major tags (#218) —
setup-uv@v7,upload-artifact@v7,upload-pages-artifact@v5. - Portable
libghostty-vt+ GTK E2E diagnostics (#219) — build the vendoredlibghostty-vtfor a baseline CPU (-Dcpu=baseline) so the shipped binaries run on any CPU of the architecture; a native-CPU build cached across CI's mixed runner fleet was SIGILL-crashing both the GTK UI and theroost-vtffi test. Also capture + upload the GTK UI's log so a boot failure under xvfb isn't blind (the gap that hid this).
Docs¶
- Extending Roost guide (#214, #215) — added an advanced multi-step wizard
(Python) provider example and made the provider example's
PATHhandling cross-platform.
v0.0.10 — 2026-06-05¶
Provider polish release. Two refinements to the v0.0.9 script-backed provider system
that make providers portable and forgiving: Roost now hands a provider the path
to its own roostctl, and an activate that just does something no longer has to
sanitize its stdout.
Features¶
ROOST_ROOSTCTLfor providers (#212) — Roost injectsROOST_ROOSTCTL(the absolute path to its ownroostctl) into provider scripts, so they can drive Roost withoutroostctlonPATH. Closes the macOS gap where the.dmgbundlesroostctlinside the app (offPATH) and a Finder-launched app gets a minimalPATH; the Linux.debalready installs it onPATH. Use"${ROOST_ROOSTCTL:-roostctl}". Best-effort — when Roost can't resolve its own CLI the var is omitted (and any inherited one stripped) so thePATHfallback fires.
Fixes¶
- Lenient
activatestdout (#213) — anactivatephase is a side effect, so its stdout is now ignored unless it looks like a provider payload (a JSON object/array = a drill-down sub-menu). A command's incidental output — e.g. the new tab idroostctl tab openprints — no longer fails parsing with "Provider failed". JSON-shaped output that doesn't parse is still reported (so a malformed sub-menu isn't swallowed), and the parse error now names the expected shape. Thelistphase stays strict.
Docs¶
- Extending Roost documents
ROOST_ROOSTCTL(with the Mac/Linuxroostctllocation split) and the lenientactivate-stdout contract;docs/reference/cli.mdgains a "Whereroostctllives" section + the env-var row.
v0.0.9 — 2026-06-04¶
Extensibility release. Roost gains a script-backed provider system: drop an
executable in ~/.config/roost/providers/ (or add a provider = line to config)
and it appears as a dynamic, on-demand menu in a new custom palette
(⌘⇧E / Alt+Shift+E) — "open shed", "switch worktree", anything a script can list —
plus the CLI building blocks to act on a selection. See the new
Extending Roost guide.
Features¶
- Script-backed providers + custom palette (#210) — a
provider =config entry, or an executable under~/.config/roost/providers/, is run on demand to populate a palette frame (list), then again to act on the choice (activate). Active-tab context is passed via env vars + a stdin JSON object, and{items}JSON is read back (drill-in supported). Surfaced via ⌘⇧E / Alt+Shift+E and a conditional "Custom Commands…" row in the command palette. One contract, both UIs. palette.presentIPC op (#210) — a script hands Roost a list and blocks for the user's pick; the programmatic twin of the command palette. Exposed asroostctl palette present(items via--itemsor stdin).- Scriptable
tab open+tab list --json(#211) —roostctl tab opengains a trailing-- <cmd…>(run a command in the tab; closes on exit = hold=false),--hold(keep it open afterward, likecommand = … hold=true),--after-tab <id>(place the new tab next to that one), and--focus;tab listgains--json. - Non-actionable palette rows (#211) — palette items can be marked non-selectable
(
actionable: false), so a provider's empty/disabled row (e.g. "No results") renders but can't be picked and leaves the palette open.
Docs¶
- New Extending Roost guide — CLI/IPC automation, the
command =launcher, and theprovider =protocol with bash / Python / TypeScript examples (incl. a complete "Open shed" provider). The Configuration reference is now linked in the nav, anddocs/reference/{cli,ipc}.mddocument the newtab.*/palette.*surface.
v0.0.8 — 2026-06-02¶
Terminal color-fidelity release. Fixes a palette bug that made every 256-color TUI (vim, htop, lazygit — and notably opencode over SSH) render the wrong colors, and adds OSC 4 palette-query replies so color-detecting TUIs read roost's palette correctly.
Features¶
- OSC 4 palette-query replies (#208) — roost now answers
OSC 4;Ps;?palette queries from its live palette (reflecting any mid-sessionOSC 4;Ps;rgb:…set), mirroring the existing OSC 10/11/12 replies. opentui- based TUIs (opencode in local mode, among others) gate their color detection on the OSC 4 reply.docs/reference/terminal-queries.mddocuments roost's two reply-channel model — embedder-synthesized OSC colors vs. the planned libghosttywrite_ptydevice-query channel (tracked in #209).
Fixes¶
- 256-color palette: cube + grayscale ramp (#207) — roost's theme palette
only populated the 16 ANSI colors; the xterm 6×6×6 color cube (16–231) and
24-step grayscale ramp (232–255) were a flat placeholder (
#808080gray on Mac, black on Linux). Becauseset_color_palettepushes the full 256-entry array, it also overwrote libghostty's correct compiled-in cube — so everySGR 48;5;N/38;5;Ncell rendered the same wrong color. Most visible over SSH: opencode in a shed uses 256-color (becauseCOLORTERMis unset there), so its#080808background rendered as#808080gray and was unreadable. Both UIs now compute the standard xterm-256 palette (cube levels[0,95,135,175,215,255], grayscale8+10·n) as the base, with theme files overriding on top. Affects every 256-color TUI, locally and over SSH.
v0.0.7 — 2026-06-01¶
Command-palette polish release. The palette gains a Select Font… picker that writes your theme, font, and size choices back to config, and its root list is reordered so the Theme/Font drill-ins lead. Tab titles now follow the working directory on any shell (not just integrated ones), three GTK/Linux parity bugs are fixed, and the e2e suite is reworked so a local run exercises exactly what CI does.
Features¶
- Select Font… in the command palette + config write-back (#203) — a new
Select Font… entry in ⌘⇧P / Ctrl+Shift+P drills into a sub-frame of
installed monospace families (arrow = live preview, Esc = revert, Enter =
commit), mirroring the existing Select Theme… pattern. A curated
programming-font list leads (JetBrains Mono, Fira Code, Cascadia, Iosevka,
IBM Plex, plus platform natives like SF Mono / Menlo and DejaVu / Ubuntu
Mono), filtered to what's actually installed. Theme, font family, and font
size now persist to
~/.config/roost/config.confvia a new atomicRoostConfig.setKey/config::set_keyhelper (tmp+rename, comment- and indentation-preserving). No new IPC ops — both UIs at parity. - Palette root leads with the drill-ins (#206) — the command-palette root now orders Select Theme… → Select Font… → View Notifications → Clear All Notifications → the rest, on both UIs (the notification rows previously led).
- GTK sidebar collapsed state persists across relaunch (#192) — a ⌘B-collapsed sidebar on Linux now stays collapsed after quit + relaunch.
Fixes¶
- Tab title follows cwd on any shell (#202, closes #196) — the model
re-derives a tab's title from the cwd basename when the tab isn't
user-titled, so the title tracks
cdeven on shells without the OSC 0 integration loaded (Apple/bin/bash3.2,--norc, etc.). Integrated shells still refine to the tilde-abbreviated full path on the next prompt (latest-wins, no flicker).user_titledis now persisted in the tab snapshot so a manual rename survivescdacross relaunch;derive_title("/")matches across UIs. - Linux default tab is a non-login interactive shell (#192) — match
Ghostty's platform split (login shell only on macOS). A stray
~/.bash_profileno longer shadows~/.bashrc, so the prompt, aliases, and color the user sees in their normal terminal load. - GTK palette overlay double-removal CRITICALs (#192) — closing the command
palette no longer logs a pair of
gtk_overlay_remove_overlayassertion failures;Dropskips removal whendismissalready ran.
Tests + CI¶
- Trustworthy e2e suite — local == CI (#194) — a skip now means only "this
environment genuinely can't exercise this," never "the setup didn't work."
Adds
ROOST_STATE_DIR(both UIs) + MacUserDefaultsisolation (ROOST_DEFAULTS_SUITE) so the harness can own a fresh hermetic instance;--roost-fresh/make e2e-{gtk,mac}-cimake local runs exercise the same set CI does. - CI shell provisioning + flake hardening (#204, #205) — provision zsh + Homebrew bash on CI and harden a shell-startup race; fix two local-env e2e flakes (OSC 52 PRIMARY selection + a sidebar layout-stall).
Docs¶
- Document the e2e harness flags, CI configs, and skip policy (#198).
- Abbreviate
$HOMEto~in the rooster-title recipe, portably (#199).
Release process¶
- mise-aware cargo lookup in
update-version.sh(#201) — the release bump script resolves cargo through mise. create-github-app-tokenv2 → v3 + app-id → client-id (#195, #200) — bump the token action to v3 (Node 24) and switch toclient-idto resolve the v3 deprecation.- URL-embedded token for the Sparkle appcast push (#122) — fixes the appcast push that publishes the release.
v0.0.6 — 2026-05-30¶
Mouse-aware terminals release. Strix and other mouse-driven TUIs now click, drag, hover, and change cursor shape through Roost the same way they do under ghostty, on both Mac and Linux. Plus two Mac sidebar fixes that ship the behavior v0.0.5 was supposed to have, and a release-pipeline migration where the same release-bot GitHub App now drives every cross-repo push (no more per-pipeline PATs).
Features¶
- TUI mouse-tracking, focus, and OSC 22 cursor shape — Mac (#183) and
Linux (#184) — TUIs like strix that drive mouse-tracking modes (button,
motion, SGR encoding), focus reporting, and OSC 22 cursor-shape changes
now work end-to-end through Roost on both platforms. A new
tools/roosttest/test_mouse_tracking.pysuite enforces behavioral parity across--roost-target macand--roost-target gtkso a regression on either side fails the matching CI job.
Fixes¶
- Mac sidebar holds its width on window resize (#180) — re-fix of the bug
PR #159 misdiagnosed (and that v0.0.5 still shipped). The Mac sidebar now
owns resize redistribution directly via
splitView(_:resizeSubviewsWithOldSize:); the sidebar clamps to [160, 400] and the content view absorbs the window-resize delta. - Mac sidebar stays collapsed across quit + relaunch (#181, #182) — fixes
a pre-existing bug where ⌘B-collapsed sidebars silently re-opened on
relaunch AND silently corrupted persistence by writing
RoostSidebarVisible=trueback to UserDefaults. PR #182 refactored the fix into a cleaner vision.md DL-11 shape:selectProject(id:)andfocusTab(tabID:)are pure data mutators that never touch sidebar visibility; user-action call sites invokeensureSidebarVisible()explicitly.
Release process¶
- Adopt
cc-plugins:release-workflowsconvention (#179) — roost is the first consumer of the new release framework:scripts/release/update-version.shbumps Cargo.toml + Cargo.lock together (closes the Cargo.lock-drift bug class that produced the v0.0.5 mac-job failure),RELEASING.mddocuments the per-repo policy, two commits per release (docs(changelog)thenchore(version)), and the skill flow is one command:/release-workflows:release vX.Y.Z. - Release-bot App as single cross-repo credential (#191) —
apt-charliekdispatch and the Sparkle appcast push now both authenticate via tokens minted from thecharliek-release-botApp (scoped per-target viaactions/create-github-app-token'sowner+repositories). The legacyAPT_DISPATCH_TOKENPAT is retired; the appcast bot identity reads from the action'sapp-slugoutput at runtime instead of being hardcoded so the App can be renamed without per-repo edits. sanity-check-app.ymlnow verifies both roost ANDapt-charliek— multi-target shape catches App-install-on-target-repo mistakes before the next release tries to push there. Also fixes a latent/user403 bug (installation tokens can't call/user— bot identity comes from the action'sapp-slugoutput).
Tests + CI¶
- Cross-platform mouse-tracking regression suite (#183, #184) — every case runs against both UIs by default; gtk-skip markers from PR #183 were dropped in #184 once the GTK wiring landed.
- Pipeline-helper consolidation (#190) — shared
_wait_tab_attached+_drain_untilhelpers extracted totools/roosttest/util.py(CodeRabbit flagged the duplication across both mouse-tracking and OSC-pipeline tests).
v0.0.5 — 2026-05-29¶
URLs, selection, and SSH-aware terminals release. URLs in the terminal are now
clickable on both Mac (⌘-click) and Linux (Ctrl-click), with OSC 8 hyperlink
ranges plumbed through the workspace; double-click selects words and
triple-click selects lines; COLORTERM=truecolor now follows you across SSH;
and release.yml cuts releases end-to-end — no more local post-release
script for the Sparkle appcast.
Features¶
- Click-to-open URLs (#161, #171, #173, #175) — new
roost-urlcrate detects URLs in the terminal viewport; OSC 8 hyperlinks honored; ⌘-click on Mac (#173), Ctrl-click on Linux (#175). Mirrored Swift implementation. - Word-on-double-click + line-on-triple-click selection (#161, #176, #177) — both UIs; matches familiar terminal ergonomics; selection respects URL ranges where applicable.
COLORTERMforwarded across SSH (#172) — newssh-envshell feature injectsCOLORTERM=truecolorinto the SSH environment so remote sessions render truecolor instead of dropping to 256-color.
Release process¶
- App-driven Sparkle appcast publish (#178, closes #136) —
release.ymlEdDSA-signs the DMG and pushesdocs/appcast.xmlto main as thecharliek-release-botGitHub App, which is inmain's rulesetbypass_actors. Replaces v0.0.4's localpublish-appcast.shscript (deleted). The release flow is now one command:/release:release vX.Y.Z. update-appcast.pyis now idempotent — preserves the priorpubDatewhen replacing the same version, so workflow re-runs produce a clean no-op diff instead of a content-identical churn commit.mainmigrated from classic protection to a ruleset withci-successrequired + the release-bot App in the bypass list.
Fixes¶
- Mac sidebar holds its width when the window is resized (#159).
Tests + CI¶
- Test-only IPC ops (#157) —
tab.feed_pty_bytes,tab.capture_pty_input,tab.dump_resolvedunlock new pytest coverage paths. - OSC pipeline end-to-end coverage (#142, #145, #158) — real OSC bytes
driven through the full pipeline in
tools/roosttest/test_osc_pipeline.py. - Mac OSC drain tests (#156) — exercise
TerminalView.appendBytesdrain with real OSC byte sequences. - URL + word selection fixtures (
tests/url-fixtures/,tests/word-fixtures/) — text-based fixtures covering schemes, unicode, trailing punctuation, multi-cell glyphs.
Docs¶
- Spawned-shell env-vars table completed + cross-linked between the two shell-integration docs (#174).
v0.0.4 — 2026-05-28¶
Rendering, selection, and clipboard release. Ghostty's sprite renderer is now
ported for crisp box-drawing + block elements; text selection survives
scrollback; copy-on-select + middle-click paste land for X11-style terminal
ergonomics; and clipboard image paste delivers a .png path to Claude Code on
both Mac and Linux. Plus OSC 10/11/12 + OSC 52 fixes that unblock codex's
theme detection and well-behaved program-initiated clipboard writes.
Features¶
- Sprite renderer ported from Ghostty (#140) — box-drawing and block-element characters render crisp + cell-aligned instead of the font's fallback glyphs.
- Three-state copy-on-select + middle-click paste (#147) — selection auto-copies; middle-click pastes; matches X11/Linux terminal ergonomics.
- Clipboard image paste → Claude Code — clipboard images are written to a
temp
.pngand the path is pasted as text, soclaudepicks the image up natively. Mac (#149) and Linux GTK (#153). - OSC 52 program-initiated clipboard writes (#154) — programs (
tmux, ssh forwards, etc.) can copy to the system clipboard via the standard escape. - Scrollback-aware selection (#141, #146) — selection anchors to scrollback-stable coords, so highlights stay attached to the right text while you scroll through history.
- Theme
bold-color(#142) — themes can override the color of bold cells. selection.*/clipboard.*IPC ops (#151) — let the pytest harness drive selection + clipboard from outside.
Fixes¶
- OSC 10/11/12 query replies (#144, #145, #152) — answer terminal-color queries with libghostty's live colors so apps (e.g. codex) detect the dark/light theme correctly and stop rendering with a stuck gray bar.
- SGR inverse + Mac two-pass rendering (#139) — inverse / reverse-video
cells render correctly under Mac's two-pass path;
Cell.styleexposed. - OSC 52 hardening (#155) — drop oversized payloads, tighten selector parsing.
Release process¶
mac/scripts/publish-appcast.sh <tag>(#137) — local script (shed-style) replaces release.yml's bot-driven appcast push, whichmain's branch protection rejected on v0.0.3. The maintainer runs it post-release; theirgit pushlands the appcast entry cleanly. Closed #136.
Docs¶
- Shell setup notes:
$SHELLvswhich bashand how tochshto Homebrew bash (#138).
v0.0.3 — 2026-05-27¶
Auto-update + shell-aware terminals release. Roost.app now ships with Sparkle 2 auto-update so future fixes reach users automatically (no more hand-delivered DMGs), and the terminal is shell-aware: native cwd tracking, OSC 133 prompt/command marks, and shipped shell-integration scripts that auto-bootstrap for bash and zsh. Plus the v0.0.2 clean-install crash fix and a batch of input + tab fixes.
Features¶
- Sparkle 2 auto-update for the Mac app (#122, #128, #130) — EdDSA-signed releases via a GitHub Pages appcast; works under ad-hoc signing (no Apple Developer ID required). "Check for Updates…" in the App menu.
- Native shell-cwd tracking (#120) — new tabs inherit the active tab's working directory, read straight from the shell's PTY.
- OSC 133 prompt/command marks parsed by both VT scanners (#121, #127); a
tab's
hookActiverun-state flips from them. - Shell-integration scripts shipped in-bundle (#125, #126) —
roost.bash/roost.zshemit the env contract (cwd + prompt boundaries). - Auto-bootstrap for bash (
--posix+ENV) and zsh (ZDOTDIR) (#129, #132) — no~/.bashrc/~/.zshrcedits required.
Fixes¶
- Mac clean-install crash (#116) — v0.0.2 crashed at launch on any machine
that wasn't the build host because themes resolved through
Bundle.module's compile-time path. Themes now load viaBundle.mainfromContents/Resources(and a deterministic CI guard catches this class of regression). - Default shell now spawned as a login shell (#119) — picks up
~/.zprofile/~/.bash_profilelike a normal terminal. - Kitty / mouse-tracking input fixes — scroll wheel encoded as button-4/5 under mouse tracking; Ctrl+letter works under Kitty (unshifted codepoint set); Cmd-T / Ctrl-T new tab inherits the active tab's cwd.
Tests + CI¶
- Mac E2E is a required CI gate for PRs and releases (#118).
- New pytest coverage in
tools/roosttest/for new-tab cwd inheritance and shell integration (title, prompt, OSC 133 edges) (#131).
Docs¶
- Shell-integration documentation rewrite (#126); dropped a stale
ROOST_PROJECT_IDenv reference never injected by the Rust/Swift port (#133).
v0.0.2 — 2026-05-26¶
Programmability + automation release: the command palette and a growing set of control ops are now driveable over IPC, with an end-to-end test harness exercising both UIs — plus boot-reliability and Mac↔GTK parity fixes.
Features¶
- Command palette over IPC —
palette.open/state/query/activate/dismissops +roostctl palette …. Activating a row runs the same command its keybind would, so the palette is a scriptable command surface, not just UI. tab.dump— read a tab's terminal viewport as text (roostctl tab dump), the determinism backbone for content assertions.roostctl wait— block until a tab reaches a state / shows text / is gone; a no-sleepsynchronization primitive for scripts and tests.- Command launcher (
Cmd/Alt+Shift+T), configured viacommand =lines (label/run/title/hold/env), and Jump to Unread (Cmd/Alt+Shift+U) — now on both UIs. ROOST_CONFIGenvironment variable to read config from an alternate file.
Fixes¶
- Boot race: a tab opened via IPC during launch now reliably materializes (resync-on-subscribe) instead of never appearing.
- The notification jump + focus-tab now update the core active tab, so
identify/tab.focusand the restored selection track what's on screen (both UIs). - Mac↔GTK parity: one command-palette command set (
close_project,jump_to_unread); Mactab.focusswitches the visible tab.
Tooling & docs¶
tools/roosttest/— pytest E2E driving a real UI over IPC, headless in CI on both platforms; plus thetools/screenshot/(visual) andtools/input/linux/(real-input) harnesses, mapped intools/README.md.- Reference docs for the IPC ops, CLI, keybindings, and config brought current;
architecture + principles in
docs/development/vision.md.
v0.0.1 — 2026-05-23¶
First packaged release of Roost — a cross-platform (macOS + Linux) desktop terminal multiplexer built around libghostty-vt, with multi-project workspaces and notification routing for AI coding agents (Claude Code, Codex).
Features¶
- Sidebar of projects, tabs per project, one terminal per tab.
- In-process workspace + PTY supervisor + JSON IPC server per UI process — no
daemon. External tooling (
roostctl, Claude hooks) talks newline-delimited JSON over a Unix-domain socket. - OSC-driven tab titles, notification banners, and sidebar rollup for agent activity.
Packaging¶
- macOS:
Roost.app(Swift + AppKit) shipped asRoost-0.0.1.dmg, with theroostctlCLI embedded underContents/Resources/bin/. - Linux: GTK4 UI (
roost) +roostctlshipped asroost_0.0.1_amd64.debandroost_0.0.1_arm64.deb, auto-published toapt.stridelabs.ai(sudo apt install roost). - The macOS DMG is ad-hoc-signed for now (Developer ID signing + notarization
land in a follow-up once an Apple Developer account is available). Until then,
open it via right-click → Open, or
xattr -dr com.apple.quarantine /Applications/Roost.app.