JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 71
  • Score
    100M100P100Q106170F
  • License MIT

Stream your terminal to your phone. Agent-agnostic, peer-to-peer, zero config.

Package Exports

  • @cbreland/coop-cli
  • @cbreland/coop-cli/src/index.js

This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@cbreland/coop-cli) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

Readme

coop

Stream your terminal to your phone. Agent-agnostic. Peer-to-peer. Zero config.

npm license node

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
coop

Then 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-token to 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.
  • coop always shows the QR + code on startup. No "skip if running" — you can always see how to connect.
  • coop code prints 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-cli

Then 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 --help

Unlink 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.tgz

Useful 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 prebuilt spawn-helper binary so posix_spawnp works on first coop run.
  • scripts/check-deps.js — prints a warning (not an error) if jq or nc aren't on PATH. 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 --help

By 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)

coop

The 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 coop

tmux-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-C passes through to the shell, so claude / 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:

  1. A Unix domain socket is created at ~/.coop/hook.sock (mode 600).
  2. Claude Code hooks are merged into ~/.claude/settings.json for Notification, Stop, StopFailure, SessionStart, SessionEnd. Existing user hooks are preserved; only coop-hook entries are added.
  3. Each PTY is spawned with a unique COOP_SESSION_ID env var so hook events can be routed back to the right session tab.
  4. coop-hook (shipped in this package) tags each hook payload with that id and forwards it to the socket via nc -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 bytes

A 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 test

Uses 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-C flows to the PTY).
  • SIGTERM / SIGHUP — external kill and 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.