The live terminal
Opening a terminal on this host or on another seat, the wire beneath it, and what closing the tab does.
Before you open one
The terminal is behind the master-admin console session — the same one
/console/next 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: 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
Originis not this console (see §5).
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 your $SHELL — the SHELL
of the process that started the server, not anything the browser sends.
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).
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 |
A message with any other tag is dropped. 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 and it is the one that was missing. sleep 300 &
typed into a console terminal does not survive the tab closing. It used to:
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 never reached 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 the
kernel unhooks a dying process from getsid(2) a few tens of microseconds
before it marks it a corpse in the process table, so a sweep that asked once
read a shell that was still on its way out as a shell it could not see, and
reported the no-orphan guarantee broken on closes where it held.
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.
a. 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 in §3 (identity, hostname, port and
host-key pin all come from the fleet projection) 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.
b. Which seats a terminal will open on
The three checks in §6a 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 through the same package and with the same 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.Both of this gate's earlier shapes were wrong, and both were found by review rather than by a test. Until round 4 the fold keyed on
(machine, config_dir), and because a lane-bound seat (agent-loop,openai-compat) carries a lane and an emptyconfig_dir, every lane-bound seat on a machine collapsed onto one key — one slot's later success read as another slot's recovery. Round 4 keyed on(machine, slot)instead, which fixed that and made the chair the subject: the mirror defect above. Both are closed, and the controls that would redden if either came back area control in the product’s own suite(the collapse) anda control in the product’s own suiteplusa control in the product’s own suite(the chair).
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 is §6c, and it is a runbook rather than a one-liner for a measured reason |
| 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 (MachineIdentity.IdentityisIdentityRecord.Subject, the product), 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 it is documented and not repaired. Two reviewers of the console wagon described the same code and neither was wrong — one measured that the mechanism does exactly what it was designed to do, the other asked what that design leaves open. Re-keying seat identity from a label to a credential fingerprint is a change to the registry's identity model, which every verb that names a seat would follow; it is not a console change and it is not made inside a console wagon. The question travels to the fleet as its own act.
The argument against the second one, kept rather than buried: an ssh login
spends no model allowance, so refusing a terminal on a spent seat takes the
shell away on the one surface you reach for when a seat is already stuck. That
was argued, and it was overruled — by the review and by the order — on the
ground that 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.
c. 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.
An earlier revision of this page printed a one-liner that named --machine,
--slot, --identity and --role and nothing else. It was measured by review
and it was wrong twice over: it refuses (--added-by is required, so the
command never ran at all), and had it run it would not have been the
"latest-wins no-op" this page called it.
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):
If you are EDITING this page rather than following it: both blocks above
are executed byte for byte by a control in the product, and the control reads
them with a strict CommonMark fence parse. Keep each block to one command
line, and close it with a bare fence — ``` and nothing after it. An info
string is legal on the opening fence only; put one on the closing fence and the
control refuses by name (ceremony-closing-fence-illegal) instead of quietly
running whatever it managed to read. That refusal is the point: it was measured
that a page can otherwise drift away from the command anyone tests.
--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.
And this runbook is executed, not merely written. Both fenced blocks above
are read out of this file, substituted with a fixture's own values and run by
a control in the product’s own suite, which then compares the
seat row field by field and fails if anything but role moved. Edit a block and
the control runs your edit — which is the only arrangement in which a page like
this cannot quietly rot into the last one.