feat(spawn): Remote Control as a typed spawn knob — one lab setting, providers do what they want (grilled 2026-07-14) #163
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab#163
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Remote Control is hardcoded on for every claude run:
There is no way to launch an agent without it. It is also not the isolated little flag it looks like: the claude.ai deep link is a byproduct of it —
CaptureDeepLink(ADR-0017'sDeepLinker) scrapes the URL out of claude's session registry, which only exists because the session registered itself with Remote Control. The "Open" affordance on a run row is Remote Control.So this is not "add remote" — it is "make the always-on remote a knob", and the interesting half is what happens when it is off.
Decisions (pinned)
The seam: a fourth typed spawn knob, not a bag entry
Remote joins provider/model/effort as a typed spawn knob: a new
Remote boolfield onprovider.SpawnSpec. One lab-level setting, declared once; every provider receives the bool and does with it whatever it wants. claude-code adds/omits--remote-control <SessionName>; codex and gemini receive it and ignore it. Lab never learns the mechanism.It is deliberately not an entry in the ADR-0021
SpawnOptions()bag: that bag is provider-declared and provider-filtered, so "one setting that every provider sees" is not expressible in it (two providers declaringremote= two keys to set; a provider declaring nothing = the toggle silently vanishes). And lab must act on the value (deep-link gating, below) — which is precisely ADR-0021's own criterion for a lab-domain flag rather than opaque provider data.Honesty: providers advertise whether they honor it
A new optional capability, advertised structurally by type assertion (the ADR-0017
DeepLinkerpattern — not a config flag):Surfaced as
supports_remoteonGET /api/v1/providers. The UI disables the toggle (with a "Codex ignores this" note) when the resolved provider does not honor it. Lab learns whether, never what.This also buys a sharp Tier-1 conformance check (
providertest.Conformance): for a provider implementingRemoteCapable,SpawnArgv(spec{Remote:true})must differ fromSpawnArgv(spec{Remote:false}); for a provider that does not implement it, the two argvs must be identical.Layering: full parity with the other typed knobs (ADR-0030 / ADR-0021)
request → repo.remote_default → spawn_remote_default → falserepo.afk_remote_default ?? spawn_remote_default_afk ?? repo.remote_default ?? spawn_remote_defaultA new
ResolveRemote(ctx, prov, repo, kind, req *bool)ininternal/instance/credential.gobesideResolveProvider/ResolveModelEffort/ResolveSpawnOptions, feedingLaunchSpec.Remote → SpawnSpec.Remote.Storage — migration 0011 (sqlite + postgres, mirrored)
spawn_remote_defaultfalsespawn_remote_default_afkremote_defaultafk_remote_defaultremoteruns.remoteis required (unlike the deferredruns.options) because deep-link capture arms at boot re-adoption as well as at Start, so lab must know per-run whether a live session is remote. Backfill existing rows totrue— they were launched remote, and their Open links must keep working.Default is OFF — a deliberate, accepted regression
New claude runs come up without Remote Control, and therefore without a claude.ai Open link, until the operator opts in. Lab's own chat is the primary surface; the claude.ai link is redundant with it.
Deep link when remote is off
CaptureDeepLink— not at Start, not at re-adoption (otherwise every non-remote run logs ADR-0017's loud capture-miss).FallbackOpenpicker link, which would point at a session that does not exist.deep_link_urlstays NULL.UI
web/src/routes/NewRun.tsx): a "Remote control" checkbox in the existing…MoreChippopover (wherelabelalready lives), pre-filled from the resolved default via the client-side resolver mirror inweb/src/lib/spawn.ts.POST /repos/{id}/instancesgains an optionalremote(*bool).Explicitly out of scope
ultracodecheckbox over a 3-state inherit model) is not fixed here — different code path (schema-driven bag rendering vs a typed field). The Inherit/On/Off select shipped here is the reference implementation #21 should copy.codex remote-control start|stop|pair(an experimental host-level app-server daemon + pairing code, not a per-session flag) is not wired up. Codex simply ignores the bool and reportssupports_remote: false. If it is ever wired, it happens behind this same seam with no core change — that is the point of the design.Deliverable 0 — the ADR (write it first)
The implementing agent writes
docs/adr/0045-remote-control-as-a-typed-spawn-knob.mdas part of this issue, following the house style of the existing 44 ADRs (problem → pinned decisions → Status → Considered options → Consequences). It evolves ADR-0021 (spawn defaults / provider-options bag — this is the first knob that is deliberately not in the bag, and the ADR must say why), ADR-0017 (optional provider capabilities —RemoteCapableis the third instance of the pattern), and ADR-0030 (three-level skip-layer resolution). "Considered options" must record the rejected alternatives: a per-providerremoteentry in the options bag; a lab-owned well-known key that bypasses bag filtering; keeping remote hardcoded on claude; and defaulting the setting on (pure opt-out).Deliverable 1 — the live spike (blocks merge)
Default-off breaks a pinned invariant on paper.
CONTEXT.md:70: "session exit is never the done-signal, because--remote-controlidles after finishing." Strip the flag and that reasoning no longer holds — a non-remote session may exit on completion, which the AFK sweep, the dead-session logic, and re-adoption are all built on top of.Tier-2 live spike (ADR-0036), evidence committed to
internal/compat/claude-code/compat.md:--remote-control, or does it idle like today?LiveSignals.Setup's per-run--settingsfile) still fire identically?CaptureDeepLinkcorrectly find nothing (rather than hanging or erroring)?Then amend
CONTEXT.md:70to scope the invariant to remote runs, and record whatever (1) turns up. If a non-remote session exits on completion, stop and report — that changes AFK lifecycle handling and is a design decision, not an implementation detail.Definition of done
internal/compat/claude-code/compat.md,CONTEXT.md:70amended (Deliverable 1).provider.SpawnSpec.Remote,provider.RemoteCapable; claude-code implements it and applies the flag; codex does not implement it and its argv is byte-identical for both values.providertest.Conformancegains the argv-differs / argv-identical check;providertestfakes scriptable for both cases.runs.remote = truebackfill.ResolveRemote+LaunchSpec.Remote;internal/afkstays provider-agnostic (it carries the bool, never reads it).GET /api/v1/providersexposessupports_remote;PATCH /api/v1/settingsandPATCH /api/v1/repos/{id}validate and store the new fields (bad value → 400 that writes nothing);POST /repos/{id}/instancesacceptsremote.supports_remote: false.Landing audit — PR #164
Verdict: PASS (after one fix pushed to the head branch).
Verification signal relied on: the required
ci / nativecheck, green on the final head54c0a33(run #183, 5m37s). It runs the SPA lint/prettier/vitest/build plusgo build -tags ui+go test -tags ui ./...(with real git/tmux/prlimit) andgolangci-lint. The hermetic nix gate is path-gated to nix/dependency changes; this PR touches noflake.nix/go.mod, so it correctly did not run. Not covered by any gate: the postgres dialect of migration 0011 was never executed — the postgres store job inci.ymlis still a commented-out M2 stub, soLAB_TEST_POSTGRES_DSNis unset everywhere. That is a standing repo-wide condition (all ten prior migrations shipped the same way), not something this PR introduced; the statements are standard PG and match the baseline's ownrepos.incogniconvention.Checked
Closes #163; merges cleanly intomain; migration 0011 collides with nothing onmain(which tops out at 0010) nor with the other open PR (#129 adds none).SpawnSpec.Remoteas a fourth typed knob,RemoteCapableadvertised by type assertion (correctly the fifth such capability), the capability clamp inResolveRemote, and the deep-link gate placed at the singleArmCapturechoke point both Start and boot re-adoption pass through.*booltri-state — the trap this issue turns on, wherefalseis a legal value so "unset" cannot be spelledfalse— is handled correctly at every layer (request, both repo columns, both settings keys). An explicitly-off repo really does beat a globally-on default;internal/instance/remote_test.gopins it.runs.remoteis NOT NULL; both repo columns nullable; the backfill-to-trueis proven against the real goose provider (TestRunRemoteBackfill: migrate to 0010, insert a legacy run, migrate up, read backtrue).provider.supports_remote && !run.remote), not a bare!run.remote— verified that codex keeps itstmux attachaffordance in every case, including the clamped-remote-false one. The PR's own correction of the issue here was right.spawn-remoteobligation is sharp in both directions and both fakes honor it.One defect found and fixed on the branch (
54c0a33)RunChat's "answer it elsewhere" hint derived its wording fromfallback_openalone, so it was not gated on remote-ness like the Open affordance beside it. On a remote-off run (the new default) the two degraded branches — the locked question state with no structured dialog, and an unanswerable dialog shape — still told the operator to open the provider's web session in prose, while the Open button was correctly hidden and no web session had ever been registered. Composer locked, no attach affordance, Interrupt the only way out.That is the same broken promise the affordance gate exists to prevent, just spelled in words — and these branches are exactly where a non-remote run lands, since this PR's own discovery is that without remote control the agent flushes its pending
tool_useand the dialog arrives by transcript scan rather than the spool. Fixed by gating the hint on the same condition, extracted asdeepLink.remoteGated()so the affordance and the prose cannot drift apart; a gated run falls back to the generic "open the session" wording already used for terminal-only providers. Four tests added; remote-on runs and providers without the knob are unchanged.Non-blocking, worth a follow-up
…popover and the repo-settings inherit row show the unclamped value, so withrepo.remote_default = trueunder codex the checkbox renders checked-but-disabled while the run will actually spawnremote: false. The "Codex ignores this" note softens it, but the displayed value misstates what will happen.remote = true— in mild tension withResolveRemote's own "a codex run must never be recorded as remote" invariant. Not observable (both consumers are capability-scoped), so it changes no behavior; aWHERE provider = 'claude-code'would merely be more truthful.