Skip to content

feat(live-sessions): per-session Remote Control toggle - #101

Merged
JayantDevkar merged 7 commits into
mainfrom
remote-control-toggle
Oct 21, 2026
Merged

JayantDevkar merged 7 commits into
mainfrom
remote-control-toggle

Conversation

@the-non-expert

@the-non-expert the-non-expert commented Aug 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds a Remote Control toggle to the live-session header so you can turn Claude Code Remote Control on/off for a session straight from the Karma dashboard — no need to switch to its terminal to reach it from your phone.

Claude Code exposes no local API for Remote Control; the only lever is the /remote-control slash command typed inside the session. Karma runs on the same machine as the tracked terminal, so it drives Remote Control by typing into the session's terminal tab (tmux / macOS Terminal.app / iTerm2), reusing the tty / iTerm-session targeting from services.terminal_focus.

/remote-control is not a symmetric toggle, so the two directions differ:

  • ON is fully automated — the bare command turns it on; the endpoint types it and confirms from the transcript.
  • OFF opens Claude Code's interactive "Disconnect Remote Control" menu. Nothing drives that menu reliably from outside the process (do script can't deliver arrow keys; System Events needs a machine-wide Accessibility grant that misfires). So Karma types the command to open the menu, raises the terminal, and returns method:"menu-open" — the user picks "Disconnect this session" themselves, and the next liveStatus poll flips the pill.

State is read back from the session JSONL transcript, where Claude Code writes {"type":"system","subtype":"bridge_status", …} lines on connect/disconnect. No line anywhere → off; a line that can't be classified → unknown (endpoint 409s rather than blind-toggle).

The endpoint is honest by construction: sent=false means nothing was typed; sent=true, confirmed=false means keys went but the transcript hasn't caught up yet.

Changes

  • POST /live-sessions/{id}/remote-control {desired} — async, process-wide asyncio.Lock so button-mashing can't pile up keystroke sequences, bounded confirm-poll (6 × 0.5s).
  • services/remote_control.py (new) — read_remote_control_state, type_remote_control_command, can_send_remote_control. Whole-transcript scan (48 MB cap) for the last bridge_status, memoized on (path, st_mtime_ns, st_size); strict type==system && subtype==bridge_status match so ordinary text containing the string is ignored.
  • RemoteControlToggle.svelte (new) — the pill: off / on (+ ↗ open to claude.ai/code) / unknown / busy / staged / menu / retry / ok. Never dims; only the button disables. role="switch", live-region announcements, reduced-motion.
  • remote_control + can_remote_control on LiveSessionSummary — computed only on the single-session GET (they open the JSONL — too costly for the 1s-polled list endpoints).
  • CSRF gate — rc_trusted_origins (default :5180 only, deliberately narrower than cors_origins which also trusts :5173/:3000) + an X-Karma-RC request header, and Origin must be present. This required adding X-Karma-RC to cors_allow_headers so the browser's CORS preflight passes.
  • Status gate — blocked only for WAITING_INPUT (a dialog is open; the trailing Enter could answer it), STARTING (no REPL yet) and ENDED. Everything else, including ACTIVE, is allowed — a slash command typed during a tool run just queues.
  • Hardening from a blind-review pass (visible as review #N comments): ~/.claude/projects path confinement against symlink escape, https://claude.ai/ filter on the URL handed to <a href>, session_id regex, live-pid + supported-terminal gate.
  • .gitignore — ignores .storygraph.db and its -wal/-shm sidecars. An external tool drops them in the repo root, nothing here references them, and they were showing up as untracked noise in every git status. (Unrelated to Remote Control, folded in as a drive-by.)
  • api-types.ts carries two incidental Prettier line-collapses (PermissionMode, CronStateTriggerEvent) alongside the new RC types; prettier --check passes with them.

Review round 2 — all five inline comments addressed

Rebased onto main (now includes #99, #100, #102); merges clean.

Review comment Resolution
ConversationHeader.svelte — conflicts with #100, copiedTarget === 'resume' becomes a type error Kept #100's ResumeSessionButton outright and dropped this branch's copy-resume block. Taking the reviewer's note: #100's button focuses the terminal when live, which is useful, so this branch's "hide on live" was the wrong instinct and is gone. CopyTarget stays narrowed to 'uuid'.
live_sessions.py — full-transcript scan on the 1s-polled single-session GET Scan result memoized on (path, st_mtime_ns, st_size). A JSONL transcript is append-only, so unchanged mtime+size ⇒ unchanged content and a quiet session is scanned exactly once. LRU-bounded at 256 entries, lock-guarded. Measured — see Testing.
live_sessions.py — a request with no Origin passes the gate Missing Origin now 403s. For a keystroke-injecting endpoint it isn't worth the benefit of the doubt: this closes DNS-rebinding and the no-Origin-on-same-origin-POST corner. The dashboard always sends one; scripted callers can pass Origin: http://localhost:5180.
remote_control.py — "Esc-dismisses" overclaims; a stale-off ON click leaves the Disconnect menu open in an unfocused terminal Took both options offered. The docstring no longer claims an Esc that nothing sends, and unconfirmed-ON now raises the terminal exactly like the OFF path, with a detail that says the disconnect menu may be showing. The frontend's menu-open method stays reserved for the OFF click, so the ON path reports the honest "sent, unconfirmed".
remote_control.py — tmux gate passes on pid liveness alone; recycled pid + recycled pane could enable RC on the wrong session Implemented the suggested check: tmux display-message -p -t <pane> '#{pane_pid}' must be the recorded pid or one of its ancestors (walk capped at 8, 5s TTL cache). Unverifiable — no tmux, pane gone — is treated as not owned. Verified against real tmux 3.7c — see below.

The reviewer's follow-up (moving #100's resume-in-terminal to rc_trusted_origins) is deliberately not in this PR — it hardens a different endpoint from a different feature, and was flagged as "follow-up once this lands". It'll be its own PR.

tmux gate — verified on real tmux ✅

The pane-ownership check was initially mocked-only, which was called out as the one unproven claim in this PR. It is now verified end to end against tmux 3.7c, and the verification is committed as an integration test (test_pane_ownership_against_real_tmux, skipif when tmux is absent so CI and non-tmux machines are unaffected):

Case Expected Result
_tmux_pane_pid() agrees with tmux display-message equal ✅
The pane's own process owned ✅
A descendant of the pane process — the real shape, since claude runs under the pane's shell owned ✅
init (pid 1) not owned ✅
An unrelated process outside tmux not owned ✅
A nonexistent pane (%99999) not owned ✅

Run it yourself with pytest tests/api/test_live_sessions_remote_control.py::test_pane_ownership_against_real_tmux -v — it creates and destroys its own throwaway session and never touches your own. Locally it reports PASSED, not SKIPPED.

Component

  • API (FastAPI backend)
  • Frontend (SvelteKit dashboard)
  • Captain Hook (hook models)
  • Hook Scripts
  • Documentation
  • CI / Build

Type

  • Feature (new functionality)

Testing

  • API tests pass — test_live_sessions_remote_control.py 40 pass (33 + 7 new: pane-ownership accept/reject/unverifiable, a real-tmux integration test, scan memoization, per-path isolation, unconfirmed-ON raises vs confirmed-ON doesn't). Full tests/api/ 1885 pass (the 6 test_background_shells_router failures are pre-existing, unrelated, and local-only: that test's fixture doesn't stub the router's _maybe_sync(), so each test ingests the developer's real ~/.claude/projects history into its temp DB and gets 200 rows where it expects a handful. Green on CI, which has no such history. An earlier revision of this description blamed a stale .storygraph.db — that was wrong; karma's DB is ~/.claude_karma/metadata.db.).

  • Frontend type-checks (npm run check, 0 errors) and lints clean (npm run lint, 0 errors)

  • Captain Hook tests pass — N/A (no hook changes)

  • Memoization measured, not asserted. A/B across two detached worktrees (pre-fix vs post-fix), each a real uvicorn server hit over real HTTP with an isolated $HOME, against a 40 MB transcript containing zero bridge_status lines (the worst case: every byte scanned, nothing found). 100 sequential requests:

    pre-fix post-fix
    Server CPU, read from ps (the OS, not the app) 4.06 s 0.06 s 68× less
    Wall clock, measured by the external request loop 4.71 s 0.63 s 7.5× faster

    That's 40.6 ms → 0.6 ms of CPU per request. Both variants returned remote_control: {'state': 'off'}, confirming the scan path actually ran rather than an early-out. The regression test was mutation-checked: disabling the memo makes it fail assert 3 == 1 for the right reason, so it detects the real defect instead of passing vacuously. (The endpoint's @cacheable(max_age=5) throttles how often a browser triggers this; it doesn't change the per-scan cost, which is what was measured.)

  • Manually tested — ON and OFF both verified end-to-end against a throwaway Terminal.app claude session:

    • ON → {sent:true, confirmed:true, state:"on", url:"https://claude.ai/code/session_…"}; confirmed three ways (endpoint response, a bridge_status "is active" line Claude Code wrote to the throwaway's own transcript, and a fresh Karma GET re-reading it as on).
    • OFF → {sent:true, method:"menu-open", state:"on"}; focus_terminal raised the window for the user to finish in the menu.

Screenshots

Pill states (off / on+open / menu / retry) render via npm run check + component review; no static screenshots attached.

Related Issues

Separate feature branch off main; independent of #99 (settings / schema-drift). Rebased onto main after #99/#100/#102 merged.

🤖 Generated with Claude Code

@JayantDevkar JayantDevkar left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The security design here is solid and I'd like to land this — but it needs a rebase and one perf fix first.

Checked and clean

  • The typed payload is the constant RC_COMMAND; nothing request- or session-derived reaches it. All subprocess calls are list-form, AppleScript interpolation goes through _applescript_str, tmux uses send-keys -l --.
  • The origin gate is exact-match and enforced server-side independent of CORS, so adding X-Karma-RC to cors_allow_headers doesn't let :5173/:3000 through (they pass preflight, then get 403). Desktop app uses :5180, so it's covered.
  • Transcript paths are realpath-confined under projects_dir; the href is filtered to https://claude.ai/ on both sides. Blocking calls in the async handler are all in asyncio.to_thread.

Needs to change before merge

  1. Rebase: #100 just merged and this now conflicts in ConversationHeader.svelte and api/schemas.py (see inline).
  2. The full-transcript scan on the polled single-session GET (see inline).

Follow-up once this lands: #100's resume-in-terminal endpoint should move to this PR's narrower rc_trusted_origins gate — it spawns a process and currently trusts all of cors_origins.

Not checked: real toggle against a live terminal, Claude Code's actual bridge_status wording vs _ON_MARKERS/_OFF_MARKERS (a wording change degrades to unknown → 409, which is the safe direction).

Comment thread frontend/src/lib/components/conversation/ConversationHeader.svelte Outdated
Comment thread api/routers/live_sessions.py
Comment thread api/routers/live_sessions.py Outdated
Comment thread api/services/remote_control.py Outdated
Comment thread api/services/remote_control.py
the-non-expert and others added 3 commits September 19, 2026 21:57
Add a toggle on the live-session header to turn Claude Code Remote
Control on/off for that session. Claude Code exposes no local API for
this, so Karma drives it by typing `/remote-control` into the session's
terminal (tmux / macOS Terminal.app / iTerm2).

- POST /live-sessions/{id}/remote-control {desired}: reads current state
  from the transcript's bridge_status lines, types the command, confirms
  from the transcript. Honest by construction — sent=false means nothing
  was typed; sent=true/confirmed=false means keys went but the transcript
  hasn't caught up.
- ON is fully automated. OFF opens Claude Code's interactive "Disconnect
  Remote Control" menu and raises the terminal; the user finishes it
  there (nothing drives that menu reliably from outside the process).
- Only WAITING_INPUT / STARTING / ENDED are blocked — a slash command
  typed during a tool run just queues.
- CSRF: rc_trusted_origins (narrower than cors_origins) + an X-Karma-RC
  header, which also required adding X-Karma-RC to cors_allow_headers so
  the browser CORS preflight passes.
- state read confined to ~/.claude/projects; only https://claude.ai URLs
  surfaced to the frontend <a href>; a process-wide asyncio.Lock
  serialises toggles.

remote_control + can_remote_control are computed only on the
single-session GET (they open the JSONL — too costly for the 1s list
poll).

ConversationHeader also moves the live-sync spinner to a badge by the
H1 and hides `resume` on live sessions. api-types.ts carries two
incidental Prettier line-collapses.

33 endpoint/unit tests. ON + OFF verified end-to-end against a throwaway
Terminal.app session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLBBkC227GsETwuAfB6SyN
CI installs ruff unpinned (now 0.16.5); its formatter wraps the
multi-line lambda in test_disable_opens_menu_and_raises_terminal.
No behaviour change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLBBkC227GsETwuAfB6SyN
…n, no orphan menu

Review feedback on #101, plus the rebase onto main (#99/#100/#102).

- Memoize the transcript scan on (path, st_mtime_ns, st_size). The single-
  session GET is polled ~1s per open tab and, when RC has never been used,
  found no bridge_status line anywhere — so every uncached hit re-read and
  decoded the whole multi-MB chain. A JSONL transcript is append-only, so an
  unchanged transcript is now scanned exactly once.
- Verify a tmux pane still owns the recorded pid before enabling the toggle.
  pid_is_live_claude accepts node/bun/deno, so a recycled pid in a recycled
  pane could have enabled RC on a *different* Claude session. The pane's
  pane_pid must now be that pid or an ancestor of it; unverifiable => refused.
- Require Origin on the toggle, not just "trusted if present". For a
  keystroke-injecting endpoint, no-Origin isn't worth the benefit of the
  doubt: this closes DNS-rebinding and the no-Origin-same-origin-POST corner.
- Drop the "Esc-dismisses" claim in read_remote_control_state — nothing here
  sends Esc. A stale "off" (session launched with --remote-control, no
  bridge_status line) meant the ON click opened the *disconnect* menu and left
  it open in an unfocused terminal, where the user's next Enter picks an item.
  Unconfirmed ON now raises the terminal like the OFF path and says so.

ConversationHeader: #100's ResumeSessionButton supersedes this branch's
live-session hide (it focuses the terminal when live, which is useful), so the
rebase keeps #100's version outright and drops the copy-resume block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the-non-expert and others added 4 commits September 19, 2026 22:25
The pane-ownership check added in a08634c was covered only by mocks, so a
wrong `display-message` invocation would have silently withheld the toggle
from every tmux user while looking fine on Terminal.app / iTerm2.

Adds an integration test that drives a throwaway tmux session end to end:
`_tmux_pane_pid` must agree with tmux, the pane's own process and a
descendant of it (claude sits under the pane's shell in the real setup) are
owned, and init / an unrelated process / a nonexistent pane are not.
Skipped when tmux is absent, so CI and non-tmux machines are unaffected.

Verified locally on tmux 3.7c: PASSED, not skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.storygraph.db and its -wal/-shm sidecars are an external tool's local
artifact that sits in the repo root and shows up as untracked noise in every
`git status`. Nothing in this repo references them — karma's own database is
~/.claude_karma/metadata.db.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st on a private server, aria-disabled toggle, one-line comments
@JayantDevkar

Copy link
Copy Markdown
Owner

Re-reviewed: all five fixes from the last round are real, and I found no semantic conflict with current main. Pushed two commits on top so this can land now: the terminal gate (can_send_remote_control) runs via asyncio.to_thread since a cache miss can shell out to ps/tmux several times with 5 s timeouts; the real-tmux integration test runs on a private server (TMUX_TMPDIR in a short temp dir, TMUX unset, kill-server in finally) and polls for the pane shell before asserting ownership, since it flaked about 1 in 30 locally; the toggle uses aria-disabled instead of disabled so the reason text stays reachable by keyboard; the OFF docstring no longer claims to navigate the menu; and the multi-line comment blocks are one line each. Merged current main in. Locally: 1952 passed, 6 skipped on the full API suite, npm run check 0 errors, the tmux test ran 10/10. Merging once CI agrees.

@JayantDevkar
JayantDevkar merged commit efa59fc into main Oct 21, 2026
28 checks passed
@JayantDevkar
JayantDevkar deleted the remote-control-toggle branch October 21, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants