npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

osc-terminal-bridge

v0.6.2

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.

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 · Terminal switch 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 pagehttp://<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) or apt 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:// URL

It 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 public https:// URL automatically 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.

    Choosing the provider--tunnel takes an optional argument. Bare --tunnel is auto, which tries cloudflared first and only falls back to ngrok if cloudflared fails; on a box with both installed, auto always lands on cloudflared, so name the one you want:

    npx osc-terminal-bridge --token <t> --tunnel              # auto: cloudflared, else ngrok
    npx osc-terminal-bridge --token <t> --tunnel ngrok        # force ngrok       (alias: ng)
    npx osc-terminal-bridge --token <t> --tunnel cloudflared  # force cloudflared (aliases: cloudflare, cf)
    TB_TUNNEL=ngrok npx osc-terminal-bridge --token <t>       # same, via env

    --tunnel=ngrok works too. A pinned provider never falls back — if it isn't installed or can't start, you get that provider's error instead of a surprise URL from the other one. Needs the chosen provider installed (brew install cloudflared / brew install ngrok).

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 [provider], --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_TOKEN env vars still work as an optional bootstrap, but the /bridges registry above is the primary way to add boxes now.

3. Tunnel it — remote access (optional)

Only needed to cross a network or an https origin; same-machine use needs none. Either provider works — pick by what you need:

| | cloudflared | ngrok | |---|---|---| | Account / setup | none for quick tunnels | authtoken required, once | | URL | random per run | random, or reserved domain (paid) | | Region control | automatic (nearest edge) | explicit --region | | Concurrent agents | unlimited | one on the free plan | | Install | brew install cloudflared | brew install ngrok |

A. Let the bridge run it

npx osc-terminal-bridge --token <t> --tunnel cloudflared   # aliases: cloudflare, cf
npx osc-terminal-bridge --token <t> --tunnel ngrok         # alias: ng
npx osc-terminal-bridge --token <t> --tunnel               # auto: cloudflared, else ngrok

One command, and the printed box already carries the public URL. The URL is ephemeral — new every run, so you re-paste it into /bridges each time. See Tunnel provider for precedence and pinning.

B. Run the tunnel yourself

--tunnel hardcodes its arguments, so provider-specific flags — a reserved domain, a custom config, a region — mean running it yourself:

# cloudflared — start the bridge WITHOUT --tunnel (no adoption; see below)
cloudflared tunnel --url http://localhost:7681      # -> https://abc123.trycloudflare.com
npx osc-terminal-bridge --token <t>

# ngrok — your flags, and `--tunnel ngrok` will ADOPT this tunnel rather than add one
ngrok http 7681 --url=<your>.ngrok.app --config=./ngrok.yml --region=ap
npx osc-terminal-bridge --token <t> --port 7681 --tunnel ngrok

Only ngrok can be adopted — it has a local :4040 inspector to ask. cloudflared quick tunnels register nowhere, so running one and passing --tunnel cloudflared yields two tunnels with two URLs (you get a warning, not adoption). Details in Preflight.

Then point something at the resulting host:

  • Standalone page: open https://<your-tunnel-host>/?token=<TB_TOKEN>.
  • In-chat terminal from a remote browser: set TERMINAL_BRIDGE_URL=wss://<your-tunnel-host> (+ matching token) in .env.local and restart pnpm dev.

Latency: each keystroke is a round-trip, so a far edge adds 200–400 ms of typing lag. ngrok exposes the choice (--region ap / eu / us / au / jp / in / sa); cloudflared picks its nearest edge automatically. If one feels laggy, try the other.

Shutdown: a tunnel the bridge started dies with it — Ctrl-C (SIGINT), SIGTERM and SIGHUP (closing the terminal) all close it. kill -9 cannot, and a tunnel you started yourself is never touched; both leave it running with a public hostname still aimed at the port.

Free URLs on both providers change every restart — update the env line and restart pnpm dev each session, or get a stable one (a named cloudflared tunnel / a reserved ngrok domain). ngrok can add --basic-auth "user:pass" as 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 screen installed (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.


Theme follow — {"type":"theme"} (0.4.0)

The app's light/dark toggle sends a control frame over the WS (text frames are control JSON, like resize):

{ "type": "theme", "mode": "light" }

On the per-chat spawn path the bridge re-styles the tb-* session's tmux status bar to match (status-style; dark restores tmux's stock green). Sent on connect and on every toggle; the bar is session-global, so with several viewers the last toggler wins. Raw attaches (/ws?attach=) never restyle — a foreign session's options are never touched. Older bridges parse and ignore the frame, so no version gate is needed.


Truecolor (0.4.2)

TUIs decide their colour depth once at launch from the environment, so a 24-bit brand colour only survives if the app sees a truecolor signal. Two things make that happen:

  • The pty is spawned with COLORTERM=truecolor (PTY_ENV), which the tmux client reports — tmux ≥3.2 then auto-enables RGB passthrough to the viewer.
  • Inside tmux, that client COLORTERM is not copied into the session's pane (it isn't in update-environment, and a reused durable server's global env predates it), so the pane's shell also exports it — the one place tmux can't strip it. Without this, an app launched in the pane (e.g. typing claude) detects no truecolor and downsamples its RGB to ANSI red.

Only NEW sessions are affected — an already-running one keeps the depth it chose at launch until it's respawned. A box on tmux <3.2 (no COLORTERM auto-detect) additionally needs set -ga terminal-overrides ",*:Tc" in its tmux.conf so tmux forwards the RGB the app now emits.


Tunnel provider — --tunnel [provider] (0.4.3)

--tunnel used to try cloudflared and fall back to ngrok only if cloudflared failed. On a box with both installed that fallback never fires, so ngrok was unreachable — there was no way to ask for it. The flag now takes an optional provider:

| Form | Provider | |---|---| | --tunnel | auto — cloudflared, else ngrok (unchanged from ≤0.4.2) | | --tunnel ngrok / --tunnel=ng | ngrok | | --tunnel cloudflared / =cloudflare / =cf | cloudflared | | TB_TUNNEL=ngrok | same, via env — an explicit flag value beats it |

Precedence is explicit value → TB_TUNNELauto; TB_TUNNEL=false|0|no|off disables the tunnel, since env vars carry boolean-ish values in practice.

A pinned provider never falls back. Asking for ngrok and silently getting a cloudflared URL would defeat the point of asking, so you get ngrok's own error instead — with a bare ENOENT rewritten to ngrok not found on PATH (brew install ngrok), that being the usual cause. An unknown provider exits 1 rather than quietly starting something that works.

The argument is positional and optional, so --tunnel --port 9000 still reads as auto: a following arg starting with - is the next flag, not a provider.

Also fixed here: the ngrok path read the first https entry from ngrok's :4040 inspector, but that API lists tunnels from every ngrok process on the box — so an unrelated ngrok running alongside would have had its URL printed as the bridge's. It now matches config.addr against this bridge's port.

Preflight — an already-running tunnel (0.4.4)

Running a tunnel yourself and then also passing --tunnel is an easy mistake, and the two providers failed at it differently. --tunnel now checks first:

ngrok is checked properly, because :4040 is a real answer. If an agent is already tunnelling this bridge's port, it is adopted — no second process is spawned, and the box says so:

  │  REUSING the ngrok tunnel already running on this box — it already
  │  points at :7681, so a second one would just be a second URL.
  │  This bridge didn't start it, so Ctrl-C here will NOT close it.

Adoption is deliberate rather than accidental. Previously the spawned second agent would be refused (free ngrok allows ONE session) while the poll raced to read the first agent's tunnel and report it as ours — a URL the bridge thought it owned and would try to kill. An adopted tunnel carries no child process, so shutdown leaves it alone. If an agent is up but on a different port, that's a warning instead: the session limit still applies, so the spawn may fail.

cloudflared gets a best-effort hint only, and the asymmetry is the point: quick tunnels register nowhere local — no inspector, no state file — so there is nothing to adopt and no URL to read back. A process-table scan can only say "something that looks like cloudflared is already pointed at this port", so it warns and continues rather than blocking:

note: cloudflared already appears to be tunnelling :7681. Starting a SECOND tunnel —
      you'll get a different public URL, and the existing one stays open after this
      bridge stops. Ctrl-C it first if you meant to reuse it.

That second sentence is the real hazard: your own tunnel outlives the bridge, leaving a public hostname aimed at the port — which a later pnpm dev will happily bind again. Both URLs are gated only by TB_TOKEN.

Shutdown — what actually closes a spawned tunnel (0.4.4)

A tunnel the bridge started is a child process, killed on the way out:

| Stopping the bridge with | Its spawned tunnel | |---|---| | Ctrl-C (SIGINT) | closed | | SIGTERM | closed | | SIGHUP — closing the terminal window | closed | | kill -9 (SIGKILL) | survives — nothing can run on SIGKILL |

SIGHUP was added here because it's a normal way to stop this: without a listener node takes the OS default and dies without running exit handlers, orphaning a public tunnel with nothing left holding a reference to close it. An adopted tunnel is deliberately exempt from all of the above — it carries a null child, so shutdown never touches what the bridge didn't start.


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 | | TB_TUNNEL | (none) | tunnel provider when --tunnel is used: auto / cloudflared / ngrok (also off to disable). An explicit --tunnel <provider> beats it |

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) | | GET /control/jupyter | — | running local Jupyter servers → { servers: [{port, baseUrl}] } (see JupyterLab passthrough below) | | POST /control/jupyter/grant | — | one-time, 60s browser grant → { grant, ttlMs } — how the app opens the lab without the token riding a URL |

/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"

JupyterLab passthrough (0.6.0)

If this box runs a JupyterLab (say, a platform-managed one at --ServerApp.base_url=/notebook/<ns>/<pod>/ with auth disabled), the bridge can serve it through its own tunnel — no second tunnel, no Jupyter credentials:

  • DiscoveryGET /control/jupyter reads the jpserver-*.json runtime files (what jupyter server list reads), falls back to the process table, and drops anything that fails a local liveness probe. Advertised as caps.jupyterProxy. Root-mounted servers (base_url=/) are out of scope — they'd swallow the bridge's own routes. Since 0.6.1 each server also reports its root_dir, so the app can deep-link straight to a notebook (…/lab/tree/<path>).
  • Passthrough — any path under a discovered base_url pipes to 127.0.0.1:<port>; WebSocket upgrades (kernels) are raw TCP splices, so subprotocols and binary frames pass untouched. Path-preserving: no URL rewriting exists anywhere.
  • Gate — browser traffic needs a session cookie, minted by GET /jupyter/open?grant=…&next=… from a one-time grant the app fetched over /control/jupyter/grant (Bearer). next must land inside a discovered base_url (no open redirect); the cookie is a random id, never TB_TOKEN. With no TB_TOKEN set the passthrough is open, like everything else here.

In the app this surfaces as the Jupyter chip in a chat terminal's header (shown only when this box reports a live server).


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 on 127.0.0.1 and reaching it through the authenticated app, or via an SSH tunnel.
  • Treat the tunnel URL + token as secrets, whichever provider minted it — a trycloudflare.com URL is as public as an ngrok one. ngrok can add --basic-auth; cloudflared wants Cloudflare Access for the equivalent.
  • The /control/* API is the same RCE surface over HTTP (it runs LLM-driven tmux/shell on this box). Same rule: strong TB_TOKEN, TLS only, a box you trust.
  • The JupyterLab passthrough exposes a (typically auth-disabled) Jupyter — also RCE — through the tunnel. Its grant/cookie gate rides TB_TOKEN: set one, and never widen the passthrough beyond discovered base_urls.
  • 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=true in 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 restart pnpm dev. If it instead connects then immediately drops, the token doesn't match (TB_TOKENTERMINAL_BRIDGE_TOKEN).
  • Typing lag (200–400 ms) — a network round-trip per keystroke. Use a nearer ngrok --region, switch provider (cloudflared and ngrok route over different networks, so try --tunnel ngrok against --tunnel cloudflared), or stay local (ws://localhost). TCP_NODELAY is already enabled in the bridge.
  • posix_spawnp failed on macOS — node-pty's prebuilt spawn-helper lost its +x bit; npm run postinstall (auto-run on install) restores it.
  • EACCES … ~/.npm/_cacache — borked global npm cache (often a past sudo 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/):

  • cli.mjs — the launcher: token, --tunnel, the connect box.
  • server.mjs — HTTP + WS sidecar; spawns agents via node-pty (+ tmux).
  • jupyter.mjs — pure routing/guard logic for the JupyterLab passthrough (unit-tested from the app repo, the cwd.mjs pattern).
  • public/index.html — standalone xterm page.
  • test/npm test: the CLI's tunnel behaviour (argument parsing, provider routing, preflight/adoption, shutdown signals). Self-contained — stub cloudflared/ngrok shadow the real ones on PATH and bridge ports are OS-assigned, so no public tunnel is ever opened. The ngrok cases fake the inspector on :4040, which the CLI hardcodes; if a real agent is holding that port they skip rather than test against a live tunnel, so run them with ngrok stopped for full coverage. Filter with npm test -- <substring>. Not shipped in the npm tarball.
  • 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's spawn-helper.

In the app:

  • components/chat/terminal-view.tsx — xterm client (connects to the sidecar).
  • components/chat/chat-mode-selector.tsx — the Chat · Terminal switch.
  • 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-chat mode and swaps body ⇄ terminal.