anvilsign in

collin/browser-terminal-extension

RenderedSource

1# terminal
2
3A tmux sidebar for Chrome and Firefox.
4
5The extension is called **terminal**; the daemon it talks to is `termbridge`.
6They are deliberately separate names — the daemon is a browser-neutral CLI with
7its own config directory, and keeping it stable means renaming the extension
8never touches `~/.config/termbridge/`, the token, or the certificate.
9
10```
11sidebar (xterm.js) daemon (Rust)
12 │ │
13 │ ws://127.0.0.1:7681 │
14 │ ─── {"type":"auth","token":"…"} ──────▶ │ origin + host + token
15 │ ◀── {"type":"ok"} ───────────────────── │
16 │ ─── {"type":"open","cols":80,…} ──────▶ │ spawn pty
17 │ ═══ binary frames (raw bytes) ════════▶ │ ──▶ tmux new-session -A -s default
18 │ ◀══ binary frames (raw bytes) ═════════ │
19 │ ─── {"type":"resize","cols":…} ───────▶ │ TIOCSWINSZ
20```
21
22The daemon runs a PTY. tmux inside it does all multiplexing and, crucially, all
23persistence — close the sidebar, restart the browser, reattach and everything is
24where you left it.
25
26## Setup
27
28```sh
29cd daemon && cargo build --release
30./build.sh # produces dist/chrome and dist/firefox
31```
32
33Load the extension:
34
35| | |
36|---|---|
37| Chrome | `chrome://extensions` → Developer mode → Load unpacked → `dist/chrome` |
38| Firefox | `about:debugging#/runtime/this-firefox` → Load Temporary Add-on → `dist/firefox/manifest.json` |
39
40Open the sidebar, hit ⚙, and it shows you the command to run. Then:
41
42```sh
43termbridge pair chrome-extension://<the id it showed you>
44termbridge token # paste this into the sidebar
45termbridge serve
46```
47
48**Firefox needs one extra step.** HTTPS-Only Mode silently rewrites `ws://` to
49`wss://`, so the daemon serves TLS and plaintext on the same port, choosing per
50connection by sniffing the first byte. The certificate is self-signed, so trust
51it once: open `https://127.0.0.1:7681/` and accept the warning (the sidebar has a
52button for this). Chrome uses plaintext and skips the step entirely.
53
54Verify you're trusting the right certificate — `termbridge cert` prints the
55SHA-256 the browser will show you.
56
57Pairing is per-browser. Firefox's `moz-extension://` origin is a random UUID
58regenerated on each temporary install, so you'll re-pair each time until the
59add-on is signed.
60
61## Starting the daemon on demand
62
63Remembering to run `termbridge serve` before opening the sidebar is the worst
64part of the setup above. Hand it to the service manager instead:
65
66```sh
67termbridge install # then never think about it again
68```
69
70On Linux that is socket activation, not an always-on service. systemd binds
71`127.0.0.1:7681` at login and holds it; the daemon is only exec'd when the
72sidebar actually connects, inheriting the already-bound socket as fd 3. Fifteen
73minutes after the last client disconnects the daemon exits again, and the next
74connection starts a fresh one. So at rest there is no process, only a socket.
75
76That is safe *only* because tmux, not the daemon, is the persistence layer.
77Exiting drops no session state, which is the same property that lets you close
78the sidebar and reattach later. If tmux is unavailable and the daemon falls back
79to a plain shell, do not install with an idle timeout — the fallback shell dies
80with the daemon.
81
82```sh
83termbridge install --idle-timeout 0 # stay resident once started
84termbridge install --port 7999 --session work
85termbridge reload # restart the daemon after a rebuild
86termbridge uninstall
87systemctl --user status termbridge.service
88journalctl --user -u termbridge.service -f
89```
90
91### Updating the daemon
92
93The unit points at the binary by absolute path, so a rebuild is picked up by the
94next activation — but only once the daemon holding the old code goes away:
95
96```sh
97cargo build --release
98termbridge reload # socket keeps listening
99```
100
101The next sidebar connection starts the new binary. Nothing else is needed, and
102if you can wait out the idle timeout you don't even need the reload.
103
104`reload` is `systemctl --user daemon-reload`, `stop termbridge.service`,
105`restart termbridge.socket` and an `import-environment PATH`, in that order, and
106nothing else — it never rewrites a unit, so a port, idle timeout or pinned
107session you set at install time survives it. On macOS it is `launchctl kickstart
108-k` against the login agent. Either way tmux is not involved: the sessions and
109everything running in them belong to the tmux server, so all a reload costs is
110the moment the sidebar takes to reconnect.
111
112If you changed anything the *unit* encodes (port, idle timeout, session, or
113where the binary lives), re-run `termbridge install` instead. It rewrites both
114units, stops the running daemon, and restarts the socket, in that order. The
115order is the whole trick: `enable --now` is a no-op on an already-active socket,
116so a reinstall without the restart leaves the old configuration listening while
117systemd logs "Unit configuration changed while unit was running ... Unit not
118functional until restarted" and the port quietly stops accepting. And the socket
119cannot be restarted before the daemon is stopped, because the daemon is still
120holding the port.
121
122`systemctl stop` prints "Stopping termbridge.service, but its triggering units
123are still active" every time. That is systemd describing socket activation back
124to you, not a problem.
125
126The units land in `~/.config/systemd/user/`, and re-running `install` overwrites
127them, so edit freely and expect to lose it on upgrade. `install` also runs
128`systemctl --user import-environment PATH`, because a user service otherwise
129inherits the manager's PATH rather than your shell's, and the usual symptom is
130the daemon reporting "tmux not found" while `tmux` works fine in every terminal
131you have open.
132
133On macOS `install` writes a launchd agent that runs at login and stays resident.
134launchd can do socket activation too, but only through
135`launch_activate_socket(3)`, so the on-demand half is Linux-only for now.
136
137Nothing about this changes the protocol: `termbridge serve` by hand still works
138and still binds its own socket. `--systemd-socket` is what switches it to the
139inherited one, and it's an error rather than a fallback if no socket arrives —
140binding a second port would leave the sidebar talking to a daemon nobody dialed.
141
142## Theme
143
144The **◐** button in the header cycles *follow system → light → dark*, and the ⚙
145panel has the same setting. `auto` tracks `prefers-color-scheme` live, so it
146follows the OS without a reconnect.
147
148Both palettes live in `extension/lib/theme.js` as the single source of truth:
149the `ui` block becomes CSS custom properties on `:root`, the `xterm` block goes
150to `term.options.theme`. Chrome and terminal cannot drift apart.
151
152## Header size
153
154⚙ → Appearance has a header density control:
155
156| | |
157|---|---|
158| Normal | Status text plus icons |
159| Compact | Icons only, tighter padding — about one extra terminal row |
160| Hide header | No header; hover the top edge of the panel to bring it back |
161
162Changing it refits the terminal and sends the new size to the pty, so tmux
163reflows immediately.
164
165Note this only covers *our* header. The bar above it — extension name, close ✕,
166panel switcher — is browser chrome. Chrome's side panel and Firefox's sidebar
167both render it and neither exposes any way for an extension to remove or restyle
168it.
169
170## Choosing a tmux session
171
172By default the sidebar joins the tmux session you already have running, when
173there is exactly one — no point starting a second session beside the only one
174you are using. With none, or with several, it attaches to a session called
175`default` and creates it if needed. The check happens per connection, so it
176reflects what tmux holds when the sidebar connects, not when the daemon
177started.
178
179To pin a specific session and skip that guessing entirely:
180
181```sh
182termbridge serve --session my-existing-work
183```
184
185Or pick it live: the header's top row is one tab per session on the server.
186Clicking one runs `switch-client` on the sidebar's own tmux client — the
187WebSocket stays up, no second pty is spawned, and whatever is running in the
188session you left keeps running. **+** names a new one inline (`new-session -A`,
189so an existing name attaches instead of failing), and the ⚙ panel's **tmux
190session** field does the same from settings.
191
192A session tab carries the number of windows behind it, and in its favicon slot
193the loudest thing Claude Code is doing anywhere inside it — a Claude waiting on
194you in a session you are not looking at still gets a light. That is what the
195daemon's whole-server status frames are for.
196
197The selected tab is the session you are *actually* on, not the one you asked
198for, so a `switch-client`, `choose-tree` or prefix-`(`/`)` typed in the terminal
199moves it too.
200
201Both names are validated server-side — see the security notes below.
202
203## Window tabs
204
205The header's second row is a browser-style tab bar nested under the session
206tabs: one tab per **window** of the selected session — the same windows
207`prefix 2` selects and the tmux status line lists. Clicking one runs
208`select-window`, and **+** runs `new-window`. Neither touches the connection:
209the pty, the session and everything running in it stay exactly as they were.
210
211Selecting a window deliberately moves *every* client watching that session, not
212just the sidebar — a window belongs to the session, so this behaves the same as
213pressing prefix-2 in your terminal, and the tab bar tracks what you do there.
214
215The active tab is drawn in the terminal's own background so the two read as one
216surface. The dot in its favicon slot is what Claude Code is doing in that
217window — amber and cycling for working, blue for waiting on you, green for
218finished with something you have not read — and stays
219empty for a window that is just a shell, rather than lighting up a status
220indicator with no status to report. A background window that has produced
221output since you last looked wears tmux's activity flag as a bolder name.
222
223A new window needs no name (tmux names it after what it runs), so **+** is one
224click with nothing to fill in.
225
226The **✕** closes a window (`kill-window`) on the first click, like a browser
227tab. Unlike a browser tab there is no undo — it kills whatever was running in
228that window — so it goes red under the pointer, and the log records what went.
229
230It only appears on the window you are on and the one you are pointing at, and
231never on a session's *last* window: that would take the session and the
232sidebar's own client with it, which is not a tab close.
233
234Right-clicking a tab offers **Pin**, **Select** and **Close window**.
235
236Pinning works like a browser's: the tab moves to the head of the strip and
237shrinks to its dot and index, and loses its ✕ so it can't be closed by a
238mis-aimed click. What it does *not* do is touch tmux. Nothing is renumbered, no
239`move-window` is sent, and your terminal's status line doesn't change — the
240window keeps its real index, which is why the index is the thing a pinned tab
241keeps showing: `prefix 3` still selects it, pinned or not. The gain is purely
242that a window you care about stays visible when the strip overflows, at about a
243quarter of the width.
244
245Pins live in extension storage, keyed by session name, and are dropped when the
246window they point at closes. They are per-browser-profile, not shared with
247anyone else attached to the session.
248
249## How the daemon talks to tmux
250
251The interactive client in the pty is busy being a terminal, so the daemon
252attaches a *second* client in [control
253mode](https://github.com/tmux/tmux/wiki/Control-Mode) to use as a query and
254event channel:
255
256```
257tmux -C attach -t <session> -f read-only,ignore-size,no-output
258```
259
260Each flag is load-bearing. `read-only` means the channel can never send
261keystrokes to a pane. `ignore-size` stops an 80x24 control client from shrinking
262your windows to fit itself. `no-output` stops tmux streaming every byte every
263pane produces to a client with no use for it.
264
265tmux pushes `%client-session-changed`, `%sessions-changed`, `%window-renamed`
266and friends, so the sidebar updates when something happens rather than on a
267timer, and no `tmux` process is spawned per refresh.
268
269`read-only` governs keys, not commands, so the same channel carries the
270sidebar's requests. Those are a closed allowlist — switch to a session,
271create-and-switch, focus a pane, select a window, go to a window in another
272session, open a window, move a window, close a window, rename a session, set a
273session's colour —
274expressed as an enum, not a command string. The wire protocol cannot name a
275tmux command, and every argument is validated (`valid_session_name`,
276`valid_pane_id`, `valid_window_id`, `valid_group_color`) before it is quoted
277into a command line.
278
279The colour is the one piece of the panel's own state kept on the server rather
280than in the browser. It goes in a tmux user option, `@termbridge_color`, set on
281the session and read back as one more field of the `list-sessions` format the
282status frame is already built from — so it costs no extra round trip. Keeping it
283there rather than in extension storage means it follows a session through a
284rename, every panel on the server agrees on it, and it dies with the session.
285The value is a hue in degrees or `-1` for grey, and nothing else parses.
286
287The channel also sets one option on each session the sidebar's client lands on.
288tmux's default is `detach-on-destroy on`: exit the last shell of a session and
289every client attached to it is detached too. For a terminal emulator that just
290closes the window, but here it is EOF on the pty, so the socket closes and the
291whole panel goes dead even though other sessions are still running. The daemon
292switches it to `off`, which moves the client to another session instead and only
293falls back to detaching when there is nothing left to show. Only tmux's own
294default is overridden — `no-detached` and `previous` are deliberate choices with
295the same effect, and are left alone.
296
297Exactly one of them destroys anything, `kill-window`, and it can only ever name
298one window: a window id is `@` plus digits, so `-a` (which would kill every
299window *but* the target) and `session:` targets do not parse. There is no
300kill-session and no kill-pane.
301
302## Claude Code status
303
304The glyph on each window tab is what Claude Code is doing in that window. It is
305Claude Code's own asterisk spinner, so a window that is thinking looks in the
306tab strip the way it looks in the pane:
307
308| Glyph | Meaning |
309|---|---|
310| amber, cycling `· ✢ ✳ ∗ ✻ ✽` | working |
311| blue `✳`, pulsing | waiting on you |
312| green `✻`, pulsing | done, and you haven't looked yet |
313| grey `✻` | idle at the prompt |
314| faded `·` | Claude is there, but no hooks are installed for it |
315| nothing | no Claude in this window |
316
317One timer drives the whole strip and only runs while something is working, so
318an idle panel is not repainting forever. `prefers-reduced-motion` drops the
319pulse and parks the spinner on a single frame rather than dropping the
320indicator; the colours, which are what carry the meaning, stay.
321
322Joined on the tmux window id, so a window shows the loudest state in it —
323waiting beats done, and both beat working. The tab's tooltip carries the detail
324the dot can't: the tool in flight, or what Claude is blocked on.
325
326### Action required
327
328Green and grey are the same thing to Claude Code — a session sitting at its
329prompt. What separates them is whether that pane has been in front of you since
330it went quiet, which no hook can report: nothing in Claude's process knows which
331tmux pane a person is looking at. tmux does, so the daemon is where the two are
332put together.
333
334The rules, in full:
335
336- **Lit** for any Claude at rest in a pane that is not on screen. On screen
337 means the active pane, of the active window, of the session this daemon's
338 client is attached to — a tab in the strip is not a pane on screen.
339- **Cleared by looking.** Selecting the window clears it within the second, and
340 a pane you are already on never lights up in the first place.
341- **Re-armed by the next turn.** Sending a prompt puts the pane back to
342 `working`, and the rest after *that* is news again.
343
344It is a stamp per pane, not a flag: what is remembered is the `updated` time of
345the record you were shown, so any later hook event stops matching it and the
346pane goes back to unseen on its own. A pane id tmux recycles into a new pane
347can't inherit a stale mark for the same reason.
348
349The state lives in the daemon, so it is shared by every panel on that tmux
350server and survives a panel reload — but not a daemon restart, after which
351everything at rest reads as ready once. The tmux status line (below) doesn't
352take part: it runs as its own short-lived process with no view of the daemon's
353memory, and shows plain `idle`.
354
355There used to be a second row of per-pane chips under the header saying the
356same thing at more length. It was costing a terminal line to repeat what the
357tabs already show, so it's gone; the trade is that a window whose tab is
358scrolled out of a narrow panel no longer announces itself.
359
360This comes from [Claude Code's hook
361interface](https://code.claude.com/docs/en/hooks), not from reading the screen:
362Claude runs `termbridge hook` on `UserPromptSubmit`, `PreToolUse`,
363`PostToolUse`, `Notification`, `Stop`, `SessionStart` and `SessionEnd`, and each
364event's JSON tells us the state directly. `Notification` even distinguishes
365`permission_prompt` (blocked on you) from `idle_prompt` (just quiet).
366
367Install it once:
368
369```sh
370termbridge hooks # prints the block to merge into ~/.claude/settings.json
371```
372
373Each session's state lands in `~/.config/termbridge/agents/<session_id>.json`.
374The pane a session belongs to comes from `$TMUX_PANE`, which Claude's process
375inherits and passes to the hook — that is the join between a Claude session and
376a tmux pane. `permission_mode` is carried forward across the events that omit it
377(`Notification` is the one that matters: the mode must not blink out exactly
378when you are being asked to approve something).
379
380### The same glyph in tmux's own status line
381
382The sidebar reads its state over the daemon's control channel, which exists
383only while a browser is attached — exactly the case where you are not looking
384at the browser. So the terminal gets it from the other end: the hook already
385runs inside the pane it is reporting on, with `$TMUX` naming the right server,
386and it sets a window user option on the way out.
387
388```sh
389termbridge tmux # prints the ~/.tmux.conf lines
390```
391
392```tmux
393set -g window-status-format "#{@tb_claude}#I:#W#F"
394set -g window-status-current-format "#{@tb_claude}#I:#W#F"
395```
396
397The option holds a styled glyph and a space, and is unset — expanding to
398nothing — for a window with no Claude in it, so windows that never see one look
399exactly as they do now. A window with several Claudes in it shows the loudest,
400the same rule the tabs use: `list-panes` on the hook's own pane is the join.
401
402It advances one frame per hook event rather than on a timer. tmux only redraws
403its status when an option changes or `status-interval` elapses, so a true
404spinner would mean forcing a full status repaint on every attached client
405several times a second; stepping on events costs nothing and moves the glyph
406exactly when Claude crosses a tool boundary.
407
408The only command this issues is `set-option -w @tb_claude`. It cannot rename a
409window, change a layout, or send a key.
410
411## Opening and focusing the terminal
412
413**Alt+Shift+T** is one key for the whole cycle, and what it does depends on
414where the keyboard is:
415
416| Sidebar state | What the key does |
417|---|---|
418| Closed | Opens it |
419| Open, focus is on the page | Focuses the terminal |
420| Open, focus is in the terminal | Closes it |
421
422So it is hold-to-glance from the page and press-twice to dismiss, without ever
423reaching for the mouse. Rebindable in the same place as the picker shortcut:
424`chrome://extensions/shortcuts`, or `about:addons` → gear → Manage Extension
425Shortcuts.
426
427The sidebar has to be open for the browser to know its own state, which is why
428the panel keeps a connection to the background worker while it lives. That
429connection carries three commands (open, focus, close) and nothing else — no
430terminal traffic goes through it, for the reason in the header of `sidebar.js`.
431
432## Picking elements off the page
433
434Two ways to start it, and the difference matters:
435
436| | |
437|---|---|
438| **Alt+Shift+P** | Always works. Rebindable at `chrome://extensions/shortcuts` or `about:addons` → gear → Manage Extension Shortcuts |
439| Crosshair button in the header | Convenient, but may fail — see below |
440
441Then: hover highlights the element under the cursor with its tag and size, click
442selects. Clicks are swallowed, so picking a link or a submit button doesn't
443navigate or submit.
444
445To get out: **Esc** (from either the page or the sidebar), or press the
446crosshair again — it toggles. Esc in one half of a split view ends the whole
447pick, not just that half's overlay.
448
449**Split view.** Chrome puts two tabs side by side but marks only one of them
450`active`, so aiming at "the active tab" always lands in whichever half last had
451focus — the other half looks dead. The picker instead runs in *both* halves at
452once (found via `splitViewId`, Chrome 140+) and takes the first click; the loser
453is torn down. The half you clicked is made active before the screenshot,
454because `tabs.captureVisibleTab` takes no tab id and shoots whatever is active.
455One caveat on the **Alt+Shift+P** path: the `activeTab` grant the shortcut mints
456covers the active half only, so a pick in the *other* half needs that origin
457granted (see below) — otherwise that half sits out and the active one still
458works. Firefox has no split view and no `splitViewId`; it takes the single-tab
459path.
460
461Picking sends the result straight to the terminal, screenshot first:
462
4631. The element's box is cropped out of a screenshot of the tab and put on the
464 system clipboard as a PNG.
4652. A literal **^V** goes down the wire. That byte is not a paste in the panel —
466 xterm.js never sees it — it reaches the program in the pty, and an agent that
467 handles image paste (Claude Code does) reads the clipboard and attaches the
468 PNG itself.
4693. The selector follows, control-character stripped and single-quoted, with no
470 trailing newline. You press Enter yourself.
471
472This works because the daemon runs on the same machine as the browser, so the
473clipboard the extension writes is the clipboard the agent reads. In a bare shell
474^V is literal-next-character instead, so it is only sent when a screenshot was
475actually captured; if the capture fails, only the selector is inserted and the
476reason is logged.
477
478The panel still shows the element afterwards: CSS selector, XPath, `id`, test
479id, text, or `href`. **copy** puts the selected one on the clipboard, and
480**insert** re-types it (text only — the screenshot is already on the clipboard
481if you want it again).
482
483**Why the button can fail.** Touching a page needs `activeTab`, which the
484browser grants only on certain user gestures — a toolbar click, a context menu,
485or a keyboard command. A click inside the side panel is not one of them, so the
486button falls back to standing host permissions. Those are:
487
488- **localhost is granted up front** — `localhost`, `*.localhost` and `127.0.0.1`
489 on both schemes. Match patterns carry no port, so `:5173`, `:3000` and
490 everything else are covered.
491- **Every other site is opt-in, one origin at a time.** When the button hits a
492 site it can't reach, it offers an *Allow https://example.com/\** button that
493 triggers the browser's own permission prompt. Nothing is granted until you say
494 so.
495
496The shortcut needs none of this — it runs in the background worker, which is a
497gesture the browser does honour, and works on any page.
498
499**Why the button can pick but not screenshot.** `tabs.captureVisibleTab` accepts
500exactly two things: the `activeTab` grant a keyboard command mints, or a host
501permission set containing the literal `<all_urls>` pattern. A per-origin grant is
502enough to read the element but not to capture it, so the button hands back a
503selector and no image even on a site you approved. When that happens the panel
504offers an *Allow screenshots on all sites* button, which requests `<all_urls>`;
505it is optional and revocable in the extension's settings, and Alt+Shift+P keeps
506capturing without it.
507
508Picks made while the sidebar is closed are parked in `storage.local` and appear
509when you next open it.
510
511Nothing is inserted automatically — see below for why that matters.
512
513## Security model
514
515This daemon hands out shell access. It is `sshd` with a smaller feature set, and
516is treated that way.
517
518**Threats it stops**
519
520| Attacker | Defense |
521|---|---|
522| 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 |
523| `evil.com` rebound to 127.0.0.1 | `Host` header must be loopback |
524| Another user on the machine | Token in a `0600` file, `0700` dir; refuses to load if the mode loosens |
525| The network | Binds `127.0.0.1` only, not configurable |
526| Token brute-force | Constant-time compare, lockout after repeated failures |
527| Firefox HTTPS-Only Mode breaking the connection | Serves TLS and plaintext on one port; no browser setting has to be weakened |
528| A page injecting shell commands via the element picker | Picked text is control-character stripped and single-quoted before it can reach the pty |
529| 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 |
530
531**Threats it does not stop**
532
533A process running as *you* can already read `~/.ssh`, patch `~/.bashrc`, and
534ptrace your browser. Same-uid isolation is not a thing, and pretending otherwise
535would be theatre.
536
537**Design rules**
538
539- Token travels in a post-upgrade frame, never the URL — query strings leak into
540 logs, crash dumps, and devtools history.
541- Pairing is explicit. No trust-on-first-use, no blanket `chrome-extension://`
542 prefix match (that would admit every other extension you have installed).
543- The extension requests `storage` and `sidePanel`. No host permissions, no
544 content scripts, no `web_accessible_resources`, no `externally_connectable` —
545 so there is no bridge from page content to the socket.
546- The sidebar owns the WebSocket directly. Terminal data never passes through
547 `runtime.sendMessage`, which content scripts can reach.
548- The client cannot choose what runs. The protocol has no argv field, so an auth
549 bypass yields the configured profile rather than arbitrary exec.
550- TLS changes no policy: origin, host and token are enforced identically over
551 `wss://`. The private key is `0600` and refused if the mode loosens, and the
552 certificate is a leaf with `CA:FALSE` — trusting it cannot be leveraged to
553 vouch for any other host.
554- **The element picker treats the page as hostile.** A selector is built from
555 attributes the page chose, so an `id` of `x'; rm -rf ~; '` or an `aria-label`
556 containing a newline is arbitrary code execution — a newline typed at a
557 terminal is a pressed Enter. Two independent defenses, both required: strip
558 every C0 control character, then POSIX single-quote. `insert` never appends a
559 newline; you press Enter yourself. The UI says so when a value had to be
560 modified.
561- The picker's standing page access is limited to loopback hosts, which are your
562 own machine. Everything else is `optional_host_permissions`, granted per-origin
563 through the browser's prompt and revocable in the extension's settings — never
564 a blanket `<all_urls>` at install time. `<all_urls>` is offered as an optional
565 permission too, because the screenshot API takes nothing narrower, but only
566 when a capture has already failed and only behind an explicit button. Still no
567 declared content scripts and no `web_accessible_resources`.
568- The picker returns its result as the `executeScript` **return value**, not via
569 `runtime.sendMessage` — so the sidebar still has no inbound message listener
570 that a content script could reach.
571- tmux session names are client-selectable but validated: no leading dash (tmux
572 would read it as a flag), and `[A-Za-z0-9_-]` only. They reach `execvp` as a
573 separate argv element, never a shell. An invalid name falls back to the
574 default rather than erroring.
575- The HTTPS landing page exists only so the certificate-trust visit is
576 comprehensible. It serves no data and, unlike the implementation we looked at,
577 there is no endpoint anywhere that hands out the auth token.
578
579Every one of those is covered by a test, and the suite is mutation-checked:
580reverting the origin check to "allow any origin" turns 5 tests red.
581
582```sh
583cd daemon && cargo test # 46
584npm test # 25
585npm run check # types, see below
586```
587
588The sanitizer tests don't just assert on strings — they hand the quoted output
589to a real `/bin/sh` and check it comes back as one literal argument, including
590for `x'; rm -rf ~; echo '` and `harmless\nid`.
591
592The theme tests compute WCAG contrast ratios for all 16 ANSI slots against their
593own background, plus body text, muted text, buttons and selection. They already
594earned their keep: `brightBlack` landed at exactly 3.00:1 on the dark background
595— the slot most tools use for comments — and was corrected.
596
597## Types without a build step
598
599The extension is plain `.js` that the browser loads exactly as it sits on disk
600— no bundler, no transpile, `build.sh` is a `cp`. That stays true. What was
601added is a type *checker* over it: JSDoc annotations plus `npm run check`,
602which runs `tsc --noEmit` and emits nothing. Editors pick the same config up
603automatically and give completion on the daemon's frames.
604
605```sh
606npm install # one dev dependency: typescript
607npm run check
608```
609
610Two projects, because the manifest creates two global scopes and both declare
611`const api` at the top level:
612
613| Config | Realm | Files |
614|---|---|---|
615| `extension/jsconfig.json` | sidebar document | `sidebar.js`, `picker.js`, `lib/` |
616| `extension/jsconfig.sw.json` | service worker | `sw.js`, `picker.js`, `lib/shot.js`, `lib/split.js` |
617
618`extension/types/globals.d.ts` is hand-written rather than pulled from
619`@types/chrome`, so it doubles as the inventory of extension API surface this
620extension touches — adding a call means adding it there first, which is the
621same conversation as growing the manifest's permission list. It also carries
622the daemon's wire frames (`TbOkFrame`, `TbStatusFrame`, `TbSessionInfo`,
623`TbAgent`); the other half of those shapes is `daemon/src/status.rs`, and the
624two have to move together.
625
626Nothing in `node_modules/` reaches `dist/`.
627
628## Known gotchas
629
630**tmux resizes for everyone.** Default `window-size` is `latest`, so attaching a
631narrow sidebar shrinks the same session in your real terminal. Verified: a
632100x30 window became 40x20 on sidebar attach. Either give the sidebar its own
633session, or:
634
635```sh
636setw -g window-size manual
637```
638
639**Firefox temporary add-ons** get a new UUID per install, so re-pair after each
640reload.
641
642## Layout
643
644```
645daemon/src/paths.rs token + paired-origin files, permission enforcement
646daemon/src/auth.rs origin / host / token checks
647daemon/src/server.rs listener, handshake, session pump
648daemon/src/pty.rs portable-pty backend
649daemon/src/tls.rs self-signed cert generation, rustls config
650daemon/src/rewind.rs replayable stream, so we can inspect the request head
651daemon/src/activation.rs taking the listening socket systemd passed us
652daemon/src/install.rs the systemd units / launchd agent `install` writes
653daemon/tests/ security.rs (28), tls.rs (10), pty_e2e.rs (8),
654 activation.rs (5)
655extension/picker.js injected element picker (no privileges, runs in page)
656extension/lib/ sanitize.js (page-text-to-shell boundary), theme.js
657 (light/dark palettes), split.js (running the picker in
658 both halves of a split view) — all three with tests;
659 shot.js (element screenshot crop, clipboard write)
660extension/types/ hand-written ambients: extension API surface, the
661 daemon's wire frames, the vendored xterm build
662extension/ sidebar, two manifests, vendored xterm.js
663```
664
665## Not done yet
666
667- Firefox reaches the daemon (confirmed: HTTPS-Only Mode was rewriting the
668 scheme, which is why TLS exists). Not yet confirmed end-to-end after trusting
669 the certificate.
670- Chrome has not been loaded at all; whether MV3 needs anything in
671 `host_permissions` or CSP `connect-src` is still unverified.
672- No packaging: there's no distributable build, though `termbridge install` now
673 covers starting the daemon (socket activation on Linux, a login agent on
674 macOS).