feat: in-browser terminal attached to worker tmux session (#7) #11

Manually merged
lz merged 20 commits from terminal-access into main 2026-05-16 18:53:48 +02:00
Owner

Closes #7.

Summary

Adds an in-browser terminal attached to a worker's tmux window. Each session card gets a term button that opens a full-viewport xterm.js connected via WebSocket to a docker exec PTY running tmux attach pinned to that session's window. Host-side docker exec ... tmux a from a host shell still works unchanged — the browser terminal uses a grouped tmux session so each viewer has its own active-window pointer.

Secondary feature (in-scope per #7's discussion): session rows are reconciled against tmux ground truth. If a window vanishes (operator runs tmux kill-window, claude exits inside the browser terminal, container restart), the DB row is hard-deleted after two consecutive missed polls.

How it works

Backend. A custom Node entry nexus/src/server.ts wraps SvelteKit's adapter-node request handler in http.createServer and attaches an upgrade listener on /api/workers/:id/sessions/:sid/terminal. The handler validates the operator cookie (same contract as hooks.server.ts), opens a docker exec with Tty: true running a grouped tmux session, and bidirectionally pipes the duplex stream to a ws WebSocket.

Tmux attach argv. No shell wrapping — passed as an argv array so session names don't need escaping:

tmux new-session -A -d -s view-RANDOM -t claude ;
     select-window -t view-RANDOM:NAME ;
     set-option -t view-RANDOM destroy-unattached on ;
     set-option -t view-RANDOM prefix None ;
     set-option -t view-RANDOM prefix2 None ;
     set-option -t view-RANDOM status off ;
     attach-session -t view-RANDOM

The -t claude on new-session makes the view a group member of the original session: shared window list, independent active-window pointer per viewer. destroy-unattached on cleans up the shim when the WS closes. prefix=None makes Ctrl-b inert (mouse and copy-mode keep working). status off hides the tmux bottom bar in the browser view (the browser header already says which session you're on). The original claude session is never touched, so host-side docker exec ... tmux a is unaffected.

Wire format.

  • binary frame, both directions: raw PTY bytes
  • text frame, client→server: JSON { "type": "resize", "cols": N, "rows": M }

Keystrokes are sent as binary so they can't collide with the text-JSON control channel.

Frontend. Full-viewport xterm.js with FitAddon + xterm-addon-canvas (cell-perfect box-drawing on high-DPI displays — without it, the DOM renderer leaves 1px seams in claude's ASCII logo). Esc and browser back both close. Includes a font picker (IBM Plex Mono default, JetBrains Mono, Fira Code, Cascadia Code, system mono) with lazy-loading.

Session reconciliation. Extended checkWorkerHealth (the existing 10s poll) with a reconcileSessions(db, docker, workerId) step. Lists tmux list-windows -t claude -F '#W' inside the container, and after two consecutive missed polls for a row with status != 'creating', hard-deletes it. Orphan windows (live tmux window with no DB row) log a warning but are never auto-imported — operator-created windows have ambiguous origin.

Notable gotchas hit along the way

  • Custom server entry duplicates singletons. src/server.ts is bundled separately by esbuild from SvelteKit's adapter-node output, so $server/singletons.ts ended up compiled into both bundles. Auth/unlock wrote to one SessionStore while the WebSocket upgrade read from another, and every authenticated WS got 401. Fixed by stashing the singletons on globalThis under Symbol.for('agent-nexus.singletons') (community-canonical pattern from suhaildawood/SvelteKit-integrated-WebSocket).
  • Pino bundles with require('node:os'). esbuild's ESM bundle wraps unknown requires in a shim that throws at runtime. Fixed with --packages=external so all node_modules deps stay external.
  • pnpm ate $server from the alias flag. POSIX shell expansion happens before esbuild sees the arg. Escaped with a \$ in the script string.
  • select-window -t claude:NAME doesn't move our pointer. Chained inside a single tmux invocation, it targets the original claude session's active-window cursor instead of our grouped view's. Fixed by targeting view-X:NAME (our own session). Regression-guard test added.
  • Dockerfile CMD was still node build. Updated to node build/server.js so the runtime image actually runs our custom entry instead of adapter-node's default.

Compatibility

  • No DB migrations. Reconciliation deletes existing rows; the miss counter is in-memory.
  • No worker-image changes. Tmux session/window names match worker/entrypoint.sh (documented via a BOOTSTRAP_WINDOW_NAME=boot variable on both sides).
  • No proxy config changes. The existing EXEC=1 on tecnativa/docker-socket-proxy covers /exec/:id/resize (verified against the haproxy.cfg regex ^(/v[\d.]+)?/exec).
  • Auth, cookies, in-memory session store unchanged — terminal endpoint uses the same nexus_sid cookie.

Test plan

  • 137 vitest tests pass, typecheck clean
  • docker compose up -d --build brings the stack up cleanly
  • WS endpoint returns 401 without a valid cookie (curl -i ... /api/workers/x/sessions/y/terminal)
  • Smoke-tested in browser: clicking term opens the pinned window, ASCII logo renders without gaps, Ctrl-b is inert, no bottom status bar
  • (manual, optional) Phone test via DevTools device emulation
  • (manual, optional) Two-tab test: both clients attach independently, neither kicks the other
  • (manual, optional) tmux kill-window from host shell → DB row vanishes within ~20s

Files

Design + plan: docs/superpowers/plans/2026-05-15-in-browser-terminal.md. AGENTS.md updated with two new "verified facts" notes (#13 grouped-tmux + WS endpoint, #14 reconciliation rule).

Closes #7. ## Summary Adds an in-browser terminal attached to a worker's tmux window. Each session card gets a **term** button that opens a full-viewport xterm.js connected via WebSocket to a `docker exec` PTY running `tmux attach` pinned to that session's window. Host-side `docker exec ... tmux a` from a host shell still works unchanged — the browser terminal uses a *grouped* tmux session so each viewer has its own active-window pointer. Secondary feature (in-scope per #7's discussion): session rows are reconciled against tmux ground truth. If a window vanishes (operator runs `tmux kill-window`, claude exits inside the browser terminal, container restart), the DB row is hard-deleted after two consecutive missed polls. ## How it works **Backend.** A custom Node entry `nexus/src/server.ts` wraps SvelteKit's `adapter-node` request handler in `http.createServer` and attaches an `upgrade` listener on `/api/workers/:id/sessions/:sid/terminal`. The handler validates the operator cookie (same contract as `hooks.server.ts`), opens a `docker exec` with `Tty: true` running a grouped tmux session, and bidirectionally pipes the duplex stream to a `ws` WebSocket. **Tmux attach argv.** No shell wrapping — passed as an argv array so session names don't need escaping: ``` tmux new-session -A -d -s view-RANDOM -t claude ; select-window -t view-RANDOM:NAME ; set-option -t view-RANDOM destroy-unattached on ; set-option -t view-RANDOM prefix None ; set-option -t view-RANDOM prefix2 None ; set-option -t view-RANDOM status off ; attach-session -t view-RANDOM ``` The `-t claude` on `new-session` makes the view a group member of the original session: shared window list, independent active-window pointer per viewer. `destroy-unattached on` cleans up the shim when the WS closes. `prefix=None` makes `Ctrl-b` inert (mouse and copy-mode keep working). `status off` hides the tmux bottom bar in the browser view (the browser header already says which session you're on). The original `claude` session is never touched, so host-side `docker exec ... tmux a` is unaffected. **Wire format.** - binary frame, both directions: raw PTY bytes - text frame, client→server: JSON `{ "type": "resize", "cols": N, "rows": M }` Keystrokes are sent as binary so they can't collide with the text-JSON control channel. **Frontend.** Full-viewport xterm.js with FitAddon + xterm-addon-canvas (cell-perfect box-drawing on high-DPI displays — without it, the DOM renderer leaves 1px seams in claude's ASCII logo). Esc and browser back both close. Includes a font picker (IBM Plex Mono default, JetBrains Mono, Fira Code, Cascadia Code, system mono) with lazy-loading. **Session reconciliation.** Extended `checkWorkerHealth` (the existing 10s poll) with a `reconcileSessions(db, docker, workerId)` step. Lists `tmux list-windows -t claude -F '#W'` inside the container, and after **two** consecutive missed polls for a row with `status != 'creating'`, hard-deletes it. Orphan windows (live tmux window with no DB row) log a warning but are never auto-imported — operator-created windows have ambiguous origin. ## Notable gotchas hit along the way - **Custom server entry duplicates singletons.** `src/server.ts` is bundled separately by esbuild from SvelteKit's adapter-node output, so `$server/singletons.ts` ended up compiled into both bundles. Auth/unlock wrote to one `SessionStore` while the WebSocket upgrade read from another, and every authenticated WS got 401. Fixed by stashing the singletons on `globalThis` under `Symbol.for('agent-nexus.singletons')` (community-canonical pattern from `suhaildawood/SvelteKit-integrated-WebSocket`). - **Pino bundles with `require('node:os')`.** esbuild's ESM bundle wraps unknown requires in a shim that throws at runtime. Fixed with `--packages=external` so all node_modules deps stay external. - **`pnpm` ate `$server` from the alias flag.** POSIX shell expansion happens before esbuild sees the arg. Escaped with a `\$` in the script string. - **`select-window -t claude:NAME` doesn't move our pointer.** Chained inside a single tmux invocation, it targets the original `claude` session's active-window cursor instead of our grouped view's. Fixed by targeting `view-X:NAME` (our own session). Regression-guard test added. - **Dockerfile CMD was still `node build`.** Updated to `node build/server.js` so the runtime image actually runs our custom entry instead of adapter-node's default. ## Compatibility - **No DB migrations.** Reconciliation deletes existing rows; the miss counter is in-memory. - **No worker-image changes.** Tmux session/window names match `worker/entrypoint.sh` (documented via a `BOOTSTRAP_WINDOW_NAME=boot` variable on both sides). - **No proxy config changes.** The existing `EXEC=1` on `tecnativa/docker-socket-proxy` covers `/exec/:id/resize` (verified against the haproxy.cfg regex `^(/v[\d.]+)?/exec`). - **Auth, cookies, in-memory session store** unchanged — terminal endpoint uses the same `nexus_sid` cookie. ## Test plan - [x] 137 vitest tests pass, typecheck clean - [x] `docker compose up -d --build` brings the stack up cleanly - [x] WS endpoint returns 401 without a valid cookie (`curl -i ... /api/workers/x/sessions/y/terminal`) - [x] Smoke-tested in browser: clicking term opens the pinned window, ASCII logo renders without gaps, Ctrl-b is inert, no bottom status bar - [ ] (manual, optional) Phone test via DevTools device emulation - [ ] (manual, optional) Two-tab test: both clients attach independently, neither kicks the other - [ ] (manual, optional) `tmux kill-window` from host shell → DB row vanishes within ~20s ## Files Design + plan: `docs/superpowers/plans/2026-05-15-in-browser-terminal.md`. AGENTS.md updated with two new "verified facts" notes (#13 grouped-tmux + WS endpoint, #14 reconciliation rule).
lz added 20 commits 2026-05-15 19:10:10 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add reconcileSessions() that hard-deletes DB session rows whose tmux window
has been absent for two consecutive polls, using a module-scoped missedPolls
Map. Skips sessions in 'creating' status and does nothing when the container
is not running. Logs a warning for orphan tmux windows with no DB row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Drop dead `init` hook from hooks.server.ts; migrations now run only from
  the custom server entry (src/server.ts). Prevents drift if anyone reverts
  to `node build`.
- wire.ts: handle ArrayBuffer WS frames explicitly so they don't get
  stringified into '[object ArrayBuffer]' before hitting the PTY.
- Replace the hardcoded 'boot' magic string in reconcileSessions with a
  BOOTSTRAP_WINDOW_NAME constant, paired with a pointer to
  worker/entrypoint.sh so the next rename gets caught.
- Upgrade test happy-path: provide a real fake WebSocket so the wire setup
  is actually exercised, instead of relying on the production try/catch to
  swallow `ws.on is not a function`.
Pairs with BOOTSTRAP_WINDOW_NAME in nexus/src/lib/server/sessions/service.ts
so a grep for either spelling catches the other side.
pnpm's shell expanded $server to empty before esbuild saw the flag,
producing 'Invalid alias name'. Escape the dollar in the script string
(JSON \$ → shell \$ → esbuild $server) — keeps the one-liner.
Pino uses CommonJS `require('node:os')` internally; esbuild's ESM bundler
wraps unknown requires in a shim that throws "Dynamic require of X is not
supported" at runtime. `--packages=external` keeps node_modules out of the
bundle entirely (they're resolved from node_modules at runtime anyway), so
$server/* and src local code is still bundled but pino, dockerode, ws,
better-sqlite3, argon2, kysely, etc. are all left alone.
The custom Node entry (src/server.ts) is bundled separately from the SvelteKit
adapter-node output, so src/lib/server/singletons.ts ended up inlined into
both build/server.js and build/server/chunks/singletons-*.js. Each got its
own `new SessionStore(...)`: the HTTP /api/auth/unlock wrote to one Map, the
WebSocket upgrade handler read from the other, and every authenticated WS
got 401.

Use the community-canonical pattern (also used by
suhaildawood/SvelteKit-integrated-WebSocket): stash the singletons on
globalThis under Symbol.for('agent-nexus.singletons'). Whichever module
copy evaluates first creates the underlying handles; the second reuses.

Also drop the debug logs added while diagnosing this — the production
shape is unchanged.
Two bugs caught during smoke:

1. The terminal landed on the bootstrap 'boot' window instead of the
   session's window. Cause: `select-window -t claude:NAME` inside the
   `tmux ... ; select-window ... ;` chain targets the original `claude`
   session's pointer, not our grouped view session's. Each grouped session
   has its own active-window pointer; we need `select-window -t VIEW:NAME`
   to move OUR pointer. Verified by running each tmux command separately
   (lands correctly) vs chained (lands on boot).

2. The default DOM renderer left 1px seams between rows in claude's ASCII
   logo and any box-drawing characters. Causes are documented in xterm.js
   issues #2572 / #967 (DOM renderer + non-1 devicePixelRatio interactions).
   Add xterm-addon-canvas, which renders block/box-drawing glyphs cell-
   perfectly, and set lineHeight: 1.0 + letterSpacing: 0 explicitly as
   belt-and-suspenders.

Adds a regression-guard test that asserts the argv targets VIEW:NAME (not
claude:NAME) on select-window.
Scoped to -t viewId via three new set-options on the grouped view session:

- prefix = None, prefix2 = None: Ctrl-b is now inert in the browser
  terminal. The user can't accidentally trigger detach / window-switch /
  kill-session bindings. Mouse mode, copy-mode and any non-prefix bindings
  still work, so wheel-scroll and click-selection are unaffected.
- status = off: hide tmux's bottom status bar. The browser-terminal
  header already tells the user which session they're in, and the status
  bar invited tmux navigation we deliberately want to gate.

None of these changes touch the original `claude` session, so host-side
`docker exec ... tmux a` keeps its normal keybindings and status bar.
feat(terminal): per-session font picker
All checks were successful
ci / nexus (pull_request) Successful in 2m0s
ci / images (./nexus, agent-nexus) (pull_request) Successful in 4m22s
ci / images (./worker, nexus-worker) (pull_request) Successful in 2m8s
93878b35c4
Five candidates (IBM Plex Mono default, JetBrains Mono, Fira Code,
Cascadia Code, system mono). Each lazy-loads its Google Fonts stylesheet
on first use so the initial render only fetches what's actually shown.
On change, we wait for document.fonts.ready before swapping the xterm
font-family and forcing a refresh — without that, xterm's char-width
cache is stale until the next resize.
lz manually merged commit f660c4214c into main 2026-05-16 18:53:48 +02:00
lz deleted branch terminal-access 2026-05-16 18:54:28 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!11
No description provided.