feat: in-browser terminal attached to worker tmux session (#7) #11
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "terminal-access"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 execPTY runningtmux attachpinned to that session's window. Host-sidedocker exec ... tmux afrom 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.tswraps SvelteKit'sadapter-noderequest handler inhttp.createServerand attaches anupgradelistener on/api/workers/:id/sessions/:sid/terminal. The handler validates the operator cookie (same contract ashooks.server.ts), opens adocker execwithTty: truerunning a grouped tmux session, and bidirectionally pipes the duplex stream to awsWebSocket.Tmux attach argv. No shell wrapping — passed as an argv array so session names don't need escaping:
The
-t claudeonnew-sessionmakes the view a group member of the original session: shared window list, independent active-window pointer per viewer.destroy-unattached oncleans up the shim when the WS closes.prefix=NonemakesCtrl-binert (mouse and copy-mode keep working).status offhides the tmux bottom bar in the browser view (the browser header already says which session you're on). The originalclaudesession is never touched, so host-sidedocker exec ... tmux ais unaffected.Wire format.
{ "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 areconcileSessions(db, docker, workerId)step. Liststmux list-windows -t claude -F '#W'inside the container, and after two consecutive missed polls for a row withstatus != '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
src/server.tsis bundled separately by esbuild from SvelteKit's adapter-node output, so$server/singletons.tsended up compiled into both bundles. Auth/unlock wrote to oneSessionStorewhile the WebSocket upgrade read from another, and every authenticated WS got 401. Fixed by stashing the singletons onglobalThisunderSymbol.for('agent-nexus.singletons')(community-canonical pattern fromsuhaildawood/SvelteKit-integrated-WebSocket).require('node:os'). esbuild's ESM bundle wraps unknown requires in a shim that throws at runtime. Fixed with--packages=externalso all node_modules deps stay external.pnpmate$serverfrom the alias flag. POSIX shell expansion happens before esbuild sees the arg. Escaped with a\$in the script string.select-window -t claude:NAMEdoesn't move our pointer. Chained inside a single tmux invocation, it targets the originalclaudesession's active-window cursor instead of our grouped view's. Fixed by targetingview-X:NAME(our own session). Regression-guard test added.node build. Updated tonode build/server.jsso the runtime image actually runs our custom entry instead of adapter-node's default.Compatibility
worker/entrypoint.sh(documented via aBOOTSTRAP_WINDOW_NAME=bootvariable on both sides).EXEC=1ontecnativa/docker-socket-proxycovers/exec/:id/resize(verified against the haproxy.cfg regex^(/v[\d.]+)?/exec).nexus_sidcookie.Test plan
docker compose up -d --buildbrings the stack up cleanlycurl -i ... /api/workers/x/sessions/y/terminal)tmux kill-windowfrom host shell → DB row vanishes within ~20sFiles
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).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.