10 KiB
BooCode v1.1 — Batch 10
Theme: BooTerm. Second container, dedicated to in-browser terminals. Per-session tmux. xterm.js + node-pty in-container. New pane type wires into the BooCode shell.
Status: Planned. Largest batch in v1.1. Depends on Batch 3 (pane system), Batch 7 (settings drawer pattern reused).
Repo: /opt/boocode/ (shared monorepo). New apps/booterm/ subdirectory.
Goals
- New container
bootermrunning Fastify + node-pty + tmux. Per-session tmux session keyed by(user, session_id). - xterm.js terminal pane in the BooCode shell. Multiple terminal panes per session, each attached to a separate tmux window.
- PTY traffic over WebSocket. Auth via
Remote-User. - tmux as session manager so terminals survive WebSocket reconnects, page refreshes, even container restarts.
- Read+write capability scoped to project root. No
cd ..escape. - Path-based routing:
code.indifferentketchup.com/api/term/*→ booterm;/ws/term/*→ booterm.
Architecture
browser ──HTTPS──> Caddy (droplet) ──Tailscale──> Authelia
│
├── /api/chat/*, /ws/chat/* → boocode :9500
├── /api/term/*, /ws/term/* → booterm :9501
└── / → boocode (SPA)
booterm container:
- Fastify (Node 20)
- node-pty
- tmux installed in container (apk add tmux)
- same Postgres (boocode_db)
- mounts projects rw (scoped)
Mount strategy
Decided: Option A. Per-project bind mounts in docker-compose.yml. Already applied: booterm has /opt:/opt:rw to keep parity with the existing boocode mount and avoid enumerating roots. Project root for any given session derives from projects.root_path and tmux launches with cwd set there.
tmux session naming
Per-session tmux:
tmux session name: bc-<session_id> (UUID, sanitized — alphanumeric + hyphen)
tmux windows: term-<pane_id> (one window per terminal pane)
booterm spawns tmux new-session -d -s bc-<sid> -c <project_root> lazily on first attach. Subsequent attaches do tmux new-window -t bc-<sid> for additional panes, or tmux attach -t bc-<sid> and select window.
Data model
| Column | On | Type | Default | Notes |
|---|---|---|---|---|
| (none) | — | — | — | terminals are tmux-managed, no DB rows |
kind = 'terminal' |
session_panes.kind CHECK |
— | — | Extend CHECK to include 'terminal' |
state.tmux_window |
session_panes.state JSONB |
TEXT | NULL | Which tmux window this pane attaches to |
Schema (already applied to live DB + schema.sql):
ALTER TABLE session_panes DROP CONSTRAINT IF EXISTS session_panes_kind_check;
ALTER TABLE session_panes ADD CONSTRAINT session_panes_kind_check
CHECK (kind IN ('chat', 'file_browser', 'terminal'));
Backend (booterm)
New app at apps/booterm/:
apps/booterm/
├── src/
│ ├── index.ts # Fastify + WS + auth
│ ├── auth.ts # Remote-User middleware (same pattern as boocode)
│ ├── db.ts # pg pool (shared boocode_db)
│ ├── routes/
│ │ ├── health.ts
│ │ └── terminals.ts # POST /api/term/sessions/:sid/panes/:pid/start (creates tmux window)
│ ├── pty/
│ │ ├── manager.ts # tmux process management
│ │ └── pty.ts # node-pty wrapper for `tmux attach -t ... -d`
│ └── ws/
│ └── attach.ts # WS /ws/term/sessions/:sid/panes/:pid → PTY bidi pipe
├── package.json
└── tsconfig.json
Endpoints
| Method | Path | Notes |
|---|---|---|
| GET | /api/term/health |
Ping |
| POST | /api/term/sessions/:sid/panes/:pid/start |
Idempotent tmux window create. Returns {tmux_window: "term-<pid>"} |
| WS | /ws/term/sessions/:sid/panes/:pid |
Attach PTY |
| POST | /api/term/sessions/:sid/panes/:pid/resize |
{cols, rows} |
| POST | /api/term/sessions/:sid/panes/:pid/kill |
Kill the tmux window |
WS frames (binary or text):
client → server: pty input (raw bytes, typed by user)
server → client: pty output (raw bytes from shell)
server → client: {type: "exit", code} on window close
Auth + scoping
Remote-Userrequired on WS upgrade.session_idvalidated: lookup insessionstable; require row exists.pane_idvalidated: must exist insession_paneswithkind = 'terminal'and matchingsession_id.- Project root derived from
sessions.project_id → projects.root_path. tmux startscd <root>in that dir. No chroot. User cancd /and read anything mounted into the container.- Future hardening: namespace/chroot. Out of v1.1 scope.
tmux config
apps/booterm/tmux.conf bundled into image at /etc/booterm/tmux.conf; tmux invocations use -f /etc/booterm/tmux.conf:
set -g default-terminal "screen-256color"
set -g history-limit 50000
set -g mouse on
setw -g mode-keys vi
set -g status off
set -g destroy-unattached off
Boolab pattern (from services/tmux_session.py).
Frontend
| File | Change |
|---|---|
apps/web/src/components/panes/TerminalPane.tsx (NEW) |
xterm.js mount, WS attach, resize handler |
apps/web/src/api/client.ts |
api.terminals.start(sessionId, paneId), api.terminals.resize(...), api.terminals.kill(...) |
apps/web/src/components/Workspace.tsx |
Add 'terminal' to the pane kind enum; spawn button → POST start → render TerminalPane. Tab UI lives in Workspace.tsx — there is no PaneTab.tsx file. |
apps/web/package.json |
xterm + xterm-addon-fit + xterm-addon-web-links |
TerminalPane
useEffect(() => {
const term = new Terminal({ fontFamily: 'JetBrains Mono', fontSize: 14, theme: ... });
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(containerRef.current);
fit.fit();
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${window.location.host}/ws/term/sessions/${sid}/panes/${pid}`);
ws.binaryType = 'arraybuffer';
ws.onmessage = e => term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data));
term.onData(data => ws.send(data));
term.onResize(({ cols, rows }) => api.terminals.resize(sid, pid, cols, rows));
const ro = new ResizeObserver(() => fit.fit());
ro.observe(containerRef.current);
return () => { ws.close(); term.dispose(); ro.disconnect(); };
}, [sid, pid]);
Dev: vite.config.ts needs /api/term and /ws/term proxy entries mirroring the existing /api and /ws ones.
Send-to-terminal from chat
Boolab pattern: select text in a message → "Send to terminal" button → text becomes terminal input.
- Right-click context menu on selected text in chat → "Send to terminal" submenu lists open terminal panes.
- Click target → sends
<text>\nto that pane's WS.
Implementation:
| File | Change |
|---|---|
apps/web/src/components/MessageBubble.tsx |
Selection handler + context menu |
apps/web/src/lib/events.ts |
New event send_to_terminal with payload {pane_id, text} |
apps/web/src/components/panes/TerminalPane.tsx |
Subscribe to event for its pane_id, write to WS |
Docker compose (already applied)
booterm service is already in docker-compose.yml with:
- build context
., dockerfileapps/booterm/Dockerfile - port
100.114.205.53:9501:3000 /opt:/opt:rwmountDATABASE_URLenv pointing atboocode_dbboocode_netnetwork- depends_on:
boocode_db
Do not re-edit compose.
Backend dependencies
apps/booterm/package.json:
fastify@fastify/websocketpgzodnode-ptytslib
node-pty requires native build. Dockerfile installs python3 make g++ in build stage and tmux in runtime stage:
FROM node:20-alpine AS build
RUN apk add --no-cache python3 make g++ tmux
WORKDIR /app
COPY ...
RUN pnpm install --frozen-lockfile && pnpm build
FROM node:20-alpine
RUN apk add --no-cache tmux
WORKDIR /app
COPY --from=build /app/apps/booterm/dist ./dist
COPY --from=build /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
Files to touch
New app:
apps/booterm/(entire subtree)
Existing changes:
apps/web/package.jsonapps/web/src/api/client.tsapps/web/src/api/types.tsapps/web/src/components/Workspace.tsxapps/web/src/components/MessageBubble.tsxapps/web/src/components/panes/TerminalPane.tsx(NEW)apps/web/src/lib/events.tsapps/web/vite.config.ts(proxy entries)
Already done by user — do not touch:
docker-compose.yml(booterm service added)apps/server/src/schema.sql(terminal CHECK constraint)- Live DB constraint applied
Verification
docker compose up -d --build booterm→ container healthy.curl -s http://100.114.205.53:9501/api/term/health -H 'Remote-User: sam'→ 200.- Browser smoke test:
- Open a session. Workspace → "+ Terminal" → terminal pane appears with shell prompt in project root.
- Type
ls -la→ output. - Type
vim test.txt, write something, save,:q→ file exists on host (since rw mount). - Refresh browser → terminal reconnects, history intact (tmux persistence).
- Open second terminal pane → same project, separate tmux window. Both work independently.
- Select code in chat → right-click → "Send to terminal" → terminal pane receives the text.
- Container restart (
docker compose restart booterm) → on reconnect, tmux session resumes from where it left off. - Close pane via tab context menu → tmux window killed. Reopen pane → fresh shell.
Constraints
- node-pty is a native dep. Image size grows.
- tmux history capped at 50k lines per window.
- WebSocket frames are bidirectional binary;
binaryType = 'arraybuffer'. - Resize debounced 100ms client-side; backend
tmux resize-windowper resize. - No chroot/namespace isolation in v1.1. User has full read+write under
/opt/. Acceptable for single-user homelab. - Don't expose 9501 on 0.0.0.0. Tailscale binding only (already configured in compose).
Open
- Color theme matching for xterm.js. Defer.
- File-drop into terminal (upload via terminal pane). Out of scope.
- Multi-user (each user gets own tmux server) — defer until BooCode goes multi-user, which isn't planned.
- BooCoder container — same skeleton as booterm but with edit_file / create_file tools instead of PTY. Will follow this pattern when built.