Skip to content

Remote Control becomes a fourth typed spawn knob — Remote bool on SpawnSpec, one lab setting every provider sees, deliberately kept OUT of the ADR-0021 options bag — defaulting OFF, with providers advertising only whether they honor it

Issue #163 (2026-07-14): --remote-control <session> is hardcoded into claude-code's spawn argv. There is no way to launch an agent without it, and it is not the isolated little flag it looks like — the claude.ai deep link is a byproduct of it. ADR-0017's DeepLinker scrapes the URL out of claude's session registry, and that bridge id only exists because the session registered itself with Remote Control. The "Open" affordance on a run row is Remote Control. So this slice is not "add remote"; it is "make the always-on remote a knob," and the interesting half is everything that happens when it is off — a lifecycle premise, a hook contract, a transcript shape, and a deep-link capture path all sit downstream of a flag nobody had ever removed.

The design question underneath is a seam question that ADR-0021 left an implicit answer to and this ADR makes explicit. Since ADR-0021, every new spawn-time provider knob was supposed to ride the generic SpawnOptions() bag — provider-declared, lab-stored, provider-applied, with the core never learning what a key means. Remote is the first knob where that answer is wrong, and understanding exactly why is what pins the shape of everything below: the bag is the right home for opaque provider data, and remote is not opaque provider data. It is one lab-domain bit that lab itself must act on.

The decisions, pinned:

  • Remote joins provider/model/effort as a typed spawn knob: Remote bool on provider.SpawnSpec. It is declared once, at lab level, and handed to every provider on every spawn; each provider does whatever it wants with it. claude-code (internal/provider/claudecode/claudecode.go) adds or omits --remote-control <SessionName> from its argv; codex receives the same bool and ignores it entirely. Lab never learns the mechanism — not the flag name, not the bridge, not the registry key — exactly the isolation D14 draws around model/effort catalogs. The knob is a fourth rung on the same ladder ADR-0030 widened from two to three: one field on SpawnSpec, one field on LaunchSpec, one resolver, one column, one setting pair, one select.

  • It is deliberately NOT an entry in the ADR-0021 SpawnOptions() bag — the first knob kept out of it on purpose. The bag is provider-declared and provider-filtered, and those two properties make "one setting that every provider sees" structurally inexpressible in it. Two providers that each declare a remote option are two independent keys the operator must find and set in two places, with nothing tying them together; a provider that declares nothing means the toggle silently vanishes from the Settings UI the moment that provider is the effective one — the operator's global preference evaporates rather than being ignored honestly. A bag key can only ever say "this provider offers this option," never "lab offers this and asks every provider about it." And the second, decisive half: lab must act on the value. Deep-link capture is armed or not armed on the strength of this bool (below), which is precisely the criterion ADR-0021 itself set when it routed ultracode through the opaque bag but kept incogni as a typed lab-domain flag — "a lab-domain repo flag with cross-cutting behavior lab owns and enacts" versus "opaque provider option data the provider alone interprets." Remote is the former by that ADR's own test. The bag stays exactly as ADR-0021 pinned it, and gains nothing here; this ADR draws the line the bag's boundary always implied.

  • Providers advertise whether they honor it, never what they do with it: RemoteCapable. A new optional capability interface, type RemoteCapable interface{ SupportsRemoteControl() bool } (internal/provider/provider.go), advertised structurally by type assertion at the call site — the ADR-0017 pattern, and its fifth instance after ConnectingReporter, DeepLinker, LiveSignals, and LoginCodeReporter. It is surfaced additively as supports_remote on GET /api/v1/providers, and the SPA disables the toggle with an explanatory note when the resolved provider does not honor it, rather than hiding the knob or pretending the setting took. That is the whole contract: lab learns whether a provider will act on the bool, and nothing whatsoever about the mechanism behind it. It is not a config flag, not a registry entry, not a provider-id branch anywhere in core — a provider that grows remote support later grows it by implementing one method, with zero change to the seam, the resolver, the store, or the UI.

  • The capability clamp: ResolveRemote returns false for a provider that does not advertise RemoteCapable, whatever the layers say. A repo could have remote_default = true and a global spawn_remote_default = true and still resolve false on a codex spawn, because codex does not implement the interface. This is not defensive tidiness — it is a truthfulness requirement on the run row. runs.remote must record what the session actually is, not what the defaults asked for, because two live consumers read that stored bool back and act on it: the ArmCapture deep-link gate and the SPA's Open affordance. A run row that claimed remote:true for a session spawned by a provider that ignores the flag would arm a capture that can only miss and advertise an Open link to a session that does not exist. The clamp is where the honest-degradation rule of ADR-0017 becomes a stored fact rather than a UI courtesy.

  • Layering is full parity with the other typed knobs, skip-layer per ADR-0030. Manual resolves request → repo.remote_default → spawn_remote_default → false; AFK resolves repo.afk_remote_default → spawn_remote_default_afk → repo.remote_default → spawn_remote_default → false — the identical base + AFK-override apparatus ADR-0021 built for model/effort and ADR-0030 widened to provider, one rung wider again. It lands as a new ResolveRemote in internal/instance/credential.go, sitting beside ResolveProvider, ResolveModelEffort, and ResolveSpawnOptions (all four now run-kind-aware, all four feeding one LaunchSpec), and it feeds LaunchSpec.Remote → SpawnSpec.Remote. Nothing about the resolution shape is novel; what is novel is that this is the first knob whose value type breaks the shape's oldest unstated assumption, which is the next bullet.

  • The bool trap, and why *bool is load-bearing at every layer. Every layered knob so far spells "unset" as the empty string, and that is safe only because "" is never a legal model id, effort id, or provider id — the sentinel is disjoint from the value space by luck of the domain. false IS a legal value for this knob. So "unset" can never be spelled false, and any layer that conflates them is silently broken in one specific, operator-hostile direction: a repo explicitly set to off would inherit on from the global default, and the operator's most deliberate act — turning remote off for one repo — would be the one the system ignores. Hence *bool at every layer that must distinguish unset from explicitly-off: the spawn request, both repo columns (nullable, NULL = inherit), and the PATCH decoders on both scopes, which must tell "the field was absent" from "the field was sent as false". The settings layer, which has no nullable column type, spells unset as ""/absent-row instead. The invariant that falls out and must never be softened: a layer that is present-and-false beats every lower layer that is true. Presence is what the chain resolves on, not truthiness.

  • Storage: migration 0011, sqlite + postgres, mirrored. Settings gain spawn_remote_default (the base, seeded false) and spawn_remote_default_afk (the AFK override, deliberately unseeded — an absent row means inherit, and seeding it would collapse the AFK chain's top rung into a hardcoded answer). Repos gain nullable remote_default and afk_remote_default. And runs gains a real, NOT NULL remote column carrying the resolved value stamped at launch. That last one is the interesting deviation: ADR-0021 explicitly deferred a runs.options history column on the reasoning that "re-adoption re-adopts a live tmux session, never re-spawns, so nothing needs the bag after launch." That reasoning does not extend to remote, because deep-link capture arms at boot re-adoption as well as at Start — after a restart, the run row is the only artifact in the world that knows whether the live session it is re-adopting was launched remote. A generic bag lab is forbidden to interpret cannot answer that question; a typed column can. Existing rows are backfilled to true: they were launched with --remote-control, their bridge sessions are real, and their captured claude.ai Open links must keep working across the migration.

  • The default is OFF, and that is a deliberate, accepted regression. With spawn_remote_default seeded false, a new claude run comes up without Remote Control and therefore without a claude.ai Open link, until an operator opts in at global or repo scope. This costs something real and we are paying it knowingly: lab's own embedded chat (ADR-0016/ADR-0025) is the primary surface for driving a run, and the claude.ai deep link is redundant with it — a second window onto a session lab already renders, answers dialogs in, and interrupts. Defaulting on (pure opt-out) would have preserved every link at the cost of making the knob a formality nobody ever finds; defaulting off makes the choice explicit and, more importantly, forces the off-path to be correct rather than theoretically supported — which is exactly the work the live spike below did.

  • When remote is off, lab does not arm deep-link capture at all — gated at the single ArmCapture choke point. Not at Start, not at boot re-adoption; both paths call Service.ArmCapture (internal/instance/instance.go), so the gate is one condition in one function rather than two conditions that can drift apart. deep_link_url stays NULL, which is already legal (ADR-0017 pinned the write-only-on-hit rule and a nullable column). The alternative — arming capture anyway and letting it miss — is not benign, and the spike measured exactly how not-benign: CaptureDeepLink => url="" err=<nil> elapsed=30.152s, one full 30-second background poll per spawn, plus ADR-0017's deliberately loud capture-miss WARN on every single non-remote run. A miss log that fires on the expected path is a log that trains operators to ignore the one case it exists to shout about. So the honest thing is not to ask the question: a provider with no bridge to capture and a run with remote off are the same situation ADR-0017 already handles by simply not arming.

  • The Open affordance gate is capability-scoped, not a bare !run.remote. The SPA hides Open entirely — the exact deep link and ADR-0017's FallbackOpen claude.ai picker link, which would otherwise point at a session that does not exist — exactly when provider.supports_remote && !run.remote. The supports_remote conjunct is not decoration. Because the capability clamp makes every codex run remote:false, a bare !run.remote check would also strip the tmux attach affordance that link-less providers show today (ADR-0017's "a link-less provider's rows get a copyable tmux-attach affordance") — a live regression for codex, introduced by a feature codex does not participate in, and a direct violation of ADR-0017's honest-degradation rule. Scoping the gate to providers that can do remote keeps the two cases distinct: "this provider has no web surface, here is a terminal command" and "this provider has one but this run opted out of it." Codex's behavior is unchanged in every case.

  • The Tier-1 conformance bar it buys: a new spawn-remote obligation. providertest.Conformance (ADR-0036) gains an eleventh obligation, and it is sharp in both directions. A provider that implements RemoteCapable must produce a different argv for SpawnSpec{Remote:true} than for SpawnSpec{Remote:false} — advertising the capability and then ignoring the bool is the exact dishonesty supports_remote exists to prevent, and it is now a CI failure rather than a code-review catch. A provider that does not implement it must produce a byte-identical argv for both — a provider cannot smuggle remote behavior in without declaring it. Both directions are proved load-bearing by deliberately-broken fakes in the suite's own self-tests, per ADR-0036's rule that an obligation which does not fail when violated is decorative.

  • The done-signal gate is clear — the live spike cleared it, and amended CONTEXT.md's reasoning while doing so. Default-off put a pinned invariant on trial: CONTEXT.md's "session exit is never the done-signal, because --remote-control idles after finishing." If the flag were what kept the process alive, then every non-remote run would exit at turn end and the AFK done-signal, the dead-session sweep, and re-adoption would all change meaning at once. A Tier-2 live spike (ADR-0036) against real claude 2.1.206, evidence committed to internal/compat/compat.md §12, ran lab's actual argv with and without the flag and found: a non-remote session does not exit on completion — it idles at its composer, observed still S (sleeping) at ~12 minutes past turn end, with the remote arm behaving identically. So the invariant's conclusion holds and its stated cause was simply wrong. CONTEXT.md is amended accordingly: the CLI idles because an interactive CLI does not quit when a turn ends, remote control on or off. Hooks, the dialog spool, the Notification blocked marker, the per-run --settings liveness signals, and the transcript path (same directory slug, same <sessionId>.jsonl rule, located by the same registry pass) are all unchanged without the flag; the session registry file is still written, with only bridgeSessionId absent.

  • The one genuine remote-dependent divergence — flush-on-resolve is a Remote Control behavior, and a "dormant fallback" just became the live path. This is the most surprising consequence of the entire change and it is recorded here so nobody deletes the code it resurrects. Compat §5 pinned that a pending AskUserQuestion/ExitPlanMode tool_use is not written to the JSONL while it is pending. The spike shows that is true only with --remote-control: spawned without the flag, claude flushes the pending tool_use to the transcript immediately, while the picker is still up and unanswered. The knock-on runs straight through ADR-0020's dialog-spool overlay: pendingDialog's answer guard (toolIDInTranscript) sees the spool's tool_use_id already present in the transcript, reads it as resolved, and correctly declines to surface the side-channel — so Chat.PendingDialog is nil, and the dialog instead arrives through the transcript-tail scan in internal/chat, the path whose own comment described it as "the dormant fallback for a provider that DOES flush pending tool_use." That fallback is now THE live path for every non-remote run, on both the server (PendingDialog falling back to the last dialog message when the side-channel is nil and the state is question) and the SPA (RunChat.tsx's messages-scan). It needs no code change — answering through it is verified live end to end, and both arms compose to the same operator-visible state — but it must never be deleted as dead code, and the spool's rotation-staleness guard still earns its keep for remote runs. The design was right for a reason its author had not met yet.

  • Repo and global scope both get a 3-way Inherit / On / Off select, showing the effective value on the inherit entry. This is what the *bool model looks like on screen, and it is the reference implementation issue #21 should copy — a 2-state checkbox cannot render a 3-state inherit model without lying about one of the states. #21 (that exact bug, in the bag's schema-driven rendering of ultracode) is explicitly not fixed here: it is a different code path, the generic bag's renderer, and this ADR touches the typed core only. What this slice gives it is a worked, shipped answer to copy.

Status

Accepted. Settled via issue #163. Amends ADR-0021: remote is the first spawn knob deliberately kept out of the SpawnOptions() bag, and this ADR states the boundary that ADR always implied — opaque provider data rides the bag, lab-domain bits lab must act on stay typed (the same ultracode-vs-incogni line, now with a rule attached). Extends ADR-0017: RemoteCapable is the fifth optional capability advertised by type assertion (after ConnectingReporter, DeepLinker, LiveSignals, LoginCodeReporter), and the deep-link no-arm path it already built for link-less providers is reused verbatim for remote-off runs. Extends ADR-0030: remote is a fourth layered, AFK-symmetric, skip-layer spawn knob on the same apparatus — the first one whose value type forces *bool rather than the ""-means-unset sentinel every prior knob relied on. Extends ADR-0036: providertest.Conformance gains the spawn-remote obligation, and the Tier-2 live spike behind the default-off decision is recorded at internal/compat/compat.md §12. Reaffirms ADR-0020's dialog-spool design by discovering that its speculative transcript-tail fallback is now load-bearing. One migration (0011, sqlite + postgres). Not non-breaking by choice: new claude runs default to remote-off and therefore to no claude.ai Open link, an accepted regression (see below); existing runs are backfilled true and keep theirs. CONTEXT.md's done-signal entry is amended — same conclusion, corrected cause.

Considered options

  • A per-provider remote entry in the ADR-0021 SpawnOptions() bag. Rejected on the bag's own two defining properties. Provider-declared means two providers supporting remote produce two unrelated keys the operator must discover and set independently — there is no single "remote" setting, only N of them. Provider-filtered means the toggle silently disappears from Settings whenever the effective provider declares nothing, so the operator's stated preference does not get honestly ignored, it gets erased from the screen. And the bag is opaque by construction — the core is forbidden to interpret a key — while lab must read this value to decide whether to arm deep-link capture at Start and at re-adoption. A knob lab must act on cannot live in the one structure whose entire contract is that lab never looks inside it.

  • A lab-owned "well-known key" that bypasses the bag's provider-filtering. Rejected as the worst of both: it keeps the bag's untyped map[string]string storage and validation while carving a special case into the one rule that makes the bag comprehensible ("lab stores and renders, the provider declares and applies"). The moment one key is special, every reader of the bag has to know which keys are special, and the seam's whole value — that a provider option is opaque data lab moves without understanding — is gone. A typed field is what a special key is, minus the pretence.

  • Keep remote hardcoded on for claude (the status quo). Rejected: it is an undeclared, unremovable coupling to one provider's optional feature sitting in the hot path of every spawn, and it makes a claude.ai bridge session an unavoidable side effect of using lab at all. It also leaves the off-path — the flush-on-resolve divergence, the capture gate, the affordance gate — permanently unexplored and therefore permanently untrustworthy.

  • Default the setting ON (pure opt-out). Rejected. It preserves every existing Open link at the cost of a knob nobody discovers and an off-path nobody exercises — the failure mode where the feature technically ships and is never correct. Lab's embedded chat is the primary surface and the claude.ai link is redundant with it, so off is the honest default for the common case; an operator who wants the bridge turns it on once, globally, and gets it everywhere.

  • A bare !run.remote gate on the Open affordance. Rejected — and it is worth recording as a rejection because it is the obvious implementation and it is wrong. The capability clamp forces every codex run to remote:false, so a bare check would hide codex's tmux attach affordance too: a regression in a provider that has nothing to do with this feature, caused by a bool that means "this run declined a capability it had" being read as "this run has no way in." The gate must be provider.supports_remote && !run.remote — capability first, then the run's own choice.

Consequences

  • internal/provider/provider.go: SpawnSpec gains Remote bool; the new RemoteCapable interface{ SupportsRemoteControl() bool } joins ConnectingReporter/DeepLinker/LiveSignals/LoginCodeReporter as the fifth optional capability. internal/provider/claudecode/claudecode.go implements it (true) and adds or omits --remote-control <SessionName> from its argv on the bool; codex implements nothing, receives the bool, ignores it, and reports supports_remote: false.
  • internal/provider/providertest: the Fake implements RemoteCapable; Conformance gains the spawn-remote obligation (capable ⇒ argvs must differ; non-capable ⇒ argvs must be byte-identical), each direction proved by a deliberately-broken fake in the suite's own self-tests.
  • internal/store: migration 0011 (sqlite + postgres) adds settings spawn_remote_default (seeded false) and spawn_remote_default_afk (unseeded; absent = inherit), nullable repos.remote_default and repos.afk_remote_default, and NOT NULL runs.remote — with existing rows backfilled to true so their captured claude.ai links keep resolving.
  • internal/instance: ResolveRemote joins ResolveProvider/ResolveModelEffort/ResolveSpawnOptions in credential.go, run-kind-aware and skip-layer, clamping to false for a provider that does not advertise RemoteCapable; it feeds LaunchSpec.Remote → SpawnSpec.Remote. Service.ArmCapture (instance.go) — the single choke point both Start and boot re-adoption call — does not arm deep-link capture for a non-remote run, so deep_link_url stays NULL and no 30 s poll and no capture-miss WARN fire on the expected path.
  • internal/httpapi: POST .../instances accepts an optional remote (*bool — absent ≠ false); the repo and global PATCH decoders carry *bool for both scopes' base and AFK slots; GET /api/v1/providers gains the additive supports_remote.
  • Web client: a 3-way Inherit / On / Off select at repo and global scope (showing the effective value on the inherit entry), the toggle disabled with a note when the resolved provider reports supports_remote: false, and the Open affordance — deep link and FallbackOpen picker link — hidden exactly when provider.supports_remote && !run.remote. Codex's tmux attach affordance is untouched.
  • internal/chat and RunChat.tsx: no change, and a standing warning. The transcript-tail dialog scan, written as "the dormant fallback for a provider that DOES flush pending tool_use," is now the live path for every non-remote run — because claude without --remote-control flushes pending tool_use immediately and the ADR-0020 spool overlay therefore self-suppresses. It is verified answerable end to end. It must never be removed as dead code.
  • internal/compat/compat.md §12 is the Tier-2 record (live A/B, real claude 2.1.206) the default-off decision rides on; CONTEXT.md's done-signal entry is amended to keep its conclusion and correct its cause.
  • Codex's own codex remote-control start|stop|pair is NOT wired up. It is an experimental host-level app-server daemon with a pairing code — not a per-session spawn flag — so codex simply ignores the bool and reports supports_remote: false. If it is ever wired, it happens entirely behind this seam: one method, one argv branch, zero change to the resolver, the store, the API, or the UI. That is the whole point of the design.
  • Acceptance shape: a fresh install spawns claude without --remote-control, arms no capture, logs no miss WARN, stores runs.remote = false and deep_link_url = NULL, shows no Open affordance — and still answers a dialog end to end (through the transcript-tail path) and still holds the AFK done-signal, because the session idles at its composer rather than exiting. Flip the global setting on and the same repo spawns with the flag, captures its link within seconds, and shows Open. A repo set explicitly to Off under a global On spawns non-remote. Every codex run resolves remote:false, shows supports_remote: false, and keeps its tmux attach affordance unchanged.