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## Pinning a session to a browser tab
250
251Right-clicking a session or a window tab also offers to **pin** it to the
252browser tab you are on. After that, switching to that browser tab switches the
253terminal: the tab you keep the app in brings up the session you run the app
254from, and the docs tab beside it brings back whatever you had there.
255
256This is a different thing from the **Pin** in the paragraph above, which is
257about where a tab sits in the strip. This one is about which browser tab brings
258it up. They are independent, and a window can have both.
259
260Nothing is focused when it does: the terminal moves underneath, and the caret
261stays on the page.
262
263### It is a loan, not a move
264
265A pin *borrows* the terminal. Leaving for a browser tab with no pin of its own
266puts it back where it was before the pinned tab took it, so flicking between a
267pinned tab and an unpinned one flicks the terminal between two places rather
268than stranding it on the pinned one. Without that, a single glance at a pinned
269tab would relocate the terminal permanently.
270
271Three rules keep that from being annoying:
272
273- Only the first pin in a run records a return. Pinned tab to pinned tab to
274 unpinned goes back to where the run *started*, not to the middle of it — the
275 middle was never somewhere you chose to be.
276- A pin that had nothing to do owes nothing. Landing on a tab pinned to where
277 you already are records no return, so leaving it moves nothing.
278- A terminal you have since moved by hand is left alone. The return is an undo
279 of a move this code made; once you have steered somewhere yourself there is
280 nothing to undo, and dragging you back would be the panel overruling you.
281
282Following also waits about a sixth of a second before it acts, so Ctrl-Tabbing
283through six tabs is one move at the end rather than six on the way.
284
285A pin can key on two things:
286
287- **the site**, e.g. `localhost:26210`. Any tab on that origin matches, and the
288 pin outlives the tab, the window and the browser, because an origin is a name.
289 This is the one to reach for.
290- **that exact browser tab**, by the id Chrome gave it. Survives nothing, and
291 exists for what an origin cannot express: two tabs on the same site pointing
292 at different sessions, or a page whose URL says nothing.
293
294A tab pin wins over a site pin, being the more specific statement.
295
296Pinning the **session** rather than one of its windows is usually what you want:
297it means "this tab brings up that project, wherever I left it", and it keeps
298working when you close and reopen the window the dev server was in. A window pin
299whose window has since been killed falls back to its session rather than going
300quietly dead.
301
302A small accent dot marks whichever row the tab on screen points at, so the panel
303moving on its own always has a visible reason. The tooltip says which rule
304answered.
305
306### And back the other way
307
308The same pin also reads backwards, so moving the terminal brings the browser
309along: switch to that session and the tab you pinned to it comes up. Anything
310that moves the terminal counts — a click on a session tab in the panel, a
311`prefix n` typed into the pane, another client switching a session this one is
312watching — because by the time it reaches the panel it is one status frame
313either way.
314
315This is the half that touches the browser, so it is deliberately timid, and it
316has its own checkbox (**and switch tabs back**) for turning off without giving
317up the forward direction:
318
319- It only ever **activates a tab that is already open**, in the panel's own
320 browser window. It does not create tabs, does not focus the browser, does not
321 raise a window and does not reach into another window. The worst it can do is
322 show you a tab you already had.
323- It does nothing when the tab already showing satisfies the pin. That is also
324 what stops the two directions chasing each other: the forward one will not
325 move a terminal that is already where the tab points, and this one will not
326 move a browser that is already on a tab pointing here, so whichever fires
327 second finds its work done.
328- Two tabs can answer to one pin — two tabs on the pinned origin — and the tie
329 goes first to the more specific rule and then to the one you looked at more
330 recently.
331- It waits the same sixth of a second, so holding `prefix n` through six windows
332 moves the browser once at the end rather than flicking it through five tabs on
333 the way.
334- The loan being handed back does not count as a move. Leaving a pinned tab puts
335 the terminal back where it was borrowed from, and if some third tab happens to
336 be pinned to *that* place this stays out of it rather than chasing it and
337 undoing the tab switch you just made by hand.
338
339It reads the pins you already have rather than a second set of its own, which
340means a veto keeps vetoing and a tab pin keeps outranking a site pin from this
341end too. There is nothing extra to set up: pin a session to a tab and both
342directions light up together.
343
344### Guessing from the port
345
346Under the explicit pins sits a guess, on by default and switchable in settings:
347a tab on a `localhost` port that `devport` would hand to a project we have a
348session in is treated as pinned to that session, without anyone saying so.
349
350devport gives a project a stable block of ten ports from a checksum of its
351directory name, so nothing has to be registered anywhere — and that mapping is
352reproducible offline. `extension/lib/devport.js` reimplements it (POSIX `cksum`
353and all) and hashes the working directories tmux already reports, which is the
354same answer `devport -r` gives without a subprocess, a filesystem walk, or
355devport being installed.
356
357The guess is deliberately timid. Local hosts only, in-range ports only, and an
358ambiguous match — two sessions in one block, which collisions make possible —
359resolves to nothing rather than a coin toss. Unpinning a guessed match records a
360veto against that origin, which is the only way to say "no, not this one" to a
361rule that would otherwise keep re-deriving itself.
362
363Everything here is panel state: it lives in extension storage, it is
364per-browser-profile, and none of it reaches the daemon, which has never heard of
365a browser tab. It also only works while the panel is open — the panel is what
366watches the tabs.
367
368## How the daemon talks to tmux
369
370The interactive client in the pty is busy being a terminal, so the daemon
371attaches a *second* client in [control
372mode](https://github.com/tmux/tmux/wiki/Control-Mode) to use as a query and
373event channel:
374
375```
376tmux -C attach -t <session> -f read-only,ignore-size,no-output
377```
378
379Each flag is load-bearing. `read-only` means the channel can never send
380keystrokes to a pane. `ignore-size` stops an 80x24 control client from shrinking
381your windows to fit itself. `no-output` stops tmux streaming every byte every
382pane produces to a client with no use for it.
383
384tmux pushes `%client-session-changed`, `%sessions-changed`, `%window-renamed`
385and friends, so the sidebar updates when something happens rather than on a
386timer, and no `tmux` process is spawned per refresh.
387
388`read-only` governs keys, not commands, so the same channel carries the
389sidebar's requests. Those are a closed allowlist — switch to a session,
390create-and-switch, focus a pane, select a window, go to a window in another
391session, open a window, move a window, close a window, rename a session, set a
392session's colour —
393expressed as an enum, not a command string. The wire protocol cannot name a
394tmux command, and every argument is validated (`valid_session_name`,
395`valid_pane_id`, `valid_window_id`, `valid_group_color`) before it is quoted
396into a command line.
397
398The colour is the one piece of the panel's own state kept on the server rather
399than in the browser. It goes in a tmux user option, `@termbridge_color`, set on
400the session and read back as one more field of the `list-sessions` format the
401status frame is already built from — so it costs no extra round trip. Keeping it
402there rather than in extension storage means it follows a session through a
403rename, every panel on the server agrees on it, and it dies with the session.
404The value is a hue in degrees or `-1` for grey, and nothing else parses.
405
406The channel also sets one option on each session the sidebar's client lands on.
407tmux's default is `detach-on-destroy on`: exit the last shell of a session and
408every client attached to it is detached too. For a terminal emulator that just
409closes the window, but here it is EOF on the pty, so the socket closes and the
410whole panel goes dead even though other sessions are still running. The daemon
411switches it to `off`, which moves the client to another session instead and only
412falls back to detaching when there is nothing left to show. Only tmux's own
413default is overridden — `no-detached` and `previous` are deliberate choices with
414the same effect, and are left alone.
415
416Exactly one of them destroys anything, `kill-window`, and it can only ever name
417one window: a window id is `@` plus digits, so `-a` (which would kill every
418window *but* the target) and `session:` targets do not parse. There is no
419kill-session and no kill-pane.
420
421## Claude Code status
422
423The glyph on each window tab is what Claude Code is doing in that window. It is
424Claude Code's own asterisk spinner, so a window that is thinking looks in the
425tab strip the way it looks in the pane:
426
427| Glyph | Meaning |
428|---|---|
429| amber, cycling `· ✢ ✳ ∗ ✻ ✽` | working |
430| blue `✳`, pulsing | waiting on you |
431| green `✻`, pulsing | done, and you haven't looked yet |
432| grey `✻` | idle at the prompt |
433| faded `·` | Claude is there, but no hooks are installed for it |
434| nothing | no Claude in this window |
435
436One timer drives the whole strip and only runs while something is working, so
437an idle panel is not repainting forever. `prefers-reduced-motion` drops the
438pulse and parks the spinner on a single frame rather than dropping the
439indicator; the colours, which are what carry the meaning, stay.
440
441Joined on the tmux window id, so a window shows the loudest state in it —
442waiting beats done, and both beat working. The tab's tooltip carries the detail
443the dot can't: the tool in flight, or what Claude is blocked on.
444
445### Action required
446
447Green and grey are the same thing to Claude Code — a session sitting at its
448prompt. What separates them is whether that pane has been in front of you since
449it went quiet, which no hook can report: nothing in Claude's process knows which
450tmux pane a person is looking at. tmux does, so the daemon is where the two are
451put together.
452
453The rules, in full:
454
455- **Lit** for any Claude at rest in a pane that is not on screen. On screen
456 means the active pane, of the active window, of the session this daemon's
457 client is attached to — a tab in the strip is not a pane on screen.
458- **Cleared by looking.** Selecting the window clears it within the second, and
459 a pane you are already on never lights up in the first place.
460- **Re-armed by the next turn.** Sending a prompt puts the pane back to
461 `working`, and the rest after *that* is news again.
462
463It is a stamp per pane, not a flag: what is remembered is the `updated` time of
464the record you were shown, so any later hook event stops matching it and the
465pane goes back to unseen on its own. A pane id tmux recycles into a new pane
466can't inherit a stale mark for the same reason.
467
468The state lives in the daemon, so it is shared by every panel on that tmux
469server and survives a panel reload — but not a daemon restart, after which
470everything at rest reads as ready once. The tmux status line (below) doesn't
471take part: it runs as its own short-lived process with no view of the daemon's
472memory, and shows plain `idle`.
473
474There used to be a second row of per-pane chips under the header saying the
475same thing at more length. It was costing a terminal line to repeat what the
476tabs already show, so it's gone; the trade is that a window whose tab is
477scrolled out of a narrow panel no longer announces itself.
478
479This comes from [Claude Code's hook
480interface](https://code.claude.com/docs/en/hooks), not from reading the screen:
481Claude runs `termbridge hook` on `UserPromptSubmit`, `PreToolUse`,
482`PostToolUse`, `Notification`, `Stop`, `SessionStart` and `SessionEnd`, and each
483event's JSON tells us the state directly. `Notification` even distinguishes
484`permission_prompt` (blocked on you) from `idle_prompt` (just quiet).
485
486Install it once:
487
488```sh
489termbridge hooks # prints the block to merge into ~/.claude/settings.json
490```
491
492Each session's state lands in `~/.config/termbridge/agents/<session_id>.json`.
493The pane a session belongs to comes from `$TMUX_PANE`, which Claude's process
494inherits and passes to the hook — that is the join between a Claude session and
495a tmux pane. `permission_mode` is carried forward across the events that omit it
496(`Notification` is the one that matters: the mode must not blink out exactly
497when you are being asked to approve something).
498
499### The same glyph in tmux's own status line
500
501The sidebar reads its state over the daemon's control channel, which exists
502only while a browser is attached — exactly the case where you are not looking
503at the browser. So the terminal gets it from the other end: the hook already
504runs inside the pane it is reporting on, with `$TMUX` naming the right server,
505and it sets a window user option on the way out.
506
507```sh
508termbridge tmux # prints the ~/.tmux.conf lines
509```
510
511```tmux
512set -g window-status-format "#{@tb_claude}#I:#W#F"
513set -g window-status-current-format "#{@tb_claude}#I:#W#F"
514```
515
516The option holds a styled glyph and a space, and is unset — expanding to
517nothing — for a window with no Claude in it, so windows that never see one look
518exactly as they do now. A window with several Claudes in it shows the loudest,
519the same rule the tabs use: `list-panes` on the hook's own pane is the join.
520
521It advances one frame per hook event rather than on a timer. tmux only redraws
522its status when an option changes or `status-interval` elapses, so a true
523spinner would mean forcing a full status repaint on every attached client
524several times a second; stepping on events costs nothing and moves the glyph
525exactly when Claude crosses a tool boundary.
526
527The only command this issues is `set-option -w @tb_claude`. It cannot change a
528layout or send a key.
529
530### Titles from the last prompt
531
532Two panes running Claude in the same repo are indistinguishable by anything tmux
533knows about them: `#{pane_current_command}` is `node` in both and the cwd is the
534same. What tells them apart is what you asked each one to do, and
535`UserPromptSubmit` hands the hook exactly that.
536
537So the hook flattens the prompt to one line — whitespace collapsed, control
538characters dropped, cut to 80 characters at a word — stores it on the session's
539record, and writes it to the pane title:
540
541```sh
542tmux select-pane -T "fix the flaky pty test"
543```
544
545Pane title rather than window name, deliberately. `rename-window` would turn
546automatic renaming off for that window for good and overwrite a name you or your
547shell chose; the pane title is a field almost nothing else uses, and a window
548that wants to follow it opts in through `automatic-rename-format` — which also
549means the *active* pane is what names the window, for free:
550
551```tmux
552set -g automatic-rename-format \
553 "#{?#{==:#{pane_title},#{host}},#{pane_current_command},#{=/40/…:pane_title}}"
554```
555
556Both fallbacks are load bearing. tmux seeds a pane's title with the hostname, so
557"has no title of its own" has to be tested for rather than assumed empty, and a
558pane that never ran Claude keeps the name tmux would have given it anyway.
559Substitute your own format for `#{pane_current_command}` if you had one.
560
561`select-pane -T` expands its argument as a tmux format, so `#` in a prompt is
562doubled on the way in and a prompt containing `#{...}` or `#[fg=red]` is inert.
563
564The title is only ever set, never cleared: the last prompt is still the most
565useful thing to say about a pane that just finished running it, and a shell that
566emits an OSC title on each prompt takes the pane back on its own. The same
567string is what the sidebar shows for a session that has no name yet — Claude
568only names a session once it has thought of one, and until then "what you asked"
569beats "idle".
570
571## Opening and focusing the terminal
572
573**Alt+Shift+T** is one key for the whole cycle, and what it does depends on
574where the keyboard is:
575
576| Sidebar state | What the key does |
577|---|---|
578| Closed | Opens it |
579| Open, focus is on the page | Focuses the terminal |
580| Open, focus is in the terminal | Closes it |
581
582So it is hold-to-glance from the page and press-twice to dismiss, without ever
583reaching for the mouse. Rebindable in the same place as the picker shortcut:
584`chrome://extensions/shortcuts`, or `about:addons` → gear → Manage Extension
585Shortcuts.
586
587The sidebar has to be open for the browser to know its own state, which is why
588the panel keeps a connection to the background worker while it lives. That
589connection carries three commands (open, focus, close) and nothing else — no
590terminal traffic goes through it, for the reason in the header of `sidebar.js`.
591
592## Picking elements off the page
593
594Two ways to start it, and the difference matters:
595
596| | |
597|---|---|
598| **Alt+Shift+P** | Always works. Rebindable at `chrome://extensions/shortcuts` or `about:addons` → gear → Manage Extension Shortcuts |
599| Crosshair button in the header | Convenient, but may fail — see below |
600
601Then: hover highlights the element under the cursor with its tag and size, click
602selects. Clicks are swallowed, so picking a link or a submit button doesn't
603navigate or submit.
604
605To get out: **Esc** (from either the page or the sidebar), or press the
606crosshair again — it toggles. Esc in one half of a split view ends the whole
607pick, not just that half's overlay.
608
609**Split view.** Chrome puts two tabs side by side but marks only one of them
610`active`, so aiming at "the active tab" always lands in whichever half last had
611focus — the other half looks dead. The picker instead runs in *both* halves at
612once (found via `splitViewId`, Chrome 140+) and takes the first click; the loser
613is torn down. The half you clicked is made active before the screenshot,
614because `tabs.captureVisibleTab` takes no tab id and shoots whatever is active.
615One caveat on the **Alt+Shift+P** path: the `activeTab` grant the shortcut mints
616covers the active half only, so a pick in the *other* half needs that origin
617granted (see below) — otherwise that half sits out and the active one still
618works. Firefox has no split view and no `splitViewId`; it takes the single-tab
619path.
620
621Picking sends the result straight to the terminal, screenshot first:
622
6231. The element's box is cropped out of a screenshot of the tab and put on the
624 system clipboard as a PNG.
6252. A literal **^V** goes down the wire. That byte is not a paste in the panel —
626 xterm.js never sees it — it reaches the program in the pty, and an agent that
627 handles image paste (Claude Code does) reads the clipboard and attaches the
628 PNG itself.
6293. The selector follows, control-character stripped and single-quoted, with no
630 trailing newline. You press Enter yourself.
631
632This works because the daemon runs on the same machine as the browser, so the
633clipboard the extension writes is the clipboard the agent reads. In a bare shell
634^V is literal-next-character instead, so it is only sent when a screenshot was
635actually captured; if the capture fails, only the selector is inserted and the
636reason is logged.
637
638**Where the keyboard ends up.** In the page, where you just clicked — the
639selector is typed into a terminal you have to click into before you can type
640there yourself. Moving the keyboard into the panel was tried and does not work:
641the only way to focus a side panel is for Chrome to *open* it, which means
642closing and reopening it (see *Opening and focusing the terminal*), and even
643driven from the pick click's own user activation Chrome reopens the panel
644without handing it the keyboard. All that bought was a flicker, so the pick
645leaves the focus where it finds it.
646
647The panel still shows the element afterwards: CSS selector, XPath, `id`, test
648id, text, or `href`. **copy** puts the selected one on the clipboard, and
649**insert** re-types it (text only — the screenshot is already on the clipboard
650if you want it again).
651
652**Why the button can fail.** Touching a page needs `activeTab`, which the
653browser grants only on certain user gestures — a toolbar click, a context menu,
654or a keyboard command. A click inside the side panel is not one of them, so the
655button falls back to standing host permissions. Those are:
656
657- **localhost is granted up front** — `localhost`, `*.localhost` and `127.0.0.1`
658 on both schemes. Match patterns carry no port, so `:5173`, `:3000` and
659 everything else are covered.
660- **Every other site is opt-in, one origin at a time.** When the button hits a
661 site it can't reach, it offers an *Allow https://example.com/\** button that
662 triggers the browser's own permission prompt. Nothing is granted until you say
663 so.
664
665The shortcut needs none of this — it runs in the background worker, which is a
666gesture the browser does honour, and works on any page.
667
668**Why the button can pick but not screenshot.** `tabs.captureVisibleTab` accepts
669exactly two things: the `activeTab` grant a keyboard command mints, or a host
670permission set containing the literal `<all_urls>` pattern. A per-origin grant is
671enough to read the element but not to capture it, so the button hands back a
672selector and no image even on a site you approved. When that happens the panel
673offers an *Allow screenshots on all sites* button, which requests `<all_urls>`;
674it is optional and revocable in the extension's settings, and Alt+Shift+P keeps
675capturing without it.
676
677Picks made while the sidebar is closed are parked in `storage.local` and appear
678when you next open it.
679
680Nothing is inserted automatically — see below for why that matters.
681
682## Security model
683
684This daemon hands out shell access. It is `sshd` with a smaller feature set, and
685is treated that way.
686
687**Threats it stops**
688
689| Attacker | Defense |
690|---|---|
691| 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 |
692| `evil.com` rebound to 127.0.0.1 | `Host` header must be loopback |
693| Another user on the machine | Token in a `0600` file, `0700` dir; refuses to load if the mode loosens |
694| The network | Binds `127.0.0.1` only, not configurable |
695| Token brute-force | Constant-time compare, lockout after repeated failures |
696| Firefox HTTPS-Only Mode breaking the connection | Serves TLS and plaintext on one port; no browser setting has to be weakened |
697| A page injecting shell commands via the element picker | Picked text is control-character stripped and single-quoted before it can reach the pty |
698| 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 |
699
700**Threats it does not stop**
701
702A process running as *you* can already read `~/.ssh`, patch `~/.bashrc`, and
703ptrace your browser. Same-uid isolation is not a thing, and pretending otherwise
704would be theatre.
705
706**Design rules**
707
708- Token travels in a post-upgrade frame, never the URL — query strings leak into
709 logs, crash dumps, and devtools history.
710- Pairing is explicit. No trust-on-first-use, no blanket `chrome-extension://`
711 prefix match (that would admit every other extension you have installed).
712- The extension requests `storage` and `sidePanel`. No host permissions, no
713 content scripts, no `web_accessible_resources`, no `externally_connectable` —
714 so there is no bridge from page content to the socket.
715- The sidebar owns the WebSocket directly. Terminal data never passes through
716 `runtime.sendMessage`, which content scripts can reach.
717- The client cannot choose what runs. The protocol has no argv field, so an auth
718 bypass yields the configured profile rather than arbitrary exec.
719- TLS changes no policy: origin, host and token are enforced identically over
720 `wss://`. The private key is `0600` and refused if the mode loosens, and the
721 certificate is a leaf with `CA:FALSE` — trusting it cannot be leveraged to
722 vouch for any other host.
723- **The element picker treats the page as hostile.** A selector is built from
724 attributes the page chose, so an `id` of `x'; rm -rf ~; '` or an `aria-label`
725 containing a newline is arbitrary code execution — a newline typed at a
726 terminal is a pressed Enter. Two independent defenses, both required: strip
727 every C0 control character, then POSIX single-quote. `insert` never appends a
728 newline; you press Enter yourself. The UI says so when a value had to be
729 modified.
730- The picker's standing page access is limited to loopback hosts, which are your
731 own machine. Everything else is `optional_host_permissions`, granted per-origin
732 through the browser's prompt and revocable in the extension's settings — never
733 a blanket `<all_urls>` at install time. `<all_urls>` is offered as an optional
734 permission too, because the screenshot API takes nothing narrower, but only
735 when a capture has already failed and only behind an explicit button. Still no
736 declared content scripts and no `web_accessible_resources`.
737- The picker returns its result as the `executeScript` **return value**, not via
738 `runtime.sendMessage` — so the sidebar still has no inbound message listener
739 that a content script could reach.
740- tmux session names are client-selectable but validated: no leading dash (tmux
741 would read it as a flag), and `[A-Za-z0-9_-]` only. They reach `execvp` as a
742 separate argv element, never a shell. An invalid name falls back to the
743 default rather than erroring.
744- The HTTPS landing page exists only so the certificate-trust visit is
745 comprehensible. It serves no data and, unlike the implementation we looked at,
746 there is no endpoint anywhere that hands out the auth token.
747
748Every one of those is covered by a test, and the suite is mutation-checked:
749reverting the origin check to "allow any origin" turns 5 tests red.
750
751```sh
752cd daemon && cargo test # 46
753npm test # 25
754npm run check # types, see below
755```
756
757The sanitizer tests don't just assert on strings — they hand the quoted output
758to a real `/bin/sh` and check it comes back as one literal argument, including
759for `x'; rm -rf ~; echo '` and `harmless\nid`.
760
761The theme tests compute WCAG contrast ratios for all 16 ANSI slots against their
762own background, plus body text, muted text, buttons and selection. They already
763earned their keep: `brightBlack` landed at exactly 3.00:1 on the dark background
764— the slot most tools use for comments — and was corrected.
765
766## Types without a build step
767
768The extension is plain `.js` that the browser loads exactly as it sits on disk
769— no bundler, no transpile, `build.sh` is a `cp`. That stays true. What was
770added is a type *checker* over it: JSDoc annotations plus `npm run check`,
771which runs `tsc --noEmit` and emits nothing. Editors pick the same config up
772automatically and give completion on the daemon's frames.
773
774```sh
775npm install # one dev dependency: typescript
776npm run check
777```
778
779Two projects, because the manifest creates two global scopes and both declare
780`const api` at the top level:
781
782| Config | Realm | Files |
783|---|---|---|
784| `extension/jsconfig.json` | sidebar document | `sidebar.js`, `picker.js`, `lib/` |
785| `extension/jsconfig.sw.json` | service worker | `sw.js`, `picker.js`, `lib/shot.js`, `lib/split.js` |
786
787`extension/types/globals.d.ts` is hand-written rather than pulled from
788`@types/chrome`, so it doubles as the inventory of extension API surface this
789extension touches — adding a call means adding it there first, which is the
790same conversation as growing the manifest's permission list. It also carries
791the daemon's wire frames (`TbOkFrame`, `TbStatusFrame`, `TbSessionInfo`,
792`TbAgent`); the other half of those shapes is `daemon/src/status.rs`, and the
793two have to move together.
794
795Nothing in `node_modules/` reaches `dist/`.
796
797## Known gotchas
798
799**tmux resizes for everyone.** Default `window-size` is `latest`, so attaching a
800narrow sidebar shrinks the same session in your real terminal. Verified: a
801100x30 window became 40x20 on sidebar attach. Either give the sidebar its own
802session, or:
803
804```sh
805setw -g window-size manual
806```
807
808**Firefox temporary add-ons** get a new UUID per install, so re-pair after each
809reload.
810
811## Layout
812
813```
814daemon/src/paths.rs token + paired-origin files, permission enforcement
815daemon/src/auth.rs origin / host / token checks
816daemon/src/server.rs listener, handshake, session pump
817daemon/src/pty.rs portable-pty backend
818daemon/src/tls.rs self-signed cert generation, rustls config
819daemon/src/rewind.rs replayable stream, so we can inspect the request head
820daemon/src/activation.rs taking the listening socket systemd passed us
821daemon/src/install.rs the systemd units / launchd agent `install` writes
822daemon/tests/ security.rs (28), tls.rs (10), pty_e2e.rs (8),
823 activation.rs (5)
824extension/picker.js injected element picker (no privileges, runs in page)
825extension/lib/ sanitize.js (page-text-to-shell boundary), theme.js
826 (light/dark palettes), split.js (running the picker in
827 both halves of a split view) — all three with tests;
828 shot.js (element screenshot crop, clipboard write)
829extension/types/ hand-written ambients: extension API surface, the
830 daemon's wire frames, the vendored xterm build
831extension/ sidebar, two manifests, vendored xterm.js
832```
833
834## Not done yet
835
836- Firefox reaches the daemon (confirmed: HTTPS-Only Mode was rewriting the
837 scheme, which is why TLS exists). Not yet confirmed end-to-end after trusting
838 the certificate.
839- Chrome has not been loaded at all; whether MV3 needs anything in
840 `host_permissions` or CSP `connect-src` is still unverified.
841- No packaging: there's no distributable build, though `termbridge install` now
842 covers starting the daemon (socket activation on Linux, a login agent on
843 macOS).