Your real local shell — zsh, bash, whatever $SHELL points to — running inside a browser side panel. xterm.js on the inside, a tiny Node PTY bridge on the outside. Open the panel, type commands, reload the page next to it.
Think VS Code's integrated terminal, but in the browser you're actually building the thing for. Multi-tab, auto-reconnect, no page-content access.
Ships with a Chrome (MV3) extension today; Firefox is planned and will slot in alongside.
npm install && npm run build:xterm && npm start
# then load extensions/chrome/ as unpacked in chrome://extensions┌──────────── browser ────────────┐ ┌────── your machine ──────┐
│ ┌── page ───┐ ┌─ side panel ┐ │ │ │
│ │ │ │ xterm.js │◀┼─WS────▶│ bridge/server.js │
│ │ render │ │ terminal │ │ :7681 │ ws + node-pty → zsh │
│ └───────────┘ └─────────────┘ │ │ │
└─────────────────────────────────┘ └──────────────────────────┘
Browser extensions (Chrome MV3, and similarly Firefox) can't spawn processes — the sandbox forbids it. So the extension can't spawn("zsh") directly. The pragmatic workaround is a tiny local process that owns the PTY and speaks WebSocket: the extension is the renderer, the bridge is the shell host. Bridge binds to 127.0.0.1 only, so nothing is exposed off-machine.
bridge/server.js # ~60 lines: ws + node-pty, one pty per connection
extensions/
chrome/ # MV3 Chrome extension
manifest.json # uses side_panel + tabs + storage
background.js # opens the side panel on toolbar click
sidepanel.html / .css / .js # xterm.js UI + reconnect / reload-tab / settings
vendor/ # xterm.js assets (generated by build:xterm)
icon.png # placeholder 1×1; swap for a real one
# firefox/ # planned — same UI, MV2/MV3-compatible shim
scripts/
copy-xterm.js # vendors xterm from node_modules into each extension
fix-node-pty-perms.js # postinstall: restore +x on spawn-helper (see below)
package.json
npm install # installs node-pty + ws, vendors nothing yet
npm run build:xterm # copies xterm.js + addons into extensions/chrome/vendor/
npm start # starts bridge on ws://127.0.0.1:7681Then load the Chrome extension:
- Chrome →
chrome://extensions - Enable Developer mode (top-right).
- Load unpacked → select the
extensions/chrome/folder. - Pin the icon. Click it → side panel opens with a live shell.
| Control | What it does |
|---|---|
| status pill | online / connecting / offline — WebSocket state of the active tab |
| tab strip | Click a tab to switch, × to close, + to open a new one |
↻ tab |
Reloads the browser's active tab — the edit-in-terminal / see-it-render loop |
⟳ |
Force-reconnect the active session to the bridge |
⚙ |
Change the bridge URL (persisted in chrome.storage.local) |
Keyboard shortcuts (on the side panel):
⌘/Ctrl + T— new tab⌘/Ctrl + W— close active tab⌘/Ctrl + 1..9— switch to tab 1..9
Other niceties:
- Each tab is an independent PTY (the bridge already spawns one per WebSocket connection).
- Tab label follows the PTY title (OSC 0/2 escape), so
ssh host/vim fileretitle the tab automatically. - Clicking URLs in the terminal opens them in a browser tab (
WebLinksAddon). - Resizing the panel resizes the active PTY (
FitAddon+ResizeObserver→ control frame). - Auto-reconnect with a 2s backoff if the bridge goes away; tab count is remembered across reopens.
WebSocket, text frames. The extension uses a small in-band control channel so we don't need a second socket for resize.
- Client → server
- Stdin: plain text (UTF-8 bytes as strings).
- Control frame: first byte
0x01, then JSON.{"type":"resize","cols":N,"rows":N}
- Server → client
- Raw stdout bytes from the PTY.
CLI_COMPANION_PORT(default7681) — change the bridge port, then update the URL in ⚙ inside the side panel. The legacyCHROME_CLI_PORTis still honored as a fallback.SHELL— whatever's in your env; override it if you want bash on a zsh box.
- Dropbox strips
+xfromnode-pty'sspawn-helper, which makespty.fork()die withposix_spawnp failed.scripts/fix-node-pty-perms.jsruns as apostinstalland restores the bit. If you ever see that error again, re-runnpm install. Same thing applies to iCloud Drive and OneDrive. - MV3 can't load scripts from a CDN, so xterm has to be vendored into the extension.
npm run build:xtermdoes this —extensions/*/vendor/is gitignored; regenerate on fresh clones. - Resize control frames need branching on text-vs-binary, not on
raw[0]—wsgives you a Buffer either way but the framing type matters. Fixed inbridge/server.js; verified end-to-end (resize to 132×40 →tput colsreturns 132).
The pieces here aren't new — the packaging is.
- ttyd (11.5k ⭐) — the grandparent of this space. A C server that exposes a local shell over WebSocket and serves its own web UI at
localhost:7681. Same architectural shape asbridge/server.js, minus the extension. If you want a browser terminal and don't care about side-panel integration, use ttyd. - ysk2014/webshell, wrfly/container-web-tty, and friends — node-pty + xterm.js + a web page, usually container-scoped.
- Chrome's old Secure Shell (hterm/nassh) from Google — SSH client in a Chrome app, not a local shell.
- VS Code's integrated terminal — does exactly this pattern, but inside an editor, not a browser extension.
What's different here: it's an MV3 side-panel extension, not a full-tab web app. The terminal lives next to the page you're working on, the ↻ tab button reloads that page, and tabs inside the panel are independent PTYs. Small, focused, and opinionated toward the edit-in-shell / see-it-render loop rather than general terminal-in-browser.
- Bridge listens on
127.0.0.1only; no external exposure. - Any process on your machine that can open a loopback socket can talk to the bridge. That's fine for a dev tool; if you want paranoia, add a shared-secret handshake on connect.
- MV3 extension only holds
sidePanel,tabs,storage, and host permission forws://127.0.0.1/*+ws://localhost/*. No page content access.
- Firefox extension under
extensions/firefox/— same xterm.js UI, adapted manifest. companion-reload/companion-open <url>CLIs — the bridge reads markers on the PTY's stdout and forwards actions back to the extension over the same WebSocket.companion-screenshot— usechrome.tabs.captureVisibleTabfrom the panel and dump a PNG to stdout.- Persist scrollback in
chrome.storage.localacross side-panel reopens.
Small, focused project. PRs welcome — especially for the Firefox port. The layout is intentionally set up so a new extension is just a sibling directory under extensions/ and one more entry in scripts/copy-xterm.js.
MIT — see LICENSE.
