From 2d841ee0b446ecfa91bd9b30c26f1f1c12a89b9f Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Wed, 20 May 2026 14:56:02 +0000 Subject: [PATCH] handoff --- CLAUDE.md | 9 + apps/booterm/Dockerfile | 5 +- apps/booterm/src/pty/manager.ts | 166 ++-- apps/booterm/src/pty/pty.ts | 19 +- apps/booterm/src/routes/terminals.ts | 85 +- apps/booterm/src/ws/attach.ts | 112 ++- apps/booterm/tmux.conf | 18 +- apps/web/package.json | 11 +- apps/web/src/App.tsx | 7 +- apps/web/src/api/client.ts | 25 +- .../web/src/components/panes/TerminalPane.tsx | 875 ++++++++++++------ apps/web/src/hooks/useWorkspacePanes.ts | 11 +- apps/web/src/main.tsx | 5 + apps/web/src/styles/globals.css | 96 +- pnpm-lock.yaml | 100 +- 15 files changed, 1041 insertions(+), 503 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9243dac..d35f05d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,13 @@ Key patterns: - **`hooks/useSidebar.ts`** — Module-singleton with Set subscriber pattern; one bus subscription guarded by `globalThis.__boocode_sidebar_subscribed` for HMR safety. Every new `SessionEvent` type needs a `case` in the `applyEvent` switch (no-op `return prev` is fine). - **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace. +Font / CSS pipeline (apps/web): +- Tailwind v4's `@import "tailwindcss"` directive strips font URLs from subsequent CSS `@import`s — `@fontsource*` packages must be imported as JS side-effect modules in `apps/web/src/main.tsx`, not via `@import` in `globals.css`. Otherwise the woff2 files never make it to `dist/`. +- Lightning CSS (inside `@tailwindcss/postcss` v4) collapses contiguous unicode-ranges to wildcard shorthand (`U+0000-FFFF` → `U+????`), which iOS Safari/Vivaldi mishandles (silently drops the font from those codepoints). Use explicit non-wildcard-collapsible subranges (e.g. `U+2500-259F` not `U+2500-25FF`). The `apps/web` build script greps `dist/assets/*.css` for `U+2500-259F` and fails the build if missing — preserve that guard. +- `@font-face` blocks must live AFTER all `@import` statements (CSS spec). Earlier placement silently breaks every subsequent `@import` (this broke the 18 theme palette imports in globals.css for one session). +- JetBrainsMono Nerd Font self-hosted in `apps/web/src/fonts/` (TTF from ryanoasis/nerd-fonts release) — needed because `@fontsource-variable/jetbrains-mono` ships subsetted woff2s that don't cover `U+2500-259F` (box drawing + block elements, used by opencode's banner). "NL" = No Ligatures (matches `font-feature-settings: "liga" 0`); "Mono" = single-cell icon width so TUI layouts don't desync. +- xterm-addon-webgl rasterizes glyphs via Canvas2D into a GPU texture atlas. Canvas2D does NOT honor `font-display: block` — it uses whatever font is currently registered. Gate xterm initialization on `document.fonts.load()` resolving before calling `term.open()` (see `fontsReady` useState in `TerminalPane.tsx`). iOS Safari/Vivaldi also reclaims WebGL contexts from backgrounded tabs: keep `webgl.onContextLoss(() => webgl.dispose())` + recreate via visibilitychange. Do NOT manually dispose+recreate the addon after font load — iOS silently fails the second GL context creation and the terminal drops to DOM renderer with stale metrics. + ### Data flow for chat 1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows @@ -105,6 +112,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0 - node-pty's compiled `.node` is libc-specific: proddeps and runtime Dockerfile stages must share libc (alpine↔musl or bookworm-slim↔glibc); the TS-only builder stage can stay alpine for speed. - pnpm 10 `--frozen-lockfile` skips node-pty's postinstall — the Docker proddeps stage runs `cd node_modules/node-pty && npm run install` to force the native compile. - A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted. +- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity. +- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine. ## Conventions diff --git a/apps/booterm/Dockerfile b/apps/booterm/Dockerfile index 788805a..163017d 100644 --- a/apps/booterm/Dockerfile +++ b/apps/booterm/Dockerfile @@ -39,8 +39,11 @@ RUN test -f node_modules/node-pty/build/Release/pty.node && echo "pty.node OK" | # host's nvm node) run inside the container when invoked from the terminal # pane. Side-effect: su-exec is alpine-only — Debian replacement is gosu. FROM node:20-bookworm-slim AS runtime +# v1.10.8d: openssh-client added so the terminal can ssh -t samkintop@host +# (matching boolab's pattern) — that's how the in-pane shell gets access to +# host tools (docker, claude, opencode) that don't exist inside the container. RUN apt-get update && apt-get install -y --no-install-recommends \ - tmux bash gosu ca-certificates procps \ + tmux bash gosu ca-certificates procps openssh-client \ && rm -rf /var/lib/apt/lists/* # Mirror uid/gid 1000:1000 from the host so the bind-mounted /home/samkintop # (added in docker-compose) is owned by the user from the container's view. diff --git a/apps/booterm/src/pty/manager.ts b/apps/booterm/src/pty/manager.ts index e164b5c..dc148e4 100644 --- a/apps/booterm/src/pty/manager.ts +++ b/apps/booterm/src/pty/manager.ts @@ -1,7 +1,6 @@ import { spawn } from 'node:child_process'; import type { FastifyBaseLogger } from 'fastify'; -// UUIDs already match [0-9a-f-]; allow uppercase and longer just in case. const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/; export function sanitizeId(raw: string): string | null { @@ -9,12 +8,15 @@ export function sanitizeId(raw: string): string | null { return raw.toLowerCase(); } -export function tmuxSessionName(sessionId: string): string { - return `bc-${sessionId}`; -} - -export function tmuxWindowName(paneId: string): string { - return `term-${paneId}`; +// v1.10.8c: per-pane tmux sessions (boolab pattern). Previously booterm used +// one tmux session per chat-session with one window per pane; that meant the +// session-level window-size policy was shared across panes, and +// `attach-session -d` (used to take over from a stale browser) would detach +// every other pane attached to the same session — the "[detached]" bug. +// Now each pane gets its own tmux session named `bc-`. The bc- prefix +// namespaces booterm sessions on the shared tmux server. +export function tmuxSessionName(paneId: string): string { + return `bc-${paneId}`; } interface CmdResult { @@ -23,15 +25,17 @@ interface CmdResult { code: number; } -// Wrap child_process.spawn with shell:false so each argv element is passed -// as a separate argument — no shell interpolation, no injection surface. function runTmux(tmuxConfPath: string, args: string[]): Promise { return new Promise((resolve) => { const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false }); let stdout = ''; let stderr = ''; - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); }); - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); child.on('error', (err) => { resolve({ stdout, stderr: stderr + String(err), code: 1 }); }); @@ -46,57 +50,115 @@ export async function hasSession(tmuxConfPath: string, sessionName: string): Pro return res.code === 0; } -export async function listWindows(tmuxConfPath: string, sessionName: string): Promise { - const res = await runTmux(tmuxConfPath, ['list-windows', '-t', sessionName, '-F', '#{window_name}']); - if (res.code !== 0) return []; - return res.stdout.trim().split('\n').filter(Boolean); +// Default fallback size — wider than any real terminal would care about; the +// real client size lands via the WS resize frame within a few ms of attach. +const DEFAULT_COLS = 200; +const DEFAULT_ROWS = 50; + +// v1.10.8d: per-pane shell is `ssh -t samkintop@SSH_HOST` (matches boolab's +// pattern). The container has no docker / claude / opencode binaries; SSH'ing +// to the host gives the user their full normal shell environment. Default is +// the host's Tailscale IP (100.114.205.53) — the hostname `ubuntu-homelab` +// only resolves on the host's local /etc/hosts, not from inside containers, +// so SSH'ing to the hostname fails with `Could not resolve hostname` even +// though the host machine is reachable. Boolab uses the same IP. +const SSH_HOST = process.env['BOOTERM_SSH_HOST']?.trim() || '100.114.205.53'; +const SSH_USER = process.env['BOOTERM_SSH_USER']?.trim() || 'samkintop'; + +// POSIX shell single-quote escape: wrap in '…', escape embedded singles by +// closing-the-quote, inserting an escaped quote, and re-opening. +function shellEscape(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; } -export async function killWindow( +// Idempotent. Creates the tmux session if it doesn't exist, sized via -x/-y +// from the client's measured xterm dimensions. With `window-size = largest` +// + `aggressive-resize on` in tmux.conf, the attached client's actual size +// wins once it reports in — but seeding at the right size avoids the brief +// window where bash/TUI inherits the default 80x24 from a stale fallback. +export async function ensureSession( + tmuxConfPath: string, + sessionName: string, + projectRoot: string, + log: FastifyBaseLogger, + cols?: number, + rows?: number, +): Promise { + if (await hasSession(tmuxConfPath, sessionName)) return; + const sizeCols = cols && cols > 0 ? Math.floor(cols) : DEFAULT_COLS; + const sizeRows = rows && rows > 0 ? Math.floor(rows) : DEFAULT_ROWS; + // Bypass tmux.conf's default-command — build the per-pane argv explicitly + // so we can wrap ssh in the gosu privilege drop. The remote shell sequence + // (per boolab's invariants in services/tmux_session.py target_cmd_for): + // 1. ssh's argv must flatten into a single quoted bash -lc