The live terminal
Opening a terminal on this host or on another seat, the wire beneath it, and what closing the tab does.
This page describes the terminal endpoint of the previous console, served by the projection
server (saphan server start, or the saphan-server daemon). It is an endpoint, not a screen:
the previous console draws no terminal, so you drive it with your own websocket client.
⚠ The current console's terminal is different. Its dock opens the saphan CLI on the host that
serves the console, with that server's user and workspace — not a shell, and never on another
machine. Its endpoint opens a terminal on its own host only; a ?machine= naming another machine is
refused there.
Before you open one
The terminal is behind the console session — the same one the queue at /console is behind. So,
in order:
-
A credential exists for this workspace. In a shell on the host:
If it says the console is not provisioned, set one. The verb asks for the password on your terminal, twice, with echo off — it is never typed on a command line:
Then restart the server (
saphan server stop, thensaphan server start): the console credential is resolved once per process. Signing in is the full first-login procedure. -
You are logged in. Open
/console/loginin the browser and sign in. The terminal is not a second login; it rides the session you already have. -
You reach the console at the address the browser shows. The terminal refuses a handshake whose
Origin— scheme and host — is not this console (see the refusals below).
Opening a terminal on this host
Optional query parameters:
| parameter | meaning | default |
|---|---|---|
machine | local (or omitted) for this host; otherwise a machine id | local |
cols | initial window width, 1–1000 | 80 |
rows | initial window height, 1–500 | 24 |
The terminal starts in the workspace root, running $SHELL — the SHELL of the process that
started the server (/bin/sh if it has none), not anything the browser sends. It sees only
TERM=xterm-256color and the server's HOME, PATH, LANG and USER.
Opening a terminal on another seat
The machine id is the one saphan machine list prints. It is dialled through
the engine's own ssh configuration at ~/.saphan/ssh/config — never through
your personal ~/.ssh/config — so a machine must have been admitted first:
There is nothing else to configure. The identity, hostname, port and host-key
pin all come from that file, and the connection is made by the same hardened
ssh invocation every other fleet dial uses (BatchMode=yes, so an unreachable
machine fails fast and says so inside the terminal rather than hanging).
A terminal on another machine is planned like any other session there. Unless your
configuration turns recording off, a recorder runs on this host and is reached from the far
terminal through an ssh reverse tunnel; a claude-code CLI started in that terminal is pointed at
it. ⚠ Only claude-code is pointed at the recorder — codex and every other vendor run in that
terminal unrecorded. The first notices the terminal prints say which applies, and where the
recording goes. If the configuration cannot be read, or the session is covered by the guardrail and
the guardrail cannot run, the terminal is refused with guardrail-unavailable and nothing starts.
See Session recording and
Guardrails.
The wire, if you are writing a client
Every websocket message, in both directions, is one tag byte followed by a payload. Terminal output is arbitrary bytes and is not valid UTF-8, so everything travels as a binary frame and the tag — not the frame type — says what a message is.
| direction | tag | payload |
|---|---|---|
| browser → engine | 0x00 | keystrokes, verbatim |
| browser → engine | 0x01 | {"cols":120,"rows":40} — resize |
| engine → browser | 0x00 | terminal output, verbatim |
| engine → browser | 0x02 | {"notice":"…"} — a sentence for the human |
| engine → browser | 0x03 | {"code":0,"success":true} — the program exited, with its exit status, when it is known |
A message with any other tag is dropped, and a frame is at most 64 KiB. Send a 0x01 frame
whenever the browser's terminal element changes size; on a remote terminal the far side resizes
too, without any further act.
What closing the tab does
Closing the browser tab closes the socket, and closing the socket reaps the whole session the terminal started — not just the shell. In order:
- the shell's process group is hung up (
SIGHUP), so a shell that forwards a hangup gets to end its own jobs the way it would on a real tty; - a quarter of a second passes;
- the shell's process group is killed;
- the session is swept: every process still in the terminal's session is found and killed, whatever process group it wandered into;
- the shell is waited for, so no defunct entry is left behind.
Step 4 is the one that matters. sleep 300 & typed into a console terminal does not survive
the tab closing, although an interactive shell puts each background job in a process group of
its own — that is what job control is — so killing the shell's group alone would never reach
them.
One named limit remains, and it is the same one a real terminal has: a
process that calls setsid(2) for itself has left the session and cannot be
reached by anything addressing that session. That is what nohup and setsid
are for, and it behaves here exactly as it does on a tty.
Step 4 waits, and it says so when it gives up. The sweep does not stop at the first pass that finds nothing to kill; it stops when it has also accounted for the terminal's own shell, because "I saw nothing" and "I could not see" are the same empty answer and only one of them is a successful reaping. (On macOS a dying process can briefly be invisible to a session lookup before the process table marks it finished; the wait covers that.)
The wait is on that state, not on a timer, and it is bounded at two seconds. Every other reason the sweep can fail to account for the shell is decided at once, so an ordinary close is not slowed and a genuinely blind host does not stall. If the bound is reached, the close returns
the session could not be enumerated on this host (not even its own leader was visible), so nothing was reaped and the no-orphan guarantee does NOT hold for this terminal
with the state it was still looking at and the bound it gave up at appended.
Read that sentence literally: processes from that terminal may still be
running, and ps is the instrument, not this message's absence.
Which machines a terminal will open on
?machine=<id> is resolved against the signed machine registry before any
ssh command line is composed. The id must be:
- admitted — present in the registry (
saphan machine list); - not retired — retirement is a mark on the row, so an id can be in the registry and still refused;
- ssh-dialable —
transport=ssh, with an ssh host and an ssh key, which is exactly the condition under which~/.saphan/ssh/configcarries aHost <id>stanza for it.
Why this is a refusal and not a convenience. ssh -F <config> does not
restrict ssh to the aliases in that file; it only says where to look for them.
An unmatched target is treated as a hostname and resolved through DNS. So an
id that merely looks like a registry name — example.invalid,
prod.example.com — would have become an outbound connection to whatever the
host's resolver returned, with the fleet's own hardening attached to it. The
registry lookup is what makes the promise above — identity, hostname, port and host-key pin all
come from the engine's own ssh configuration — true rather than merely intended.
?machine=local is unaffected and needs no registry: the near path opens
$SHELL on this host and never composes an ssh dial.
Which seats a terminal will open on
The three checks above are about the host. Two more are about who is
seated on it, and they are asked from the same registry read, before any ssh
command line is composed:
-
no master seat. A machine carrying a seat registered
role=masteris refused. The rule is the role, never a machine name: a second master seat enrolled tomorrow is refused with nobody editing any code. -
no seat whose lane the ledger reads as spent. The run ledger's own
status=lane-exhaustedrows are folded with the same rule, and the same default 5-hour window, that the scheduler's own placement filter uses. A laterstatus=okrun clears it immediately — a seat that recovered is not refused.A cooldown belongs to the SEAT, not to the slot — and that is a decision, not a detail. A
lane-exhaustedrow records that a vendor allowance ran out, and an allowance is attached to a credential (aconfig_dirprofile or alane), which travels with the identity. A slot is furniture:machine seatis latest-wins per identity, so you may legally move one identity from slot 0 to slot 1 of the same machine and seat someone else on the slot it vacated, and neither act refills an allowance or spends one. So the fold reads the ledger'sidentitycolumn, and the consequences are the ones you would want: a seat you re-seat onto another slot takes its cooldown with it, the seat that arrives on the vacated slot does not inherit it, and a laterokrun clears an exhaustion even when the two rows sit on different slots.The slot still matters, for the rows that can say nothing else. A ledger row's
identityis empty on every row written before that column existed and on every run that named no seat, and it is never backfilled — so nothing may claim retroactively whose credential such a row spent. The only thing it is evidence about is the slot it reserved, so an unattributed row refuses whoever is seated on that slot today. That is the fail-closed side, and it is the one place where two seats sharing a slot are refused together.
These two refusals say different things on purpose. Your next act differs:
| refusal | who clears it | how |
|---|---|---|
| master seat | you, by an owner act | re-seat the identity with role=executor — the runbook below, which is a runbook rather than a one-liner for a reason it states |
| spent lane | time | wait. There is nothing to run and nothing to repair; saphan fleet usage asks the vendor how long |
Five limits, named rather than left to be discovered:
- A machine with no seat at all is not refused by these two. They read seat rows; a host nobody is seated on has no master seat and no spent lane. It still had to be admitted, non-retired and ssh-dialable to get this far.
- The unit is the machine, which is wider than the seat.
?machine=names a host, not a seat, so a host carrying four executor seats and one master seat is refused whole. That is the fail-closed direction and the only one a host-addressed parameter can take. - The 5-hour window here is the default, always. The per-workspace
scheduler.exhausted_window_hoursoverride is not wired into this gate (it loads no config), so on a workspace that has set that key this gate's window is the default while the scheduler's is the override. - A seat's cooldown is read PER MACHINE, not across the fleet. The two ledger reads are both narrowed on the machine you named, so if one identity somehow carries rows on two machines, each host answers only for its own. The registry's cross-machine guard makes moving an identity between machines an owner act of its own rather than an ordinary re-seat, and widening the read would let one host's exhaustion take the shell away on another. This is the fail-open direction of the pair, which is why it is named here.
- A cooldown follows the seat's REGISTRY LABEL, so an owner act that
re-labels a seat moves its cooldown with the label. The paragraphs above say
a cooldown belongs to the seat rather than the chair, and the seat is
identified by the
identitystring on the ledger row — which is the registry row's own subject, an operator-assigned name set by an owner act, not a fingerprint of the credential behind it. Two consequences follow, and both need an owner act on a signed registry row, which is why they are a named limit rather than an open door: re-seating the same credential under a new name presents a seat with no cooldown history, and re-using a retired name for a different credential presents a seat that inherits the old one's. This is the fail-open direction in one case and the fail-closed direction in the other, and neither is reachable from the console: the console only reads this key.
Why a spent seat is refused although an ssh login spends no model allowance: the work a
terminal is opened to do on such a seat dies lane-exhausted after burning the wall clock.
?machine=local and every non-spent seat are unaffected.
Runbook — clearing a master role off a seat
This is the owner act the master-seat refusal above sends you to. It is your
hand, not the engine's: machine seat writes a signed identity row, and only
the owner's root key can sign one.
Read this before you type. machine seat is latest-wins on the whole
row, not a patch on one column. Every field you omit is not "left alone" — it
is re-derived from that flag's own default and written back. Omit --backend
and the new row says claude-code; omit the credential flag and the profile
home is regenerated from the naming convention; omit --billing-class and the
seat's economic classification lands as unknown. So on a Codex or lane-bound
seat, a one-liner that names only --role changes the seat's credential shape
and its billing metadata while you were trying to change one word. The steps
below exist to make that impossible, and step 1 is not optional.
1. Read the row you are about to replay. Nothing here is remembered or guessed — the seat's own row is the input to step 2.
Find your machine, then the seat under its identities array, and copy down
four values verbatim: slot_index, backend, billing_class, and — whichever
one is present — config_dir or lane. A lane-bound backend
(agent-loop, openai-compat) carries lane and an empty config_dir; every
other backend carries config_dir and an empty lane. Naming the wrong one of
the two is refused, which is the guard doing its job.
2. Replay the row with every field spelled out, changing only --role.
Two shapes, because the credential flag differs — use the one matching the
backend you read in step 1.
A config-dir seat (claude-code, codex):
A lane-bound seat (agent-loop, openai-compat):
--billing-class is the one field with a hole in it, and it is named rather
than left to surprise you. unknown is not a value you may pass — the flag
takes subscription | metered | local-energy and refuses anything else. If step
1 read billing_class: unknown, drop the whole --billing-class flag: an
omitted class writes an empty field, and an empty field reads back as unknown.
That is the only spelling that preserves an unclassified seat.
--added-by is you, the owner performing this act, not whoever seated the
row originally — the identity store is an append-only record of acts, and this
is a new one. --added-at defaults to now, which is what you want; the original
seating instant stays in the superseded row.
3. Read it back and confirm exactly one thing moved.
The seat's role now reads executor. slot_index, backend,
billing_class, config_dir and lane must read exactly what you copied
in step 1. If any of them moved, the row you just signed is not the row you
meant, and step 2 is where a flag was omitted.
What you will not need. --move-from is for a cross-machine move only:
re-seating an identity on its own (machine, slot) is not a move and is
never refused for the want of it.
⚠ This runbook covers the fields machine seat takes as flags. A value the row carries that
the verb has no flag for — an account set with saphan machine set-account, for example — is not
checked by it. Compare the whole row from step 1 with the row from step 3, not only the five fields
named above.