Skip to content

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:

  1. 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.
  2. The plugin reports that address as server_url in 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.
  3. The Tauri app stamps a row's session DTO with an agent_lane field whenever a tab's ownership has source == "opencode" and a non-empty server_url and a non-empty session_id:
{
  "kind": "opencode",
  "session_id": "ses_...",
  "server_url": "http://127.0.0.1:41234"
}

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-sessionopencode 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's docs/guides/agents.md for 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_url directly.
  • Everything else — the app opens ssh -N -L <local>:127.0.0.1:<port> to the machine and points the adapter at http://127.0.0.1:<local> instead. This forward is shared: two sessions on the same agent server on the same machine ride one ssh -N child. 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 new server_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.tokennever 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:

{"question": [["release"], []], "custom_text": [null, "ship it on Friday"]}

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().interject is false. Every send goes through opencode's prompt_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_cursor is false. Every reconnect refolds the full transcript from the top rather than resuming from a client-held position; this is what the ResetReady bracket exists to make invisible to the panel. A durable, resumable stream (opencode's session.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.