feat(spawn): Remote Control as a typed spawn knob — one lab setting, providers do what they want (grilled 2026-07-14) #163

Closed
opened 2026-07-14 00:41:01 +02:00 by dominik.polakovics · 1 comment

Grilled with the operator 2026-07-14. The decisions below are settled — do not re-litigate them. The implementing agent writes the ADR (see "Deliverable 0").

Problem

Remote Control is hardcoded on for every claude run:

// internal/provider/claudecode/claudecode.go:362
argv := []string{claudeBin, "--remote-control", spec.SessionName, "--permission-mode", "auto"}

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 itCaptureDeepLink (ADR-0017's DeepLinker) 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 bool field on provider.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 declaring remote = 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 DeepLinker pattern — not a config flag):

// RemoteCapable is implemented by providers that honor SpawnSpec.Remote.
type RemoteCapable interface{ SupportsRemoteControl() bool }

Surfaced as supports_remote on GET /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 implementing RemoteCapable, SpawnArgv(spec{Remote:true}) must differ from SpawnArgv(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)

  • manual: request → repo.remote_default → spawn_remote_default → false
  • AFK: repo.afk_remote_default ?? spawn_remote_default_afk ?? repo.remote_default ?? spawn_remote_default

A new ResolveRemote(ctx, prov, repo, kind, req *bool) in internal/instance/credential.go beside ResolveProvider / ResolveModelEffort / ResolveSpawnOptions, feeding LaunchSpec.Remote → SpawnSpec.Remote.

Storage — migration 0011 (sqlite + postgres, mirrored)

Scope Key/column Semantics
settings spawn_remote_default base; seeded false
settings spawn_remote_default_afk AFK override; absent = inherit (not seeded)
repos remote_default nullable = inherit
repos afk_remote_default nullable = inherit
runs remote NOT NULL; the resolved value, stamped at launch

runs.remote is required (unlike the deferred runs.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 to true — 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.

  • Lab does not arm CaptureDeepLink — not at Start, not at re-adoption (otherwise every non-remote run logs ADR-0017's loud capture-miss).
  • The SPA hides the Open affordance entirely for a non-remote run — including the FallbackOpen picker link, which would point at a session that does not exist. deep_link_url stays NULL.

UI

  • Global Settings: a "Remote control" checkbox in the base spawn-defaults section and another in the AFK-defaults section.
  • Repo Settings: a 3-way select — Inherit / On / Off — at both scopes, showing the effective value on the inherit entry ("Inherit — currently off from global"). This follows the model/effort select pattern and therefore sidesteps #21 by construction.
  • Composer (web/src/routes/NewRun.tsx): a "Remote control" checkbox in the existing MoreChip popover (where label already lives), pre-filled from the resolved default via the client-side resolver mirror in web/src/lib/spawn.ts. POST /repos/{id}/instances gains an optional remote (*bool).
  • AFK start stays bodyless (ADR-0021, unchanged): AFK is defaults-only.

Explicitly out of scope

  • #21 (the bag's 2-state ultracode checkbox 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's own 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 reports supports_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.md as 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 — RemoteCapable is the third instance of the pattern), and ADR-0030 (three-level skip-layer resolution). "Considered options" must record the rejected alternatives: a per-provider remote entry 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-control idles 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:

  1. Does the claude process exit after finishing a prompt without --remote-control, or does it idle like today?
  2. Do the dialog-capture hooks, the dialog spool, and the liveness signals (LiveSignals.Setup's per-run --settings file) still fire identically?
  3. Is the transcript path (and therefore the chat tailer) unchanged?
  4. Does CaptureDeepLink correctly find nothing (rather than hanging or erroring)?

Then amend CONTEXT.md:70 to 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

  • ADR-0045 written and accepted (Deliverable 0).
  • Live spike run, evidence in internal/compat/claude-code/compat.md, CONTEXT.md:70 amended (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.Conformance gains the argv-differs / argv-identical check; providertest fakes scriptable for both cases.
  • Migration 0011, sqlite + postgres, with the runs.remote = true backfill.
  • ResolveRemote + LaunchSpec.Remote; internal/afk stays provider-agnostic (it carries the bool, never reads it).
  • GET /api/v1/providers exposes supports_remote; PATCH /api/v1/settings and PATCH /api/v1/repos/{id} validate and store the new fields (bad value → 400 that writes nothing); POST /repos/{id}/instances accepts remote.
  • Deep-link capture is gated on the resolved value at Start and at boot re-adoption; the SPA hides Open (link and fallback) for non-remote runs.
  • Settings + RepoSettings + composer UI, with the Inherit/On/Off select at repo scope and the disabled-with-note state for providers reporting supports_remote: false.
  • Existing runs keep their Open link after migration (backfill verified).
> *Grilled with the operator 2026-07-14. The decisions below are settled — do not re-litigate them. **The implementing agent writes the ADR** (see "Deliverable 0").* ## Problem Remote Control is **hardcoded on** for every claude run: ```go // internal/provider/claudecode/claudecode.go:362 argv := []string{claudeBin, "--remote-control", spec.SessionName, "--permission-mode", "auto"} ``` 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's `DeepLinker`) 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 bool` field on `provider.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 declaring `remote` = 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 `DeepLinker` pattern — not a config flag): ```go // RemoteCapable is implemented by providers that honor SpawnSpec.Remote. type RemoteCapable interface{ SupportsRemoteControl() bool } ``` Surfaced as `supports_remote` on `GET /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 implementing `RemoteCapable`, `SpawnArgv(spec{Remote:true})` **must** differ from `SpawnArgv(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) - **manual**: `request → repo.remote_default → spawn_remote_default → false` - **AFK**: `repo.afk_remote_default ?? spawn_remote_default_afk ?? repo.remote_default ?? spawn_remote_default` A new `ResolveRemote(ctx, prov, repo, kind, req *bool)` in `internal/instance/credential.go` beside `ResolveProvider` / `ResolveModelEffort` / `ResolveSpawnOptions`, feeding `LaunchSpec.Remote → SpawnSpec.Remote`. ### Storage — migration 0011 (sqlite + postgres, mirrored) | Scope | Key/column | Semantics | |---|---|---| | settings | `spawn_remote_default` | base; **seeded `false`** | | settings | `spawn_remote_default_afk` | AFK override; absent = inherit (**not** seeded) | | repos | `remote_default` | nullable = inherit | | repos | `afk_remote_default` | nullable = inherit | | runs | `remote` | NOT NULL; the resolved value, stamped at launch | `runs.remote` is required (unlike the deferred `runs.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 to `true`** — 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 - Lab **does not arm** `CaptureDeepLink` — not at Start, not at re-adoption (otherwise every non-remote run logs ADR-0017's loud capture-miss). - The SPA **hides the Open affordance entirely** for a non-remote run — *including* the `FallbackOpen` picker link, which would point at a session that does not exist. `deep_link_url` stays NULL. ### UI - **Global Settings**: a "Remote control" checkbox in the base spawn-defaults section and another in the AFK-defaults section. - **Repo Settings**: a **3-way select — Inherit / On / Off** — at both scopes, showing the effective value on the inherit entry ("Inherit — currently **off** from global"). This follows the model/effort select pattern and therefore sidesteps #21 by construction. - **Composer** (`web/src/routes/NewRun.tsx`): a "Remote control" checkbox in the existing `…` `MoreChip` popover (where `label` already lives), pre-filled from the resolved default via the client-side resolver mirror in `web/src/lib/spawn.ts`. `POST /repos/{id}/instances` gains an optional `remote` (`*bool`). - **AFK start stays bodyless** (ADR-0021, unchanged): AFK is defaults-only. ### Explicitly out of scope - **#21** (the bag's 2-state `ultracode` checkbox 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's own `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 reports `supports_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.md`** as 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 — `RemoteCapable` is the third instance of the pattern), and **ADR-0030** (three-level skip-layer resolution). "Considered options" must record the rejected alternatives: a per-provider `remote` entry 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-control` idles 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`: 1. Does the claude process **exit** after finishing a prompt without `--remote-control`, or does it idle like today? 2. Do the **dialog-capture hooks, the dialog spool, and the liveness signals** (`LiveSignals.Setup`'s per-run `--settings` file) still fire identically? 3. Is the **transcript path** (and therefore the chat tailer) unchanged? 4. Does `CaptureDeepLink` correctly find nothing (rather than hanging or erroring)? Then amend `CONTEXT.md:70` to 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 - [ ] ADR-0045 written and accepted (Deliverable 0). - [ ] Live spike run, evidence in `internal/compat/claude-code/compat.md`, `CONTEXT.md:70` amended (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.Conformance` gains the argv-differs / argv-identical check; `providertest` fakes scriptable for both cases. - [ ] Migration 0011, sqlite + postgres, with the `runs.remote = true` backfill. - [ ] `ResolveRemote` + `LaunchSpec.Remote`; `internal/afk` stays provider-agnostic (it carries the bool, never reads it). - [ ] `GET /api/v1/providers` exposes `supports_remote`; `PATCH /api/v1/settings` and `PATCH /api/v1/repos/{id}` validate and store the new fields (bad value → 400 that writes nothing); `POST /repos/{id}/instances` accepts `remote`. - [ ] Deep-link capture is gated on the resolved value at Start **and** at boot re-adoption; the SPA hides Open (link *and* fallback) for non-remote runs. - [ ] Settings + RepoSettings + composer UI, with the Inherit/On/Off select at repo scope and the disabled-with-note state for providers reporting `supports_remote: false`. - [ ] Existing runs keep their Open link after migration (backfill verified).
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PR #164

Verdict: PASS (after one fix pushed to the head branch).

Verification signal relied on: the required ci / native check, green on the final head 54c0a33 (run #183, 5m37s). It runs the SPA lint/prettier/vitest/build plus go build -tags ui + go test -tags ui ./... (with real git/tmux/prlimit) and golangci-lint. The hermetic nix gate is path-gated to nix/dependency changes; this PR touches no flake.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 in ci.yml is still a commented-out M2 stub, so LAB_TEST_POSTGRES_DSN is 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 own repos.incogni convention.

Checked

  • Contract: title is Conventional Commits; body carries a working Closes #163; merges cleanly into main; migration 0011 collides with nothing on main (which tops out at 0010) nor with the other open PR (#129 adds none).
  • Seam: SpawnSpec.Remote as a fourth typed knob, RemoteCapable advertised by type assertion (correctly the fifth such capability), the capability clamp in ResolveRemote, and the deep-link gate placed at the single ArmCapture choke point both Start and boot re-adoption pass through.
  • The *bool tri-state — the trap this issue turns on, where false is a legal value so "unset" cannot be spelled false — 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.go pins it.
  • Migration 0011: sqlite and postgres are statement-identical; runs.remote is NOT NULL; both repo columns nullable; the backfill-to-true is proven against the real goose provider (TestRunRemoteBackfill: migrate to 0010, insert a legacy run, migrate up, read back true).
  • The Open-affordance gate is capability-scoped (provider.supports_remote && !run.remote), not a bare !run.remote — verified that codex keeps its tmux attach affordance in every case, including the clamped-remote-false one. The PR's own correction of the issue here was right.
  • Conformance: the new spawn-remote obligation 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 from fallback_open alone, 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_use and the dialog arrives by transcript scan rather than the spool. Fixed by gating the hint on the same condition, extracted as deepLink.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

  • Unclamped display on a disabled control. The composer's popover and the repo-settings inherit row show the unclamped value, so with repo.remote_default = true under codex the checkbox renders checked-but-disabled while the run will actually spawn remote: false. The "Codex ignores this" note softens it, but the displayed value misstates what will happen.
  • The backfill is unconditional, so pre-existing codex runs are also stamped remote = true — in mild tension with ResolveRemote's own "a codex run must never be recorded as remote" invariant. Not observable (both consumers are capability-scoped), so it changes no behavior; a WHERE provider = 'claude-code' would merely be more truthful.
> *This was generated by AI while landing a PR.* ## Landing audit — PR #164 **Verdict: PASS** (after one fix pushed to the head branch). **Verification signal relied on:** the required `ci / native` check, green on the final head `54c0a33` (run #183, 5m37s). It runs the SPA lint/prettier/vitest/build plus `go build -tags ui` + `go test -tags ui ./...` (with real git/tmux/prlimit) and `golangci-lint`. The hermetic nix gate is path-gated to nix/dependency changes; this PR touches no `flake.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 in `ci.yml` is still a commented-out M2 stub, so `LAB_TEST_POSTGRES_DSN` is 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 own `repos.incogni` convention. ### Checked - **Contract:** title is Conventional Commits; body carries a working `Closes #163`; merges cleanly into `main`; migration 0011 collides with nothing on `main` (which tops out at 0010) nor with the other open PR (#129 adds none). - **Seam:** `SpawnSpec.Remote` as a fourth typed knob, `RemoteCapable` advertised by type assertion (correctly the *fifth* such capability), the capability clamp in `ResolveRemote`, and the deep-link gate placed at the single `ArmCapture` choke point both Start and boot re-adoption pass through. - **The `*bool` tri-state** — the trap this issue turns on, where `false` is a legal value so "unset" cannot be spelled `false` — 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.go` pins it. - **Migration 0011:** sqlite and postgres are statement-identical; `runs.remote` is NOT NULL; both repo columns nullable; the backfill-to-`true` is proven against the real goose provider (`TestRunRemoteBackfill`: migrate to 0010, insert a legacy run, migrate up, read back `true`). - **The Open-affordance gate is capability-scoped** (`provider.supports_remote && !run.remote`), not a bare `!run.remote` — verified that codex keeps its `tmux attach` affordance in every case, including the clamped-remote-false one. The PR's own correction of the issue here was right. - **Conformance:** the new `spawn-remote` obligation 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 from `fallback_open` alone, 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_use` and the dialog arrives by transcript scan rather than the spool. Fixed by gating the hint on the same condition, extracted as `deepLink.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 - **Unclamped display on a disabled control.** The composer's `…` popover and the repo-settings inherit row show the *unclamped* value, so with `repo.remote_default = true` under codex the checkbox renders checked-but-disabled while the run will actually spawn `remote: false`. The "Codex ignores this" note softens it, but the displayed value misstates what will happen. - **The backfill is unconditional**, so pre-existing codex runs are also stamped `remote = true` — in mild tension with `ResolveRemote`'s own "a codex run must never be recorded as remote" invariant. Not observable (both consumers are capability-scoped), so it changes no behavior; a `WHERE provider = 'claude-code'` would merely be more truthful.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#163
No description provided.