@cbreland/coop-cli
v0.1.13
Published
Stream your terminal to your phone. Agent-agnostic, peer-to-peer, zero config.
Downloads
1,638
Maintainers
Readme
coop
Stream your terminal to your phone. Agent-agnostic. Peer-to-peer. Zero config.
Run one command on your dev machine, scan a QR with your phone, and your terminal is live on the other device with full fidelity — spinners, colors, ANSI redraws, all of it. Works with Claude Code, Aider, Gemini CLI, plain shells, anything that runs in a terminal. Peer-to-peer over WebRTC — terminal bytes never touch a server.
npm install -g @cbreland/coop-cli
coopThen scan the QR with the Coop app (or paste the COOP-XXXX-XXXX short code). That's the whole setup.
Node.js CLI daemon that spawns shells in PTYs, pairs with a phone/browser client via a short code, and streams terminal output both ways. Multi-session (multiple terminals per daemon), multi-client (multiple devices simultaneously), persistent identity (same pairing code forever), and Claude Code hook-driven notifications with Web Push fallback for iOS background.
The daemon is fully standalone. It talks only to the WebRTC pairing relay, STUN/TURN, and (when the user opts into Claude Code notifications) open-standard Web Push endpoints. No Firebase, no Google APIs beyond STUN, no auth providers, no telemetry, no update checks. Anything that looks like "Coop's backend" is client-side.
Network boundaries
Every outbound connection the daemon makes, by design:
| Endpoint | Purpose | When | Whose infra |
|----------|---------|------|-------------|
| wss://coop-production-fdc9.up.railway.app | WebRTC signaling (pairing relay) | Always, while running | Coop (relay only — no user data stored) |
| https://coop-production-fdc9.up.railway.app/turn-credentials | Fetch short-lived ICE server list (STUN + TURN) | Once per connect | Coop relay (master TURN key lives server-side, never shipped in the package) |
| stun:stun.cloudflare.com:3478, turn:turn.cloudflare.com:…, turns:… | NAT traversal (STUN) and TURN relay when P2P fails | During WebRTC handshake | Cloudflare Realtime TURN; credentials are per-request, TTL ~24h |
| stun:stun.l.google.com:19302 | STUN fallback if the credentials fetch fails | During WebRTC handshake (rare) | Google's public STUN, stateless, no auth |
| https://fcm.googleapis.com/..., https://web.push.apple.com/..., https://updates.push.services.mozilla.com/... | Web Push delivery | Only when a Claude Code hook fires AND the client is disconnected AND has registered a push subscription | Standard Web Push (RFC 8030); daemon auths with VAPID keys it generates locally in ~/.coop/vapid.json |
Everything else is local-only: PTYs, scrollback, Claude Code hook socket (~/.coop/hook.sock), identity files (~/.coop/identity.json, ~/.coop/vapid.json), and the hook config in ~/.claude/settings.json.
The daemon does not talk to any Coop Firebase project, does not verify ID tokens, does not register users anywhere. User/auth/daemon-list persistence is entirely the client's responsibility.
Robustness contract
- Token persists. Once the daemon generates a pairing token (
COOP-XXXX-XXXX), the same code stays valid forever. Saved to~/.coop/identity.json. Use--regen-tokento rotate. - Daemon stays up unless you tell it to stop. Survives all clients disconnecting, signaling reconnects, internet blips, and individual PTY exits. Only shuts down on explicit signals (
Ctrl-C/Ctrl-\/kill). - PTY sessions stay alive until their shell exits or a client explicitly closes them. The daemon never auto-kills them.
coopalways shows the QR + code on startup. No "skip if running" — you can always see how to connect.coop codeprints the QR + code on demand without affecting a running daemon. Useful when the daemon's terminal is buried or you forgot the code.
Install
From npm (the normal path)
npm install -g @cbreland/coop-cliThen coop is on your PATH. Requirements: Node.js ≥ 20 on macOS or Linux. The installer also runs a best-effort check for jq and nc, which the Claude Code hook pipeline uses — if either is missing you'll get a friendly one-line message telling you how to install them (daemon runs fine without).
From this checkout (development)
cd cli
npm install
npm link # symlinks `coop` to this checkout; edits take effect immediately
coop --helpUnlink with npm unlink -g @cbreland/coop-cli.
From a tarball
cd cli
npm pack # produces cbreland-coop-cli-<version>.tgz
npm install -g ./cbreland-coop-cli-0.1.1.tgzUseful for handing a build to a teammate without publishing.
Post-install behavior
Two scripts run automatically on install:
scripts/fix-pty-helper.js— chmods node-pty's prebuiltspawn-helperbinary soposix_spawnpworks on firstcooprun.scripts/check-deps.js— prints a warning (not an error) ifjqorncaren't onPATH. These are OS-level tools used by the Claude Code hook forwarder; both ship with macOS and most Linux distros.
Neither blocks the install.
Run
coop # headless: client drives the terminal size (default)
coop -v, --verbose # print full status stream — signaling state, client join/leave,
# resize events, session lifecycle. Useful for debugging.
coop --mirror # mirror: local terminal drives the size
coop --regen-token # rotate the pairing token (invalidates the old code)
coop code # print the current QR + code without starting the daemon
coop --helpBy default the daemon is quiet — it prints the QR code, the short code (COOP-XXXX-XXXX), and a single Ready. Scan the QR above to connect. line, then stays silent until you interact. Errors always print to stderr regardless of verbosity. Add --verbose when debugging a connection to see the full event stream; or set COOP_DEBUG=1 for a structured JSONL log at /tmp/coop-debug.log.
The token is persisted across daemon restarts; the same code stays valid forever until you --regen-token or delete ~/.coop/identity.json.
Modes
Headless (default)
coopThe client is the authoritative terminal. The PTY starts at 120×40 and resizes to whatever the client reports. The local terminal stays untouched — it just shows [coop] status lines. Ctrl-C shuts down the daemon cleanly.
Use this when the phone/browser is the primary surface.
Mirror (opt-in, --mirror or -m)
coop --mirror
# or: COOP_MIRROR=1 cooptmux-style shared attachment. The daemon hands the local terminal over to the PTY after the QR prints, so whatever you type runs in the shell and the client mirrors it byte-for-byte. Requires a real TTY on both stdin and stdout; falls back to headless with a warning otherwise.
- Local resize re-fits the PTY and notifies the client.
- Client resize requests are ignored — the local terminal is the source of truth.
- Status lines go to stderr so they don't corrupt shell output.
- Quit key:
Ctrl-\(Ctrl-backslash). Cleanly kills the PTYs, disconnects, restores cooked mode, and exits. - Status flash:
Ctrl-](Ctrl-rightbracket). Prints a one-line session summary to stderr —[coop] 3 sessions: 0(zsh) 1(zsh) 2(bash)— and immediately returns to pass-through. No command mode. Ctrl-Cpasses through to the shell, soclaude/vim/ etc. get their interrupt.
Mirror mode pins the client's grid for the primary session (id 0) to the local terminal's width. Background sessions opened from the client are independently sized and don't appear locally.
Multi-session
Up to 256 PTY sessions concurrently, multiplexed over the connection. The client opens, closes, and routes input per session; each session has its own scrollback ring buffer (1 MB cap each). Session id 0 is the initial session; new sessions get monotonically-increasing ids (1, 2, 3, …).
Multi-client
The daemon accepts N concurrent clients at the same pairing code. Every connected device sees the same PTY output, can send input (interleaved at the PTY — your phone, your tablet, your laptop browser all live at once). One client at a time is "focused" — its viewport size drives the PTY size. Clients connect, disconnect, and switch focus independently; one device leaving doesn't affect the others.
The daemon broadcasts a central event feed (snapshot + delta events) over the data channel so the frontend can render reactively. See client/docs/multi-client-protocol.md for the full event catalog and CONTROL action set.
Identity in the snapshot event
Every snapshot event carries daemonId (stable across restarts, from ~/.coop/identity.json) and machineName (from os.hostname()). Clients typically persist { daemonId, token, machineName } to localStorage when a pairing succeeds so they can auto-reconnect on page reload — entirely their own bookkeeping, no daemon-side registry involved.
Notifications
Coop can push real-time notifications to the client when Claude Code finishes a task, needs permission, idles, or errors out. First-run setup is automatic:
- A Unix domain socket is created at
~/.coop/hook.sock(mode 600). - Claude Code hooks are merged into
~/.claude/settings.jsonforNotification,Stop,StopFailure,SessionStart,SessionEnd. Existing user hooks are preserved; onlycoop-hookentries are added. - Each PTY is spawned with a unique
COOP_SESSION_IDenv var so hook events can be routed back to the right session tab. coop-hook(shipped in this package) tags each hook payload with that id and forwards it to the socket vianc -U.
The daemon rate-limits duplicate notification types (one per type per session per 2 s) and debounces Stop events by 500 ms so Claude's mid-task tool-use chains don't fire spurious "task complete" notifications.
Dependencies. The coop-hook shim uses jq and nc. Both ship with macOS and most Linux distros. If a hook fails for any reason, Claude Code keeps running — Coop outages are never allowed to break the user's session.
Uninstall. To strip Coop's hooks from ~/.claude/settings.json without touching your own, run: node -e "require('./cli/src/hook-config').uninstall()" (from the repo checkout, or a global install).
Reconnection
If the client drops, the daemon prints a new pairing code and waits again — every session stays alive and its scrollback is replayed to the next client so they don't see black tabs.
Configuration
| Env var | Default | Meaning |
| -------------------- | ---------------------- | ------------------------------------------------ |
| COOP_SIGNALING_URL | production endpoint | Override the pairing service URL. |
| COOP_MIRROR | unset | Set to 1 for mirror mode (same as --mirror). |
| COOP_DEBUG | unset | Set to 1 to enable JSONL diagnostic logging. |
| COOP_DEBUG_PATH | /tmp/coop-debug.log | Where the debug log is written (append mode). |
Debugging
Every run prints a transport summary on exit:
[coop] diag: sent N frames / M bytes, recv K frames / L bytesA fast sanity check for whether bytes reached the client.
For deeper tracing, run with COOP_DEBUG=1. A JSONL log is appended to /tmp/coop-debug.log containing the full session lifecycle and per-frame metadata. Tail it with:
tail -f /tmp/coop-debug.log | jq .Tests
npm testUses node:test. All suites run locally without network.
Shutdown
Signals that kill the daemon cleanly:
Ctrl-\— mirror-mode quit key.Ctrl-C— headless quit (in mirror mode,Ctrl-Cflows to the PTY).SIGTERM/SIGHUP— externalkilland terminal close both work.
All paths funnel through a single shutdown routine with a 500 ms grace window, then a forced exit. No stragglers.
Pairing code format
8 characters from a reduced alphabet (no ambiguous I/L/O/U), displayed as COOP-XXXX-XXXX. Typed or scanned, both produce the same canonical token. See src/token.js.
