Host Sessions¶
A host session is a workspace that keeps running after Roost's window closes. Instead of a PTY supervisor living inside the Roost app itself, it lives inside a small headless daemon — roost-session — and Roost attaches to it the way you'd attach to a tmux server, except the chrome is the real Roost sidebar and tabs, not a text UI. Close the window, and the shells inside it — including whatever long-running agent you had going — keep going. Reopen Roost, and they're still there.
This guide is task-shaped: enabling a host on your own machine, reaching a second machine over SSH, the verb set, and what to do when something looks wrong. For the wire contract, see reference/ipc.md; for the architecture, see Host sessions (development).
Both platforms run a local session now. Host sessions are built into the iced UI — the Linux roost package and the Roost-Iced Mac app both ship roost-session and can run a localhost session of their own; see Enabling a host on this machine below. Either can also be the far end of an SSH host — a Linux box (amd64 or arm64) or an Apple-silicon Mac — and Roost installs and upgrades roost-session there the same way; see macOS note below for what differs on a Mac. The Swift Roost.app doesn't have this feature at all; host sessions are iced-only.
Enabling a host on this machine (localhost)¶
If roost-session isn't running yet, the fastest way to get one is to just try connecting — the palette will offer to start it for you:
- Open the command palette (
Cmd-Shift-P/Alt-Shift-P). - On a fresh install with no saved hosts, you'll see Connect Host: localhost. Activate it.
That saves localhost as a host and connects it in one step, starting roost-session if nothing is listening yet. You can also do this by hand:
then use the palette's Add Host… (or roostctl host add, below) to save it.
Once connected, the sidebar gains a LOCALHOST section (or whatever label you gave it) alongside your local LOCAL projects, and any project you create there survives quitting Roost. With zero hosts saved, the sidebar is exactly what it always was — this is purely additive.
roost-session's own socket, state, and logs live at: on Linux, $XDG_RUNTIME_DIR/roost-session/roost.sock (state under $XDG_DATA_HOME/roost-session/, logs under $XDG_STATE_HOME/roost-session/); on macOS, ~/Library/Caches/RoostSession/roost.sock (state under ~/Library/Application Support/RoostSession/, logs under ~/Library/Logs/RoostSession/ — RoostSessionDev in place of RoostSession for a debug build, so a dev daemon can never collide with a real one). See Paths and Environment for the full table.
Persistence is scoped to host sections, not to Roost as a whole — worth stating plainly. A project or tab created under any host section, including LOCALHOST, survives quitting Roost, because its shells live in roost-session, not in the app. An ordinary LOCAL tab is still hosted in-process and still ends when Roost quits — unless you've switched your local tabs onto a session too, which is what the next section covers. A fresh Roost-Iced install starts that way (see First Run); an install with no local-backend key that already has a state.json or config.conf on disk keeps in-process local tabs until you switch.
Switching the local backend¶
Adding a host, above, is opt-in and additive: it puts a second section in the sidebar, below your ordinary local tabs. This section is about something different — moving your local tabs themselves onto a session, so the ones you always start with (Cmd-N, Cmd-T, the sidebar's own project list) persist too.
local-backend (see reference/config.md) is the config key that decides which one you get on launch: in-process (the default for any machine that already has Roost on it) or session (what a genuinely fresh install starts on, no editing required). Either way, you don't have to relaunch or hand-edit a config file to change it — two palette rows do it in place, live, while Roost is open.
The two verbs¶
Open the command palette; the switch row is the last one in the frame, since it's the heaviest thing there — only one of the two ever shows, whichever names the backend you're not currently on, and neither shows while a switch is already running:
| Palette row | Shown when | What it does |
|---|---|---|
| Use a session for local tabs | local-backend = in-process |
Moves your local projects and tabs onto a localhost session — starting one if none is saved yet — and ends the in-process shells. |
| Use in-process local tabs | local-backend = session |
Flips back. Your local band goes back to being in-process. |
Both raise a confirmation dialog before doing anything; Cancel leaves everything exactly as it was.
Forward: Use a session for local tabs¶
Confirming replays your current local layout onto the localhost session (starting or connecting it first if needed) — every project, in order, with its tabs, cwds, and any title you set by hand — then ends the in-process shells and writes local-backend = session. What was selected stays selected: the same project and tab you were looking at are what's selected and attached once the switch finishes. If the session already had projects of its own on it (say, you'd been using it as an ordinary saved host already), your local layout is added alongside them rather than overwriting anything.
This isn't instant — it's copying a real layout onto a real daemon — so the palette is quiesced while it runs: both switch rows disappear, and local mutations (new project, new tab, closing one, dragging to reorder) are refused with a busy message until it settles. If something goes wrong partway — the session can't be reached, a project fails to replay — the switch backs out cleanly: your in-process layout is untouched, nothing is left half-moved, and local-backend stays in-process. A crash mid-switch is recovered the same way the next time Roost launches, picking up wherever it left off rather than leaving things in a mixed state.
Reverse: Use in-process local tabs — nothing is copied back¶
This is deliberately not the mirror image of forward. Reverse does not replay the session's layout back into the in-process workspace — nothing is copied, nothing is duplicated. What it does:
- Writes
local-backend = in-processand flips the local band back. - The session keeps running, exactly as it was — every project and tab you had on it is untouched, and none of its shells are stopped, disconnected, or removed. It simply demotes from "the local band" back to being an ordinary LOCALHOST section, one click away in the sidebar. If the session isn't running when you switch, the palette row and the confirm card say so rather than claiming it keeps running; and if it never once started, the switch forgets the
localhosthost saved for it, since nothing was ever on it — a dead row would only sit in the sidebar. - Your in-process band comes back from whatever it last held before you switched away — empty (seeded with a fresh
Untitled 1, the same as any first run) if this is the first time you've gone in-process since switching, or whatever you'd left there if you'd used both backends before.
So a forward-then-reverse round trip never duplicates anything: your session-side work stays exactly where the forward switch put it, one click away under LOCALHOST, and your in-process band starts fresh. If you want something back in the local band, that's a deliberate re-create — dragging tabs back and forth was never something this was built to automate.
Closing a host's last project¶
Close the last project on any host — including the slot your local tabs are switched onto — and Roost forgets that host from the sidebar (never stops the session itself, whose shells and projects, if any remain elsewhere, keep running). It's added to your recents, so getting it back is one row rather than retyping the whole Add Host form — recents show up as Add Host: <label> rows right beside Add Host…, and as rows at the bottom of the New Project on… picker if you'd rather create straight onto one. localhost itself never becomes a recent, since it's always offered as its own row regardless (see the verb set above).
There's one exception, and it's the point where this connects to the exit rule: emptying the slot — the localhost host your local tabs are currently switched onto — doesn't leave you looking at an empty local band with nothing in the sidebar. It closes the window instead, the same as closing the very last local project would under in-process. A local band that's still trying to reconnect, or one you haven't saved a localhost host for yet, is a different case — that keeps the window open, showing the error, rather than exiting out from under you.
Adding a remote host (over SSH)¶
Roost reaches a remote host directly over ssh — there's nothing to forward by hand first. A saved host's target can be any of:
- a bare hostname or
user@host(workbox,charlie@workbox) — anythingssh(1)itself would accept as a destination, resolved through your own~/.ssh/config(host aliases,IdentityFile,ProxyJump, all of it apply); ssh://user@host:port— the only spelling that carries an explicit port (ssh://workbox:2222). A barehost:portwith nossh://scheme is rejected — it reads ambiguously against a Unix socket path — and a literal IPv6 address needs brackets:ssh://[::1]:22;- a Unix socket path — anything containing
/(/tmp/roost.sock,~/.roost-hosts/workbox.sock,./roost.sock). This is the pre-SSH-transport form and still works unchanged — see Scripted / fallback forwarding below for when you'd still reach for it; localhost— this machine's own session, unchanged.
Target strings are trimmed before they're classified, and a target starting with - is refused outright (it would otherwise reach ssh as a flag rather than a destination). One behavior change from earlier builds: a bare relative filename with no / in it — roost.sock sitting in the current directory — used to be read as a same-directory socket path. It's now read as an SSH hostname instead, since there's no way to tell the two apart without a separator; spell a same-directory socket path ./roost.sock.
On the remote machine (workbox below), start (or confirm) a session once:
This manual step is optional, not required — skip it and just Add & Connect below, and if nothing is there yet Roost detects that and offers to install and start roost-session for you in-app (see Troubleshooting's not-found and no-session rows). Doing it by hand here is still the fastest path if you're already sshed in for another reason.
Then add the host — no forward to stand up first. Either the palette's Add Host… dialog (Name: workbox, Target: workbox, then Add & Connect — which dials the target before saving, so a typo or an unreachable host is caught immediately), or the CLI:
roostctl host add --label workbox --target workbox --verify
roostctl host connect --id <the id host add printed>
--verify is optional on host add (registry-only is the default — a typo'd target still saves, and the sidebar's dot reports the problem at the next connect attempt) but matches what the dialog does. Over an SSH target it's a mux-less one-shot probe — its own throwaway ssh exec, outside any multiplexed connection, nothing left running afterward — so it's safe to use from a script or a dialog before you've committed to the host.
A remote host never auto-connects at launch — Roost doesn't even reach for it, so it simply sits disconnected until you Connect. That's deliberate: connecting to a remote machine is an outbound decision that costs a handshake and can fail loudly, and when Roost has only just opened, nobody has asked for it. Once you've connected it, though, a remote host behaves like localhost again for a mid-session drop: close your laptop, let Wi-Fi hiccup, whatever kills the link — Roost notices and retries on its own, with a growing delay (up to 30 seconds) shown right in the sidebar band as reconnecting in Ns (k/10). If ten attempts don't get it back it settles with reconnect gave up after 10 tries, and ↻ Reconnect — which was on screen the whole time — is exactly the button you'd have clicked anyway. Not every failure gets retried this way: a changed or unknown host key, a rejected login, and roost-session simply not being installed over there each settle immediately instead, because each of those needs a person to do something different — see Troubleshooting below for what a failed SSH connect looks like and how to recover.
A rebooted remote machine gets the same ladder a flaky link does, not an immediate settle: it comes back with no roost-session running yet, so every reconnect attempt in that gap classifies as "no session" (see the no-session row below) — and Roost retries that exactly like a dropped link, on the chance the daemon is merely still coming up, settling only once ten attempts are spent. Roost ships no supervisor for roost-session itself, so a machine that takes longer than that to finish booting — or one where nothing ever relaunches it — still needs a manual ↻. If you'd rather a host survive its own reboots without reconnecting by hand at all, run roost-session under a unit you write yourself. See Surviving reboots and logouts below for a unit that works and the reasoning behind it.
Scripted / fallback forwarding with ssh -N -L¶
Roost's own SSH transport execs a fresh ssh -T … 'exec roost-session client-bridge' per connection over a private multiplexed ssh master — see Host sessions (development) → Transport: SSH hosts for the shape. It is not a socket forward, but the older forwarding recipe below still works, because a saved host can always be a plain socket path: OpenSSH supports Unix-socket-to-Unix-socket forwarding, so this needs zero Roost-side SSH code and stays useful for a script, a systemd --user unit, or a host you'd rather reach through an already-open tunnel than hand Roost an interactive login.
On the remote machine, start (or confirm) a session and find its socket:
ssh workbox
roostctl session start
echo "$XDG_RUNTIME_DIR/roost-session/roost.sock" # e.g. /run/user/1000/roost-session/roost.sock
exit
Back on your Mac or local Linux box, open a persistent forward — this needs to stay running for the connection to work, so run it in its own terminal (or under something like a systemd user unit or screen):
mkdir -p ~/.roost-hosts
# Use the socket path the command above printed — the `1000` in
# `/run/user/1000` is that machine's UID, not necessarily yours.
ssh -N -L ~/.roost-hosts/workbox.sock:/run/user/1000/roost-session/roost.sock workbox
Then add the forwarded socket as a host — a socket-path target, exactly like the native form above but pointed at the local end of the tunnel:
roostctl host add --label workbox --target ~/.roost-hosts/workbox.sock --verify
roostctl host connect --id <the id host add printed>
The verb set¶
Every host action lives in the command palette (Cmd-Shift-P / Alt-Shift-P), and the same actions are in each host's right-click menu, which also shows the session version (see below). One row per (verb, host) pair, and a verb only appears where it applies (you can't Stop a session you're not attached to):
| Palette row | What it does |
|---|---|
| Add Host… | Opens the Name + Target dialog described above. Always offered, on every platform. |
Connect Host: <label> |
Dials the host, starting it first if it's localhost and nothing answers. A second connect to an already-connected host is just another connection, alongside any this window or another already has open (see below). |
Disconnect Host: <label> |
Drops the connection. The session's shells keep running. |
Install Update: <label> / Restart Session: <label> / Update roost-session: <label> |
Offered only where they apply; see Seeing and changing a host's session version. The plain maintenance Restart is menu-only; the palette shows a Restart row only when a newer build is staged or the host is at reduced fidelity. |
Stop Session: <label> |
Ends every shell on that host and flushes its layout. Offered only while connected — you can't stop what you're not attached to. Confirms first. |
Remove Host: <label> |
Forgets the saved host — connected or not; on a connected one it disconnects first, then forgets it, and never stops the session. Not offered mid-dial (that would race the attempt it is removing), and not offered on the slot — the localhost host your local tabs are switched onto under local-backend = session — since that host isn't something you can forget. A host you Remove is kept as a recent so re-adding it is one row, not a retyped form. |
| New Project on… | Opens a picker of LOCAL (when you're in-process), localhost (always — even before you've saved it, in which case picking it saves+connects+creates in one go), every other connected host, and your recents. Appears once that picker holds more than one destination, which in practice means as soon as Roost has started once. |
roostctl has a matching verb for every one of these except Stop (which is a plain session.stop on the host's own connection, not a client-side registry op):
roostctl host add --label pop-os --target ~/.roost-hosts/pop-os.sock
roostctl host list
roostctl host connect --id <id>
roostctl host disconnect --id <id>
roostctl host remove --id <id>
See reference/cli.md for the full flag reference.
Checking a host from the command line¶
host connect returns as soon as the attempt is under way, not once it settles, so the follow-up question — did it work? — has its own verb:
roostctl host status # every saved host
roostctl host status --id <id> # just one
roostctl host status --json # the same, for a script
It prints the state the sidebar's band is showing you: the host's state, the band's own line (disconnected — reconnecting in 8s (3/10), or an agent count while connected), and — indented underneath, when there is one — the full reason if the band's 60-character line had to cut it short (a changed-host-key sentence, say), followed by the detail the band has no room for, which is where a failed local session start spells out what it actually tried. roostctl host list carries an abbreviated state= column for the same reason. If you're scripting against it, watch the --json generation field: it counts connection attempts started, so it moves even when two attempts fail identically.
If the band just says it's retrying and you want to know why, the human form shows it too: an indented armed because: <reason> line under the row carries the classified failure that armed the current rung (connecting to workbox failed: …), rendered the same way the give-up line renders one — --json's retry.reason is that same value. The two can name different failures, though: this field is about the drop that armed this rung, while a give-up names the drop that exhausted the ladder — and if that last one was a bare disconnect with nothing to classify, the give-up carries no cause at all while the rung before it did. Read it whenever it's there rather than waiting for a particular attempt number; it's absent on the first rung of an outage often enough (a live connection dropping has nothing to classify yet) that its absence means "not known yet", not "no reason".
This is a question about the client: which hosts this Roost is connected to. roostctl session status is the other half — run on the machine hosting a session, it says whether a daemon is listening there at all.
Keybinds and creation routing¶
Creation follows context, so you never have to think about "which host am I on" for the common case:
| Shortcut | What it creates |
|---|---|
Cmd-N / Alt-N, or the sidebar's + New Project button |
A new project on the currently selected project's host (LOCAL if you're not looking at a host). |
Cmd-Shift-N / Alt-Shift-N |
Opens New Project on… — pick LOCAL or any connected host explicitly. |
Cmd-T / Alt-T, or the tab bar's + |
A new tab on the project's own host — a tab can never land on a different host than its project. |
The sidebar's Agents panel (toggled by Cmd-Shift-A / Alt-Shift-A) and the agents palette (Cmd-Shift-O / Alt-Shift-O) both span every host — a running Claude session on pop-os shows up next to a local one: under a POP-OS › project head in the panel (pop-os › project on the row itself when it is sorted by priority or recency), suffixed project · pop-os in the palette. A disconnected host's agents stay in the panel, dimmed, until it reconnects. See Keybindings for the complete table.
Disconnect vs. Stop¶
These are deliberately different actions:
- Disconnect (closing the Roost window, quitting, or the palette's Disconnect verb) leaves the session's shells running. The sidebar keeps listing that host's projects and tabs, dimmed, with an inline ↻ Reconnect row underneath — those shells are still alive on the host, so the sidebar says so rather than pretending they're gone.
- Stop Session actually ends them — every PTY is hung up, the layout is flushed, and reconnecting after a Stop starts every tab over as a fresh shell in its saved directory (same "layout, not live state" contract normal Roost restarts use).
If all you want is to close your laptop for the night, disconnect (or just quit) — don't stop.
Reordering a host's projects and tabs¶
Inside a connected host's section you drag a project row or a tab pill exactly as you do in LOCAL — same gesture, same drop targets, no separate verb.
The difference is where the new order lives. It is saved on the session, not on this Roost — so it survives a disconnect and reconnect, and it is what another client attaching to that host sees. That's also why the row settles a beat after you let go: the session is the one that decides, and the sidebar takes its new order from the session rather than from your drop.
A disconnected section's rows stay listed and dimmed, and they can't be dragged — there is nothing to route the change to. Reconnect first (the section's inline ↻ Reconnect, or the palette's Connect), then drag.
Pasting images and files into a host tab¶
Copy a screenshot and press Ctrl+V in a host tab, or drag a file onto one, and the agent running on the far machine attaches it — the same gesture that has always worked in a local tab.
It has to work differently underneath, and knowing how explains everything else on this page. Every agent reads the clipboard itself when you press Ctrl+V, which cannot reach across an SSH connection. What Roost does instead is send the bytes to the host and paste the host path the session answers with. So a clipboard image is written on workbox, never on your laptop, and what appears in the composer is a workbox path the agent can open. A dropped file works the same way; the basename is preserved (design.pdf stays design.pdf, which is what the agent shows you in its chip) under a random directory, so two drops of the same name never collide.
Local tabs are unchanged — nothing crosses a boundary and the local path is pasted, exactly as before.
The same thing is a command, for scripts and for when a drop is awkward:
See cli.md — note --tab is required there, and that it blocks until the paste lands.
The three limits¶
| Limit | What it is | What you see |
|---|---|---|
| 10 MiB | per file | The file is skipped and named, and the rest of the drop still goes. |
| 256 MiB | per drop | The whole drop is refused before a byte moves: That drop is 540 MiB, over the 256 MiB per-drop limit. |
| 512 MiB | per host, total | Uploads start failing, and the reason names the store: the host's file store … cannot take … more; restart the session to clear it. |
The last one is the one worth understanding. The host's store never evicts: a path Roost handed back may sit in an agent's composer unsubmitted for an hour, and deleting the file out from under it would be worse than refusing the next upload. So a full store stays full until the session goes away — Stop Session and connect again, or roostctl session stop and start on the host. Everything already uploaded keeps working meanwhile.
What is skipped, and what it says¶
A drop can hold anything. Directories, files that are missing or unreadable, things that are not regular files (a FIFO, a device node), and files over the 10 MiB cap are all skipped and named — the rest of the drop still goes. The status line reports both halves in one sentence:
Sending shot.png (4.2 MiB) to workbox…
Sent 3 files to workbox (skipped: build/ is a directory, core.dump is over 10 MiB)
Could not send shot.png to workbox: the host disconnected
A drop where nothing was sendable says so (Nothing to send to workbox (skipped: …)) and pastes nothing. And a drop is all-or-nothing for the paste: if one upload of several fails, nothing is pasted at all, rather than silently leaving out a file you dropped.
Uploads ride their own connection, so a large one never stalls typing in another tab on the same host, and the host stays connected while it runs. If the host disconnects, reconnects, or is taken over mid-upload, the gesture is failed and named — a reconnect refuses even when the session survived it, because from this side a link blip and a restarted session look identical.
What is cleaned up, and when¶
The host's file store is swept entirely at start and again on a clean stop. That is the whole rule, and its consequences are worth stating:
- Stop Session (or
roostctl session stop) removes every uploaded file on that host as part of stopping. - A disconnect removes nothing. The shells keep running and so do the files — reconnect and the paths in your agents' composers still work.
- A session that was killed rather than stopped —
kill -9, a crash, a machine that lost power — leaves its files behind. They are removed by that session's next start, not by anything in between. (A plainSIGTERM—systemctl --user stop, a logout — is routed into the same clean stop and does sweep; only if that self-stop can't complete does it fall through to the next start.)
Which agents attach what¶
Most of these attach a single, unquoted, absolute path with the extension intact — which is exactly what Roost pastes, and why it pastes it bare rather than quoted or escaped. All five were driven live against a real session over SSH for this release:
| Agent | A pasted host path |
|---|---|
| Claude Code | Attaches images and PDFs. |
| Codex | Attaches images — if image_paste_enabled is on in that user's Codex config. Off, Codex leaves the path as plain text (locally too; this is not a host-tab thing). One path per paste. |
| OpenCode | Attaches images and PDFs. One path per paste. |
| grok / gx | Attaches images, and deliberately rejects tiny ones — a 1×1 test PNG stays text. |
| cursor-agent | Does not attach. The path stays plain text in its composer, and cursor-agent reads it as part of your prompt — it will often try to ls or cat the file rather than look at it. Verified live, and it behaves the same way for an ordinary local paste, so this is cursor-agent's own composer rather than anything about host paths. |
cursor-agent aside, three things follow. A multi-file drop attaches in none of them — several paths on one line is not a shape any of these parse — but the paths are still real paths on the host, so an agent can be asked to read them. And any regular file uploads, not just images: what you always get is a working host path, and whether the agent turns it into an attachment chip depends on that agent's own supported types.
One-time cost: every host needs its session updated¶
This feature moved the session protocol version, which means a Roost with it cannot talk to a roost-session that predates it at all — not just for file paste. Every saved host needs its session updated once. Roost catches this at Connect and shows the upgrade / restart flow rather than a broken screen:
- A
localhosthost offers Restart Session…, onto theroost-sessionshipped with the app. - A host reached over SSH offers Update roost-session… — install the matching
roost-sessionand restart it, with the usual consent card first. Since this session cannot connect at all, its menu header readsneeded to connectand the band shows aneeds updatepill. - A host reached over a plain
ssh -Lforward (a Unix-socket target) gets no in-app offer — Roost cannot reach in over a bare forwarded socket. SSH into that machine yourself, updateroost-session, thenroostctl session stopandroostctl session startthere, and Connect again from here.
Several windows on one session¶
Two windows can type into the same tab at the same time. A second Roost window, a script, a phone over an SSH forward — any client that can open the session's socket as the user running it may attach to a tab and put bytes in it. Same-UID access to that socket is the whole boundary, and always was: another person logged into the same box cannot open it. There is no ownership to claim first, and nothing is ever closed when somebody else joins — connecting from a second place is just another connection, symmetric with the one you already have.
So when another client connects to a host you have open, nothing freezes and nothing changes about your window. Your terminal keeps redrawing, your keystrokes keep landing, your tabs keep switching, your sidebar keeps listing, and there is no banner, no strip, and no button naming who else is connected.
What you get from two Roosts, or a Roost and a phone, on one session:
- A bell or a copy reaches every connected client, and each applies it by its own rule — a bell marks whichever of its tabs owns one; an OSC 52 clipboard write lands only on the tab that client is actually viewing, so a phone attached to a different tab never has its clipboard overwritten by a desktop's paste, and a client looking at the same tab as you gets the same copy you do.
-
A notification reaches every window, and the window you are looking at answers for you. The session no longer decides who gets told — it tells everyone, every time. The window that is showing that tab, with the focus, shows no banner and quietly acknowledges the notification instead, which takes the dot down on every client. Every other window banners as usual. This is what stops a phone left open on a tab from silencing your laptop, which is what used to happen when a tab was muted for everybody while any one client was looking at it.
Three consequences worth knowing. Another window may show the dot for the moment it takes the acknowledgement to travel — the window you are looking at never does. Bringing a window back to the front does not clear anything: focus alone has never acknowledged a notification, on a host or locally; clicking the tab does. And a banner your desktop has already put in front of you stays until you dismiss it, even if another client clears the tab — the dot and the inbox row go, the banner is yours. - The PTY is sized by whoever typed or resized last. Every client that interacts sets the tab's size; connecting from somewhere else does not by itself resize anything. A client that only watches (never typing) never shrinks the one that is working. - The session-wide theme is last-writer-wins, from whichever client sent it most recently. Two clients with different themes flip it on every reconnect; that is by design, not a bug. Agent hooks are not last-writer-wins: a connecting client can only ever raise the host's
agent-hookskey — union its own allow-list into whatever the host already has, never removing — so two clients with different lists both win, additively, rather than fighting over the same key (see Agent Hooks for the file side of it). File uploads are not a setting and do not compete either: every upload lands in a directory of its own, named at random, so two clients sending the same filename at the same moment get two files rather than one overwriting the other.
Nothing stops anyone from typing. There was never a "steal" step and there still isn't — every same-UID client could always attach and type, and reading a session never required anything either. What changed with this release is that there is no longer a single client whose window the effects and notifications route through by default: they route to everyone, and each client decides for itself what to do with what it gets.
The upgrade / restart flow¶
roost-session and the Roost client both pin the same libghostty build. The most common way that pairing comes apart — on literally every package upgrade, until the host itself is restarted — is a build mismatch: you upgrade Roost, but a roost-session you started before the upgrade is still running the old build.
Since plan 053 that mismatch no longer stops you. A session that can serve the vt payload — a plain VT byte stream your Roost replays into its own terminal, rather than the exact-build binary snapshot — is attachable across a build skew, so Roost connects normally: no dot, no dialog, tabs list and render, you type into them. It is a reduced-fidelity connection, and the section below says exactly what that costs. Every roost-session from plan 053 on serves vt.
So the amber "needs restart" dot now means one of two narrower things, both of which really are terminal:
- a protocol mismatch — the two ends disagree about the wire itself, which no payload kind can bridge; or
- a session too old to serve
vt(anything predating plan 053) that also disagrees on the build — the binary snapshot was all it ever had, and the two builds cannot exchange one.
When the dot is showing, connecting again raises a dialog rather than a corrupted screen, and which one depends on how the host is reached:
- On a localhost host you can restart: "Restart the session on ‹label›? This session was started by an older/newer Roost (…detail…). Restarting reopens every tab as a fresh shell in its directory — running programs end." Buttons: Not now / Restart Session. The restart runs the newest usable
roost-sessionRoost can find, never an older one (see never downgrade). Restarting issession.stop→ wait for the socket to actually go → spawn a freshroost-session→ reconnect, composed on the client side. It's the same layout-survives contract as any other restart — every tab reopens in its saved directory, but whatever was running inside it is gone. - On a remote host reached over SSH, Roost offers to fix it for you. "The session on ‹label› needs a restart… Roost can install the matching roost-session on ‹label› over ssh and restart the session there — it will show you what it would do before anything is changed." Button: Update roost-session on ‹label›. This runs the same install/upgrade flow as the not-found row below — a consent card naming exactly what will be installed and from where, shown before anything is touched — then installs the matching build, stops the stale session, waits for it to actually go, starts the new one, and reconnects. If the session on the far end happens to be newer than this Roost, the card says "Update this Roost to connect" and offers no button — installing an older build isn't the fix there, upgrading Roost is.
- On a remote host reached over the fallback
ssh -Lforward (a plain Unix-socket target), this client still can't reach in and fix it for you — the bootstrap flow only knows how to reach a host over Roost's own SSH transport. The dialog says so instead of offering a dead button: "The session on ‹label› needs a restart… Only the machine running it can restart it — stop and start the session there (roostctl session stop, thenroostctl session start)." SSH into that machine and run those two commands yourself, then Connect again from here.
Roost never installs or restarts anything without asking first, on every one of these paths — the consent card is always the first remote activity, and Cancel leaves the host exactly as it was. roostctl has no install, upgrade, or remote-restart verb of its own (the UI socket's host.update / host.restart ops require confirm: true), and an IPC-originated connect (a script, a hook) never raises a dialog — a machine is never prompted, only a person looking at the window is.
Seeing and changing a host's session version¶
Every host's right-click menu — on its band, on a project row, on a tab — shows which roost-session that host is running, in a header at the top of the host's rows. Nothing else on screen carries a version (no band text, no tooltip). On the band the rows have short labels (Connect, Disconnect, Install Update…, Restart Session…, Update roost-session…, Stop Session…) because the band already names the host. On a project or tab menu the host's rows open with a header that names the host as well, for example mini3 · Session 0.0.21 · 0.0.22 available, followed by the same short labels. The command palette keeps its "…: mini3" titles.
| Header | Meaning |
|---|---|
Session 0.0.22 · up to date |
The session matches this Roost. |
Session 0.0.21 · 0.0.22 available |
A build matching this Roost can be had. Over SSH that means Install Update…; on localhost it means the app bundle (or the deb) carries a newer roost-session than the one running, and Restart Session… will use it. |
0.0.22 installed · restart to use it |
An SSH host already has the newer build on disk; the running session keeps going until you restart it. |
Session 0.0.23 · newer than this Roost |
The session is newer than this Roost. Nothing is offered that would downgrade it. |
Session 0.0.22 dev a1b2c3d |
A dev build whose version equals this Roost's, so Roost cannot say which is newer. If this Roost is a dev build too, · this Roost dev f00ba12 follows. |
Session 0.0.19 · 0.0.22 needed to connect |
The session cannot connect at all (a protocol mismatch, or a pre-plan-053 session across a build skew) and is not connected. SSH offers Update roost-session…; localhost offers Restart Session…. |
Session 0.0.23 · update this Roost to connect |
The session is newer than this Roost and cannot connect. No action is offered: update Roost. |
Session 0.0.21 |
A host reached over a plain ssh -L forward. Roost can read the version and nothing else. |
Session version unknown · … |
The session reported no version; the rest of the line still gives its state. |
When Restart is not possible, the header says why and the row is absent: its roost-session is gone, can't read its roost-session, its roost-session is older, or its roost-session can't talk to this Roost. A disconnected host keeps its ordinary rows with no header.
Two states also mark the host band with a pill on its right-hand side, where the reduced fidelity pill goes, because they are the ones you must act on: needs update (the session cannot connect until it is updated) and update Roost (the session is newer than this Roost and cannot connect). While a restart runs the band's reason line reads restarting…. At reduced fidelity the reduced fidelity pill opens the action that fixes it, and does nothing when no action can (a session newer than this Roost, or builds Roost cannot order); the row under it then says why.
Install Update, Restart Session, and Update roost-session¶
These are separate actions; Install never restarts and Restart never installs.
- Install Update… (SSH hosts only) writes the matching
roost-sessionto the host without stopping anything. The running session keeps going, your stream stays attached and your programs stay running; the header turns into0.0.22 installed · restart to use it. The card ends with "Restart it when you're ready to use the new build." It is refused — the row is absent, and the card or op says why — when this Roost is older than the running session, when the host already has a newer build than this Roost (restart instead), or when this Roost's session protocol differs from the running session's (that is the "needed to connect" case below, because the install-only window would leave tabs' agent hooks running a new binary against an old daemon). - Restart Session… works on every transport that has a usable build. It stops the session and starts the newest usable build; every tab reopens as a fresh shell in its directory and running programs end. The card names the build it will run (bundled, installed, current or override) and lists the tabs with a foreground program or agent it will end, or says
nothing running. The button is Restart Session, drawn as a danger action like Stop. - Update roost-session… (SSH, session that cannot connect) is the combined install-and-restart. Over SSH that is what the "needed to connect" case offers. It is subject to the same two install refusals (client older than the session, host build newer than this Roost), but not the protocol one, since the old daemon is stopped at once.
Nothing here is automatic: every one of these asks first, and Cancel mutates nothing.
Never downgrade, and where each transport gets its binary¶
A restart runs the newest build Roost can find, never one older than the running session, and never one this Roost cannot connect to (a different session protocol). A build skew alone is fine, since every session from plan 053 on serves vt. Versions are compared as MAJOR.MINOR.PATCH; a build Roost cannot order against the running one (a prerelease, or equal versions with a dev build involved) is not treated as a downgrade, because those are driven by hand.
Where the candidate comes from depends on how the host is reached:
localhost(including the local-backend slot): theroost-sessionshipped next to the app — the bundle'sContents/MacOS/roost-session, or/usr/bin/roost-sessionfor the deb — or the running session's own binary if that is newer. IfROOST_SESSION_BINis set and non-empty it is the only candidate, and an unrunnable one is an error rather than a fall-through. A deb upgrade replaces the packaged binary in place, so the next Restart Session… picks it up; an app update does the same through the new bundle.- SSH: the first rung of the exec ladder on that host (
~/.local/bin/roost-sessionfirst, thenPATHand the system locations) and nothing else. Tabs' agent hooks run that same rung, so starting the daemon from another one would split the hooks from their daemon. An unreadable first rung means no restart; Roost never falls through to a later rung. - A plain forwarded socket: a version header and no actions, as before.
The ssh-to-self trap on Linux. Adding the machine you are sitting at as an SSH host reaches the same session as localhost (the socket is per user), but through a different binary source. An Install Update over ssh-to-self writes ~/.local/bin, which the localhost row never considers, so restarting from the localhost row re-runs the bundled build. Restart from the SSH row to use what you installed, or upgrade the deb and restart from localhost. Either path is safe; they just disagree about which binary is "the new one".
Safety. The facts behind a card are only a hint. On confirm, and again right before anything is stopped or installed, Roost re-identifies the running session and the target binary; if either changed it aborts with "the session changed; check again" and refreshes the offer. Only one action runs per host at a time, and two rows that reach the same session (the ssh-to-self case) cannot both act. After a restart Roost checks that the new session has a different id and is the build it meant to run.
Dev builds and older sessions¶
A build is a dev build if the release workflow did not produce it: any cargo build or dev-session.sh build, a prerelease tag such as v0.0.23-rc1, debug or release profile alike. Menus show it as dev plus the short git sha (dev a1b2c3d), or dev alone when no sha was available. Two dev builds with the same sha compare as the same build even if one has uncommitted edits. Sessions started before this feature report neither the dev flag nor a sha, so a pre-plan-076 dev session reads as a release build until it is restarted.
No background SSH probes¶
Roost never probes an SSH host in the background: a probe can raise an ssh-agent approval (1Password, Secretive, a FIDO key) with nobody there to answer it. An SSH host's "installed" state is therefore learned only from this Roost's own installs, or when you start an action. If another machine installed a newer build, the header keeps saying "0.0.22 available" until you press Install Update…; its probe finds the build already there, the card says so, and the host moves to installed · restart to use it. localhost candidates are identified in the background, since that spawns a local process and never uses ssh.
Scripting¶
host.update and host.restart on the UI socket do what the menu rows do. Both take {"id": …, "confirm": true} (without it they answer invalid-param), answer {"accepted": true} at once, and never raise a card; the outcome appears as update.action (running, then done or failed) in host.status, which also reports the whole update state (up-to-date, available, staged, required, session-newer, unordered, unknown). A second action on the same host answers busy; a socket host answers not-supported. See the IPC reference for the wire details. There are no roostctl verbs for either.
When a build skew connects instead: the vt fallback¶
A skew against a plan-053-or-later session takes the other path, and it no longer connects silently. The dot stays green — this isn't a dot-level state — but the host band grows a reduced fidelity indicator the moment the connection is up, before you've attached to a single tab:
‹label› ● reduced fidelity
On a host reached over SSH or on localhost, that indicator is itself a button, and there's also a row under the band and a command-palette verb that open the same fix — see "Getting back to full fidelity" below. On a host reached over the fallback ssh -L forward (a Unix-socket target), the indicator and the row are plain text: Roost still can't reach in over a bare forwarded socket, so there's no button to press, only the manual steps below.
The first time a tab actually attaches at reduced fidelity, a one-time status-bar message says so as well. And you can always ask directly:
roostctl host status --id <id> --json
# "connect": {"reduced_fidelity": true, ...} -- known as soon as the connection is up
# "payload_kind": "vt" -- known only once a tab has attached
connect.reduced_fidelity is what the band indicator and the update/restart offers key on, so it's there before any tab has attached. payload_kind reports what's actually being decoded, so it appears only once a tab on that host has attached — and both fields go away with the connection, so neither can claim a fallback you have since reconnected out of.
What the fallback does not carry. These are real gaps in a screen that otherwise looks right, which is why they are worth knowing about rather than discovering:
- Link hover and click stop working on a tab attached this way. Roost turns hyperlinks on in every shell it spawns, so a tab full of
OSC 8links fromgh,cargoor a test runner still looks linked and no longer behaves that way. - The other screen is empty. If you attach while a full-screen program (vim,
htop, a TUI agent) is running, the shell underneath comes back blank when that program exits — the alternate screen is what was captured, and only one screen travels. - Wrapped lines replay as separate lines, so selecting or resizing re-flows them differently than the same tab does on the host.
- Some scrolled-off rows lose their background fill — a coloured status bar that scrolled into history comes back as blank space. On the visible screen it is repainted correctly.
- Kitty images do not survive (nor do they under the binary snapshot), and neither does a saved cursor position or a pushed kitty-keyboard stack.
The IPC reference has the complete list, including the mode-by-mode reasoning. Everything else — the tab list, titles, agent status, notifications, typing, resizing, scrollback — behaves exactly as it does on a matched build.
Getting back to full fidelity. Restart the session onto a roost-session that matches your Roost. On a host reached over SSH or localhost, the fastest way there is the indicator itself: click the reduced fidelity text on the band, the row underneath it, or run the matching command-palette verb. All of them land on the same card, and which one depends on where the stale build is (see Seeing and changing a host's session version):
- On localhost, that is Restart Session…. The card names the build it will run — normally the
roost-sessionshipped alongside the app — and confirming disconnects the host, stops the stale session, waits for the socket to actually go, spawns that exact build, and reconnects. Same layout-survives contract as any other restart: every tab reopens in its saved directory, but whatever was running inside it is gone. (By hand:roostctl session stop, then Connect again from Roost — a localhost connect spawns the build shipped alongside the app.) - On a remote SSH host, a stale
~/.local/bincopy opens Install Update… (the matching build is installed, the session keeps running, and you restart when ready); a build that is already installed opens Restart Session…. Both show the consent card first, name both build strings, and touch nothing until you confirm. - On a host reached over the fallback
ssh -Lforward (a Unix-socket target), there is still no in-app offer — Roost can't reach in over a bare forwarded socket, so the indicator and the row are informational only. Do it by hand: SSH to the host, update theroost-sessionbinary there,roostctl session stop && roostctl session start, then Connect again from Roost. If you would rather have Roost install the matching binary for you,roostctl session stopover SSH first and then Connect — with nothing listening, the connect falls into the ordinary bootstrap ladder, which does offer to install and start one, with its usual consent card.
This is a deliberate trade: connecting at reduced fidelity beats refusing to connect, and until you act on the indicator, that's the price you're paying.
macOS note¶
localhost now works on macOS exactly the same way it does on Linux: roost-session ships inside Roost-Iced.app itself (Contents/MacOS/roost-session), so the seeded Connect Host: localhost row, spawn-if-missing, and launch-time auto-reconnect described above all apply unchanged to the Mac build. What stays Mac-specific:
- A Mac can be an SSH host. The install/upgrade flow — the not-found row below, Install Update…, and the remote "needs restart" dialog — targets an Apple-silicon Mac exactly as it does a Linux box: the binary lands in
~/.local/bin/roost-sessionon the Mac, and nothing is written anywhere else. The bytes come from the same ladder: a Mac client sends its own bundledroost-session(the same build it runs), and a Linux client downloads the release'sroost-session-<version>-darwin-arm64, checksum-verified. Releases before the one that added this publish no macOS download, so a Linux client can't install an older build onto a Mac. An Intel Mac has no build and is refused before anything is offered. - A Mac that also runs Roost-Iced has two copies. Its own app starts its
localhostsession from the bundle (Contents/MacOS/roost-session); an SSH client installs to and execs~/.local/bin/roost-session. They are independent, the same as a deb's/usr/bincopy and an SSH install on Linux (see the ssh-to-self trap above). A restart from an SSH client starts the~/.local/bincopy, and the Mac's own app keeps attaching to it as long as the two builds stay compatible. Updating the app on that Mac does not update~/.local/bin. - Add Host reaches a Linux box or a Mac directly —
workbox/user@host/ssh://…— with no manualssh -Lforward to stand up first (see Adding a remote host above — the older forwarding recipe still works too, if you'd rather use it). Everything past Add Host — Connect, the sidebar section, the verb set — behaves identically whether the target islocalhostor a remote machine. - The Swift
Roost.appstill has none of this feature at all — permanently (see the top of this guide).
Surviving reboots and logouts¶
Roost ships no supervisor artifact for roost-session — nothing installs
a unit or a LaunchAgent for you, and nothing needs to: a session comes up
on demand from whichever client connects first, whether that's the
localhost launch ladder, the SSH bootstrap ladder, or roostctl session
start run by hand. If you want a host to survive its own reboots
anyway — so it's already up by the time your next reconnect attempt
lands — write the unit yourself. Here's one that works.
Linux — ~/.config/systemd/user/roost-session.service (honoring
XDG_CONFIG_HOME if you've moved it):
[Unit]
Description=Roost host session
[Service]
Type=simple
WorkingDirectory=%h
ExecStart="/usr/bin/roost-session" start --foreground
Restart=on-failure
KillMode=mixed
[Install]
WantedBy=default.target
enable-linger is what makes the unit start at boot instead of waiting
for a login.
macOS — ~/Library/LaunchAgents/ai.stridelabs.roost-session.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.stridelabs.roost-session</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/Roost-Iced.app/Contents/MacOS/roost-session</string>
<string>start</string>
<string>--foreground</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>WorkingDirectory</key>
<string>/Users/charlie</string>
</dict>
</plist>
Substitute your own home directory in WorkingDirectory — launchd
expands neither ~ nor $HOME in a plist, so the literal path is the
only spelling that works.
KeepAlive is deliberately {SuccessfulExit: false}, not bare true.
A clean Stop Session (or roostctl session stop) exits 0 — under
bare KeepAlive: true, launchd would read that as a crash and
immediately resurrect the daemon you just told to stop, and it would
just as happily race the upgrade flow's own stop-then-restart.
SuccessfulExit: false reads that same exit 0 as "stopped on
purpose" and only respawns on a nonzero exit — an actual crash — which
auto-reconnect's connect-if-present then absorbs on its own.
Restart=on-failure is systemd's counterpart. The half that matters is
the same — a deliberate stop exits 0, and neither supervisor undoes it
— but it is not literally the same rule: systemd also restarts on an
uncaught signal, a start/stop timeout, or a watchdog timeout, none of
which a clean Stop Session produces. Two
more lines worth reading literally: KillMode=mixed sends the stop's
SIGTERM to the daemon process alone, so its own orderly shutdown
(SIGHUP the shells, reap, SIGKILL fallback) runs the way session stop
always does, instead of every shell in the unit's cgroup getting
SIGTERM at once and racing it; WorkingDirectory (%h on Linux, the
literal home path in the plist) is what seeds the daemon's first
project when it starts from an empty state.json — without it a
unit-launched daemon would seed /.
Stop Session and roostctl session stop still work exactly as they
always do under a unit like this: the daemon ends and, because of the
rule above, stays ended — your unit starts a fresh session again only at
your next login (macOS) or the unit's next trigger (Linux).
Say what this actually buys you, honestly: a macOS gui/$UID
LaunchAgent starts at your next login, not at boot, and it stops at
logout — a materially smaller scope than Linux's enable-linger
(which keeps a unit running with nobody logged in at all; see Adding a
remote host above for pairing the unit
with it). A reboot brings your saved sidebar layout back either way, the
next time Roost opens, but the running shell processes inside a
session never survive a reboot, on any platform — what a unit like this
buys you is not having to remember to start roost-session by hand
after you log back in.
Troubleshooting¶
"Needs restart" / an amber dot that won't turn green. A build or protocol mismatch — see The upgrade / restart flow above. This is expected the first time you Connect after upgrading Roost on a machine whose roost-session predates the upgrade.
The band says "cannot find roost-session", "roost-session failed to start" or "roost-session exited early (status N)". The daemon couldn't be started — a different thing from a session that died, and Roost treats it differently: it says so once and stops, rather than burying the message under a retry every quarter-second against a socket nothing is going to create. The band has room for the headline only; roostctl host status prints the full detail beside it — for a missing binary, the launch ladder's three rungs verbatim ($ROOST_SESSION_BIN, next to this program, $PATH), for a failed start, the exec error or the daemon's own verdict, and for a roost-session that exited before it was ready — (status N), or (signal N) for a crash — the last 4 KiB it wrote to stderr. roostctl session start reproduces the same ladder in your shell if you'd rather see it there. The fix is one of three: point ROOST_SESSION_BIN at the binary, put it on PATH, or reinstall so it sits next to the Roost app again (a packaged install always does — a half-copied build directory is the usual cause). Then press ↻ Reconnect; nothing is retrying behind your back.
The window says "Roost couldn't start its local session." That is the case above, for the session your local tabs run on (local-backend = session, a fresh install's default) when there is nothing else to show: rather than an empty terminal area, the window names the band's reason and the log that has the rest (~/.local/state/roost/roost.log). Try again runs the launch once more, as ↻ Reconnect does. Use in-process tabs raises the same confirm card as the palette's Use in-process local tabs row; confirming it runs your local tabs inside Roost, and a localhost that never started is forgotten on the way. Roost never makes that switch on its own. To go back once the session starts again, use Use a session for local tabs (see Switching the local backend).
A host stays "disconnected" and won't come back (a stale socket). The socket file can outlive the process that created it — the daemon crashed, was killed, or the machine rebooted without cleaning up. On localhost, Roost retries a session that dropped — one that was running and went away — with a capped backoff (you'll see the amber dot briefly, then grey); a session that cannot start is the case above and settles instead of retrying. If a dropped one never recovers, run roostctl session status (or, over SSH, run it on the remote machine) to check whether anything is actually listening, and roostctl session start if not. A host reached over Roost's own SSH transport does the same once it's actually connected at least once — a mid-session drop retries on its own (reconnecting in Ns (k/10), capped at 30 seconds between tries) and settles with a "gave up after 10 tries" message if the far side genuinely isn't coming back; ↻ Reconnect is still right there either way. On a remote host reached over the fallback ssh -L forward instead — a plain socket path, not Roost's SSH transport — none of that applies: first check the tunnel itself is still up, since a dropped SSH connection there looks identical to a dead session from Roost's side.
Connecting over a native SSH target fails outright (a classified reason). Every SSH connect attempt is bounded, and a failure is classified into one of six families — its exact copy lands in the sidebar band as disconnected — <reason>, and in the log; an attempt you asked for (a palette Connect, roostctl host connect) also raises a toast, a background retry does not. That's what happens whenever an attempt settles, which on a first connect is every one of these families, transport included. Where it differs is a host that had already reached connected and then lost the link: only there do transport and no-session re-dial on the retry ladder, and the band then shows the countdown (reconnecting in 8s (3/10)) instead of the copy — so while a rung is armed, read the family from roostctl host status --json, in retry.reason. The other four never retry, whether the host had connected before or not. The families, and what they mean:
| Family | Copy (target interpolated) | What to do |
|---|---|---|
| Changed host key | "the host key for <target> has CHANGED since it was last seen — this can mean the host was reinstalled, or that something is impersonating it. Do not accept the new key from here; verify its fingerprint with <target> out-of-band…" |
Never dismiss this by accepting the new key from inside Roost — there's no prompt to accept it with, on purpose. Verify the new fingerprint some other way (a call, a channel other than this machine), then update it yourself with ssh-keygen -R <target> and ssh <target> once, out-of-band, before connecting again. |
| Unknown host key | "<target>'s host key has not been seen before. Run ssh <target> once in a terminal to review and accept it, then try again." |
Do exactly that — Roost's SSH transport runs with BatchMode=yes, so it can never prompt to accept a key itself. |
| Auth refused | "<target> refused authentication. Check that your key is loaded in an agent, then try ssh <target> in a terminal to confirm you can log in." |
BatchMode=yes also means no password and no interactive 2FA prompt — key/agent auth only, this slice. Confirm ssh <target> logs in on its own first. |
| No session on the far end | "<target> is reachable but has no roost session running. Run roostctl session start on that machine, then try again." |
On a first connect, exactly what it says — SSH itself is fine, there's just nothing to attach to yet: run roostctl session start over there, or, from the palette or the Add Host dialog (an attended connect), accept Roost's own Start roost-session on ‹label›? offer (no writes beyond starting the process you already have installed). On a mid-session drop, since #387 this is the one exception to "settles instead of retrying" above — it re-dials on the retry ladder exactly like a transport failure, on the chance the daemon is merely restarting, and only needs the manual fix above if all ten attempts run out first. roostctl host connect / host add --verify still just report a first-connect no-session and stop — a script is never shown the in-app offer. |
roost-session not found |
"roost-session isn't installed on <target> (or isn't on the non-interactive PATH ssh uses there) — connect from the Roost app to install it." |
The #1 gotcha in practice — and Roost now offers to fix it for you. From the palette or the Add Host dialog, a NotFound result opens an Install roost-session on ‹label›? consent card naming exactly what will be installed and from where (this Roost's own binary, a checksum-verified download, or an override you've set) before anything is written; confirming installs it to ~/.local/bin, starts it, and reconnects. Cancel touches nothing. Roost execs the remote command as sh -c '... exec roost-session client-bridge' — a non-interactive, non-login shell, so anything a login-only rc file (.bash_profile, .zprofile) or an interactive-only guard adds to PATH is invisible to it, which is exactly why the install lands in ~/.local/bin: it's the first place Roost looks, so it sidesteps the whole PATH question on the very next connect. roostctl has no install verb of its own and never prompts — this offer only ever appears in the app, to a person looking at it. |
| Transport (anything else) | "connecting to <target> failed: <last stderr line>" (or with no line, just "connecting to <target> failed") |
The catch-all — a DNS failure, a refused TCP connection, a timeout. The interpolated line is ssh's own last line of stderr; read it literally. |
Two things that look like a hang but aren't: a biometric-gated SSH agent (Touch ID/agent unlock) can eat a few seconds of the connect budget on the first use in a while — the warm-up connection gets 30 seconds (scaled up under ROOST_TEST_TIMEOUT_SCALE) before it's classified as a transport timeout, which is generous for a single unlock prompt but not infinite; and BatchMode=yes means there's genuinely no password/2FA prompt to answer, ever, over this transport — if a host requires one, log in with a plain ssh <target> once to confirm your key/agent setup covers it non-interactively, or fall back to the ssh -L forward recipe if it can't.
"The session on ‹label› ended." This is the honest reading of a session that told Roost it was shutting down cleanly (an explicit session.stop, from any client — including a palette Stop from a different window). The banner's button is Start a new session, not a reconnect — the previous session's shells are genuinely gone, and this starts a fresh one. If you didn't stop it yourself, check whether another client or script did (roostctl session stop from anywhere reaches the same daemon).
Which tab you're looking at is never sent to the host. The client decides for itself which tab it shows and whether its own window has focus — nothing about that selection travels to the session. Instead, a fired notification reaches every connected client, every time (see Several windows on one session); each client separately compares the fired tab against what it is showing itself, and the one that matches quietly acknowledges it rather than bannering it, exactly as the same-tab suppression rule (Notifications → Focus policy) does locally. So there is no "focus reporting" a session can be missing, and no version-dependent gap here beyond ordinary protocol compatibility. The one thing the UI does remember on its own, for a session-backed project, is which tab you last viewed in it — kept in the UI's own state.json, keyed by the session's id, so a relaunch returns you to it; that memory is local too, and is never told to the session either.
A host can get your agent hooks raised, on every connect. Right after connecting, a client whose own agent-hooks names at least one agent sends that list to the host, which unions it into whatever its own agent-hooks key already says and wires its Claude Code / Codex / grok / cursor-agent / OpenCode config files to match — the same mechanism Agent Hooks describes for a local machine, just aimed at the host's $HOME instead of yours, and additive rather than a replacement: nothing the host already allows is ever removed by a connect. This reaches a host whose own key is off or still unanswered — a host has no screen to ask on, so the connecting client is the only authority there is — but a client whose own key is off or unanswered sends nothing and so cannot raise (or lower) a host either. A claude (or any other agent) already running in a host tab when you first connect picks the hooks up on its next launch, not retroactively. Two clients raising different lists both win, additively; roostctl agent status run on the host names which client raised which agent and when. Lowering a host's key only ever happens on that machine — roostctl agent ensure/uninstall, run there. A session too old to serve this op is refused by the compatibility gate before any of it runs, so there is no partially-wired state to reason about. See Agent Hooks → Remote hosts for the full behavior.
Related¶
reference/ipc.md— the wire contract this feature drives, including thehost.*ops and the attach data plane.reference/cli.md—roostctl sessionandroostctl hostin full.- Agent Hooks — how Claude Code / Codex / grok / cursor-agent / OpenCode get wired, locally and on a host.
- Host sessions (development) — the architecture: how
HostConnis put together, the attach sequence, and how multiple clients share one session. - Keybindings — the full shortcut table, including the host-context notes on
Cmd-N/Cmd-T.