Live Terminal
MangoStudio can open a real interactive shell on the machine an environment describes and
show it in the browser: as a Terminal panel in the chat’s rail, or as its own browser
window from /terminal. Several sessions can run at once. The shell runs where the
environment’s runtime runs — Local, stdio, WSL, SSH, container, or a paired machine — never
on the hub by special case.
Where things run
| Concern | Owner |
|---|---|
| The PTY, the shell process, its environment and its lifetime | Runtime (crates/mangostudio-runtime) |
| Who may open a session, the registry, limits, idle reaping | Hub (apps/api) |
| Relaying bytes to the browser and flow control per socket | Hub (apps/api) |
| Session shape, socket framing, limits | Shared (apps/shared) |
| Rendering, keystrokes, resize, acknowledgements | Frontend (xterm.js) |
The runtime opens the PTY itself (crates/mangostudio-runtime/src/terminal/pty.rs): on Unix the
shell is a session leader with the PTY as its controlling terminal, so it owns its job control; on
Windows it runs under ConPTY. Either way it lives in the same guardian or kill-on-close Job as any
supervised process. There is no native addon to ship. The runtime keeps
each session’s last 256 KiB of output so a viewer that comes back sees where it was.
Protocol
Eight runtime methods carry the session — terminal.open, attach, detach, write,
resize, ack, close, list — and one event topic, terminal.output, streams bytes up
to the hub keyed by the hub-minted session id. Every method needs the shell capability; a
readonly machine refuses all of them, listing included. The runtime announces
terminal: true on its manifest and health report only when the owner granted shell, a
shell is present, and the build can open a PTY. Absent means unavailable.
The runtime never emits terminal.output before a hub has called terminal.attach on that
session. That invariant is what makes the topic additive: a hub too old to decode the
payload never receives it.
The browser talks to /api/terminal/:id, a WebSocket route of its own. /api/ws stays
invalidation-only. Frames are binary with a one-byte type prefix: client data, resize,
ack, ping; server data, exit, notice, pong. The codec lives in
@mangostudio/shared/terminal.
Flow control
The socket options every hub WebSocket route shares close a connection at 64 KiB of backpressure rather than throttling it. So flow control is explicit and lives in three places:
- Browser. After xterm.js parses a chunk, the client acknowledges the bytes. Acks are coalesced, not sent per frame.
- Hub. Acks are relayed to the runtime as
terminal.ack. The relay keeps its own queue per socket, sends only while the socket’s buffered amount stays under 48 KiB, resumes on drain, and past 1 MiB queued discards the oldest bytes and sends onequeue_overflownotice. - Runtime. Emission is credit-gated: at most 256 KiB may be in flight unacknowledged.
Output beyond the window waits in a 1 MiB pending buffer; past that the oldest bytes are
discarded and one
droppedmarker goes out when the stream resumes. The terminal draws it as a dim line.
A yes loop therefore costs bounded memory on every hop, and the socket is never closed by
the server for being fast.
Sessions
Sessions belong to the runtime and outlive the browser socket. Closing the panel or switching tabs detaches; attaching again replays the scrollback. One viewer holds a session at a time: popping it out into a window takes it from the rail, which then offers to bring it back.
A viewer attaches in three steps, and the order is the contract: terminal.detach to quiesce
whatever the last one left running, then the output subscription, then terminal.attach.
Only that order makes every frame either scrollback or newer than it — the topic has no
buffer, so subscribing after the reply loses whatever shared its read, and subscribing before
the quiescing detach replays bytes the scrollback already carries.
A session ends when the user closes it, when it sits with no viewer for
idle_timeout_minutes, when the runtime connection drops, or when the hub stops. The hub’s
registry is in memory; sessions do not survive a hub restart.
Who may open one
Same capability as shell.run: allow.shell on the runtime. On the Local runtime — a
child of the hub on the hub’s own OS account — a terminal additionally requires the Local
connection to be identity-attested: the single-owner claim the external-agent path already
enforces. A second MangoStudio user on the same
hub closes every Local terminal and refuses new ones with TERMINAL_NOT_ISOLATED.
The shell’s environment is the runtime’s own with secret-shaped variables stripped exactly as
shell.run strips them, plus TERM=xterm-256color, COLORTERM=truecolor and
MANGOSTUDIO_TERMINAL=1. The audit log records the shell, working directory and size for
terminal.open; keystrokes are never written.
Configuration
[terminal]
enabled = true
idle_timeout_minutes = 30
max_sessions_per_user = 8
scrollback_kib = 256
Environment: MANGO_TERMINAL_ENABLED, MANGO_TERMINAL_IDLE_TIMEOUT_MINUTES,
MANGO_TERMINAL_MAX_SESSIONS_PER_USER, MANGO_TERMINAL_SCROLLBACK_KIB. With enabled = false the panel is hidden and opens answer TERMINAL_DISABLED.
Limits and errors
| Refusal | Code |
|---|---|
| Terminals switched off on the hub | TERMINAL_DISABLED |
| Per-user cap reached | TERMINAL_LIMIT |
| Local runtime on a multi-user hub | TERMINAL_NOT_ISOLATED |
| Runtime offers no PTY, or the owner refused | UNSUPPORTED |
GET /api/terminals/availability?environmentId=… answers all of these before anyone types,
so the panel can explain rather than fail on open. Container environments need a shell in
the image; the panel says so.
Windows
Sessions on a Windows runtime use ConPTY (crates/mangostudio-runtime/src/subprocess/windows_job.rs)
with PowerShell (pwsh preferred, powershell.exe fallback), started inside the same
kill-on-close Job as a bounded child. Resize works. The runtime’s real-shell terminal tests are
Unix-only (#[cfg(unix)]), so the ConPTY branch has no test of its own; the POSIX path is
covered by real-PTY tests.