From 52d4432c71946e7674c66a6e407a20d842e11d81 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 7 Aug 2026 14:22:08 +0200 Subject: [PATCH] chore(plan): record remote-control bridge findings (T-454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-454 step 1 asked us to characterize the mechanism rather than assume it. The original hypothesis — that the PID-keyed session registry under ~/.claude/sessions is what the remote surface enumerates — turns out to be wrong; that registry is incidental. Remote Control is enabled per-session over the stream-json control channel clide already owns, via a `remote_control` control_request. Claude Code ships a dedicated SDK-mode bridge alongside the interactive TUI one, so no interactive session and no extra spawn flag are needed. Verified live against clide's exact flag set. Also records the prompt-arbitration model: first-to-apply wins, both sides linked by the same request_id, loser retracts via control_cancel_request, clear is idempotent. That is already the shipped protocol rather than a policy clide has to invent — which narrows the remaining work to honoring the retraction, since clide currently has no control_cancel_request handling at all. Co-Authored-By: Claude Opus 5 (1M context) --- .pql/changelog/ticket_history/2026-08.sql | 133 ++++++++++++++++++++++ .pql/changelog/tickets/2026-08.sql | 120 +++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 .pql/changelog/ticket_history/2026-08.sql create mode 100644 .pql/changelog/tickets/2026-08.sql diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql new file mode 100644 index 00000000..f737978b --- /dev/null +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -0,0 +1,133 @@ +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0ABC7QNEC3XCTV73YPSGR4', 'description', 'User report 2026-06-16: Claude Code''s **remote-control** feature (start / monitor / steer a running Claude Code session remotely — e.g. from claude.ai or the mobile app) is **not plumbed into clide**. + +clide hosts the `claude` CLI as a stream-json child (`--session-id` / `--resume`, D-77). For remote control to work through clide, a clide-hosted session needs to be discoverable + steerable by the remote-control surface the same way a terminal-launched `claude` is. + +**Step 1 — characterize (don''t assume the mechanism):** determine exactly what Claude Code exposes for remote control on the running CLI version. Likely touch points: +- the `~/.claude/sessions/.json` live-session registry we characterized in T-437 (PID-keyed: sessionId, cwd, version, kind, entrypoint, status) — is this what the remote-control surface enumerates? clide-spawned sessions DO write an entry (we saw `entrypoint: sdk-cli`). Confirm whether clide''s entry is visible/controllable remotely, or whether `entrypoint: sdk-cli` / headless spawn excludes it. +- any flag / capability in the `initialize` stream-json probe (T-411/T-151 cache) advertising remote control. +- whether enrollment requires the interactive TUI (and so is suppressed under clide''s stream-json spawn). + +**Step 2 — plumb:** wire clide so a clide-hosted session participates in remote control (register correctly / pass the needed flag), and surface its state in the UI (e.g. an indicator that the session is remote-controllable / being driven remotely). If it conflicts with clide''s session ownership (pane pins a session-id, T-156), document the boundary. + +**Open question:** is the goal (a) clide sessions become remotely controllable from claude.ai/mobile, or (b) clide can act as a remote controller of other sessions? Default read is (a). Confirm with the user before building. + +Related: D-77 (stream-json session model), T-437 (sessions registry characterization), T-156 (clide owns session lifecycle), T-411 (capability probe).', 'User report 2026-06-16: Claude Code''s **remote-control** feature (start / monitor / steer a running Claude Code session remotely — e.g. from claude.ai or the mobile app) is **not plumbed into clide**. + +clide hosts the `claude` CLI as a stream-json child (`--session-id` / `--resume`, D-77). For remote control to work through clide, a clide-hosted session needs to be discoverable + steerable by the remote-control surface the same way a terminal-launched `claude` is. + +**Step 1 — characterize (don''t assume the mechanism):** determine exactly what Claude Code exposes for remote control on the running CLI version. Likely touch points: +- the `~/.claude/sessions/.json` live-session registry we characterized in T-437 (PID-keyed: sessionId, cwd, version, kind, entrypoint, status) — is this what the remote-control surface enumerates? clide-spawned sessions DO write an entry (we saw `entrypoint: sdk-cli`). Confirm whether clide''s entry is visible/controllable remotely, or whether `entrypoint: sdk-cli` / headless spawn excludes it. +- any flag / capability in the `initialize` stream-json probe (T-411/T-151 cache) advertising remote control. +- whether enrollment requires the interactive TUI (and so is suppressed under clide''s stream-json spawn). + +**Step 2 — plumb:** wire clide so a clide-hosted session participates in remote control (register correctly / pass the needed flag), and surface its state in the UI (e.g. an indicator that the session is remote-controllable / being driven remotely). If it conflicts with clide''s session ownership (pane pins a session-id, T-156), document the boundary. + +**Open question:** is the goal (a) clide sessions become remotely controllable from claude.ai/mobile, or (b) clide can act as a remote controller of other sessions? Default read is (a). Confirm with the user before building. + +Related: D-77 (stream-json session model), T-437 (sessions registry characterization), T-156 (clide owns session lifecycle), T-411 (capability probe). + +## Step 1 COMPLETE — mechanism characterized and empirically proven (2026-08-07) + +**Goal confirmed as (a):** clide-hosted sessions become remotely controllable from +claude.ai / the mobile app. (b) is not in scope. + +### The mechanism is a control_request, not the sessions registry + +The Step-1 hypothesis (that `~/.claude/sessions/.json` is what the remote +surface enumerates) is **wrong**. That registry is incidental. Remote Control is +enabled per-session over the stream-json control channel clide already owns. + +Claude Code 2.1.220 ships two bridge integrations: +- `[bridge:repl]` — the interactive TUI path (`--remote-control` flag, `/remote-control`) +- `[bridge:sdk]` — a **stream-json/SDK path**, which is the one clide needs + +No interactive session, no extra flag, and no CLI changes are required. + +### Wire contract (verified live) + +``` +→ {"type":"control_request","request_id":"…", + "request":{"subtype":"remote_control","enabled":true,"name":""}} + +← {"type":"system","subtype":"bridge_state","state":"ready"} +← {"type":"control_response","response":{"subtype":"success","response":{ + "session_url":"https://claude.ai/code/session_0127…", + "connect_url":"https://claude.ai/code?environment=", + "environment_id":""}}} +← {"type":"system","subtype":"bridge_state","state":"connected"} + +→ {"request":{"subtype":"remote_control","enabled":false}} # clean teardown +← {"type":"control_response","response":{"subtype":"success"}} +``` + +Proven by probe against clide''s exact spawn flags (`stream_json_session.dart:78`). +`ready → connected` in ~420ms. Debug log confirms `[bridge:sdk] State change: ready` +then `connected`; transport is CCR v2 over SSE, shared with the repl bridge. + +**Surface `session_url`, not `connect_url`.** `connect_url` came back as +`https://claude.ai/code?environment=` with an empty `environment_id` — the +environment concept is not populated on the bare-SDK path (it appears to be filled +in only when the `claude remote-control` daemon owns a registered directory). +`session_url` is the complete, live link. Confirm before building a QR affordance. + +### CLI-side callback surface (from binary analysis) + +| Callback | Effect | +|---|---| +| `onInboundMessage` | remote message injected as a user turn (tagged `bridgeOrigin`, `clientPlatform`, `origin`) | +| `onPermissionResponse` | → `injectControlResponse` — the remote can answer permission prompts | +| `onInterrupt` | remote cancel of the running turn | +| `onSetModel` / `onSetPermissionMode` / `onSetMaxThinkingTokens` | remote session control | +| `onStateChange` | emits `system/bridge_state` (`ready`\|`connected`\|`failed` + `detail`) on the normal output stream | + +Plus outbound `writeSdkMessages` forwarding of task/thinking/vcs events. + +### Prompt arbitration — resolved, no clide policy to invent + +Decided model (user, 2026-08-07): **first-to-apply wins, both sides linked by the +same `request_id`, loser''s prompt retracts on that link, clear is idempotent.** + +This is already the shipped protocol, and it is symmetric: +- `setOnControlRequestSent` forwards the pending request to the remote; + `setOnControlRequestResolved` → `sendControlCancelRequest(request_id)` retracts it. +- `RemoteSessionManager.handleMessage` handles inbound `control_cancel_request` by + looking up `pendingPermissionRequests` / `pendingDialogRequests` and retracting; + unknown ids are ignored ("nothing pending, ignoring"). +- The refusal schema states the rule directly: *"Evict on RESOLUTION (your own + response — any choice — or `control_cancel_request` retirement), never on receipt + … Eviction is idempotent."* + +**The clide gap:** `stream_json_session.dart` parses `control_request` but has **no +`control_cancel_request` handling at all**. If the remote answers first, clide''s +prompt widget hangs with no retraction. Since pending prompts are already keyed by +`request_id` for `resolvePrompt()` (`:758`), the fix is a delete + UI clear on the +same key. NOT YET OBSERVED live — the probe ran no tools, so the retraction path is +confirmed by decompile only. Verify during implementation. + +### Step 2 scope (remaining) + +1. `enableRemoteControl({String? name})` / `disableRemoteControl()` on + `StreamJsonSession` — shaped exactly like `setModel()` (`:932`): request with + `request_id`, pending map, reconcile response into `SessionStatus`. +2. Handle `system/bridge_state` in the event switch → connection state. +3. **Handle inbound `control_cancel_request` → retract the matching pending + `ToolPrompt`.** (The arbitration work above; also correct on its own merits.) +4. Two `SessionStatus` fields (`session_url`, bridge state). +5. Cockpit affordance: connect link / QR + connected indicator. +6. `clide claude remote-control ` verb for D-6 parity. +7. Teardown on pane close; pass clide''s derived pane name as `name` (the CLI + already names clide sessions e.g. `clide-18`). + +### Constraints found + +- Requires a logged-in subscription account; bridge is skipped on non-first-party + providers (`isFirstPartyProvider` check) — needs a disabled UI state. +- Help text for the daemon path notes the workspace trust dialog must have been + accepted by running `claude` interactively in the directory once. clide spawns + non-interactively where trust is auto-skipped — verify this doesn''t block the bridge. +- `remoteControlAtStartup` is a user setting ("Start Remote Control bridge + automatically each session") — a zero-code way to A/B the behaviour, and a + setting clide should probably respect rather than fight. +- Related but out of scope: `isolatePeerMachines` (cross-machine `SendMessage` + between peer sessions); `claude remote-control` hidden subcommand (`bridgeMain`) + which runs a per-directory daemon with `--spawn same-dir|worktree|session`.', NULL, '2026-08-07 12:21:08', '2026-08-07 12:21:08.809', '2026-08-07 12:21:08.809', NULL, '3908441f5df127b533670e4b4e35b283', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql new file mode 100644 index 00000000..539cb7cf --- /dev/null +++ b/.pql/changelog/tickets/2026-08.sql @@ -0,0 +1,120 @@ +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0ABC7QNEC3XCTV73YPSGR4', 'task', NULL, 'Plumb in Claude Code''s remote-control feature', 'User report 2026-06-16: Claude Code''s **remote-control** feature (start / monitor / steer a running Claude Code session remotely — e.g. from claude.ai or the mobile app) is **not plumbed into clide**. + +clide hosts the `claude` CLI as a stream-json child (`--session-id` / `--resume`, D-77). For remote control to work through clide, a clide-hosted session needs to be discoverable + steerable by the remote-control surface the same way a terminal-launched `claude` is. + +**Step 1 — characterize (don''t assume the mechanism):** determine exactly what Claude Code exposes for remote control on the running CLI version. Likely touch points: +- the `~/.claude/sessions/.json` live-session registry we characterized in T-437 (PID-keyed: sessionId, cwd, version, kind, entrypoint, status) — is this what the remote-control surface enumerates? clide-spawned sessions DO write an entry (we saw `entrypoint: sdk-cli`). Confirm whether clide''s entry is visible/controllable remotely, or whether `entrypoint: sdk-cli` / headless spawn excludes it. +- any flag / capability in the `initialize` stream-json probe (T-411/T-151 cache) advertising remote control. +- whether enrollment requires the interactive TUI (and so is suppressed under clide''s stream-json spawn). + +**Step 2 — plumb:** wire clide so a clide-hosted session participates in remote control (register correctly / pass the needed flag), and surface its state in the UI (e.g. an indicator that the session is remote-controllable / being driven remotely). If it conflicts with clide''s session ownership (pane pins a session-id, T-156), document the boundary. + +**Open question:** is the goal (a) clide sessions become remotely controllable from claude.ai/mobile, or (b) clide can act as a remote controller of other sessions? Default read is (a). Confirm with the user before building. + +Related: D-77 (stream-json session model), T-437 (sessions registry characterization), T-156 (clide owns session lifecycle), T-411 (capability probe). + +## Step 1 COMPLETE — mechanism characterized and empirically proven (2026-08-07) + +**Goal confirmed as (a):** clide-hosted sessions become remotely controllable from +claude.ai / the mobile app. (b) is not in scope. + +### The mechanism is a control_request, not the sessions registry + +The Step-1 hypothesis (that `~/.claude/sessions/.json` is what the remote +surface enumerates) is **wrong**. That registry is incidental. Remote Control is +enabled per-session over the stream-json control channel clide already owns. + +Claude Code 2.1.220 ships two bridge integrations: +- `[bridge:repl]` — the interactive TUI path (`--remote-control` flag, `/remote-control`) +- `[bridge:sdk]` — a **stream-json/SDK path**, which is the one clide needs + +No interactive session, no extra flag, and no CLI changes are required. + +### Wire contract (verified live) + +``` +→ {"type":"control_request","request_id":"…", + "request":{"subtype":"remote_control","enabled":true,"name":""}} + +← {"type":"system","subtype":"bridge_state","state":"ready"} +← {"type":"control_response","response":{"subtype":"success","response":{ + "session_url":"https://claude.ai/code/session_0127…", + "connect_url":"https://claude.ai/code?environment=", + "environment_id":""}}} +← {"type":"system","subtype":"bridge_state","state":"connected"} + +→ {"request":{"subtype":"remote_control","enabled":false}} # clean teardown +← {"type":"control_response","response":{"subtype":"success"}} +``` + +Proven by probe against clide''s exact spawn flags (`stream_json_session.dart:78`). +`ready → connected` in ~420ms. Debug log confirms `[bridge:sdk] State change: ready` +then `connected`; transport is CCR v2 over SSE, shared with the repl bridge. + +**Surface `session_url`, not `connect_url`.** `connect_url` came back as +`https://claude.ai/code?environment=` with an empty `environment_id` — the +environment concept is not populated on the bare-SDK path (it appears to be filled +in only when the `claude remote-control` daemon owns a registered directory). +`session_url` is the complete, live link. Confirm before building a QR affordance. + +### CLI-side callback surface (from binary analysis) + +| Callback | Effect | +|---|---| +| `onInboundMessage` | remote message injected as a user turn (tagged `bridgeOrigin`, `clientPlatform`, `origin`) | +| `onPermissionResponse` | → `injectControlResponse` — the remote can answer permission prompts | +| `onInterrupt` | remote cancel of the running turn | +| `onSetModel` / `onSetPermissionMode` / `onSetMaxThinkingTokens` | remote session control | +| `onStateChange` | emits `system/bridge_state` (`ready`\|`connected`\|`failed` + `detail`) on the normal output stream | + +Plus outbound `writeSdkMessages` forwarding of task/thinking/vcs events. + +### Prompt arbitration — resolved, no clide policy to invent + +Decided model (user, 2026-08-07): **first-to-apply wins, both sides linked by the +same `request_id`, loser''s prompt retracts on that link, clear is idempotent.** + +This is already the shipped protocol, and it is symmetric: +- `setOnControlRequestSent` forwards the pending request to the remote; + `setOnControlRequestResolved` → `sendControlCancelRequest(request_id)` retracts it. +- `RemoteSessionManager.handleMessage` handles inbound `control_cancel_request` by + looking up `pendingPermissionRequests` / `pendingDialogRequests` and retracting; + unknown ids are ignored ("nothing pending, ignoring"). +- The refusal schema states the rule directly: *"Evict on RESOLUTION (your own + response — any choice — or `control_cancel_request` retirement), never on receipt + … Eviction is idempotent."* + +**The clide gap:** `stream_json_session.dart` parses `control_request` but has **no +`control_cancel_request` handling at all**. If the remote answers first, clide''s +prompt widget hangs with no retraction. Since pending prompts are already keyed by +`request_id` for `resolvePrompt()` (`:758`), the fix is a delete + UI clear on the +same key. NOT YET OBSERVED live — the probe ran no tools, so the retraction path is +confirmed by decompile only. Verify during implementation. + +### Step 2 scope (remaining) + +1. `enableRemoteControl({String? name})` / `disableRemoteControl()` on + `StreamJsonSession` — shaped exactly like `setModel()` (`:932`): request with + `request_id`, pending map, reconcile response into `SessionStatus`. +2. Handle `system/bridge_state` in the event switch → connection state. +3. **Handle inbound `control_cancel_request` → retract the matching pending + `ToolPrompt`.** (The arbitration work above; also correct on its own merits.) +4. Two `SessionStatus` fields (`session_url`, bridge state). +5. Cockpit affordance: connect link / QR + connected indicator. +6. `clide claude remote-control ` verb for D-6 parity. +7. Teardown on pane close; pass clide''s derived pane name as `name` (the CLI + already names clide sessions e.g. `clide-18`). + +### Constraints found + +- Requires a logged-in subscription account; bridge is skipped on non-first-party + providers (`isFirstPartyProvider` check) — needs a disabled UI state. +- Help text for the daemon path notes the workspace trust dialog must have been + accepted by running `claude` interactively in the directory once. clide spawns + non-interactively where trust is auto-skipped — verify this doesn''t block the bridge. +- `remoteControlAtStartup` is a user setting ("Start Remote Control bridge + automatically each session") — a zero-code way to A/B the behaviour, and a + setting clide should probably respect rather than fight. +- Related but out of scope: `isolatePeerMachines` (cross-machine `SendMessage` + between peer sessions); `claude remote-control` hidden subcommand (`bridgeMain`) + which runs a per-directory daemon with `--spawn same-dir|worktree|session`.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 11:20:36', '2026-08-07 12:21:08.809', NULL, 'a420c6a2175901ac10d20685907847a5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);