The socket
forge serves one WebSocket, at /socket on the address [server]
bind:port names. It is served from the process that already owns the
sessions, so a client costs a listener rather than a second scheduler, a
second cron store or a second set of connectors.
It serves data and nothing else. There is no page, no markup and no colouring on it: a client is its own program, it draws everything itself, and what it gets from here is what the core knows. The changed files cross as raw hunks - lines with their kinds and their line numbers, bounded and flagged - never as anything drawn.
Reaching it
Loopback by default. forge is reached over loopback or a private network and is never exposed publicly, which is the whole reason the socket carries no authentication: the network is what the access control stands on, not a proxy in front of a public bind.
A browser origin must be one of the user’s own. A WebSocket handshake
carries the page’s Origin, and the server refuses a present origin that
is not the shell’s (tauri://localhost, http://tauri.localhost,
https://tauri.localhost), one of the server’s own (http://<bind>:<port>,
read from the config), a page served on the user’s own machine - the
localhost name or any loopback IP address, on either web scheme and any
port, in every build - or one the operator listed in
[server] allowed_origins. A web client served from another address has
its page origin in that list. The authority is read as a host and an
optional numeric port, so a lookalike such as localhost.evil.com or
[::1].evil.com stays refused. A request with no Origin is a native
client - the protocol client, a harness - and is allowed, because browsers
always send one. A refusal happens before the greeting, answers 403, and
logs socket_origin_refused with the origin and the peer.
The connection is kept alive by a ping. The server pings every 15
seconds and ends a connection whose ping has gone unanswered for 60: a
peer that vanished without closing would otherwise read nothing for ever,
and the viewer it attached as would stay counted on its tab. The pair
is chosen rather than measured. Every WebSocket client answers a ping
itself, so a client needs no code for this; one that stops answering is
disconnected. What a reconnect must do is the client’s own rule, and a
seat is not like every other subject: forge’s clients re-ask the non-seat
subjects they hold and re-attach only the seat they have on screen, leaving
the rest stale until their next click - and a client re-attaching the seat
it is showing must re-send Showing too, because the server’s showing died
with the old connection and nothing else would spend the seat’s marks.
A client probes too, with ping. The server’s ping proves the peer is
reading; it does not prove the peer will ever hear a close. A half-open
socket - a locked phone, a network that went away - never delivers one, so a
client that only answers would sit open for ever and draw pre-drop data as
though it were live. Every client sends ping after 15 seconds of quiet, and
the expiry closes the socket so the ordinary reconnect runs. The detection
bound differs by transport, because what each can see differs. The shell’s
transport (desktop and phone) counts bytes under the socket, so a slow frame
is progress; it measures the quiet at beats, and a beat that only just saw
traffic restarts it from itself - the worst case from the last byte is about
40 seconds. A browser is handed whole messages only, so its quiet restarts on
every frame and its worst case from the last one is 15 seconds plus its
deadline: about 80 seconds.
The answer is a byte, and the deadlines differ by what each side can see.
The shell’s transport counts bytes under the socket and calls the link dead 10
seconds after a probe that drew none. A
browser is handed whole messages only, so its deadline is the write bound
above plus a margin (65 seconds): while the server spends up to 60 seconds
writing one frame it cannot answer a probe, and a tighter deadline would
close a live socket mid-frame and redial into the same record for ever.
The frame is application-level rather than a WebSocket control frame,
because a browser page cannot send a Ping through its WebSocket API - one
mechanism serves the desktop, the phone and the web build alike, and the
server answers it with pong and no workspace involvement at all.
One frame write is bounded too. The server ends a connection when a single write takes longer than 60 seconds: a peer that stopped reading holds that write open while answering no ping, and TCP can take minutes to report the socket gone. A frame that was partly written cannot be resumed, so the connection is torn down rather than the frame retried. The bound sits above the slowest frame measured - a 3.5 MB snapshot to a slow client, 31.6 s - and matches the ping’s dead bound.
A connection whose own loop stops is ended as well. The ping is a branch in the connection’s loop, so it cannot see that loop parked in an await: a subsystem that never answers leaves the socket open with no ping, no reap and no error, which is how one freeze went unnoticed for 26.7 minutes. A watchdog outside the connection’s future, in the same task, samples its progress and ends the connection after four write bounds without one, then runs the same teardown a clean close does. Progress is a completed write, stamped where every frame is flushed rather than once per loop turn, because one turn can issue several bounded writes. Four write bounds also clear the unbounded snapshot encode the loop performs between writes, so a legitimate long operation cannot trip it and a healthy connection is never reaped into a redial loop.
The greeting
The first message a client receives is the greeting, before it has asked for anything:
{
"kind": "greeting",
"version": 29,
"forge_version": "1.1.25 · abc1234",
"forge_version_short": "1.1.25+abc1234",
"settings": {
"mark": null,
"theme": null,
"font": null,
"dictate": {
"styling": "semi_formal",
"structure": "prose",
"context": "general"
}
}
}
version is the protocol the server speaks. It is fixed rather than
negotiated, because the server changes far more slowly than a client’s
visuals do. Nothing is refused over it: the client records the server’s
number beside its own, and a difference is a fact the surface draws - the
rail footer carries both halves’ build and socket numbers, and a mismatch
raises the notice beside them. Connection is never refused over a version.
One thing is gated on it: a client sends no ping to a server older than
the protocol that added the pair, because that server’s answer would be taken
for a subscribe refusal (see below).
Nothing is negotiated in either direction: the server reads no version
from a client, because nothing a client sends carries one.
Every skew sentence says what the wire can carry. This client’s build and the command that updates the stale half are always named. The server’s build is named where the greeting carried it: a server ahead carries it, and an older one may speak a protocol from before the field existed.
forge_version and forge_version_short are the build serving the
socket, the full stamp and the short one; the header draws the short one as
its server build. They ride here because the greeting is the only channel
both halves are guaranteed to have, so a skew that could name only a number
would name nothing a person can act on.
settings carries forge.toml’s [client] keys: the three names
mark, theme and font. They are a client’s settings rather than this
server’s, and the greeting’s copy is the pre-attach handshake. The
settings frame beside it is what the page and the client’s axes read, so
a reload can re-theme an open page without a reconnect. A null is not a
failure: it means the built-in. The browser’s idle clocks are the server’s
and live under [browser]; they are not carried here.
settings.dictate is [dictate]’s three prompt axes, and it is a
DEFAULT rather than a state: a client that captures its own audio holds
the values in force per seat, so this is what its panel draws as unset
and what its reset row returns to. Each take carries the values it
normalized by.
The effective config
A settings frame follows the greeting in the same open, and is pushed
again to every connection after each applied reload:
{
"kind": "settings",
"document": {
"version": "1.1.34+abc1234",
"source": "/home/me/.claude/forge/forge.toml",
"fingerprint": "0123456789abcdef",
"totals": { "keys": 232, "areas": 12 },
"areas": [
{
"id": "server",
"name": "server",
"note": "the socket a client reads through, and the browser the server hosts",
"keys": [
{
"path": "server.port",
"leaf": "port",
"group": ["server"],
"pattern": "server.port",
"kind": "number",
"value": 8790,
"class": "restart",
"edit": {
"kind": "number",
"required": true
}
}
]
}
]
}
}
path is the concrete key, with the instance where the table repeats
(accounts.Subspace.token). leaf is the key’s own name as a row draws
it, and group the instance names the row belongs to - an account, an org
and a project, a Slack table and workspace, a model, a server table - or an
empty list when the area’s keys do not repeat. Both are the server’s,
because a name can contain dots (a model slug, an env var, a display name)
and a reader cannot cut one from path. pattern is the catalogue pattern
the row fills (accounts[].token); a save sends it back so the writer
locates the TOML item without re-parsing a display path. kind is the
value’s shape for drawing: string, number, bool, list, table or
set. class is one of live (read on its next use), next_session
(read when a session or a connection opens), restart (built once at
boot) or not_read_yet (the loader accepts the key and nothing consumes
it). The class catalogue in crates/forge-workspace/src/settings.rs is
the one source for the frame and for the reload’s own summary.
fingerprint is a digest of the raw file text the document was built
from. edit says how a client draws the key’s control: kind is
string, number, bool, enum or list; allowed holds an enum’s
accepted names and is absent when the key has none; required says the
key may not be unset, so an emptied optional string clears it while a
required one keeps its value; and group names the repeated entry the
key belongs to (account, project, slack, model, org), absent
for a table’s own keys.
Values cross as the file loaded them, unredacted. The socket is reached over loopback or the private network, and that boundary is the decision; the server never writes a settings frame to the tracing log. There is no settings read verb: a connection attaching while a reload is in flight reads the current load from the next push, and the attach frame always reflects the current one. The document is built once per load and shared by every connection.
The frame is server-wide rather than seat-scoped. It carries no seat, it is delivered to a connection that has subscribed to nothing, and it arrives again on every reconnect.
Editing the config
A client saves a batch of edits with settings_edit:
{
"kind": "settings_edit",
"fingerprint": "0123456789abcdef",
"id": 7,
"edits": [
{ "area": "server", "key_path": "server.port", "pattern": "server.port",
"op": "set", "value": 8791 }
]
}
fingerprint is the one the page loaded from its settings document; a
file whose raw text has moved since refuses the save, and it is read
again after the temp file exists and before the rename, so a file that
moves in that window is refused too. id is the page’s own number for
the save; every answer echoes it, so a page clears its draft only for
the save that made it. area is the page’s area id the edit was made
under. op is set, add or remove.
setwrites a key:valueis its new value in theedit.kind’s shape. Anullvalue clears the key - the key goes back to unset and its default applies - which is how an optional string is emptied.addappends an entry:patternis empty,key_pathnames the new entry,valueis its fields, andareais one ofaccounts,projects,connectorsormodels.removewith apatternremoves that key; with an emptypatternit removes the entrykey_pathnames, with the same fourareavalues. The guards are identity diffs, so a rename is a removal too: a project or account the save leaves absent is checked exactly as a removal is.
A save that writes is answered with settings_saved, and the reload push
follows it with every client’s new document:
{ "kind": "settings_saved", "id": 7 }
A save that writes nothing is answered with settings_refused:
{
"kind": "settings_refused",
"reason": "invalid",
"id": 7,
"message": "forge.toml at ... failed to parse at line 12, column 14 in `port`: ...",
"key_path": "server.port"
}
reason is stale (the file moved; the page offers a reload),
invalid (the candidate failed the boot loader; the message is the
loader’s own, including its line and key where it has one), refused
(a guard declined it, naming what it found) or io (the file could not
be read or written; nothing was applied). The writer preserves the
file’s comments and order, replaces the file by rename in the
destination’s own mode and through a symlink, and the guards live on the
server so a hand-rolled client cannot force them.
The same writer backs the settings MCP tools. A settings__set call
carries one or more edits - a section’s mutually required keys go in the
same one - and a settings__unset call one clear. Either parks as
settings_write_pending on that seat, carrying every key with the value it
holds now and the value the write would leave, and the blocked tool call
waits for respond_settings_write { key, id, approved }. The batch is
validated against the loader before the prompt is minted, so a save the
file would refuse is refused without asking. Approving applies the whole
batch through the save path above and the watcher’s reload push follows;
declining writes nothing. A settings_write_resolved names the ending, so
a view that did not answer it can close the dock.
A vault call parks the same way. Every vault__* call parks as
vault_ask_pending on the seat, carrying a draft that draws no secret: the
tool, the item, the field or masked diff, an attachment’s name and size,
and whether the shared session is locked. The blocked tool call waits for
respond_vault_ask { key, id, answer }; the answer is approve,
send_back with a note, or decline, plus the unlock material when the card
asked for it: the master password and 2FA code, or the refresh token and user
key a platform authenticator released. An approval unlocks the one shared
session and runs the call; needs_unlock is stamped from the live session
on every read, so the other parked cards re-base to ordinary approve cards.
The unlock pushes a restamped vault_ask_pending for each card still
parked, so a dock that already drew one merges the frame by id; a call that
then re-locks the session (a failed refresh, or a decryption that means the
keys rotated) pushes the restamped frame the other way. vault_ask_resolved
names the ending. The
answer is a local channel: it travels the same connection the card came
over, and the password’s privacy is the socket’s - [server] bind is the
boundary, so a vault answer is only meaningful over loopback or the private
network the config names.
A device enrols on the same session, with material the core hands out.
vault_enrolment_material answers through reply_to with the session’s
current refresh token and the account’s user key, or null while it is
locked; the client wraps them under its platform authenticator and registers
the blob with biometric_enrol. When the session’s refresh token rotates -
the release redemption or the session’s own refresh - it pushes the
slot-less vault_rotated with the new token, and the client holding the
enrolment rewrites its blob through the update-only biometric_rewrite (a
row the person created, never a new one); a client that cannot falls back to
the password. The frame is live-socket-only: a device that was disconnected
when the token rotated keeps the spent token, and its next gesture start
falls back to the password once - enrolling that device again is the
settings card’s enrol, which needs the vault open.
Subjects
Everything a client reads is addressed by a subject, and there are six:
home, board, session <org>/<project>/<label>, usage, dictate_models
and extensions.
A seat nobody has started is an answer rather than a silence. Subscribing
to one that is not there comes back as an error saying so, so a client
never draws an empty snapshot as a broken page.
How a message is tagged
Every message is tagged on kind, which sits beside the message’s own
fields rather than replacing them. The bold name in the two sections below
is that tag, so the subscribe below is {"kind": "subscribe", ...}.
The two payloads are tagged differently, and this is where a client goes
wrong. command carries one of the core’s own Command values and
update carries a SessionUpdate, and those two enums are tagged on the
variant’s own name rather than on kind:
{
"kind": "command",
"command": {
"cancel": { "key": { "org": "Acme", "project": "proj", "label": "lead" } }
},
"reply_to": null
}
A command’s variant is its name around its field bag - Command has 70
variants, 63 of them struct variants; the seven unit variants,
dictate_catalogue_check, dictate_bench_stop, voice_bench_start,
voice_bench_stop, plugin_update_all, plugin_check_updates and
vault_lock, cross as the name alone. An update is the same
shape one level in,
{"kind": "update", "update": {"chat_appended": {"key": ..., "msg": ...}}},
and 79 of SessionUpdate’s 82 variants are struct variants too. The other
three are why the update payload is not one shape: they are unit variants and
cross as the name alone - "catalog_loaded", "cli_version_changed" and
"dictate_availability".
What a client sends
ping - a liveness probe with no fields. A client sends it after 15
seconds of quiet, and a page sends one on a return from the background so a
dead socket is caught by the deadline rather than up to a quiet spell later.
A probe already out is neither asked again nor re-armed: a return while
one is unanswered leaves its deadline standing, or a person returning every
few seconds would keep a dead link open for ever. Any inbound traffic
answers it - a byte to the shell’s transport, a whole frame to a page - and
the pong exists so a quiet peer is always asked something. The answer’s
value is the transport’s: it closes the socket on its own deadline (10
seconds on the shell’s transport, 65 in a browser; worst-case detection is
about 40 seconds and about 80 respectively) and lets the ordinary reconnect
run. The server clears its own keepalive on it like any other frame and
answers with pong. A server older than the protocol that added the pair
is never asked: its answer is a client_message error, which a client
books as a subscribe refusal, and the greeting already said which server it
is.
subscribe {what, answering, review} - answered with a snapshot of the
whole subject. The subscription this opens is the one updates arrive on. The
browser is not a capability a client declares: it lives on the server, and a
client attaches to it as a viewer (browser_view below).
answering declares whether this client can answer a prompt, and it is
off unless you say otherwise. The core parks a turn on the reply of
whoever registered as answering, so a client that says so must be able to
show what it is handed: one counted as able to answer and unable to draw
would leave the turn waiting on a reply that never comes. A read-only
dashboard says nothing and still READS the prompts - every update reaches
every subscriber whatever its role, and no turn hangs on it, because the
core asks only the answerers whether a prompt can be answered. A client with
a dock to answer from sets this true, usually in its first subscribe, and
the connection registers with the core accordingly before it forwards
anything.
The declaration sticks for the connection, and it only goes one way.
A later subscribe that leaves answering false does not take it back:
the connection keeps the answering stream it already has, because the role
is what the core counts when it decides whether a prompt can be answered,
and a client that has drawn a dock once is not made an observer again by a
second message. So say it on the first subscribe and expect it to stand.
review asks for the review page’s own read, and it is off unless you say
otherwise. It is the read that checks the branch diff’s base
(origin/<default>): it answers the last completed check the store holds
inside its four-minute window, or base: unverified pinned to the local tip
when it holds none, and kicks a throttled background refresh whose measurement
answers the next read - so the diff the record carries and its base
describe one check, drawn without waiting on the network. A seat with no
session is the one exception: no watch runs for it to deliver a kicked
check, so its review read runs the refresh and waits for it. A local base runs
no fetch at all - the read probes whether it can lag behind anything and
answers local when it cannot. When a fetch cannot run, diff.base_error
carries git’s own words for why; a failed check is answered for the store’s
window rather than retried on the next open. Every other read of a seat that
has a session serves the stores and never checks the base; a record answered
without it carries base: unverified beside a branch layer and no
base_error. (A seat with no session is the exception the pr row below
already states: its record looks the PR up on the read, because there is no
loop to defer that to.)
The flag follows the page holding the seat rather than one ask, with one deliberate exception: a read a page asks for on a notice - a review write, another client’s activity - is an ordinary record read, so ten comments in a minute are ten records rather than ten base checks. The page’s own read follows it, and that is what re-states the base. The page says false again when it lets the seat go. That last one is the client’s own held state rather than a frame - nothing on this wire says “forget the scope” - so a reconnect after it re-asks an ordinary record.
browser_view {seat, tab, reply_to} and browser_view_page {seat, target, reply_to} - attach this connection to one of a seat’s browser
tabs as a viewer. seat names the seat whose browser to view; tab is a
tab’s number from the browser_tabs list, and omitting it takes the asking
seat’s current tab, else the profile’s first. browser_view_page names
the page instead - the CDP target id a hand-off carries - and is what a
hand-off’s Open and its Retry send, because a tab number names a different
tab after a host replacement. Both answer identically; a page that is gone
is refused with the target and the tabs open now. The profile
is not on the wire: a seat’s profile is forge.toml’s, resolved from the
project’s browser_profile, else its org’s, else default. The answer is a
browser_view naming the profile’s wire id, the profile key and the tab, a
browser_nav_state for that tab, and frames as they paint - the newest kept
frame first when one exists, so an attach on a still page draws at once. A
client that waits for the answer sends reply_to: the attach’s own reply
carries {"ok": true, "profile": ..., "tab": ...} with what it actually
attached - a request that left the tab out is resolved to the seat’s current
one - and {"ok": false, "why": ...} when the server refused it, so a
surface that could not open draws the reason instead of a dark page.
Attaching again moves what this connection is viewing: a re-attach to the
tab it is already on keeps the viewer and the controller role it holds
there, while another tab or another profile lets the old tab go - and the
role with it - before the new one is attached. Every attach that lands is
recorded (browser_attach with the profile and tab it took, the profile
and tab it came from, and same_surface when the two are the same), and a
refused attach drops whatever the connection was viewing and is recorded too
(browser_attach_refused with the tab asked for, what was left and why). A
connection that goes is detached with it. A hand-off’s tab is the tool
call’s own:
browser_hand_off names it by the page id its listing printed, an id no tab
carries is refused before any ask is parked, and the ask crosses as the
seat’s parked ask carrying both the tab that id resolved to and the page id
itself. The surface’s Open is browser_view_page - the page id rather
than the tab number, because a parked ask outlives the host that minted the
number and a successor host’s first tab takes number 1 again. A page that is
gone is refused with the tabs open now; a number-addressed browser_view is
still what the strip’s own rows send.
browser_detach - stop viewing the attached tab. Lowering the takeover
sends this: an attached viewer counts as a use, so a lowered surface gives
its viewer up rather than keeping the profile warm. A connection that
switches tabs does not detach first: the attach itself lets the old tab go,
so a switch is one message rather than two.
browser_resize {width, height, scale} - lay the viewed tab’s page out
to one viewer’s surface: device pixels and the ratio they draw at. A held
role owns the layout: while a controller holds the tab only its size
applies, so a watcher’s window never moves the page under the person driving
it. With nobody holding the role the layout belongs to the tab’s
longest-watching viewer, never to whoever asks - which is what lets a client
with no shell lay out its view without taking control, while a second
watcher cannot move the page under the first. A size the page already has
asks nothing, so a re-attach or a re-measure does not move it. The host
adopts the size on its next pump and the next frame is a key frame at it.
browser_input {seq, event} - one input event from the controlling
viewer. The wire carries the person’s gesture, not the browser’s
protocol: a press, a wheel, a key or inserted text, with the modifier keys
held; the server turns a press and a wheel into CEF’s own mouse calls and a
key or inserted text into CDP’s Input.*. seq is the
viewer’s own ordering - the server drops an event at or below the last one
the role carried, so a superseded gesture never lands late. Input from a
viewer without the controller role is refused with an error naming
browser_input, and a discrete gesture writes browser_input_refused. A
pointer move is a stream: it is refused the same way but writes no record,
because the click that follows carries its refusal. A gesture from a
connection with no attach is answered the same way - a discrete one is
refused and writes browser_input_unattached - while a move there is
dropped with no answer and no record. A refused gesture is recorded with
its kind and, for typed text, only its character count, never the
characters: this browser signs people in.
browser_control {take} - take the controller role, or give it back.
There is no default controller and no override machinery: a surface starts
controller-less, any attached client takes the role with an explicit
control, and exactly one holds it at a time. A take moves the role to the
caller; the previous holder becomes a watcher and its input is refused from
that moment. A take that does not land is answered, with an error
naming browser_control, and where it failed is which record it leaves: a
connection with no attach writes browser_control_unattached (take,
has_browser, reason), while a host that refused the role - a tab that
moved, a session that ended under it - writes browser_control with
landed=false. A take that lands writes browser_control with
landed=true, and a release writes browser_role_released once, when it
actually held the role; a release that held nothing is not an event and
writes nothing. Every viewer of the tab is sent the new state
(browser_view), so the same truth is drawn everywhere.
browser_nav {back} - move through the page’s history, the takeover
bar’s arrows. Only the controller’s move lands: a watcher’s is refused
with an error naming browser_nav, the same rule input follows. The answer
is a browser_nav_state for the tab, and the same frame is pushed
whenever the page navigates on its own.
browser_file_answer {id, files} - the files a person chose for a
page’s file chooser, each {name, data} with the bytes base64. The bytes
cross the wire: the host is a windowless process on the server, so the
server writes the chosen files beside the profile and hands them to the
driver’s captured chooser (or CEF’s own dialog when no driver is attached).
Only the controller may answer, and only the id the page’s outstanding ask
names; anything else is refused before a byte is written.
An empty list is a dismiss; past 16 files or 8 MiB the whole answer is a
dismiss. The size is a chosen bound, not the wire’s: 8 MiB of bytes encodes
to about 10.7 MiB of base64, comfortably inside the 16 MiB inbound bound, so
the cap is there to keep one answer from spending the socket.
client_perf {records} - one page’s own perf records, for a client with
no shell to write them. The records cross as the page serialised them and
are written to forge-perf-web.log beside the server’s own forge.log,
with one rolled copy; nothing answers the batch. A page with a shell writes
the same records through its own invoke instead, so this is the web build’s
path only.
browser_tabs {seat, reply_to} - the strip’s rows for one seat: the tabs
of the profile its org and project resolve to. The answer rides the
reply, so a client that draws the rows sends reply_to:
{"ok": true, "tabs": {...}}, where the list is
{profile, id, up, connected, closing_at_ms, rows}. profile is the key,
<org>/<name>; id is the wire id the binary frames carry; up is whether
the profile’s host is running; connected is whether a driver is attached
to it; closing_at_ms is the idle clock’s deadline as Unix epoch
milliseconds, or null when no host is up; and each row is
{number, title, url, owner, opener, state, dialog}. number is the host’s
own tab number, which frames and input already speak; owner is the session
whose calls drive it, as {org, project, label}; opener is the tab whose
page opened this one, or null for a tab a session owns outright; state is
in_use (a call is driving it), idle, or held (a parked hand-off is
waiting on the person in it); and dialog is the page dialog holding the
tab, as {kind, message}, or null when none is open. The ask is the subscription: the connection follows the same row
list from then on, and every change is pushed as a browser_tabs frame. A
refusal answers {"ok": false, "why": ...}; with no reply_to, a refusal is
an error frame and a success is silent.
terminal_view {seat, id?, reply_to} - attach this connection to a
seat’s terminal as a viewer. The answer is a terminal_view frame carrying
the screen as a VT stream and the sequence the byte stream continues from,
then output frames as the shell writes them. id omitted opens the seat’s
terminal, creating it on first use - the person’s door on a seat no agent
has touched; a named id that is not the seat’s is refused with
{"ok": false, "why": ...}.
terminal_detach - stop viewing the attached terminal. Lowering the
surface sends this, so the idle clock and the viewer count see the truth.
terminal_resize {seat, id, cols, rows} - the attached surface’s size.
The PTY and the server’s screen model resize together.
terminal_pause {paused} - client-driven flow control, so a phone that
backgrounded stops being written to rather than being dropped. A resume is
answered with a fresh terminal_view: the frames a pause missed are not
replayed.
terminal_control {seat, id, take} - take the person’s control of the
attached terminal (take: true) or give it back (false). One controller
at a time, like the browser’s role: a take always lands and the previous
holder is told it lost the role, and a release by anyone else is not theirs
to make. Every viewer hears the result as a terminal_control frame. While
a client holds it the terminal tools are refused, exactly as during a
hand-off; lowering the surface or dropping the connection releases it.
terminal_close {seat, id} - the person’s close door: the terminal’s
process group is killed and the seat’s next open starts a fresh shell.
There is no profile verb on the socket. A profile is forge.toml‘s -
its org’s browser_profile, or the project’s override - so no client names,
closes or deletes one, and a tab is the calls’ rather than the strip’s: it
opens with a session’s first page call - or the host’s own boot tab, which
that call adopts - and closes with its session or its host.
unsubscribe {what} - nothing comes back, because the client asked to
stop hearing. Note that a second plain subscribe to one subject adds a
second entry rather than replacing the first, so one unsubscribe drops
one entry: a client that subscribed twice and unsubscribed once still
hears that subject, and needs a second unsubscribe to stop. It is counted
rather than flagged for a reason: two subscriptions to one seat are one
seat still subscribed, and a single unsubscribe must not stop the frames
the other subscription still wants.
A subscription carries the frames; showing below is what spends the
marks. A client may keep a seat subscribed for a page it is not drawing,
and that alone does not count as a visit as far as the rail is concerned.
showing {seat} - which seat this connection has on screen; null when
it shows none. The last one wins: repeating the same seat is a no-op. Once the
seat is also watched, it is what spends the needs-you diamond and the failure
mark, and one of the two things that spend the death trace - the other is a
prompt the reader themself sent, while a cron’s, a peer’s or forge’s own does
not count as looking. Subscribing a seat does not show it. Nothing answers it,
but a home snapshot may follow unsolicited when it spent a mark and this
connection holds the home.
more {conversation, before, turns} - a page of a session’s
conversation, as whole turns. Each turn carries key and messages, which is
the same shape the session snapshot’s conversation carries; the grouping
inside a turn is the client’s to decide.
A page carries cron_names where any of its rows is a fired prompt: the
schedule each came from, keyed by the prompt’s own id, because the prose a
reloaded row replays from holds the prompt and never the schedule that fired
it. The map is absent when the page holds no fired row, and the same field
rides the session snapshot’s conversation below.
seat_range {slot, epoch, from, to} - the frames a seat’s stream emitted
between two numbers, for a client that read a gap in them. Every frame the
workspace emits for a seat is numbered on that seat’s own counter, so a client
that reads 5 and then 7 knows exactly one frame went missing; the seat’s
header.seq says where the stream stands and the occupant’s session id beside
it is the epoch those numbers belong to. The answer is seat_frames, and a
range the seat’s own window no longer holds is answered from the session’s
transcript instead. An error naming seat_range means read the seat
again: a seat forge holds no session for, or one whose occupant has changed
since those numbers were minted - a repair across two epochs would splice one
conversation onto another.
A page that cannot be answered is REFUSED rather than answered empty. An
empty page carries cursor: null, which a client reads as “nothing above” and
stops asking on - so an answer the server could not give would make the seat’s
history unreachable rather than merely late. An error naming more means
ask again, not that the conversation is over.
A cursor below the held window is the exception, and it is answered either
way. Past its oldest frame the window stops, and the page is read from the
session’s own transcript on disk by the ROW the page above stopped at: the
transcript’s own bytes, because the frames the CLI streams and never writes
sit between the window’s rows and no arithmetic lines the two numberings up,
so a walk from any cursor converges on the same descending chain of rows.
Where that read cannot answer - no file, a row the file does not carry, one
further from the file’s end than the read’s cap reaches - the answer IS the
empty page, and what it ends is the history, not the seat. An error still
means ask again.
A turn can carry a frame the CLI did not send. A backgrounded task’s
ending reaches a transcript as a row of its own, and a row that opens no turn
itself still lands in a later turn than the call it ends whenever something
that does open one - a delivery, a peer message, a person’s next prompt - sits
between the two, which happens to about a third of them. A turn holding such a
call carries the ending as a frame too, which is what lets a client that folds
one turn at a time read it. The forged frame has no uuid, because the row
keeps the id the CLI minted for it and a second one would disagree; the row
stays where it is, and a fold draws nothing for it, the ending being drawn on
the call’s own row.
key is null on every turn of a transcript-derived conversation, and a
client must not key by it. The name comes from a Result frame, and a
transcript holds none: its reader maps user, assistant and system rows
and nothing else. A client that keys its rows by key collapses the whole
conversation into one. It is carried because a live session does name its
turns, and what a client does with it is its own business - the cursor is a
position and is the one handle that always names a turn.
The boundary between turns is the server’s where a page has one, and it is the only part of the fold that crosses. How a run of tool calls groups within a turn is a drawing decision; where one turn ends and the next begins is what the paging contract is built on, so a page above the window’s floor never hands over half a turn. A page below that floor can carry a stretch inside a turn - a running turn’s older rows are exactly that - and the server keeps what a fold needs to read it: a page never opens on a tool result without the call that produced it, and a running turn is the whole of what a late-attaching view can reach when the session never ends one.
The cursor is a position, not an index. Echo it back as before and do
not take it apart: above the window’s floor it names the message the page’s
first turn opens on, below it names the row the page stopped at by the byte
that row starts at, and what either is made of is the server’s business.
null means there is nothing above the page it came with, and that is where
a walk backwards ends.
document_read {session, path, reviewable} - one document out of a
seat’s own working tree or its project’s document_roots in forge.toml,
for the client’s viewer. path resolves against that tree, and nothing
outside the tree or those roots reads, however the path is spelled: a
.. walk, an absolute path elsewhere and a symlink pointing out are each
refused BY NAME. reviewable is what a hand-off asked for - the hand-off’s own
fact rather than the reader’s, so it decides nothing about whether the
viewer offers the document’s review - and a PDF asked reviewable is refused
with the reason rather than quietly dropped. The read is
bounded by the [documents] max_size_mib ceiling (10 unless forge.toml
says otherwise), and a document over it is refused naming the size before
any bytes are read. Every refusal is an error naming document_read,
with the sentence as its why - missing, unreadable, outside the tree,
over the ceiling, a PDF handed reviewable, or a markdown or text kind that
is not UTF-8 - and that sentence is what a viewer draws as its own line. The
answer is a document_read message or that error; those are its only two.
answer_document_hand_off {key, id, answer} - answer the parked
document hand-off id for the seat key names. The answer is the
reader’s decision, never a bare done: answer is
{"approve": {"review_id": ..., "note": ...}} or
{"feedback": {"review_id": ..., "note": ...}}, and "not_now" declines
the ask before anything opens. review_id is the review the viewer’s
finish filed under the document’s scope, null when nothing was
commented; note is the reader’s own words, null when they wrote none.
Approval is the one signal an agent may proceed on; feedback means the
document is revised and handed off again. A call stays parked until one
of the two is chosen, and a client that answers an ask no longer waiting
is refused by name.
file_content {session, path, sha} - one file’s whole content at a
revision, for a review page’s between-hunk expansion. The record’s diff
carries bounded hunks, so the lines between them are what only this read
can supply: sha is the scope’s own revision - a commit’s tree for a
commit’s scope - and null reads the seat’s branch tip, the new side of
the all-changes diff. The revision is resolved to a commit id before git
sees it, so an option-shaped string is refused as a revision rather than
read as a flag. The read is bounded by the same [documents] max_size_mib
ceiling the document read is, and by the file’s SIZE first (git cat-file -s): a file inside the ceiling reads whole, and one over it answers the
lines the ceiling reaches, flagged truncated, rather than refusing.
There is no error arm: a read that could not run answers as a
file_content message with the failed state and the sentence for its
why, because a page draws that as the absence of a control rather than as
a broken file.
Binary messages are frames, and a frame’s first byte says which kind. A client that captures sends one dictation frame per 20 ms of speech:
| bytes | field | value |
|---|---|---|
| 1 | kind | 0 = dictation; 1 = browser frame; 2 = browser audio; 4 = terminal output; 5 = terminal input |
| rest | samples | i16 little-endian, mono, 16 kHz (kind 0) |
A terminal output frame (kind 4) is the server’s: a little-endian u16
terminal id, a u32 sequence, then the shell’s own bytes. The sequence is
per terminal and monotonic; a gap means the viewer fell behind and the server
sends that attachment a fresh screen rather than replaying the bytes. A
resume after a pause is answered the same way - the frames it missed are
not replayed.
A terminal input frame (kind 5) is a client’s: the same id and then the
keystrokes. Both are refused from the wrong direction, as a browser frame is.
A dictation frame carries no seat. It addresses the take its own CONNECTION started: one connection streams one take at a time and its messages are ordered, so a frame can only arrive between its own take’s start and its stop. A frame this server cannot decode - a short header, an unknown kind, a payload past 16 KiB - or one that arrives with no take to hold it is dropped with a debug record and no answer, because a take’s audio has no reply channel and the take’s own outcome is what a reader sees either way.
1 is a browser frame, and only the server sends one. A viewer’s
frames are relayed exactly as the host encoded them:
| bytes | field | value |
|---|---|---|
| 1 | kind | 1 = browser frame |
| 2 | profile | the profile’s wire id, little-endian u16 |
| 2 | tab | the tab’s number, little-endian u16 |
| 4 | seq | monotonic per tab, little-endian u32 |
| 2 | width | device pixels, little-endian u16 |
| 2 | height | device pixels, little-endian u16 |
| 1 | format | 1 = WebP |
| 1 | key | 1 = full quality (a surface’s first frame, a resize, an attach) |
| 2 | page width | the page’s own viewport width, little-endian u16 |
| 2 | page height | the page’s own viewport height, little-endian u16 |
| rest | bytes | the encoded pixels, untouched by the server |
The page’s viewport crosses beside the frame’s pixels, because that is the space a gesture is sent in: a picture letterboxed in a surface of another shape, or painted at another raster, still puts a press on the pixel it shows.
The server never re-encodes a frame. The host encodes once and the bytes reach every viewer as they left it. A viewer that falls behind is served the newest frame rather than a queue, and a stale frame is only ever superseded by a newer one for the same tab. A browser frame arriving from a client is refused: the host is the only encoder.
2 is the page’s own sound, and only the server sends one. A page the
browser host is playing crosses the wire as interleaved PCM, so a person at a
client hears it from their own device rather than the server’s:
| bytes | field | value |
|---|---|---|
| 1 | kind | 2 = browser audio |
| 2 | profile | the profile’s wire id, little-endian u16 |
| 2 | tab | the tab’s number, little-endian u16 |
| 4 | seq | monotonic per tab, little-endian u32 |
| 4 | rate | samples a second, little-endian u32 |
| 1 | channels | interleaved channels |
| rest | samples | i16 little-endian, interleaved |
One profile’s audio carries every tab’s sound, and a viewer hears only the tab it is showing. The viewed tab’s packets go to its controller alone: a watcher is looking at the page, not driving its sound. A packet that arrives after the player has fallen behind is dropped rather than queued, so the sound stays beside the picture.
3 is one compressed JSON frame, and only the server sends one. A
connection that dialled its socket URL with deflate=1 declared that it can
inflate one; from then on a ServerMessage body of 64 KiB or more crosses
gzipped behind the tag, and a smaller frame is the text frame it has always
been, byte for byte. The declaration is made at the upgrade, and a server
that predates the kind ignores a query it does not read - so a client that
never asked never meets a frame it cannot read.
| bytes | field | value |
|---|---|---|
| 1 | kind | 3 = one gzipped ServerMessage |
| rest | body | gzip of the frame’s JSON text |
Order is the socket’s. Each transport inflates the frame in its own read path before the frames behind it are delivered, so a compressed snapshot and the updates that follow it keep the sequence the server wrote.
A binary frame from a client is audio or it is not a frame this server takes. The browser frames and the page’s sound are the server’s alone, so one arriving from a client is refused by its kind tag; nothing is waiting on it and no ask is failed by it. A refused frame is answered by nothing - a binary frame has no reply channel - so the debug record is what makes it legible.
dictate_stream {key, options, surface} - begin a take the CLIENT
captures. The connection that sends it feeds the audio as the binary frames
above, so nothing is opened on the server; the take registers as the message
is dispatched, so the frame that follows on the same ordered connection
always finds it. options is the axes that client’s panel was showing, and
surface names the page whose own capture began the take (composer,
review, files, doc), so the record can hand the landed words back to
that page and no other. A server-side take records from forge’s machine, and
its axes are the session’s stored overrides.
A take belongs to the connection that started it. Every dictate_*
update about it - the start, the levels, the phases, the end - goes back to
that connection alone: no other subscriber to the seat draws its meter, its
phases or its words, and the seat’s record carries no take at all. A second
dictate_stream for a seat whose take is live is refused with a sentence
naming the reason, and only the refused connection hears it. A connection
that goes away drops its take rather than submitting it - its reader is
gone, so nothing more is transcribed and nothing lands anywhere. What does
land sits in the box that began the take while the page that holds it is
there. On the review, files and document pages a re-hand for a box the page
no longer holds is dropped rather than placed in another box; the composer
is the exception, and re-places its own carried landing after a remount.
Pressing enter sends the words as an ordinary prompt every view then sees.
voice_mode {key, on, explicit} - a client turns voice on or off for the
seat it is showing. It is that connection’s own switch, not the seat’s: two
clients can watch one seat and only one of them be listening. The transport
stamps the connection on it, so the listener set counts connections; the seat
answers with a voice_mode_state carrying the count, to every subscriber
of that seat. The first client in puts a note into the session, so the
agent is told once that the person is listening and to send each update’s
substance through mcp__forge__voice__say in a live-conversation register.
The last client out tells the agent the person stopped and that it may stop
speaking when the off is explicit, the reader’s own switch, and when the
connection that held it goes away: a closed client or a dropped socket
empties the seat’s listener set too, so the same note fires. A client
that moves to another seat sends explicit: false and keeps its intent, so
the seat pauses without toggling the agent and a return reconnects. The note
rides a [Voice] envelope, so the transcript draws it as a delivery row
rather than as the reader’s turn.
voice_preview {key, voice} - the settings page’s audition: one fixed
line rendered in voice on that seat’s own voice stream, through the same
voice_audio frames speech uses. It needs a client with voice on for the
seat, and the core refuses it in its own words when none is on, so the page
says to turn the speaker on rather than opening a second playback path.
voice_audio {key, seq, sample_rate, pcm} - one rendered clip of the
seat’s speech, mono 16-bit samples at sample_rate. Clips arrive in seq
order, numbered per seat in render order, and every connection subscribed
to that seat receives them; a client plays them only while its own voice is
on. voice_mode_state {key, listeners} carries how many clients have
voice on for that seat, so a control shows the room rather than only itself.
command {command, reply_to?} - any of the core’s own commands, as
the core’s own enum. reply_to is absent or null on most of them, and
the two mean the same thing - a null is what the field is if a client
writes it at all - because the command is fire-and-forget: its effect
arrives through the subscription, which is why a client subscribes before
it acts.
reply_to is required on exactly nine of them: a worker spawn, a
despawn, the three review writes, and the four biometric commands - a
release, an enrolment, a rotation’s update-only rewrite, and the vault’s
material request. Their outcome rides the reply and no update carries it, so
a client that omitted reply_to would not be declining a reply - it would be
declining to learn whether the work happened.
Both mismatches are refused, with a sentence naming the field, and
refused before the command runs: a required field left off, and a
reply_to set on a command that has no reply to send. The second matters
for the same reason the first does - a client that set it is waiting on a
channel nothing will ever come down.
Each of the nine answers with its own type: a spawn with a
worker-spawn reply, a despawn with a despawn result, the review writes with a
bool and a review set, and the biometric four with the released blob, the
enrolment’s true, the rewrite’s true/false, and the vault’s released
material. One channel cannot carry nine different answers, so the reply type
follows the command. A refusal arrives in that same reply, as the body of the
answer rather than as an error - which is what the nine are for. A command
that is not one of the nine refuses as an error like anything else the
core declines, whether or not a reply was asked for.
read_call_output answers a call’s own output: the newest lines of
the file the CLI streamed the task’s output to, or a named reason there is
none. It answers while the task still runs as well as after it ends.
Until the ending frame arrives the path is composed from the task id the
launch frame carries - <base>/claude-<uid>/<slug>/<session-id>/tasks/<task-id>.output,
where base is the same CLAUDE_CODE_TMPDIR the child runs with and the
slug is a wildcard. The file itself is there from launch, so a read taken
between the launch and the command’s first line comes back as an empty
tail rather than as a path the read could not find; a path that resolves
to nothing at all answers no-path. Once the task ends, the path its
task_notification names is the one the read uses. Nothing is parsed out
of prose on either side.
What a client receives
-
pong- the answer to aping, with no fields. It is the client’s proof that the link carries frames in both directions, and a client draws nothing of it. -
snapshot {subject, data}- a subject in full, in answer to asubscribe. -
update {update, seq, span}- oneSessionUpdate, for whichever subjects the client is subscribed to. The subject decides, and a home subscription has two arms rather than one: an update a home row draws something of, and an update belonging to no seat at all. Two home fields arrive only through the second: the service status and the fatal error. Read that arm as absent and a page keeps what it read at subscribe for the life of the connection. The account pool is a home field as well, and its announcement is also a fleet redraw, so it arrives through the first arm too; the frame carries the pool’s own state, so a usage-only move is applied instead of read. A move in the loading rows, the settled flag, the listener, a probe failure or the selection signal -at_capandcoolingon each pool row - is still a read, because a project row’swould_bindand its chip derive from those. A card left at0 ready, probinglooks like a slow probe rather than like a page that stopped listening, which is why the frame carries the state rather than staying a bare wake-up.The plugin records are keyless without being home fields. They cross to a home subscriber for the same reason - they name no seat - but nothing in the home’s snapshot carries them; their reader is the
extensionssubject, and a client declines the home read for them on purpose.seqandspanare the frame’s place in its seat’s own sequence. Every frame the workspace emits for a seat is numbered on that seat’s counter, and the number is what lets a client see what it missed: a client that reads 5 and then 7 knows exactly one frame went missing and asks for it (seat_range), where a snapshot’sheader.seqsays where a fresh reader joins.seqis the number the frame’s OLDEST update was minted under andspanhow many of the seat’s numbers it stands for - 1 for a frame that is one update, and n for a run of token appends the batch folded into the one frame they draw as.seqis absent from a frame belonging to no seat, andspanis 0 only on the turn the batch forges from a delivery- a frame with no number - and 1 everywhere else. Both are absent from a
server older than the fields, which leaves a client with the frames alone.
seqalone is absent from a seat’s frame the server answers out of band rather than emitting into the stream: theread_call_outputanswer carries its seat,seq: nullandspan: 1, because it stands nowhere in the sequence.
- a frame with no number - and 1 everywhere else. Both are absent from a
server older than the fields, which leaves a client with the frames alone.
-
page {conversation, turns, cursor, cron_names}- in answer tomore;cron_namesis absent when the page holds no fired prompt. -
seat_frames {slot, epoch, frames}- in answer toseat_range: the frames numbered infrom..=to, oldest first, each{seq, update}with the update in the same shape a liveupdatecarries - so a repaired frame folds through the same pipeline a live one does. The range is answered from the seat’s own window while it holds the frames, and from the session’s transcript below that: the transcript’s own newest rows cross, numbered to END at the range’s end, because what a frame was numbered is minted in memory and written down nowhere. The client resumes exactly where it was reading rather than asking again for a stretch that can never arrive. -
document_read {session, path, document_kind, bytes, reviewable, content_hash, threads, reviews}- in answer todocument_read: the seat it was read for, the resolved absolute path the viewer addresses by, the kind the read detected (markdown,pdf,text, or{"other": "<extension>"}kept as itself), the bytes base64 - the ceiling keeps a document well inside the socket’s frame bound - whether the DOCUMENT is reviewable, which is its own fact: markdown and text are, a PDF and an unrecognised kind are not, so the viewer’s door does not depend on how the page was reached - the document’s own hash as DECIMAL DIGITS (the same convention an anchor’scontent_hashcrosses under), and the document’s scope read: its comment threads - each carrying aresolutionnaming where its anchor sits in THIS document now, re-found by its first captured line, oroutdatedwhen the revision left it nowhere; a read whose bytes are not text carries no landing at all, soresolutionisnullfor every thread - and the reviews they were filed into.sessionis named back so a client holding two seats routes each answer to its own viewer; a refused read comes back as anerrornamingdocument_read, under the seat that asked - and so does one whose review store could not be read, because an answer with no comments would draw an uncommented document. -
file_content {session, path, sha, content}- in answer tofile_content: the three names of the ask back - so a page holding two seats, two scopes or several files routes each answer to its own ask, and the scope it names is the one that asked rather than the one on screen - and the read undercontent:{"state": "read", "lines": [...], "truncated": bool}for a read,{"state": "failed", "why": ...}for one that could not run. There is noerrorarm. -
reply {reply_to, body}- in answer to a command that asked for one. -
browser_view {id, profile, tab, controller, viewers, viewer}- one tab’s viewer state: the wire id the binary frames carry, the profile’s key, the tab’s number, the controller’s viewer id when one holds the role on that tab, how many clients are attached, and this connection’s own viewer id - the numbercontrollernames when the bar is this client’s to draw a release on. Sent on attach and whenever either changes, so every viewer draws the same truth about who is driving. -
browser_nav_state {id, tab, can_back, can_forward}- where the viewed tab’s history sits: what the takeover bar’s arrows may do. Sent on attach, in answer to a move, and whenever the PAGE navigates on its own - a link click, a redirect or a same-document move - so the arrows do not wait for the next open. -
browser_tabs {id, profile, up, connected, closing_at_ms, rows}- one profile’s tabs, pushed to a connection that asked withbrowser_tabsonce per change: the same list the ask answered above, in the frame’s own shape. The strip draws its rows from this rather than polling. -
browser_surface_failed {id, tab, reason}- the viewed tab’s browser stopped. Said out loud, because a frozen last frame reads as a slow page and the person has no way to tell the two apart. -
terminal_view {seat, id, cols, rows, seq, screen, viewers, viewer, controller}- one terminal’s screen as a VT stream a client can draw, and where the
byte stream continues from:
seqis the last sequence the repaint reflects, so a chunk at or below it is one the screen already carries;vieweris this connection’s own viewer number andcontrollerthe viewer holding the person’s control,nullwhen the agent does. Sent on attach and on a resume, never as a reply: the attach’sreplyis its own{"ok": true, "id": ...}.
- one terminal’s screen as a VT stream a client can draw, and where the
byte stream continues from:
-
terminal_control {seat, id, controller}- who holds the terminal’s input, pushed to every viewer on a take or a release: the controller’s viewer id, ornullfor the agent. Every bar draws the same truth about whose keyboard it is. -
terminal_changed {key, terminal}- a seat’s terminal opened, changed or closed, as anupdateframe and not a message of its own: the summary carries the id, the shell, the cwd, the size, whether the shell is alive, the state (with itssource), the title and whether a hand-off holds it.terminalisnullwhen the terminal is gone. It is pushed on every real state change - a command starts, a password is wanted, the shell exits - so the strip’s words never wait for a record re-read. -
browser_file_dialog {id, tab, multiple}- a page asked for a file. The shell opens this machine’s picker and answersbrowser_file_answerunder the same id;multipleis whether the page accepts more than one file andtabis the tab that asked. Only the controller of that tab is asked - a watcher, or a viewer on another tab, is sent nothing and does not cancel it. With no controller the chooser waits for the agent’s ownbrowser_file_upload; CEF’s own dialog is the fallback when no driver is attached. -
browser_download {tab, name, data, error, dropped, lost}- a page’s download finished on the server. The shell opens this machine’s save door and writesdata(base64), or sayserrorand writes nothing; a file past 32 MiB is refused in words rather than handed over.droppedis true when the offer was lost before this viewer read it (the backlog’s ceiling), so the shell titles the notice as a loss rather than a save that failed; it is false, and absent from an older server, for every download the host actually finished. A lost offer is the profile’s, not a tab’s: that notice carries notab, andlostsays how many were dropped, so the shell reads one loss or many from it. Only a controller is sent one - the controller oftabfor a save, and of its own tab for a profile-level notice - and the server’s own copy is removed once it has crossed, whether or not a viewer took it. -
error {what, why, seat, dispatch_id}-whatfailed andwhy, in the core’s own words.seatnames the conversation a refusal belongs to where it belongs to one - a refusedsubscribenames the seat it refused as well as a refusedmore- anddispatch_idthe dispatch the refused press made, so only that press hears its own refusal; both arenullwhere they do not apply.
What a subject carries
The whole set, so a client author can see what is reachable without
reading the code. A subject’s snapshot carries all of it, and its
updates carry the pieces that change.
home is the fleet: every project, every account, and the app-level
facts a row is drawn from.
| Field | What it is |
|---|---|
projects | One row per project: project (name, org, path, sessions, has_model), work (branch, changed, gate) read at the project’s own path, would_bind, and chip - the account the row binds and its state. The board’s own detail - rows, schedules and connector sets - is the board subject’s (#2517). |
fleet | One glance per project, for the home’s own fleet row: how many seats are live against the project’s cap, the queue (ready and unowned), the rows waiting on the user, the project’s total row count, and the project’s own misses by name. |
agents | Every seat’s row: slot, label, lifecycle, whether it has background work, what it is waiting on (the ask’s kind, how many wait behind it, and the ask’s own sequence), when it was last active, why it failed if it did, tree (the seat’s own directory, home-shortened, only when the worker runs somewhere other than its project’s), and work (branch, changed, gate) read at that seat’s OWN directory, which for a worker is its worktree and not its project. |
unseen | The seats whose last turn finished while nobody was showing them. A mark is drawn from this, and nothing else can reconstruct it. |
accounts | Loading state per account, whether all of them settled, the gateway listener’s ready state and port, and each account’s cached usage snapshot. |
connectors | The connector liveness: Gotify’s connection, Slack’s workspaces and whether the stored subscription set failed to load. What a project is subscribed to is not here - those sets are per project and ride the board subject’s project row. |
biometric | The device enrolments the settings page lists: each device, the purposes it enrolled, and each purpose’s enrol and last-release stamps. No wrapped blob is here - the bytes stay behind biometric_release, which answers the client that unwraps them. |
vault | The shared vault session’s lock state and idle bound, and null when the config declares no [vault]. The client draws the settings vault card from it. |
dictate | Whether dictation is on, the per-model fetch and load state, and where the models land. |
voice | Whether [tts] enabled is set, and where the speaking model’s files land. |
cli_version | The installed and latest claude versions. |
forge_version, forge_version_short | Which forge build is serving the socket. A client draws it as the server build beside its own client build. |
boot_id | The server run’s own identity, minted once per process. An ask’s sequence restarts with the run, so a client scopes the asks it has announced by this - a mark from a dead run can never match a live one. |
service_status | The statuspage’s last answer, null for healthy or unreachable. |
fatal_error | The last fatal error the core held, null when there has been none. |
board is every project’s board: the rows, schedules and connector sets
the board page and the session rail draw. It is a subject of its own because a
home read happens on every update that covers the fleet, and these three
fields were most of a ~708 KB home read for a page that draws none of them
(#2517).
| Field | What it is |
|---|---|
projects | One entry per project: project (its identity - org and name; the rest of a project’s view is the home’s), rows (the board: each one a task with its worked time, ages and marks, plus its rollup and its epic’s subject), crons, and connectors (this project’s own gotify and slack subscription sets). |
board hears the variants that move it: tasks_changed,
cron_schedules_changed and connector_subscriptions_changed, plus the
seat events its rows’ stale mark is read from (spawning, connected,
session_replaced, releasing, connection_failed,
worker_status_changed). A row’s ages and its time-derived marks move with
the clock and no frame announces that, so a page holding the subject re-reads
on a slow beat of its own - 30 s - while it is mounted. The home hears
tasks_changed too, because its fleet row’s counts are derived from the same
tasks; it hears neither of the other two, because its snapshot no longer
carries them.
session <org>/<project>/<label> is one seat: its record, its
conversation, and what the composer is doing.
| Field | What it is |
|---|---|
slot | The seat itself. |
header | The occupant’s session id - the one a copy control hands out, and null when there is no occupant to name (nothing started, nothing connected yet, or one dropped after a sign-in or a failed connection) - the resolved model and the catalogue a picker draws from, the effort level, the permission mode, context usage, asked for on the reads that encode a subject when the seat has none, again when a turn finishes on a seat a page shows, and again when a compaction settles, a page or not (the socket issues the ask whether or not an agent is behind the seat, so where there is none it is refused and nothing reaches the CLI; the answer lands as a context_usage_snapshot update only where there is one) and whether a turn is in flight. seq is the number the seat’s next frame will carry, and the occupant’s session id beside it is the epoch that number belongs to: a client that attaches mid-stream reads where the stream stands, so the frames it hears from here continue the count it holds rather than looking like a gap. |
conversation | The NEWEST turns, in order, with the compaction count and the cursor above them (cursor, the same handle a more answers with), and cron_names for any fired-prompt row it holds (the schedule each came from, keyed by the prompt’s own id, absent when there is none) - up to twenty of them, the same page a more answers with, so a client seeds its conversation from this answer and asks for what is above it rather than for the page again. An older server’s record omits cursor: the page still seeds, and the client asks for the newest page rather than reading the history above as the end - a more answer names the window’s floor, and the field is additive, so a client never refuses a snapshot that lacks it. (Fewer than twenty when the held window’s 64 MiB byte ceiling has dropped messages off its front, or when [server] first_frame_cap_kb holds the page below that many turns: the newest whole ones are kept, and the front of what it keeps is where the walk below the floor begins.) Each turn carries key and messages, the frames the turn ran as. The live update stream carries those too, and both paths fold a run of consecutive token appends into ONE frame carrying the summed estimated_tokens_delta - the stream by its flush, a page by its turn, except a turn whose first carried frame is a counter (the window’s front cut inside a running turn, so its leading run is served as itself) - and a page’s frame adds folded_from and folded_frames: the run’s first stamp and how many frames the one stands for, so a client landing it knows which counters the frame already covers. The two differ elsewhere: a forged frame carries the id its prompt or delivery was minted under, which is the id the read’s copy of the same row carries - the two pair on it - while a frame the CLI sent carries the CLI’s own. A page differs the other way as well - it carries an ending for a backgrounded task’s call as a frame of its own, which the stream never sends - so read these as the ones this row states rather than as the whole list. Every path to a client carries the same bound on a frame’s structured record: a tool_use_result over [server] tool_record_cap_kb is reduced to the keys a client draws (a mutation’s patch and outside-edit mark, a question’s answers and notes), and arrives as a {omitted, bytes} marker only when that reduction is still over the cap. The transcript keeps the record whole. |
work | The working tree as state: branch, how much changed, and whether git runs here. |
git | The tree behind the row’s depth: default_branch (which branch the tree is measured against), worktree (the uncommitted files, each with its path, added and removed counts and its status - modified / added / deleted / renamed / copied / typechange / unmerged / untracked) and ahead (the branch’s commit_count, the chain itself - each commit a short sha, subject, its own stats and the instant it was committed in unix seconds - and the range’s own stats) all come from one scan, and base is what the last read that measured it found for the base ahead is read against - local, current or unverified from this server, behind as a value a client still draws, null until a read has measured one. A scan only kicks a refresh for a remote-tracking default and never waits for it, so base is what the last completed refresh measured - a watch pass’s kick, or a review page’s own read (the one that waits when the seat has no session) - and local arrives from a review read’s own probe of a base with no remote-tracking ref; base may be older than the scan beside it, because it is carried across scans that do not refresh. Beside it sits base_error - git’s own first line for a refresh that could not run, null when it ran or no measurement has landed - carried with the state so a page that hears the row names the reason rather than only that the base could not be checked. Every stats block carries the full totals and the busiest files up to the scan’s cap of seven, each classified with the same statuses the uncommitted layer speaks; the chain is capped at the scan’s walk. null layers are nothing to state - a clean tree, an unscanned one - and it moves with work_changed frames like the row above it. base is what makes a base-only move news: an upstream advance the branch does not contain moves origin/main while the branch’s own count and chain stay exactly as they were, so that field is the one that changes. A completed check is itself news: a refresh that landed after the row was taken is pushed on the seat’s next watch pass whenever its state or its base_error differs from what the row already states, because a page that drew the base unchecked is waiting for that check; a check that found exactly the same thing again is not news. |
pr, closes | The open pull request this seat’s branch is on - its number and URL, and whether it is a draft - and the issues it closes, which is the PR #N -> closes #M line a view draws under its PR row. null and an empty list when there is none, or when the branch is not pushed. The record carries the PR the seat’s last scan knows: the lookup is one network call, two when it finds a PR (the second fetches its closing issues), and a record read never waits on it - a cache past its refetch window is served as it stands and the seat’s own watch looks it up on its next pass, pushing the corrected row as work_changed. So a first open after that window can draw the previous value, or nothing when the seat has no scan yet, for about two seconds when the lookup finds a PR and about one when it finds none. A seat with no session has no store to serve and no watch to correct it, so its record still looks the PR up on the read. |
diff | The changed files with their raw hunks - data, never a rendering. Both layers the work row’s state describes (worktree against HEAD, branch_ahead against the merge base), each clean, populated or scan_failed; a populated file carries its path, the old path a rename came from, its status, binary / submodule / truncated flags, and the carried lines with their kind, text and line numbers. Bounded per file and per read - 400 lines and 32 KiB per file, 512 KiB of carried line text over 100 files - with every cap that bites flagged. Beside the two layers sits base - local, current or unverified from this server, behind as a value a client must still draw - the state of the last completed check of the remote-tracking base the diff is measured against. A successful refresh trusts the ref its explicit refspec wrote and answers current; a fetch that could not run reads no remote tip, so it answers unverified with git’s own words and no lag count. behind is {"behind": {"behind": N}} in JSON (serde nests a struct variant under its name); this server no longer measures one. An ordinary record read never refreshes the base, so it answers unverified beside a branch layer. A review-scoped read (a subscribe with review: true) answers at once on a seat with a session: the last completed measurement inside the store’s window, words and all, or unverified when it holds none, and kicks a throttled background refresh whose measurement answers the next read. A check outside that window is held and ignored, so a base drawn as current can be up to four minutes old, and the record carries its state rather than its age. A seat with no session has no watch to deliver that measurement, so its review read runs the refresh and waits for it; and a local base runs no fetch at all, the read probing whether it can lag behind anything and answering local when it cannot. The measurement then reaches the page as a work_changed frame, and the page’s own read against it answers the checked base. null when there is no branch layer. Beside it sits base_error - git’s own first line for a base fetch that could not run, null when the fetch ran or no branch layer is drawn; a measurement a completed refresh left carries its failure words beside its state, so a reused answer names them too - so a page names what failed rather than only that the base could not be checked. Read once per record and never pushed; a stored scan past its window is taken again before the record reads it, so its hunks are at most that window older than the row beside them, and the tree’s later movement arrives as work updates. |
file_index | Every file under the session’s scan cwd, walked with the user’s own gitignore preference and with the root’s own .git dropped whether or not that preference is on, and pushed as file_index_changed when a walk finds it moved. The walk follows the seat’s change watch: writes inside one poke become one walk, a still tree is walked at most once per five seconds, and a frame goes out only when a walk finds the index differing from what the seat’s readers already hold - the record’s snapshot, or the frame last announced. |
mcp | The session’s MCP servers, their status and tools, and the failure when the read did not complete. |
processes | The last walk of the session’s process tree, or null for a seat nothing has walked - a seat a client holds is walked once a second and its movement is pushed as a processes_changed update, a seat nobody holds is not walked at all, and a session ending clears it. |
background_tasks | The seat’s live tasks: the CLI’s background registry plus the long foreground calls it announces on task_started and ends with a task_notification, a terminal task_updated, or the turn the call ran in, each entry saying whether the CLI backgrounded it (is_backgrounded), with the line the row leads with, the command its own call carried and the call that began it. The processes feed leads its rows with these, because a backgrounded bash is detached from claude’s tree and the OS walk cannot see it for itself. |
monitors | The watches the session has running. |
terminal | The seat’s terminal as the strip draws it, null until one is opened: the id, shell, cwd, size, whether the shell is alive, the state (with its source), the title and whether a hand-off holds it. Pushed as terminal_changed on every real state change, so the strip’s words never wait for a record re-read. |
pending_asks | Every prompt the seat is holding, oldest first within each kind that keeps arrival order, the held kinds leading (a Slack draft, a browser hand-off, a document hand-off, a terminal hand-off, a held vault call, a held configuration write); the vault registry keeps no arrival order, so its own cards have none to offer. Two questions of one batch or two calls that ran in parallel park several at once, so this is what a client that attached mid-batch reads. |
pending_ask | The front of pending_asks - the prompt a client that draws a single ask waits on, null when there is none. Kept beside the list so a client reading only this one still draws the oldest ask. |
reviews | The review threads and the submitted reviews, each read separately so an unreadable one is not reported as empty. |
slash_commands, subagents | What the CLI last advertised: its commands and its agent-type catalogue, pushed as slash_commands_changed / subagents_changed when a turn’s init (or a plugin reload, for the commands) moves them. |
state | The seat’s scan cwd, queue - the prompts still waiting in the CLI’s queue, oldest first, each {uuid, source, text} - and voice_listeners, the clients with voice on for the seat - this one too while its switch is on. The queue is a fact about the seat, so it is read here as well as followed on the stream: prompt_queued adds a row and prompt_lifecycle settles it. voice_listeners is what a late attach seeds its control from before any voice_mode_state arrives. |
composer | What the composer is doing: the machine turn running on the seat (machine, handoff or compacting, null when it takes input), the push-to-talk key and mode forge.toml configures, and the last landed take nobody has sent (notice, with carried: true and the surface whose capture began it - null for a server-side take or a client older than the field - which a page reads back after a reload). No take: a take belongs to the connection that started it, so its meter and its phases ride that connection’s own updates and never the record. The asks it is answering ride pending_asks rather than being copied here. |
usage is the token/cost pool, scanned on the ask.
| Field | What it is |
|---|---|
today, week, month, lifetime | One window each, holding the same two groupings: by_model and by_project rows of tokens and notional cost, plus the total row. Each list is sorted by cost, descending. |
pricing_available | Whether a price table was loaded when the report was built. false means every cost is a placeholder zero, which is why it crosses rather than being left to a client to infer. |
Nothing updates this subject. It is a scan of the session-JSONL pool, not a feed: no update announces that a transcript’s tokens moved, so a subscription is answered once and then hears nothing. A client that wants the current numbers asks again by subscribing again.
dictate_models is the models page’s read: the pinned models, the
catalogue check, every catalogue row, and what the feed proposes adopting.
It is a subject of its own rather than a field of home because the rows
are the whole catalogue and only the models page draws them.
| Field | What it is |
|---|---|
enabled | Whether [dictate] enabled is set. Carried rather than inferred from an empty in_use, which a failed preflight empties while a switched-off section still carries the speaking row when [tts] is on. |
models_dir | Where the dictation models land, or null when the platform has no usable cache directory and none was configured. |
speaking | The speaking role’s own read: enabled, models_dir, speaking_model (the model’s own name), snapshot (the role’s own model row and its state), active (the variant that runs, with its variant, file, quant, size, installed and active), from (a bare name: a [tts] speaking_model pin, an installed pick, or the compiled pin), variants (every ONNX variant with whether it is installed and whether it runs), voices (the installed voice ids), voice (the configured id, or null for the engine’s default) and preview_error (the last preview’s render failure, or null). |
in_use | One row per model the role runs: role, file, size, sha256 (the declared digest, or null for an installed model that publishes none), the preflight state for a dictation row (pending, downloading, verifying, fetched, loading, ready, failed) or pending / ready for the speaking row from its installed set, facts - what the pin declares about the checkpoint (quant, parameters, licence, runtime) - catalogue, the feed’s entry for this file joined by file name, with the pin’s byte length as the witness, when the feed carries one, and from / at, where the choice came from (a [dictate] or [tts] config key, an installed pick, or the compiled pin) and when a runtime pick chose it. The speaking row follows [tts] speaking_model, the installed pick or the compiled pin, and its catalogue is null because the Hub, not the feed, carries it. |
check | The last catalogue check, tagged on state: never, checking, fresh (at, release, skipped) or unreachable (error). The rows stand through an unreachable: a failed check costs the freshness line, not the feed. |
updates | One entry per in-service model the feed can read against: role, file, current (the in-use model’s own catalogue numbers, speed_x and fleurs_en_wer) and candidates, every comparable row ranked, each with the rule’s verdict on it and which one the rule picks. |
rows | The whole feed, one row per variant: display identity, parameters, licence, languages, streaming, download (the preferred quant and its size), speed (the m4-max Metal row), wer (FLEURS English where the feed carries it, else the entry’s own headline), kind (which role’s list the row belongs in), url (the entry’s own page, where the feed names one) and download_count (the Hub’s own count for a cleanup candidate, absent from the speech feed). |
install, activate | Where the last model download and the last role swap got to, tagged on state (idle, downloading with its bytes, activating with its role, failed with the core’s own reason). |
installed | Every model this machine downloaded from the feed: variant, file, url, size, facts and when it landed. |
bench, results | The bench in flight - its target, its tier, its clip count and the agreement so far - and every finished run, each with its metrics, its tier, its target and the corpus it scored (clips, audio seconds, digest). Only runs sharing one tier and one corpus are comparable. |
speaking_bench, speaking_results | The speaking loop’s own run - its clip count and progress, or its failure - and every finished run, each with its round-trip word error, first-audio latency, realtime factor, peak_rss_bytes (the process’s peak resident set above the run’s baseline), the model file and the corpus it scored. |
read_aloud | The read-aloud set: the recordings kept here (id, duration, bytes, digest, when), whether one is being recorded right now, the passage they are read from, the terms a run scores them on, and the last write’s failure when there was one. |
A candidate beats the model in use on both measured axes or it is not
one: lower FLEURS English word error rate and higher m4-max Metal
wall-clock realtime factor, both from the feed’s own rows. The entry must
also carry English and must not be the variant already in use. An entry
whose file name matches the pin at a different byte length has been
rebuilt, and is never a comparison. Among the entries that clear all of it,
the fastest wins; a non-commercial licence is a fact on the row rather than
a filter, because what an adoption does now is download a model onto this
machine. Nothing is adopted without a press: the page proposes, and the
person presses - the core only ever does what a dictate_activate names.
The check’s landing is the update. dictate_catalogue_check starts a
fetch, and dictate_models_changed carries the re-read view when it
answers; a refresh due at boot lands the same way, so a page that attached
mid-check hears the result without asking again. dictate_availability
reaches this subject too: when the preflight finishes loading the models -
which it announces with that update alone - a page reads again rather than
drawing pending chips until the next check.
extensions is the Extensions page’s read: the plugin inventory, the
PATH checks the scan cannot carry, and the last update run. A subject of
its own rather than a field of home for dictate_models’ reason - only
that page draws any of it.
| Field | What it is |
|---|---|
snapshot | The disk scan, or null when no plugins root resolves (CLAUDE_CONFIG_DIR or HOME), which is its own state rather than an empty machine. Carries installed (the registry entries: id, version, scope, enabled, install path), marketplace (the catalog each marketplace offers), marketplaces (the configured sources: name, source kind, repo, clone location), components (per plugin: its skills, agents, commands, hook trigger events, an mcp flag, its declared lsp servers, its available_version, the auto dependency mark, and a load_error with its reason), marketplace_health (per marketplace: plugin count, load error, install-location drift), and token_costs (the always-on cost per plugin version, empty until a producer fetches it). |
lsp | One row per declared LSP server of an INSTALLED plugin: the plugin, the server, the command its manifest declares, and whether that command resolves on the machine’s PATH. Computed here because it is a fact about the machine the socket is on. |
update_run | The last update run or check, in flight or finished: the trigger, whether it has finished, and one row per plugin with its scope, its status (queued, updating, updated, already_current, failed, skipped, update_available), the versions on either side, and the failure’s own words. |
The read and the frames are one shape. plugins_inventory_updated
embeds the snapshot object and plugins_update_run_progress /
_finished embed the run, so a page that took one off the stream and a
page that read it are drawing the same bytes. The plugins_* family
reaches this subject in full: an action’s outcome, a run’s rows moving, a
failed refresh, and a rollback’s result. plugins_cli_action_failed and
plugins_inventory_refresh_failed carry the core’s own words for a press
that ran and failed, which is what a page draws when it has nothing else.
Two representations of one conversation cross, and they agree. A
settled turn arrives by more as the messages it ran as; the turn in
flight arrives as the same CLI frames, one update at a time - except
that a run of consecutive token appends inside one flush crosses as ONE
frame carrying what they grew by summed, where a page’s copy of the same
turn carries the CLI’s own several. A client folds both with one rule of
its own - how a run of tool calls groups, the labels it draws and the
tail it shows - and the two meet with nothing to reconcile about their
shape, because the frames are the same shape on either side. The one thing
the server keeps is the turn boundary where a page has one - and, below the
window’s floor, the call a result belongs to, so a page never opens on a
result whose call it does not carry; where a turn BEGINS is a fact about the
session, and how its work is drawn is not. Neither view is short of a token for the fold:
both draw a turn’s estimate as the sum of the deltas, so the two agree on
the number where they differ in frames. What can differ is the id, and
only for the forged frame below.
Not every frame on the stream is the CLI’s, and for the forged kind the
id is the tell. A prompt handed to claude on stdin is not echoed back,
so forge forges the user turn itself and sends it as a chat_appended like
any other frame: a delivery (a cron fire, a Gotify notification, a Slack
message, a peer comm), forge’s own machinery (a worker kick, an
auto-continue, a board chase, a board notice), and a reader’s own send,
which without it no viewer but the sender would ever see. The folded frame
is the other kind, and its id is the CLI’s: it is the last arrival’s,
because the one frame stands for the run the flush carried.
A prompt frame carries no origin since v27. The field said whether
the core’s own input handler or a view submitted the words, and it is gone
with the dispatch-time row it described: the row is drawn when the CLI
starts the prompt, so no frame needs it. A client older than v27 reads the
field as absent; what it draws for a dispatch is that client’s own rule,
unchanged by the missing field.
And it carries a source, the same label the queued row wears: you
for a person’s send, and the delivery’s or the machinery’s own kind
otherwise. A frame’s shape cannot say this - forge’s machinery forges prose
as the reader’s own row on the frame a person’s send uses - so it is the
one field that tells a reader’s words from forge’s when a client decides
whether new words should bring a scrolled-up reader back to the foot.
A forged turn carries the prompt’s own uuid, the id the prompt was
dispatched under. The CLI stamps the prompt’s id into the transcript it
persists - on the user row of a prompt that started a turn, and inside the
queued_command block’s source_uuid when a mid-turn prompt is delivered
as an attachment - so the frame and the page’s later copy of the same
message agree on it, and a client reconciles the two by id. It is also what
lets a view hold the forged row while the prompt waits in the pile: the
command_lifecycle frames carry the same id.
The server’s fold and the client’s are two readings of one conversation. The server folds a conversation into where its turns sit and the task endings it holds, which is what its pages are cut on and what a carried ending rides; the client’s chat makes its own calls over the same frames - its own unit fold, its own lifecycle carving, its own status aggregation. The drawing is the client’s, and the client’s answer is the one on screen.
What is not here
- Anything drawn from the diff. The changed files and their raw hunks
cross, bounded and flagged (the session table’s
diffrow); what does not is any rendering of them - glyphs, colours, folding, a tree, highlighting. The two heavier reads a review surface wants are here now: one commit’s own diff (commit_diff, asked for on demand rather than carried on the record) and one file’s whole content at a revision (file_content, above, for a between-hunk expansion). What is still not here is a diff against an arbitrary base. - The rest of the extensions surface. The inventory, the PATH checks,
the last run and six actions cross (
extensions, above: install, one update, update-all, a check, marketplace add and marketplace repair). What does not is the per-plugin details overlay and the verbs it holds - enable, disable, uninstall, roll back, marketplace remove and update - and the always-on token cost a plugin charges. Filed as its own piece of work (the details overlay and the rest of the plugin verbs). - The emoji set. Which shortcodes exist is the typeahead’s own business, so a client carries its own set rather than being handed one.
- forge’s own command table. The commands forge handles itself, and
what each one does, live on the server:
forge_commandsis a read on the view surface, and both views that exist take the table from there rather than carrying a copy. It is not a wire subject, so a client has to know the set it is offering - and the names it offers on top of that are the CLI’s, fromslash_commands. A client that hardcodes its own copy should expect it to fall behind the core’s command enum, because the two are the same list and only one of them is generated from the code. - The CLI’s own surfaces.
slash_commandscarries the names the CLI advertises,/configamong them. Some of those names open a dialog the CLI itself draws; the name crosses and the surface does not, so a client that offers one is offering a command whose UI it cannot show. - Any rendering. Glyphs, colours, weights, spacing, the order of a list and the label a row is spelled with are the client’s. The test is whether removing a thing changes what the data IS or only how it is DRAWN.
- A shipped client. The client under
client/speaks this socket, and it is what this page is for. It is a client rather than an instrument, and it runs fromjust client-devrather than from an installed bundle, so what is missing is a released one rather than a consumer. The instruments are three, and they are not equal:forge-protocol-clientandforge-fold-cost, both undercrates/forge-server/src/bin/and neither installed nor shipped - the first by a normal build, the second only with--features testing, because it serves a fixture fleet - and the integration tests that open real clients against a server they start themselves.forge-fold-costalso measures: it prices one request inpsCPU time against an idle arm, on a server over a real transcript, and every arm prints the bytes and turns it moved so a reader can tell an arm that stopped seeing the conversation from one that worked.
Today
The forge binary is the server: it boots the core and binds the
socket, so a running forge serves one. The client under client/ is
what reads it.