Skip to content

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.list returns summaries only (id, kind, label, openedAt); a full ask (asks.get) caps each body string at 256 KiB and sets truncated when 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 aside notification 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:

{"jsonrpc":"2.0","id":"2","method":"session.attach","params":{"sessionId":"session-fake-1"}}
  • id is 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) — id addresses one JSON-RPC round trip; commandId addresses an idempotent action that can outlive several of them.
  • params is 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:

{"jsonrpc":"2.0","method":"synchronized","params":{"subscription":"s-1","seq":1}}

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, reason unknown_field, naming the field. A newer client sends an optional param only after hello has advertised the capability it depends on.
  • hello is the one tolerant method (see hello): a host ignores any field of hello's params it does not know, at any depth, because hello is 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 plain reset — is dropped, and only the one terminal line survives (an attached connection's reset{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 fresh hello answering resumed: false means 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, reason aside_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, reason context — 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 ready when their binary resolves as a created host resolves it: [agents].<provider> in the hub's config.toml, else the provider's own binary names on the hub's PATH (cursor-agent then agent for cursor, grok, gx) — never CRAZE_AGENT_BIN, which the hub hands no host. A binary not found is unavailable, 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 unavailable when the hub runs outside the Mac's login session — first started over ssh — 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 ready when 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_setup when none has; unavailable when providers.toml or models.toml cannot be read. The reason then names the file and nothing from it: an error there can quote a pasted key's name, and craze auth list on 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:

  1. One per craze id: its newest row, whatever it is — the row a load of the craze id loads (craze serve --load and session.reopen take the newest row carrying it). An index row from a craze before craze ids (no crazeId) is the row it is, by its provider and provider session id.
  2. 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.
  3. 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 marked stopping is 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.

  1. Resolve. The id is looked up in the hub's own session index (its CRAZE_HOME's sessions.jsonl) to the row a load of it loads, as craze serve --load looks 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 is unknown_session. That row decides, as it decides whether sessions.saved lists the session: one of a provider this craze cannot resume, or malformed, is not_accepting, reason not_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 is stat'ed here.
  2. 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, is unavailable, reason host_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.
  3. 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 no session.stop and no signal: another client may be attached to it. A start that failed is not_accepting, reason start_failed, its data.cause the host's first error line; a host that cannot be reached is unavailable, reason host_unreachable. joined is true. 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 refused unknown_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.
  4. 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 refused unavailable, reason closing (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 refused closing while 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), never host_unreachable for it.
  5. No host means a spawn. The hub first checks the session's workspace is still a directory: one that is not is not_accepting, reason not_resumable (that session ran in <dir>, which is no longer a directory). It then spawns craze serve --load <id> with the workspace as its working directory, as craze send reloads 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, --workspace or --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. joined is false.
    • held: another loader holds the session's claim (another reopen, another hub, the session list, craze send, a direct craze 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, reason busy. Any other refusal of the session (no such row any more, a row that cannot run where it ran) is not_accepting, reason not_resumable, in the host's words.
    • any other not-ok line, or none: unavailable, reason spawn_failed. A host that cannot be started because the hub's own craze is gone from its path is spawn_failed too, in words naming the remedy, as for a create.
  6. 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, reason busy: 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), are busy too: that session changed hands <n> times while the hub reopened it; try again.
  7. 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 synchronized has 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's reset or its session.detach reply.
  • It has no seq and 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 seq order. 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's text is 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.

{"jsonrpc":"2.0","id":"2","method":"session.attach","params":{"sessionId":"session-fake-1"}}
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":{}
  }
}}
  • subscription is a server-assigned id (s-1, s-2, …), unique per connection, carried on every notification of this attachment.
  • after is where the stream continues: the client's own cursor when it was honoured, otherwise the snapshot's {incarnation, seq}. Everything at or below after is already covered by the cursor's replay or by snapshot.
  • snapshot is present exactly when the cursor was not honoured (absent, refused, or none was given).
  • reset (a CursorReason, see below) is present when a cursor was given and refused — the reason the first refusal named.
  • The reply is on the wire, and after decided, before the very first event of 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":"..."}
  • contextTokens is the estimated size of what the session's next request sends; contextWindow is the window of the model it goes to, 0 when craze does not know it.
  • turn is what the turn the report followed spent — after a load, the last turn the stored session recorded — and session what the whole session has: its own requests, its compactions' (a /compact is 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: input is the uncached input, and reasoning is part of output, never counted twice. costPicoUsd is the priced part's cost in picodollars (10⁻¹² US dollars: $1 is 1000000000000), exact, each request at its own price tier when its model has one (long-context pricing). unpriced: true says 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 0 and a false included.

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: id and name always, recent its rank and absent for a model not remembered, and its effort facts (efforts, defaultEffort, switchEffort) where the session capability effortFacts is true. The list has bounds of its own, which the session keeps where it builds the list — so catalogs.models keeps 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; each name at most 128 bytes, a longer one cut on a character boundary and ended with …; and a model whose id would be over 256 bytes is not offered at all, since an id is never cut — one cut short would be one no session.set could 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.
  • revision is always written. It is 0 for the list a session started with, and only ever grows within one incarnation, in seq order. A change found while a turn runs (a pending refresh) 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 model and config sections, whenever that changed any model's rank or effort facts: the model switched to ranks first, and its row's switchEffort is 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 with requires is shown only where what it names is true — the session capability stop for session.stop, aside for session.aside, and effort while the current model has an effort option, which the client reads from the session's config and which changes with the model. It is always listed, because whether a session is served with stop and aside is the host's fact, not the session's, and whether its current model has an effort option changes without the catalog changing.
  • A requires row 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 (aside absent) the builtin /btw is 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 /effort on 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 /sessions shows.
  • whileWorking. While a turn is running — activity working, or a foreignTurn — a client runs only the rows with whileWorking, and sends a prompt row 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 commands or plugins always restates slash beside 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 carries slash alone; an update that changes nothing publishes nothing. Every install restates it: session/new's, and a load's, stamped replayed before the replay's end.
  • 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 each session.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.slash is the last section a delta carried, absent when there is none, and settings.truncated.slash says the snapshot did not carry it whole. Truncation never alters an action: a row whose name, source, plugin, qualified, requires or run key other than argHint and reason is 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.argHint and run.reason are cut to their head at that size, as a command's description is; and at most 512 rows are carried. truncated.slash is 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 commands and plugins beside 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 no settings.slash and sets settings.truncated.slash. truncated.slash with no slash means the catalog did not fit: the client holds no catalog until the next slash section 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 commands and plugins: 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's settings: 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 (so foo/bar and https://x are never one). A bare / lists every row.
  • Builtins (source builtin) 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: name starting with what was typed, then qualified starting with it, then name containing it, then qualified containing 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 a qualified — 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,

{"socket":"/tmp/.../host.sock","sessionId":"session-fake-1","hostId":"0123456789ab"}

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's PROGRAM under clean, in the background. Its stdin is every line appended to NAME.in, which tail -n +1 -f follows, and it writes NAME.out and NAME.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 of NAME.out that hold TEXT, once there is one, waiting at most SECONDS (30 if not given).
  • reply NAME ID [SECONDS] prints NAME's reply to request ID once it has come: the line whose id is ID. A line with no id is a notification, and a reply can come before or after one.
  • poll NAME SECONDS TEXT METHOD PARAMS sends METHOD with PARAMS to NAME again and again, with the ids 100, 101 and so on, until a reply holds TEXT, and prints that reply. SECONDS bounds the whole poll, to the second: its end is fixed once, when it starts (AWAIT_END, which await then 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.
  • cleanup ends 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 unavailable because neither cursor-agent nor agent is on PATH.
  • grok is ready because [agents].grok names a binary that exists.
  • gx is absent: it is listed only when its binary is found.
  • native is needs_setup because no API key is set and the native directory is empty.
  • defaultProvider is config.toml's provider.
  • recentDirs is [], 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:

  1. The hub's hello, as in step 4.
  2. {"jsonrpc":"2.0","id":2,"result":{}}, the hub's last line on this connection.
  3. The host's hello: "endpoint":{"kind":"host","hostId":"<host-id>",…}, with the clientId and token the host minted.
  4. The attach reply, with the session's snapshot. The synchronized and presence notifications follow.
  5. The poll's answer: a session.snapshot reply ("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.

R=/tmp/craze-recipe
"$R/poll" a 5 '/tmp/craze-recipe/work","usedAt":"' sessions.createOptions '{}'

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 the await prints 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.
  • cleanup finds 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 each tail -n +1 -f of one of its .in files. 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) in home/.cache/craze/hubs.
  • Each process's command line is read again just before its signal. So cleanup signals 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/path yourself.

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):

  1. 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.
  2. $XDG_RUNTIME_DIR/craze, when XDG_RUNTIME_DIR is set, non-empty and absolute.
  3. Linux only: /run/user/<uid>/craze, when /run/user/<uid> exists, is owned by the uid, and is 0700.
  4. /tmp/craze-<uid> (/private/tmp/craze-<uid> on macOS, where /tmp is a symlink) — never $TMPDIR, which on macOS is long enough on its own to crowd out sun_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:

  • hostId is 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).
  • ns is the first 8 hex digits of sha256 over the absolute, cleaned craze directory (CRAZE_HOME, default ~/.craze) — one namespace per user and per CRAZE_HOME, so two craze homes on one machine never share a socket directory. A relative CRAZE_HOME resolves 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_HOME or CRAZE_RUNTIME_DIR an interactive shell happened to have set plays no part in resolving anything.
  • Nothing about the runtime base. craze bridge never searches runtime bases itself: it reads the socket's absolute path out of the registry under $HOME — assumed to be the same $HOME as the tab that started the host, true for an ordinary SSH login as the same user. A host started under any XDG_RUNTIME_DIR or CRAZE_RUNTIME_DIR is 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 is sh -c '<script>' — so the far end never has to source a login shell just to find craze. 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 the exec at 126), tried in this order:

  • $HOME/.local/bin/craze — gated on $HOME itself being absolute (case "${HOME:-}" in /*) …), the same way rung 2 gates on command -v's answer: an unset, empty, or relative HOME (HOME=., say) must not turn $HOME/.local/bin/craze into 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-interactive PATH essentially 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-HOME guard 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 ssh a fresh craze bridge for every local connection rather than to multiplex one remote process, and nothing about craze bridge discourages 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 0700 and the socket is 0600. 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 by sshd itself, 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, macOS LOCAL_PEERCRED/LOCAL_PEERPID); craze bridge, craze attach, and internal/remote check 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 with openat(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, because bind(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 reserved auth field is where a future scope narrows it.