Workspace
-
Session status, unread marks and whether a session is still running are now decided by the backend, and every client sees the same answer. Each client used to work these out from whatever it happened to observe, so "unread" really meant "not read in this window": reading a session in the browser left the desktop copy unread, and the dynamic status filter kept pushing that row back into the shared list, where even "Refresh status" could not remove it. Status events were also registered only once a client had spawned a session itself, so a browser that had just connected showed dots for the sessions it had opened and nothing for the rest. The backend now keeps one authoritative record per session — the agent, its state, whether its process is alive, and the unread flag — answers a bulk query when a client connects or reconnects, and broadcasts every change to everyone. Clients report what they observe; the backend draws the conclusions. Reading a session on a phone clears the mark on the desktop, a browser that has just connected shows the right dots for sessions it has never opened, and the arbitration rules that used to live in the frontend moved across with their reasons and their tests: a hook that has reported once locks out anything inferred from raw output, continuous output only implies busy for a session that has an agent, is not Codex and has no authoritative report yet, and a 1200ms hold keeps a task that has just started from being cancelled by a finish event arriving right behind it. Screen reading is the exception, because it needs the rendered grid and that only exists in a client: the client that owns the terminal size reports what it read, and the backend decides whether to accept it — a report from any other client is refused. If any of this goes wrong, setting
vlx-arbitrationtofrontendin localStorage hands arbitration back to the old frontend chain. -
Restarting a session no longer closes its tab on the other client.
pty://killedcarried no payload, so the other side could not tell a restart from a close: it treated everything as a close, removed the pane, and then mirrored that layout back. The event now says which client killed the process and why, so a restart keeps the pane and waits for the new process to arrive. A missing or unrecognised reason still counts as a close — leaving a pane open for a session that is never coming back puts a dead terminal on screen, which is worse than closing one that was about to restart. -
A browser connecting to a desktop that has just restored its workspace no longer starts every session for real. Restoring a workspace renders placeholder cards instead of launching processes, but that decision only existed on the desktop; the browser followed the mirrored layout, could not tell "not running" from "running, just never opened here", and mounted the terminals — and mounting one is launching it. A leaf that arrives with someone else's layout now renders a placeholder when the backend says there is no process behind it. Opening a session yourself is still an intent to start it, and a terminal you are currently looking at is never replaced by a card when its process exits, because you may still want to read what it printed.
-
A settings change reaches the other clients immediately. The backend has always held the authoritative settings, but it changed them without telling anyone, so the other client found out at its next launch. Over a remote connection that is more than a display mismatch: turning off "grow status filter dynamically" on one client did nothing while another one kept adding rows to the shared list, and the client with the smaller live-tab limit evicted background tabs out from under everyone else. Writing a setting now broadcasts which key changed, and each client re-reads it through the same path it uses at startup, so the rules that hide protected values from remote callers still apply — the broadcast carries key names only, never values. Phones, which previously neither sent nor received settings, now take part as well.
-
A browser waits for the mirrored layout before restoring its own. A remote window has two sources of layout — the one in its own localStorage and the one the host pushes under mirror mode — and whichever arrived first was overwritten by the other. Putting the local one up first cost more than a flicker: mounting a terminal leaf launches a real process, and for a session whose process was already gone that meant starting a shell nobody would ever look at, which then stayed, because a browser detaches its terminals instead of killing them. The browser now waits for the first alignment to settle before restoring anything, for at most two seconds; if the backend is slow or unreachable it falls back to the local layout rather than sitting on an empty window. Phones, and any client with mirroring off, are let through immediately.
Remote access
-
An SSH connection can turn mirror mode on for the service it starts. The switch lives in the Remote access panel, but SSH starts a headless service on the far machine and there is no panel there to click. Hold Option while clicking "Connect remote" and the SSH form now offers a "Mirror UI across clients" checkbox, off by default. The value travels with the connection and is held in the service's memory rather than written to the far machine's database: when you also reuse the remote desktop's own database, one SSH connection should not quietly flip a switch on somebody's panel. The choice is remembered per host, so picking the same machine out of the history brings it back. Reusing a service that is already running now requires the version, the data mode and the mirror mode to all match — if any of the three differs the old service is replaced, which ends the sessions running on it, so this option sits behind Option alongside the database switch.
-
A client that is being mirrored says so. Tabs and splits on a following client used to rearrange themselves with nothing on screen to explain where the change came from. The title bar now carries a Mirrored badge, with an explanation on hover. The host does not show one — it has the switch.
-
Files can be moved between your machine and the one running the terminal. Remote access showed you the far machine's files but gave you no way to take one home or put one there — the only route was a shell command in a terminal. The Files panel now offers Download on a file's context menu and Upload in its header, and dragging files from your desktop onto a folder row sends them there. Both directions ride the same authenticated connection everything else uses, so this works from a browser on the local network, from a phone, and from a remote-connection window alike. Transfers move in chunks with a progress queue below the tree, and they keep running while you look at another panel. Download is an ordinary download link, so your browser's own download manager handles it: it streams to disk, shows speed and time remaining, and can be paused and resumed, at any file size and in every browser including a phone. The link carries a ticket minted for that one file and valid for a few minutes, because this server keeps credentials in a header and a browser fetching a link sends none. An upload writes to a temporary name and is renamed into place only once it is complete, so an interrupted transfer never leaves a half-written file where a whole one should be; a name that is already taken is refused before anything moves rather than after. Uploads show their speed and time remaining, and they survive the connection dropping: a failed chunk backs off and retries for about a minute, asking the server how far its temporary file actually got rather than resending a chunk that may already have landed. Giving up keeps those bytes — drop the same file on the same folder again and it continues from where it stopped, even after a reload, because only cancelling throws the partial file away.
AI agents
-
Antigravity and Copilot sessions are named after their first message. Both were missing from automatic renaming, which left rows of "Antigravity 1, 2, 3" in the sidebar. Antigravity's hook events carry no user text at all, only a conversation id and a transcript path, so the first message is read out of the transcript instead; the metadata block that follows it is left out of the title. Copilot's events carry no event name either, and are told apart by shape, so a body with a prompt and no tool name is now taken as a submission — which leaves the prompt a session is started with, and tool calls, correctly out of it.
-
An Antigravity session reopens with its history. Resuming needs the conversation id, and the parser that pulls a session id out of the launch arguments did not recognise Antigravity's spelling of it, so
--conversation=<id>never had an anchor to point at and every reopened session came up empty. -
vspawn --yesspawns without the confirmation card. Anyone who keeps "confirm before spawning" on had to click through a card for every child session in a run. The flag — also spelled-yor--no-confirm— skips the card for that one call and starts the session with the default settings. It does not change the setting itself, so the next spawn without it asks again. -
The model field on the spawn card takes anything you type. It was a plain dropdown, so only the models in the list could be chosen — and an agent understands far more identifiers than that: dated names like
claude-opus-4-6, vendor-prefixed names, aliases configured locally. It is now a text field with the known models hanging off a dropdown beside it as shortcuts. The list is a suggestion, not a whitelist: whatever you type is what gets passed, an empty field means no--modelat all, and the list filters as you type and folds away when a custom identifier matches nothing.
Interface
-
One dropdown, used everywhere. The dropdowns around the app had been copied from the same code more than a dozen times and then drifted apart: three panel backgrounds, four shadows, three hover colours, trigger heights of 26, 28 and 32 pixels, and ticks on selected rows that only a multi-select list needs. A single component now backs the branch pickers on merge, the language, default shell and font pickers in settings, the agent picker, the worktree dialogs, the agent type on new and restored sessions, and the last native select in the form modal. It also brings keyboard control, which none of them had: arrow keys to move, Enter to choose, Escape to close without closing the dialog behind it, Home and End to jump. The two menus in the status bar behave as they did, and the status filter in the sidebar keeps its ticks, because it really is multi-select.
-
The password field in the Remote access panel can be revealed. It was a bare password input, so you could not see what you had typed; the eye button existed, but only inside the connect panel's own file. Both now share one component, and the revealed state resets when the panel closes.
-
The IP picker no longer looks like a system control. It was a native select, which WKWebView dresses in system chrome that sits badly on a dark panel — the same complaint as the dropdowns replaced above. It now uses the shared component, and its label is shortened to "IP", since the text beside it already says what it is for.
Fixed
-
Checking for updates asks the server every time. A client left running was pinned to the first version it ever saw: having found 0.1.101 it went on offering 0.1.101 after 0.1.104 shipped, and "Check for updates" only reopened the same dialog, because the old code returned early whenever a notice was already pending. Every check is now a real request. A newer version replaces the notice on screen, the same version or a download in progress leaves it alone, and a server that reports no update at all takes down a notice that has gone stale — the version was pulled, or you installed it yourself in the meantime. The "Download manually" button now opens the download page on the website; it used to hand over the updater's own package, which unpacks in place and cannot be installed by hand.
-
A full-screen error overlay no longer appears when terminals are torn down quickly. xterm's viewport schedules a scroll-area sync when it is constructed and another when it resets, and cancels neither on dispose, so a terminal opened and closed within the same task — which is exactly what rebuilding the session tree during a remote connection does — still ran those callbacks, found a renderer that had already been cleared, and threw. The throw comes from a timer, where neither try/catch nor an error boundary can reach it, so it is caught globally and matched narrowly: only a stack or message naming that sync, together with a mention of the renderer or its dimensions, is swallowed as harmless and written to the request log. Real crashes still raise the overlay.