Protocol Reference¶
Protocol 1 is craze's control socket: a per-session Unix socket that speaks
JSON-RPC 2.0, one JSON object per line. It is what craze
bridge exposes to SSH clients (shed's craze lane) and
what craze attach speaks to run a second full TUI on a session someone else
started.
The socket is local, same-uid only: the server checks the connecting
process's uid before it reads a byte, and refuses anyone else's. There is no
authentication in protocol 1 — hello's auth field is reserved for scopes,
which arrive at S6 — and no encryption, because nothing crosses the machine
boundary yet.
An endpoint is one of two kinds. A host is one process serving one
session — by default a detached craze serve the
ordinary craze spawned, or, with detaching off, the TUI's own process. The
hub (S4b) is the machine's one place that knows every host: it lists
them all (The hub's roster) and splices a client through
to the one it asks for (The hub splice). It never
multiplexes sessions on one connection, and it never serves a session's
methods itself: those are always the host's. hello's endpoint.kind says
which one answered.
This page documents protocol 1 as internal/protocol, internal/control,
internal/remote and internal/fakehost build it. Where this page and a
craze client disagree, the code is right — file an issue.
Framing¶
Every message is a UTF-8 JSON object on its own line, terminated by \n (a
trailing \r before it is tolerated on input). There is no length prefix and
no batching: a JSON array at the top level is refused as an invalid request.
| direction | limit | over the limit |
|---|---|---|
| client → host (inbound) | 4 MiB | discarded up to the next newline; answered -32600 with id: null. The connection stays open |
| host → client (outbound) | 16 MiB | never sent: a reply that would exceed it is replaced by failed, reason response_too_large (never truncated silently) |
A client must be able to read a 16 MiB line; hello's result carries both
limits (limits.inboundLine, limits.outboundLine) so a client never has to
hard-code them. Everything a host writes is bounded under the outbound limit
by construction:
- an event's body is at most
MaxRecordBytes(8 MiB) plus its envelope; - a snapshot is at most the request's
snapshotBytes, itself capped at 8 MiB; asks.listreturns summaries only (id,kind,label,openedAt); a full ask (asks.get) caps each body string at 256 KiB and setstruncatedwhen it cut one;session.state's queue rows are capped at 32 KiB each, and its settings carry only the provider's own (bounded) config catalog and, for a native session, the fixed-size usage section;- an
asidenotification carries at most 4 KiB of text.
A host tolerates a final line at end-of-file with no trailing \n (a lone
trailing \r is stripped): a peer that half-closes right after an
unterminated object still gets its answer. Every line a host itself writes
ends in \n.
The envelope¶
A request:
idis the client's own (a number or a string), echoed back verbatim, and unique among that connection's requests in flight. It is not a command id (below) —idaddresses one JSON-RPC round trip;commandIdaddresses an idempotent action that can outlive several of them.paramsis always an object; a method that takes nothing may send{}or omit it.
A reply carries exactly one of result and error:
{"jsonrpc":"2.0","id":"2","result":{"...": "..."}}
{"jsonrpc":"2.0","id":"2","error":{"code":-32000,"message":"...","data":{"code":"not_accepting","reason":"not_accepting"}}}
id is null only when the request's own id could not be read (a line that
was not JSON, or one too long to parse).
A notification has no id:
Clients send no notifications in protocol 1.
Ordering and limits¶
A host answers requests as each finishes, not in the order they were
sent, so a client matches a reply to its request by id. It admits at most 16
requests per connection: a request keeps its slot until its reply has been
written to the socket, and the host stops reading the connection while 16
are admitted and unwritten, so a peer that sends and never reads stalls at
16. Across all of a host's connections at most 64 mutating commands run at
once; one past that is refused unavailable, reason busy, and is not run
and not stored, so the client resends the same commandId.
The hub answers in order: it reads a line, answers it and only then reads
the next, on each connection. A request that takes long — a
session.create or a session.reopen,
which hold their connection until they answer — delays every later request on
that connection, so a client sends each on a connection of its own.
Tolerant inbound, strict outbound¶
- A host refuses a params field it does not know:
-32602, reasonunknown_field, naming the field. A newer client sends an optional param only afterhellohas advertised the capability it depends on. hellois the one tolerant method (seehello): a host ignores any field ofhello's params it does not know, at any depth, becausehellois where two versions meet.- A client ignores result and notification fields, event kinds and notification methods it does not know. A host's results and notifications are otherwise exactly what protocol 1 says — nothing extra rides along "just in case".
Rejected: silently ignoring an unknown params field on every method. A swallowed option can make a client believe it took effect (shed's own rule: "a swallowed allow becoming a no-op is worse than an error").
hello¶
hello opens the connection. A method protocol 1 does not know is refused
-32601, reason unknown_method, whether or not hello has run yet — that
check comes first. Any method protocol 1 does know, sent before hello, is
refused bad_request, reason hello_required. A second hello on a
connection is refused bad_request, reason bad_request (a connection is
bound once), by a host and by a hub alike.
A host that is not yet serving a session — it has started, but its engine is
not up — answers hello unavailable, reason not_ready ("the host is not
serving a session yet; retry"); a client retries it a moment later.
{"jsonrpc":"2.0","id":"1","method":"hello","params":{"protocols":[1],"client":{"kind":"test","name":"fakehost-wire"}}}
| params | |
|---|---|
protocols |
every protocol version the client speaks, e.g. [1] |
client.kind |
required, e.g. "tui", "shed" |
client.name, client.version |
optional, free-form; the server logs nothing a client sends — only the client id it assigns and the resume outcome |
client.capabilities |
the client's own capability set; empty in protocol 1 |
resume |
{clientId, token} to take back a client id this host minted (see Client ids and resume) |
auth |
reserved for S6's scopes; absent or null |
via |
reserved for a hub or relay that originates the connection itself (S4/S7); absent or null |
The host answers with the highest protocol version it shares with the
client. If none is shared, hello is refused bad_request, reason
protocol_version, with data.result.supported naming what the host does
speak:
{"jsonrpc":"2.0","id":"1","error":{"code":-32000,"message":"...","data":{"code":"bad_request","reason":"protocol_version","result":{"supported":[1]}}}}
A host's result¶
{"jsonrpc":"2.0","id":"1","result":{
"protocol":1,
"endpoint":{"kind":"host","hostId":"0123456789ab","crazeVersion":"0.0.0-fakehost","pid":4242},
"clientId":"c-1",
"token":"52fdfc072182654f163f5f0f9a621d72",
"resumed":false,
"capabilities":{"rosterSubscribe":false,"sessionCreate":false,"multiplex":false,"connect":false,"snapshot":true,"attachWhenNow":true},
"codecs":{"event":1,"snapshot":1},
"limits":{"inboundLine":4194304,"outboundLine":16777216},
"retryHorizon":{"commands":1024,"ageMs":600000}
}}
endpoint.kind tells the two results apart: a host's result always
carries clientId, token, resumed (false included — it is never
omitted) and retryHorizon. A hub's result carries none of those four —
a hub mints no client ids and keeps no receipts of its own — only protocol,
endpoint, capabilities, codecs and limits. Client ids, tokens and
receipts are always the host's, even through a hub's splice, so a client
reads those four fields only from a host's hello.
capabilities here is the connection's capability set (hello's own),
distinct from the session's (the attach reply's — see
Capabilities): whether the endpoint can multiplex several
sessions, subscribe to the roster, create a session, or splice a client
through (all false on a host in protocol 1), plus whether it serves
session.snapshot and an attach with when: "now" (both true on a host).
The hub's result¶
{"jsonrpc":"2.0","id":"1","result":{
"protocol":1,
"endpoint":{"kind":"hub","hostId":"0a1b2c3d4e5f","crazeVersion":"0.4.0","pid":5150},
"capabilities":{"rosterSubscribe":true,"sessionCreate":true,"multiplex":false,"connect":true,"snapshot":false,"attachWhenNow":false,"createOptions":true,"createTitle":true,"reopen":true},
"codecs":{"event":1,"snapshot":1},
"limits":{"inboundLine":4194304,"outboundLine":16777216}
}}
endpoint.hostId is the hub's own id — twelve hex digits, minted per hub
process — and the roster's epoch: a client that sees it
change is talking to a new hub, and reseeds. The hub serves the roster
subscription (rosterSubscribe), session creation
(sessionCreate) and the splice (connect); snapshot and
attachWhenNow are false because a session's methods are its host's,
reached through the splice; multiplex is false because one connection
carries the roster or one spliced session, never several. sessionCreate is
true only on a hub that serves session.create — false on a hub from
before it existed: a client checks it, never the hub's version.
A hub closes a connection that has not said hello within 5 seconds,
unanswered. A hub that has decided to close answers hello unavailable,
reason closing ("the hub is closing") and ends the connection, which is the
answer a client takes for no hub at all. A hub keeps at most 256 connections;
one past that is closed as it is accepted, unanswered.
createOptions says the hub serves
sessions.createOptions, what a create can start.
Unlike the six before it, it is omitted when false: written only by a
hub that serves the method, so a host's hello, an older hub's and that of a
hub given no answer to it are byte for byte what they were. Absent means an
older hub (or one that cannot answer): a client makes no call. A client built
before it reads this hello as it always has, because a client ignores a
result member it does not know (above).
createTitle says the hub's session.create takes a
title, the session's name from its start. It is omitted when false in the
same way: absent on a host and on a hub from before it, which refuses a
create naming a title unknown_field. A client with a title to set checks
it first — craze new --title asks the hub for it and reports an older hub
as one that "cannot set a title at create", never sending the title there and
never creating the session untitled.
reopen says the hub serves both sessions.saved and
session.reopen: a client's whole resume of a saved
session — the list, and bringing one back. It is omitted when false in the
same way: absent on a host, on a hub from before the two methods, and on one
given no answer to either. A client hides its resume entry without it, and
craze new --load reports such a hub as one that "cannot reopen a saved
session".
Client ids and resume¶
hello mints a client id (c-1, c-2, …) for this connection and hands
back a resume token: 128 random bits, hex, known only to this host and
never logged. A later hello{resume: {clientId, token}} on a new connection
to the same host takes the id back:
| the resume | answer |
|---|---|
| the token matches, names this client, same engine incarnation | resumed: true, the same clientId |
| the token names a different client | bad_request, reason bad_token |
| the token is from a previous engine incarnation, or unknown to this host (another host's, retired, expired) | resumed: false, a fresh clientId |
The token is stable across resumes — it is never rotated. Rotating it
would make "of two simultaneous resumes the second one wins" (below)
impossible, since the second presents the token the first replaced, and a
client that crashed between the reply and saving a new token would lose its
identity for good. A token is valid only on the host that issued it
(hello's endpoint.hostId is what a client checks a resume against, not
just the first host it ever dialed).
A client id is retired — and can never be resumed again — once it has been
released (its connection is gone) for at least the retry horizon (below) and
the host holds no receipt of it, open or running. A binding with no
connection for twice that long is dropped even if the client id itself
stays retirable a while longer, so a host does not grow one entry per client
that never comes back. A resend is never safe unless the host's hello
answered resumed: true for this exact client id, on this exact host: any
other answer means the outcome of a command sent before the disconnect is
unknown, and a client must re-read state (session.state) rather than
resend it under a fresh id (which risks running it twice).
retryHorizon is the size and age bound of the host's command-id table,
which holds one stored answer per command id that actually reached the
engine — every code except unavailable, not_accepting, in_progress and
stale_model (protocol.Retry), which the engine never stores because
nothing ran under that id. A resend of one of those four is evaluated fresh
every time, not answered from the table. A resend of a command whose answer
is stored, within the horizon, gets that stored answer back; past it,
unknown_command.
Sessions and the roster¶
sessions.list returns every session this endpoint knows. On a host, that
is its one session (below); on the hub, every host on the machine — the
hub's roster, whose rows wrap these.
| result | |
|---|---|
epoch |
on a host, the host's own id — a new host process is a new epoch, so a client that sees the epoch change reseeds from scratch |
cursor |
the log's committed seq when the rows were read |
sessions[] |
one row per session (below) |
A row is the session info document plus:
| field | |
|---|---|
title |
|
activity |
starting, replaying, idle, working, error or closing |
foreignTurn |
the agent is running a turn of its own — grok's interjection fallback, or a native wake (a background sub-agent's or shell job's result) — during which activity reads idle |
pendingAsks |
how many asks are open |
headAsk |
{id, kind, label, summary?}, the first open ask (absent when none is); summary is a row fact |
lastTurn |
how the last turn ended — below; absent while a turn runs, before any has ended, and from an older host |
doing, lastReply, since, startFailed, startErr, prompted, model |
the row facts, where the session capability rowFacts is true |
attached |
where the session capability presence is true: how many clients are attached as the row is read — the count a presence notification carries; absent is 0 on such a host, and unknown on any other |
activity/foreignTurn answer "is it running"; pendingAsks/headAsk
answer "is it blocked on me" — two independent signals, because an agent can
be idle and waiting on a permission at the same time.
The row facts¶
A host whose session capability rowFacts is true — every craze from plan
030's session list on, detached or TUI-hosted — puts on its row what a list of
every session needs to say what each one wants without attaching to any. Each
is omitted when unset, so on such a host an absent one is its zero ("",
false) — except model, which a rowFacts host from before plan 035 does
not send, so an absent model is unknown on any host; on a host without
rowFacts (an older one) none is there and a client reads the row as it was.
| field | |
|---|---|
headAsk.summary |
what the head ask is about: a permission's command or tool title, a question's first question (its title when it asks none), a plan's name. Never on session.state's head ask |
doing |
what a working session is doing: the title of the most recently started tool of the running turn that is still running (pending or in progress; its name when it has no title), else Responding while the agent's text streams, else Thinking. Present only while a turn — craze's own or a foreign one — is working |
lastReply |
the first line of the last completed assistant message |
since |
when the row entered its current state, on the host's clock, UTC — the first of these that holds: needs you (pendingAsks > 0), failed (startFailed, or lastTurn.outcome failed — or activity error with neither lastTurn nor foreignTurn: a failure whose ending is still on its way), working (activity starting, replaying, working or closing, or foreignTurn), idle. A turn that follows another from the queue in the same settlement keeps the first one's |
startFailed, startErr |
the session's start failed, and the first line of its error |
prompted |
a turn has been started at all |
model |
the session's current model id, exactly as session.state's settings.model gives it and a session.set of kind model names it: an identifier, not display text, and never cut, so it has no length bound. Absent means unknown |
Every string but model is one line — the first non-blank one, trimmed, tabs
expanded, control characters dropped — of at most 200 terminal cells, an
ellipsis ending one that was cut. The host computes them from its engine's own
transcript model and ask registry when the row is asked for: like the rest
of the row, a read, not a cut through the stream.
The last turn¶
lastTurn — on a sessions.list row and in session.state, never in a
snapshot, whose codec stays at version 1 — is {outcome, err?, endedAt,
turnId}: how the session's most recent turn ended, whether it was craze's
own or one the agent ran itself (a foreign turn).
| field | |
|---|---|
outcome |
done (ran to its end — and every foreign turn, whose closing bracket carries no outcome), failed (its ending carried an error), or cancelled (a cancel, a prompt withdrawn before it was sent, or the session's close) |
err |
a failed turn's error text; absent otherwise |
endedAt |
the ending event's time, UTC |
turnId |
the ended turn's id as the stream names it: turn.id on its started/ended events for a turn of craze's own, the foreign_turn bracket's id for the agent's own |
It is kept from the stream's own turn events, in commit order, and a turn
starting supersedes it: from a turn's started (or a foreign turn's
running bracket) it is absent until that turn ends. So a lastTurn that is
present always names the latest turn, with nothing run since — a list shows
"failed" from it without asking whether a turn came after. A client that
reads it after a restore (a reconnect's fresh snapshot carries no ending)
applies it only if it has folded no later turn since, by turnId.
sessions.subscribe and session.connect are unsupported on a host
(reasons roster_unsupported and hub_only): both belong to the hub.
The hub's roster¶
The hub learns hosts from the machine's registry (see The
registry) and reads each one's own sessions.list; it lists
every live host that has a craze session id and is not stopping (its entry's
stopping, below), keyed by host id, in host-id order. It polls as the
session list does — a round at most every second that
reads the registry (sweeping hosts that died, so a crashed host's row leaves
within one round) and asks every host, eight at a time, 500 ms each, over a
connection it keeps — but only while someone wants the roster: a
subscription, or a sessions.list or sessions.subscribe waiting for its
answer. Its poll connections say hello as client kind hub.
A sessions.list or sessions.subscribe answers once the poll's current
round has heard from every host it lists — an answer, or a failure — and at
most a second after it was asked: a host not heard from by then is listed
connecting. A hub that was not polling starts a round for it, so its
answer is what the hosts say now. A hub that cannot read the registry — its
last read failed, and none has succeeded since the answer's round began —
answers both unavailable, reason host_unreachable (as session.connect
does then), never an empty roster. Its sessions.list result:
| result | |
|---|---|
epoch |
the hub's own id (its hello's endpoint.hostId): a new hub process is a new epoch, and a client reseeds |
cursor |
the hub's roster sequence, an unsigned 64-bit integer: bumped by every change to a row's content (an approximate flip included); it only increases within an epoch, and may jump |
sessions[] |
one roster row per host (below) |
truncated |
true when the roster was cut at 512 rows; absent otherwise |
sessions.list's result is a host's or the hub's — the schema says
anyOf, since an empty roster is both — and a client knows which from the
endpoint it said hello to. A roster row:
| field | |
|---|---|
hostId |
the host's id, twelve lowercase hex digits: the row's key, stable from the host's first appearance in the registry |
sessionId |
the host's craze session id, a token of at most 128 characters of [A-Za-z0-9._-]: what session.connect and craze bridge --session take |
host |
what the hub knows about the host beside its row: pid, protocol, provider (its name, at most 64 characters), workspace (at most 4096), startedAt and ready from its registry entry, and crazeVersion (at most 128) from its hello ("" until the hub's first) |
status |
connecting (not yet attempted), reachable (the last attempt read its row) or unreachable (the last attempt failed) |
approximate |
the row is not fresh — its last successful read is more than 3 s old, or the host is not reachable — or the hub cut or dropped something to keep it within its bounds (below) |
row |
the host's own sessions.list row, as the JSON value the host sent — not re-encoded — so a newer host's members this build does not know, at any depth, pass through the hub untouched; absent until the hub's first read, and while the host is unreachable the last one read |
No socket path crosses the wire: a local client that needs a host's socket
resolves it from the registry by hostId. A row's row is a host's of
whatever build, so the schema describes it as any object (forwardedRow) —
a host's own row is still held to this build's strict sessionRow — and a
client reads it tolerantly — the members it knows, the rest ignored — its
capabilities.rowFacts saying what it holds, exactly as when the client
polls the host itself.
The roster's bounds keep any roster one line, whatever the hosts behind
it send: at most 512 roster rows (truncated beyond, in host-id order), and
each roster row at most 32,000 bytes as JSON. The hub keeps every roster row
within them, in this order, and marks each one it changes approximate: a
host string over its bound is cut to it; a host row over 16 KiB as JSON is
dropped (row absent); and a roster row still over 32,000 bytes — only a
large row beside host strings that JSON escapes can be — has its row
dropped, which always suffices. With a request id of at most 1 KiB, the
hub's sessions.list and sessions.subscribe replies and every roster
notification are then under the 16 MiB line limit (internal/protocol's
TestAFullRosterFitsOneLine builds the largest). A host whose ids are not
of the forms above is not listed. The numbers are internal/protocol's
Roster* constants, which the schema states too.
sessions.subscribe ({}) is the roster as a subscription, one per
connection (a second is bad_request, reason already_subscribed). The
reply is the roster at a cursor and the subscription's id, written before any
of its notifications:
{"jsonrpc":"2.0","id":"2","result":{"subscription":"r-1","epoch":"0a1b2c3d4e5f","cursor":9,"sessions":[...]}}
then roster notifications, each the net change since the cursor the
subscriber last had — rows added or changed in upserts, each at its
latest; the host ids of rows the subscriber holds that are gone in
removes; a host in at most one of the two, and a host that came and went
between two notifications in neither — flushed at most every 250 ms, in
host-id order within each list:
{"jsonrpc":"2.0","method":"roster","params":{"subscription":"r-1","epoch":"0a1b2c3d4e5f","cursor":12,"upserts":[...],"removes":["0190ab12cd36"]}}
A subscription ends with the connection, or with a reset on the
subscription: slow_consumer — a notification's write blocked for 10 s; the
rest of that line is written, then the reset, and the connection stays —
omitted — the roster's completeness changed: it went over 512 rows, or
back to 512 or fewer, which a roster notification cannot say (truncated
is the reply's alone); the connection stays — or hub_closing — the hub is
shutting down: it writes the reset within 5 s and closes the connection. A
reset the subscriber does not read within 10 s closes the connection. In
each case the client subscribes again: after slow_consumer or omitted it
may on the same connection, and reads truncated from the new reply; after
hub_closing, on a new one, the new hub's epoch reseeding it.
A stopped session leaves at once. A host marks its registry entry
stopping before anything of its stop can be seen: before a
session.stop's receipt is sent, first thing when a signal,
its idle exit or a lost socket stops it, and before a TUI-hosted session's
quit closes its engine. A round of the hub's poll whose registry read
follows the mark does not list the host, and nor does a round whose attempt
at the host follows it and is answered, or fails where the host was
reachable: the hub reads the host's entry again before it takes such a
result, and drops a host whose entry is gone or says stopping, for good.
So nothing the hub sees of the host after its mark is published as the
host's row, and once a round of its poll has read the mark, the host is
removed and not listed again. Both are best effort, as the mark is: a mark
the host cannot write (its failure is logged, and the stop goes on), or an
entry the hub cannot read back (it keeps the host as it stood), leaves the
host listed until its entry is gone. The residual is what the hub had seen
before the mark: roster notifications composed from that — one already
being written to a subscriber, and changes already waiting for the ones
after it — may still reach the subscriber after the receipt, and may carry
the host's row, before the notification that removes it. No time bound is
promised for them: a subscriber that reads slowly receives them late (a
notification's write may block for up to 10 s before the subscription is
reset). Nor is any order promised between lines on different connections —
a subscriber reads the roster on its own connection, and the receipt
arrives on the stopping client's — and none ever was. A host from a craze
before the mark writes none: its row can be upserted, closing or
unreachable, for a second or more after its receipt, until its host has
exited. The session list's own poll and craze ps --no-hub keep the same
promise.
On one connection, the lines that carry the roster's cursor — roster
notifications and sessions.list replies — are written in the order their
roster was taken: a cursor never goes back from one line to the next. A
sessions.list reply on a connection that holds a subscription is that
subscription's roster from then on: its next notification is the change from
the rows the reply listed. A sessions.subscribe whose reply cannot be
written — a request id long enough to take it over the line limit, answered
failed, reason response_too_large — subscribes to nothing, and the
connection may subscribe again.
On the hub, every session-scoped method but session.connect is
unsupported, reason host_only: a session's methods are its host's,
reached through the splice.
Methods¶
Every session-scoped method carries sessionId, the durable craze session
id, at params.sessionId (hello and sessions.* do not). Every mutating
one also carries commandId: a canonical positive decimal string, counted
from 1 per client, never reused. Resending a mutating method under the
same commandId is how a client asks "did that happen?" — see
Errors and retry.
The hub serves hello, sessions.list, sessions.subscribe,
session.connect, session.create, sessions.createOptions,
sessions.saved and session.reopen; every other session-scoped method sent
to it is refused unsupported, reason host_only. session.create is
neither session-scoped (its session does not exist until it answers) nor
mutating (the hub mints no client ids and keeps no command receipts: its
idempotency is its own requestId). Nor is session.reopen, which names a
saved session no host serves until it answers, and whose retry safety is the
session's claim. Nor are sessions.createOptions and sessions.saved,
endpoint-level reads.
| method | mutating | params (beyond sessionId/commandId) |
result | notes |
|---|---|---|---|---|
hello |
see hello |
see hello |
tolerant; before any other method; a host's result or the hub's | |
sessions.list |
— | epoch, cursor, sessions[]; the hub's truncated? |
a host's one row, or the hub's roster of roster rows | |
sessions.subscribe |
— | subscription, epoch, cursor, sessions[], truncated? |
the hub's: then roster notifications (above); a second on one connection is bad_request, reason already_subscribed; unsupported on a host, reason roster_unsupported |
|
session.connect |
sessionId |
{} |
the hub's splice: the connection's first request after hello, else bad_request, reason connect_not_first; unsupported on a host, reason hub_only |
|
session.attach |
cursor?, when?, budget? |
subscription, session, ready, after, snapshot?, reset? |
see Attach, resume and snapshots | |
session.detach |
subscription |
{} |
ends this connection's attachment; the reply is its terminal acknowledgement | |
session.state |
— | activity, foreignTurn, turn, waiting, sendNow?, queue[], pendingAsks, headAsk?, err, startFailed, prompted, cancelled, settings, lastTurn? |
a read, not a cut of the stream; lastTurn is the last turn's ending |
|
session.snapshot |
agentId?, budget? |
snapshot |
one bounded snapshot of the whole session with one sub-agent's entries budgeted first, no subscription | |
session.sync |
— | seq |
the reply barrier with no command (below) | |
session.prompt |
✓ | text?, fromRow?, mode (queue|send_now|interject) |
turn+text | queued | armed | {} |
mode: interject with fromRow is refused -32602, reason bad_request — interject takes text alone |
session.cancel |
✓ | turnId? |
outcome, turn, reported |
with no turnId, cancels the current turn — craze's own or the agent's, whichever holds the session when the host validates the call (see The foreign turn); a named turn that is not current is stale_turn |
session.disarm |
✓ | — | {} |
takes back an armed send-now, leaving its text queued |
session.queue.add |
✓ | text |
row |
|
session.queue.edit |
✓ | rowId, text, expectedVersion? |
{} |
expectedVersion is optional: given and stale, the edit is refused stale_version — the check-and-edit that stops two editors from silently overwriting each other; omitted, the edit is unconditional and can overwrite a concurrent edit |
session.queue.remove |
✓ | rowId |
row |
|
session.queue.clear |
✓ | — | removed[] |
every row removed, in queue order |
session.set |
✓ | setting{kind, id?, value, forModel?} |
value, rev |
rev is the seq of the state delta that carried the change, 0 if it could not be learned |
session.setTitle |
✓ | title |
{} |
the delta, not the reply, is how a client learns the title took |
session.subagent.cancel |
✓ | agentId |
{} |
stops one running sub-agent; the rest of the turn goes on; its outcome is the child's finished row, as an event |
session.stop |
✓ | — | {} |
served where the session capability stop is true (every craze serve), from any client: a receipt, not the stop's completion — see session.stop; a host whose stop is false (a TUI-hosted session, an older host) answers unsupported, reason stop_unsupported |
session.models.refresh |
nativeDir? |
status, revision, sameDir? |
served where the session capability modelsRefresh is true (a native session): the session takes up the models funded since it opened — see session.models.refresh; idempotent, so not mutating; a session whose modelsRefresh is absent (an ACP session) is refused unsupported, reason models_refresh_unsupported |
|
session.aside |
asideId, question, prior?[] |
text, model?, usage? |
served where the session capability aside is true (a native, grok or gx session): a side question answered from the session's context, its answer streamed as aside notifications to this connection and whole in the reply, nothing of it journaled or in the session's history — see session.aside; not mutating; a session whose aside is absent (cursor) is refused unsupported, reason aside_unsupported |
|
session.aside.cancel |
asideId |
{} |
cancels this connection's session.aside of that asideId, which then answers aborted, reason context; answers {} always, after that aside's reply when it found one — see session.aside.cancel; refused aside_unsupported where aside is absent |
|
asks.list |
— | asks[] (summaries: id, kind, label, openedAt) |
||
asks.get |
askId |
ask (the full record, body strings capped at 256 KiB) |
||
asks.answer |
✓ | askId, answer |
{} |
the first valid answer wins; an invalid one is bad_request, reason bad_answer, and leaves the ask open |
session.create |
cwd, prompt?, provider?, model?, effort?, fast?, permissionMode?, requestId?, title? (no sessionId, no commandId; title where hello says createTitle) |
session (a roster row), prompt, promptError? |
the hub's: a new session, answered once it has started — see session.create; unsupported on a host, reason hub_only |
|
sessions.createOptions |
— | providers[], defaultProvider?, recentDirs[] |
the hub's, where its hello says createOptions: true: what a create can start — see sessions.createOptions; a hub without it answers unsupported, reason unsupported; unsupported on a host, reason hub_only |
|
sessions.saved |
workspace?, limit?, subAgents? |
sessions[] (each a SavedSession), more |
the hub's, where its hello says reopen: true: the saved sessions — not running, each with the id that reopens it — see sessions.saved; a hub without it answers unsupported (reason unsupported, or unknown_method from a hub before it); unsupported on a host, reason hub_only |
|
session.reopen |
id (a sessions.saved id; no sessionId, no commandId) |
session (a roster row), joined |
the hub's, where its hello says reopen: true: a saved session ensured running — joined where a host serves it, loaded in a host the hub starts otherwise — answered once its start is done; see session.reopen; a hub without it answers unsupported (reason unsupported, or unknown_method from a hub before it); unsupported on a host, reason hub_only |
Deliberately not on the wire: GiveUp/GiveUpDrain (craze prompt's own
foreign-turn policy, no socket client's concern), Start/Close (a host's
own lifecycle), a bare Subscribe (attach subsumes it) and NewClientID
(hello does that).
The reply barrier¶
Every mutating method's reply except session.stop's receipt (queued before
anything the stop causes), and session.sync's own (it is the barrier with no
command) and session.models.refresh's, waits for the event barrier before it is queued: the
log's committed head at the moment of the call, forwarded to this
connection's attachment — or the attachment's terminal acknowledgement, if it
ends first — so a client is never handed a reply before the events the
command caused. seq (session.sync's result, and every state read's) is
that committed head.
What happens to a reply already admitted, but not yet past this barrier, differs by how the engine's turn at being "the session" ends:
- An ordinary end (the session closes on its own —
session.stop, the provider exiting, an error) drains normally: every admitted reply is still written, in order, once its barrier clears, and the connection closes only once nothing is owed. - An engine replacement (the host is handed a new session to serve,
S4/
session/load) is not drained. The connection becomes terminal-only from that instant: every ordinary line still queued — a handler's reply, an event, a plainreset— is dropped, and only the one terminal line survives (an attached connection'sreset{session_replaced}, or a claimed detach's{}). A handler still running when this happens replies to nobody; its client never resends it. A reconnect's freshhelloansweringresumed: falsemeans this exact command may have already run under the old identity, so resending it under a new one risks running it twice; the command instead resolves outcome-unknown (resume_lost), and the client re-reads state (session.state, the attach snapshot) to learn what actually happened (internal/remote/reconnect.go:354-373).
session.stop¶
Ends the session and its host — distinct from closing a connection or
detaching a view, neither of which ever stops a session. A host serves it
where its session capability stop is true; a client that finds stop:
false (or no answer but stop_unsupported) detaches instead, and the session
runs on where it is.
The reply {} is a receipt: the host has taken the stop, and the
session's end follows on the stream — its closing records (a running turn's
ended, stop reason closing; every open ask's closing) and then
reset{session_closed}, after which the host closes the connection. Before
the receipt is sent, the host has stopped admitting new attachments: from
then on a session.attach on any connection is unavailable, reason
closing, so a client that reads the receipt and attaches is never
half-attached to a session on its way out; an attachment that was already
made (pending, live or closing) goes on to the session's end like any other.
And before the receipt is sent the host has marked its registry entry
stopping (The registry), so the hub's roster lists it no
more (The hub's roster).
Any client may stop the session, and a stop while one is under way — from
another client, or the same commandId resent — is answered {} too and
joins the first: the host runs its stop once. A stop runs no command of the
engine's, so it is not in the receipts table: a resend is answered {}
again, never unknown_command, and a client that lost the receipt need not
resend it at all — it watches for the session's end.
session.models.refresh¶
A native session reloads its model table while it runs — a key saved, the
ChatGPT plan signed in to or out of, the plan's or the Anthropic account's
model list fetched again — and offers what is funded now, without the client
leaving the conversation. It does so on its own at every turn's start; this
method asks it to now, so an idle session picks a new key up at once. A
client sends it when it has just funded a model — after it saves a key, after
a sign-in has written the plan's model list — and when it is about to show
the list. Once it has reloaded, it also starts, in the background and on the
table it just read, each provider's model-list fetch that is due: the plan's
(by its age, or a list fetched for another client_version), and the
Anthropic account's for the key that now funds Anthropic (by its age, or a
list fetched with another key) — so a key the client just saved is the one
fetched with. A list a fetch brings is taken up by the session itself and
published the same way. In each session each provider's fetch runs one at a
time — a call while one is in flight joins it, and one for another Anthropic
key is run once it ends — and none starts within 30 seconds of one that
failed for the same account or key, so a client calling one session in a loop
costs an account at most one request per half minute. The backoff is the
session's own, not the account's: each session keeps its own, so several
sessions open at once can each make a request in the same half minute. The
call itself still reloads and answers.
It is served where the session capability modelsRefresh is true: every
native session, whoever hosts it. Where it is absent — every ACP session, and
every host from before the method — a client makes no call, and a newly
funded model is the next session's, as it always was; a host refuses the call
anyway unsupported, reason models_refresh_unsupported (one from before the
method, unknown_method), and craze's own client reads either as the status
unsupported.
| params | |
|---|---|
sessionId |
as every session-scoped method |
nativeDir |
optional: the craze native directory the client itself saves keys and sign-ins in — an absolute path, at most 4096 characters, else -32602, bad_request. Absent is none; present and empty is not a path, and refused the same, as the schema's pattern refuses it. Paths are not secrets |
| result | |
|---|---|
status |
applied — what the session reads its models from had changed, and it offers what that funds now; current — nothing had changed; pending — it had changed while a turn ran (or a load replayed), and the session takes it up as that ends; unsupported — the session cannot take up models while it runs; failed — the files could not be read as a model table, and the list is as it was (the host journals why, value-free) |
revision |
the catalog section's revision of the list the session offers after the call: 0 for the list it started with; a pending refresh's is the list still offered |
sameDir |
present when nativeDir was: whether it is the directory the session reads its models from — compared absolute and cleaned and, where both exist, by the file system's own identity, so a symlink to it, or a spelling a case-insensitive volume folds to it, is the same directory. false is the home mismatch — the client and the session's host run with different CRAZE_HOMEs, so a key the client saved is not one the session can see |
It is not mutating: no commandId, no receipt, and a resend — of a call
whose answer was lost, or of one already answered — only asks again (a second
call with nothing changed answers current). Its reply waits for the reply
barrier all the same, so an applied answer follows, on
the calling connection, the catalog delta that carried the new list: a
client that reads applied has already been handed what /model offers now.
Every other attached client receives the same delta. The refresh waits only on
the session's local file reads — never the network, never a turn — and is
answered not_accepting before the session has started and once it has
closed.
{"jsonrpc":"2.0","id":"4","method":"session.models.refresh","params":{"sessionId":"session-fake-1","nativeDir":"/home/other/.craze/native"}}
{"jsonrpc":"2.0","method":"event","params":{"subscription":"s-1","seq":2,"event":{"type":"meta","state":{"catalog":{"models":[{"id":"grok","name":"Grok"},{"id":"fast","name":"Fast"},{"id":"fireworks/kimi-k3","name":"Kimi K3 (Fireworks)","recent":1}],"revision":1}},"at":"2026-01-01T00:00:00Z"}}}
{"jsonrpc":"2.0","id":"4","result":{"status":"applied","revision":1,"sameDir":false}}
(From the fake host's 24-models-refresh fixture.)
session.aside¶
A side question — the /btw of craze's TUI — asked of the session's agent
about the session's work, without interrupting a running turn and without
touching the session: nothing of it is journaled, appended to the session's
history, or published on its stream, and no other client sees it. The agent
answers from the session's context as it stands (a native session's stored
history; grok's and gx's own), with no tools. A side conversation is the
client's to keep: a follow-up carries the earlier pairs in prior, and the
host remembers nothing between calls.
It is served where the session capability aside is true: a native session
(it streams its answer) and a grok or gx session (answered in one piece,
through grok's own x.ai/btw). Where it is absent — cursor, and every host
from before the method — a client offers no side questions and makes no
call; a host refuses the call anyway unsupported, reason
aside_unsupported (one from before the method, unknown_method). A grok
that turns out not to serve it (an older grok) is refused the same reason,
with a message saying to update grok.
| params | |
|---|---|
sessionId |
as every session-scoped method |
asideId |
the client's own id for the call, with session.create's requestId rules: 1 to 64 of [A-Za-z0-9._-], else -32602, bad_request. It must be unique among this connection's asides in flight — one an aside of this connection still holds is refused bad_request — and may be reused once that aside has answered. The aside notifications carry it, and session.aside.cancel names it |
question |
the question: not blank (bad_request), at most 32 KiB (AsideQuestionMax) |
prior |
optional: the side conversation so far, oldest first, each {question, answer} — at most 20 pairs (AsidePriorMax; more is bad_request), each question at most 32 KiB and each answer at most 64 KiB (AsideAnswerMax) |
The bounds are bytes of UTF-8, which the host holds; the schema's
maxLength (characters) carries the same numbers and is never the stricter.
A question or answer over its bound is refused text_too_long. A request at
every bound is about 1.9 MiB before JSON escaping, inside the 4 MiB inbound
line; escaping can grow it, so a client checks its encoded request against
the line limit (less some headroom) and drops the oldest prior pairs until
it fits. craze's TUI cuts an answer it resends to 64 KiB, keeping its head.
| result | |
|---|---|
text |
the whole answer. It is authoritative: a client that ignored every aside notification still has it |
model |
the display name of the model that answered; absent when unknown |
usage |
{input, output, cacheRead}, the call's token counts, where the provider reports them (a native session); absent for grok and gx |
While the answer is produced, the host sends the asking connection — and
only it — aside notifications carrying its pieces, every one
before the reply. It is not mutating: no commandId, no receipt, no
reply barrier (it publishes nothing), and it is never
resent — it costs a model call, so a client whose connection went after it
sent one does not send it again (craze's client answers that loss
ErrConnectionLost); the connection's close has cancelled it on the host.
Refusals and endings, beyond the parameters':
not_accepting— the session has not started, has closed, or is closing; nothing was asked.unavailable, reasonaside_busy— two side questions are already in flight on the session, from every client together; nothing was asked. Ask again once one has answered.aborted, reasoncontext— the aside was cancelled:session.aside.cancel, its connection's close (a hard close or a read error; a client's half-close lets a request in flight finish, so the answer is still written), the session's end, or the host's own bound of 10 minutes on one aside.failed— the model or the agent failed, or answered nothing (a native model that called a tool instead of answering), with a readable message.
An aside holds one of the connection's 16 request slots while it runs, as
every request does: asides finished but not yet written, beside other
requests in flight, can fill them all, and a session.aside.cancel behind
them then waits to be read (the connection's close still cancels). Asides do
not count toward the session's activity: they hold no turn, fence no close
and keep no idle host alive. On grok and gx a cancel abandons the wait and
frees the session's slot while grok may still be generating the answer, in
its own process.
{"jsonrpc":"2.0","id":"3","method":"session.aside","params":{"sessionId":"session-fake-1","asideId":"a-1","question":"what is left to do?"}}
{"jsonrpc":"2.0","method":"aside","params":{"asideId":"a-1","seq":1,"text":"You asked: "}}
{"jsonrpc":"2.0","method":"aside","params":{"asideId":"a-1","seq":2,"text":"what is left to do?"}}
{"jsonrpc":"2.0","method":"aside","params":{"asideId":"a-1","seq":3,"text":" (side question 1)."}}
{"jsonrpc":"2.0","method":"aside","params":{"asideId":"a-1","seq":4,"text":" The session goes on."}}
{"jsonrpc":"2.0","id":"3","result":{"text":"You asked: what is left to do? (side question 1). The session goes on.","model":"Grok","usage":{"input":1000,"output":4,"cacheRead":800}}}
(From the fake host's 27-aside-served fixture, whose scripted answer names
the question.)
session.aside.cancel¶
Cancels a side question of the same connection: {sessionId, asideId},
answered {} always. When it finds that aside in flight it cancels it, and
answers once the aside's own reply — aborted, reason context — is queued
(waiting at most 5 seconds), so a client reads the aside's ending and then
the cancel's {}, in that order. An asideId unknown, already answered, or
another connection's is answered {} at once and cancels nothing; an
asideId of the wrong shape is -32602, bad_request. A cancel sent right
behind its aside always finds it: the host reserves the aside's id as it
reads the aside's line, before the aside starts. It is served, and refused
aside_unsupported, exactly where session.aside is.
{"jsonrpc":"2.0","id":"3","method":"session.aside.cancel","params":{"sessionId":"session-fake-1","asideId":"a-1"}}
{"jsonrpc":"2.0","id":"2","error":{"code":-32000,"message":"context canceled","data":{"code":"aborted","reason":"context"}}}
{"jsonrpc":"2.0","id":"3","result":{}}
(From the fake host's 28-aside-cancel fixture: request 2 is the aside.)
session.snapshot¶
Returns one bounded snapshot with no subscription — the same codec and
windowing an attach's own snapshot uses (newest entries first, up to the
budget, with an omitted-entry ledger telling a client what it is missing).
The result is always the whole snapshot — the main transcript and every
sub-agent's, in the codec an attach's snapshot uses, restorable as any other.
agentId changes only who gets the room first: the mandatory sections, then
the main transcript's newest entry (so the snapshot restores exactly as any
other, and is refused for size exactly when the same snapshot without
agentId would be), then the named sub-agent's entries, newest first, then
what remains of the main transcript's, then every other sub-agent's in the
order they were created. A sub-agent the roster names that has no transcript
of its own (a cursor task) has nothing to put first, and the answer is the
plain snapshot. An id that neither a sub-agent transcript nor a roster row
names is unknown_subagent; an empty agentId is the plain snapshot.
budget.snapshotBytes defaults to 4 MiB and is capped at 8 MiB regardless of
what is asked.
This is deliberately not a page API over older history. An entry's id names the event that created it, not its latest contents — a tool entry is updated in place long after it is first written — so paging entries that can still change needs rules protocol 1 does not yet have. If a client needs history older than the snapshot window, that arrives later as a new method behind a new capability; it is not a breaking change.
session.create¶
The hub's, where its hello says sessionCreate: true: a new session in a
host the hub spawns (craze serve, as any launch's), answered once the
session has started.
{"jsonrpc":"2.0","id":"2","method":"session.create","params":{"cwd":"/home/me/projects/lumen","prompt":"fix the flaky test","provider":"grok","effort":"high","requestId":"new-9c1f04ab7d2e3a10","title":"Flaky test"}}
{"jsonrpc":"2.0","id":"2","result":{"session":{"hostId":"0a1b2c3d4e5f","sessionId":"01a0f89a-4db5-7621-8335-63ae8327352e","host":{...},"status":"reachable","approximate":false,"row":{...}},"prompt":"accepted"}}
Params. cwd is an absolute path to an existing directory. provider
is a provider id; absent, it is the hub's configured default — provider in
the hub's config.toml, read at each create (the provider the last session
to start persisted), which
sessions.createOptions reports as
defaultProvider — and with none configured the create is bad_request.
model, effort and fast are the session's start settings, each absent
for the provider's own default: an absent model is the agent's own default
model (craze keeps no default model of its own for an ACP provider, and a
launch without --model passes none either). permissionMode, absent, is a plain
launch's: bypass, --force's default — config.toml has no permission
setting. No params member names an agent binary: the host finds
its own ([agents], then PATH), and the hub hands it neither a launch's
--agent-bin nor CRAZE_AGENT_BIN. requestId (1–64 of [A-Za-z0-9._-])
makes the create idempotent (below).
title (where the hub's hello says createTitle: true) is the
session's name from its start, as a /rename would give it: pinned, so
no title the agent sends (session_info_update) and no first-prompt title (a
native session's, the index's fallback) replaces it — only a later
session.setTitle does. Absent, the session starts untitled, as before.
Omit it; never send it empty. The hub normalizes it the way /rename
does, to exactly the string the session stores, the roster shows and the
session index holds — the same bytes everywhere, and what the create's
idempotency hash is taken over. In order: escape sequences (7-bit and 8-bit),
control characters other than newline and tab (a carriage return included),
the zero-width space (U+200B), the word joiner (U+2060), the byte-order mark
(U+FEFF) and the bidirectional embeddings, overrides and isolates
(U+202A–U+202E, U+2066–U+2069) are dropped, and invalid UTF-8 becomes U+FFFD;
then it is folded onto one line — a newline or tab turned into a space, runs
of whitespace collapsed to one space — cut to 120 characters (runes), and
trimmed at both ends. ZWJ and ZWNJ (U+200D, U+200C) and the directional marks
(U+200E, U+200F) are kept: they are letters, emoji glue and punctuation
direction, not formatting. A title that comes to nothing — "", blank, or
nothing but what is dropped ("\u200b") — is bad_request; a long one is
cut, not refused. The host is handed the normalized title and holds it before
its session is published, and the session's first event says it: a
title-only state delta (no text: it is craze's own title, not the agent's),
committed before the host serves the session and before its start runs. So
the session's first sessions.list row, the create's session row, its
first session.state and every attach snapshot carry it — an attach with
when: "now" while the start is still running included, and the stream of a
session whose start then fails before its install. The session's install
delta restates it, as every install restates every section. A session created
without a title has no such event: its stream is what it always was. A roster subscription opened before the create may first receive a
connecting upsert with no row (the roster coalesces, so it is not
guaranteed); every row that describes the session, from
its first reachable one, carries the title. Once the session has its id the host writes it to the session index as
the user's title, pinned, as a /rename is (a failed write is the session's
indexErr, as for every index write it makes on its own). A hub from before
title refuses it unknown_field — check createTitle first.
What the hub does. It spawns the host with the session's directory as
its working directory and the hub's own environment less what is one
launch's or one terminal's (the environment contract: CRAZE_HOME absolute;
no CRAZE_PROVIDER, CRAZE_AGENT_BIN, TMUX, HERDR_*, …), reads its ready
line, attaches with when: "ready" and waits for the session's start —
at most 60 s — sends prompt when there is one (queued, as the session list's
background dispatch sends one, its answer waited for at most 15 s), and lets
go of that connection: a detach after a prompt the session answered, a close
at once after one whose answer was lost or whose deadline passed. Then it
reads the session's own row on a connection of its own, bounded (2 s). The
session runs on in its host, listed in the roster like any other; its host
persists its provider as the next plain launch's default just after its
start, as every new session does — so the create's answer can precede that
save by an instant.
The result. session is the session's roster row, read fresh from its
host after the create let go of it, and approximate as any roster row's is
(a row the hub had to cut or drop to its bounds says so) — and true also
when that fresh read fails: the create is still a success — the session
exists, and its id must reach the client — and session is the row the hub
builds from the registry, row absent. prompt says what became of the
first prompt:
none (there was none), accepted, unknown (it was sent and its answer
lost — the session exists, and may be working on it; promptError says why
it was lost) or refused (the session's refusal, in promptError; the
session runs on, idle).
Refusals. Bad params are bad_request (unknown_field for a member the
schema does not define, and for title on a hub whose hello has no
createTitle; a title that normalizes to nothing is bad_request, reason
bad_request). A cwd that is no existing directory and a provider
this craze cannot start are bad_request too, checked only for a create that
will start a session: a repeat (below) is answered without them. A host that fails to spawn, or to become ready, is
unavailable, reason spawn_failed — the hub has ended it. A host that
cannot be started because the hub's own craze is gone from its path (an
upgrade removed it) is the same refusal, its message naming that path and the
hub's pid to stop — this hub's craze (<path>) was replaced by an upgrade:
stop this hub with kill <pid> (no session ends) and run this again — and the
hub serves on: a client shows the message as it is. A session whose
start fails — its agent binary missing, a locked keychain — or does not end
within 60 s is not_accepting, reason start_failed, data.cause the
host's first error line. For a start that failed because its agent exited
with a non-zero status or a signal after craze sent initialize, that line
is craze's error followed by the agent's last non-blank stderr lines — from
what craze had captured when the failed start collected them, first waiting up
to half a second for its copy of that stderr to finish; sanitized, folded onto
the line with /, at most 512 bytes, not redacted: acp: agent exited: exit
status 1: Error: Your macOS login keychain is locked. / Run security
unlock-keychain and try again. — and on macOS, when the host runs outside
the GUI login session, a hint naming the hub's pid (below). An agent that
exits before initialize is sent, or exits 0, leaves craze's error alone,
with no hint. None of it is a field of its own. The hub stops that host
(session.stop, then, its own child, its termination: SIGTERM, a grace,
SIGKILL, and its reap) — a host
that does not exit even then, stuck in an uninterruptible wait, is left as
it is. More than 16 creates
in flight are unavailable, reason busy; a hub tearing down answers
unavailable, reason closing.
Idempotency. A create's requestId is kept with a hash of its
normalized params — in the hub's memory (an answer kept 10 minutes, at most
256 of them; a create in flight never forgotten) and in the new host's
registry entry (requestId, requestHash). A repeat with
the same params is answered the first create's answer — its failure too —
or joins it while it runs; with other params it is bad_request, reason
request_conflict. The title is hashed as normalized: a repeat whose title
normalizes the same (" Fix the login bug " and "Fix the login bug") is
the same create and joins it; another title, a title where the first create
had none, or none where it had one, is request_conflict. An untitled
create hashes exactly as it did before title existed, so a create retried
under its requestId across an upgrade of the hub — its session started by
the older hub, whose host's entry carries the older hash — is joined, not
refused. A repeat is answered before the directory and the
provider are checked again: a create answered once stays answered, whatever
the configuration or the file system has become. A waiter that disconnects
does not cancel the create. A hub that does not remember the id — it
restarted since — looks among the live hosts of its own namespace (its
CRAZE_HOME: a host whose socket is in the hub's own runtime directory; a
create under the same id in another CRAZE_HOME is that hub's, and neither
joined nor in conflict). It must prove the id unused before it starts a
session: a registry it cannot read, or a live host whose entry it cannot
read, refuses the create unavailable, reason host_unreachable — try again
— rather than risk a second session. One whose entry carries the id is
joined under the same contract: a session
already started is answered at once (its row, prompt: "unknown": this hub
never saw the prompt's answer, nor sent one); a start still running is
waited for, at most 60 s from the join; a start that failed is answered
start_failed and its host stopped. A retry after the created session has
ended finds nothing, and creates another. A client must not retry a create
across a hub restart unless it reuses its requestId (Errors and
retry).
Teardown. A hub that is tearing down gives the creates in flight its own
bound (10 s); past it their waiters see the connection close, and a host
already started runs on, its requestId intact for the next hub to join.
On macOS a session the hub creates runs in the hub's login (security)
session: a hub first started over ssh cannot start a cursor session,
whose keychain is locked there — a start that failed, start_failed. Its
data.cause then ends, after cursor's own lines, with ; this session runs
outside your macOS login session (its launcher was started over ssh): if the
agent needs the login keychain, as cursor does, stop the hub with kill <hub
pid> (no session ends) and run this again from a terminal in your Mac's login
session (a Roost tab, Terminal.app). The host adds it only when its agent
exited and its session lacks the GUI login's graphic access; a client should
show the cause as it is, not parse it.
sessions.createOptions¶
The hub's, where its hello says createOptions: true: what a
session.create can start on this machine, before any
session exists — the agent providers and whether each can start, the default
provider, and the directories sessions last ran in. It is what a client's
create sheet offers; craze providers --hub prints it.
{"jsonrpc":"2.0","id":"3","method":"sessions.createOptions","params":{}}
{"jsonrpc":"2.0","id":"3","result":{"providers":[{"id":"cursor","label":"cursor","state":"unavailable","reason":"this craze runs outside the macOS login session (over ssh), where cursor may not reach the login keychain","fix":"kill 5150 (no session ends), then run \"craze ps\" in a terminal on the Mac"},{"id":"grok","label":"grok","state":"ready"},{"id":"native","label":"native","state":"needs_setup","reason":"no model provider has a key","fix":"craze auth login (an API key, or \"craze auth login chatgpt\" for a ChatGPT plan)"}],"defaultProvider":"grok","recentDirs":[{"dir":"/home/me/projects/lumen","usedAt":"2026-10-03T12:00:00Z"}]}}
Params. None: {}, or none at all. Any member is bad_request, reason
unknown_field.
providers is every agent provider this machine lists, in craze's own
order — cursor, grok, gx, native — at most 16. gx, a fork few
machines have, is listed only when its binary is found. Each has its id
(what session.create's provider takes), its label (what a client shows)
and its state:
state |
meaning |
|---|---|
ready |
it can start here |
needs_setup |
it cannot start until something craze can lead a person through is set up: native with no model whose provider has a key (craze auth login funds one) |
unavailable |
it cannot start, and nothing in craze can make it: its binary is not found, cursor runs outside the macOS login session, or native's model files cannot be read |
reason (why) and fix (what to do) are present exactly when the state is
not ready, each one line of at most 200 terminal cells, cut as a row
fact is. The hub judges each provider as a host it creates
would start it, in the hub's own process:
- cursor, grok and gx are
readywhen their binary resolves as a created host resolves it:[agents].<provider>in the hub'sconfig.toml, else the provider's own binary names on the hub'sPATH(cursor-agentthenagentfor cursor,grok,gx) — neverCRAZE_AGENT_BIN, which the hub hands no host. A binary not found isunavailable, and the reason names where the hub looked:cursor-agent not found on PATH, or[agents].cursor /opt/x/cursor-agent not found. - cursor, on macOS, is also
unavailablewhen the hub runs outside the Mac's login session — first started overssh— where cursor may not reach the login keychain; its hosts inherit that session. The fix names the hub's pid:kill <pid> (no session ends), then run "craze ps" in a terminal on the Mac. A missing binary is reported first. Where the session is not known (Linux), nothing is marked for it. - native is
readywhen its model table — in the hub's craze directory, with the hub's environment, both of which its hosts inherit — loads and a model's provider has a key;needs_setupwhen none has;unavailablewhenproviders.tomlormodels.tomlcannot be read. The reason then names the file and nothing from it: an error there can quote a pasted key's name, andcraze auth liston the machine shows the detail.
Nothing is cached: every call reads the config, looks the binaries up and
reads native's files again — no network request, no keychain access. A state
only marks: session.create starts a provider whatever its state, with the
same start failure as ever when it cannot start.
defaultProvider is the provider a session.create that names none
starts: provider in the hub's config.toml (the provider the last session
to start persisted). It is absent when none is set, or when it names no
provider craze knows, and it may name one that is not listed (a missing gx)
or not ready.
recentDirs is the directories sessions ran in, from the hub's own
session index (its CRAZE_HOME's sessions.jsonl), newest first: each once
(compared cleaned), its usedAt its newest session's last update, in UTC.
Only absolute paths that are directories as the hub answers are listed, at
most 20. It is always a list: [] for none, and for an index that cannot be
read, which the hub's log then says.
Refusals. A hub that cannot answer — one from before the method, or one
given no answer — says no createOptions in its hello and answers the
method unsupported, reason unsupported. A host answers it unsupported,
reason hub_only.
sessions.saved¶
The hub's: the saved sessions — the sessions of the hub's own session
index (its CRAZE_HOME's sessions.jsonl) that no running host serves —
newest first, each with the id that reopens it. It is what a client's
"resume" list offers; craze ps --saved prints it.
{"jsonrpc":"2.0","id":"4","method":"sessions.saved","params":{"workspace":"/home/me/projects/lumen","limit":20}}
{"jsonrpc":"2.0","id":"4","result":{"sessions":[{"id":"0190ab12-cd34-7ef0-8123-456789abcdef","crazeSessionId":"0190ab12-cd34-7ef0-8123-456789abcdef","provider":"cursor","providerSessionId":"7c1f9e2a-55b0-4d8e-9a43-1f0e2d3c4b5a","workspace":"/home/me/projects/lumen","title":"fix the flaky test","createdAt":"2026-10-12T09:00:00Z","updatedAt":"2026-10-12T10:00:00Z"},{"id":"grok:4f1e2d3c","provider":"grok","providerSessionId":"4f1e2d3c","workspace":"/home/me/projects/lumen","createdAt":"2026-10-11T09:00:00Z","updatedAt":"2026-10-11T10:00:00Z"}],"more":false}}
Params, each optional; {} or none at all lists the 50 newest:
| member | meaning |
|---|---|
workspace |
only the sessions whose directory is exactly this one: an absolute path (the hub cleans it first), at most 4096 characters |
limit |
the most sessions to list, an integer from 1 to 200; 50 when absent |
subAgents |
true lists the sessions a sub-agent ran too (an unattended session: craze new --wait's, craze send's reload of one), each marked subAgent: true; absent or false leaves them out |
A member it does not define is bad_request, reason unknown_field. A
null (the params or a member), a member given twice, a member of another
type, an empty or relative workspace and a limit outside 1–200 (a
fraction, an exponent such as 1e2, or a string included) are
bad_request.
Which sessions. The session list's own choice of its saved rows — one function computes both:
- One per craze id: its newest row, whatever it is — the row a load of the
craze id loads (
craze serve --loadandsession.reopentake the newest row carrying it). An index row from a craze before craze ids (nocrazeId) is the row it is, by its provider and provider session id. - That row is listed only when it can be resumed: of a provider this craze
can resume (
cursor,grok,gx,native), and within the bounds below. A craze id whose newest row cannot be — a newer craze's row of a provider this one does not know, a malformed row — is not listed at all, never by an older row a load would not take. - Less every running session: every live host in the
registry — the whole
$HOME's, whatever namespace started it — counts by its craze id and by its provider and provider session id, and a row is running when either matches (another craze directory's index can have given the same provider session another craze id). A host whose entry is markedstoppingis not counted, so a stopped session is listed saved as soon as it leaves the roster, never in neither list.
Then, in this order: the workspace filter (an exact match on the row's
directory); the sub-agent filter; newest first by updatedAt, ties by id;
and the limit. Every filter runs before the cut: more is true when
sessions the filters kept were left out, by the limit or by the result's
bound. No workspace is stat'ed, so a
hung filesystem cannot stall a listing, and a session whose directory has
gone is still listed (a reopen refuses it).
SavedSession:
| member | |
|---|---|
id |
what reopens it, opaque to a client: its craze session id, or <provider>:<providerSessionId> for a row with none — the form craze serve --load takes. At most 512 bytes |
crazeSessionId? |
its craze session id, a token a load takes: [A-Za-z0-9._-], at most 128 bytes, never . or ..; absent for a row with none |
provider |
its provider id |
providerSessionId |
the provider's id for its last agent session; at most 512 bytes |
workspace |
the directory it ran in, absolute, as its index row holds it; at most 4096 bytes |
title? |
its newest row's title, cut to 120 characters (the index is a file a person can edit); absent for none |
createdAt, updatedAt |
its index row's times, in UTC; createdAt is 0001-01-01T00:00:00Z for a row that records none |
subAgent? |
true for a sub-agent's session, listed only when subAgents asked for them; absent otherwise |
Bounds. An index row over a bound — its craze id not a token a load
takes (above: craze serve --load claims the session by it, and a craze id
with a : would read as another row's legacy key), its id or provider
session id over 512 bytes, its workspace over 4096 bytes or not absolute —
or with a time
that cannot be written in UTC with a four-digit year
(9999-12-31T23:59:59-01:00 is the year 10000 in UTC) is malformed: not
listed, and a craze id whose newest row it is not listed at all. The bounds
are bytes; the schema's
maxLength, which counts characters (JSON Schema cannot count bytes), states
a weaker one. The whole result is at most 1 MiB: the hub drops trailing
sessions to keep within it, and says more.
An index or registry it cannot read. An index that cannot be read is
answered as sessions.createOptions answers one:
no sessions, [], and the hub's log says why. A registry that cannot be read
is refused unavailable, reason host_unreachable — listing without taking
the running out would offer a running session as saved — and so is a registry
holding a live host's entry that cannot be read (its session cannot be
named), as session.create refuses one; the hub's log says
why (the refusal names no path).
Every call reads the index and the registry again; nothing is cached.
Refusals. The connection capability reopen says the hub serves it, and
session.reopen too: a client checks it, and hides its
resume entry without it. A hub that cannot answer it refuses it
unsupported — reason unsupported from a hub given no answer,
unknown_method (-32601) from a hub from before the method. A host answers
it unsupported, reason hub_only.
session.reopen¶
The hub's, where its hello says reopen: true: ensure a saved session is
running. Its id is a sessions.saved id. If a host
already serves the session, the hub joins it. Otherwise it starts a host that
loads the session (craze serve --load). Either way it answers once the
session's start is done. craze new --load asks for
one.
{"jsonrpc":"2.0","id":"5","method":"session.reopen","params":{"id":"0190ab12-cd34-7ef0-8123-456789abcdef"}}
{"jsonrpc":"2.0","id":"5","result":{"session":{"hostId":"3c4d5e6f7a8b","sessionId":"0190ab12-cd34-7ef0-8123-456789abcdef","host":{...},"status":"reachable","approximate":false,"row":{...}},"joined":false}}
Params. id is required: a non-empty string of at most 512 bytes, as
sessions.saved gave it — the craze session id, or
<provider>:<providerSessionId> for an index row with none. It is opaque to
a client. A member the schema does not define is bad_request, reason
unknown_field. A null (the params or id), a member given twice, an id
of another type, and an empty or oversized id are bad_request. There is
no requestId (below).
The result. session is the session's roster row, read fresh from its
host once the hub has let go of it, as session.create
reads one. It is approximate as any roster row is, and also when that fresh
read fails: the reopen is still a success, and session is the row the hub
builds from the registry, row absent. Treat such a row as a create's. A
legacy id's session carries the craze id its host gave it. Bind to its
hostId: that is where session.connect reaches the
session. joined is true when a host already served the session and the
hub joined it, and false when the hub started a host to load it.
What the hub does. The reopen is admitted as a create is: it counts
against the 16 creates in flight (unavailable, reason busy, past them),
it keeps the hub from its idle exit, and a hub tearing down refuses it
closing. It then runs on a goroutine of its own, so a waiter that
disconnects does not cancel it.
- Resolve. The
idis looked up in the hub's own session index (itsCRAZE_HOME'ssessions.jsonl) to the row a load of it loads, ascraze serve --loadlooks one up: a craze id names its newest row,<provider>:<providerSessionId>the row of that key. A legacy key whose row has since been given a craze id (a host loaded it, and minted one) resolves to that craze id and its newest row. An id no row has isunknown_session. That row decides, as it decides whethersessions.savedlists the session: one of a provider this craze cannot resume, or malformed, isnot_accepting, reasonnot_resumable(that session's provider "<id>" is not one this craze can resume,that session's index row is malformed) — never reopened in an older row's place — and so is an index the hub cannot read. Nothing isstat'ed here. - Find the holder. One host serves a session: the one holding its
claim (the session lock), which every loader
takes before it loads the session. (A TUI-hosted session whose claim
could not take its lock file runs unclaimed.) The hub reads the
registry, the whole
$HOME's, whatever namespace started the host, for a live host serving the session: by its craze id, or by its provider and provider session id (best effort: an entry carries the latter only once its engine is ready, and the claim catches what this misses). A registry it cannot read, or a live host's entry in it, isunavailable, reasonhost_unreachable: whether a host serves the session cannot be known. Of several hosts, the hub joins one serving it before it waits for one on its way out, and waits for one it has looked past (below) only when there is no other; of several alike, the newest. No wait ends on a host the hub would not join. - A host serving it is joined: dialled, attached with
when: "ready"and no prompt, and its start waited for (below). A host the hub joins is never the hub's to stop. Whatever its start comes to — a start that failed or ran past the budget, a lost connection, a teardown — the hub sends it nosession.stopand no signal: another client may be attached to it. A start that failed isnot_accepting, reasonstart_failed, itsdata.causethe host's first error line; a host that cannot be reached isunavailable, reasonhost_unreachable.joinedistrue. The answer names the session it was asked for: a host that no longer serves it when the hub reads its row — a TUI-hosted session whose engine changed meanwhile, its row or its registry entry naming another session, or an attach refusedunknown_session— is not the answer, and the hub looks again (below). Found again with its entry as it was then (its rewrite not landed yet), it is waited for, as a holder is, until that entry changes or its process has gone; it is never joined again on the same entry. - A host on its way out is waited for until its process has gone,
and the hub then looks again. That is a host whose entry is marked
stopping, one whose attach is refusedunavailable, reasonclosing(a stop the hub raced, or a host from before the mark, which never writes one), and one that cannot be dialled, or whose connection is lost, once its entry has gone or is marked. A stopping host unlinks its entry before it releases the session's claim, so neither the mark nor the entry's absence says the session can be loaded again; the process having gone does. An attach refusedclosingwhile the host's entry still serves the session, unmarked, may be only an idle check's (the host deciding whether to stay, which it may): the hub tries that host again once it has waited a poll (20 ms) for it — and so again, a poll later, when that host then cannot be dialled or its connection is lost (a host from before the mark closes its socket before it unlinks its entry), neverhost_unreachablefor it. - No host means a spawn. The hub first checks the session's workspace
is still a directory: one that is not is
not_accepting, reasonnot_resumable(that session ran in <dir>, which is no longer a directory). It then spawnscraze serve --load <id>with the workspace as its working directory, ascraze sendreloads a session: a person's session with the plain defaults, a sub-agent's (unattended) session with its recorded mode, permission mode, model and effort, and--unattended. Never--provider,--workspaceor--title: the row decides where and as what the session runs. The host's ready line decides what follows:ok: the session's own host. Its start is waited for (below), and a start that fails or runs past the budget stops it, as a create stops its own; so does its row naming another session, and the hub looks again.joinedisfalse.held: another loader holds the session's claim (another reopen, another hub, the session list,craze send, a directcraze serve --load). This host exits by itself, and the holder is never touched. The hub waits until the holder's registry entry serves the session (it joins it) or the holder's process has gone (it spawns again).- refused because the session index was busy or changed:
unavailable, reasonbusy. Any other refusal of the session (no such row any more, a row that cannot run where it ran) isnot_accepting, reasonnot_resumable, in the host's words. - any other not-ok line, or none:
unavailable, reasonspawn_failed. A host that cannot be started because the hub's own craze is gone from its path isspawn_failedtoo, in words naming the remedy, as for a create.
- Waiting for holders may take 20 s in all — only the time spent
waiting counts, not a join's or a spawn's own — and a reopen spawns at
most 3 hosts. A holder still there when the 20 s are spent is
unavailable, reasonbusy:that session is held by host <hostId> (pid <pid>), which has not finished stopping; try again. So is a holder alive with no entry serving the session for that long (one whose control socket never bound, say), and a host found again as it was when it no longer served the session (…, which is still moving to another session; try again). A fourth spawn wanted, and a fourth host found no longer serving the session (each host counted once, however often it is found), arebusytoo:that session changed hands <n> times while the hub reopened it; try again. - The workspace on a join. A host found serving the session is joined even if the session's directory has since gone: only a spawn checks it.
Budgets. A reopen takes at most 180 s from its admission. Within
that, the waits for holders take at most 20 s in all, each spawn's ready line
10 s, each dial its own 5 s, and each start — the agent's initialize, which
has no deadline of its own, its session/load (bounded by craze's ACP client
at 90 s, past which the start fails) and the catalog's wait — at most 150
s from its dial, every one of them cut to what is left of the 180 s. A
start past its 150 s is not_accepting, reason start_failed, data.cause
the session did not start within 2m30s. A reopen whose 180 s pass before
the session is running is unavailable, reason busy: that session was
not running within the reopen's 3m0s; try again (a host it started for it
stopped, a host it joined left alone). Two things are not bounded: the index
read and the workspace's stat, as a create's are not. A client allows at
least 200 s for the answer.
One short connection per reopen. The hub answers a connection's requests in order, and a reopen holds its connection until it answers. A client sends each reopen on a connection of its own, as it sends a create, and never on the connection that holds its roster subscription.
Retry by session. session.reopen means "ensure this session is
running": its safety comes from the session's claim, not from a request id
(the one exception is a TUI-hosted session running unclaimed, above).
Two reopens at once — on one hub, on two hubs, or racing any other loader,
by the same id or by a legacy id and its craze id — end on one host: the
loser's spawn is answered held, and it joins the winner. A client that lost
an answer (its connection dropped, its deadline passed) sends the same {id}
again on a fresh connection and ends on whatever host serves the session
then. So across tries the hostId and joined may differ (the first try's
host may have idled out meanwhile), and a try sent after the session was
explicitly stopped reopens it again, by design: a client stops retrying when
its user cancels.
Idle. A reopened host follows the normal idle rules. Once no client is
attached it exits after host_idle_exit idle — at most 5 minutes while the
session has had no prompt from craze since it loaded (its replay is not one),
as a create with no prompt does. A reopen nobody then attaches to is gone
again within those minutes.
Refusals, in sum:
| answer | when | a client |
|---|---|---|
unknown_session |
no index row has the id (it was evicted, or never there) | final: drop the row |
not_accepting, reason not_resumable |
the workspace is no longer a directory; the session's newest row is of a provider this craze cannot resume, or malformed; the index cannot be read; the host refused the session | final: drop the row |
not_accepting, reason start_failed |
the session's start failed, or ran past its budget; data.cause says why |
final: show the cause |
unavailable, reason busy |
a holder did not finish stopping within 20 s; the session kept changing hands (a fourth spawn wanted, or a fourth host found no longer serving it); the reopen's 180 s passed before the session was running; a host's index was busy or changed; 16 creates and reopens in flight | transient: offer to retry |
unavailable, reason closing |
the hub is tearing down | transient: offer to retry |
unavailable, reason host_unreachable |
the registry cannot be read, or the host serving the session cannot be reached | show the message |
unavailable, reason spawn_failed |
the host could not be spawned, or did not become ready | show the message |
bad_request |
bad params | a client bug |
A host answers session.reopen unsupported, reason hub_only. A hub that
cannot answer it refuses it unsupported, reason unsupported, and one from
before it unknown_method (-32601): check reopen in its hello first,
and hide the resume entry without it.
Notifications¶
A notification has no id and carries its subscription id in params — an
attachment's, from a host, or the roster subscription's, from the hub — or,
for aside, its aside id.
| notification | params | meaning |
|---|---|---|
event |
subscription, seq, event |
one committed event; event is the record's body verbatim — the same JSON the lossless event codec wrote, never re-encoded |
synchronized |
subscription, seq |
the stream has delivered through this attachment's cutoff — shed's "ready" for the stream (not to be confused with ready, the session's readiness). Sent once per attachment |
ready |
subscription, session, startFailed, err? |
the session's start has finished (startFailed: false) or failed (true, err the text); owed once, only to an attachment made before readiness (when: "now", or a when: "ready" attach that raced the start) — but not delivered if that subscription resets before its position reaches the seq the start completed at, and not owed at all if the session closes without ever starting (that attachment ends reset{session_closed} instead, with no ready). session is the final info document — catalogs and provider session id included |
reset |
subscription, reason |
the subscription is over (below) |
presence |
subscription, attached |
how many clients are attached to the session now, this one included — see below; only from a host whose session capability presence is true |
roster |
subscription, epoch, cursor, upserts[], removes[] |
the hub's alone: the roster's net change since the cursor the subscriber last had — see the hub's roster |
aside |
asideId, seq, text |
the next piece of a session.aside's answer, sent only to the connection that asked — see below |
A reset is a notification, not a gap: nothing is ever silently lost. Every
reason and what a client does about it:
| reason | cause | what a client does |
|---|---|---|
slow_consumer |
the client fell behind its own budget | after readiness: re-attach with its cursor — the log answers from the ring or the journal, or refuses and a snapshot follows. Before readiness: re-attach when: "ready", with no cursor |
omitted |
a record no client could ever fold (over the per-record limit) | re-attach with no cursor — unless this omitted is itself the session's closing reset (an unfoldable record in the final tail): the connection then ends exactly as session_closed ends it, and there is nothing to re-attach to |
replay_failed |
the journal leg of a cursor replay failed asynchronously | discard everything folded since the cursor; re-attach with no cursor |
session_replaced |
the host swapped its engine for a new session | the connection closes (terminal-only from the swap: no ordinary reply queued before it survives); reconnect, say hello afresh (the old token is void) and attach the new session |
session_closed |
the session is over; its final records were delivered first — or, if the journal's tail could not be read, the contiguous prefix of them the host still had | the connection closes; there is nothing to reconnect to |
hub_closing |
the hub is shutting down — a roster subscription's reset, never an attachment's | the connection closes; reconnect (a hub is started on demand) and subscribe again: the new hub's epoch reseeds the roster |
A roster subscription can also end slow_consumer (the subscriber fell 10 s
behind) or omitted (the roster's completeness changed: it crossed 512 rows,
either way); the client subscribes again and reads truncated from the
reply.
presence¶
A host whose session capability presence is true — every craze from plan
032 on, detached or TUI-hosted — tells each attachment how many clients are
attached to the session: attached counts every attachment the host holds
that is not yet closed (pending, live or closing), less any whose connection's
peer has half-closed, and, on a TUI-hosted session, the hosting TUI's own seat
(so a craze attach beside it reads 2). A connection that only says hello
or lists is not attached.
- It is sent on an attachment once its
synchronizedhas been written, and then whenever the count changes — the latest count only, at most two a second on a connection: a count that moves several times between two sends goes out once, as its latest value, and one that comes back to what was last sent is not sent at all. - It is never sent before
synchronized, nor after the attachment'sresetor itssession.detachreply. - It has no
seqand is no session event: nothing sequences, journals or replays it, and no cursor covers it. A client keeps the latest for its live subscription and forgets it when that subscription ends — until the next attachment's arrives. - Each connection is sent its count by its own writer: a client that has stopped reading delays its own count and nobody else's.
A client that does not know presence ignores it, as any notification it
does not know. A host without the capability sends none, and its rows carry no
attached.
aside¶
The streamed answer of a session.aside: asideId is the
aside's, seq counts from 1 per aside, and text is the next piece of the
answer, at most 4 KiB (AsideNoticeMax), cut between characters.
- It is sent only on the connection that asked, every one before that
aside's reply, in
seqorder. It carries no subscription: it needs no attachment, and an attached client's other connections and every other client are sent none. - It is no session event: nothing sequences, journals or replays it, and no cursor covers it.
- The host gathers the pieces an agent streams and sends what has arrived every 50 ms, a full 4 KiB at once. A grok or gx answer comes in one piece, and so has no notification before its reply.
- A connection that is not reading holds them back: once 1 MiB
(
AsidePendingMax) of an aside's text waits unsent, the host sends no more of that aside's notifications and drops their text. The reply'stextis the whole answer all the same — a client never relies on the notifications. - A client drops a notification of an aside it is no longer waiting for (one it cancelled, or has its answer to), and one it does not know.
Attach, resume, and snapshots¶
session.attach puts a connection on a session's event stream. A connection
holds at most one live attachment at a time; a second session.attach
while one has not yet fully ended is bad_request, reason
already_attached.
| params | |
|---|---|
cursor? |
{incarnation, seq} — where the client already is. Absent asks for a snapshot |
when? |
"ready" (default) or "now" |
budget? |
{maxItems?, maxBytes?, snapshotBytes?} — a client may lower the subscription's item/byte budget, or raise it up to the host's own maximum (4,096 items and 32 MiB; an absent member is the host's default, 1,024 items and 8 MiB); snapshotBytes is capped at 8 MiB |
The item/byte budget bounds what the log holds for this subscription that
its forwarder has not yet taken: a live record that would take it past
that budget ends the subscription as a slow consumer, and the forwarder
then queues its reset{slow_consumer} (a cursor replay already larger
than the budget is refused when the attach subscribes instead). The
forwarder moves records from the subscription into the connection's writer
queue (32 MiB, protocol.WriterQueueBytes, every outbound byte counted, of
which 1 KiB is kept for a final reset) and waits only when the next line
would not fit in the rest. So a reader that
simply stops reading is usually reset only once the kernel's socket
buffers and most of that writer queue have filled and the subscription's
budget then overflows behind them — a stalled reader of a modest reply may
never be reset at all. A burst that outruns the forwarder can overflow a
small budget sooner, however full the writer queue is, and a single live
record larger than the budget overflows it at once (the wire fixture
04-slow-consumer-reattach's design).
when: "ready" (the default) waits until the session's start has finished —
a session/load replay therefore reaches this client as a snapshot,
never as a live burst. The wait is bounded by the client's own patience, not
a flat timeout: a load can legitimately take minutes. The host's own bound is
10 minutes, past which the attach is unavailable, reason not_ready (never
stored — safe to retry). A start that already failed is answered
not_accepting, reason start_failed, with data.cause.
when: "now" attaches immediately, the start included: the reply answers
ready: false while the session is still starting, the subscription
receives the start as ordinary live events, and the host owes this
attachment one ready notification once the gate opens — delivered once the
subscription's position reaches the seq the start completed at. If this
subscription resets before that point (a slow consumer, a replay failure),
the ready is dropped, not resent later; and if the session closes without
ever starting, no ready is owed at all — this attachment ends
reset{session_closed} instead. This is what lets a client watch a session
start.
The reply¶
A real instance, from the fixtures
(internal/fakehost/testdata/wire/01-hello-attach-snapshot.ndjson), a fresh
attach with no cursor — session is the info
document below, and snapshot is
transcript.EncodeSnapshot's JSON, embedded raw:
{"jsonrpc":"2.0","id":"2","result":{
"subscription":"s-1",
"session":{
"sessionId":"session-fake-1",
"providerSessionId":"stub-session-1",
"incarnation":"INCARNATION-1",
"hostId":"0123456789ab",
"workspace":"/work",
"provider":{"name":"","label":"cursor"},
"catalogs":{
"models":[{"id":"grok","name":"Grok"},{"id":"fast","name":"Fast"}],
"modes":[
{"id":"agent","name":"Agent","description":"Full agent capabilities with tool access"},
{"id":"plan","name":"Plan","description":"Read-only mode for planning and designing before implementation"},
{"id":"ask","name":"Ask","description":"Q&A mode - no edits or command execution"}
]
},
"capabilities":{"interject":false,"subagentCancel":false,"subagentBackground":false,"modes":true,"effort":true,"fastToggle":true,"subagentRows":true,"subagentTranscript":false,"todos":true,"askCards":true,"planCards":true,"parameterizedPicker":true,"cancel":true,"approvals":true,"historyCursor":true,"stop":false},
"retryHorizon":{"commands":1024,"ageMs":600000}
},
"ready":true,
"after":{"incarnation":"INCARNATION-1","seq":1},
"snapshot":{
"version":1,
"incarnation":"INCARNATION-1",
"seq":1,
"settings":{
"mode":"agent",
"model":"grok",
"config":{
"options":[
{"id":"effort","name":"Effort","category":"thought_level","type":"select","current":"medium","selectValues":[{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},{"value":"high","name":"High"}]},
{"id":"fast","name":"Fast","category":"model_config","type":"select","current":"false","selectValues":[{"value":"false","name":"Off"},{"value":"true","name":"Fast"}]}
]
},
"commands":{"commands":[{"name":"research","description":"Agent-advertised command"}]}
},
"main":{}
}
}}
subscriptionis a server-assigned id (s-1,s-2, …), unique per connection, carried on every notification of this attachment.afteris where the stream continues: the client's own cursor when it was honoured, otherwise the snapshot's{incarnation, seq}. Everything at or belowafteris already covered by the cursor's replay or bysnapshot.snapshotis present exactly when the cursor was not honoured (absent, refused, or none was given).reset(aCursorReason, see below) is present when a cursor was given and refused — the reason the first refusal named.- The reply is on the wire, and
afterdecided, before the very firsteventof this attachment: a client never sees an event whose position it cannot place.
Cursors and resets¶
A cursor is {incarnation, seq}: a log incarnation and the seq of the last
event this client folded. A cursor from another incarnation is refused
synchronously (foreign_incarnation) and answered with a fresh snapshot. The
full set of refusal reasons:
foreign_incarnation, future_seq, backlog_too_large, no_journal,
journal_gap, journal_behind, evicted.
Any of these on the attach reply itself means: take the snapshot that came
with it, forget the old cursor, and go on from after. The same reasons,
arriving later as a reset{reason: "replay_failed"} or {reason:
"omitted"} notification, mean: re-attach with no cursor.
Re-attaching, and the per-episode bound¶
A client that loses its connection and gets it back re-attaches with its
held cursor (the last seq it has actually received and queued, in
order — never a seq it has merely heard about). Resending an in-flight
command after a reconnect is safe only when the reconnecting hello
answered resumed: true for the same client id, token and host; any other
answer means the command's outcome is unknown, and no client should ever
resend a command whose earlier attempt it cannot rule out having run.
A client bounds how many times it will re-attach within one "episode" (a
connection loss and its resulting resets, resends and retries) — 8
re-attaches, counted from every reset, reconnect and retried refusal, reset
to zero the moment the stream reaches synchronized again
(ReattachesPerEpisode).
That is the stream's bound. Getting a connection back at all is bounded
separately, in attempts and time: a reconnect episode makes at most 3 dial
attempts, and ends 10 s after the loss, whichever comes first
(remote.Options.Redials/RedialWindow) — every dial, handshake and resend
of an in-flight command bounded by what is left of it. That 10 s is the
episode's own clock, not a wall-clock deadline: it pauses while the adopting
connection waits for a when: "ready" re-attach's reply (a slow load can
take longer than 10 s) and runs again once that reply arrives or another
write starts, so the episode's total wall time can exceed 10 s while its
clock stays within budget. Past either bound, a client gives up on this
attempt and surfaces the outcome as unknown (resume_lost or disconnected
— see Errors) rather than looping forever against a host
that keeps resetting it, or that it cannot reach at all.
Bounded history is session.snapshot, not pages¶
There is no history(beforeSeq, limit) method in protocol 1 — see
session.snapshot above. The attach snapshot's window
(the tail, up to 8 MiB) is everything a fresh client gets; anything older
than that is future work behind a new capability.
The session info document¶
Carried by an attach reply's session, a ready notification's session,
and the first half of a sessions.list row. It is final once the
session is up — its catalogs are empty and providerSessionId may be ""
until then — but for a native session's model list, which
changes while it runs: each document carries the list as it
stands when it is built.
{
"sessionId":"session-fake-1",
"providerSessionId":"stub-session-1",
"incarnation":"INCARNATION-1",
"hostId":"0123456789ab",
"workspace":"/work",
"provider":{"name":"","label":"cursor"},
"catalogs":{"models":[{"id":"grok","name":"Grok"}],"modes":[{"id":"agent","name":"Agent","description":"Full agent capabilities with tool access"}]},
"capabilities":{"interject":false,"subagentCancel":false,"subagentBackground":false,"modes":true,"effort":true,"fastToggle":true,"subagentRows":true,"subagentTranscript":false,"todos":true,"askCards":true,"planCards":true,"parameterizedPicker":true,"cancel":true,"approvals":true,"historyCursor":true,"stop":false},
"retryHorizon":{"commands":1024,"ageMs":600000}
}
Every field above is real (drawn from the same fixture as the attach
reply); capabilities' fields are documented in full under
Capabilities below.
| field | |
|---|---|
sessionId |
the durable craze session id every session-scoped call names |
providerSessionId |
the agent's own id; changes on a session/load |
incarnation |
the event log's id: the scope of seqs, turn ids and ask ids, and a cursor's first half |
hostId |
the host serving this session |
workspace |
the session's working directory |
provider |
{name, label} — name is the provider id (cursor, grok or gx for the ACP agents, native for the one that runs inside craze), label what a client shows |
catalogs |
{models[{id,name,recent?,efforts?,defaultEffort?,switchEffort?}], modes[{id,name,description}], revision?} — empty until the session is ready. A model's recent is its rank in the native provider's model memory: 1 for the model most recently picked in a native session's /model that this session offers, then 2, 3…; absent for a model not remembered, for every ACP provider's model, and on an older host. Where the session capability effortFacts is true (a native session), every model also states its effort facts: efforts, the levels it offers in order (absent for a model with no effort control; at most 8, each at most 32 bytes), defaultEffort, its default, and switchEffort, the effort a switch to it lands on — the session's own last choice on that model, else the one the model memory remembers for it, else its default. A client reads the current model's effort from its effort option (settings.config), never from its row. All three are absent for every ACP provider's model and on an older host. A native session's models are only those whose provider has a key, plus the one it runs on, as the session stands when the document is built: it changes while the session runs as keys are saved and the ChatGPT plan is signed in to or out of. revision is that list's revision, the catalog section's, absent while 0 (the list the session started with, every ACP session's, and every older host's). A client lists the current model first, then the ranked ones by rank, and shows no label for either |
capabilities |
the session's own capability set, below |
retryHorizon |
{commands, ageMs} — the command-id table's size and age bound |
permissionMode |
how the host's agent handles permission requests: bypass (spawned with --force: it runs tools unasked) or prompt (--no-force: it asks, and a client answers). Absent when the host does not say |
startedAt |
when the host started serving the session, UTC — what a client counts the session's elapsed time from. Absent when the host does not say |
permissionMode and startedAt are absent from the example above, as they
are from every host that does not set them — an older one, or the fake host
by default. Absent, a client falls back to what it knew without them: its own
configuration's permission mode, and an elapsed time counted from its own
attach (see Versioning).
A native session's catalog carries the ranks (from the fake host's
18-model-ranks fixture, which gives its stub a native catalog):
"catalogs":{"models":[{"id":"muse-spark-1.3-contributor","name":"Muse Spark 1.3 Contributor (Meta)","recent":1},{"id":"fireworks/kimi-k3","name":"Kimi K3 (Fireworks)","recent":2},{"id":"fireworks/deepseek-v4p1-flash","name":"DeepSeek V4.1 Flash (Fireworks)"}],"modes":[…]}
and, where its capabilities say effortFacts, every model's effort facts —
here Muse Wonder, last used at max, a model whose switch lands on its
default, and one with no effort control (from the fake host's
33-model-efforts fixture):
"catalogs":{"models":[{"id":"muse-wonder-1.0","name":"Muse Wonder 1.0 (Meta)","recent":1,"efforts":["minimal","low","medium","high","xhigh","max"],"defaultEffort":"high","switchEffort":"max"},{"id":"glm-5.3","name":"GLM 5.3 (Z.AI)","efforts":["low","high","max"],"defaultEffort":"max","switchEffort":"max"},{"id":"other/plain","name":"Plain (no effort control)"}],"modes":[…]}
Capabilities¶
Two independent levels: what the connection (the endpoint) can do, and what this session can do.
Connection (hello's capabilities): rosterSubscribe, sessionCreate,
multiplex, connect — all false on a host in protocol 1, the hub's
alone — plus snapshot and attachWhenNow, both true on a host. On the
hub, rosterSubscribe and connect are true, sessionCreate is true
where it serves session.create, and the other three are false (see the
hub's result). A seventh, createOptions, is the hub's
and omitted when false: true where the hub serves
sessions.createOptions, absent on a host, on an
older hub and on one given no answer. An eighth, createTitle, is the hub's
too and omitted when false the same way: true where its
session.create takes a title, absent on a host and on an
older hub. A ninth, reopen, is the hub's and omitted when false too:
true where the hub serves both sessions.saved and
session.reopen.
Session (the info document's capabilities): every field of the
engine's own capability set, in its wire name, plus four the protocol states
for every host of protocol 1 — three always true, and stop, which says
what this host can do — and rowFacts and presence, the host's too, and
modelsRefresh, aside, slashCatalog and effortFacts, the session's own:
| wire name | meaning |
|---|---|
interject |
the provider can fold text into a running turn without cancelling it |
subagentCancel |
session.subagent.cancel is served |
subagentBackground |
the provider can run a sub-agent's turn in the background (a native "wake") |
modes |
the provider has switchable modes |
effort |
the provider has an effort/thinking-level setting |
fastToggle |
the provider has a fast-mode toggle |
subagentRows |
sub-agent rows are reported at all |
subagentTranscript |
a sub-agent's own transcript can be put first in a snapshot (session.snapshot{agentId}) |
todos |
the provider reports a todo list |
askCards |
the provider can ask permission/question cards |
planCards |
the provider can ask plan-approval cards |
parameterizedPicker |
the config catalog can offer per-model options |
cancel |
true on every host in protocol 1 |
approvals |
true on every host in protocol 1 |
historyCursor |
true on every host in protocol 1 |
stop |
the host's own, not the provider's: true where session.stop is served (every craze serve), false on a TUI-hosted session and on a host from before it existed |
rowFacts |
the host's own too, and omitted when false: the session's sessions.list row carries the row facts. It is a session capability, not a connection one, because it describes the row, and a hub's roster carries rows of hosts of different builds |
presence |
the host's own too, and omitted when false: the host counts the clients attached to the session, sends each attachment the presence notification, and puts attached on the session's row |
modelsRefresh |
the session's own, and omitted when false: the session takes up models funded while it runs (a native session), so the host serves session.models.refresh for it, whether it runs detached or in a TUI. Absent for every ACP session and on an older host: a client makes no call |
aside |
the session's own, and omitted when false: the session answers side questions (a native, grok or gx session), so the host serves session.aside and session.aside.cancel for it, whether it runs detached or in a TUI. Absent for cursor and on an older host: a client offers no side questions and makes no call |
slashCatalog |
the session's own, and omitted when false: the session publishes the slash catalog — the state delta's slash section and the snapshot's settings.slash, every / row it offers and what running each does — a cursor, grok, gx or native session, whether it runs detached or in a TUI. true before the session's install has published the section is a catalog still to come. Absent on an older host and for a session that is not craze's own (the fake host's by default): a client offers no / menu from the stream |
effortFacts |
the session's own, and omitted when false: every model of the session's catalogs states its effort facts — catalogs.models and the catalog section's models carry efforts, defaultEffort and switchEffort, and a model with no effort control lists none (a native session), whether it runs detached or in a TUI. A client can then show a model's levels, and the effort a switch to it would land on, before it is chosen. Absent for every ACP session (cursor's levels exist only for its current model, in its effort option), on an older host and for a session that is not craze's own (the fake host's by default): a client shows only the current model's levels, from its effort option |
A client hides — never merely disables — whatever a capability says this
session cannot do. A capability the engine's own agent.Capabilities grows
later always gets a wire name here before it is used: every field of that Go
struct has a matching entry in this table, mechanically checked.
Versioning¶
The protocol is one integer, 1, deliberately decoupled from craze's own
release version — a client and a host can be built from very different
commits and still speak the same protocol. A hello whose protocols share
none with the host's is refused (see hello).
The event and snapshot codecs carry their own version numbers in hello's
codecs (and the session info document does not repeat them): a client that
does not recognize a codec version must not attempt to fold that stream — it
can still forward it opaquely, but not interpret it.
New behaviour is always announced as a capability, never inferred from a
version number: a client checks capabilities.foo, never "am I talking to a
build recent enough to have foo". A capability or a field that is absent
means an older host: capabilities.stop is false there (and session.stop
answers stop_unsupported), capabilities.rowFacts is absent (and so are
the row facts), capabilities.presence is absent (and so are the row's
attached and every presence notification — a client shows no count), and
the info document's permissionMode and startedAt and
the state's and row's lastTurn are simply not there — a
client falls back to what it did before each existed, and the schema, closed
as it is, describes every one of them as optional. A catalog model's effort
facts are optional the same way: where capabilities.effortFacts is absent —
every ACP session, every older host — no model carries efforts,
defaultEffort or switchEffort, and a client falls back to what it did
before them, the current model's levels from its effort option alone. A catalog model's recent
is optional the same way, except that its absence is ordinary on any host —
the model is not remembered — so a catalog with no ranks at all, an ACP
provider's or an older host's, is listed after the current model in the
client's own order.
Live models are announced the same way. Where
capabilities.modelsRefresh is absent — every ACP session, every older host
— a client makes no session.models.refresh call. An absent
catalogs.revision is revision 0. The state delta's catalog section is
one an older client's decoder skips as it skips any key it does not know (the
event codec stays at version 1), so an older client attached to a newer host
keeps the list the info document gave it until it attaches again — what every
client did before the section existed.
The slash catalog is announced the same way: where
capabilities.slashCatalog is absent — every older host — a client offers no
/ menu from the stream (the TUI builds its own, as it always has). The
state delta's slash section and the snapshot's settings.slash are keys an
older client's decoder skips, so it folds every other section exactly as
before and the codecs stay at version 1; and the catalog's values are open
— a client hides a row with a source, requires or run value it does not
know — so a later host can add one without a version of its own.
Side questions are announced the same way: where capabilities.aside is
absent — cursor, every older host — a client makes no session.aside call.
The aside notification goes only to a connection that asked, so a client
that does not know it never receives one; aside_busy and
aside_unsupported are new reasons under codes a client already decides
from.
The hub is announced the same way. A client learns it is talking to one from
hello's endpoint.kind, and what it serves from its connection
capabilities; an older client never dials it — it reaches hosts through the
registry, and refuses a hello from anything but a host where it expects
one. The hub lists hosts of every build side by side: each roster row's
row is that host's own row, read tolerantly, its capabilities.rowFacts
saying what it holds; a host with no stop keeps refusing
stop_unsupported however it was found. The hub's reasons
(host_only, connect_not_first, ambiguous_session, already_subscribed,
request_conflict, spawn_failed, host_unreachable) are new values of the
reason set, each under a code a client already decides from, so one that does
not know a reason decides by its code (Errors and retry);
the reset reason hub_closing ends only a roster subscription, which no older
client holds. A hub's capabilities.createOptions is absent on an older hub
(and on one given no answer): a client makes no sessions.createOptions call
there, and a client built before the method ignores the member. Its
capabilities.createTitle is absent on an older hub, which refuses a create
naming a title unknown_field: a client sends no title there (and says
why), and a client built before it ignores the member and sends none.
sessions.saved has no capability of its own in this release: an older hub
answers it unsupported, reason unknown_method, which a client takes as a
hub that cannot list the saved sessions (craze ps --saved then reads the
index itself).
The foreign turn¶
foreignTurn (on sessions.list rows, session.state, and inside a
snapshot) says the agent is running a turn of its own that craze did not
start: grok's interjection fallback, or a native wake (a background
sub-agent's or shell job's result). While one runs, the engine's own activity reads idle — a foreign
turn is not itself an activity, it overlays whichever one is current.
On the event stream, a foreign turn is bracketed by two foreign_turn events
whose foreignTurn member is {id, text?, reason?, running?} — running: true
on the start, absent on the end:
{"type":"foreign_turn","foreignTurn":{"id":"wake-1","text":"sub-agent result","reason":"subagent_wake","running":true},"at":"..."}
{"type":"foreign_turn","foreignTurn":{"id":"wake-1","reason":"subagent_wake"},"at":"..."}
reason is an open string — not a closed enum — with three values known
today: "" (grok's fallback), subagent_wake (a native child's wake) and
job_wake (a native wake delivering background shell jobs' results). An
unknown value renders with the default wording; a client must not fail on
one it has never seen.
A foreign turn has no EventDone and no EventError: nothing about how
it ended is on the wire beyond the end bracket (no running). A client learns only that it
is over, never its outcome. The end bracket (foreignTurn with no running) always precedes the
next turn's own output on the stream — an agent's own next turn (a queued
follow-up draining, say) never races ahead of the foreign turn's ending.
A foreign_turn ending followed by a non-empty queue does not
mean the queue is stuck: it can equally mean a queued row was restored to
the head of the queue and will drain on its own paced retry. A client tells
the two apart by session.state.activity (idle means it will drain; a
genuine stall shows an error), or by seeing a queued event for that same
row, at position 0, carrying the row's own cause, immediately before the
ending (the same batch, adjacent seqs).
session.cancel with no turnId cancels whatever turn currently holds the
session — craze's own, or the agent's foreign one — whichever the host is
looking at the instant it validates the call. There is a small residual
window here (tracked as follow-up SF-48): if a foreign turn ends and its
owed drain claims a queued row between the moment a client decided to cancel
and the moment the host validates that cancel, the cancel lands on the row's
new turn (which is cancelled, not restored) rather than on nothing. The
eventual fix is additive — a session.cancel{expect: "foreign"} that answers
stale_turn if the turn has already moved on — behind a future capability,
not in protocol 1.
Compaction¶
A native session compacts its context: it summarizes the conversation so far
and continues from the summary — on its own when the context nears the
model's window, when a request is refused as too large, or on request, when
the user sends /compact [focus] (the one command a native session
advertises, and an ordinary prompt on the wire: no method, no capability). No
ACP provider compacts through craze, so none of these events ever comes from
one.
On the event stream a compaction is two compaction events, started then
ended, {phase, reason, tokensBefore?, tokensAfter?, err?}, with the
event's agent naming the sub-agent whose context it was (absent for the
session's own):
{"type":"compaction","compaction":{"phase":"started","reason":"manual"},"at":"..."}
{"type":"compaction","compaction":{"phase":"ended","reason":"manual","tokensBefore":890000,"tokensAfter":21000},"at":"..."}
{"type":"compaction","agent":"child-1","compaction":{"phase":"ended","reason":"overflow","tokensBefore":300000,"err":"native: provider \"x\" failed (HTTP 500)"},"at":"..."}
reason is auto, manual or overflow — an open string, like the
foreign turn's: an unknown value is still a compaction. An ended carries
tokensBefore, the context's estimated size before it, and either
tokensAfter, its estimated size after, or err, why no summary was written.
A session/load replay carries only the ended of each stored compaction, in
place — with the /compact … user event before it for one the user asked
for — stamped replayed like the rest of the replay.
Every started is followed by its ended, whatever becomes of the
compaction. A client folding the stream draws "compacting context…" from the
started until the ended, and also stops at whatever ends the turn it ran
in — for the session's own, the turn's ending (done, error, turn ended,
a foreign_turn ending) or a replay's end; for a child's, that child's
finished — so a lost ended can never leave it up. The ended draws one
note in the transcript, context compacted · 890k → 21k tokens (on request
for manual, context was too large — compacted for overflow), or
compaction failed: <err>.
A snapshot carries an open compaction on the transcript it belongs to — the
main one or a child's — as compacting {since, reason}, absent when none is
open, so a client that attaches mid-compaction draws the same "compacting
context…" until the ended it folds next. Both fields are additive: a client
that does not know them ignores the kind and the member (see Tolerant
inbound, strict outbound), and neither
bumps a codec version.
Usage¶
A native session reports what it has spent and how full its context is: the
usage section, {contextTokens, contextWindow, turn, session}. It is a
state delta section like the model or the config — state.usage on a meta
event — and it stands in the two reads of where things are: session.state's
settings.usage and a snapshot's settings.usage. No ACP provider reports
usage through craze, so for every ACP session the key is absent — never
null and never {} — as it is for a native session before its first step.
A client reads the key's presence as "this session reports usage".
settings.usage is reported state, not a setting: it sits in settings
because that is where a session's state sections are read, but no client
changes it. It is never a session.set kind — those are model, mode and
config — so a client that draws settings as controls leaves it out of
them, and shows it as a read-out.
{"type":"meta","state":{"usage":{"contextTokens":68000,"contextWindow":200000,
"turn":{"input":8000,"output":2000,"reasoning":0,"cacheRead":58000,"cacheCreation":0,"costPicoUsd":71400000000,"unpriced":false},
"session":{"input":68000,"output":3500,"reasoning":0,"cacheRead":58000,"cacheCreation":0,"costPicoUsd":273900000000,"unpriced":false}}},"at":"..."}
contextTokensis the estimated size of what the session's next request sends;contextWindowis the window of the model it goes to,0when craze does not know it.turnis what the turn the report followed spent — after a load, the last turn the stored session recorded — andsessionwhat the whole session has: its own requests, its compactions' (a/compactis a turn of its own), and the sub-agents its turns ran, each priced by its own model.- Tokens are as the provider billed them:
inputis the uncached input, andreasoningis part ofoutput, never counted twice.costPicoUsdis the priced part's cost in picodollars (10⁻¹² US dollars:$1is1000000000000), exact, each request at its own price tier when its model has one (long-context pricing).unpriced: truesays some usage in the sum had no price in craze's model table: its tokens are counted and it added no cost, so the cost is a floor. - Every key is always present, a
0and afalseincluded.
A report comes after every step, every compaction that ran, and once at the
end of a session/load replay. Each carries the whole section — nothing is
summed on the client — so the last one folded is where the session stands. A
load's report rides in the load's install delta (the one stamped replayed
before the replay's end), never in a delta of its own inside the bracket. A
usage-only delta prints no craze prompt --json line, but it takes a seq
like every event. The section is additive, like compaction: no codec
version bump and no capability.
Live models¶
A native session's model list changes while it runs: a key saved, the
ChatGPT plan signed in to or out of, or the plan's or the Anthropic account's
list fetched again is offered at once (the model it runs on, and everything it froze at open — its
prompt, its tools, the model-facing agent tool's menu, its compaction and
sub-agent settings — stay as they were). Each change reaches a client as the
catalog section, {models, revision}, a state delta section like the
usage section — state.catalog on a meta event — carrying the whole list
the session offers now, in the picker's order, and the list's revision:
{"type":"meta","state":{"catalog":{"models":[{"id":"grok","name":"Grok"},{"id":"fast","name":"Fast"},{"id":"fireworks/kimi-k3","name":"Kimi K3 (Fireworks)","recent":1}],"revision":1}},"at":"2026-01-01T00:00:00Z"}
- Each model is the info document's catalog model:
idandnamealways,recentits rank and absent for a model not remembered, and its effort facts (efforts,defaultEffort,switchEffort) where the session capabilityeffortFactsistrue. The list has bounds of its own, which the session keeps where it builds the list — socatalogs.modelskeeps them too, and the section fits every record limit and every snapshot budget whatever the owner's model files say: at most 512 models, in the picker's order, the running model always among them; eachnameat most 128 bytes, a longer one cut on a character boundary and ended with…; and a model whoseidwould be over 256 bytes is not offered at all, since an id is never cut — one cut short would be one nosession.setcould name; a model's effort facts at most 8 levels of at most 32 bytes each (the model files refuse more as they load), or none. The session says on its diagnostics, once and without the text, what the bounds cut. Real tables are far inside them. revisionis always written. It is0for the list a session started with, and only ever grows within one incarnation, inseqorder. A change found while a turn runs (apendingrefresh) is published once that turn has ended, and none is ever published inside a load's replay bracket.- A native session also publishes the section in the same delta as a model
switch or an effort change it confirmed, beside its
modelandconfigsections, whenever that changed any model's rank or effort facts: the model switched to ranks first, and its row'sswitchEffortis the effort it was left at — the same models, at the next revision. A change that changes no row publishes no section. - No ACP session publishes one, and neither does a native session before its
first change: the key is absent then — never
null, never{}— and the list is the info document's.
The section stands in the reads of where things are, as usage does: a
snapshot's settings.catalog (the last section a delta carried) and
session.state's settings.catalog (present from revision 1), and the
info document's catalogs.models, built from the list as it stands when the
document is built, with its catalogs.revision. So an attach after a change
— its reply, its snapshot, a ready notification, a sessions.list row —
already carries the new list.
The revision rule. A client never applies a revision lower than the one
it holds: a catalog section older than the list it has is ignored, and of the
list a document carries and the list its fold holds, it shows the one with
the higher revision (the same revision is the same list). So a reconnect, a
replay, or a document read before a delta and folded after it never brings
back a list the session has moved past — a model a reload took away never
reappears. A new incarnation is a new session: its snapshot replaces the
fold, and revisions start again from 0.
Revisions may skip. Within one incarnation a revision — the catalog
section's and the info document's catalogs.revision alike — only grows, but
a client must not expect every number: a native session numbers each reload of
its list as the reload starts, and a reload overtaken by a later one publishes
nothing under its number. A reader also sees only the revisions after it
attached. A client compares revisions (higher wins, the same revision is the
same list) and never counts them or treats a gap as a lost update.
A catalog-only delta prints no craze prompt --json line, but takes a seq
like every event. The section is additive: no codec version bump; the method
that asks for a change now, session.models.refresh,
has the capability modelsRefresh.
Slash catalog¶
A session says which / commands it offers, and what running each one does:
the slash catalog section, {items}, a state delta section like the
catalog section — state.slash on a meta event — and a snapshot's
settings.slash. The host builds it with the same builder the TUI's own menu
uses, from the session's facts: craze's builtins under the session's gates,
the agent's advertised commands, the plugin commands and skills craze found,
and (for cursor, grok and gx) the provider's disk skills. A client renders the
rows instead of re-deriving the TUI's rules. A session publishes it where
capabilities.slashCatalog is true — every cursor, grok,
gx and native session on a host that has it.
{"type":"meta","state":{"commands":{"commands":[{"name":"research","description":"Agent-advertised command"},{"name":"btw","description":"The agent's own btw"}]},"slash":{"items":[
{"name":"help","description":"Keybindings and commands","source":"builtin","whileWorking":true,"run":{"kind":"local","local":"help"}},
{"name":"model","description":"Switch model","source":"builtin","run":{"kind":"picker","arg":"optional","argHint":"<model> [effort]","picker":"model"}},
{"name":"rename","description":"Rename this session, /rename <title>","source":"builtin","whileWorking":true,"run":{"kind":"method","method":"session.setTitle","param":"title","arg":"required","argHint":"<title>"}},
{"name":"plan","description":"Set plan mode","source":"builtin","run":{"kind":"method","method":"session.set","setting":{"kind":"mode","value":"plan"}}},
{"name":"ask","description":"Set ask mode","source":"builtin","run":{"kind":"unavailable","reason":"mode ask is not advertised"}},
{"name":"btw","description":"Ask a side question (not saved): /btw <question>","source":"builtin","requires":"aside","whileWorking":true,"run":{"kind":"aside","param":"question","arg":"required","argHint":"<question>"}},
{"name":"exit","description":"End this session","source":"builtin","requires":"stop","whileWorking":true,"run":{"kind":"method","method":"session.stop"}},
{"name":"effort","description":"Change the current model's effort","source":"builtin","requires":"effort","run":{"kind":"picker","arg":"optional","argHint":"<level>","picker":"effort"}},
{"name":"research","description":"Agent-advertised command","source":"agent","run":{"kind":"prompt","arg":"optional"}},
{"name":"btw","description":"The agent's own btw","source":"agent","run":{"kind":"prompt","arg":"optional"}},
{"name":"review","description":"Review a pull request","source":"plugin","plugin":"pr-tools","qualified":"pr-tools:review","run":{"kind":"prompt","arg":"optional"}},
{"name":"deploy","description":"Ship it to production","source":"skill","run":{"kind":"prompt","arg":"optional"}}
]}},"at":"2026-01-01T00:00:00Z"}
(An illustration, its rows drawn from the fixtures 30-slash-catalog-acp
and 31-slash-catalog-native — the ask row's from a session that does not
advertise ask mode. A real catalog lists every builtin its session has.)
The rows¶
The rows are in the order a menu lists them, and each name appears once —
case-insensitively, the first source to claim a name keeps it, in the order
builtins, the agent's commands, plugin rows, disk skills — except that a row
with requires claims no name (below). Each row:
| field | meaning |
|---|---|
name |
the token without its /: one run of characters with no whitespace or control character, what a client matches, accepts and runs the row by. Always present, and never cut |
description |
what the TUI's menu draws for the row, folded onto one line: a builtin's own sentence, or the advertised, plugin or skill description. Absent when there is none |
source |
builtin (craze's own), agent (a command the agent advertised), plugin (a plugin command or skill craze found and expands) or skill (a disk skill the agent resolves) |
plugin, qualified |
a plugin row's: the plugin it came from, and its plugin:name spelling — present only where it differs from name, the second spelling the row answers to |
requires |
what the row needs: the session capability stop or aside — show the row only where that capability is true — or effort, the client's own reading of the session's state: show the row only while the current model has an effort option (a config option the client reads as the effort, as the TUI's status row does). Absent for none |
whileWorking |
true where the row runs while a turn is running — craze's or the agent's own (foreignTurn) — rather than waiting for the turn to end. Absent is false; a prompt row leaves it absent, since a prompt queues |
run.kind |
what selecting the row does — the next table |
run.arg |
absent for none (the row takes no argument), optional or required; run.argHint is how to show it (<title>) |
run.method |
a method row's method: session.setTitle, session.set or session.stop |
run.param |
the parameter the argument fills: title (session.setTitle), question (an aside row) |
run.setting |
a session.set row's setting, sent verbatim: {kind: "mode", value}, the mode already resolved to an id the session advertised |
run.picker |
a picker row's picker: model or effort |
run.local |
a local row's action, the client's own: help, clear, tasks, theme or connect |
run.reason |
why an unavailable row cannot run, in the words the TUI refuses it with (mode ask is not advertised) |
run.kind |
selecting the row |
|---|---|
prompt |
sends /name — or /qualified — and the argument, if any, after a space, as a session.prompt's text, which starts a turn or queues as any prompt does. craze expands a plugin row into its file's prompt on the host; an agent command or a skill goes to the agent as typed |
method |
calls run.method with its own params plus the client's usual sessionId and commandId: session.setTitle with the argument as title; session.set with setting = run.setting; session.stop with nothing more |
picker |
opens the client's picker named by run.picker. model's lists the info document's catalogs.models (and the catalog section's, live models) and sends session.set {kind: "model"}; a client that parses <model> [effort] may also take the model inline, and one that does not shows only the picker. effort's lists the current model's effort option's values and sends session.set {kind: "config", id, value, forModel} for that option, bound to the current model; a client may take the level inline (<level>), and with effortFacts may read default as the model's defaultEffort |
aside |
asks a side question, session.aside, the argument as question |
local |
runs the client's own action named by run.local — a help view, clearing the client's transcript view, the tasks panel, the theme picker (or /theme <name>), storing a provider key. None touches the session, and connect stores a key on the machine the client runs on, so a remote client hides it |
unavailable |
nothing: the row cannot run in this session. A client lists it or hides it as it likes, and selecting it reports run.reason. The TUI lists the mode rows a session shows modes for, and refuses one the session did not advertise this way |
Every value of source, requires and the run keys is open — a later host
may add one, and the schema does not close them: a client hides a row with a
value it does not know, and only that row — an unknown source, run.kind,
run.method, requires or run.arg, a run.picker or run.local it has no
action for; a run with no kind has a kind the client does not know.
Malformed rows are dropped individually. A row a client cannot read — not
an object, a key of the wrong type ("name":42, "run":5), a name that is
empty or not one token — is dropped, and only that row: a client never rejects
the section, still less the event, for one row, and a slash or items value
that is not an object or an array is a section with no rows (which still
replaces the catalog, as {} does). A key a client does not know is ignored.
craze's own decoder does exactly this, so one bad row from a host never ends a
craze attach or a remote TUI's stream; craze itself never writes such a
row.
Which rows a client shows¶
requires. A row withrequiresis shown only where what it names is true — the session capabilitystopforsession.stop,asideforsession.aside, andeffortwhile the current model has an effort option, which the client reads from the session'sconfigand which changes with the model. It is always listed, because whether a session is served withstopandasideis the host's fact, not the session's, and whether its current model has an effort option changes without the catalog changing.- A
requiresrow does not claim its name. The catalog deduplicates the rows after it as if it were not there, so a client keeps, per name (case-insensitively), the first row it actually shows. On cursor (asideabsent) the builtin/btwis hidden and an agent's own/btw, listed after it, shows; on grok the builtin shows and the agent's is hidden. The same for/efforton a model with and without an effort option. - Client rows. A client may add rows of its own, and a client row wins
over a catalog row with the same name (case-insensitively), which it
hides. The host never lists one: whether a client has a session list is
the client's fact. The TUI adds
/sessions, before/exit, where it has a hub session list; without one, an agent's own/sessionsshows. whileWorking. While a turn is running —activityworking, or aforeignTurn— a client runs only the rows withwhileWorking, and sends apromptrow as it sends any prompt (it queues). The TUI leaves any other builtin in the composer until the turn ends; the host itself refuses nothing for it.
Carrying it¶
The section follows the list sections' rules. Absent is unchanged;
present replaces the whole catalog; present and empty ({}) clears it.
Restoring a snapshot and folding the deltas after it gives the catalog
folding the whole stream gives.
- One delta per change. A delta that carries
commandsorpluginsalways restatesslashbeside them — even when the catalog came out the same, so a client reads the two as one change; a change of the disk skills alone carriesslashalone; an update that changes nothing publishes nothing. Every install restates it: session/new's, and a load's, stampedreplayedbefore the replay'send. - Disk skills. For a cursor, grok or gx session the host looks at the
provider's skills on disk at the install, after every turn of craze's ends
(done, cancelled or failed), at each
available_commands_update, and at eachsession.attach: a skill created while the session sat idle reaches a client at the next attach — in that attach's snapshot or a slash-only delta after it: the look starts as the attach does, and can finish before the snapshot is cut, so a client must not wait for a delta its snapshot already made unnecessary — or after the next turn. A native session's skills are plugin rows, found once at its start. - The snapshot.
settings.slashis the last section a delta carried, absent when there is none, andsettings.truncated.slashsays the snapshot did not carry it whole. Truncation never alters an action: a row whosename,source,plugin,qualified,requiresorrunkey other thanargHintandreasonis over 262,144 bytes (256 KiB, the snapshot's item cap) is dropped, never cut, since a cut name would run a command that does not exist;description,run.argHintandrun.reasonare cut to their head at that size, as a command's description is; and at most 512 rows are carried.truncated.slashis set when a row was dropped or a string cut, and the next section a delta carries clears it. Real catalogs are far inside these bounds. - The catalog yields to the snapshot's budget. It repeats what the
commandsandpluginsbeside it already carry, so it is never what makes an attach fail: when the snapshot's mandatory state — with the main transcript's newest entry — would not fit its budget with the catalog, the snapshot carries nosettings.slashand setssettings.truncated.slash.truncated.slashwith noslashmeans the catalog did not fit: the client holds no catalog until the nextslashsection a delta carries restates it — at the next change of the session's commands, plugins or disk skills — and meanwhile offers what it would without one (the TUI builds its menu itself). Where even that mark would not fit, it is left out too, and the snapshot is exactly the one a host without the catalog would send; a client then reads it as a session whose catalog is still to come. An attach that would succeed without the catalog always succeeds with it. - The stream carries the section whole, uncapped, as it carries
commandsandplugins: an event over the event log's record limit is omitted like any other, and the client attaches again, its snapshot bounded as above. - It is not in
session.state'ssettings: the stream and the snapshot are where it is read.
A slash-only delta prints no craze prompt --json line, but takes a seq
like every event. The section is additive: no codec version bump, and a
client that predates it skips the key and folds every other section exactly
as before — the capability slashCatalog is how a client knows to look.
The menu, as the TUI draws it¶
How a client matches, completes and runs what is typed is its own; the TUI's rules, which a client can follow to feel the same:
- The token is the run of non-whitespace characters around the cursor,
starting with
/at the start of the draft or after whitespace (sofoo/barandhttps://xare never one). A bare/lists every row. - Builtins (
sourcebuiltin) are offered only when the token is the draft's first word and the draft is one line — the only place one runs. - Matching is case-insensitive, in four groups, each in catalog order:
namestarting with what was typed, thenqualifiedstarting with it, thennamecontaining it, thenqualifiedcontaining it. No fuzzy matching and no cap; the list scrolls. - Accepting a row (Tab) replaces the token with
/name— or/qualified, when the typed token holds a:and the row has aqualified— taking in one space that already followed it. - Enter on a highlighted row whose name (either spelling) is not yet
fully typed accepts it; once the token spells it, Enter runs it (or sends
it). A bare
/accepts the first row.
Errors and retry¶
A refused call's error object:
{"code":-32000,"message":"engine: not accepting that now","data":{"code":"not_accepting","reason":"not_accepting"}}
| field | |
|---|---|
code |
the JSON-RPC integer: -32700 parse error, -32600 invalid request (a batch, an over-long line, a missing method), -32601 unknown method, -32602 invalid params, -32000 for every craze-level refusal |
message |
for people; never matched on |
data.code |
the closed set a client decides from — see Codes and retry |
data.reason |
a second, finer closed set naming the exact sentinel — for wording and for reconstructing the underlying Go error, never for retry |
data.result |
a result carried beside the error: session.cancel's {outcome, turn, reported}, or hello's {supported} on protocol_version |
data.cause |
a wrapped failure's own text — session.setTitle's index-write half, or a start failure |
A client decides everything from data.code alone. data.reason is
richer but is not part of the retry contract; a client that only knows code
still behaves correctly.
Codes and retry¶
| code | the command | resend the same commandId? |
|---|---|---|
unavailable |
did not run — a gate refusal | yes |
not_accepting |
did not run — a gate refusal | yes |
in_progress |
still running under this exact id | yes (mandatory: it is the safe way to learn the answer once it lands) |
stale_model |
did not run — refused before the provider was asked, because the session had already left the model the change was bound to | yes, once the session is back on that model — it is judged fresh, never as a replay of the refusal |
| every other code | ran (or is this command's own stored answer) — when the id ever reached the engine | no — a resend replays the stored answer; send a new commandId for another attempt |
That "ran" column is only true once a commandId has reached the engine.
bad_request, unknown_session and unsupported can also be returned
before that — a malformed request, an unknown session, or an unsupported
method are refused by the socket layer itself (internal/control/dispatch.go,
handlers.go), with no command receipt at all. Resending one of those is
just resending the same malformed or unsupported request, never a replay of
a stored answer. The hub holds no command receipts at all: every refusal of
its own is of that kind.
aborted is the one exception worth calling out: the command ran, but its
outcome cannot be vouched for (a caller's context ended mid-flight, a call
that panicked). Re-read state before deciding anything else; never blindly
resend the same intent under a new id, or a queued interjection can go in
twice.
Retrying is also bounded by the connection: after a reconnect, a client
resends an in-flight command only when the reconnecting hello answered
resumed: true for the exact client id, token and host it held before —
see Attach, resume, and snapshots.
A create across a hub restart. session.create (the hub's, where its
hello says sessionCreate: true) carries a requestId, which the hub
stamps into the new host's registry entry, so that a hub that restarts still
knows the session a create started. A client that sees the hub's epoch change
(its hello's endpoint.hostId is new) while a create of its own went
unanswered must not retry that create automatically unless it reuses its
requestId: under the same id the new hub answers with the session the
first create started (or joins its start if it is still running), while a new
id is a new create, and can start a second session.
A reopen, sent again. session.reopen has no request
id: its safety is the session's claim, which lets one host serve a session at
a time, and every reopen joins that host (a TUI-hosted session running
unclaimed excepted). A client that lost a reopen's
answer sends the same {id} again, once, on a fresh connection; the host id
and joined may differ from the first try's. Its not_accepting refusals
(not_resumable, start_failed) are final for that session, not "try
later": nothing is resent under them.
The reason table¶
Every reason a host or the hub sends, grouped by its one code — a reason is always one code's. Client-side reasons (last group) are named here so a client never mistakes one for a host's own answer — no host ever sends them.
| code | reasons a host or the hub sends |
|---|---|
not_accepting |
not_accepting, not_in_turn, start_failed; the hub's not_resumable |
aborted |
command_aborted, set_outcome_unknown, bad_catalog, context |
unavailable |
log_backed_up, ask_unavailable, set_unavailable, not_run, attach_raced, not_ready, busy, aside_busy, closing; the hub's spawn_failed, host_unreachable |
failed |
option_gone, failed, response_too_large, snapshot_too_large |
bad_request |
bad_request, bad_answer, hello_required, unknown_field, line_too_long, protocol_version, bad_token, already_attached; the hub's connect_not_first, ambiguous_session, already_subscribed, request_conflict |
unsupported |
unsupported, unknown_method, stop_unsupported, models_refresh_unsupported, aside_unsupported, roster_unsupported, hub_only; the hub's host_only |
unknown_session |
unknown_session |
unknown_ask |
unknown_ask |
already_submitted |
already_submitted |
already_resolved |
already_resolved |
queue_full |
queue_full |
text_too_long |
text_too_long |
prompt_in_flight |
prompt_in_flight |
foreign_turn |
foreign_turn |
prompt_cancelled |
prompt_cancelled |
stale_version |
stale_version |
stale_turn |
stale_turn |
unknown_row |
unknown_row |
unknown_command |
unknown_command |
in_progress |
in_progress |
stale_model |
stale_model |
index_write |
index_write |
unknown_subagent |
unknown_subagent |
| (client-side; no host ever sends these) | resume_lost, disconnected (both ErrOutcomeUnknown after a reconnect — see above), no_answer (a client-local deadline passing with no reply at all) |
The code → shed LaneError table¶
shed_core::lane::LaneError (shed/crates/shed-core/src/lane.rs) is shed's
one closed error type across every agent lane it drives, craze's included.
It is deliberately smaller than craze's own code set — nine variants against
craze's twenty-three — so several craze codes map onto the same
LaneError, and the mapping below is craze's own judgment call about which,
not something the wire states. A craze client that is not shed can use
craze's own codes directly and does not need this table.
craze data.code |
LaneError |
why (where not obvious) |
|---|---|---|
bad_request |
BadRequest |
|
unknown_session |
UnknownSession |
|
unknown_ask |
UnknownApproval |
craze's "ask" is shed's "approval" |
already_submitted |
AlreadySubmitted |
|
already_resolved |
AlreadyResolved |
|
not_accepting |
NotAccepting |
did not run — a gate refusal; protocol.Retry is true, and craze resends the same commandId, which is exactly NotAccepting's own posture: "not yet, try later". A session.reopen's not_resumable and start_failed are the exception: final for that session (its refusals) |
foreign_turn |
NotAccepting |
"the session will not take this send-now right now" is exactly NotAccepting's posture, but unlike not_accepting and in_progress, protocol.Retry is false: retrying under the same commandId after the foreign turn ends replays the stored foreign_turn refusal rather than trying again, so a later attempt needs a new commandId |
in_progress |
NotAccepting |
did not run under a new attempt — it is this command's own attempt, still running; protocol.Retry is true (mandatory: it is the safe way to learn the answer). A resend of a command still running reads the same way to a caller: "not yet, try later" |
stale_model |
NotAccepting |
did not run — refused before the provider was ever asked, because the session had already left the model the change was bound to. protocol.Retry is true: craze resends the same commandId once the session is back on that model, judged fresh, never as a replay. This is not an argument error — NotAccepting's "will not take this right now" is the closer shed posture, and it is the closest fit shed has, not an exact one: a shed caller should still consult craze's own data.code (and protocol.Retry) rather than assume shed's NotAccepting alone carries the "resend once the model comes back" rule |
unavailable |
Unavailable |
did not run — a gate refusal; protocol.Retry is true, and LaneError::Unavailable's own doc ("nothing to talk to… quiet") already reads as retryable rather than terminal. The hub's host_unreachable (a host it could not dial) and spawn_failed (a session it could not start) read the same way |
unsupported |
Failed |
reachable by ignoring a capability already false, or by sending a method protocol 1 does not know at all (unknown_method, no capability involved), or by sending the hub a session's method (host_only: a session's methods are its host's, through the splice); LaneError has no dedicated variant for any of them |
stale_version |
BadRequest |
about this command's own arguments (an edit against a row version that moved), the same posture as a malformed request; protocol.Retry is false — it ran (was refused) and a resend replays that refusal |
stale_turn |
BadRequest |
a cancel naming a turn that is no longer current — likewise about the command's own arguments; protocol.Retry is false |
queue_full, text_too_long, prompt_in_flight, prompt_cancelled, unknown_row, unknown_command, unknown_subagent, aborted, failed, index_write |
Failed |
no LaneError variant models a queue, a row, a sub-agent or "ran but the outcome cannot be vouched for"; the craze code's own text, kept in Failed's string, is what a shed client actually shows |
| (unused) | Unauthorized |
protocol 1 has no authentication (hello.auth is reserved for S6); no craze code maps to it today |
The gate table¶
What each of internal/engine's own gated commands answers in every state
the engine can be in — the table's columns are its mutating methods, every
one but session.stop. That one is the host's, not the engine's: it is
mutating and session-scoped, answered {} from any client in every state
below — closing and closed included — by a host that serves it, and
stop_unsupported in every state by one that does not; it is never stored,
so never retried by id (above). It is not
every method protocol 1 defines: reads (session.state, session.snapshot,
sessions.list, asks.list, asks.get), the attachment methods
(session.attach, session.detach), hello, and the methods a host never
serves are answered as their own sections above describe, not by this table.
This table is
rendered mechanically from internal/engine's own
TestTheGateTableIsTheEngines — the same test that drives every cell through
the real engine — by gateTableMarkdown(), and
TestPublishedGateTableIsTheTested fails the build if the section below
drifts from that render. Do not hand-edit between the markers.
| state | prompt queue | prompt send_now | interject | cancel | disarm | queue verbs | set | setTitle | answer | subagent.cancel |
|---|---|---|---|---|---|---|---|---|---|---|
| starting | not_accepting |
not_accepting |
not_accepting |
not_accepting; allowed if an ask is pending; allowed if a foreign turn runs; stale_turn naming a turn that is not current |
not_accepting |
not_accepting |
not_accepting |
not_accepting |
allowed | allowed — the session's answer |
| replaying | not_accepting |
not_accepting |
not_accepting |
not_accepting; allowed if an ask is pending; allowed if a foreign turn runs; stale_turn naming a turn that is not current |
not_accepting |
not_accepting |
not_accepting |
not_accepting |
allowed | allowed — the session's answer |
| idle | starts | starts | not_accepting (not_in_turn); unsupported if the provider cannot interject |
not_accepting; allowed if an ask is pending; allowed if a foreign turn runs; stale_turn naming a turn that is not current |
not_accepting — nothing is armed |
allowed | allowed | allowed | allowed | allowed — the session's answer |
| working | queues | arms; already_submitted if one is armed |
allowed; unsupported if the provider cannot interject |
allowed; stale_turn naming a turn that is not current |
not_accepting; allowed if a send-now is armed |
allowed | allowed | allowed | allowed | allowed — the session's answer |
| cancelling | queues | arms; already_submitted if one is armed |
allowed — the session's answer: it has not been told of the cancel yet; unsupported if the provider cannot interject |
allowed; stale_turn naming a turn that is not current |
not_accepting; allowed if a send-now is armed |
allowed | allowed | allowed | allowed | allowed — the session's answer |
| waiting | queues | foreign_turn |
not_accepting (not_in_turn) — the session's answer: no turn of craze's is open |
allowed; stale_turn naming a turn that is not current |
not_accepting — nothing can be armed |
allowed | allowed | allowed | allowed | allowed — the session's answer |
| error (start failed) | not_accepting |
not_accepting |
not_accepting |
not_accepting; allowed if an ask is pending; allowed if a foreign turn runs; stale_turn naming a turn that is not current |
not_accepting |
not_accepting |
not_accepting |
not_accepting |
allowed | allowed — the session's answer |
| stopped | not_accepting |
not_accepting |
not_accepting |
not_accepting; allowed if an ask is pending; allowed if a foreign turn runs; stale_turn naming a turn that is not current |
not_accepting |
not_accepting |
not_accepting |
not_accepting |
allowed | allowed — the session's answer |
| closing | not_accepting |
not_accepting |
not_accepting |
not_accepting — always, even with a foreign turn running or a turn named |
not_accepting |
not_accepting |
not_accepting |
not_accepting |
allowed — the registry is still open: the session's own close, not yet run, is what ends every ask; unknown_ask naming an ask never opened |
not_accepting |
| closed | not_accepting |
not_accepting |
not_accepting |
not_accepting — always, even with a foreign turn running or a turn named |
not_accepting |
not_accepting |
not_accepting |
not_accepting |
already_resolved — the close ended every ask |
not_accepting |
| + foreign turn | queues | foreign_turn |
not_accepting (not_in_turn) — the session's answer |
allowed — written at once; stale_turn naming a turn that is not current |
not_accepting — nothing can be armed |
allowed | allowed | allowed | allowed | allowed — the session's answer |
| + ask pending | as the row | as the row | as the row | allowed — in every row but closing or closed, where e.closed refuses it outright | as the row | as the row | as the row | as the row | as the row | as the row |
waiting is craze prompt's own foreign-turn retry policy (it holds a
refused claim and retries once the foreign turn ends); no socket client sees
it — the TUI shows the plain refusal instead, and a socket client sees
foreign_turn from session.prompt{mode: send_now} exactly as the +
foreign turn row shows.
The hub splice¶
Protocol 1 defined the splice before any hub existed, so the hub (S4b)
needed no change to it. A connection to the hub starts in hub mode:
hello (the hub's result: no client id, no receipts),
then the roster — sessions.list, sessions.subscribe — or the splice.
session.connect{sessionId} makes the hub dial that session's host and
splice the two connections together: the hub answers {} — the last line it
writes on the connection — and from the next byte on, the client is talking
directly to the host, starting with its own hello. So client ids,
tokens and receipts, always the host's, are minted by the host the splice
lands on, and a resumed connection resumes with that same host, never with
the hub. Bytes the client sent after session.connect before the reply — a
pipelined host hello — reach the host first, in order.
The splice takes the whole connection, so session.connect must be the
first request after hello — any line the hub has answered since
hello, a refused one included, makes it not the first — with nothing else
in flight and no roster subscription; otherwise it is refused bad_request,
reason connect_not_first, and the connection stays in hub mode. sessionId
is matched against every live host's craze session id, provider session id
and host id, as craze bridge --session matches it:
| the match | answer |
|---|---|
| exactly one host | {}, then the splice |
| none | unknown_session |
| more than one | bad_request, reason ambiguous_session — name the host by its host id |
| one, which the hub cannot dial | unavailable, reason host_unreachable |
| none known: the hub cannot read its registry | unavailable, reason host_unreachable |
The hub's dial is a connect and a peer check (the host must run as the same
user), within 500 ms. The hub says nothing to the host: the host sees the hub
as its peer, and hello's via stays as the client sent it, absent.
Once spliced, each side's bytes reach the other as they were sent, through one 32 KiB buffer per direction: a client that stops reading stops the host's writes toward it, which the host's own write-stall bound (60 s) then ends; the hub's own writes give up on a peer that takes nothing for 60 s. Closing is carried through:
- a client that closes its writing half (or its connection) closes the host leg's writing half only: the host sees the client go — its attachment stops counting toward the host's idle exit — and still answers what it admitted, which reaches the client;
- a client gone entirely, not just half-closed, ends the splice within about a second once the splice has read the client's EOF and half-closed the host leg: the hub closes both legs, however quiet the host is (a client that only half-closed, and still reads, keeps the splice). A client that closes while the splice is still blocked writing to a host that has stopped reading has not reached that point, and is reaped by the write-stall bound instead;
- the host's end (its connection closed) closes the client's connection;
- any other failure closes both.
A spliced connection counts as one of the hub's clients, keeping it from its idle exit, until both legs are closed. A hub shutting down half-closes every splice — each side reads its end after everything already forwarded — and closes it once it has ended or half a second has passed. A hub that dies takes its splices with it and nothing else: the session lives on in its host. The client redials the hub — craze's own clients start one when none answers, and the new hub finds the session from the registry at once — and resumes with its token, from its cursor.
A per-session host answers session.connect unsupported, reason
hub_only: a client dialing a host directly is already where the splice
would have put it. The mirror: the hub answers every other session-scoped
method unsupported, reason host_only.
The published schema¶
Every method, notification and shared document has a hand-written JSON
Schema (2020-12) file, embedded in
internal/protocol and checked by reflection against the Go wire types both
ways (TestSchemaCoversEveryWireField) — a field the code sends that the
schema does not describe, or vice versa, fails the build — the hub's
session.create, sessions.createOptions and sessions.saved among them,
which a host answers unsupported, reason hub_only, like session.connect. The event and snapshot codecs' own
hand-shaped wire structs get the same treatment in internal/agent
(TestEventSchemaCoversTheCodec) and internal/transcript
(TestSnapshotSchemaCoversTheCodec), since this package cannot import
either.
The copy published here, under reference/protocol/schema/, is byte-for-byte
the same as the one internal/protocol embeds
(TestPublishedSchemaIsTheEmbedded fails the build otherwise — it walks the
published copy recursively, so a file or directory added anywhere under
reference/protocol/schema/, at any depth, fails the build, not only one
added directly under it). The embedded side (//go:embed schema/*.json) is
flat by construction: the glob matches files directly under schema/ only,
so a subdirectory or a non-.json file added there is silently left out of
the embed rather than failing anything — TestSchemaSourceHasNoNestedFiles
guards that assumption on the source directory itself
(internal/protocol/schema/, read straight off disk with os.ReadDir, not
through the embed), so a nested or non-.json file added there fails the
build too. Every file's
$id is https://charliek.github.io/craze/reference/protocol/schema/<name>,
so a $ref between files resolves at this URL exactly as it does against the
embedded copy — e.g. hello.json,
event.json,
session.attach.json.
Fixtures and the fake host¶
internal/fakehost/testdata/wire/*.ndjson is thirty-two scripted scenarios
against a real internal/control server over a real engine (wrapping the
TUI's own Stub, never a fixture-only re-implementation) — hello and a fresh
attach; a cursor resume and its replay; a foreign-incarnation cursor; a
slow_consumer reset and cursor re-attach; an ask answered twice; an invalid
answer; a cancel on idle; a resent commandId, replayed and then mismatched;
two clients sharing one prompt; four kinds of refusal; a snapshot of the main
transcript and a child's; an omitted reset; a hello resume with a right
token, a wrong one, and one aged past its bound; a host that serves
session.stop, its info document carrying permissionMode and startedAt,
answering a stop's receipt and then ending the session; a stop joined by
another client's and by its own resend, with an attach refused closing;
and lastTurn in session.state and a roster row, after a cancelled turn
and a foreign one; a host with the row facts through a turn — a running
tool, streaming text, an ask, the ending; a catalog whose remembered
models carry their recent rank; and, against the hub in front of a host,
the hub's hello and roster (19), a roster subscription told of the
host's change and then of its leaving the registry (20), and a splice — a
session.connect with the client's host hello pipelined behind it, an
attach and an event through it, and on a second connection a connect to no
session and one that is no longer the first (21); against a hub that
creates sessions, a session.create with a first prompt, taken, its repeat
answered the same, the same requestId with other params refused
request_conflict, and a create whose session's start fails,
start_failed with its cause (22); a presence host
counting two clients — each told after its synchronized, the first told
again of the second's arrival and of its detach, the row saying attached: 2
(23); a native-like session's session.models.refresh — current with
sameDir: true, then applied at revision 1 with sameDir: false, its
catalog delta before the reply and on a second client's stream, then
current again — and a third client whose attach reply, snapshot and
session.state carry the new list at its revision, then, through the hub, a
fourth whose splice carries an attach whose snapshot holds the list and that
client's own refresh, applied at revision 2 after its catalog delta,
the delta reaching the three direct clients too (24); and, through the
hub, the method sent to the hub itself (host_only) and, through a splice,
an ACP-like session whose info document says no modelsRefresh — the
document an older host sends too, to which a client makes no call — refusing
it models_refresh_unsupported (25); and a hub that serves
sessions.createOptions, its hello saying createOptions: true,
answering it with its answer as it stands and refusing a stray params member
unknown_field, beside the host, whose hello has no createOptions and
which refuses the method hub_only (26); a session that answers side
questions — its info document saying aside: true — answering a
session.aside with four aside notifications, one per piece of its scripted
answer, and then the reply, while a second attached client's next line is its
own session.state reply, then a follow-up carrying the first pair as
prior (27); a session.aside held after its first notification, cancelled
by session.aside.cancel — the aside's aborted, reason context, before
the cancel's {} — and the same cancel again, answered {} (28); and a
session that answers none — cursor's, or an older host's — whose info
document says no aside, refusing both methods aside_unsupported (29); a
session that publishes the slash catalog — its info
document saying slashCatalog: true, its attach snapshot carrying
settings.slash — whose agent's commands update is one delta carrying the
commands and the catalog restated (an agent's own /help shadowed by the
builtin, its own /btw listed after the builtin's requires row), an
identical update publishing nothing, a disk skill found by a scan the
catalog alone (a skill an agent command shadows left out), an identical scan
nothing — a session.sync answering at the seq the two deltas left — and a
second client whose attach snapshot carries the catalog as it now stands
(30); and a native session's catalog — /connect after /model, a plugin row
with its qualified spelling and one shown qualified because a builtin
holds its bare name, its info document saying stop, aside and
slashCatalog — whose plan row's setting and rename row's title,
sent as written, change the mode and the title (31); and a hub whose
session.create takes a title — its hello saying createTitle: true — a
create whose title of tabs, runs of spaces, escape sequences, a zero-width
space and a bidi override is normalized, its session row carrying the
normalized title, its repeat with that title spelt plainly answered the same,
the same requestId with another title and with none refused
request_conflict, a blank title, one of nothing but zero-width and bidi
runes and (marked invalid) an empty one refused bad_request, and a titled
create whose session's start fails, start_failed with its cause (32); and
a session that states its models' effort facts — its info document saying
effortFacts: true, its catalogs.models carrying each model's efforts,
defaultEffort and switchEffort, and one with no effort control carrying
none — whose session.models.refresh takes up a list with other ranks and
switch efforts, its catalog delta, the sessions.list row, a second
client's attach reply and snapshot and its session.state all carrying the
new facts (33).
Every line is
{"conn": N, "dir": "c2s"|"s2c", "msg": {...}}, plus {"dir": "op", "op":
{...}} lines that are not wire messages at all — they script the host
directly (emitting text, opening an ask, restarting the engine into a fresh
incarnation, stalling or dropping connections, running a stop's sequence) —
and, as a fixture's first line or not at all, {"dir": "host", "host":
{...}}, which says how the host was built: stop, permissionMode,
startedAt, rowFacts, presence, modelsRefresh, aside, slash and
effortFacts turn on what an older host does not have, models gives it a
catalog of its own (each model written as the catalog section writes one,
its effort facts included), provider names the provider its session reports (native, say) in
place of the Stub's own, and plugins are the plugin rows its session
starts with, each as the plugins section writes one. The seventeen
fixtures from before modelsRefresh without one are, byte for byte, an older
host to a newer client. TestWireFixtures replays
every one of them byte for byte, validating every line against the schema
above as it sends or reads it — except a c2s line fixtures 10, 26 and 32 mark
"invalid": true: deliberately not a well-formed request of a method
protocol 1 defines with today's params (an unknown method, or a field no
schema allows), sent as it stands and held to no request schema, since it is
designed never to pass one.
A wire line may also carry "sock": "hub": its connection is to the hub's
socket rather than the host's (a connection's first line decides which, for
good). A fixture with any such line is a two-socket fixture: the runner
binds and lists its host in a registry of the fixture's own, exactly where a
hub looks for hosts, and starts the hub in front of it — the real hub, in
the runner's process — so one script speaks to both. What the hub writes
carries three values no fixture can pin, named by placeholder as the
incarnation is: the hub's id (its hello's endpoint.hostId and its
roster's epoch) is HUB-ID, its craze version HUB-VERSION, and the
runner's own pid — where the hub's hello carries it as endpoint.pid, and
where a roster row carries it as host.pid, the pid the host's registry
entry carries — 999999999, which no process can have (it is above every
pid_max). A pid anywhere else, the fake host's own 4242 in its hello
through the splice included, is compared as written. A fixture whose host
line says hubCreates has a hub that creates sessions, each host it spawns
the test binary run as a fake craze serve; a host a create started is
named by placeholder too, from its registry entry: its id (which the hub
minted) as cccccccccccc, its session's incarnation as
CREATED-INCARNATION, and its pid, in the create's result, as 999999999.
A host line that also carries createOptions — a sessions.createOptions
result, written out — has that hub answer the method with it, as it stands,
at every call (it needs hubCreates); one that says createTitle has a hub
whose create takes a title, which the fake craze serve it spawns seeds
in its session (it needs hubCreates too). Without it the hub is one from
before titles, whose hello has no createTitle, so every two-socket
fixture before 32 is, byte for byte, what it was.
cmd/craze-fake-host is the same host as a standalone binary, for anyone
scripting against protocol 1 without Go: craze-fake-host --socket PATH
serves on a socket at PATH, listed nowhere; craze-fake-host --registry
ROOT instead binds and lists it exactly as a craze host is — its socket in
the runtime tree (CRAZE_RUNTIME_DIR), its registry entry and lifetime lock
under ROOT/.cache/craze/hosts/, ROOT standing for HOME — so a hub, or
craze attach, given that HOME finds it, and unlists it when it exits.
--host-id (twelve lowercase hex digits) and --session-id replace the
fixed ids, so several can run, and be listed, side by side. It prints one
ready line on stdout,
then reads ops from stdin, one JSON object per line: the op member of a
fixture's op line, e.g. {"name":"text","text":"hi"} or
{"name":"foreign_turn","id":"wake-1","reason":"subagent_wake","running":true}.
The names are text, thought, tool, permission, question, plan, end,
foreign_turn, stall_writes, resume_writes, drop_connections,
restart (a new incarnation of the same session), quit, plus a few the
fixtures alone need (spawn_subagent, oversized_event, advance_clock,
hang_next, run_stop, stage_models — what the next
session.models.refresh of a modelsRefresh host takes up — hold_aside —
the next session.aside of an aside host parked after its first
notification, until it is cancelled — set_commands and set_skills — an
agent's whole command list, {"commands":[{name, description}]}, and the
disk skills a scan found, {"skills":[{name, description}]}, which a slash
host publishes as a live session does — and unlist — a --registry host
leaving the registry while it keeps serving the connections it has — and
mark_stopping — a --registry host's entry marked stopping while it
serves on). The
binary has no flag for the modelsRefresh, aside or slash opt-ins yet,
so its host refuses the first two features and publishes no slash catalog,
as an older host does: stage_models and hold_aside change nothing there,
and set_commands publishes the commands section alone. Its clock, ids, host id, craze version, pid and token source
are all deterministic by default, so a script against it produces the same
wire traffic on every run and every machine (a listed host's registry entry
carries its real pid, as every host's does).
Testing a client against a real hub¶
A client of the hub, such as shed's create sheet, can be tested against the
real hub with nothing real behind it. The hub lists a craze-fake-host as it
lists any host, and the sessions it creates run craze-fake-agent in place
of an agent. Nothing reaches the network or a keychain, and nothing of the
machine's own craze is seen or touched. The recipe needs only the three
binaries make build writes to bin/: craze, craze-fake-agent and
craze-fake-host. tests/cli/test_client_recipe.py is its executable form,
run in CI on Linux and macOS, with the same environment, the same lines and
the same answers.
The environment. Every craze process in the recipe runs under env -i
with exactly six variables, through a wrapper, clean, that step 1 writes.
Nothing is inherited, because the first bridge's environment becomes the
hub's when the bridge starts it. The hub hands that environment on (less one
launch's choices: see session.create) to every host it
creates, and each host hands it to its agent. An exported API key would fund
native, a stray CRAZE_FAKE_* variable would change the fake agent's
script, and an installed cursor-agent, agent, grok or gx would answer
in place of the fakes. The environment is fixed when the hub starts, so it is
set before the first bridge runs.
| variable | value | why |
|---|---|---|
HOME |
/tmp/craze-recipe/home |
the registry, $HOME/.cache/craze/: where the hub lists hosts and keeps its record, and where craze-fake-host --registry lists itself. A scratch one, so no session or hub of the machine's is seen |
CRAZE_HOME |
/tmp/craze-recipe/craze-home |
config.toml, the session index that recentDirs reads, and native's keys (there are none). The hub and every host under it share its namespace |
CRAZE_RUNTIME_DIR |
/tmp/craze-recipe/run |
where the sockets are bound. It must be short, because craze refuses a socket path over 100 bytes (above) and a macOS $TMPDIR alone can crowd one out. It is mode 0700, because craze refuses a runtime directory others can write |
PATH |
/tmp/craze-recipe/path |
a directory of the recipe's own, holding copies of the two programs it runs, craze and craze-fake-host, and nothing else. So cursor-agent and agent (cursor's two names), grok and gx are found nowhere, whatever a system directory or BIN holds. cursor is unavailable, "not found", on Linux and macOS alike: a missing binary is reported ahead of the macOS login-session rule, whose answer depends on how the machine was reached. gx is not listed |
CRAZE_FAKE_SCRIPT |
grok-echo |
the fake agent speaks grok's dialect and answers each prompt with echo: <prompt>. No other CRAZE_FAKE_* variable reaches it |
CRAZE_FAKE_SESSION_ID |
{dir} |
the fake agent names its session after its working directory (work), so each session's index row is its own |
Nothing a craze process starts is looked up on PATH except the providers'
binaries, which must not be found. craze runs itself again (its own
executable, by its full path) as the hub and as each host; the one agent a
host starts here, grok's, is [agents].grok, a full path; and neither fake
starts anything. So the two programs are all the private PATH needs. The
recipe's own shell commands — the scripts step 1 writes, and the env,
tail, grep, ps, date and sleep they run — run under your PATH. Only what
clean starts sees the private one, and everything craze starts inherits it
from there: every bridge, the hub, each host, each agent, and the fake host.
The two programs are copies, not links, and clean runs each by its full
path. So the command line of every craze process of the recipe's starts with
/tmp/craze-recipe/path/: each bridge and the fake host, as clean runs
them, and the hub and each host, which craze runs again by its own path —
on Linux the real path of the file it runs, which a link would leave in
BIN. Nothing else runs a program from there, unless you do, and that is
how cleanup tells the recipe's processes from any other. (The fake agent runs
from BIN, as config.toml names it; its host ends it.)
grok reaches the fake agent only through config.toml's [agents]. That is
the one route to the hub's hosts, because the hub strips CRAZE_AGENT_BIN
and CRAZE_PROVIDER from what it hands on. grok, not cursor, is the ready
provider because grok has no login-session rule, so its ready is the same
on every machine.
Driving a bridge. Besides clean, step 1 writes five small scripts
beside the recipe's files:
start NAME PROGRAM ARGS…runs the recipe'sPROGRAMunderclean, in the background. Its stdin is every line appended toNAME.in, whichtail -n +1 -ffollows, and it writesNAME.outandNAME.err. A bridge, like the fake host, ends when its stdin does, and its stdin ends when its tail does.await NAME TEXT [SECONDS]prints the lines ofNAME.outthat holdTEXT, once there is one, waiting at mostSECONDS(30 if not given).reply NAME ID [SECONDS]printsNAME's reply to requestIDonce it has come: the line whoseidisID. A line with noidis a notification, and a reply can come before or after one.poll NAME SECONDS TEXT METHOD PARAMSsendsMETHODwithPARAMStoNAMEagain and again, with the ids 100, 101 and so on, until a reply holdsTEXT, and prints that reply.SECONDSbounds the whole poll, to the second: its end is fixed once, when it starts (AWAIT_END, whichawaitthen waits until), and every send, reply and pause between two requests comes out of it. A reply that comes after the end is not taken.cleanupends every process of the recipe's (below).
A request is one line appended to NAME.in with printf … >>, which never
blocks. A bridge that has died never reads it, and the wait for its reply
times out. Run the blocks in order. Each block sets R again, so a shell
that keeps no variables between blocks runs it the same. The processes that
start starts keep running until cleanup ends them. If any step fails,
or you interrupt one, run /tmp/craze-recipe/cleanup: it is the way out
from anywhere, and running it again is harmless.
1 and 2. The environment and config.toml. Set BIN to the absolute
path of the directory that holds the three binaries. mkdir fails if
/tmp/craze-recipe is left over from an earlier run: run its cleanup, if
it has one, and remove it first.
BIN=/absolute/path/to/craze/bin
R=/tmp/craze-recipe
mkdir -m 700 "$R" "$R/home" "$R/craze-home" "$R/run" "$R/work" "$R/path"
cp "$BIN/craze" "$BIN/craze-fake-host" "$R/path/"
cat > "$R/clean" <<EOF
#!/bin/sh
# clean PROGRAM ARGS...: the recipe's own copy of PROGRAM, run by its full
# path with the recipe's environment alone.
p=\$1; shift
exec env -i HOME='$R/home' CRAZE_HOME='$R/craze-home' CRAZE_RUNTIME_DIR='$R/run' PATH='$R/path' CRAZE_FAKE_SCRIPT=grok-echo CRAZE_FAKE_SESSION_ID='{dir}' "$R/path/\$p" "\$@"
EOF
cat > "$R/start" <<'EOF'
#!/bin/sh
# start NAME PROGRAM ARGS...: PROGRAM under clean, in the background, reading
# each line appended to NAME.in and writing NAME.out and NAME.err.
d=$(dirname "$0"); d=${d#/private}; n=$1; shift
case $d in /*) ;; *) echo "start: run it by its full path" >&2; exit 2 ;; esac
: > "$d/$n.in"; : > "$d/$n.err"
tail -n +1 -f "$d/$n.in" 2>> "$d/$n.err" | "$d/clean" "$@" > "$d/$n.out" 2>> "$d/$n.err" &
EOF
cat > "$R/await" <<'EOF'
#!/bin/sh
# await NAME TEXT [SECONDS]: the lines of NAME.out holding TEXT, once there is
# one, waiting at most SECONDS (30 if not given) -- or, when AWAIT_END is
# set, until that time (date +%s).
out="$(dirname "$0")/$1.out"
end=${AWAIT_END:-$(( $(date +%s) + ${3:-30} ))}
until grep -F -- "$2" "$out"; do
if [ "$(date +%s)" -ge "$end" ]; then echo "nothing in $out holds $2" >&2; exit 1; fi
sleep 0.1
done
EOF
cat > "$R/reply" <<'EOF'
#!/bin/sh
# reply NAME ID [SECONDS]: NAME's reply to request ID, once it has come.
exec "$(dirname "$0")/await" "$1" "{\"jsonrpc\":\"2.0\",\"id\":$2," "${3:-30}"
EOF
cat > "$R/poll" <<'EOF'
#!/bin/sh
# poll NAME SECONDS TEXT METHOD PARAMS: METHOD sent to NAME with ids 100, 101,
# ... until a reply holds TEXT, which is printed. Its end is fixed once, here:
# each reply is waited for until then, and one that comes later is not taken.
d=$(dirname "$0"); id=100
AWAIT_END=$(( $(date +%s) + $2 )); export AWAIT_END
while [ "$(date +%s)" -lt "$AWAIT_END" ]; do
printf '{"jsonrpc":"2.0","id":%d,"method":"%s","params":%s}\n' "$id" "$4" "$5" >> "$d/$1.in"
if r=$("$d/reply" "$1" "$id") && [ "$(date +%s)" -lt "$AWAIT_END" ] &&
printf '%s\n' "$r" | grep -F -- "$3"; then exit 0; fi
id=$((id + 1))
sleep 0.1
done
echo "no reply from $1 held $3 within $2 s" >&2; exit 1
EOF
cat > "$R/cleanup" <<'EOF'
#!/bin/sh
# cleanup: end every process of the recipe's, wait for them to go, and print
# what is left, or "nothing left". Running it again is harmless.
d=$(dirname "$0"); d=${d#/private}
case $d in /*) ;; *) echo "cleanup: run it by its full path" >&2; exit 2 ;; esac
# ours ARGS [fake]: ARGS, a command line, is a process of the recipe's -- a
# program run from its own copy in path/, or a tail of one of its .in files.
# The fake host counts only with "fake". (macOS can spell /tmp /private/tmp.)
ours() {
case ${1#/private} in
"$d/path/craze-fake-host "*) [ "$2" = fake ] ;;
"$d/path/"* | "tail -n +1 -f $d/"*.in) true ;;
*) false ;;
esac
}
# pids [fake]: the pid of each process of the recipe's. (-ww: whole command
# lines, which Linux ps cuts to $COLUMNS when that is set.)
pids() { ps -A -ww -o pid= -o args= | while read -r p a; do ours "$a" "$1" && echo "$p"; done; }
# term [fake]: SIGTERM each, its command line checked again just before.
term() { for p in $(pids "$1"); do ours "$(ps -ww -p "$p" -o args=)" "$1" && kill "$p" 2>/dev/null; done; }
# left: what is still here, one line each.
left() {
ps -A -ww -o pid= -o args= | while read -r p a; do ours "$a" fake && echo "process $p: $a"; done
for f in "$d"/home/.cache/craze/hosts/*.json "$d"/home/.cache/craze/hubs/*.json; do
[ -f "$f" ] && echo "file $f"
done
}
# waitfor SECONDS COMMAND...: until COMMAND prints nothing, at most SECONDS.
waitfor() {
end=$(( $(date +%s) + $1 )); shift
while [ -n "$("$@")" ] && [ "$(date +%s)" -lt "$end" ]; do sleep 0.1; done
}
# 1. SIGTERM each but the fake host: each tail (its end is a bridge's or the
# fake host's stdin's), each bridge, each host the hub created (its stop)
# and the hub (its teardown, which removes its record). The fake host goes
# at its stdin's end, and unlists itself.
term
waitfor 10 pids fake
# 2. SIGTERM any of them still running, the fake host included.
term fake
waitfor 10 left
rest=$(left)
if [ -z "$rest" ]; then echo "nothing left"; else printf 'left:\n%s\n' "$rest"; exit 1; fi
EOF
chmod +x "$R/clean" "$R/start" "$R/await" "$R/reply" "$R/poll" "$R/cleanup"
cat > "$R/craze-home/config.toml" <<EOF
provider = "grok"
host_idle_exit = "30s"
[agents]
grok = "$BIN/craze-fake-agent"
EOF
provider is what a create that names none starts, and what
sessions.createOptions reports as defaultProvider. host_idle_exit
bounds a created host that something leaves running: it exits after 30 s
unattended and idle, not the default hour.
tail -n +1 -f is POSIX, and GNU tail (Linux) and BSD tail (macOS)
follow a growing file alike. GNU tail waits for a change with inotify, or
with a once-a-second poll where it has none, so there a reply can take up to
a second longer to start.
3. A fake host. --registry lists it as a craze host is listed: its
entry under HOME's registry, its socket under CRAZE_RUNTIME_DIR, in
CRAZE_HOME's namespace. That is exactly where the hub looks. Its stdin is
how it is told to go: it exits at its stdin's end, as cleanup ends it, or
at a quit op. It has no SIGTERM handler, so a SIGTERM kills it before it
unlists itself, and its registry entry is left behind.
R=/tmp/craze-recipe
"$R/start" fake-host craze-fake-host --registry "$R/home" --host-id 0a0a0a0a0a0a --session-id recipe-fake
"$R/await" fake-host '"hostId":"0a0a0a0a0a0a"'
The await prints the fake host's ready line, which the fake host prints
once its registry entry is written. The socket's directory is the namespace
(on macOS the path reads /private/tmp/…):
{"socket":"/tmp/craze-recipe/run/<namespace>/0a0a0a0a0a0a.sock","sessionId":"recipe-fake","hostId":"0a0a0a0a0a0a"}
4. Bridge A, and the hub's hello. The bridge finds no hub in this
namespace and starts one, which inherits the bridge's environment.
R=/tmp/craze-recipe
"$R/start" a craze bridge --hub
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocols":[1],"client":{"kind":"test","name":"recipe"}}}' >> "$R/a.in"
"$R/reply" a 1
The hub's result. hostId, crazeVersion and pid are this hub's own.
rosterSubscribe, sessionCreate, connect and createOptions are what
the recipe uses (createTitle says its create would take a title, which
the recipe does not send; reopen is what the reopen cell
uses):
{"jsonrpc":"2.0","id":1,"result":{"protocol":1,"endpoint":{"kind":"hub","hostId":"0a1b2c3d4e5f","crazeVersion":"dev","pid":5150},"capabilities":{"rosterSubscribe":true,"sessionCreate":true,"multiplex":false,"connect":true,"snapshot":false,"attachWhenNow":false,"createOptions":true,"createTitle":true,"reopen":true},"codecs":{"event":1,"snapshot":1},"limits":{"inboundLine":4194304,"outboundLine":16777216}}}
5. What a create can start.
R=/tmp/craze-recipe
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"sessions.createOptions","params":{}}' >> "$R/a.in"
"$R/reply" a 2
The answer, exactly, on Linux and macOS:
{"jsonrpc":"2.0","id":2,"result":{"providers":[{"id":"cursor","label":"cursor","state":"unavailable","reason":"cursor-agent not found on PATH","fix":"install cursor-agent, or set [agents].cursor in /tmp/craze-recipe/craze-home/config.toml"},{"id":"grok","label":"grok","state":"ready"},{"id":"native","label":"native","state":"needs_setup","reason":"no model provider has a key","fix":"craze auth login (an API key, or \"craze auth login chatgpt\" for a ChatGPT plan)"}],"defaultProvider":"grok","recentDirs":[]}}
- cursor is
unavailablebecause neithercursor-agentnoragentis onPATH. - grok is
readybecause[agents].groknames a binary that exists. - gx is absent: it is listed only when its binary is found.
- native is
needs_setupbecause no API key is set and the native directory is empty. defaultProviderisconfig.toml'sprovider.recentDirsis[], because no session has run yet.
6. The roster.
R=/tmp/craze-recipe
printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"sessions.subscribe","params":{}}' >> "$R/a.in"
"$R/reply" a 3
Its sessions has one row, the fake host's, which begins
{"hostId":"0a0a0a0a0a0a","sessionId":"recipe-fake",. From here on a.out
also collects roster notifications.
7. Create a session. No provider is named, so the hub starts the
default, grok, in a host of its own, and sends the first prompt.
R=/tmp/craze-recipe
printf '%s\n' '{"jsonrpc":"2.0","id":4,"method":"session.create","params":{"cwd":"/tmp/craze-recipe/work","prompt":"hello recipe","requestId":"recipe-1"}}' >> "$R/a.in"
"$R/reply" a 4
"$R/reply" a 4 | grep -o '"session":{"hostId":"[0-9a-f]*","sessionId":"[^"]*"' | cut -d'"' -f6 > "$R/host-id"
"$R/reply" a 4 | grep -o '"session":{"hostId":"[0-9a-f]*","sessionId":"[^"]*"' | cut -d'"' -f10 > "$R/session-id"
cat "$R/host-id" "$R/session-id"
"$R/await" a '"method":"roster"'
The result's session is the new session's roster row, its
host.provider "grok", and prompt is "accepted". host-id and
session-id now hold its two ids for the next step. The last await prints
the roster subscription's notifications once one has come, and their
upserts name the new session. One can follow the create's reply by a
second or so, because the hub reads the registry once a second.
8. Read it back through the hub. Bridge B says hello to the hub, then
session.connect to the new session's host. After the splice the client is
talking to the host, which has not met it yet, so it says hello again
before it attaches. Without that second hello, the attach is refused
bad_request, reason hello_required. Then session.snapshot is polled,
for at most 10 s, until the session's reply is whole.
R=/tmp/craze-recipe
"$R/start" b craze bridge --hub
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocols":[1],"client":{"kind":"test","name":"recipe"}}}' >> "$R/b.in"
"$R/reply" b 1
printf '{"jsonrpc":"2.0","id":2,"method":"session.connect","params":{"sessionId":"%s"}}\n' "$(cat "$R/host-id")" >> "$R/b.in"
"$R/reply" b 2
printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"hello","params":{"protocols":[1],"client":{"kind":"test","name":"recipe"}}}' >> "$R/b.in"
"$R/reply" b 3
printf '{"jsonrpc":"2.0","id":4,"method":"session.attach","params":{"sessionId":"%s"}}\n' "$(cat "$R/session-id")" >> "$R/b.in"
"$R/reply" b 4
"$R/poll" b 10 '"kind":"assistant","text":"echo: hello recipe"' session.snapshot "{\"sessionId\":\"$(cat "$R/session-id")\"}"
The replies, in order:
- The hub's
hello, as in step 4. {"jsonrpc":"2.0","id":2,"result":{}}, the hub's last line on this connection.- The host's
hello:"endpoint":{"kind":"host","hostId":"<host-id>",…}, with theclientIdandtokenthe host minted. - The attach reply, with the session's snapshot. The
synchronizedandpresencenotifications follow. - The
poll's answer: asession.snapshotreply ("id":100, or a later id) whose main entries are the prompt and the echo,{…,"kind":"user","text":"hello recipe",…}and{"id":…,"kind":"assistant","text":"echo: hello recipe",…}.
The echo comes in two chunks, echo: and hello recipe, and the host
folds them into the one assistant entry. A snapshot taken mid-turn holds
only part of it, as can the attach's own, so the poll asks again until the
entry's text is exactly echo: hello recipe: the closing " is part of what
it matches.
9. Recent directories. The host writes the session's index row just
after its start, so right after the create this can still be []. The
poll asks again for at most 5 s.
providers and defaultProvider are as in step 5, and recentDirs is now
[{"dir":"/tmp/craze-recipe/work","usedAt":"<when>"}]: the work directory,
which on macOS can read as its resolved spelling,
/private/tmp/craze-recipe/work (the text polled for matches both).
10. Cleanup. session.stop is mutating, so it carries a commandId,
this client's first: "1".
R=/tmp/craze-recipe
printf '{"jsonrpc":"2.0","id":5,"method":"session.stop","params":{"sessionId":"%s","commandId":"1"}}\n' "$(cat "$R/session-id")" >> "$R/b.in"
"$R/reply" b 5
"$R/await" b '"reason":"session_closed"'
"$R/cleanup"
- The stop's reply is
{"jsonrpc":"2.0","id":5,"result":{}}. The session then ends, and theawaitprints its last line on bridge B,{"jsonrpc":"2.0","method":"reset","params":{"subscription":"s-1","reason":"session_closed"}}. Its host exits and closes the connection. Wait for that reset before the cleanup: the cleanup stops the hub, and a hub's SIGTERM ends every splice through it, so a session's end that has not yet reached the client is lost to it. cleanupfinds the recipe's processes by their command lines: each one that starts with/tmp/craze-recipe/path/(or/private/tmp/…, as macOS can spell it), and eachtail -n +1 -fof one of its.infiles. It sends SIGTERM to each but the fake host. A tail's end is a bridge's stdin's or the fake host's. A host the hub created whose session is still running (a run that failed before step 10) stops, which ends its session. The hub's SIGTERM is its clean teardown, which removes its record; left alone, a hub exits 60 s after its last client and host have gone. The fake host goes at its stdin's end, and unlists itself. The cleanup waits, for at most 10 s, for all of them to go.- It then sends SIGTERM to any of them still running, the fake host
included (a SIGTERM leaves its registry entry behind), and waits, for at
most 10 s more, until none of them runs, no registry entry is left in
home/.cache/craze/hosts, and no hub record (.json) inhome/.cache/craze/hubs. - Each process's command line is read again just before its signal. So
cleanupsignals no process but the recipe's, save in two cases it cannot rule out: a process that ends in the instant between that read and its signal, its pid taken by another process in that same instant; and a program you run from/tmp/craze-recipe/pathyourself.
It prints nothing left, or left: and a line for each process or file
still there, and exits 1. Run it again to retry; it ends only what is still
there. A file left with no process is the record of one that died without
removing it (a fake host killed by a signal, say), and removing
/tmp/craze-recipe removes it. The hubs directory keeps one file,
<namespace>.lock: the hub's lock, which is never removed, and which is
harmless, since the lock's flock, not the file, is what a running hub holds.
rm -rf /tmp/craze-recipe removes the rest.
In the test, conftest.py's isolation supplies HOME, CRAZE_HOME and a
short CRAZE_RUNTIME_DIR in place of /tmp/craze-recipe, and its private
PATH holds the same two copies, of the binaries under test, each run by
its full path. The test checks that the hub and the host it creates run the
copy of craze — the premise of cleanup's rule — and checks the hub's
again just before it signals it. Each bridge's and the fake host's stdin is
a pipe the test holds, so closing it is what ending the tail does here. The test files each process the moment it
starts, before it waits for anything from it, and a fixture that names
host_cleanup as its own dependency closes every stdin first, so even a
fake host whose ready line never came is ended, and a fake host always
unlists itself before the cleanup looks. host_cleanup is the backstop: it
stops a hub or host a failed run leaves.
Reopening a saved session in the recipe¶
A client's resume of a saved session — sessions.saved
and session.reopen — is tested the same way, in a run of
its own. tests/cli/test_client_recipe.py's second test is its executable
form. The environment is the recipe's with one change: the fake agent's
script is grok-load, which answers every session/load by replaying a
short transcript (and refuses session/new, so this run creates nothing). A
session index holding one saved grok session is seeded before the first
bridge starts the hub.
1 and 2. Run the recipe's steps 1 and 2, then change the script and seed the index:
R=/tmp/craze-recipe
sed -i.bak 's/CRAZE_FAKE_SCRIPT=grok-echo /CRAZE_FAKE_SCRIPT=grok-load /' "$R/clean"
printf '%s\n' '{"sessionId":"recipe-saved","provider":"grok","cwd":"/tmp/craze-recipe/work","title":"a saved session","pinned":false,"createdAt":"2026-10-12T09:00:00Z","updatedAt":"2026-10-12T10:00:00Z","crazeId":"0192f0aa-6666-7000-8000-0000000reopen"}' > "$R/craze-home/sessions.jsonl"
3. Bridge A, and what is saved. The hub's hello says reopen, and
sessions.saved lists the seeded session, its id its craze id:
R=/tmp/craze-recipe
"$R/start" a craze bridge --hub
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocols":[1],"client":{"kind":"test","name":"recipe"}}}' >> "$R/a.in"
"$R/reply" a 1 | grep -F '"reopen":true'
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"sessions.saved","params":{}}' >> "$R/a.in"
"$R/reply" a 2
{"jsonrpc":"2.0","id":2,"result":{"sessions":[{"id":"0192f0aa-6666-7000-8000-0000000reopen","crazeSessionId":"0192f0aa-6666-7000-8000-0000000reopen","provider":"grok","providerSessionId":"recipe-saved","workspace":"/tmp/craze-recipe/work","title":"a saved session","createdAt":"2026-10-12T09:00:00Z","updatedAt":"2026-10-12T10:00:00Z"}],"more":false}}
4. Reopen it, on a connection of its own. Bridge B says hello and
sends session.reopen. The hub starts a host that loads the session and
answers once its start is done, joined false; host-id keeps the answer
row's host id, where the session is reached. The bridge's stdin then ends:
one short connection per reopen.
R=/tmp/craze-recipe
"$R/start" b craze bridge --hub
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocols":[1],"client":{"kind":"test","name":"recipe"}}}' >> "$R/b.in"
"$R/reply" b 1
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"session.reopen","params":{"id":"0192f0aa-6666-7000-8000-0000000reopen"}}' >> "$R/b.in"
"$R/reply" b 2 200
"$R/reply" b 2 | grep -o '"session":{"hostId":"[0-9a-f]*"' | cut -d'"' -f6 > "$R/host-id"
"$R/reply" b 2 | grep -F '"joined":false'
5. Running, it is no longer saved; reopened again, it is joined. On
bridge A sessions.saved is now {"sessions":[],"more":false}. Bridge C's
reopen of the same id answers joined true, the same hostId, and starts
nothing.
R=/tmp/craze-recipe
printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"sessions.saved","params":{}}' >> "$R/a.in"
"$R/reply" a 3
"$R/start" c craze bridge --hub
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocols":[1],"client":{"kind":"test","name":"recipe"}}}' >> "$R/c.in"
"$R/reply" c 1
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"session.reopen","params":{"id":"0192f0aa-6666-7000-8000-0000000reopen"}}' >> "$R/c.in"
"$R/reply" c 2 200 | grep -F "\"hostId\":\"$(cat "$R/host-id")\"" | grep -F '"joined":true'
6. Reach it by the answer's host id. Bridge D connects to the host the
answer named, says hello to it, attaches, and polls session.snapshot for
the replayed answer:
R=/tmp/craze-recipe
"$R/start" d craze bridge --hub
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocols":[1],"client":{"kind":"test","name":"recipe"}}}' >> "$R/d.in"
"$R/reply" d 1
printf '{"jsonrpc":"2.0","id":2,"method":"session.connect","params":{"sessionId":"%s"}}\n' "$(cat "$R/host-id")" >> "$R/d.in"
"$R/reply" d 2
printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"hello","params":{"protocols":[1],"client":{"kind":"test","name":"recipe"}}}' >> "$R/d.in"
"$R/reply" d 3
printf '%s\n' '{"jsonrpc":"2.0","id":4,"method":"session.attach","params":{"sessionId":"0192f0aa-6666-7000-8000-0000000reopen"}}' >> "$R/d.in"
"$R/reply" d 4
"$R/poll" d 10 '"kind":"assistant","text":"the workspace holds main.py and README.md"' session.snapshot '{"sessionId":"0192f0aa-6666-7000-8000-0000000reopen"}'
7. Cleanup, as the recipe's step 10, on bridge D: session.stop with
commandId "1", the await for "reason":"session_closed", then
"$R/cleanup".
A client that adopts reopen does what this run does: it checks reopen in
the hub's hello; it sends each reopen on a short connection of its own,
allowing at least 200 s for the answer; and it binds the session to the
answer row's hostId.
Reaching a host¶
A host's socket lives in a runtime namespace, and everything a resolver
needs in order to find that socket lives in a separate, fixed place: the
two are split because a Unix socket's path has to fit sun_path (about 100
bytes, the way craze binds one — see below), while discovery must work the
same way under an SSH exec as in the terminal tab that started the host,
whatever environment variable either one happens to have set.
The socket base¶
A host picks the first usable of four candidates, in this order, each
canonicalised and deduplicated by canonical path (so $XDG_RUNTIME_DIR
naming /run/user/<uid> makes the second and third candidates the same one):
CRAZE_RUNTIME_DIR, when set: absolute and validated. Unlike the other three, an unusable value here is an error, not a fall-through — it was asked for by name.$XDG_RUNTIME_DIR/craze, whenXDG_RUNTIME_DIRis set, non-empty and absolute.- Linux only:
/run/user/<uid>/craze, when/run/user/<uid>exists, is owned by the uid, and is0700. /tmp/craze-<uid>(/private/tmp/craze-<uid>on macOS, where/tmpis a symlink) — never$TMPDIR, which on macOS is long enough on its own to crowd outsun_path.
Only a host, and its namespace's hub (<base>/<ns>/hub.sock), search these
bases; a resolver never does; see
the registry below.
The namespace and the socket path¶
The socket lives at <base>/<ns>/<hostId>.sock, where:
hostIdis 12 random lowercase hex digits, minted fresh at process start and never reused (which is what lets a dead host's own lock file be unlinked safely — Liveness below).nsis the first 8 hex digits ofsha256over the absolute, cleaned craze directory (CRAZE_HOME, default~/.craze) — one namespace per user and perCRAZE_HOME, so two craze homes on one machine never share a socket directory. A relativeCRAZE_HOMEresolves to one absolute path at resolution time; an empty one (no home directory, or the removed config-file environment variable still set — see Configuration) is refused before anything is built.
A typical path: /run/user/1000/craze/3f2a9c1e/0190ab12cd34.sock, about 48
bytes. craze refuses to bind a socket path over 100 bytes — before creating
anything under its base — with an actionable message asking for a shorter
CRAZE_RUNTIME_DIR; sun_path itself is 104 bytes on Darwin and 108 on
Linux, NUL included, so 100 leaves margin on both.
Both the <ns> directory and its parent are 0700; the socket file itself
is 0600, chmod-ed to that inside the already-0700 directory (not bound
under a changed process umask, which is process-wide and would leak to any
other file craze creates concurrently).
The registry¶
Everything a resolver must find — as opposed to the socket itself — lives
under one fixed per-user path, <HOME>/.cache/craze/ — the process's own
$HOME (HOME wins over the account database; never CRAZE_HOME, and
never affected by XDG_RUNTIME_DIR or CRAZE_RUNTIME_DIR). An SSH exec is
assumed to share this $HOME with the tab that started the host —
true for an ordinary SSH login as the same user, not guaranteed in
general — so discovery does not depend on CRAZE_HOME, XDG_RUNTIME_DIR or
CRAZE_RUNTIME_DIR, whatever those held in the launching tab.
<HOME>/.cache/craze/
hosts/<hostId>.json the registry entry (see below)
hosts/<hostId>.lock the host's lifetime flock; holds "<pid> <hostId>"
locks/<crazeSessionId>.lock one lock per craze session (SQ16, below)
host-logs/<hostId>.log a detached host's log; not part of discovery
catalogs/<provider>.json the model catalog cache; not part of discovery
hubs/<ns>.json the per-machine hub's record; <ns> is the runtime namespace
hubs/<ns>.lock the hub's lifetime flock
results/<short>-<inc>-<turn>.md a waiting command's whole result when its output was cut; not part of discovery
A registry entry, hosts/<hostId>.json, has exactly these members:
| Member | Holds |
|---|---|
protocol |
The control protocol version this host speaks |
hostId |
The host's id (the entry's own file name) |
pid |
The host process's pid |
startedAt |
When the host started |
socket |
The control socket's absolute, canonical path |
crazeSessionId |
craze's own id for the session |
providerSessionId |
The provider's id for the session, "" before the engine is ready |
incarnation |
The engine's incarnation (the journal's own UUIDv7) |
provider |
The provider's name |
workspace |
The session's working directory |
ready |
Whether the engine has started |
requestId, requestHash |
Only on a host the hub's session.create spawned, and absent otherwise: the create's requestId and its params' hash, fixed for the host's life — what a restarted hub joins a retried create by |
pidStart |
The host process's start as its kernel records it, which the host reads of itself as it binds — on Linux clock ticks since boot (/proc/<pid>/stat field 22), on macOS microseconds since the epoch — fixed for the host's life: with pid, what tells the host from a later process given its pid, so a craze new --wait that never heard from the host it started ends that host and no other process. Absent when the host could not read it, and on hosts from crazes that predate it |
stopping |
true once the host has begun to stop, and absent before. The host writes it itself, before anything of its stop can be seen — before a session.stop's receipt is sent, first thing when a signal, its idle exit or a lost socket stops it, before a TUI-hosted session's quit closes its engine — and every later rewrite keeps it until the entry is unlinked as the host exits. Best effort: a mark the host cannot write is logged, and the stop goes on. A roster lists no stopping host (the hub's, the session list's own, craze ps); a resolver that finds a session to connect to may still find it while it exits, a session on its way out. Never written by hosts from crazes that predate it, which leave their entry as it was until they exit |
This set is a one-way door: a resolver — craze bridge, craze
attach, shed — reads these members and connects to nothing merely to find a
session, so none of them is ever renamed or removed; members may be added
(requestId, requestHash, pidStart and stopping were), and a reader
ignores those it does not know.
The socket writes an initial entry at bind time, before any engine is
attached (crazeSessionId, provider and the rest of the identity still
empty). From there the entry is rewritten whole, each rewrite carrying
the engine's complete identity as it stands at that moment, never a partial
update: queued the moment an engine is attached to the socket — the one
the TUI starts with, or the one its provider or resume picker builds, a
fresh incarnation each time — and again once that engine becomes ready, when
the provider session id is finally known. Queuing is not landing: the
registry's one writer processes a current-engine rewrite in the order
queued, but drops one whose engine has since been superseded — so it may
never be written at all — and until a rewrite lands, or, if it fails, until
the next one for that engine succeeds, the entry can still describe the
previous engine, or, before the first engine is attached, the empty
bind-time entry. The stopping mark is the exception: the host writes it
directly, done before the step it precedes goes ahead — never queued behind
those rewrites — and every rewrite after it keeps it. A resolver treats the
entry as a pointer to a socket, not as the engine's live identity: hello
and sessions.list, answered by the socket itself once dialed, are what
actually says which engine is live.
Liveness and the stale sweep¶
A host's liveness is its own hosts/<hostId>.lock flock, not a connection: a
resolver that finds hosts/<hostId>.json opens the matching .lock and
tries to take it non-blocking. Held by another process, the host is live and
the entry is read; taken cleanly, the holder is dead and the entry is stale —
"connect failure is not authority": nothing here ever dials the socket
just to decide whether a host is alive.
While holding that lock — the only authority to remove anything of that
host's — a resolver's sweep removes the dead host's registry entry, its
temporaries (writes that died mid-rename before the sweep ever got there) and
last the lock file itself, then unlocks. Host lock files therefore never
accumulate once a host has a registry entry to be found by: the sweep
only visits hosts/<hostId>.lock names paired with a hosts/<hostId>.json
it walked to first, so a host that dies before that first entry exists
leaves a lock nothing here ever opens or removes (SF-49).
The sweep never removes the socket. A lock in the cache tree is authority
over that tree's own entry and lock, never over a file in the separate
runtime tree the entry merely names — even a hostile copy of the registry,
renamed into place by another writer sharing the same group, carries at most
a copied lock over that copy's own entry, never over a socket somewhere else.
So a crashed host's socket file is left behind in its runtime directory,
under a name that is never reused, until whatever ages that directory clears
it (/run/user is a tmpfs emptied at logout; /tmp is emptied at boot or by
systemd-tmpfiles) — a host's own clean exit still removes its socket,
identity-checked against what it bound. (The residuals this leaves — a
crashed host's temporaries a sweep never visits, and similar edge cases — are
tracked in the project's own follow-up backlog as SF-49.)
The session lock (SQ16)¶
locks/<crazeSessionId>.lock is a second kind of lock, one per craze
session rather than per host: the mechanism behind
--continue/--resume of a session already open elsewhere.
It holds "<pid> <hostId>", is taken for the life of the process that loaded
or started that session (a detached craze serve, or the TUI's own process when detaching is off), and does not depend on the control socket at all —
it is taken even with control_socket = false.
SSH exec: what a client may assume¶
craze bridge is the far side of an SSH transport: something on another
device execs it once per accepted connection (shed's craze lane), and it
resolves and dials the local session for that one connection. What that exec
may — and may not — assume:
- Nothing from the launching tab's environment. The exec is a fresh
process; whatever
XDG_RUNTIME_DIR,CRAZE_HOMEorCRAZE_RUNTIME_DIRan interactive shell happened to have set plays no part in resolving anything. - Nothing about the runtime base.
craze bridgenever searches runtime bases itself: it reads the socket's absolute path out of the registry under$HOME— assumed to be the same$HOMEas the tab that started the host, true for an ordinary SSH login as the same user. A host started under anyXDG_RUNTIME_DIRorCRAZE_RUNTIME_DIRis found all the same. -
How to find the binary at all: a fixed ladder, modelled on roost's
exec_chain_command(roost-ipc/src/bootstrap.rs:276-453). One argv element — the whole thing issh -c '<script>'— so the far end never has to source a login shell just to findcraze. Each rung is[ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge …([ -f ] && [ -x ], never[ -x ]alone, since an executable directory would otherwise pass and then fail theexecat 126), tried in this order: -
$HOME/.local/bin/craze— gated on$HOMEitself being absolute (case "${HOME:-}" in /*) …), the same way rung 2 gates oncommand -v's answer: an unset, empty, or relativeHOME(HOME=., say) must not turn$HOME/.local/bin/crazeinto a path relative to whatever directory the shell happened to start in, which[ -f ] && [ -x ]alone cannot tell from a real absolute one command -v craze, accepted only when it resolves to an absolute path (a builtin, function or alias answers with a bare word, which this rung refuses — a non-interactivePATHessentially never carries a relative entry, and exec-ing out of one is a security hazard, not a convenience)/opt/homebrew/bin/craze/usr/local/bin/craze/home/linuxbrew/.linuxbrew/bin/craze/usr/bin/craze$HOME/.nix-profile/bin/craze— the same absolute-HOMEguard as rung 1/etc/profiles/per-user/$USER/bin/craze/run/current-system/sw/bin/craze- else:
printf 'craze: command not found\n' >&2; exit 127
The quoting rule. A craze session id (--session's argument) is
[A-Za-z0-9._-] only, never empty, never . or .. (ValidToken,
internal/rundir/rundir.go) — none of those characters can end a shell
single-quote early, so wrapping the id in one pair of single quotes with
no embedded quote to escape is always safe. That is a fact about the id,
not a license to skip quoting it: a client builds this from an id whose
provenance it does not control, so it must still quote every interpolated
value going in, the same way roost quotes its whole chain in one pair of
single quotes. The whole <ladder> is itself one more single-quoted
argument to sh -c, so each of those inner '…' pairs around the id has
to close and reopen the outer quoting to survive — written '"'"'
(close the outer single quote, a literal ' inside a double-quoted pair,
reopen the outer single quote), not the '\'' some shells treat as an
escape and sshd's login shell cannot be assumed to (roost's own reason
for preferring it).
Here is the exact, complete sh -c '<ladder>' argv a client runs, with a
session id (01a0bbe5-69b4-79de-b75c-483a15b73d78) interpolated and
quoted exactly as above — copy this verbatim and substitute the id:
sh -c 'case "${HOME:-}" in /*) p="$HOME/.local/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge --session '"'"'01a0bbe5-69b4-79de-b75c-483a15b73d78'"'"';; esac; p=$(command -v craze 2>/dev/null) || p=; case "$p" in /*) [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge --session '"'"'01a0bbe5-69b4-79de-b75c-483a15b73d78'"'"';; esac; p="/opt/homebrew/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge --session '"'"'01a0bbe5-69b4-79de-b75c-483a15b73d78'"'"'; p="/usr/local/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge --session '"'"'01a0bbe5-69b4-79de-b75c-483a15b73d78'"'"'; p="/home/linuxbrew/.linuxbrew/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge --session '"'"'01a0bbe5-69b4-79de-b75c-483a15b73d78'"'"'; p="/usr/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge --session '"'"'01a0bbe5-69b4-79de-b75c-483a15b73d78'"'"'; case "${HOME:-}" in /*) p="$HOME/.nix-profile/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge --session '"'"'01a0bbe5-69b4-79de-b75c-483a15b73d78'"'"';; esac; if [ -n "${USER:-}" ]; then p="/etc/profiles/per-user/$USER/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge --session '"'"'01a0bbe5-69b4-79de-b75c-483a15b73d78'"'"'; fi; p="/run/current-system/sw/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge --session '"'"'01a0bbe5-69b4-79de-b75c-483a15b73d78'"'"'; printf '"'"'%s\n'"'"' '"'"'craze: command not found'"'"' >&2; exit 127'
With no --session (the one-running-session default), every bridge …
above is plain bridge, and the same ladder is:
sh -c 'case "${HOME:-}" in /*) p="$HOME/.local/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge;; esac; p=$(command -v craze 2>/dev/null) || p=; case "$p" in /*) [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge;; esac; p="/opt/homebrew/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge; p="/usr/local/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge; p="/home/linuxbrew/.linuxbrew/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge; p="/usr/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge; case "${HOME:-}" in /*) p="$HOME/.nix-profile/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge;; esac; if [ -n "${USER:-}" ]; then p="/etc/profiles/per-user/$USER/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge; fi; p="/run/current-system/sw/bin/craze"; [ -f "$p" ] && [ -x "$p" ] && exec "$p" bridge; printf '"'"'%s\n'"'"' '"'"'craze: command not found'"'"' >&2; exit 127'
Both forms were verified by running them with sh -c the way an SSH exec
would: a craze on any one rung is exec'd with the arguments above, and
with none the command prints the last line and exits 127.
craze itself ships no remote-exec code; the ladder above is published here for shed (or anything else driving this over SSH) to copy verbatim, so "anything the probe can find, the transport can exec" stays a property of one list, not two that can drift apart.
- A per-connection re-exec behind one stable local port is expected and
cheap: shed-mobile's model is to
ssha freshcraze bridgefor every local connection rather than to multiplex one remote process, and nothing aboutcraze bridgediscourages that.
Security¶
The socket is local, same-uid only, with no authentication layer above
that in protocol 1 (hello's auth field is reserved for scopes, which
arrive at S6) and no encryption, because nothing crosses the machine boundary
here — craze bridge is the boundary, and SSH is what encrypts and
authenticates getting to the machine at all.
- The directories are
0700and the socket is0600. That stops another local user from opening the socket by path on this machine, and nothing more: an SSH-forwarded Unix socket, for instance, is opened bysshditself, running as the forwarding user, so file mode alone says nothing about who is really at the other end once a connection is reached some other way. - So each end also asks the kernel who the other runs as. A host checks
every accepted connection's effective uid before reading a byte of it
(Linux
SO_PEERCRED, macOSLOCAL_PEERCRED/LOCAL_PEERPID);craze bridge,craze attach, andinternal/remotecheck the uid of the host they dialed before writing a byte to it. A lookup failure is always a refusal, never an allow — a connection whose owner cannot be named is exactly the one not to trust. Any OS other than Linux or macOS refuses every connection outright, for lack of a way to ask. - The cache tree — the registry, host locks, and session locks — is held
by file descriptor, not trusted by path. It is walked from
/one component at a time withopenat(O_NOFOLLOW), and everything below its leaves — a lock file, the registry's own rename, a stale entry's removal — is done relative to the descriptor that walk returned, never by re-opening a path. That is what makes a group-writable~/.cache(a user-private group under a permissive umask, common enough on real machines) safe to accept there without reading any system file to prove it exclusive: the worst a member of that group can do is rename something into place after validation, which — because everything from the leaf down runs against the held descriptor — makes the next walk refuse, not this one misbehave. The separate runtime tree (where the socket itself lives) has no such exemption, becausebind(2)takes a path and nothing holds it open between the check and the call: a group-writable ancestor there is refused whoever owns it. - Scopes are S6's. Protocol 1 has none: same-uid access is all-or-nothing
for now, and
hello's reservedauthfield is where a future scope narrows it.