A shared terminal has one process, one pseudoterminal, and one size. The people watching it do not. Yesterday's shell.online releases, v0.11.2 and v0.11.3, changed how that one size gets picked when a phone joins a session. The mechanism is small, it is all in the open source, and it shows what a relay can decide without ever reading the terminal.
One PTY grid, many viewers
Every session started with the shell.online CLI wraps the command in a PTY that the CLI owns. Viewers in a browser get the output of that PTY, but they cannot each have their own size, because the program inside (a shell, a build, Claude Code or Codex drawing a full-screen TUI) only sees one window size. If two viewers with different windows both got to resize it, each would keep deforming the other's screen.
So the CLI refuses to treat viewer resize requests as resize requests. In cmd/shell/session_unix.go the protocol.Resize case is a comment and nothing else: a shared PTY keeps one canonical grid, and browser or local viewport changes are presentation-only. The relay agrees. In the Cloudflare Worker, a resize frame from an older browser client is validated (an encrypted one must be exactly 34 bytes, an unencrypted one must fall within 10 to 500 columns and 4 to 300 rows, or the socket closes with code 4002) and then dropped as a no-op.
Instead there are three grids the PTY is ever allowed to run at, declared in shared/terminal-grid.ts and mirrored as constants in the Go CLI:
| Grid | When |
|---|---|
| 120x36 | Default, every viewer is on a desktop or tablet |
| 80x40 | At least one viewer is a phone or in portrait, and the CLI is new enough |
| 80x24 | Same, but the CLI predates v0.11.2 |
Before this release the portrait grid was 80x24, a wide shape on a tall screen. The new 80x40 grid is what the changelog means by "fill portrait terminals": the validation run recorded in the pull request, at 390x844 against a real CLI and a local Worker, used 640 of 721 available terminal pixels with no horizontal overflow.
Who decides, and from what
The choice is made by the relay, specifically the Durable Object that holds a session's WebSockets, in a function called broadcastTerminalGrid. It runs whenever a host or viewer connects, a viewer disconnects, or a viewer reports a layout change, and it reads exactly two kinds of metadata.
The viewers. Each viewer socket carries a device class, derived from the Sec-CH-UA-Mobile client hint or a user-agent regex in worker/analytics.ts, plus a portrait flag. The browser sets that flag with a layout=portrait query parameter on the WebSocket URL when it connects, and afterwards by sending a small viewer_layout JSON message whenever orientation flips. If any live viewer is mobile or portrait, the session needs the narrow grid.
The host. The Go relay client adds one header when it dials: X-Shell-Terminal-Grid: 80x40. That is the entire capability negotiation. A CLI built before this release does not send the header, so the Worker records it as not supporting the taller grid and falls back to 80x24 when a phone joins. The pull request notes this is why the Worker could ship before any new CLI binary existed.
The result goes out as a terminal_size control message to every socket whose recorded grid differs. The CLI calls ptmx.Resize only if the requested size is one of the three canonical grids. The browser client makes the same check in adoptGrid and ignores anything else, since rendering a shape the CLI will never open a PTY at helps nobody.
One detail in web/main.ts is easy to miss. Opening the on-screen keyboard on a phone compresses the visual viewport until it is wider than it is tall, which would read as a rotation to landscape and resize the PTY out from under the person typing. The client keeps reporting whatever layout it last reported while the keyboard is open, using the keyboard detector that already existed for the mobile terminal.
Why the relay is allowed to know this
shell.online encrypts terminal frames end to end by default. The CLI seals output before it reaches the relay and the browser opens it locally, so the relay never sees what is on screen. What it does see is listed in the README: connection metadata, encrypted frame sizes, timing, labels, and session lifecycle events.
The grid decision fits inside that list. A user-agent string, a query parameter, a request header, and a plaintext layout message are all things the relay already had. Nothing about the negotiation loosened what the relay can read, and the sealed frames carrying the terminal bytes pass through it untouched. The relay picks the PTY size from who is connected, not from what they are looking at.
Zoom stays local
The other half of the release is pinch zoom, from 50% to 150%, and it deliberately never touches the PTY. TerminalPinchZoomGesture in web/touch-scroll.ts tracks the two original touch identifiers, returns nothing if a third finger appears, needs a 3 pixel change in finger distance before reporting, and scales from whatever zoom was active when the second finger landed. The chosen zoom is written to localStorage when the fingers lift. Two people can watch the same 80x40 session at different zooms and neither affects the other. That is the same rule the desktop client adopted in v0.11.2 when it started sizing the font from the pane width instead of shrinking it until the whole grid fit in one direction; the changelog cites a 120x36 session on a 1440x900 window going from 11.5px filling 77% of the pane to 14.75px filling 98%.
Rows get separate treatment in portrait. terminalTypography in web/mobile-viewport.ts raises line height from 1.18 to 1.8 when the viewport is taller than 1.15 times its width, spreading 40 rows down the screen instead of bunching them at the top.
From the agent's side
None of this is visible to the process being shared. A coding agent running under shell claude gets a window-size change when a phone joins and another when it leaves, the same as if someone had dragged a terminal window, and it redraws. We build shell.online at Pilot Protocol as the place to hand a running agent's terminal to a person, and that person is increasingly holding a phone. The Worker and CLI halves of the change sit side by side in pull request #100.
Top comments (0)