feat(spawn): Remote Control as a typed spawn knob — one lab setting, providers do what they want #164

Merged
dominik.polakovics merged 2 commits from afk/163 into main 2026-07-14 08:19:44 +02:00

Closes #163

Remote Control was hardcoded on for every claude run — and it was never the isolated flag it looked like. The claude.ai deep link is a byproduct of it, so the "Open" affordance on a run row is Remote Control. This turns it into a knob, and the interesting half is everything that happens when it is off.

The seam

Remote joins provider/model/effort as a fourth typed spawn knob (provider.SpawnSpec.Remote), deliberately not an entry in the ADR-0021 options bag: that bag is provider-declared and provider-filtered, so "one setting every provider sees" isn't expressible in it — and lab must act on the value (deep-link gating), which is ADR-0021's own criterion for a lab-domain field.

Providers advertise only whether they honor it, via a new optional capability RemoteCapable. claude-code adds/omits --remote-control; codex receives the bool and ignores it. Lab never learns the mechanism.

This buys a sharp Tier-1 conformance check (spawn-remote): a RemoteCapable provider's argv must differ between Remote:true/false; a non-capable provider's must be byte-identical. I verified it's load-bearing by reverting the claude adapter to always-on — the suite caught it with a named error.

Deliverable 1 — the live spike: GATE CLEAR

Default-off put a pinned invariant on trial (CONTEXT.md: "session exit is never the done-signal, because --remote-control idles after finishing"). A Tier-2 live A/B against real claude 2.1.206 (evidence in internal/compat/compat.md §12) found:

  • A non-remote session does NOT exit on completion — it idles at its composer, still sleeping ~12 min past turn end, identical to the remote arm. The AFK done-signal, dead-session sweep, and re-adoption are all safe. The invariant's conclusion holds; its stated cause was wrong, and CONTEXT.md is amended (an interactive CLI doesn't quit when a turn ends, remote or not).
  • One genuine divergence, and it matters: flush-on-resolve is a Remote Control behavior. Without the flag, claude flushes a pending tool_use to the transcript immediately, so the ADR-0020 dialog-spool overlay self-suppresses and dialogs instead surface through internal/chat's transcript-tail scan — the path whose own comment called it "the dormant fallback for a provider that DOES flush pending tool_use". That fallback is now the LIVE path for every non-remote run. No code change needed (answering verified end to end), but it must never be deleted as dead code — pinned in the code comment and the ADR.

Three things the issue got wrong, corrected here

  • There is no gemini provider in this repo — only claude-code and codex.
  • RemoteCapable is the FIFTH instance of ADR-0017's type-assertion pattern (after ConnectingReporter, DeepLinker, LiveSignals, LoginCodeReporter), not the third.
  • The Open-affordance gate must be capability-scoped, not a bare !run.remote. Since the resolver clamps non-capable providers to false, a bare check would also have stripped the tmux attach button codex shows today — an unintended regression and a violation of honest degradation. The gate is provider.supports_remote && !run.remote; codex is untouched in every case.

The bool trap

Every knob so far spells "unset" as "", which is only safe because "" is never a legal model id. false IS a legal value here, so unset can never be spelled false. *bool at every layer that must distinguish unset-from-explicitly-off (request, both repo columns, PATCH decoders); nullable repo columns; ""/absent row at the settings layer. Pinned by tests at three levels: a repo false beats a global true, nullfalse through PATCH, and an explicit {"remote": false} beats a global true all the way to SpawnSpec.

What's in it

  • Migration 0011 (sqlite + postgres, mirrored): nullable repos.remote_default / afk_remote_default; runs.remote NOT NULL — a real column, because deep-link capture arms at boot re-adoption as well as at Start, so after a restart the run row is the only thing that knows. Existing rows backfilled to true so their Open links keep working (verified by a test that drives goose to 0010, inserts a legacy row, migrates, and asserts).
  • Deep-link gating at the single ArmCapture choke point — covering Start and re-adoption, so internal/reconcile needed no change. Without it every non-remote run would burn the 30s bridge timeout and log ADR-0017's loud capture-miss (the spike measured exactly that: elapsed=30.152s + WARN).
  • UI: 3-way Inherit / On / Off select at repo scope (the reference implementation #21 should copy — it sidesteps that bug by construction), composer checkbox in the popover, and a disabled-with-note state when the resolved provider reports supports_remote: false.
  • ADR-0045 records the design, the rejected alternatives, and the spike.

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 link is redundant with it.

Verification

  • Go: gofmt clean, go vet clean, go build ./..., all 40 packages pass (forced -count=1 on every touched package).
  • Web: tsc --noEmit, eslint, prettier --check all clean; 822 vitest tests pass (up from 795); production vite build succeeds.
  • golangci-lint is not installed in this sandbox, so make lint was substituted with go vet + gofmt.

Follow-up spotted (not fixed here)

The spike incidentally found that on 2.1.206 the 60s dialog auto-dismiss did not fire (pickers sat undriven 5+ min with no afkTimeoutMs), and hook payloads report permission_mode:"default" despite --permission-mode auto. Both are version drift in both arms, unrelated to this change, but compat §5/§7/§11 pin that 60s self-resolve — it deserves its own re-verification issue.

Closes #163 Remote Control was **hardcoded on** for every claude run — and it was never the isolated flag it looked like. The claude.ai deep link is a *byproduct* of it, so the "Open" affordance on a run row **is** Remote Control. This turns it into a knob, and the interesting half is everything that happens when it is off. ## The seam `Remote` joins provider/model/effort as a fourth **typed** spawn knob (`provider.SpawnSpec.Remote`), deliberately **not** an entry in the ADR-0021 options bag: that bag is provider-*declared* and provider-*filtered*, so "one setting every provider sees" isn't expressible in it — and lab must *act* on the value (deep-link gating), which is ADR-0021's own criterion for a lab-domain field. Providers advertise only *whether* they honor it, via a new optional capability `RemoteCapable`. claude-code adds/omits `--remote-control`; codex receives the bool and ignores it. Lab never learns the mechanism. This buys a sharp **Tier-1 conformance check** (`spawn-remote`): a `RemoteCapable` provider's argv **must differ** between `Remote:true`/`false`; a non-capable provider's **must be byte-identical**. I verified it's load-bearing by reverting the claude adapter to always-on — the suite caught it with a named error. ## Deliverable 1 — the live spike: **GATE CLEAR** Default-off put a pinned invariant on trial (`CONTEXT.md`: *"session exit is never the done-signal, because `--remote-control` idles after finishing"*). A Tier-2 live A/B against **real claude 2.1.206** (evidence in `internal/compat/compat.md` **§12**) found: - **A non-remote session does NOT exit on completion** — it idles at its composer, still sleeping ~12 min past turn end, identical to the remote arm. The AFK done-signal, dead-session sweep, and re-adoption are all safe. The invariant's *conclusion* holds; its stated *cause* was wrong, and `CONTEXT.md` is amended (an interactive CLI doesn't quit when a turn ends, remote or not). - **One genuine divergence, and it matters:** flush-on-resolve is a *Remote Control* behavior. Without the flag, claude flushes a pending `tool_use` to the transcript **immediately**, so the ADR-0020 dialog-spool overlay self-suppresses and dialogs instead surface through `internal/chat`'s transcript-tail scan — the path whose own comment called it *"the dormant fallback for a provider that DOES flush pending tool_use"*. **That fallback is now the LIVE path for every non-remote run.** No code change needed (answering verified end to end), but it must never be deleted as dead code — pinned in the code comment and the ADR. ## Three things the issue got wrong, corrected here - **There is no gemini provider** in this repo — only claude-code and codex. - **`RemoteCapable` is the FIFTH** instance of ADR-0017's type-assertion pattern (after `ConnectingReporter`, `DeepLinker`, `LiveSignals`, `LoginCodeReporter`), not the third. - **The Open-affordance gate must be capability-scoped, not a bare `!run.remote`.** Since the resolver clamps non-capable providers to `false`, a bare check would *also* have stripped the `tmux attach` button codex shows today — an unintended regression and a violation of honest degradation. The gate is `provider.supports_remote && !run.remote`; codex is untouched in every case. ## The bool trap Every knob so far spells "unset" as `""`, which is only safe because `""` is never a legal model id. **`false` IS a legal value here**, so unset can never be spelled `false`. `*bool` at every layer that must distinguish unset-from-explicitly-off (request, both repo columns, PATCH decoders); nullable repo columns; `""`/absent row at the settings layer. Pinned by tests at three levels: a repo `false` beats a global `true`, `null` ≠ `false` through PATCH, and an explicit `{"remote": false}` beats a global `true` all the way to `SpawnSpec`. ## What's in it - **Migration 0011** (sqlite + postgres, mirrored): nullable `repos.remote_default` / `afk_remote_default`; `runs.remote` NOT NULL — a real column, because deep-link capture arms at **boot re-adoption** as well as at Start, so after a restart the run row is the only thing that knows. Existing rows **backfilled to `true`** so their Open links keep working (verified by a test that drives goose to 0010, inserts a legacy row, migrates, and asserts). - **Deep-link gating** at the single `ArmCapture` choke point — covering Start *and* re-adoption, so `internal/reconcile` needed no change. Without it every non-remote run would burn the 30s bridge timeout and log ADR-0017's loud capture-miss (the spike measured exactly that: `elapsed=30.152s` + WARN). - **UI**: 3-way **Inherit / On / Off** select at repo scope (the reference implementation #21 should copy — it sidesteps that bug by construction), composer checkbox in the `…` popover, and a disabled-with-note state when the resolved provider reports `supports_remote: false`. - **ADR-0045** records the design, the rejected alternatives, and the spike. ## 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 link is redundant with it. ## Verification - Go: `gofmt` clean, `go vet` clean, `go build ./...`, **all 40 packages pass** (forced `-count=1` on every touched package). - Web: `tsc --noEmit`, `eslint`, `prettier --check` all clean; **822 vitest tests pass** (up from 795); production `vite build` succeeds. - `golangci-lint` is not installed in this sandbox, so `make lint` was substituted with `go vet` + `gofmt`. ## Follow-up spotted (not fixed here) The spike incidentally found that on **2.1.206 the 60s dialog auto-dismiss did not fire** (pickers sat undriven 5+ min with no `afkTimeoutMs`), and hook payloads report `permission_mode:"default"` despite `--permission-mode auto`. Both are version drift in *both* arms, unrelated to this change, but compat §5/§7/§11 pin that 60s self-resolve — it deserves its own re-verification issue.
feat(spawn): remote control as a typed spawn knob, defaulting off
All checks were successful
ci / native (pull_request) Successful in 5m43s
92b80c1c3a
Remote Control was hardcoded on for every claude run, and it was never the
isolated flag it looked like: the claude.ai deep link is a byproduct of it,
so the "Open" affordance on a run row *is* Remote Control. This makes it a
knob, and the interesting half is what happens when it is off.

Remote joins provider/model/effort as a fourth TYPED spawn knob —
provider.SpawnSpec.Remote — deliberately NOT an entry in the ADR-0021
options bag: that bag is provider-declared and provider-filtered, so "one
setting every provider sees" is not expressible in it, and lab must act on
the value for deep-link gating. Providers advertise only *whether* they
honor it, via a new optional capability RemoteCapable (the fifth instance of
ADR-0017's type-assertion pattern, not the third the issue guessed).
claude-code adds/omits --remote-control; codex receives the bool and ignores
it. Lab never learns the mechanism.

Layered like every other knob (ADR-0030) through a new ResolveRemote, with
one trap the string knobs never had: false is a LEGAL value, so "unset" can
never be spelled false. Every layer that must tell unset from explicitly-off
uses *bool — otherwise "off at repo scope" silently inherits true.

The live spike (Tier-2, ADR-0036; real claude 2.1.206, evidence in compat
§12) cleared the merge gate: a non-remote session does NOT exit on
completion, it idles at its composer ~12min past turn end, so the AFK
done-signal invariant survives. Its stated *cause* was wrong, though, and
CONTEXT.md is amended: the CLI idles because an interactive CLI does not
quit when a turn ends, remote or not.

The spike also found the one genuine divergence: flush-on-resolve is a
REMOTE-CONTROL behavior. Without the flag claude flushes a pending tool_use
immediately, so the ADR-0020 dialog spool self-suppresses and dialogs arrive
through internal/chat's transcript-tail scan — the path its own comment
called a "dormant fallback". It is now the LIVE path for every non-remote
run (answering verified end to end) and must never be deleted as dead code.

- provider: SpawnSpec.Remote, RemoteCapable; claude gates the flag, codex's
  argv is byte-identical for both values
- providertest: new Tier-1 `spawn-remote` conformance obligation (capable =>
  argvs differ; non-capable => byte-identical), proved load-bearing by
  deliberately-broken fakes
- migration 0011 (sqlite + postgres): repos.remote_default /
  afk_remote_default (nullable = inherit), runs.remote NOT NULL, existing
  rows backfilled TRUE so their Open links keep working
- deep-link capture gated at the single ArmCapture choke point, covering
  Start and boot re-adoption (why runs.remote is a real column)
- SPA: Inherit/On/Off select at repo scope, composer checkbox, and an Open
  affordance gate that is capability-scoped (supports_remote && !remote) —
  a bare !remote would have silently killed codex's tmux-attach button
- ADR-0045 records the design; README no longer calls manual instances
  remote-control sessions

Closes #163

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sv5CQDuJW84ywmZPtB8wDK
fix(chat): mute the answer-elsewhere hint for a remote-off run
All checks were successful
ci / native (pull_request) Successful in 5m37s
54c0a330e0
The Open affordance is hidden for a run of a remote-capable provider that
spawned without remote control (issue #163) — there is no web session to
open. But RunChat's "answer it elsewhere" hint derived its wording from
fallback_open alone, so the two degraded branches beside that hidden button
still named the provider's web host in prose: the locked question state with
no structured dialog, and an unanswerable dialog shape. Both told the
operator to open a session that was never registered, with the composer
locked and no attach affordance — Interrupt the only real 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: without remote control the agent flushes its pending tool_use, so the
dialog arrives by transcript scan rather than the spool.

Gate 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. Remote-on runs and providers without the knob are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014u58bFzx6t6PG7rhVL3g9w
Sign in to join this conversation.
No reviewers
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!164
No description provided.