osc-terminal-bridge
v0.3.0
Published
Run interactive CLI agents (claude / codex / copilot / a shell) on this machine and drive them from the osc app — a token-authed sidecar over node-pty + tmux. Add a remote machine to your bridges with one command.
Maintainers
Readme
osc-terminal-bridge
Run an interactive CLI agent — Claude Code / Codex / Copilot / Cursor — from the chatbot UI (or a plain browser tab). Keystrokes flow in, the live TUI streams back. Add this machine to the app with one command:
npx osc-terminal-bridge --token <your-secret>browser (xterm.js) ──WebSocket──► node-pty ──► tmux session ──► claude / codex / copilot
▲ keystrokes / resize durable + multi-attach
└──────────────── ANSI bytes ◄────────────────────────────┘Two front-ends, one sidecar:
- In the chatbot UI — the
Chat · Terminalswitch in a chat swaps the body to a live terminal: one shell session per chat, opened in that chat's workspace (the same dir the chat's bash/readFile tools use, uploads included), with one-click launch chips for Claude / Codex / Copilot / Cursor. Config comes from/api/terminal/config. - Standalone page —
http://<host>:7681/serves a self-contained xterm page with per-agent buttons (handy for quick tests or phone access).
It's a sidecar, not a Next route: Next handlers can't hold a long-lived WebSocket or own a PTY. Local backend only — there's no Vercel-prod version (no long-lived WS, no PTY), the same boundary the app uses for background/bash.
Prerequisites
- Node — uses
node-pty(ships prebuilt; only needs a C/C++ toolchain if it falls back to building — see Troubleshooting). - tmux — required for session resume / durability. Without it every
connect spawns a fresh agent and disconnect kills it.
brew install tmux(macOS) orapt install tmux(Linux). The bridge detects tmux once at startup, so install it before starting (or restart the bridge after).
Quick start — add this machine to your bridges
1. Run the bridge (one line)
npx osc-terminal-bridge --token <your-secret> # local URL (tunnel it yourself)
npx osc-terminal-bridge --token <your-secret> --tunnel # ALSO opens a public https:// URLIt installs, listens on port 7681, and prints the URL + Token to add to the app.
Re-running on the same box reuses the token (it's remembered in ~/.osc-terminal-bridge.token).
--tunnel— if the app is hosted / not on this box, this opens a publichttps://URL automatically (via cloudflared, else ngrok) and prints that — no separate tunnel step. The URL is ephemeral (changes each run), so it's for quick connects; a permanent box wants a stable URL (a named cloudflared tunnel / reserved ngrok domain). Keep the process running. Needscloudflared(brew install cloudflared) orngrokinstalled.
Requirements: Node ≥ 18, plus tmux for durable/resumable sessions (brew install tmux /
apt install tmux — install it before starting). Options: --port <p>, --host <h> (default
127.0.0.1; use 0.0.0.0 for LAN), --tunnel, --help. To run from a checkout instead:
cd terminal-bridge && npm install && npm start.
2. Add it in the app's /bridges page
Open the app → Bridges (sidebar) → Add bridge, and paste the URL and Token the
command printed. The app stores the token encrypted, shows the box online, and new chats can run
on it (or set it as your ★ default). No app restart, no .env.local editing.
| How the app reaches this box | URL to add | Start the bridge with |
|---|---|---|
| App on the same machine | http://localhost:7681 | default (127.0.0.1) |
| Another device on your LAN | http://<lan-ip>:7681 | --host 0.0.0.0 |
| A hosted app (over https) | the tunnel's https://… URL | default + tunnel it (step 3) |
A hosted/TLS app can only reach a public https/wss bridge, so it must be tunneled
(step 3); a raw http://localhost only works when the app runs on the same box or LAN.
The legacy
TERMINAL_BRIDGE_URL/TERMINAL_BRIDGE_TOKENenv vars still work as an optional bootstrap, but the/bridgesregistry above is the primary way to add boxes now.
3. ngrok — remote access (optional)
Tunnel the bridge so a remote browser can reach it, picking the region nearest you. Each keystroke is a round-trip, so a far region adds 200–400 ms of typing lag:
ngrok http 7681 --region ap # ap / eu / us / au / jp / in / sa — choose closest
# -> https://abc123.ngrok-free.appThen either:
- Standalone page: open
https://abc123.ngrok-free.app/?token=<TB_TOKEN>. - In-chat terminal from a remote browser: set
TERMINAL_BRIDGE_URL=wss://abc123.ngrok-free.app(+ matching token) in.env.localand restartpnpm dev.
Free ngrok domains change every restart — update the env line and restart
pnpm deveach session, or reserve a domain. Add--basic-auth "user:pass"for a second lock.
Resume / durability (tmux)
With tmux active, the per-chat session is named tb-<chatId> and uses
tmux new-session -A (attach-or-create):
- Refresh / switch chats / re-open Terminal → re-attaches the same live session — scrollback intact, and whatever agent you launched still running.
- Disconnecting only detaches the viewer — the session keeps running in the background.
- Watch/drive from your own terminal:
tmux attach -t tb-<chatId>. /quit-ing an agent drops the pane back to the shell — hit a launch chip (or type the command) to start another.- Hide tmux's status bar if you don't want it:
tmux set -g status off.
Live attach to any session — /ws?attach= (0.3.0)
Besides the per-chat tb-<chatId> flow, the WS can attach to an existing
session by exact name — any tmux session on the box (not just tb-*) or a GNU
screen session. This backs the app's Live fleet view, which lists every
running session/agent on your boxes and opens any of them live. Attach-only:
it never creates a session and never changes its options.
/ws?attach=tmux:<session-name> # tmux attach-session -t =<name> (exact match)
/ws?attach=screen:<pid>.<name> # screen -x <id> || screen -r <id>- tmux needs no extra software. screen attach needs
screeninstalled (caps.screen). A missing target closes with 4404, a bad target 4400, screen-unavailable 4501 — so the client shows a reason instead of retrying. - Disconnecting detaches the viewer; the session (tmux server / screen
backend) keeps running, same as the
tb-flow.
Feature detection. GET /control/health advertises what this bridge supports
so the app can gate its UI (and never send attach= to an old bridge that would
ignore it):
{ "ok": true, "tmux": true, "dataDir": "…", "hostname": "…",
"version": "0.3.0",
"caps": { "rawAttach": true, "screen": true, "listPanes": true } }A bridge with no caps field is ≤ 0.2.0 — treat raw attach as unavailable
and update it (npm i -g osc-terminal-bridge@latest, or just re-run
npx osc-terminal-bridge@latest). Existing tb-* sessions still attach on old
bridges via the per-chat flow.
Config reference
Bridge env vars (passed to npm start)
| Var | Default | Purpose |
|---|---|---|
| PORT / HOST | 7681 / 127.0.0.1 | where the sidecar listens (HOST=0.0.0.0 for LAN) |
| TB_TOKEN | (none) | shared secret; set before exposing. Must equal the app's TERMINAL_BRIDGE_TOKEN |
| TB_CWD | $HOME | working dir the agent starts in |
| TB_CMD_CLAUDE / TB_CMD_CODEX / TB_CMD_COPILOT / TB_CMD_CURSOR | claude / codex / copilot / agent | per-agent command (e.g. gh copilot; Cursor CLI is agent, override → cursor-agent) |
| TB_USE_TMUX | 1 | 0 bypasses tmux (no durability) |
| TB_SESSION_PREFIX | tb- | tmux session name prefix |
| TB_DATA_DIR | $HOME/.osc-sandboxes | per-chat workspace root for remote-spawned workers; the control API remaps a new-session start-dir here by chatId |
Chatbot app env vars (project-root .env.local)
| Var | Purpose |
|---|---|
| TERMINAL_BRIDGE_URL | WS base the browser connects to (table above). Unset → the app guesses ws://<app-host>:7681 |
| TERMINAL_BRIDGE_TOKEN | Must equal the bridge's TB_TOKEN. Handed only to signed-in users via /api/terminal/config, never shipped in the bundle |
| REMOTE_NODE_URL / REMOTE_NODE_TOKEN | Point the orchestrator's control plane at this box's /control/* API (the https:// origin + TB_TOKEN). Set both → chats spawn/read/tell/stop here. See below + docs/remote-nodes.md |
Control API — remote orchestration (v1a + v1b)
Besides the live-view WebSocket (/ws), the bridge exposes a small token-authed
HTTP surface so the app can drive an orchestrator's worker agents on THIS box (not
the app's host) — the "node agent" half of the remote-nodes design (see
docs/remote-nodes.md). Every request needs Authorization: Bearer <TB_TOKEN>
(when TB_TOKEN is set).
| Method + path | Body | Purpose |
|---|---|---|
| GET /control/health | — | { ok, tmux, dataDir, hostname, version, caps } — liveness + the box's TB_DATA_DIR, its hostname, this bridge's version, and caps (feature flags, see below) |
| POST /control/tmux | { "args": string[] } | run ONE tmux command → { stdout, ok } (the terminal seam) |
| GET /control/envs | ?dir= (optional) | this box's conda envs → { environments: [{name,value}] } — backs the app's env picker |
| POST /control/exec | { cmd, args?, cwd?, timeoutMs?, detached?, logPath? } | run a command → { exitCode, stdout, stderr }, or detached → { pid } (the sandbox runCommand) |
| POST /control/fs | { op: read\|readBuffer\|write\|mkdir\|exists\|stat\|rm\|readdir\|realpath, path, … } | filesystem ops on the box; bytes are base64 (the sandbox fs.* — uploads, skills, file-browser) |
/control/tmux is the single seam: the app builds the tmux args (spawn / capture /
send-keys / kill / list) and this runs them. It accepts only the verbs the app uses
(new-session, has-session, capture-pane, send-keys, kill-session,
list-sessions, list-panes, set-option) and remaps a new-session start-dir
under TB_DATA_DIR. Point the app at it with REMOTE_NODE_URL (the https://
origin) + REMOTE_NODE_TOKEN (= TB_TOKEN).
/control/exec + /control/fs back the RemoteNodeSandbox (v1b): the chat's
own bash/readFile/writeFile and uploaded files run on this box too, sharing
one workspace (TB_DATA_DIR/<chatId>) with the spawned workers.
Smoke test:
curl -s -X POST "$URL/control/tmux" -H "authorization: Bearer $TB_TOKEN" \
-d '{"args":["new-session","-d","-s","tb-smoke","--","bash","-lc","echo hi; exec bash -l"]}'
curl -s -X POST "$URL/control/tmux" -H "authorization: Bearer $TB_TOKEN" \
-d '{"args":["capture-pane","-t","tb-smoke","-p"]}' # → shows "hi"Security ⚠️
A full interactive shell on the host, plus agents that run arbitrary code (RCE by design):
- Never expose without
TB_TOKEN. Prefer keeping it on127.0.0.1and reaching it through the authenticated app, or via an SSH tunnel. - Treat the ngrok URL + token as secrets; add
--basic-authon ngrok. - The
/control/*API is the same RCE surface over HTTP (it runs LLM-driven tmux/shell on this box). Same rule: strongTB_TOKEN, TLS only, a box you trust. - Local backend only — no Vercel-prod version (
isLocalBackend()boundary).
Troubleshooting
- Every click starts a NEW session / nothing resumes — tmux isn't active.
Install tmux and restart the bridge (detected only at startup); confirm
tmux=truein the log. - In-chat: "Couldn't reach the terminal bridge at ws://…" — wrong/missing
TERMINAL_BRIDGE_URL(the browser can't reach that address) or it fell back to a guess. Set it per the URL table and restartpnpm dev. If it instead connects then immediately drops, the token doesn't match (TB_TOKEN≠TERMINAL_BRIDGE_TOKEN). - Typing lag (200–400 ms) — a network round-trip per keystroke. Use a nearer
ngrok
--region, or stay local (ws://localhost).TCP_NODELAYis already enabled in the bridge. posix_spawnp failedon macOS — node-pty's prebuiltspawn-helperlost its+xbit;npm run postinstall(auto-run on install) restores it.EACCES … ~/.npm/_cacache— borked global npm cache (often a pastsudo npm). Install with a scratch cache:npm install --cache /tmp/tb-npm-cache.
If node-pty won't build
Needs Python 3 + a C/C++ toolchain (Xcode CLT on macOS, build-essential on
Linux). Or use the prebuilt drop-in node-pty-prebuilt-multiarch and change the
import in server.mjs.
Files
Sidecar (terminal-bridge/):
server.mjs— HTTP + WS sidecar; spawns agents via node-pty (+ tmux).public/index.html— standalone xterm page.smoke.mjs— headless PTY round-trip test (npm run smoke, needs a running bridge).durtest.mjs— headless resume test (npm run durtest, needs a running bridge + tmux).scripts/fix-pty-perms.mjs— postinstall chmod for node-pty'sspawn-helper.
In the app:
components/chat/terminal-view.tsx— xterm client (connects to the sidecar).components/chat/chat-mode-selector.tsx— theChat · Terminalswitch.lib/terminal-bridge.ts— shared mode type,AGENT_LAUNCHERS+ URL/session helpers.app/(app)/(chat)/api/terminal/config/route.ts— auth-gated{ url, token, cwd }(cwd = the chat's workspace dir).components/chat/shell.tsx— holds the per-chatmodeand swaps body ⇄ terminal.
