anvilsign in

collin / browser-terminal-extension

tmux-aware terminal emulator in browser sidebar

1 branch0 tags

Clone
git clone git@anvil.richardscollin.com:collin/browser-terminal-extension.gitCopied!

· commits

TODO.md10 open

  1. be able to talk to claude about anything on the page
  2. remote host tmux session
  3. Idea: pull off a tab out side of the browser and it puts it within your default terminal emulator
  4. TODO: get working well on unconfigured tmux
  5. TODO: investigate chrome wterm and vercel wterm
  6. +6 more

terminal

A tmux sidebar for Chrome and Firefox.

The extension is called terminal; the daemon it talks to is termbridge. They are deliberately separate names — the daemon is a browser-neutral CLI with its own config directory, and keeping it stable means renaming the extension never touches ~/.config/termbridge/, the token, or the certificate.

sidebar (xterm.js)                        daemon (Rust)
  │                                          │
  │  ws://127.0.0.1:7681                     │
  │  ─── {"type":"auth","token":"…"} ──────▶ │  origin + host + token
  │  ◀── {"type":"ok"} ───────────────────── │
  │  ─── {"type":"open","cols":80,…} ──────▶ │  spawn pty
  │  ═══ binary frames (raw bytes) ════════▶ │  ──▶ tmux new-session -A -s default
  │  ◀══ binary frames (raw bytes) ═════════ │
  │  ─── {"type":"resize","cols":…} ───────▶ │  TIOCSWINSZ

The daemon runs a PTY. tmux inside it does all multiplexing and, crucially, all persistence — close the sidebar, restart the browser, reattach and everything is where you left it.

Setup

cd daemon && cargo build --release
./build.sh                      # produces dist/chrome and dist/firefox

Load the extension:

Chromechrome://extensions → Developer mode → Load unpacked → dist/chrome
Firefoxabout:debugging#/runtime/this-firefox → Load Temporary Add-on → dist/firefox/manifest.json

Open the sidebar, hit ⚙, and it shows you the command to run. Then:

termbridge pair chrome-extension://<the id it showed you>
termbridge token          # paste this into the sidebar
termbridge serve

Firefox needs one extra step. HTTPS-Only Mode silently rewrites ws:// to wss://, so the daemon serves TLS and plaintext on the same port, choosing per connection by sniffing the first byte. The certificate is self-signed, so trust it once: open https://127.0.0.1:7681/ and accept the warning (the sidebar has a button for this). Chrome uses plaintext and skips the step entirely.

Verify you're trusting the right certificate — termbridge cert prints the SHA-256 the browser will show you.

Pairing is per-browser. Firefox's moz-extension:// origin is a random UUID regenerated on each temporary install, so you'll re-pair each time until the add-on is signed.

Starting the daemon on demand

Remembering to run termbridge serve before opening the sidebar is the worst part of the setup above. Hand it to the service manager instead:

termbridge install        # then never think about it again

On Linux that is socket activation, not an always-on service. systemd binds 127.0.0.1:7681 at login and holds it; the daemon is only exec'd when the sidebar actually connects, inheriting the already-bound socket as fd 3. Fifteen minutes after the last client disconnects the daemon exits again, and the next connection starts a fresh one. So at rest there is no process, only a socket.

That is safe only because tmux, not the daemon, is the persistence layer. Exiting drops no session state, which is the same property that lets you close the sidebar and reattach later. If tmux is unavailable and the daemon falls back to a plain shell, do not install with an idle timeout — the fallback shell dies with the daemon.

termbridge install --idle-timeout 0     # stay resident once started
termbridge install --port 7999 --session work
termbridge reload                       # restart the daemon after a rebuild
termbridge uninstall
systemctl --user status termbridge.service
journalctl --user -u termbridge.service -f

Updating the daemon

The unit points at the binary by absolute path, so a rebuild is picked up by the next activation — but only once the daemon holding the old code goes away:

cargo build --release
termbridge reload                           # socket keeps listening

The next sidebar connection starts the new binary. Nothing else is needed, and if you can wait out the idle timeout you don't even need the reload.

reload is systemctl --user daemon-reload, stop termbridge.service, restart termbridge.socket and an import-environment PATH, in that order, and nothing else — it never rewrites a unit, so a port, idle timeout or pinned session you set at install time survives it. On macOS it is launchctl kickstart -k against the login agent. Either way tmux is not involved: the sessions and everything running in them belong to the tmux server, so all a reload costs is the moment the sidebar takes to reconnect.

If you changed anything the unit encodes (port, idle timeout, session, or where the binary lives), re-run termbridge install instead. It rewrites both units, stops the running daemon, and restarts the socket, in that order. The order is the whole trick: enable --now is a no-op on an already-active socket, so a reinstall without the restart leaves the old configuration listening while systemd logs "Unit configuration changed while unit was running ... Unit not functional until restarted" and the port quietly stops accepting. And the socket cannot be restarted before the daemon is stopped, because the daemon is still holding the port.

systemctl stop prints "Stopping termbridge.service, but its triggering units are still active" every time. That is systemd describing socket activation back to you, not a problem.

The units land in ~/.config/systemd/user/, and re-running install overwrites them, so edit freely and expect to lose it on upgrade. install also runs systemctl --user import-environment PATH, because a user service otherwise inherits the manager's PATH rather than your shell's, and the usual symptom is the daemon reporting "tmux not found" while tmux works fine in every terminal you have open.

On macOS install writes a launchd agent that runs at login and stays resident. launchd can do socket activation too, but only through launch_activate_socket(3), so the on-demand half is Linux-only for now.

Nothing about this changes the protocol: termbridge serve by hand still works and still binds its own socket. --systemd-socket is what switches it to the inherited one, and it's an error rather than a fallback if no socket arrives — binding a second port would leave the sidebar talking to a daemon nobody dialed.

Theme

The ◐ button in the header cycles follow system → light → dark, and the ⚙ panel has the same setting. auto tracks prefers-color-scheme live, so it follows the OS without a reconnect.

Both palettes live in extension/lib/theme.js as the single source of truth: the ui block becomes CSS custom properties on :root, the xterm block goes to term.options.theme. Chrome and terminal cannot drift apart.

Header size

⚙ → Appearance has a header density control:

NormalStatus text plus icons
CompactIcons only, tighter padding — about one extra terminal row
Hide headerNo header; hover the top edge of the panel to bring it back

Changing it refits the terminal and sends the new size to the pty, so tmux reflows immediately.

Note this only covers our header. The bar above it — extension name, close ✕, panel switcher — is browser chrome. Chrome's side panel and Firefox's sidebar both render it and neither exposes any way for an extension to remove or restyle it.

Choosing a tmux session

By default the sidebar joins the tmux session you already have running, when there is exactly one — no point starting a second session beside the only one you are using. With none, or with several, it attaches to a session called default and creates it if needed. The check happens per connection, so it reflects what tmux holds when the sidebar connects, not when the daemon started.

To pin a specific session and skip that guessing entirely:

termbridge serve --session my-existing-work

Or pick it live: the header's top row is one tab per session on the server. Clicking one runs switch-client on the sidebar's own tmux client — the WebSocket stays up, no second pty is spawned, and whatever is running in the session you left keeps running. + names a new one inline (new-session -A, so an existing name attaches instead of failing), and the ⚙ panel's tmux session field does the same from settings.

A session tab carries the number of windows behind it, and in its favicon slot the loudest thing Claude Code is doing anywhere inside it — a Claude waiting on you in a session you are not looking at still gets a light. That is what the daemon's whole-server status frames are for.

The selected tab is the session you are actually on, not the one you asked for, so a switch-client, choose-tree or prefix-(/) typed in the terminal moves it too.

Both names are validated server-side — see the security notes below.

Window tabs

The header's second row is a browser-style tab bar nested under the session tabs: one tab per window of the selected session — the same windows prefix 2 selects and the tmux status line lists. Clicking one runs select-window, and + runs new-window. Neither touches the connection: the pty, the session and everything running in it stay exactly as they were.

Selecting a window deliberately moves every client watching that session, not just the sidebar — a window belongs to the session, so this behaves the same as pressing prefix-2 in your terminal, and the tab bar tracks what you do there.

The active tab is drawn in the terminal's own background so the two read as one surface. The dot in its favicon slot is what Claude Code is doing in that window — amber and cycling for working, blue for waiting on you, green for finished with something you have not read — and stays empty for a window that is just a shell, rather than lighting up a status indicator with no status to report. A background window that has produced output since you last looked wears tmux's activity flag as a bolder name.

A new window needs no name (tmux names it after what it runs), so + is one click with nothing to fill in.

The ✕ closes a window (kill-window) on the first click, like a browser tab. Unlike a browser tab there is no undo — it kills whatever was running in that window — so it goes red under the pointer, and the log records what went.

It only appears on the window you are on and the one you are pointing at, and never on a session's last window: that would take the session and the sidebar's own client with it, which is not a tab close.

Right-clicking a tab offers Pin, Select and Close window.

Pinning works like a browser's: the tab moves to the head of the strip and shrinks to its dot and index, and loses its ✕ so it can't be closed by a mis-aimed click. What it does not do is touch tmux. Nothing is renumbered, no move-window is sent, and your terminal's status line doesn't change — the window keeps its real index, which is why the index is the thing a pinned tab keeps showing: prefix 3 still selects it, pinned or not. The gain is purely that a window you care about stays visible when the strip overflows, at about a quarter of the width.

Pins live in extension storage, keyed by session name, and are dropped when the window they point at closes. They are per-browser-profile, not shared with anyone else attached to the session.

Pinning a session to a browser tab

Right-clicking a session or a window tab also offers to pin it to the browser tab you are on. After that, switching to that browser tab switches the terminal: the tab you keep the app in brings up the session you run the app from, and the docs tab beside it brings back whatever you had there.

This is a different thing from the Pin in the paragraph above, which is about where a tab sits in the strip. This one is about which browser tab brings it up. They are independent, and a window can have both.

Nothing is focused when it does: the terminal moves underneath, and the caret stays on the page.

It is a loan, not a move

A pin borrows the terminal. Leaving for a browser tab with no pin of its own puts it back where it was before the pinned tab took it, so flicking between a pinned tab and an unpinned one flicks the terminal between two places rather than stranding it on the pinned one. Without that, a single glance at a pinned tab would relocate the terminal permanently.

Three rules keep that from being annoying:

  • Only the first pin in a run records a return. Pinned tab to pinned tab to unpinned goes back to where the run started, not to the middle of it — the middle was never somewhere you chose to be.
  • A pin that had nothing to do owes nothing. Landing on a tab pinned to where you already are records no return, so leaving it moves nothing.
  • A terminal you have since moved by hand is left alone. The return is an undo of a move this code made; once you have steered somewhere yourself there is nothing to undo, and dragging you back would be the panel overruling you.

Following also waits about a sixth of a second before it acts, so Ctrl-Tabbing through six tabs is one move at the end rather than six on the way.

A pin can key on two things:

  • the site, e.g. https://mdlab.localhost. Any tab on that origin matches, and the pin outlives the tab, the window and the browser, because an origin is a name. This is the one to reach for.
  • that exact browser tab, by the id Chrome gave it. Survives nothing, and exists for what an origin cannot express: two tabs on the same site pointing at different sessions, or a page whose URL says nothing.

A tab pin wins over a site pin, being the more specific statement.

Pinning the session rather than one of its windows is usually what you want: it means "this tab brings up that project, wherever I left it", and it keeps working when you close and reopen the window the dev server was in. A window pin whose window has since been killed falls back to its session rather than going quietly dead.

A small accent dot marks whichever row the tab on screen points at, so the panel moving on its own always has a visible reason. The tooltip says which rule answered.

And back the other way

The same pin also reads backwards, so moving the terminal brings the browser along: switch to that session and the tab you pinned to it comes up. Anything that moves the terminal counts — a click on a session tab in the panel, a prefix n typed into the pane, another client switching a session this one is watching — because by the time it reaches the panel it is one status frame either way.

This is the half that touches the browser, so it is deliberately timid, and it has its own checkbox (and switch tabs back) for turning off without giving up the forward direction:

  • It only ever activates a tab that is already open, in the panel's own browser window. It does not create tabs, does not focus the browser, does not raise a window and does not reach into another window. The worst it can do is show you a tab you already had.
  • It does nothing when the tab already showing satisfies the pin. That is also what stops the two directions chasing each other: the forward one will not move a terminal that is already where the tab points, and this one will not move a browser that is already on a tab pointing here, so whichever fires second finds its work done.
  • Two tabs can answer to one pin — two tabs on the pinned origin — and the tie goes first to the more specific rule and then to the one you looked at more recently.
  • It waits the same sixth of a second, so holding prefix n through six windows moves the browser once at the end rather than flicking it through five tabs on the way.
  • The loan being handed back does not count as a move. Leaving a pinned tab puts the terminal back where it was borrowed from, and if some third tab happens to be pinned to that place this stays out of it rather than chasing it and undoing the tab switch you just made by hand.

It reads the pins you already have rather than a second set of its own, which means a veto keeps vetoing and a tab pin keeps outranking a site pin from this end too. There is nothing extra to set up: pin a session to a tab and both directions light up together.

Guessing from the name

Under the explicit pins sits a guess, on by default and switchable in settings: a tab on a .localhost name that portless would hand to a project we have a session in is treated as pinned to that session, without anyone saying so.

portless takes the port out of the URL — a dev server started under it gets a random port and a stable https://<name>.localhost, where the name comes from package.json's name, else the git root's directory name, else the working directory's, lowercased into a DNS label. In a git worktree the branch goes in front: https://<branch>.<project>.localhost.

extension/lib/portless.js reimplements that naming and applies it to the working directories tmux already reports, so https://mdlab.localhost finds the session sitting in ~/Code/mdlab without a subprocess, without reading ~/.portless/routes.json, and without portless being installed or running. The price of matching names rather than looking them up is that a project whose package.json name is not its directory name is invisible to the guess — the panel cannot read package.json, and an explicit pin is the answer for those.

The guess is deliberately timid. .localhost only, which is reserved for this machine, and an ambiguous match — two sessions answering to one name — resolves to nothing rather than a coin toss. A worktree host wants the worktree's own session (a directory called <branch>, <project>-<branch> or <branch>-<project>) and will not settle for the checkout it forked from. Unpinning a guessed match records a veto against that origin, which is the only way to say "no, not this one" to a rule that would otherwise keep re-deriving itself.

Everything here is panel state: it lives in extension storage, it is per-browser-profile, and none of it reaches the daemon, which has never heard of a browser tab. It also only works while the panel is open — the panel is what watches the tabs.

How the daemon talks to tmux

The interactive client in the pty is busy being a terminal, so the daemon attaches a second client in control mode to use as a query and event channel:

tmux -C attach -t <session> -f read-only,ignore-size,no-output

Each flag is load-bearing. read-only means the channel can never send keystrokes to a pane. ignore-size stops an 80x24 control client from shrinking your windows to fit itself. no-output stops tmux streaming every byte every pane produces to a client with no use for it.

tmux pushes %client-session-changed, %sessions-changed, %window-renamed and friends, so the sidebar updates when something happens rather than on a timer, and no tmux process is spawned per refresh.

read-only governs keys, not commands, so the same channel carries the sidebar's requests. Those are a closed allowlist — switch to a session, create-and-switch, focus a pane, select a window, go to a window in another session, open a window, move a window, close a window, rename a session, set a session's colour — expressed as an enum, not a command string. The wire protocol cannot name a tmux command, and every argument is validated (valid_session_name, valid_pane_id, valid_window_id, valid_group_color) before it is quoted into a command line.

The colour is the one piece of the panel's own state kept on the server rather than in the browser. It goes in a tmux user option, @termbridge_color, set on the session and read back as one more field of the list-sessions format the status frame is already built from — so it costs no extra round trip. Keeping it there rather than in extension storage means it follows a session through a rename, every panel on the server agrees on it, and it dies with the session. The value is a hue in degrees or -1 for grey, and nothing else parses.

The channel also sets one option on each session the sidebar's client lands on. tmux's default is detach-on-destroy on: exit the last shell of a session and every client attached to it is detached too. For a terminal emulator that just closes the window, but here it is EOF on the pty, so the socket closes and the whole panel goes dead even though other sessions are still running. The daemon switches it to off, which moves the client to another session instead and only falls back to detaching when there is nothing left to show. Only tmux's own default is overridden — no-detached and previous are deliberate choices with the same effect, and are left alone.

Exactly one of them destroys anything, kill-window, and it can only ever name one window: a window id is @ plus digits, so -a (which would kill every window but the target) and session: targets do not parse. There is no kill-session and no kill-pane.

Claude Code status

The glyph on each window tab is what Claude Code is doing in that window. It is Claude Code's own asterisk spinner, so a window that is thinking looks in the tab strip the way it looks in the pane:

GlyphMeaning
amber, cycling · ✢ ✳ ∗ ✻ ✽working
blue ✳, pulsingwaiting on you
green ✻, pulsingdone, and you haven't looked yet
grey ✻idle at the prompt
faded ·Claude is there, but no hooks are installed for it
nothingno Claude in this window

One timer drives the whole strip and only runs while something is working, so an idle panel is not repainting forever. prefers-reduced-motion drops the pulse and parks the spinner on a single frame rather than dropping the indicator; the colours, which are what carry the meaning, stay.

Joined on the tmux window id, so a window shows the loudest state in it — waiting beats done, and both beat working. The tab's tooltip carries the detail the dot can't: the tool in flight, or what Claude is blocked on.

Action required

Green and grey are the same thing to Claude Code — a session sitting at its prompt. What separates them is whether that pane has been in front of you since it went quiet, which no hook can report: nothing in Claude's process knows which tmux pane a person is looking at. tmux does, so the daemon is where the two are put together.

The rules, in full:

  • Lit for any Claude at rest in a pane that is not on screen. On screen means the active pane, of the active window, of the session this daemon's client is attached to — a tab in the strip is not a pane on screen.
  • Cleared by looking. Selecting the window clears it within the second, and a pane you are already on never lights up in the first place.
  • Re-armed by the next turn. Sending a prompt puts the pane back to working, and the rest after that is news again.

It is a stamp per pane, not a flag: what is remembered is the updated time of the record you were shown, so any later hook event stops matching it and the pane goes back to unseen on its own. A pane id tmux recycles into a new pane can't inherit a stale mark for the same reason.

The state lives in the daemon, so it is shared by every panel on that tmux server and survives a panel reload — but not a daemon restart, after which everything at rest reads as ready once. The tmux status line (below) doesn't take part: it runs as its own short-lived process with no view of the daemon's memory, and shows plain idle.

There used to be a second row of per-pane chips under the header saying the same thing at more length. It was costing a terminal line to repeat what the tabs already show, so it's gone; the trade is that a window whose tab is scrolled out of a narrow panel no longer announces itself.

This comes from Claude Code's hook interface, not from reading the screen: Claude runs termbridge hook on UserPromptSubmit, PreToolUse, PostToolUse, Notification, Stop, SessionStart and SessionEnd, and each event's JSON tells us the state directly. Notification even distinguishes permission_prompt (blocked on you) from idle_prompt (just quiet).

Install it once:

termbridge hooks          # prints the block to merge into ~/.claude/settings.json

Each session's state lands in ~/.config/termbridge/agents/<session_id>.json. The pane a session belongs to comes from $TMUX_PANE, which Claude's process inherits and passes to the hook — that is the join between a Claude session and a tmux pane. permission_mode is carried forward across the events that omit it (Notification is the one that matters: the mode must not blink out exactly when you are being asked to approve something).

The same glyph in tmux's own status line

The sidebar reads its state over the daemon's control channel, which exists only while a browser is attached — exactly the case where you are not looking at the browser. So the terminal gets it from the other end: the hook already runs inside the pane it is reporting on, with $TMUX naming the right server, and it sets a window user option on the way out.

termbridge tmux           # prints the ~/.tmux.conf lines
set -g window-status-format         "#{@tb_claude}#I:#W#F"
set -g window-status-current-format "#{@tb_claude}#I:#W#F"

The option holds a styled glyph and a space, and is unset — expanding to nothing — for a window with no Claude in it, so windows that never see one look exactly as they do now. A window with several Claudes in it shows the loudest, the same rule the tabs use: list-panes on the hook's own pane is the join.

It advances one frame per hook event rather than on a timer. tmux only redraws its status when an option changes or status-interval elapses, so a true spinner would mean forcing a full status repaint on every attached client several times a second; stepping on events costs nothing and moves the glyph exactly when Claude crosses a tool boundary.

The only command this issues is set-option -w @tb_claude. It cannot change a layout or send a key.

Titles from the last prompt

Two panes running Claude in the same repo are indistinguishable by anything tmux knows about them: #{pane_current_command} is node in both and the cwd is the same. What tells them apart is what you asked each one to do, and UserPromptSubmit hands the hook exactly that.

So the hook flattens the prompt to one line — whitespace collapsed, control characters dropped, cut to 80 characters at a word — stores it on the session's record, and writes it to the pane title:

tmux select-pane -T "fix the flaky pty test"

Pane title rather than window name, deliberately. rename-window would turn automatic renaming off for that window for good and overwrite a name you or your shell chose; the pane title is a field almost nothing else uses, and a window that wants to follow it opts in through automatic-rename-format — which also means the active pane is what names the window, for free:

set -g automatic-rename-format \
  "#{?#{==:#{pane_title},#{host}},#{pane_current_command},#{=/40/…:pane_title}}"

Both fallbacks are load bearing. tmux seeds a pane's title with the hostname, so "has no title of its own" has to be tested for rather than assumed empty, and a pane that never ran Claude keeps the name tmux would have given it anyway. Substitute your own format for #{pane_current_command} if you had one.

select-pane -T expands its argument as a tmux format, so # in a prompt is doubled on the way in and a prompt containing #{...} or #[fg=red] is inert.

The title is only ever set, never cleared: the last prompt is still the most useful thing to say about a pane that just finished running it, and a shell that emits an OSC title on each prompt takes the pane back on its own. The same string is what the sidebar shows for a session that has no name yet — Claude only names a session once it has thought of one, and until then "what you asked" beats "idle".

Opening and focusing the terminal

Alt+Shift+T is one key for the whole cycle, and what it does depends on where the keyboard is:

Sidebar stateWhat the key does
ClosedOpens it
Open, focus is on the pageFocuses the terminal
Open, focus is in the terminalCloses it

So it is hold-to-glance from the page and press-twice to dismiss, without ever reaching for the mouse. Rebindable in the same place as the picker shortcut: chrome://extensions/shortcuts, or about:addons → gear → Manage Extension Shortcuts.

The sidebar has to be open for the browser to know its own state, which is why the panel keeps a connection to the background worker while it lives. That connection carries three commands (open, focus, close) and nothing else — no terminal traffic goes through it, for the reason in the header of sidebar.js.

Keys the browser takes first

Most browser shortcuts are cancelable: the page sees the keydown, calls preventDefault(), and the browser drops it. That is why Ctrl+P, Ctrl+F, Ctrl+S and friends reach the pty like any other key.

Three do not. Ctrl+N, Ctrl+T and Ctrl+W are reserved: the browser process acts on them before the keystroke is handed to the renderer, so the panel never receives an event and has nothing to cancel. Extension commands cannot claim them either. chrome://extensions/shortcuts refuses reserved combinations for the same reason. Nothing inside the extension can win them back.

So they are offered one modifier over, and only while the terminal has focus:

PressSent to the pty
Ctrl+Alt+N^N
Ctrl+Alt+T^T
Ctrl+Alt+W^W

The bytes are written straight to the socket, so tmux and readline see exactly what a real Ctrl+N would have produced.

If you want the real keycap. The browser cannot be talked out of Ctrl+N, but a remapper below it can make sure the browser never sees the key. Bind Ctrl+N to Ctrl+Alt+N and the panel picks it up as above:

  • xremap can do it per-application, so Ctrl+N still opens a window everywhere else. On GNOME/Wayland it needs the companion shell extension to know which window is focused; on X11 it does not.
  • keyd sits at evdev and is simpler to run, but it is system-wide: Ctrl+N stops meaning "new" in every other app too.

Both are outside this repo, and neither is required. Ctrl+Alt+N works on its own.

Picking elements off the page

Two ways to start it, and the difference matters:

Alt+Shift+PAlways works. Rebindable at chrome://extensions/shortcuts or about:addons → gear → Manage Extension Shortcuts
Crosshair button in the headerConvenient, but may fail — see below

Then: hover highlights the element under the cursor with its tag and size, click selects. Clicks are swallowed, so picking a link or a submit button doesn't navigate or submit.

To get out: Esc (from either the page or the sidebar), or press the crosshair again — it toggles. Esc in one half of a split view ends the whole pick, not just that half's overlay.

Split view. Chrome puts two tabs side by side but marks only one of them active, so aiming at "the active tab" always lands in whichever half last had focus — the other half looks dead. The picker instead runs in both halves at once (found via splitViewId, Chrome 140+) and takes the first click; the loser is torn down. The half you clicked is made active before the screenshot, because tabs.captureVisibleTab takes no tab id and shoots whatever is active. One caveat on the Alt+Shift+P path: the activeTab grant the shortcut mints covers the active half only, so a pick in the other half needs that origin granted (see below) — otherwise that half sits out and the active one still works. Firefox has no split view and no splitViewId; it takes the single-tab path.

Picking sends the result straight to the terminal, screenshot first:

  1. The element's box is cropped out of a screenshot of the tab and put on the system clipboard as a PNG.
  2. A literal ^V goes down the wire. That byte is not a paste in the panel — xterm.js never sees it — it reaches the program in the pty, and an agent that handles image paste (Claude Code does) reads the clipboard and attaches the PNG itself.
  3. The selector follows, control-character stripped and single-quoted, with no trailing newline. You press Enter yourself.

This works because the daemon runs on the same machine as the browser, so the clipboard the extension writes is the clipboard the agent reads. In a bare shell ^V is literal-next-character instead, so it is only sent when a screenshot was actually captured; if the capture fails, only the selector is inserted and the reason is logged.

Where the keyboard ends up. In the page, where you just clicked — the selector is typed into a terminal you have to click into before you can type there yourself. Moving the keyboard into the panel was tried and does not work: the only way to focus a side panel is for Chrome to open it, which means closing and reopening it (see Opening and focusing the terminal), and even driven from the pick click's own user activation Chrome reopens the panel without handing it the keyboard. All that bought was a flicker, so the pick leaves the focus where it finds it.

The panel still shows the element afterwards: CSS selector, XPath, id, test id, text, or href. copy puts the selected one on the clipboard, and insert re-types it (text only — the screenshot is already on the clipboard if you want it again).

Why the button can fail. Touching a page needs activeTab, which the browser grants only on certain user gestures — a toolbar click, a context menu, or a keyboard command. A click inside the side panel is not one of them, so the button falls back to standing host permissions. Those are:

  • localhost is granted up front — localhost, *.localhost and 127.0.0.1 on both schemes. Match patterns carry no port, so :5173, :3000 and everything else are covered.
  • Every other site is opt-in, one origin at a time. When the button hits a site it can't reach, it offers an Allow https://example.com/* button that triggers the browser's own permission prompt. Nothing is granted until you say so.

The shortcut needs none of this — it runs in the background worker, which is a gesture the browser does honour, and works on any page.

Why the button can pick but not screenshot. tabs.captureVisibleTab accepts exactly two things: the activeTab grant a keyboard command mints, or a host permission set containing the literal <all_urls> pattern. A per-origin grant is enough to read the element but not to capture it, so the button hands back a selector and no image even on a site you approved. When that happens the panel offers an Allow screenshots on all sites button, which requests <all_urls>; it is optional and revocable in the extension's settings, and Alt+Shift+P keeps capturing without it.

Picks made while the sidebar is closed are parked in storage.local and appear when you next open it.

Nothing is inserted automatically — see below for why that matters.

Security model

This daemon hands out shell access. It is sshd with a smaller feature set, and is treated that way.

Threats it stops

AttackerDefense
Any webpage you visit (WebSocket has no CORS — every page can reach 127.0.0.1)Origin allowlist; Origin is browser-set and unforgeable from page JS
evil.com rebound to 127.0.0.1Host header must be loopback
Another user on the machineToken in a 0600 file, 0700 dir; refuses to load if the mode loosens
The networkBinds 127.0.0.1 only, not configurable
Token brute-forceConstant-time compare, lockout after repeated failures
Firefox HTTPS-Only Mode breaking the connectionServes TLS and plaintext on one port; no browser setting has to be weakened
A page injecting shell commands via the element pickerPicked text is control-character stripped and single-quoted before it can reach the pty
A page reading the clipboard after the picker writes a screenshot to itThe write happens in the extension's isolated world; the page could already read its own clipboard, and the image is a picture of the page itself

Threats it does not stop

A process running as you can already read ~/.ssh, patch ~/.bashrc, and ptrace your browser. Same-uid isolation is not a thing, and pretending otherwise would be theatre.

Design rules

  • Token travels in a post-upgrade frame, never the URL — query strings leak into logs, crash dumps, and devtools history.
  • Pairing is explicit. No trust-on-first-use, no blanket chrome-extension:// prefix match (that would admit every other extension you have installed).
  • The extension requests storage and sidePanel. No host permissions, no content scripts, no web_accessible_resources, no externally_connectable — so there is no bridge from page content to the socket.
  • The sidebar owns the WebSocket directly. Terminal data never passes through runtime.sendMessage, which content scripts can reach.
  • The client cannot choose what runs. The protocol has no argv field, so an auth bypass yields the configured profile rather than arbitrary exec.
  • TLS changes no policy: origin, host and token are enforced identically over wss://. The private key is 0600 and refused if the mode loosens, and the certificate is a leaf with CA:FALSE — trusting it cannot be leveraged to vouch for any other host.
  • The element picker treats the page as hostile. A selector is built from attributes the page chose, so an id of x'; rm -rf ~; ' or an aria-label containing a newline is arbitrary code execution — a newline typed at a terminal is a pressed Enter. Two independent defenses, both required: strip every C0 control character, then POSIX single-quote. insert never appends a newline; you press Enter yourself. The UI says so when a value had to be modified.
  • The picker's standing page access is limited to loopback hosts, which are your own machine. Everything else is optional_host_permissions, granted per-origin through the browser's prompt and revocable in the extension's settings — never a blanket <all_urls> at install time. <all_urls> is offered as an optional permission too, because the screenshot API takes nothing narrower, but only when a capture has already failed and only behind an explicit button. Still no declared content scripts and no web_accessible_resources.
  • debugger is the one standing, non-revocable permission, Chrome only, and it exists for a single feature: the mobile device-emulation toggle next to the picker button. chrome.debugger plus the CDP Emulation domain is the only extension-facing way to override a tab's viewport, user agent and touch behavior together, and unlike host access it cannot be requested per-use through optional_permissions — Chrome requires it declared up front in manifest.chrome.json. Firefox has no equivalent API at all, so manifest.firefox.json never requests it and the button there just stays disabled. It attaches only to the tab you click the button on, and detaching (whether from a second click, the tab closing, or the user dismissing Chrome's own debugging infobar) reverts every override.
  • The picker returns its result as the executeScript return value, not via runtime.sendMessage — so the sidebar still has no inbound message listener that a content script could reach.
  • tmux session names are client-selectable but validated: no leading dash (tmux would read it as a flag), and [A-Za-z0-9_-] only. They reach execvp as a separate argv element, never a shell. An invalid name falls back to the default rather than erroring.
  • The HTTPS landing page exists only so the certificate-trust visit is comprehensible. It serves no data and, unlike the implementation we looked at, there is no endpoint anywhere that hands out the auth token.

Every one of those is covered by a test, and the suite is mutation-checked: reverting the origin check to "allow any origin" turns 5 tests red.

cd daemon && cargo test                      # 46
npm test                                     # 25
npm run check                                # types, see below

The sanitizer tests don't just assert on strings — they hand the quoted output to a real /bin/sh and check it comes back as one literal argument, including for x'; rm -rf ~; echo ' and harmless\nid.

The theme tests compute WCAG contrast ratios for all 16 ANSI slots against their own background, plus body text, muted text, buttons and selection. They already earned their keep: brightBlack landed at exactly 3.00:1 on the dark background — the slot most tools use for comments — and was corrected.

Types without a build step

The extension is plain .js that the browser loads exactly as it sits on disk — no bundler, no transpile, build.sh is a cp. That stays true. What was added is a type checker over it: JSDoc annotations plus npm run check, which runs tsc --noEmit and emits nothing. Editors pick the same config up automatically and give completion on the daemon's frames.

npm install     # one dev dependency: typescript
npm run check

Two projects, because the manifest creates two global scopes and both declare const api at the top level:

ConfigRealmFiles
extension/jsconfig.jsonsidebar documentsidebar.js, picker.js, lib/
extension/jsconfig.sw.jsonservice workersw.js, picker.js, lib/shot.js, lib/split.js

extension/types/globals.d.ts is hand-written rather than pulled from @types/chrome, so it doubles as the inventory of extension API surface this extension touches — adding a call means adding it there first, which is the same conversation as growing the manifest's permission list. It also carries the daemon's wire frames (TbOkFrame, TbStatusFrame, TbSessionInfo, TbAgent); the other half of those shapes is daemon/src/status.rs, and the two have to move together.

Nothing in node_modules/ reaches dist/.

Known gotchas

tmux resizes for everyone. Default window-size is latest, so attaching a narrow sidebar shrinks the same session in your real terminal. Verified: a 100x30 window became 40x20 on sidebar attach. Either give the sidebar its own session, or:

setw -g window-size manual

Firefox temporary add-ons get a new UUID per install, so re-pair after each reload.

Layout

daemon/src/paths.rs    token + paired-origin files, permission enforcement
daemon/src/auth.rs     origin / host / token checks
daemon/src/server.rs   listener, handshake, session pump
daemon/src/pty.rs      portable-pty backend
daemon/src/tls.rs      self-signed cert generation, rustls config
daemon/src/rewind.rs   replayable stream, so we can inspect the request head
daemon/src/activation.rs  taking the listening socket systemd passed us
daemon/src/install.rs  the systemd units / launchd agent `install` writes
daemon/tests/          security.rs (28), tls.rs (10), pty_e2e.rs (8),
                       activation.rs (5)
extension/picker.js    injected element picker (no privileges, runs in page)
extension/lib/         sanitize.js (page-text-to-shell boundary), theme.js
                       (light/dark palettes), split.js (running the picker in
                       both halves of a split view) — all three with tests;
                       shot.js (element screenshot crop, clipboard write)
extension/types/       hand-written ambients: extension API surface, the
                       daemon's wire frames, the vendored xterm build
extension/             sidebar, two manifests, vendored xterm.js

Not done yet

  • Firefox reaches the daemon (confirmed: HTTPS-Only Mode was rewriting the scheme, which is why TLS exists). Not yet confirmed end-to-end after trusting the certificate.
  • Chrome has not been loaded at all; whether MV3 needs anything in host_permissions or CSP connect-src is still unverified.
  • No packaging: there's no distributable build, though termbridge install now covers starting the daemon (socket activation on Linux, a login agent on macOS).