collin/browser-terminal-extension
RenderedSource
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:
| Chrome | chrome://extensions → Developer mode → Load unpacked → dist/chrome |
| Firefox | about: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:
| Normal | Status text plus icons |
| Compact | Icons only, tighter padding — about one extra terminal row |
| Hide header | No 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.
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:
| Glyph | Meaning |
|---|---|
amber, cycling · ✢ ✳ ∗ ✻ ✽ | working |
blue ✳, pulsing | waiting on you |
green ✻, pulsing | done, and you haven't looked yet |
grey ✻ | idle at the prompt |
faded · | Claude is there, but no hooks are installed for it |
| nothing | no 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 rename a
window, change a layout, or send a key.
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 state | What the key does |
|---|---|
| Closed | Opens it |
| Open, focus is on the page | Focuses the terminal |
| Open, focus is in the terminal | Closes 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.
Picking elements off the page
Two ways to start it, and the difference matters:
| Alt+Shift+P | Always works. Rebindable at chrome://extensions/shortcuts or about:addons → gear → Manage Extension Shortcuts |
| Crosshair button in the header | Convenient, 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:
- The element's box is cropped out of a screenshot of the tab and put on the system clipboard as a PNG.
- 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.
- 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.
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,*.localhostand127.0.0.1on both schemes. Match patterns carry no port, so:5173,:3000and 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
| Attacker | Defense |
|---|---|
| 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.1 | Host header must be loopback |
| Another user on the machine | Token in a 0600 file, 0700 dir; refuses to load if the mode loosens |
| The network | Binds 127.0.0.1 only, not configurable |
| Token brute-force | Constant-time compare, lockout after repeated failures |
| Firefox HTTPS-Only Mode breaking the connection | Serves TLS and plaintext on one port; no browser setting has to be weakened |
| A page injecting shell commands via the element picker | Picked 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 it | The 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
storageandsidePanel. No host permissions, no content scripts, noweb_accessible_resources, noexternally_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 is0600and refused if the mode loosens, and the certificate is a leaf withCA: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
idofx'; rm -rf ~; 'or anaria-labelcontaining 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.insertnever 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 noweb_accessible_resources. - The picker returns its result as the
executeScriptreturn value, not viaruntime.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 reachexecvpas 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:
| Config | Realm | Files |
|---|---|---|
extension/jsconfig.json | sidebar document | sidebar.js, picker.js, lib/ |
extension/jsconfig.sw.json | service worker | sw.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_permissionsor CSPconnect-srcis still unverified. - No packaging: there's no distributable build, though
termbridge installnow covers starting the daemon (socket activation on Linux, a login agent on macOS).