@idl3/claude-control
v1.12.9
Published
Local web UI to watch and drive your Claude Code sessions running in tmux — live transcripts, reply, answer AskUserQuestion, attach files, from a browser or phone.
Maintainers
Readme
claude-control
A tiny, local web UI to watch and drive your Claude Code sessions from a
browser or phone. It discovers the Claude sessions you already run inside
tmux, streams each session's transcript live, lets you reply, answer
AskUserQuestion prompts, attach screenshots/files, and capture the pane — all
over 127.0.0.1 (or your Tailscale tailnet), behind an optional token you enter
in-app (never in the URL).
No daemon to babysit, no database: it reads Claude Code's transcript files and talks to tmux. Bind is localhost-only by default.
Install (npm)
Clean machine? One command installs the prerequisites (Node + tmux), the published package, a generated auth token, and a launchd service on port 4317:
# from a checkout of this repo:
./scripts/install.sh
# …or pipe it straight from GitHub:
curl -fsSL https://raw.githubusercontent.com/idl3/claude-control/main/scripts/install.sh | bashThe installer is idempotent (re-run it to update). It prefers an existing
Homebrew for Node, falls back to nvm (no sudo, works headless over SSH) on
a bare machine, generates ~/.claude-control/token, and prints the token + URL
at the end. Flags: --no-service, --foreground (nohup instead of launchd),
--tokenless. Pin a version with
CC_PACKAGE_SPEC=@idl3/[email protected] ./scripts/install.sh.
Prefer to install by hand? The manual steps:
npm install -g @idl3/claude-control # or run once: npx @idl3/claude-control
claude-control: command not found? Use-g— a plain/localnpm installonly drops the binary in./node_modules/.bin/(not onPATH). If it's still missing after-g, your npm global bin dir isn't onPATH: runnpm prefix -gand add<that>/binto your shellPATH, or just usenpx @idl3/claude-control.
Prerequisites: Node ≥20 and tmux on your PATH (brew install tmux · sudo apt install tmux). The in-browser terminal (the composer's >_ mode) is a bundled xterm.js + pty bridge — no extra dependency to install. The web UI ships prebuilt — no build step on install.
Optional local AI (no API key):
Voice → text — run
claude-control setuponce: it installsffmpeg+whisper.cpp(Homebrew) and downloads a ggml model to~/.claude-control/models/. The mic in the composer then records audio and transcribes it locally (no API key). (Manual equivalent:brew install ffmpeg whisper-cppand drop a model at~/.claude-control/models/ggml-base.en.bin.)Microphone on a phone or tablet: browsers only allow mic access on a secure context (HTTPS or
localhost). A plainhttp://192.168.x.x:4317URL leavesnavigator.mediaDevicesundefined on iOS/Android — the permission won't stick and re-prompts every reload.localhoston the Mac itself is exempt. Easiest fix:tailscale serve --bg 4317then open thehttps://<host>.ts.netURL. Or run with your own cert:TLS_CERT=cert.pem TLS_KEY=key.pem claude-control.Prompt enhancer (✨) — defaults to a local MLX model on Apple Silicon. One-time setup:
python3 -m venv ~/.claude-control/mlx-venv ~/.claude-control/mlx-venv/bin/pip install mlx-lmclaude-control lazily starts
mlx_lm.serveron first use, keeps it warm, and shuts it down when idle. The model (defaultmlx-community/Llama-3.2-3B-Instruct-4bit, ~1.8 GB) auto-downloads on first run. Pick the backend + model in Settings (mlx→ deterministic rules fallback). Without the venv (or on non-Apple hardware) the enhancer uses the rules optimiser. Env overrides:CLAUDE_CONTROL_MLX_PYTHON,CLAUDE_CONTROL_MLX_PORT; setCLAUDE_CONTROL_MLX_PREWARM=1to trade the default lean startup for a warm model immediately after launch.
claude-control # start the server (prints the URL)
claude-control --help # config + subcommands
claude-control install-service # macOS: launchd auto-start on login + restart on crash
claude-control uninstall-serviceOpen the printed URL. If a token is configured (env CLAUDE_CONTROL_TOKEN, or a
token in ~/.claude-control/token), the app prompts for it on first load and
stores it in your browser — the token is never placed in the URL. With no token
set, it runs open on 127.0.0.1 / your tailnet.
Post-install (macOS, optional)
./scripts/install.sh covers these — the server and web UI already work
fully without either of them. Skip both unless you hit the specific case
they cover.
- Full Disk Access — only needed if your agents/sessions read
macOS-protected folders (
~/Documents,~/Desktop,~/Downloads, iCloud Drive). Ordinary dev work under~/Projectsetc. does not need this. Run interactively, the installer asks for your primary project folder and auto-detects whether it lives under a protected location (symlinks — e.g. iCloud Desktop & Documents sync — are resolved before comparing) — it only shows the Full Disk Access warning when your folder actually needs it, and otherwise prints a one-line "not required" confirmation. Piped / non-interactive installs (curl | bash, CI, SSH with no TTY) skip the prompt and print the general guidance instead, since there's no folder to check. If you hitOperation not permittedin a pane: grant Full Disk Access to the exactnodethe service runs (the installer prints the resolved path) via System Settings → Privacy & Security → Full Disk Access, then restart the service. Full walkthrough + why: macOS Full Disk Access below. - Tailscale HTTPS — optional pretty URL. Remote access already works with
no setup via
http://<host>.<tailnet>.ts.net:4317/(the installer prints your actual URL) or an SSH tunnel (ssh -L 4318:localhost:4317 <user>@<host> -N, then openhttp://localhost:4318). For a tidyhttps://<host>/URL instead, enable MagicDNS + HTTPS Certificates once in the Tailscale admin console, then runtailscale serve --https=443 http://localhost:4317.
Quick start (from source)
git clone https://github.com/idl3/claude-control.git
cd claude-control
npm install
npm run build # builds the web UI (web/dist)
npm start # prints the URLOpen the printed URL (e.g. http://127.0.0.1:4317/). If a token is configured,
the app prompts for it on first load and remembers it in your browser — it's
never put in the URL. Any Claude Code session running in tmux shows up in the
left rail.
Already have tmux running with Claude sessions? You're done — just run
npm startand they appear automatically.
The tmux setup (the one requirement)
claude-control manages sessions through tmux: it lists tmux windows, finds the ones running Claude Code, and sends your replies as keystrokes to the right pane. So your Claude sessions need to live in tmux.
A) You already use tmux
Nothing to do. claude-control reads your default tmux server (the same one
tmux ls shows). Start it and your sessions appear. To point at a non-default
tmux binary, set CLAUDE_CONTROL_TMUX=/path/to/tmux.
B) You don't use tmux yet
Install it and run Claude inside a tmux session so claude-control can see it:
# macOS: brew install tmux · Debian/Ubuntu: sudo apt install tmux
tmux new -s work # start (or attach) a tmux session
claude # run Claude Code inside it — now it's discoverableThat's it. Open more windows (Ctrl-b c) and run more Claude sessions; each
becomes a row in claude-control. (Tip: detach with Ctrl-b d — the sessions
keep running and stay visible in claude-control.)
A session is recognized when its pane is running Claude Code or has a
matching transcript under ~/.claude/projects/.
macOS Full Disk Access
If panes show Operation not permitted when reading ~/Documents,
~/Desktop, or ~/Downloads — even though the same commands work in your normal
terminal — it's macOS privacy protection (TCC), not a bug. claude-control runs
as a launchd service, and the tmux server it starts inherits that context,
which has no Full Disk Access. Your terminal app (iTerm/Terminal) already has
the grant, which is why it works there.
Fix — grant Full Disk Access to the node that runs the service:
- Find the node path the service uses:
(e.g.grep -A2 ProgramArguments ~/Library/LaunchAgents/com.*claude-control*.plist~/.nvm/versions/node/vXX/bin/node, orwhich node→/opt/homebrew/bin/node) - System Settings → Privacy & Security → Full Disk Access →
+. In the file picker press ⌘⇧G, paste that node path (the~/.nvmdir is hidden, so the typed path is the only way in), add it, and toggle it on. - Restart the service so node relaunches with the grant:
launchctl kickstart -k gui/$(id -u)/com.<your-service-name> - Kill the stale (permission-less) tmux server so new panes start under the
granted node — this ends the claude-control tmux sessions; the service recreates
them:
tmux kill-server
Verify in a fresh pane: ls ~/Documents should work (no Operation not permitted).
Grant it to your own node path, not someone else's.
Updating & restarting
How you update depends on how you installed — pick your row. Check your
current version any time with claude-control --version.
npm installdoes NOT pull the git repo. The npm package ships the app prebuilt (theweb/distbundle is included), so there's no source tree togit pulland nothing to build. Update by reinstalling the package.
Installed globally (npm install -g)
npm install -g @idl3/claude-control@latest # fetch the new version
# then restart the server (see "Restarting" below)The in-app update banner / “Update now” button is for source checkouts
only (it runs git pull); on an npm install it has no repo to update, so use
the command above instead.
Run via npx (no install)
npx @idl3/claude-control@latest # always fetches the latestnpx re-resolves the package each run, so you're already on the newest version
every time you start it — just restart the process.
From source (git checkout)
git pull && npm install && npm run build # then restart…or click Update now in the app: the server pulls from origin, reinstalls,
rebuilds web/dist, and restarts itself in place; the page reconnects
automatically.
Restarting the server
- Foreground (you ran
claude-control/npm startin a terminal): pressCtrl-C, then run it again. The web UI reconnects on its own. - launchd service (you ran
claude-control install-service):
orlaunchctl kickstart -k gui/$(id -u)/com.ernest.claude-controlclaude-control uninstall-service && claude-control install-service.
Restarting is safe — sessions live in tmux, so nothing is lost; each browser re-prompts for the token once (if one is set).
Version numbers follow npm semver (claude-control --version).
Configuration
All optional. Only CLAUDE_CONTROL_* is read — the pre-rename legacy env-var aliases were removed (hard break).
| Env | Default | Purpose |
|---|---|---|
| CLAUDE_CONTROL_PORT | 4317 | HTTP/WS port |
| CLAUDE_CONTROL_HOST | 127.0.0.1 | Bind address |
| CLAUDE_CONTROL_TOKEN | (none) | Access token. Also read from ~/.claude-control/token. Sent as Authorization: Bearer (HTTP) / WS subprotocol — never in the URL. Unset and no file ⇒ tokenless. |
| CLAUDE_CONTROL_PROJECTS | ~/.claude/projects | Where Claude Code transcripts live |
| CLAUDE_CONTROL_UPLOADS | ~/.claude-control/uploads | Where attachments are stored (TTL-swept) |
| CLAUDE_CONTROL_TMUX | (auto) | tmux binary override |
| CLAUDE_CONTROL_MAX_UPLOAD_MB | 25 | Per-file upload cap |
Security
- Binds
127.0.0.1by default; cross-origin WebSocket upgrades are rejected. - Token auth — strongly recommended before exposing it (e.g. via
tailscale serve): this UI can type into your live sessions. The token is resolved in order fromCLAUDE_CONTROL_TOKEN, else the file~/.claude-control/token(mode0600). With neither set it runs tokenless (open to anything that can reach the port — the127.0.0.1bind, tailnet ACL, and cross-origin check are the only guards).- The web app prompts for the token on first load and stores it in
localStorage. It's sent as anAuthorization: Bearerheader (and a WS subprotocol) — never placed in the URL (URLs leak via history, server logs, and referrer headers). A401returns you to the prompt. - Set or rotate: write the token to
~/.claude-control/token, then restart —launchctl kickstart -k gui/$(id -u)/com.ernest.claude-control(launchd service), or just re-runnpm start/claude-control. Each browser re-prompts once.bin/install-service.shreads the same file.
- The web app prompts for the token on first load and stores it in
- Uploads are written
0600under the uploads dir and swept after a TTL.
Inline media in transcripts (for control-session agents)
Agent responses can embed screenshots and screen recordings directly in the chat transcript with self-closing blocks:
<embedded-image url="shot.png" size="lg" />
<embedded-video url="runs/demo.webm" size="full" />url— either a path relative to the media root (~/.claude-control/media/, override withCLAUDE_CONTROL_MEDIA), served by the token-gated/api/media/route, or a fullhttp(s)URL passed through as-is.file://and every other scheme are rejected.size—sm(240px) ·md(420px, default) ·lg(640px) ·full(bubble width). Missing/unknown sizes fall back tomd.
Convention for control-session agents: for SPA/UI changes (or any visual result), always capture screenshots and a short video into the media root and emit the embed blocks in your response, so the operator sees the change inline in the transcript without navigating to files.
How it works
- Discovery — polls
tmux list-windowsevery few seconds and matches each window to the newest transcript for its cwd (lib/sessions.js). - Transcript — tails each subscribed session's
*.jsonl(bounded reads) and streams appends over WebSocket (lib/transcript.js). - Input — replies and answers are sent with
tmux send-keysto the exact pane (lib/tmux.js); attachments upload to the uploads dir and their path is appended to the message for Claude to read.
Development
npm run dev # server with --watch
cd web && npm run dev # Vite dev server for the UI
npm test # node:test unit testsLicense
MIT
