pinclaw-cc-bridge
v0.8.3
Published
Pinclaw Claude Code bridge — runs Claude Code through a local hub so your phone mirrors and controls your terminal sessions (two-way synced).
Readme
pinclaw-cc-bridge
Runs on a user's Mac so their phone mirrors and controls their Claude Code terminal sessions, two-way synced — one model, no read-only/bubble split.
How it works (the "manager"): your claude is wrapped by a thin shell that
runs the real, official claude in a pseudo-terminal. Your terminal stays
native; the shell also mirrors the session to your phone and injects what the
phone types. A single background hub daemon owns the cloud connection and
multiplexes every wrapped session, so you can run many claudes at once and the
phone switches between them.
The one hard limit (OS): a claude already running bare in a terminal can't
be reached — macOS won't let an outside process inject into another process's tty.
So only sessions started through the manager are phone-controllable. The
installer makes that invisible by putting a claude shim first on your PATH, so
you keep typing claude and every new session is automatically controllable.
Design: docs/superpowers/specs/2026-06-07-cc-manager-unified-design.md (current);
earlier: …2026-06-06-cc-shared-session-json-shell-design.md, …2026-06-04-….
Install (end users)
In the Pinclaw app: Claude Code → Connect, which shows a one-line command (it embeds an account token, so no second login):
curl -fsSL https://pinclaw.ai/install-cc | PINCLAW_CC_TOKEN=<token> bashThis binds the Mac to your account, installs the manager, puts the claude shim
on PATH, and starts the hub daemon (launchd). Then just run claude as usual.
- Bypass once:
PINCLAW_CC_DISABLE=1 claude - Remove everything:
pinclaw-cc-bridge uninstall
(No app token? curl -fsSL https://pinclaw.ai/install-cc | bash falls back to
device-code browser auth.)
Region-locked egress (e.g. China)
A phone send spawns a fresh headless claude/codex under the launchd
daemon — it does NOT go through your terminal, so it doesn't inherit the
https_proxy your terminal claude wrapper uses. If your region needs a proxy
to reach Anthropic/OpenAI, that child exits direct and fails
403 Request not allowed (while the terminal works). Worse, a different exit IP
on the same account is an account-ban risk.
Fix: set engineEnv so the bridge child exits from the same residential
IP / timezone / locale as your terminal. If your shell rc defines CLAUDE_PROXY
(or CLAUDE_PROXY_{USER,PASS,HOST,PORT}):
pinclaw-cc-bridge sync-proxy # writes engineEnv to ~/.pinclaw/{cc,codex}-bridge.json
launchctl kickstart -k gui/$(id -u)/ai.pinclaw.cc-bridgeOtherwise add an engineEnv object by hand to those config files. It fails
closed (a dead proxy errors the request, never falls back to direct) and the
daemon log prints engine egress: PROXIED via … / DIRECT so you can verify it.
Re-run sync-proxy after rotating the proxy. US users need none of this.
How discovery works
- Scans
~/.claude/projects/<encoded-cwd>/*.jsonl— each JSONL is one session,mtime= last active. (encoded-cwd= every non-alphanumeric char →-.) - Finds live sessions via
ps, matching the CLI by argv0 basenameclaude(since 0.5.1 — covers npm, pnpm, and the native installer's~/.local/bin/claude; the desktop app is excluded), deduped to root processes by PPID. Procs started with--session-id <uuid>(every wrapper-run session) bind pid↔session exactly; only bare procs without it fall back to the heuristic:lsofthe proc's cwd, then with N live procs in a project the N newest JSONLs touched within the last 30 minutes arerunning, the restidle(the freshness bound keeps hours-old sessions from showing as running just because unrelated procs exist in the same project); extra procs with no JSONL yet are virtualpending-<pid>. - List rows read only head 16KB (title/cwd) + tail 128KB (recent messages). Full transcript is read on demand when the phone opens a session.
- Row title (since 0.4.1) = the session's live recap: the freshest
{type:"system", subtype:"away_summary"}entry Claude Code keeps appending to the JSONL (tail window beats head). Falls back toai-title, then the first user message — slash-command wrappers render as/name, local-command caveats are skipped. Codex sessions have no recap and keep their meta title. - Codex transcripts (since 0.5.1) parse
~/.codex/sessions/rollout JSONL and hide the bookkeeping the Codex TUI never renders — developer/system-role instruction messages and user-role<environment_context>/<user_instructions>/<turn_aborted>prefix messages — so the phone shows exactly what Codex shows. - Live streaming (since 0.5.0): while a phone has a session open, the cloud
arms a watch (
cc_watch/ aliascc_watch_start) and the bridge tails that session's JSONL (transcript-watcher.ts+watch-manager.ts), pushing new entries ascc_event{kind:"transcript_append", startIndex, …}(idempotent by absolute index) pluswatch_ok{entryCount}reconciliation heartbeats, so terminal-driven turns appear on the phone block-by-block instead of on the next poll. Watches stop oncc_unwatch/cc_watch_stopor expire on their own TTL;watch_failedtells the phone to fall back to polling.
Commands
# normal usage (set up by the installer; you don't run these by hand)
pinclaw-cc-bridge install # install the `claude` shim + PATH + start the hub (idempotent)
pinclaw-cc-bridge uninstall # remove shim + PATH entry + hub launchd (reversible)
pinclaw-cc-bridge hub # the background daemon: holds the cloud link, multiplexes all shells
pinclaw-cc-bridge term -- ... # one wrapped `claude` that joins the hub (what the shim calls)
# debug / legacy
pinclaw-cc-bridge once # print one discovery snapshot as JSON and exit
pinclaw-cc-bridge start # legacy read-only discovery daemon
pinclaw-cc-bridge mirror # legacy single-session mirror (one bridge per user)Architecture: hub (one daemon, one cloud WS, listens on
~/.pinclaw/cc-hub.sock) ← many term shells (each = one pty-wrapped claude,
registers over the local socket). The hub multiplexes all shells to the cloud and
routes phone input back to the right one by termId. A shell exiting (Ctrl-C /
crash) sends bye, so the phone never sees a ghost terminal.
Local dev / end-to-end proof (no cloud needed)
npm install
npm test # unit tests (122)
node --import tsx src/dev-server.ts & # cloud stand-in (ws://localhost:7799)
PINCLAW_CC_URL=ws://localhost:7799/cc-bridge/connect PINCLAW_CC_TOKEN=dummy \
node --import tsx src/cli.ts start & # bridge against real ~/.claude/projects
node --import tsx src/phone-probe.ts # simulates the phone: lists + opens a transcriptWire protocols
Terminal mirror — hub ⇄ cloud (the current model):
Hub → cloud: cc_term_hello {termId,cwd,title,cols,rows}, cc_term_output
{termId,data(b64)}, cc_term_bye {termId}. Cloud → hub: cc_term_input
{termId,data}, cc_term_resize {termId,cols,rows}.
Shell ⇄ hub (local socket, newline-JSON, hub-protocol.ts):
shell → hub reg / out / bye; hub → shell in / resize.
Phone ⇄ cloud (routes/cc.ts): GET /cc/term/list, GET /cc/term/stream
(SSE bytes, replays the rolling buffer then live), POST /cc/term/input, POST
/cc/term/resize, and POST /cc/pair (logged-in phone mints an account-bound
install token). Cloud relay: services/cc-term-service.ts (per-term rolling
256KB buffer, replay-then-live, drops on bye).
Legacy (bubble/discovery, still present): cc_hello/cc_snapshot/cc_event +
/cc/sessions|attach|send|stream; migrations 049_claude_code_mode.sql +
050_cc_sessions_controllable.sql. The unified iOS UI uses the terminal path only.
