Guidance for Codex (and human contributors) working in this repo. User-facing setup/run docs live in README.md / README_en.md.
Self-hosted remote control for Claude Code and Codex: a phone/browser drives a
local claude or codex session through a WebSocket relay. Two independent links:
- model link — the local CLI → whatever its own settings/authentication point at. cc-remote never touches model credentials or the model API.
- control link (this repo) — client ⇄ relay ⇄ wrapper ⇄ Claude Agent SDK / Codex app-server. Native CLI ownership is detected and mirrored separately.
The repository skill is
.agents/skills/cc-remote-deploy/SKILL.md.
Use it for deployment, upgrade, verification and recovery requests; agents that
do not auto-discover repository skills must read it explicitly.
The repository-owned deployment procedure is deploy/README.md.
Read its automation contract and the relevant installation path completely
before any deploy, redeploy, recovery, verification, or rollback. Do not depend
on out-of-tree instructions, and do not add an operator's host aliases,
usernames, IPs, domains, home directories, or credentials to this repository.
Resolve that inventory from the environment the operator placed in scope.
Use one tested source snapshot or one set of release artifacts for all protocol
tiers, stage and validate before touching live services, preserve external
configuration and private state, and use the repository's immutable activation
transactions. A dropped SSH/control connection is an unknown result: inspect
the original transaction and live state before deciding whether a retry is
safe. Deployment is complete only after protocol/build identity, service
stability, public health, and expected Wrapper connectivity are verified.
Use deploy/cleanup.py for reviewed retention inventories, never an ad hoc
deletion script. Preserve process cwd/open-file and retained runtime dependencies
even beyond the rollback generation limit. Repeat acceptance after cleanup,
including live Codex config reads; incomplete visibility means retain the files.
For Codex Code, also follow deploy/README.md's shared-control-plane acceptance:
verify each account's daily CLI and Wrapper connect to the same official
app-server, not a private stdio fallback. Do not force takeover or kill a live
CLI to satisfy deployment checks.
After core deployment checks, follow the optional Codex App checkpoint in that
guide: detect an installed App on an in-scope desktop, ask before attaching it,
and keep a decline or pending answer separate from deployment success. App
attachment and optional App-control MCP tools are separate user choices.
- Claude service lifetime: when
CC_REMOTE_CLAUDE_SERVICE_SOCKETis set, regular Claude Code/Work SDK processes belong to the separate local service. Wrapper shutdown detaches; explicit stop/reconnect/eviction still has its deliberate native lifecycle. Never resubmit an accepted query during recovery, treat a background Result as the human terminal, or restart the service during an ordinary Wrapper deploy. Seedocs/claude-session-service.mdfor first migration, remaining in-process tasks, queue drain and version boundaries. - Drain footgun: after
ClaudeSDKClient.interrupt(), the SDK does NOT kill the session — the current turn's stream still emits a terminalResultMessage(subtype="error_during_execution"). You MUST keep consumingreceive_response()until that ResultMessage before the nextquery(), else stale deltas from the interrupted turn bleed into the new turn. The wrapper handles this structurally: oneasync forper turn runs through the interrupt to the terminal ResultMessage; state only returns toidle(and the next query is only accepted) after that break. Reject-while-busy prevents a second query racing the drain. - Claude steering: send
priority="next"through streaming input, keeping the one session reader. Rebind the visible turn only on the exact native user UUID echo. A Result before an accepted input is consumed is intermediate; the persistent service journals this distinction and commits the original root turn identity. Explicit Stop usesinterrupt(cancel_queued=true)when advertised and pending inputs exist, then drains the real Result as above. - cwd must match resume: a session's jsonl lives at
~/.claude/projects/<cwd-with-/-as->/<uuid>.jsonl.ClaudeAgentOptions.cwdMUST equal the original session's cwd orresumecan't find it. - SDK pinned to
claude-agent-sdk==0.2.157: message-type shapes and the interrupt/drain contract can shift between patch versions. Re-run the interrupt+drain verification after any upgrade (SdkHandle.preflight()guards the exact verified patch at startup). - Claude Code is the user's daily CLI, not the SDK bundle: Claude Code
>=2.1.263is required and checked before a Claude session starts. The wrapper defaultsCLAUDE_BINto~/.local/bin/claudeand passes that path explicitly to the SDK. An empty value keeps this default; only another absolute path may override it. Keep that CLI updated and signed in before starting the wrapper. include_partial_messagesis aClaudeAgentOptionsfield (set at construction, not onquery()). Streaming events arrive asStreamEvent(.event= raw Anthropic API stream-event dict) — NOTSDKPartialAssistantMessage(doesn't exist in 0.2.157). Extractcontent_block_delta→delta.textfromStreamEvent.event.- tool_use is batched, not streamed: emit one
tool_useevent from the assembledAssistantMessage(fullinput), never as JSON-fragment deltas. Text deltas still stream live viaStreamEvent. - Claude only — don't set
setting_sources=[]for Code: legacy single-account Code intentionally loads~/.claude/settings.json. Explicit account profiles keep the real HOME, clear ambient account selectors, and usesetting_sources=["user"]. A profile rooted at the real per-user~/.claudemust leaveCLAUDE_CONFIG_DIRunset, retaining~/.claude.jsonand native keychain identity; other roots set it explicitly. Setting it to~/.claudechanges the account file to~/.claude/.claude.json. Project/local settings may contain provider env or auth helpers and must not participate in an account-isolated child. Never pass the selected profile's complete settings file through--settings: that promotes every user setting above project/local precedence. Never parse or copy account credentials into cc-remote state. Single-account behavior stays native; Work is the deliberate exception with one wrapper-owned policy file,setting_sources=[], and safe mode. - Auth is URL-secret-free: the wrapper uses
Authorization: Bearer <token>at WS upgrade. Web clients POSTLOGIN_PASSWORDto/api/loginand receive a short-lived HttpOnly/SameSite cookie;/wsenforces exactPUBLIC_ORIGIN. WhenALLOW_PRIVATE_ORIGINS=1, the only additional origins are literal private/loopback IPs onRELAY_PORT, and their scheme/host/port must match the effective request target. CookieSecurefollows that trusted request transport, never the caller's Origin. Uvicorn trusts forwarded transport metadata only from loopback Caddy. Never put tokens in URLs or protocol message bodies; logging redacts token/password fields. - Protocol version gate: current wire protocol v74 is declared by
PROTOCOL_VERSIONin bothprotocol.pyandweb/src/protocol.ts.deserializehard-rejects a version mismatch, and_Baseisextra="forbid", so ANY protocol change must be deployed to all three tiers together (wrapper + relay + web) and the relay restarted — the relay importsprotocol.pyand drops frames it can't parse. - Device scope is an authorization boundary: one relay can serve multiple
wrappers. Every browser command, event, push subscription, and pairing token
is scoped by
machine_id; a credential for one enrolled device must never be accepted for another. Keepcc_remote/device.py,relay/devices.py, relay routing, and the Web device selector aligned when this contract changes. - Remote Viewer serves static pages, not arbitrary LAN services: read
docs/remote-viewer.mdbefore changing its resource or origin boundary. Keep Bridge pages in opaque HTTP-sandboxed frames (neverallow-same-origin), secrets out of URLs/JSON, and binary transfers on/ws/viewer(Wrapper) //ws/viewer-client(browser Cookie + exact Origin), never on chat/replay. Optional Isolated mode uses per-preview origins and a__Host-main HTTPS cookie; default Bridge preserves the existing cookie. Home-page discovery is enabled by default (CC_REMOTE_VIEWER_HOME_PREVIEW=0opts out): only explicit HTML references or device-verified Python static listeners create private, session-associated publications. Never crawl home, add automatic entries to the global catalog, infer ownership from a private IP alone, follow symlinks, start an engine/service, or fetch an arbitrary private URL. Preserve manual publications and FD-based project/resource boundaries. - Multi-session routing key: the wrapper runs a POOL of resident sessions
(
WrapperMachine.sessions: dict[key, SessionContext], capMAX_CONCURRENT_SESSIONS).ctx.keyis the routing identity = the real cc sid once known, elsetmp-<uuid>. Every emit stampssid = ctx.session_id or ctx.key, so a brand-new session's pre-capture frames route deterministically (never leak into the focused runtime). Keepctx.keyin sync with the pool dict key on every re-key. - Focus vs re-key (don't conflate): switching the viewed session is
SessionFocus(focus only, no disconnect — the previous session keeps streaming). A new session capturing its real id mid-turn isSessionRekey(rename tmp-key→sid), which moves focus ONLY if the client was already viewing the temp key. Emitting SessionFocus on id-capture = focus-steal by background sessions. - Session cwd migration is in-place (protocol v27): only an idle Codex Code
session may move. A cold session is first resumed without changing focus;
then resume the same native thread id under
SessionContext.query_lock, preserve its wrapper-owned deferred queue, and emitSessionMigratedwithout changing focus. The target must already be an absolute directory; reconnect the old cwd before reporting a failed move. Persist the accepted cwd in the private Codex control store and overlay it on cold resumes/session listings:thread/resume.cwdis live state and does not rewrite the native catalog metadata until a later turn materializes that context. - Deferred queries are wrapper-owned (protocol v25): queue/replace submits
transfer the complete bounded
Queryto the residentSessionContextimmediately. The wrapper waits for the active managed/spontaneous task's real terminal boundary and launches the next item without any browser callback. Web/PWA code only rendersQueryQueueState; never reintroduce an idle-driven browser drain. Queued contexts (including a worker's pop-to-preflight window) are not eligible for pool eviction or deletion. Full prompt inspection is a private one-shot read, and edits atomically replace the prompt under the queue lock; never put complete queued payloads in the replay ring. - External ownership is engine-specific: a native Claude CLI owns its transcript and is mirrored read-only until it exits or the user explicitly takes over. Codex Code sessions use the official app-server; shared-daemon CLI activity and private Codex App activity are different ownership sources and must not be collapsed into one "external process" heuristic. Ordinary shared sessions stay on the daemon; only the guarded oversized-resume path may select a newer official private app-server for compatibility.
- History = local projection + materialized summary pages; reconnect = live-tail replay
(protocol v24): IndexedDB paints the browser's last projection before network
validation.
GetHistory(detail="summary")returns a small canonical turn page (newest four, thenbefore/limitpagination), while the wrapper's rebuildable SQLite index avoids retranslating unchanged transcript/rollout bytes. Heavy tools, reasoning, process output and oversized final text stay local untilGetTurnDetailexpands that exact turn. The relay remains stateless. A fresh hello sends lightweight residentSnapshots; reconnect cursors replay only the bounded missing live tail. Source fingerprints invalidate appended pages, and rollback explicitly invalidates both server and browser projections. These reads never spawn/resume an engine or create a model turn. CodexHistory.terminal_fencesis a separate bounded lifecycle projection: only a real app-server terminal or a source-validated rollout marker may enter it; local synthetic failures may not. The browser applies a fence only to its exact native turn identity and never changes completion receipts or guesses from the last open row. - Token-aware residency: resuming an evicted Claude SDK session may rebuild a cold prompt cache, so it only happens on first spawn / re-focus after eviction; raising the cap trades RAM for fewer cold re-sends. Codex context is owned by the official app-server: cc-remote must page history and use native resume/compaction state, never re-upload a whole rollout. Browsing history is transcript/rollout I/O and must not create a model turn.
cc_remote/protocol.py— pydantic wire schema; all modules depend on it.serialize/deserializewithvcheck;is_downstreamfor seq/buffer. Control frames:SessionFocus/SessionRekey/GetHistory/History/GetTurnDetail/TurnDetail/SessionInfo.state.cc_remote/config.py— env-driven config (RelayConfig,WrapperConfig).cc_remote/device.py— device pairing CLI and persisted per-machine wrapper credential.cc_remote/log.py— JSON logging with token redaction; uselogger("...").cc_remote/wrapper/—sdk.py/stream.pyandclaude_*implement Claude;codex_handle.py/codex_stream.py/codex_daemon.py/codex_external.pyimplement the official Codex app-server paths;codex_lifecycle.pyowns the source-bound exact-terminal ledger;history_store.pyowns the rebuildable SQLite projection;machine.py,command_router.py,session_ctx.py,ringbuffer.py,transport.py, andsession.pyprovide the shared session pool, command dispatch, live replay, relay transport, and persistence.cc_remote/relay/— server.py (FastAPI/ws+/api/login+ static), auth.py (wrapper bearer + HMAC cookie session),devices.py(pairing and enrolled machine ownership),pairing.py(machine-scoped wrapper/client routing), andforward.py(bounded per-client queues; slow clients are disconnected without silently shedding deltas).web/src/—reducer.tsandhistory-merge.tsown per-session runtime and paged history merging;ws.tsis the relay client;protocol.tsmirrorsprotocol.py;components/DeviceSheet.tsxmanages enrolled machines.
- Keep every commit coherent and reviewable. Before staging, inspect
git status --short --branchand preserve unrelated user changes. Stage only the intended scope, then reviewgit diff --cached --stat,git diff --cached, andgit diff --cached --check. - Use an English Conventional Commit subject (
type(scope): summary). Do not add tool prefixes such as[Codex]/[Claude], generated-by trailers, orCo-Authored-Byunless the user explicitly requests one. - For a multiline message, use
git commit -F <message-file>with exactly one blank line after the subject and consecutive direct-bullets. After committing, verify the stored message and scope withgit log -1 --format=raw --stat, then recheckgit status --short --branch. - Before opening or updating a maintainer-authored PR (including work prepared by an agent for the maintainer), run the complete local gate below. A docs-only or apparently narrow change does not skip it unless the user explicitly accepts that exception. Every command must exit zero. Expected platform-defined test skips are allowed; report failures or missing tools rather than bypassing them. During development, use checks appropriate to the change.
- Other contributors may open or update a PR without running the complete local gate. Include the checks performed and any known validation gaps in the PR description. Automatic CI still builds Web and runs pytest for every PR.
- Run the Web gate with Node 24 (see
.nvmrc), matching CI. Newer Node browser-like globals must not mask missing browser-environment guards.
.venv/bin/python -m pytest
uvx --from ruff==0.15.13 ruff check cc_remote tests deploy
npm --prefix web run build
npm --prefix web run test:reliability
npm --prefix web run lint
bash -n \
deploy/install.sh \
deploy/install-relay.sh \
deploy/install-wrapper.sh \
deploy/setup-vps.sh
shellcheck -x \
deploy/install.sh \
deploy/install-relay.sh \
deploy/install-wrapper.sh \
deploy/setup-vps.sh \
deploy/setup_transaction.sh
git diff --check.github/workflows/ci.ymlautomatically runs only the Web build and pytest on PRs and pushes tomaster. Release tags reuse the same CI before packaging and publishing. Pytest waits only for the Web build artifact. Lint, front-end reliability tests and shell checks remain part of the local gate above.- Playwright is not part of CI or the required local PR gate. Existing browser tests remain available for explicitly requested diagnostics. When the maintainer asks for PR acceptance, check out the requested revision, run the application and verify the changed behavior; report the actual checks and any remaining gaps. The automated checks above do not call a live model.
python -m pip install -r requirements-dev.txt
python -m cc_remote.relay # terminal 1 (set WEB_STATIC_DIR=web/dist to serve the UI)
python -m cc_remote.wrapper # terminal 2 (on each machine running Claude/Codex)
pytest # zero-token unit tests
npm --prefix web run test:reliability
npm --prefix web run lint
npm --prefix web run buildpytest.ini restricts collection to tests/test_*.py; these are zero-token
unit/regression tests (stub transport, no model). Real relay/wrapper/model probes
live under scripts/live/ and may spend model tokens — run them explicitly,
keep prompts trivial ("hi"), and prefer the unit tests.