Files
boocode/boocode_batch10.md

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

  1. New container booterm running Fastify + node-pty + tmux. Per-session tmux session keyed by (user, session_id).
  2. xterm.js terminal pane in the BooCode shell. Multiple terminal panes per session, each attached to a separate tmux window.
  3. PTY traffic over WebSocket. Auth via Remote-User.
  4. tmux as session manager so terminals survive WebSocket reconnects, page refreshes, even container restarts.
  5. Read+write capability scoped to project root. No cd .. escape.
  6. 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-User required on WS upgrade.
  • session_id validated: lookup in sessions table; require row exists.
  • pane_id validated: must exist in session_panes with kind = 'terminal' and matching session_id.
  • Project root derived from sessions.project_id → projects.root_path. tmux starts cd <root> in that dir. No chroot. User can cd / 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>\n to 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 ., dockerfile apps/booterm/Dockerfile
  • port 100.114.205.53:9501:3000
  • /opt:/opt:rw mount
  • DATABASE_URL env pointing at boocode_db
  • boocode_net network
  • depends_on: boocode_db

Do not re-edit compose.

Backend dependencies

apps/booterm/package.json:

  • fastify
  • @fastify/websocket
  • pg
  • zod
  • node-pty
  • tslib

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.json
  • apps/web/src/api/client.ts
  • apps/web/src/api/types.ts
  • apps/web/src/components/Workspace.tsx
  • apps/web/src/components/MessageBubble.tsx
  • apps/web/src/components/panes/TerminalPane.tsx (NEW)
  • apps/web/src/lib/events.ts
  • apps/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

  1. docker compose up -d --build booterm → container healthy.
  2. curl -s http://100.114.205.53:9501/api/term/health -H 'Remote-User: sam' → 200.
  3. 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-window per 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.