Agent lanes¶
An agent lane is a live transcript for one coding-agent session, opened directly from a machine row: activity, the message history, pending approvals, and a prompt box, without leaving the dashboard. This page covers the contract, the current adapter (opencode), the roost handshake that turns a card into a lane, and what this cut does not do.
What a lane is¶
shed_core::lane (plan 015) defines the contract every agent adapter
implements: a set of DTOs (session, transcript row, approval, event) plus one
async trait, AgentLane — "a coding agent with sessions, a transcript and
approvals," normalized so the desktop app (and, later, mobile) drives every
agent the same way. The contract carries no I/O of its own; each agent gets
its own adapter crate that supplies the transport, the reconnect loop, and
the translation from that agent's wire format into the contract's DTOs.
There are two adapters today: crates/shed-opencode for
opencode, and crates/shed-gx for
gx's remote lane (gx-remote-api).
Nothing in the Tauri app assumes either is the only agent — the capability
signal described below is what turns the affordance on or off per row, and
Lanes::open picks a concrete client by the row's own kind, never by
assuming one.
gx is the second adapter, and that matters beyond feature count: a contract
validated against one implementation is a design, not yet a contract. Building
shed-gx forced real corrections into shed_core::lane itself — recorded in
the module's own doc as "what the gx adapter changed" — because gx's remote
lane has things opencode's local server never needed: a bearer token, a
resumable cursor, and permission options whose semantic kind is separate
from their id and not always unique. Those corrections are what the gx's
lane section below is mostly about.
opencode's lane¶
shed-opencode talks to the same local HTTP server opencode's own TUI is
already running — never a sidecar, never a second process it launches
itself. That server lists sessions, replays and streams a session's
transcript, accepts prompts, and answers permission/question approvals over
opencode's own HTTP API (the legacy GET /event stream; opencode's newer
v2 event stream was still missing session.idle at the version this
adapter was built against, so the crate stays on the proven feed and
documents why in its module comments).
Every reconnect — the first subscribe, or a recovery after the stream drops —
is bracketed by a Reset event and a Ready event: everything the client
receives in between is staged and swapped in atomically once Ready arrives,
so the transcript panel never shows a half-seeded view mid-reconnect. A
Down event means the subscription ended; the panel keeps the last good
transcript on screen with a banner explaining why, rather than clearing it.
shed-opencode's capabilities, as reported by AgentLane::capabilities():
| Field | Value | Meaning |
|---|---|---|
kind |
"opencode" |
The adapter identity. |
create |
true |
New sessions can be opened through the contract. |
cancel |
true |
A turn in flight can be aborted. |
approvals |
true |
Permissions and questions surface and can be answered. |
interject |
false |
See Limits below. |
history_cursor |
false |
See Limits below. |
Answering: scoped, and not free¶
opencode has no by-id GET for a permission or a question — the only lookup it
offers is two directory-wide lists, and an instance is per directory, so
those lists carry every root session's approvals, not just the one the panel
is looking at. answer resolves the addressed approval inside its own
session's scope first — the root session plus its transitive descendants,
the same scope approvals already computes — before touching either list, so
one panel can never answer a sibling session's request. An id outside that
scope is unknown_approval; an id open in both lists at once is refused as
ambiguous rather than routed by a guess. A failed list read propagates rather
than reading as "not found," because a half-read directory cannot say an id
is absent. Answering an approval whose session has since been deleted is
unknown_session rather than reaching the wire.
This costs four GETs per answer on a childless session (the root session,
its /children read, and the two lists) where gx pays one, because gx has a
by-id route. What it buys is the same thing gx's own re-read buys: the answer
is translated against the options the agent actually offered, on an approval
this session owns.
The roost server_url handshake¶
The desktop app never scans for opencode servers and never launches one itself. It learns a session's server address entirely from roost, the per-host tab manager the app already reads machine rows from:
- When a roost tab is an opencode session, roost's opencode plugin exposes that TUI's own HTTP server on a loopback port — starting one on the plugin's own port-0 listener if the TUI wasn't started with an explicit one, or simply reporting the address the TUI is already listening on if it was.
- The plugin reports that address as
server_urlin the tab's ownership metadata (roost R10). It is always a loopback URL — roost validates this and drops anything else — because the server it names is only ever reachable from the host that ran it. - The Tauri app stamps a row's session DTO with an
agent_lanefield whenever a tab's ownership hassource == "opencode"and a non-emptyserver_urland a non-emptysession_id:
agent_lane's presence is the entire capability signal. A card with it
gets a Transcript affordance; a card without it does not, and every
lane.* op on that session answers the no_lane error. A tab reports no
server_url in two cases worth knowing:
- The session isn't opencode, or is opencode but the plugin isn't installed — an ordinary status-only row, same as before this feature existed.
- The plugin loaded mid-session —
opencode attach, or the plugin was installed while a session was already running. roost has no create-time event to hang the address on until the next session starts; until then the row is status-only. This is a known limitation, not a bug to chase. - The operator opted out — roost's plugin honours
ROOST_OPENCODE_NO_SERVER=1, which suppresses the loopback listener entirely. A tab started under it reports no address, so it is status-only by choice. Check this before treating a missing lane as a fault; see roost'sdocs/guides/agents.mdfor the opt-out's exact scope.
Reaching the server: local vs. SSH¶
server_url names a port on the machine that reported it, not on the
machine running the desktop app, so how the app reaches it depends on where
that machine is:
- Local (the machine the app itself is running on, or a machine mapped
by a test harness's socket table) — the app dials
server_urldirectly. - Everything else — the app opens
ssh -N -L <local>:127.0.0.1:<port>to the machine and points the adapter athttp://127.0.0.1:<local>instead. This forward is shared: two sessions on the same agent server on the same machine ride onessh -Nchild. A forward is torn down when its last lane closes, when the tab disappears from roost's own snapshot (the tab's process died), or when a restarted tab reports a newserver_url(a new ephemeral port is a different server, even for the same session id).
The --port 0 alias, for hosts without roost's plugin¶
The roost handshake above is how the desktop app discovers a lane automatically. The adapter itself has no roost dependency at all — it targets whatever base URL it is given. If a host doesn't have roost's opencode plugin installed, or you want to drive a session by hand without opening the app, start opencode directly with an explicit loopback port and point the crate's own CLI at it:
opencode --port 0 --hostname 127.0.0.1
# note the printed port, then:
cargo run -p shed-opencode --example lane -- \
http://127.0.0.1:<port> <session_id> watch
examples/lane.rs (shed-opencode-lane) supports sessions, history,
watch, send <text>, cancel, approvals, and
answer <id> allow-once|allow-always|reject — the same verbs the desktop
app's lane.* ops drive. OPENCODE_SERVER_PASSWORD is honored if opencode's
own password gate is set.
gx's lane¶
shed-gx's capabilities, as reported by AgentLane::capabilities():
| Field | Value | Meaning |
|---|---|---|
kind |
"gx" |
The adapter identity. |
create |
true |
POST /v1/sessions mints a new session. |
cancel |
true |
A turn in flight can be aborted. Refused with not_accepting on an idle session — the panel gates the affordance on Working so no human sees the refusal. |
approvals |
true |
Permissions, questions, plan approvals and MCP elicitations all surface and can be answered. |
interject |
true |
lane.send with mode: interject preempts the turn in flight — opencode's lane has no equivalent verb, so its capability stays false. |
history_cursor |
true |
gx's lastEventId lets a reconnect resume from a cursor instead of refolding history from the top — see Reconnects below. |
Discovery and the healthz / instanceId pin¶
A gx leader writes a discovery record next to its home: $GROK_HOME/gx-remote.json
on the default socket, gx-remote-<16hex>.json on any other —
{url, pid, instanceId, socketPath, tokenFile, version, startedAt}. The token
itself is one file, $GROK_HOME/gx-remote.token — never suffixed, even
when the record is, because gx keeps one token per $GROK_HOME, shared by
every leader on it; a leader restart changes instanceId, never the token.
Before the adapter sends its first bearer request — and again at the start of
every transport epoch (every reconnect, every failure) — it calls the
token-free GET /v1/healthz and compares the instanceId it returns against
the discovery record. Only on a match does the token go out. A mismatch
triggers one re-discovery attempt; still mismatched, or no live record at all,
answers Unavailable and leaves the epoch unpinned — no bearer request is
ever sent on a lane the adapter cannot prove is the one discovery described.
This gate (ensure_pinned) is one serialized async lock per client, so
concurrent first callers share a single pin instead of racing separate ones.
Two URLs, never conflated¶
Same invariant as opencode's SSH forward (above), stated explicitly here
because gx's credential pin depends on it: a gx lane has a reported URL
(what a discovery record's url is matched against, and what roost stamps as
gx.remote) and a dial URL (where HTTP actually goes — the same address
on a local machine, http://127.0.0.1:<local> over a forwarded SSH tunnel).
GxClient is constructed with both, and a GxTransport::dial() hook is
called before every connect attempt — the first verb, every SSE
(re)connect, every verb after a failure — so a forward that moved is noticed
rather than dialled blind. Discovery matches the reported URL; healthz and
every bearer request ride the dial URL.
One consequence worth stating plainly, because it is easy to get backwards:
gx.remote's value is slash-free (http://127.0.0.1:2431), and
loopback_base_url — the same validator roost itself uses to decide whether
to publish the key at all — rejects a trailing slash. A trailing slash
anywhere in the reported URL means no lane at all, not a degraded one.
The credential rule¶
The lane contract carries no credential type. roost's job stops at
reporting where an agent is; how a client is let in is the client's own
problem, deliberately kept out of shed_core::lane so a third adapter never
inherits a credential shape gx happens to need. Concretely: an adapter takes
its credentials at construction, from a source the client supplies —
GxClient::new takes a credentials: Arc<dyn GxCredentialSource>. The Tauri
app's implementation reads a local $GROK_HOME directly (never by shelling
out) when the machine is local, or runs one POSIX sh -c probe over the
machine's SSH reach otherwise — checking the same things gx's own reader
checks (a regular file, mode 0600, owned by the caller) before it will hand
a token back, and refusing a symlinked or wrongly-permissioned token exactly
as gx does. StaticCredentials is the fixed-value implementation the crate's
tests, its examples/lane.rs CLI, and the phone's first cut use.
Kind promotion is a hint, not liveness¶
A gx tab reports source: "grok" in roost whether or not a lane is up.
RcKind::Gx is derived, not reported: source == "grok" and
metadata["gx.remote"] passing loopback_base_url promotes the row to Gx;
source == "grok" with no such key (or one that fails validation) leaves it
Grok — creatable, but lane-less by design (see below). Promotion is one-way
per report, and a dead lane does not un-stamp the row: a tab stays gx
for as long as roost keeps the metadata key on it, even after the leader
behind that key has died. lane.open is what surfaces the difference — it
answers unavailable rather than the Transcript affordance silently failing
to appear.
unsupported_lane¶
LaneEntry.client is Arc<dyn AgentLane>, keyed by the full stamp
(kind, server_url); no concrete adapter type is named anywhere outside
Lanes::open's own match on the stamp's kind. A kind that is neither
"opencode" nor "gx" answers LaneFailure::UnsupportedLane(kind) — IPC
code unsupported_lane — instead of the app guessing at a client to
construct.
Segments, not tokens — and lossless¶
gx's chunks stream token by token; the transcript panel does not render at
that granularity. A chunk streak closes and emits a row when a
transcript-bearing update of a different kind arrives, when the prompt
changes, when turn_completed fires, or when the streak passes 8 KiB —
whichever comes first — and a streak that has gone silent for 2 seconds
flushes on its own rather than waiting indefinitely for a reason to close.
Segmentation is lossless: text is split only on character boundaries,
never truncated to fit a cap, and the chunk after a cut opens a fresh streak
rather than losing the tail of the one before it.
Bounded silent resume; Reset … Ready is the reseed bracket only¶
opencode has no cursor, so every reconnect refolds its whole transcript from
the top, and Reset … Ready exists purely to make that invisible to the
panel. gx has Last-Event-ID, so a reconnect the server accepts from the
client's cursor resumes silently — no Reset at all: the ring, the open
streak, the generation and the panel's view all survive untouched. That
silent path is bounded — at most three attempts within thirty seconds of the
first loss — past which, or on an explicit server reset, or on a cursor the
server no longer recognizes, the adapter reseeds: the open streak is
discarded, history is rebuilt from scratch, and the rebuild is bracketed with
Reset … Ready exactly as opencode's reconnect always is. Reset.reason
(connect, cursor_lost, server_reset:<r>, stall, lagged) is free text
for a log line — nothing switches on it. lagged is not a transport loss at
all; it is what a stalled client's own channel overflow forces — see A
stalled client and the channel bound
below.
Either way, every reconnect — silent or not — re-fetches what the SSE
stream itself never replays (approvals, the session row), because a resumed
stream is not a complete one; a held-pending approval the re-fetch no longer
lists comes back as a Resolved tombstone rather than staying stuck on
screen forever. Repairing the transport (re-establishing an SSH forward) is
not a LaneEvent either — it rides the GxTransport::dial() hook described
above, called before every connect attempt, so a forward that needed
re-ensuring never forces a visible reseed on its own.
The option_for ambiguity refusal¶
This is the headline contract change, and it exists because of a real gx
permission recorded live, not a hypothetical one: asked to run id -un, a
live gx leader offered five options, and two of them declared
kind: "allow_once" — "Yes, proceed" and "Yes, and don't ask again for
anything (always-approve mode)". LaneApprovalOption.kind is the option's
semantic kind (allow_once / allow_always / reject_once /
reject_always); id is opaque and independent of it — an agent can offer
several options of the same kind, so a bare decision
(AllowOnce / AllowAlways / Reject) is not always enough to pick one.
When it is ambiguous, LaneApproval::option_for refuses to guess — it
returns None, and the adapter answers BadRequest — rather than resolving
the tie by offered order, which on gx's own five-option set would have
silently selected the option that turns off every future permission prompt.
The panel's answer is capability-driven, not a fixed three-decision form: it
renders every option an approval offers, under the agent's own label, in the
agent's own order, and posts back LaneAnswer::Choice { option_id } — the
exact id the human pressed — for every adapter. Clients send
{choice: "<id>"} over IPC. opencode's three options carry the same labels
either way, so nothing visibly changes there, and the scripted
{permission: "allow-once"} form still works when the kind it names is
unambiguous on the approval being answered.
Lane-less grok¶
RcKind::Grok — a gx session with no bound lane, or one started
--no-leader / under GX_REMOTE_DISABLE=1 — is creatable and renders as an
ordinary status-only row, same as any RC kind before agent lanes existed: no
Transcript affordance, because there is no lane to open.
Free-text answers¶
A question can accept typed prose beside its options. The contract carries it
as its own positional field on the answer — custom_text, one entry per
question, null where nothing was typed — never appended to the list of chosen
option ids, because an adapter that cannot tell a chosen label from something a
human wrote cannot map it onto the agent's own shape.
Over IPC it rides beside question and nowhere else:
custom_text beside choice, permission or reject is a bad_request — a
client that typed something and named the wrong form is told the text did not
travel. The text is trimmed once, in shed_core::lane::normalize_question_answer
(the one reader both adapters share), and text aimed at a question whose custom
is false is refused before anything reaches the wire.
| how the answer reaches the agent | |
|---|---|
| gx | The answer map is keyed by the question's TEXT and holds labels, so a typed answer is the label "Other" plus the prose in a parallel annotations[<key>].notes map. With a label picked too, the labels stand and the note rides beside them. A question answered with neither is omitted from the map — gx's own pager's rule for "unanswered". annotations is omitted when empty. |
| opencode | QuestionReply.answers is "an array of selected labels", and a custom answer is a label the ask did not offer — so the text is appended as one more entry on that question's list, which is exactly what opencode's own TUI posts. A question answered with neither stays [] (opencode reads that as unanswered). |
Which questions accept it. custom is true on every gx question: its pager
always draws a freeform row and the request carries no flag that could say
otherwise. On opencode the flag's documented default is true as well ("Allow
typing a custom answer (default: true)"), so an ask that omits it — the ordinary
wire shape — accepts free text; only an explicit custom: false does not.
The cost, on purpose: a lone single-choice gx question no longer takes the panel's one-click path (one question, one choice, no free text), because a click can no longer mean "I am done typing". It goes through Send answer like any other staged form.
The one residual is a gx pager launched with no_freeform. The request does not
advertise that, so the answer is refused by gx and surfaces as a bad_request on
an inline error card — told, never silently dropped.
Driving a lane¶
The view these ops answer from is shed_app::lane_view::LaneView — one per
open subscription, client-shared Rust, not Tauri-specific. It folds a
shed_core::lane subscription's frames in arrival order behind the same
Reset … Ready staging the contract promises, and exposes them through a
typed LaneView::snapshot(since_seq) (LaneViewSnapshot { messages, full,
activity, generation, stale, approvals }); None returns everything, Some
a delta honored only when the cursor still lands inside the live
generation's seq window. It lives in shed-app, ungated, for the same
reason machine.rs and roost.rs are — mobile links shed-app with default
features and needs the identical fold, so "the phone shows the same view the
desktop shows" is a property of one implementation rather than two that have
to agree. The Tauri crate's own lane.rs is now just the IPC layer: it
serialises the snapshot's fields into the lane.messages/lane.approvals
payload and folds nothing itself.
Once a row carries agent_lane, opening its Transcript affordance calls
lane.open {machine, session_id} and mounts the panel:
| Op | Does |
|---|---|
lane.open |
Ensures a subscription (idempotent — a second call for an already-open lane re-answers from the existing entry). |
lane.messages |
The staged transcript: up to the last 500 rows, current activity, generation, and a stale reason when the lane is Down. |
lane.approvals |
Pending permissions and questions, root session plus its children, sorted created_at then id. |
lane.send |
Queues a prompt (mode: queue), or preempts the turn in flight (mode: interject) when the lane advertises interject — the panel shows the toggle only then, and only enables it while the turn is Working. |
lane.cancel |
Aborts the turn in flight. |
lane.answer |
Answers one approval. Four forms, exactly one per answer: {choice: "<id>"} — the exact option id the approval offered, which is what the panel always sends, because an agent can offer several options of the same decision kind (see gx's option_for refusal); the scripted {permission: "allow-once" \| "allow-always" \| "reject"}, which resolves by semantic kind and refuses an ambiguous one; {question: [[…]]}, optionally with custom_text beside it (see Free-text answers); and {reject: true}. |
lane.close |
Ends the subscription; the last close on a shared SSH forward tears it down. |
A failure comes back as {code, message} with the contract's own snake_case
codes (unauthorized, unknown_session, unknown_approval,
already_submitted, already_resolved, not_accepting, unavailable,
failed), plus no_lane for a row with no agent_lane stamp at all and
unsupported_lane for a row whose agent_lane.kind names no adapter this
build has.
A stalled client and the channel bound¶
The channel each subscription streams over is bounded — LANE_CHANNEL_CAPACITY
(1024 frames) — so a client that stops draining (a backgrounded phone
mid-session is the canonical case) cannot grow a watcher's backlog without
limit; before this, it was unbounded. shed_core::lane::LanePublisher is the
one place the overflow policy lives, shared by both adapters: publish is a
try_send, and a full channel answers Publish::Lagged rather than blocking
or dropping the frame unnoticed. Every emitting helper in both adapters
propagates that, so a generation ends at the first dropped frame —
mid-stream, mid-seed, or mid-reseed alike — and is retried rather than left
half-staged.
A lagged generation is never resumed silently, even on gx: the adapter drops
its transport first (gx also unpins its epoch), waits for the channel to
drain completely with no transport held, takes the ordinary failure backoff,
and reseeds with a fresh Reset { reason: "lagged" } … Ready — because the
dropped frames may already have been folded at or before the client's cursor,
and resuming from it would leave the hole permanent. The one frame that is
never dropped is the terminal Down: it goes through
LanePublisher::publish_final, which awaits room rather than trying and
giving up, so a client can never be left holding a stale Ready view with no
stale reason to explain it.
lagged and overflow name different things and neither substitutes for the
other: overflow is the adapter's own inbox falling behind its source before
folding; lagged is this client channel falling behind the fold. A lagged
reconnect also clears gx's down_after clock rather than skipping it, since
"is the agent there" and "is the client keeping up" are different questions.
The password / status-only rule¶
There is no credential source in this cut. If opencode's server has
OPENCODE_SERVER_PASSWORD set, every request the adapter makes without
credentials answers 401, which the adapter maps to LaneError::Unauthorized
and the panel renders as an inline error — the transcript never loads. This
is deliberate and documented, not a bug: a password-protected opencode
session is status-only in this build. The client type
(shed_opencode::BasicAuth) already exists for the follow-up that adds a
config field to supply one; nothing wires it up yet, and no test claims
otherwise.
Limits¶
- No interject.
capabilities().interjectisfalse. Every send goes through opencode'sprompt_async, which is accepted and ordered after whatever the session's runner is already doing — it does not preempt a turn in flight. There is no "type over the agent" affordance. - No resume-from-cursor.
capabilities().history_cursorisfalse. Every reconnect refolds the full transcript from the top rather than resuming from a client-held position; this is what theReset…Readybracket exists to make invisible to the panel. A durable, resumable stream (opencode'ssession.next.*) is future work. - Child sessions. roost attributes a subagent's own events to the parent tab, so a session can sit blocked on a child's approval that never appears anywhere else. The adapter tracks a root session's descendants and surfaces their pending approvals — answering one routes to the child's own request id — but the transcript stays root-only: a child session's messages are never rendered, only the fact that it is waiting on you.
See also¶
- Machines and RC sessions for how a machine row and its sessions are discovered in the first place.
- IPC for the full
rc.*/ machine op surface a lane's row lives inside.