Compare commits
19 Commits
d85b17081e
...
v1.11.0-co
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a5cf0c81a | |||
| 89dcfb95dc | |||
| 8cd270a5da | |||
| c48de06f42 | |||
| dc43dd44f9 | |||
| 6aab4f7d2a | |||
| 2d841ee0b4 | |||
| 8cea4a899c | |||
| 3fceea064a | |||
| fccab20920 | |||
| ea9d261f0f | |||
| 4d466c5710 | |||
| 875db86e31 | |||
| 8eaf9591dc | |||
| 5d52b79a07 | |||
| ead7cb9d01 | |||
| d04b30687f | |||
| 9250632ac3 | |||
| 7486e7d3e0 |
21
CLAUDE.md
21
CLAUDE.md
@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
|
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
|
||||||
|
|
||||||
|
Plus `apps/booterm` (second container, port 9501, bookworm-slim+glibc): Fastify + node-pty + tmux. Browser terminal panes WS to `/ws/term/sessions/:sid/panes/:pid`; per-session tmux session `bc-<sid>`, per-pane window `term-<pid>`. Shells drop privs to samkintop via `gosu` in `tmux.conf` default-command.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -35,7 +37,7 @@ Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres) and `apps/web` (React + Vite).
|
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres), `apps/web` (React + Vite), and `apps/booterm` (Fastify + node-pty + tmux).
|
||||||
|
|
||||||
### Server (`apps/server/src/`)
|
### Server (`apps/server/src/`)
|
||||||
|
|
||||||
@@ -66,6 +68,13 @@ Key patterns:
|
|||||||
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> 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).
|
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> 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.
|
- **`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(<font-name>)` 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
|
### Data flow for chat
|
||||||
|
|
||||||
1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows
|
1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows
|
||||||
@@ -99,6 +108,12 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||||
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
||||||
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
||||||
|
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
|
||||||
|
- 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
|
## Conventions
|
||||||
|
|
||||||
@@ -109,3 +124,7 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
||||||
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
||||||
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
||||||
|
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
|
||||||
|
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
|
||||||
|
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
|
||||||
|
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
|
||||||
|
|||||||
67
apps/booterm/Dockerfile
Normal file
67
apps/booterm/Dockerfile
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
# ---- Build stage: compile TypeScript ----
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
ENV COREPACK_DEFAULT_TO_LATEST=0
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
WORKDIR /build
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||||
|
COPY apps/server/package.json ./apps/server/
|
||||||
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
COPY apps/booterm/package.json ./apps/booterm/
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY apps/booterm ./apps/booterm
|
||||||
|
RUN pnpm --filter=@boocode/booterm build
|
||||||
|
|
||||||
|
# ---- Prod-deps stage: hoisted, native built via npm rebuild ----
|
||||||
|
# v1.10.2: switched to bookworm-slim (glibc) so node-pty's native .node is
|
||||||
|
# compiled against the same libc as the runtime stage. A musl-built .node
|
||||||
|
# won't dlopen in a glibc node binary, so both stages must match.
|
||||||
|
FROM node:20-bookworm-slim AS proddeps
|
||||||
|
ENV COREPACK_DEFAULT_TO_LATEST=0
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /prod
|
||||||
|
COPY apps/booterm/package.json ./package.json
|
||||||
|
RUN pnpm install --prod --config.node-linker=hoisted --config.strict-peer-dependencies=false
|
||||||
|
# pnpm 10 ignores build scripts; force compile with npm directly.
|
||||||
|
# node-gyp is bundled with npm in the node:20-bookworm-slim image.
|
||||||
|
RUN cd node_modules/node-pty && npm run install
|
||||||
|
# Sanity check — fail the build if the artifact still isn't there
|
||||||
|
RUN test -f node_modules/node-pty/build/Release/pty.node && echo "pty.node OK" || (echo "pty.node MISSING" && exit 1)
|
||||||
|
|
||||||
|
# ---- Runtime ----
|
||||||
|
# v1.10.2: switched from node:20-alpine (musl) to node:20-bookworm-slim (glibc)
|
||||||
|
# so glibc-linked binaries from /home/samkintop (Claude Code, opencode, the
|
||||||
|
# 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 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.
|
||||||
|
# bookworm-slim ships a `node` user at 1000 — wipe whatever sits on uid/gid
|
||||||
|
# 1000 first, then create samkintop fresh.
|
||||||
|
RUN if id -u 1000 >/dev/null 2>&1; then \
|
||||||
|
userdel -r "$(id -un 1000)" 2>/dev/null || true; \
|
||||||
|
fi; \
|
||||||
|
if getent group 1000 >/dev/null 2>&1; then \
|
||||||
|
groupdel "$(getent group 1000 | cut -d: -f1)" 2>/dev/null || true; \
|
||||||
|
fi; \
|
||||||
|
groupadd -g 1000 samkintop && \
|
||||||
|
useradd -m -u 1000 -g 1000 -s /bin/bash samkintop
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /build/apps/booterm/dist ./dist
|
||||||
|
COPY --from=proddeps /prod/package.json ./package.json
|
||||||
|
COPY --from=proddeps /prod/node_modules ./node_modules
|
||||||
|
COPY apps/booterm/tmux.conf /etc/booterm/tmux.conf
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
27
apps/booterm/package.json
Normal file
27
apps/booterm/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@boocode/booterm",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/websocket": "^10.0.1",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"node-pty": "^1.0.0",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"tslib": "^2.6.3",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"tsx": "^4.16.2",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/booterm/src/auth.ts
Normal file
11
apps/booterm/src/auth.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
// Mirrors the boocode pattern: there is no app-layer auth — Authelia handles
|
||||||
|
// it at the reverse proxy (CLAUDE.md). All broker.publishUser calls use
|
||||||
|
// 'default' as the user key. We accept Remote-User when present (set by the
|
||||||
|
// proxy in prod) and fall back to 'default' on direct Tailscale access.
|
||||||
|
export function getUser(req: FastifyRequest): string {
|
||||||
|
const header = req.headers['remote-user'];
|
||||||
|
if (typeof header === 'string' && header.length > 0) return header;
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
26
apps/booterm/src/config.ts
Normal file
26
apps/booterm/src/config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const ConfigSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
LOG_LEVEL: z.string().default('info'),
|
||||||
|
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
let cached: Config | null = null;
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
if (cached) return cached;
|
||||||
|
const parsed = ConfigSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error('Invalid environment configuration:');
|
||||||
|
console.error(parsed.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
cached = parsed.data;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
46
apps/booterm/src/db.ts
Normal file
46
apps/booterm/src/db.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
let pool: pg.Pool | null = null;
|
||||||
|
|
||||||
|
export function getPool(databaseUrl: string): pg.Pool {
|
||||||
|
if (pool) return pool;
|
||||||
|
pool = new Pool({ connectionString: databaseUrl, max: 5, idleTimeoutMillis: 30_000 });
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
project_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
|
||||||
|
if (!pool) throw new Error('db pool not initialized');
|
||||||
|
const res = await pool.query<SessionInfo>(
|
||||||
|
`SELECT s.id, s.project_id, p.path AS project_path
|
||||||
|
FROM sessions s
|
||||||
|
JOIN projects p ON p.id = s.project_id
|
||||||
|
WHERE s.id = $1`,
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
return res.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pingDb(): Promise<boolean> {
|
||||||
|
if (!pool) return false;
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
if (pool) {
|
||||||
|
await pool.end();
|
||||||
|
pool = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/booterm/src/index.ts
Normal file
60
apps/booterm/src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import fastifyWebsocket from '@fastify/websocket';
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { getPool, closeDb } from './db.js';
|
||||||
|
import { registerHealthRoutes } from './routes/health.js';
|
||||||
|
import { registerTerminalRoutes } from './routes/terminals.js';
|
||||||
|
import { registerWsAttachRoute } from './ws/attach.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: config.LOG_LEVEL },
|
||||||
|
});
|
||||||
|
|
||||||
|
app.removeContentTypeParser(['application/json']);
|
||||||
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||||
|
const str = (body as string) ?? '';
|
||||||
|
if (str.trim().length === 0) {
|
||||||
|
done(null, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(str));
|
||||||
|
} catch (err) {
|
||||||
|
done(err as Error, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
getPool(config.DATABASE_URL);
|
||||||
|
|
||||||
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
|
registerHealthRoutes(app);
|
||||||
|
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
|
||||||
|
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
|
||||||
|
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
app.log.info(`received ${signal}, shutting down`);
|
||||||
|
try {
|
||||||
|
await app.close();
|
||||||
|
await closeDb();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
|
|
||||||
|
await app.listen({ port: config.PORT, host: config.HOST });
|
||||||
|
app.log.info(`booterm listening on http://${config.HOST}:${config.PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Fatal startup error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
164
apps/booterm/src/pty/manager.ts
Normal file
164
apps/booterm/src/pty/manager.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
|
||||||
|
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||||
|
|
||||||
|
export function sanitizeId(raw: string): string | null {
|
||||||
|
if (!ID_RE.test(raw)) return null;
|
||||||
|
return raw.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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-<paneId>`. The bc- prefix
|
||||||
|
// namespaces booterm sessions on the shared tmux server.
|
||||||
|
export function tmuxSessionName(paneId: string): string {
|
||||||
|
return `bc-${paneId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CmdResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTmux(tmuxConfPath: string, args: string[]): Promise<CmdResult> {
|
||||||
|
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.on('error', (err) => {
|
||||||
|
resolve({ stdout, stderr: stderr + String(err), code: 1 });
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
resolve({ stdout, stderr, code: code ?? 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasSession(tmuxConfPath: string, sessionName: string): Promise<boolean> {
|
||||||
|
const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]);
|
||||||
|
return res.code === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
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 <script>
|
||||||
|
// 2. -l on the outer bash sources ~/.profile on the remote (PATH etc.)
|
||||||
|
// 3. cd to projectRoot, then exec bash -l so the user lands in the repo
|
||||||
|
// /opt is bind-mounted host↔container, so projectRoot resolves to the
|
||||||
|
// same files on both sides.
|
||||||
|
const remoteScript = `cd ${shellEscape(projectRoot)} && exec bash -l`;
|
||||||
|
const remoteCmd = `bash -lc ${shellEscape(remoteScript)}`;
|
||||||
|
const argv = [
|
||||||
|
'new-session', '-d',
|
||||||
|
'-s', sessionName,
|
||||||
|
'-c', projectRoot,
|
||||||
|
'-x', String(sizeCols),
|
||||||
|
'-y', String(sizeRows),
|
||||||
|
'--',
|
||||||
|
// gosu drops privs from the container's root (tmux server runs as root)
|
||||||
|
// to samkintop:samkintop. env restores HOME/USER/SHELL so ssh finds the
|
||||||
|
// right ~/.ssh/id_ed25519 (key is mode 0600 and ssh refuses keys whose
|
||||||
|
// UID doesn't match the running user — both are 1000 here).
|
||||||
|
'gosu', 'samkintop:samkintop',
|
||||||
|
'env', 'HOME=/home/samkintop', 'USER=samkintop', 'SHELL=/bin/bash',
|
||||||
|
'ssh', '-t',
|
||||||
|
'-o', 'StrictHostKeyChecking=yes',
|
||||||
|
'-o', 'ServerAliveInterval=30',
|
||||||
|
'-o', 'ServerAliveCountMax=3',
|
||||||
|
`${SSH_USER}@${SSH_HOST}`,
|
||||||
|
remoteCmd,
|
||||||
|
];
|
||||||
|
log.info(
|
||||||
|
{ sessionName, projectRoot, cols: sizeCols, rows: sizeRows, sshTarget: `${SSH_USER}@${SSH_HOST}` },
|
||||||
|
'creating tmux session (ssh to host)',
|
||||||
|
);
|
||||||
|
const res = await runTmux(tmuxConfPath, argv);
|
||||||
|
if (res.code !== 0) {
|
||||||
|
log.error({ res }, 'tmux new-session failed');
|
||||||
|
throw new Error(`tmux new-session failed: ${res.stderr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function killSession(
|
||||||
|
tmuxConfPath: string,
|
||||||
|
sessionName: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const res = await runTmux(tmuxConfPath, ['kill-session', '-t', sessionName]);
|
||||||
|
return res.code === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.10.8c: capture-pane on WS attach to replay the buffer state to the fresh
|
||||||
|
// xterm (boolab pattern). `-e` preserves ANSI escape sequences so colours and
|
||||||
|
// cursor position survive the replay. Returns empty string on failure — the
|
||||||
|
// client falls back to whatever tmux itself decides to repaint, which is
|
||||||
|
// non-fatal but visually noisier.
|
||||||
|
//
|
||||||
|
// v1.10.8d: strip trailing blank rows. tmux capture-pane emits one `\n` per
|
||||||
|
// pane row (including all the empty rows below the actual content), so on a
|
||||||
|
// fresh 35-row pane with just the bash prompt at row 0, the output is
|
||||||
|
// `<prompt>` followed by 35 `\n` bytes. When xterm.write()s those naively,
|
||||||
|
// the cursor advances row-by-row until it hits the bottom of the canvas and
|
||||||
|
// scrolls — pushing the prompt into the scrollback buffer where the user
|
||||||
|
// can't see it. Stripping the trailing newlines leaves xterm's cursor at the
|
||||||
|
// natural end of the rendered content (matching tmux's actual cursor
|
||||||
|
// position for the common single-line-prompt case).
|
||||||
|
export async function capturePane(
|
||||||
|
tmuxConfPath: string,
|
||||||
|
sessionName: string,
|
||||||
|
lines: number = 2000,
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await runTmux(tmuxConfPath, [
|
||||||
|
'capture-pane', '-t', sessionName, '-p', '-e', '-S', `-${lines}`,
|
||||||
|
]);
|
||||||
|
if (res.code !== 0) return '';
|
||||||
|
return res.stdout.replace(/(?:\r?\n)+$/, '');
|
||||||
|
}
|
||||||
48
apps/booterm/src/pty/pty.ts
Normal file
48
apps/booterm/src/pty/pty.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as pty from 'node-pty';
|
||||||
|
import type { IPty } from 'node-pty';
|
||||||
|
|
||||||
|
export interface AttachPtyOptions {
|
||||||
|
sessionName: string;
|
||||||
|
projectRoot: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
tmuxConfPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanEnv(): { [key: string]: string } {
|
||||||
|
const out: { [key: string]: string } = {};
|
||||||
|
for (const [k, v] of Object.entries(process.env)) {
|
||||||
|
if (typeof v === 'string') out[k] = v;
|
||||||
|
}
|
||||||
|
out['TERM'] = 'screen-256color';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.10.8c: no `-d` (multi-attach friendly — boolab pattern). With per-pane
|
||||||
|
// tmux sessions, dropping `-d` means multiple browser tabs viewing the same
|
||||||
|
// pane share one tmux session as N clients; tmux fans I/O at the session
|
||||||
|
// layer just like boolab's backend. The earlier `-d` flag detached EVERY
|
||||||
|
// other client of the session — across windows — which caused the
|
||||||
|
// "[detached] from session" bug whenever a new pane attached to a chat
|
||||||
|
// session that already had another pane open.
|
||||||
|
//
|
||||||
|
// Tmux server + session persist across PTY exits, so a refresh resumes with
|
||||||
|
// full scrollback. Explicit destroy happens via the /kill route (called from
|
||||||
|
// the frontend when the user closes a pane).
|
||||||
|
export function attachPty(opts: AttachPtyOptions): IPty {
|
||||||
|
return pty.spawn(
|
||||||
|
'tmux',
|
||||||
|
[
|
||||||
|
'-f', opts.tmuxConfPath,
|
||||||
|
'attach-session',
|
||||||
|
'-t', opts.sessionName,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols: opts.cols,
|
||||||
|
rows: opts.rows,
|
||||||
|
cwd: opts.projectRoot,
|
||||||
|
env: cleanEnv(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/booterm/src/routes/health.ts
Normal file
9
apps/booterm/src/routes/health.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { pingDb } from '../db.js';
|
||||||
|
|
||||||
|
export function registerHealthRoutes(app: FastifyInstance): void {
|
||||||
|
app.get('/api/term/health', async () => {
|
||||||
|
const dbOk = await pingDb();
|
||||||
|
return { ok: true, db: dbOk };
|
||||||
|
});
|
||||||
|
}
|
||||||
93
apps/booterm/src/routes/terminals.ts
Normal file
93
apps/booterm/src/routes/terminals.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getSessionInfo } from '../db.js';
|
||||||
|
import {
|
||||||
|
sanitizeId,
|
||||||
|
tmuxSessionName,
|
||||||
|
ensureSession,
|
||||||
|
killSession,
|
||||||
|
hasSession,
|
||||||
|
} from '../pty/manager.js';
|
||||||
|
|
||||||
|
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
|
||||||
|
// v1.10.8c: optional cols/rows on /start so the per-pane tmux session is
|
||||||
|
// born at the right dimensions. Bodyless POSTs remain valid (Fastify's
|
||||||
|
// tolerant parser).
|
||||||
|
const StartBodySchema = z
|
||||||
|
.object({
|
||||||
|
cols: z.coerce.number().int().min(1).max(2000).optional(),
|
||||||
|
rows: z.coerce.number().int().min(1).max(2000).optional(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void {
|
||||||
|
// v1.10.8c: /start creates the per-pane tmux session. Idempotent — a second
|
||||||
|
// /start on the same paneId is a no-op (hasSession returns true). The WS
|
||||||
|
// attach handler also calls ensureSession as belt-and-suspenders, so /start
|
||||||
|
// is technically optional, but having it as a separate step surfaces tmux
|
||||||
|
// errors as HTTP responses (vs WS 1011 close codes).
|
||||||
|
app.post<{
|
||||||
|
Params: { sid: string; pid: string };
|
||||||
|
Body: { cols?: number; rows?: number } | undefined;
|
||||||
|
}>(
|
||||||
|
'/api/term/sessions/:sid/panes/:pid/start',
|
||||||
|
async (req, reply) => {
|
||||||
|
const p = ParamsSchema.safeParse(req.params);
|
||||||
|
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
|
||||||
|
const sid = sanitizeId(p.data.sid);
|
||||||
|
const pid = sanitizeId(p.data.pid);
|
||||||
|
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
|
||||||
|
|
||||||
|
const b = StartBodySchema.safeParse(req.body ?? {});
|
||||||
|
const cols = b.success ? b.data?.cols : undefined;
|
||||||
|
const rows = b.success ? b.data?.rows : undefined;
|
||||||
|
|
||||||
|
const session = await getSessionInfo(sid);
|
||||||
|
if (!session) return reply.code(404).send({ error: 'unknown_session' });
|
||||||
|
|
||||||
|
const sessionName = tmuxSessionName(pid);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSession(
|
||||||
|
tmuxConfPath,
|
||||||
|
sessionName,
|
||||||
|
session.project_path,
|
||||||
|
req.log,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error({ err }, 'ensureSession failed');
|
||||||
|
return reply.code(500).send({ error: 'tmux_failed' });
|
||||||
|
}
|
||||||
|
return reply.code(200).send({ tmux_session: sessionName });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// v1.10.8c: explicit pane teardown. Frontend calls this when the user
|
||||||
|
// intentionally closes a terminal pane (vs an implicit WS disconnect, which
|
||||||
|
// leaves the tmux session intact for refresh-driven resume).
|
||||||
|
app.post<{ Params: { sid: string; pid: string } }>(
|
||||||
|
'/api/term/sessions/:sid/panes/:pid/kill',
|
||||||
|
async (req, reply) => {
|
||||||
|
const p = ParamsSchema.safeParse(req.params);
|
||||||
|
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
|
||||||
|
const sid = sanitizeId(p.data.sid);
|
||||||
|
const pid = sanitizeId(p.data.pid);
|
||||||
|
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
|
||||||
|
|
||||||
|
const sessionName = tmuxSessionName(pid);
|
||||||
|
if (!(await hasSession(tmuxConfPath, sessionName))) {
|
||||||
|
return reply.code(404).send({ error: 'unknown_pane' });
|
||||||
|
}
|
||||||
|
const killed = await killSession(tmuxConfPath, sessionName);
|
||||||
|
if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' });
|
||||||
|
return reply.code(200).send({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resize endpoint removed in v1.10.8c. Resize now flows in-band via the
|
||||||
|
// WebSocket as a `{type:"resize",cols,rows}` text frame — no more race
|
||||||
|
// between active-PTY-map registration and HTTP POST lookup. See ws/attach.ts.
|
||||||
|
}
|
||||||
168
apps/booterm/src/ws/attach.ts
Normal file
168
apps/booterm/src/ws/attach.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { IPty } from 'node-pty';
|
||||||
|
import { getSessionInfo } from '../db.js';
|
||||||
|
import {
|
||||||
|
sanitizeId,
|
||||||
|
tmuxSessionName,
|
||||||
|
ensureSession,
|
||||||
|
capturePane,
|
||||||
|
} from '../pty/manager.js';
|
||||||
|
import { attachPty } from '../pty/pty.js';
|
||||||
|
import { getUser } from '../auth.js';
|
||||||
|
|
||||||
|
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
||||||
|
app.get<{
|
||||||
|
Params: { sid: string; pid: string };
|
||||||
|
Querystring: { cols?: string; rows?: string };
|
||||||
|
}>(
|
||||||
|
'/ws/term/sessions/:sid/panes/:pid',
|
||||||
|
{ websocket: true },
|
||||||
|
async (socket, req) => {
|
||||||
|
const sid = sanitizeId(req.params.sid);
|
||||||
|
const pid = sanitizeId(req.params.pid);
|
||||||
|
if (!sid || !pid) {
|
||||||
|
socket.close(1008, 'bad_id_format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = getUser(req);
|
||||||
|
req.log.info({ user, sid, pid }, 'ws attach');
|
||||||
|
|
||||||
|
const session = await getSessionInfo(sid);
|
||||||
|
if (!session) {
|
||||||
|
socket.close(1008, 'unknown_session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionName = tmuxSessionName(pid);
|
||||||
|
const cols = parseInt(req.query.cols ?? '', 10) || 80;
|
||||||
|
const rows = parseInt(req.query.rows ?? '', 10) || 24;
|
||||||
|
|
||||||
|
// Idempotent — /start typically created the session already, but cover
|
||||||
|
// the race where the client opens the WS before /start's response lands
|
||||||
|
// (or skips /start entirely). With per-pane tmux sessions there's no
|
||||||
|
// cross-pane interference, so creating-on-attach is safe.
|
||||||
|
try {
|
||||||
|
await ensureSession(
|
||||||
|
tmuxConfPath,
|
||||||
|
sessionName,
|
||||||
|
session.project_path,
|
||||||
|
req.log,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error({ err }, 'ensureSession failed in WS handler');
|
||||||
|
socket.close(1011, 'tmux_failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle: IPty;
|
||||||
|
try {
|
||||||
|
handle = attachPty({
|
||||||
|
sessionName,
|
||||||
|
projectRoot: session.project_path,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
tmuxConfPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error({ err }, 'attachPty failed');
|
||||||
|
socket.close(1011, 'pty_spawn_failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame contract (boolab pattern):
|
||||||
|
// server → client text: JSON control — `init` on connect, `exit` on PTY death
|
||||||
|
// server → client binary: raw PTY bytes (first frame after init = capture-pane replay)
|
||||||
|
// client → server binary: user keystrokes
|
||||||
|
// client → server text: JSON control — `{type:"resize", cols, rows}`
|
||||||
|
//
|
||||||
|
// The init frame lets the client term.clear() before paint so a remount
|
||||||
|
// doesn't show stale buffer content. The capture-pane replay then
|
||||||
|
// paints the current tmux pane state into the fresh xterm.
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify({ type: 'init', cols, rows, tmux_session: sessionName }));
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn({ err }, 'init frame send failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const capture = await capturePane(tmuxConfPath, sessionName);
|
||||||
|
if (capture.length > 0) {
|
||||||
|
socket.send(Buffer.from(capture, 'utf8'), { binary: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn({ err }, 'capture-pane failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const onData = (data: string): void => {
|
||||||
|
if (socket.readyState !== socket.OPEN) return;
|
||||||
|
try {
|
||||||
|
socket.send(Buffer.from(data, 'utf8'), { binary: true });
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn({ err }, 'ws send failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handle.onData(onData);
|
||||||
|
|
||||||
|
socket.on('message', (rawData: Buffer | string, isBinary?: boolean) => {
|
||||||
|
// ws v8 emits Buffer + isBinary boolean; older versions emit string
|
||||||
|
// for text frames. Either way: text path tries JSON parse for the
|
||||||
|
// resize control; binary path writes to the PTY.
|
||||||
|
const isTextFrame = typeof rawData === 'string' || isBinary === false;
|
||||||
|
if (isTextFrame) {
|
||||||
|
const text = typeof rawData === 'string' ? rawData : rawData.toString('utf8');
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { type?: string; cols?: number; rows?: number };
|
||||||
|
if (parsed.type === 'resize') {
|
||||||
|
const newCols = Math.max(1, Math.min(2000, Math.floor(Number(parsed.cols) || 80)));
|
||||||
|
const newRows = Math.max(1, Math.min(2000, Math.floor(Number(parsed.rows) || 24)));
|
||||||
|
req.log.info({ pid, cols: newCols, rows: newRows }, 'resize');
|
||||||
|
try {
|
||||||
|
handle.resize(newCols, newRows);
|
||||||
|
} catch {
|
||||||
|
/* ignore — invalid winsize bubble */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* malformed text frame — drop silently */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
handle.write((rawData as Buffer).toString('utf8'));
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn({ err }, 'pty write failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.onExit(({ exitCode }) => {
|
||||||
|
try {
|
||||||
|
if (socket.readyState === socket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: 'exit', code: exitCode }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
socket.close(1000);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// WS close kills the tmux client (the local PTY) but the tmux server +
|
||||||
|
// session persist — so a refresh resumes with full scrollback. Permanent
|
||||||
|
// teardown happens via the /kill route called from the frontend when the
|
||||||
|
// user closes the pane.
|
||||||
|
socket.on('close', () => {
|
||||||
|
try {
|
||||||
|
handle.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/booterm/tmux.conf
Normal file
30
apps/booterm/tmux.conf
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
set -g default-terminal "screen-256color"
|
||||||
|
set -g history-limit 50000
|
||||||
|
|
||||||
|
# v1.10.8c: per-pane tmux sessions (boolab pattern). With one session per
|
||||||
|
# pane, the session size adapts to the attached client; `window-size = largest`
|
||||||
|
# + `aggressive-resize on` make tmux pick up the client's actual cols/rows
|
||||||
|
# instead of falling back to 80x24. Critical for opencode/claude TUIs that
|
||||||
|
# read TIOCGWINSZ once at fork time.
|
||||||
|
set -g window-size largest
|
||||||
|
set -g aggressive-resize on
|
||||||
|
|
||||||
|
# v1.10.3: `set -g mouse on` removed. tmux's mouse mode captured wheel/touch
|
||||||
|
# events at the protocol level, so xterm.js never saw them and the viewport
|
||||||
|
# couldn't scroll on mobile. With mouse off, xterm.js handles scrollback
|
||||||
|
# natively (wheel on desktop, finger-drag on mobile via touch-action: pan-y).
|
||||||
|
# Tradeoff: lose tmux mouse pane-resize and scroll-inside-vim; acceptable for
|
||||||
|
# the homelab single-user setup.
|
||||||
|
set -g mouse off
|
||||||
|
setw -g mode-keys vi
|
||||||
|
set -g status off
|
||||||
|
set -g destroy-unattached off
|
||||||
|
|
||||||
|
# v1.10.1: shells drop privs to samkintop (uid 1000) so the terminal runs in
|
||||||
|
# the user's environment, not root. `env HOME=… USER=…` is required because
|
||||||
|
# gosu only changes uid/gid — env (including HOME) survives, and the tmux
|
||||||
|
# server runs as root so HOME would otherwise be /root. bash -l then sources
|
||||||
|
# samkintop's ~/.profile / ~/.bashrc to pick up PATH (nvm, ~/.local/bin,
|
||||||
|
# ~/.opencode/bin).
|
||||||
|
# v1.10.2: su-exec → gosu (alpine → debian; functionally identical).
|
||||||
|
set -g default-command "gosu samkintop:samkintop env HOME=/home/samkintop USER=samkintop SHELL=/bin/bash bash -l"
|
||||||
15
apps/booterm/tsconfig.json
Normal file
15
apps/booterm/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import { registerSkillsRoutes } from './routes/skills.js';
|
|||||||
import { createInferenceRunner } from './services/inference.js';
|
import { createInferenceRunner } from './services/inference.js';
|
||||||
import { createBroker } from './services/broker.js';
|
import { createBroker } from './services/broker.js';
|
||||||
import { listSkills } from './services/skills.js';
|
import { listSkills } from './services/skills.js';
|
||||||
|
import * as compaction from './services/compaction.js';
|
||||||
|
import { configureModelContext } from './services/model-context.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -47,6 +49,11 @@ async function main() {
|
|||||||
await applySchema(sql);
|
await applySchema(sql);
|
||||||
app.log.info('database schema applied');
|
app.log.info('database schema applied');
|
||||||
|
|
||||||
|
// v1.11.3: tell the model-context cache where llama-swap lives. Cache
|
||||||
|
// lookups go to ${LLAMA_SWAP_URL}/upstream/<model>/props to read
|
||||||
|
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
||||||
|
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
|
||||||
|
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
@@ -81,6 +88,11 @@ async function main() {
|
|||||||
publish: (sessionId, frame) => {
|
publish: (sessionId, frame) => {
|
||||||
broker.publish(sessionId, frame as unknown as Record<string, unknown> & { type: string });
|
broker.publish(sessionId, frame as unknown as Record<string, unknown> & { type: string });
|
||||||
},
|
},
|
||||||
|
// v1.11: broker handle for compaction.process to publish 'compacted'
|
||||||
|
// frames on the per-session channel. Inference's regular publish path
|
||||||
|
// is bound to (sessionId, InferenceFrame); compaction publishes a
|
||||||
|
// different frame shape, so it goes through the raw broker.
|
||||||
|
broker,
|
||||||
},
|
},
|
||||||
(user, frame) => {
|
(user, frame) => {
|
||||||
broker.publishUser(user, frame as unknown as Record<string, unknown> & { type: string });
|
broker.publishUser(user, frame as unknown as Record<string, unknown> & { type: string });
|
||||||
@@ -90,9 +102,13 @@ async function main() {
|
|||||||
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||||
},
|
},
|
||||||
enqueueCompact: (sessionId, chatId, compactId, user) => {
|
// v1.11: synchronous compaction. Awaits the LLM call inside the route's
|
||||||
inference.enqueueCompact(sessionId, chatId, compactId, user);
|
// request lifecycle; the new summary row arrives via the WS 'compacted'
|
||||||
},
|
// frame published from inside compaction.process. We let the error
|
||||||
|
// bubble up so the route can reply 500 — manual /compact failures
|
||||||
|
// should be loud (the user just clicked a button).
|
||||||
|
runCompaction: (chatId) =>
|
||||||
|
compaction.process({ sql, config, log: app.log, broker, chatId }),
|
||||||
cancelInference: async (sessionId, chatId) => {
|
cancelInference: async (sessionId, chatId) => {
|
||||||
return inference.cancel(sessionId, chatId);
|
return inference.cancel(sessionId, chatId);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -316,7 +316,8 @@ export function registerChatRoutes(
|
|||||||
}
|
}
|
||||||
const rows = await sql<Message[]>`
|
const rows = await sql<Message[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
|
summary, tail_start_id, compacted_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE chat_id = ${req.params.id}
|
WHERE chat_id = ${req.params.id}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
|
|||||||
@@ -49,7 +49,12 @@ const AskUserInputArgs = z.object({
|
|||||||
|
|
||||||
interface MessageHandlers {
|
interface MessageHandlers {
|
||||||
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
||||||
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
|
// v1.11: returns a promise that resolves after compaction.process finishes
|
||||||
|
// (await the LLM call). Throws on failure — the route surfaces a 500.
|
||||||
|
// Replaces the v1.10 enqueueCompact (which fired-and-forgot a kind='compact'
|
||||||
|
// streaming row). The new anchored-rolling strategy inserts a single
|
||||||
|
// summary=true assistant row only after the LLM responds.
|
||||||
|
runCompaction: (chatId: string) => Promise<void>;
|
||||||
publishUserMessage: (
|
publishUserMessage: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
chatId: string,
|
chatId: string,
|
||||||
@@ -81,9 +86,15 @@ export function registerMessageRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'session not found' };
|
return { error: 'session not found' };
|
||||||
}
|
}
|
||||||
|
// v1.11: returns ALL messages including compacted ones. The UI
|
||||||
|
// distinguishes via the new `summary` flag (renders an accordion
|
||||||
|
// SummaryCard) and shows compacted_at-stamped rows inline for context.
|
||||||
|
// Internal inference assembly filters compacted_at IS NULL separately —
|
||||||
|
// see services/inference.ts loadContext + services/compaction.ts.
|
||||||
const rows = await sql<Message[]>`
|
const rows = await sql<Message[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
|
summary, tail_start_id, compacted_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE session_id = ${req.params.id}
|
WHERE session_id = ${req.params.id}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
@@ -251,29 +262,30 @@ export function registerMessageRoutes(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v1.11: manual /compact. Was a streaming kind='compact' row inserted by
|
||||||
|
// this handler; now delegates to the anchored-rolling compaction service.
|
||||||
|
// Synchronous (we await the LLM call) — callers either await or rely on
|
||||||
|
// the 'compacted' WS frame to refresh their view. The response carries
|
||||||
|
// no body of interest; the new summary row arrives via the WS frame.
|
||||||
app.post<{ Params: { id: string } }>(
|
app.post<{ Params: { id: string } }>(
|
||||||
'/api/chats/:id/compact',
|
'/api/chats/:id/compact',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const chatRows = await sql<Chat[]>`
|
const chatRows = await sql<{ id: string }[]>`
|
||||||
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
SELECT id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||||
`;
|
`;
|
||||||
if (chatRows.length === 0) {
|
if (chatRows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'chat not found' };
|
return { error: 'chat not found' };
|
||||||
}
|
}
|
||||||
const chat = chatRows[0]!;
|
try {
|
||||||
const sessionId = chat.session_id;
|
await handlers.runCompaction(chatRows[0]!.id);
|
||||||
|
} catch (err) {
|
||||||
const [compactMsg] = await sql<{ id: string }[]>`
|
req.log.error({ err, chatId: chatRows[0]!.id }, 'manual compaction failed');
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, kind, status, created_at)
|
reply.code(500);
|
||||||
VALUES (${sessionId}, ${chat.id}, 'system', '', 'compact', 'streaming', clock_timestamp())
|
return { error: err instanceof Error ? err.message : 'compaction failed' };
|
||||||
RETURNING id
|
}
|
||||||
`;
|
reply.code(200);
|
||||||
|
return { ok: true };
|
||||||
handlers.enqueueCompact(sessionId, chat.id, compactMsg!.id, 'default');
|
|
||||||
|
|
||||||
reply.code(202);
|
|
||||||
return { compact_message_id: compactMsg!.id };
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ export function registerWebSocket(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.11: snapshot includes compaction fields so MessageBubble can
|
||||||
|
// render the SummaryCard for summary=true rows on first connect.
|
||||||
const messages = await sql<Message[]>`
|
const messages = await sql<Message[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
|
summary, tail_start_id, compacted_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE session_id = ${sessionId}
|
WHERE session_id = ${sessionId}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS session_panes (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
position INTEGER NOT NULL,
|
position INTEGER NOT NULL,
|
||||||
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser')),
|
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser', 'terminal')),
|
||||||
state JSONB NOT NULL DEFAULT '{}',
|
state JSONB NOT NULL DEFAULT '{}',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
UNIQUE (session_id, position)
|
UNIQUE (session_id, position)
|
||||||
@@ -179,3 +179,25 @@ INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (k
|
|||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
||||||
|
|
||||||
|
-- v1.11: anchored rolling compaction.
|
||||||
|
-- compacted_at — marks rows that are "behind the curtain" of the latest
|
||||||
|
-- summary. Inference assembly filters compacted_at IS NULL;
|
||||||
|
-- the API GET still returns all rows so the UI can show
|
||||||
|
-- history with the summary card inline.
|
||||||
|
-- summary — true on the assistant row that IS the anchored summary.
|
||||||
|
-- Exactly one row per chat is the "current" summary
|
||||||
|
-- (every prior summary row is itself compacted_at-stamped
|
||||||
|
-- when superseded, leaving one live anchor).
|
||||||
|
-- tail_start_id — points at the first preserved message that the summary
|
||||||
|
-- covers up to (exclusive). Lets the UI/debug reason about
|
||||||
|
-- the boundary without re-deriving from compacted_at.
|
||||||
|
-- needs_compaction — flag on chats (not sessions) because chat history is
|
||||||
|
-- per-chat; sessions have 1:N chats. Set true post-overflow,
|
||||||
|
-- cleared by compaction.process at the start of the next
|
||||||
|
-- inference turn.
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS compacted_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS summary BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
|
||||||
|
|||||||
258
apps/server/src/services/__tests__/compaction.test.ts
Normal file
258
apps/server/src/services/__tests__/compaction.test.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
usable,
|
||||||
|
isOverflow,
|
||||||
|
estimate,
|
||||||
|
turns,
|
||||||
|
select,
|
||||||
|
buildPrompt,
|
||||||
|
type CompactionMessage,
|
||||||
|
} from '../compaction.js';
|
||||||
|
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
|
||||||
|
|
||||||
|
// ---- fixture ----------------------------------------------------------------
|
||||||
|
// Tiny constructor for the message shape `compaction.ts` consumes. Default
|
||||||
|
// values match the post-CP1 schema (summary=false, kind='message', complete).
|
||||||
|
// Tests that need a summary row pass `summary: true`.
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
function mkMsg(
|
||||||
|
role: CompactionMessage['role'],
|
||||||
|
content: string,
|
||||||
|
overrides: Partial<CompactionMessage> = {},
|
||||||
|
): CompactionMessage {
|
||||||
|
counter += 1;
|
||||||
|
return {
|
||||||
|
id: `m${counter}`,
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
kind: 'message',
|
||||||
|
summary: false,
|
||||||
|
status: 'complete',
|
||||||
|
tool_calls: null,
|
||||||
|
tool_results: null,
|
||||||
|
metadata: null,
|
||||||
|
created_at: new Date(counter * 1000).toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- usable -----------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('usable', () => {
|
||||||
|
it('returns 0 when contextLimit is 0', () => {
|
||||||
|
expect(usable(0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when contextLimit is below the 20k buffer', () => {
|
||||||
|
// Math.max(0, x - 20000) clamps the subtraction so we never report
|
||||||
|
// negative headroom. A 10k-context model reports 0 usable, which makes
|
||||||
|
// isOverflow short-circuit to false (correct — we can't size the
|
||||||
|
// compaction with no headroom).
|
||||||
|
expect(usable(10_000)).toBe(0);
|
||||||
|
expect(usable(19_999)).toBe(0);
|
||||||
|
expect(usable(20_000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subtracts the 20k buffer from a normal-sized context window', () => {
|
||||||
|
expect(usable(100_000)).toBe(80_000);
|
||||||
|
expect(usable(32_768)).toBe(12_768);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- isOverflow -------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('isOverflow', () => {
|
||||||
|
it('returns false when usable is 0 (unknown / sub-buffer context)', () => {
|
||||||
|
expect(isOverflow({ prompt_tokens: 999_999, completion_tokens: 0 }, 0)).toBe(false);
|
||||||
|
expect(isOverflow({ prompt_tokens: 0, completion_tokens: 999_999 }, 10_000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false at 50% of usable', () => {
|
||||||
|
// usable(100k) = 80k → 50% = 40k.
|
||||||
|
expect(isOverflow({ prompt_tokens: 30_000, completion_tokens: 10_000 }, 100_000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false just under usable', () => {
|
||||||
|
expect(isOverflow({ prompt_tokens: 79_000, completion_tokens: 999 }, 100_000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true exactly at usable (>=, not strict >)', () => {
|
||||||
|
expect(isOverflow({ prompt_tokens: 80_000, completion_tokens: 0 }, 100_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true above usable', () => {
|
||||||
|
expect(isOverflow({ prompt_tokens: 50_000, completion_tokens: 40_000 }, 100_000)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- estimate ---------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('estimate', () => {
|
||||||
|
it('returns a tiny value for an empty array (JSON.stringify([]) is "[]")', () => {
|
||||||
|
// Math.ceil('[]'.length / 4) = 1. Documented here so the next reader
|
||||||
|
// doesn't think "0" is the expected baseline — char-count/4 will never
|
||||||
|
// be exactly 0 for any JSON-serializable input.
|
||||||
|
expect(estimate([])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales roughly with content length', () => {
|
||||||
|
const tiny = estimate([mkMsg('user', 'hi')]);
|
||||||
|
const big = estimate([mkMsg('user', 'x'.repeat(4000))]);
|
||||||
|
expect(big).toBeGreaterThan(tiny);
|
||||||
|
expect(big).toBeGreaterThanOrEqual(1000); // 4000 chars / 4 = 1000 floor
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic across repeated calls', () => {
|
||||||
|
const msgs = [mkMsg('user', 'one'), mkMsg('assistant', 'two')];
|
||||||
|
expect(estimate(msgs)).toBe(estimate(msgs));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- turns ------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('turns', () => {
|
||||||
|
it('returns [] for an empty message list', () => {
|
||||||
|
expect(turns([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns one turn for a single user message', () => {
|
||||||
|
const u = mkMsg('user', 'hi');
|
||||||
|
const result = turns([u]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({ start: 0, end: 1, id: u.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns two turns for user/assistant/user/assistant', () => {
|
||||||
|
const u1 = mkMsg('user', 'q1');
|
||||||
|
const a1 = mkMsg('assistant', 'a1');
|
||||||
|
const u2 = mkMsg('user', 'q2');
|
||||||
|
const a2 = mkMsg('assistant', 'a2');
|
||||||
|
const result = turns([u1, a1, u2, a2]);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ start: 0, end: 2, id: u1.id },
|
||||||
|
{ start: 2, end: 4, id: u2.id },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends the final turn end to include trailing non-user messages', () => {
|
||||||
|
// Spec wording: "user/assistant + trailing system → trailing included
|
||||||
|
// in last turn's range". Single-turn variant: [user, assistant, system]
|
||||||
|
// should produce one turn with end=3 (covers all three indices).
|
||||||
|
const u = mkMsg('user', 'q');
|
||||||
|
const a = mkMsg('assistant', 'a');
|
||||||
|
const s = mkMsg('system', 'note');
|
||||||
|
const result = turns([u, a, s]);
|
||||||
|
expect(result).toEqual([{ start: 0, end: 3, id: u.id }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips user rows flagged as summary (anchored-rolling rows)', () => {
|
||||||
|
// Defense-in-depth — process() pre-filters summary rows, but turns()
|
||||||
|
// also skips them so a misuse from another caller doesn't create a
|
||||||
|
// bogus turn boundary on the summary row itself.
|
||||||
|
const u1 = mkMsg('user', 'q1');
|
||||||
|
const a1 = mkMsg('assistant', 'a1');
|
||||||
|
const sum = mkMsg('user', 'rolled-up', { summary: true });
|
||||||
|
const u2 = mkMsg('user', 'q2');
|
||||||
|
const result = turns([u1, a1, sum, u2]);
|
||||||
|
expect(result.map((t) => t.id)).toEqual([u1.id, u2.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- select -----------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('select', () => {
|
||||||
|
it('returns empty head + undefined tail for an empty message list', () => {
|
||||||
|
const result = select([], 100_000);
|
||||||
|
expect(result.head).toEqual([]);
|
||||||
|
expect(result.tail_start_id).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full-preserves when there are fewer turns than tail_turns', () => {
|
||||||
|
// 1 turn but tail_turns=2: keep === turn0 → keep.start === 0 →
|
||||||
|
// sentinel-return path that signals "no compaction this round".
|
||||||
|
const u = mkMsg('user', 'only');
|
||||||
|
const a = mkMsg('assistant', 'a');
|
||||||
|
const result = select([u, a], 100_000, 2);
|
||||||
|
expect(result.head).toEqual([u, a]);
|
||||||
|
expect(result.tail_start_id).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the last tail_turns turns when they all fit the budget', () => {
|
||||||
|
// 3 turns, all small. tail_turns=2 means keep the last 2; head =
|
||||||
|
// messages[0..turn2.start] = just turn1's content.
|
||||||
|
const u1 = mkMsg('user', 'q1');
|
||||||
|
const a1 = mkMsg('assistant', 'a1');
|
||||||
|
const u2 = mkMsg('user', 'q2');
|
||||||
|
const a2 = mkMsg('assistant', 'a2');
|
||||||
|
const u3 = mkMsg('user', 'q3');
|
||||||
|
const a3 = mkMsg('assistant', 'a3');
|
||||||
|
const msgs = [u1, a1, u2, a2, u3, a3];
|
||||||
|
const result = select(msgs, 100_000, 2);
|
||||||
|
// Turn boundaries: [0,2), [2,4), [4,6). slice(-2) = turns at 2 and 4.
|
||||||
|
// Walking backward: u3 fits, then u2 fits → keep={start:2, id:u2.id}.
|
||||||
|
expect(result.tail_start_id).toBe(u2.id);
|
||||||
|
expect(result.head).toEqual([u1, a1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits a turn mid-stream when the whole turn would overflow the budget', () => {
|
||||||
|
// tail_turns=1 so we look only at the most recent turn. Stuff it past
|
||||||
|
// 8k of content (max preserve budget) and the splitter walks forward
|
||||||
|
// looking for the largest suffix that fits.
|
||||||
|
const u1 = mkMsg('user', 'q1');
|
||||||
|
const a1 = mkMsg('assistant', 'a1');
|
||||||
|
const u2 = mkMsg('user', 'q2 with a giant payload');
|
||||||
|
const huge = mkMsg('assistant', 'X'.repeat(40_000)); // ~10k tokens
|
||||||
|
const smallTail = mkMsg('assistant', 'short answer');
|
||||||
|
const msgs = [u1, a1, u2, huge, smallTail];
|
||||||
|
const result = select(msgs, 100_000, 1);
|
||||||
|
// The split walks from turn.start+1 forward; the first index whose
|
||||||
|
// [i, end) slice fits the budget becomes the new keep. We don't assert
|
||||||
|
// a specific id (depends on character math), only that compaction was
|
||||||
|
// triggered (tail_start_id set, head non-empty) and that the head
|
||||||
|
// doesn't include the final small message.
|
||||||
|
expect(result.tail_start_id).toBeDefined();
|
||||||
|
expect(result.head.length).toBeGreaterThan(0);
|
||||||
|
expect(result.head).not.toContain(smallTail);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full-preserves when no split point fits', () => {
|
||||||
|
// Single oversized turn; splitTurn walks but each suffix is still too
|
||||||
|
// big. After the loop, keep is undefined → full-preserve sentinel.
|
||||||
|
// Force this with a sub-buffer context so budget is the floor (2k),
|
||||||
|
// and a single 40k-char message.
|
||||||
|
const u = mkMsg('user', 'oversized');
|
||||||
|
const a = mkMsg('assistant', 'Y'.repeat(40_000));
|
||||||
|
const result = select([u, a], 30_000, 1);
|
||||||
|
// usable(30k) = 10k → budget = min(8k, max(2k, floor(10k*0.25))) =
|
||||||
|
// min(8k, max(2k, 2500)) = 2500. 40k chars ≈ 10k tokens. Can't fit.
|
||||||
|
expect(result.tail_start_id).toBeUndefined();
|
||||||
|
expect(result.head).toEqual([u, a]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- buildPrompt ------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('buildPrompt', () => {
|
||||||
|
it('opens with the "create new" anchor when previousSummary is undefined', () => {
|
||||||
|
const out = buildPrompt(undefined, []);
|
||||||
|
expect(out.startsWith('Create a new anchored summary')).toBe(true);
|
||||||
|
expect(out).toContain(SUMMARY_TEMPLATE);
|
||||||
|
expect(out).not.toContain('<previous-summary>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens with the "update" anchor and embeds previousSummary verbatim', () => {
|
||||||
|
const prev = '## Goal\n- finish v1.11 compaction';
|
||||||
|
const out = buildPrompt(prev, []);
|
||||||
|
expect(out.startsWith('Update the anchored summary')).toBe(true);
|
||||||
|
expect(out).toContain('<previous-summary>');
|
||||||
|
expect(out).toContain(prev);
|
||||||
|
expect(out).toContain('</previous-summary>');
|
||||||
|
expect(out).toContain(SUMMARY_TEMPLATE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends extra context strings after the template (reserved for plugin injection)', () => {
|
||||||
|
const out = buildPrompt(undefined, ['extra-context-line']);
|
||||||
|
expect(out.endsWith('extra-context-line')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
205
apps/server/src/services/__tests__/model-context.test.ts
Normal file
205
apps/server/src/services/__tests__/model-context.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
configureModelContext,
|
||||||
|
getModelContext,
|
||||||
|
invalidateModelContext,
|
||||||
|
} from '../model-context.js';
|
||||||
|
|
||||||
|
// ---- fixtures ---------------------------------------------------------------
|
||||||
|
|
||||||
|
const TEST_URL = 'http://llama-swap.test:8401';
|
||||||
|
|
||||||
|
function mockOkProps(n_ctx: number, total_slots = 1) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
default_generation_settings: { n_ctx },
|
||||||
|
total_slots,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
invalidateModelContext();
|
||||||
|
configureModelContext({ llamaSwapUrl: TEST_URL });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- positive cache ---------------------------------------------------------
|
||||||
|
|
||||||
|
describe('getModelContext — positive cache', () => {
|
||||||
|
it('returns the parsed body on a 200 with valid shape', async () => {
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockOkProps(262_144, 1));
|
||||||
|
const result = await getModelContext('qwen3.6');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.n_ctx).toBe(262_144);
|
||||||
|
expect(result!.total_slots).toBe(1);
|
||||||
|
expect(typeof result!.fetched_at).toBe('number');
|
||||||
|
// Verify the URL was constructed correctly — encodes the model name in
|
||||||
|
// case it contains characters that would break the path.
|
||||||
|
expect(fetchSpy).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
`${TEST_URL}/upstream/qwen3.6/props`,
|
||||||
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the second call from cache without refetching', async () => {
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(mockOkProps(262_144));
|
||||||
|
const a = await getModelContext('qwen3.6');
|
||||||
|
const b = await getModelContext('qwen3.6');
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults total_slots to 1 when the server omits it', async () => {
|
||||||
|
// Mirror the docstring claim — total_slots is informational and we don't
|
||||||
|
// reject the response just because it's missing.
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ default_generation_settings: { n_ctx: 8192 } }), {
|
||||||
|
status: 200,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = await getModelContext('partial-model');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.n_ctx).toBe(8192);
|
||||||
|
expect(result!.total_slots).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- negative cache (single-shot) ------------------------------------------
|
||||||
|
|
||||||
|
describe('getModelContext — negative cache (single failure modes)', () => {
|
||||||
|
it('returns null and negative-caches when default_generation_settings is missing', async () => {
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ total_slots: 1 }), { status: 200 }));
|
||||||
|
const result = await getModelContext('broken');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
// Second call within TTL must not refetch.
|
||||||
|
const result2 = await getModelContext('broken');
|
||||||
|
expect(result2).toBeNull();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null and negative-caches when n_ctx is missing inside default_generation_settings', async () => {
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ default_generation_settings: {}, total_slots: 1 }), {
|
||||||
|
status: 200,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await getModelContext('half-broken');
|
||||||
|
await getModelContext('half-broken');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null and negative-caches on non-200 (404)', async () => {
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(new Response('not found', { status: 404 }));
|
||||||
|
const result = await getModelContext('missing-model');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
const result2 = await getModelContext('missing-model');
|
||||||
|
expect(result2).toBeNull();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null and negative-caches on network error', async () => {
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockRejectedValueOnce(new TypeError('fetch failed: connect ECONNREFUSED'));
|
||||||
|
const result = await getModelContext('down-upstream');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
const result2 = await getModelContext('down-upstream');
|
||||||
|
expect(result2).toBeNull();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- negative cache TTL -----------------------------------------------------
|
||||||
|
|
||||||
|
describe('getModelContext — negative cache TTL', () => {
|
||||||
|
it('does NOT refetch when a second call lands within the 60s TTL', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(new Response('boom', { status: 500 }));
|
||||||
|
|
||||||
|
await getModelContext('flapping');
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
await getModelContext('flapping');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches when the second call lands after the 60s TTL expires', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(new Response('boom', { status: 500 }))
|
||||||
|
// Recovered upstream on the retry — we expect a positive cache hit
|
||||||
|
// after this fires.
|
||||||
|
.mockResolvedValueOnce(mockOkProps(8192));
|
||||||
|
|
||||||
|
await getModelContext('flapping');
|
||||||
|
vi.advanceTimersByTime(61_000);
|
||||||
|
const result = await getModelContext('flapping');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.n_ctx).toBe(8192);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- invalidateModelContext -------------------------------------------------
|
||||||
|
|
||||||
|
describe('invalidateModelContext', () => {
|
||||||
|
it('clears a single positive entry by model name', async () => {
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(mockOkProps(8192))
|
||||||
|
.mockResolvedValueOnce(mockOkProps(8192));
|
||||||
|
|
||||||
|
await getModelContext('cleared');
|
||||||
|
invalidateModelContext('cleared');
|
||||||
|
await getModelContext('cleared');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears ALL entries when called with no arg', async () => {
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(mockOkProps(8192))
|
||||||
|
.mockResolvedValueOnce(mockOkProps(16_384))
|
||||||
|
// After the full clear, both models re-fetch.
|
||||||
|
.mockResolvedValueOnce(mockOkProps(8192))
|
||||||
|
.mockResolvedValueOnce(mockOkProps(16_384));
|
||||||
|
|
||||||
|
await getModelContext('alpha');
|
||||||
|
await getModelContext('beta');
|
||||||
|
invalidateModelContext();
|
||||||
|
await getModelContext('alpha');
|
||||||
|
await getModelContext('beta');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearing a positive entry also clears the matching negative entry', async () => {
|
||||||
|
// Mixed state: first call fails (negative-caches), then we invalidate
|
||||||
|
// explicitly and the next call should fetch again rather than serve
|
||||||
|
// the stale negative entry.
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(new Response('boom', { status: 500 }))
|
||||||
|
.mockResolvedValueOnce(mockOkProps(4096));
|
||||||
|
|
||||||
|
await getModelContext('formerly-broken');
|
||||||
|
invalidateModelContext('formerly-broken');
|
||||||
|
const result = await getModelContext('formerly-broken');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.n_ctx).toBe(4096);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
apps/server/src/services/compaction-prompt.ts
Normal file
40
apps/server/src/services/compaction-prompt.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// v1.11: anchored rolling summary template. Verbatim port from opencode
|
||||||
|
// (packages/opencode/src/session/compaction.ts SUMMARY_TEMPLATE). Kept in a
|
||||||
|
// separate module so the long template literal doesn't bloat compaction.ts.
|
||||||
|
|
||||||
|
export const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||||
|
<template>
|
||||||
|
## Goal
|
||||||
|
- [single-sentence task summary]
|
||||||
|
|
||||||
|
## Constraints & Preferences
|
||||||
|
- [user constraints, preferences, specs, or "(none)"]
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
### Done
|
||||||
|
- [completed work or "(none)"]
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
- [current work or "(none)"]
|
||||||
|
|
||||||
|
### Blocked
|
||||||
|
- [blockers or "(none)"]
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
- [decision and why, or "(none)"]
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
- [ordered next actions or "(none)"]
|
||||||
|
|
||||||
|
## Critical Context
|
||||||
|
- [important technical facts, errors, open questions, or "(none)"]
|
||||||
|
|
||||||
|
## Relevant Files
|
||||||
|
- [file or directory path: why it matters, or "(none)"]
|
||||||
|
</template>
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Keep every section, even when empty.
|
||||||
|
- Use terse bullets, not prose paragraphs.
|
||||||
|
- Preserve exact file paths, commands, error strings, and identifiers when known.
|
||||||
|
- Do not mention the summary process or that context was compacted.`;
|
||||||
510
apps/server/src/services/compaction.ts
Normal file
510
apps/server/src/services/compaction.ts
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
// v1.11: anchored rolling compaction. Ported algorithms (not Effect-TS code)
|
||||||
|
// from opencode (packages/opencode/src/session/{compaction,overflow}.ts).
|
||||||
|
//
|
||||||
|
// What's different from BooCode's legacy /compact:
|
||||||
|
// - Operates per-chat (chats have N:1 to sessions; history is per-chat).
|
||||||
|
// - Detects overflow automatically after each inference completion using
|
||||||
|
// llama-swap's reported n_ctx; flags chats.needs_compaction=true.
|
||||||
|
// - On the next turn (or manual /compact) we summarize the *head* (messages
|
||||||
|
// prior to a preserved tail of N user-turns) into a single
|
||||||
|
// summary=true assistant row. Older messages get compacted_at-stamped so
|
||||||
|
// inference assembly filters them out; the GET endpoint still returns
|
||||||
|
// them so the UI can show history with the summary card inline.
|
||||||
|
// - The summary is *anchored rolling* — exactly one live summary=true row
|
||||||
|
// per chat. Subsequent compactions read the prior summary as
|
||||||
|
// previousSummary, ask the LLM to update-merge it, then mark the prior
|
||||||
|
// summary row compacted_at too (it stays in the UI but isn't sent to the
|
||||||
|
// LLM again).
|
||||||
|
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Config } from '../config.js';
|
||||||
|
import type { Broker } from './broker.js';
|
||||||
|
import { SUMMARY_TEMPLATE } from './compaction-prompt.js';
|
||||||
|
import * as modelContextLookup from './model-context.js';
|
||||||
|
|
||||||
|
const COMPACTION_BUFFER = 20_000;
|
||||||
|
const MIN_PRESERVE_RECENT_TOKENS = 2_000;
|
||||||
|
const MAX_PRESERVE_RECENT_TOKENS = 8_000;
|
||||||
|
const DEFAULT_TAIL_TURNS = 2;
|
||||||
|
|
||||||
|
// Subset of Message fields compaction touches. Selecting only what's needed
|
||||||
|
// keeps process() independent of api.ts mutations and reduces DB egress.
|
||||||
|
export interface CompactionMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||||
|
content: string;
|
||||||
|
kind: 'message' | 'compact';
|
||||||
|
summary: boolean;
|
||||||
|
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||||
|
tool_calls: Array<{ id: string; name: string; args: Record<string, unknown> }> | null;
|
||||||
|
tool_results: { tool_call_id: string; output: unknown; truncated: boolean; error?: string } | null;
|
||||||
|
metadata: { kind?: string } | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === overflow ===
|
||||||
|
|
||||||
|
// Tokens we hold in reserve for the model's response so a near-full context
|
||||||
|
// can still produce a useful turn. Mirrors opencode's COMPACTION_BUFFER.
|
||||||
|
// Returns 0 when the context limit is unknown (caller treats 0 as "do not
|
||||||
|
// trigger overflow"); avoids dividing-by-zero downstream.
|
||||||
|
export function usable(contextLimit: number): number {
|
||||||
|
if (!contextLimit || contextLimit <= 0) return 0;
|
||||||
|
return Math.max(0, contextLimit - COMPACTION_BUFFER);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Usage {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the assistant just used >= usable() tokens. Unknown limit → false
|
||||||
|
// (we never auto-trigger compaction without a budget — better to keep
|
||||||
|
// inference flowing than to fall into a compaction we can't size properly).
|
||||||
|
export function isOverflow(usage: Usage, contextLimit: number): boolean {
|
||||||
|
const budget = usable(contextLimit);
|
||||||
|
if (budget <= 0) return false;
|
||||||
|
return (usage.prompt_tokens + usage.completion_tokens) >= budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === selection ===
|
||||||
|
|
||||||
|
interface Turn {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Char-count / 4 token estimate. Matches opencode's Token.estimate (which
|
||||||
|
// also goes through JSON.stringify). Adequate for tail-fitting math; we
|
||||||
|
// don't need a real tokenizer here — the 20k buffer absorbs the slop.
|
||||||
|
export function estimate(messages: CompactionMessage[]): number {
|
||||||
|
return Math.ceil(JSON.stringify(messages).length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk messages, return one Turn per user message that is NOT a summary row.
|
||||||
|
// end = next-user-start; final turn ends at messages.length.
|
||||||
|
export function turns(messages: CompactionMessage[]): Turn[] {
|
||||||
|
const result: Turn[] = [];
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const m = messages[i]!;
|
||||||
|
if (m.role !== 'user') continue;
|
||||||
|
if (m.summary) continue;
|
||||||
|
result.push({ start: i, end: messages.length, id: m.id });
|
||||||
|
}
|
||||||
|
for (let i = 0; i < result.length - 1; i++) {
|
||||||
|
result[i]!.end = result[i + 1]!.start;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a turn that doesn't fit whole, walk forward from start+1 looking for
|
||||||
|
// the largest suffix that fits the remaining budget. Returns the keep-start
|
||||||
|
// index (the first preserved message) or undefined if no suffix fits.
|
||||||
|
function splitTurn(
|
||||||
|
messages: CompactionMessage[],
|
||||||
|
turn: Turn,
|
||||||
|
budget: number,
|
||||||
|
): { start: number; id: string } | undefined {
|
||||||
|
if (budget <= 0) return undefined;
|
||||||
|
if (turn.end - turn.start <= 1) return undefined;
|
||||||
|
for (let start = turn.start + 1; start < turn.end; start++) {
|
||||||
|
const size = estimate(messages.slice(start, turn.end));
|
||||||
|
if (size > budget) continue;
|
||||||
|
return { start, id: messages[start]!.id };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectResult {
|
||||||
|
head: CompactionMessage[];
|
||||||
|
tail_start_id: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose the boundary between the "head" (to be summarized) and the "tail"
|
||||||
|
// (preserved verbatim). Strategy:
|
||||||
|
// 1. Reserve a budget for the recent tail. Default ranges [2k, 8k] tokens
|
||||||
|
// with 25% of usable() as the target.
|
||||||
|
// 2. Take the last `tail_turns` user-turns; greedily fit from newest back.
|
||||||
|
// 3. If the next-older turn doesn't fit whole, split it mid-turn.
|
||||||
|
// 4. If we couldn't keep anything OR everything fit (keep.start === 0),
|
||||||
|
// return full-preserve (no compaction this round).
|
||||||
|
export function select(
|
||||||
|
messages: CompactionMessage[],
|
||||||
|
contextLimit: number,
|
||||||
|
tailTurns: number = DEFAULT_TAIL_TURNS,
|
||||||
|
): SelectResult {
|
||||||
|
if (tailTurns <= 0) return { head: messages, tail_start_id: undefined };
|
||||||
|
const budget = Math.min(
|
||||||
|
MAX_PRESERVE_RECENT_TOKENS,
|
||||||
|
Math.max(MIN_PRESERVE_RECENT_TOKENS, Math.floor(usable(contextLimit) * 0.25)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const all = turns(messages);
|
||||||
|
if (all.length === 0) return { head: messages, tail_start_id: undefined };
|
||||||
|
const recent = all.slice(-tailTurns);
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let keep: { start: number; id: string } | undefined;
|
||||||
|
for (let i = recent.length - 1; i >= 0; i--) {
|
||||||
|
const turn = recent[i]!;
|
||||||
|
const size = estimate(messages.slice(turn.start, turn.end));
|
||||||
|
if (total + size <= budget) {
|
||||||
|
total += size;
|
||||||
|
keep = { start: turn.start, id: turn.id };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const remaining = budget - total;
|
||||||
|
const split = splitTurn(messages, turn, remaining);
|
||||||
|
if (split) keep = split;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keep || keep.start === 0) {
|
||||||
|
return { head: messages, tail_start_id: undefined };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
head: messages.slice(0, keep.start),
|
||||||
|
tail_start_id: keep.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// === prompt assembly ===
|
||||||
|
|
||||||
|
// Build the final user message that asks the model to (re)produce the
|
||||||
|
// anchored summary. `context` is reserved for future plugin injection;
|
||||||
|
// callers pass [] today.
|
||||||
|
export function buildPrompt(
|
||||||
|
previousSummary: string | undefined,
|
||||||
|
context: string[],
|
||||||
|
): string {
|
||||||
|
const anchor = previousSummary
|
||||||
|
? [
|
||||||
|
'Update the anchored summary below using the conversation history above.',
|
||||||
|
'Preserve still-true details, remove stale details, and merge in the new facts.',
|
||||||
|
'<previous-summary>',
|
||||||
|
previousSummary,
|
||||||
|
'</previous-summary>',
|
||||||
|
].join('\n')
|
||||||
|
: 'Create a new anchored summary from the conversation history above.';
|
||||||
|
return [anchor, SUMMARY_TEMPLATE, ...context].join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === OpenAI conversion (compaction-local; intentionally does NOT call
|
||||||
|
// inference.ts buildMessagesPayload because that uses the legacy "find latest
|
||||||
|
// kind='compact' marker and skip everything before it" shortcircuit, which
|
||||||
|
// would silently drop pre-legacy-compact history before the LLM sees it.
|
||||||
|
// Compaction wants to send the entire head, full stop.) ===
|
||||||
|
|
||||||
|
interface OpenAiMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||||
|
content: string | null;
|
||||||
|
tool_calls?: Array<{
|
||||||
|
id: string;
|
||||||
|
type: 'function';
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}>;
|
||||||
|
tool_call_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCapHitSentinel(m: CompactionMessage): boolean {
|
||||||
|
return m.role === 'system' && m.metadata != null && m.metadata.kind === 'cap_hit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
|
||||||
|
const out: OpenAiMessage[] = [];
|
||||||
|
for (const m of head) {
|
||||||
|
if (isCapHitSentinel(m)) continue;
|
||||||
|
if (m.role === 'assistant' && (m.status === 'streaming' || m.status === 'cancelled')) continue;
|
||||||
|
if (m.kind === 'compact') {
|
||||||
|
// Legacy compact row — pass through as system context. The new
|
||||||
|
// anchored summary will subsume it, but the LLM should see it during
|
||||||
|
// the bridging round so it can carry forward the still-true bits.
|
||||||
|
out.push({ role: 'system', content: m.content });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (m.summary) {
|
||||||
|
// Defense in depth: process() filters these out of the select-input
|
||||||
|
// already. If one slips through, render it as assistant content so we
|
||||||
|
// never crash here.
|
||||||
|
out.push({ role: 'assistant', content: m.content });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (m.role === 'tool') {
|
||||||
|
const tr = m.tool_results;
|
||||||
|
if (!tr) continue;
|
||||||
|
const outputText = tr.error
|
||||||
|
? `error: ${tr.error}`
|
||||||
|
: typeof tr.output === 'string'
|
||||||
|
? tr.output
|
||||||
|
: JSON.stringify(tr.output);
|
||||||
|
out.push({ role: 'tool', content: outputText, tool_call_id: tr.tool_call_id });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (m.role === 'assistant') {
|
||||||
|
const msg: OpenAiMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: m.content && m.content.length > 0 ? m.content : null,
|
||||||
|
};
|
||||||
|
if (m.tool_calls && m.tool_calls.length > 0) {
|
||||||
|
msg.tool_calls = m.tool_calls.map((tc) => ({
|
||||||
|
id: tc.id,
|
||||||
|
type: 'function' as const,
|
||||||
|
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
out.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({ role: 'user', content: m.content });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === llama-swap call ===
|
||||||
|
|
||||||
|
// Non-streaming completion. Opencode streams; for a one-shot summary call a
|
||||||
|
// single POST is less code and the latency hit is acceptable (the user
|
||||||
|
// doesn't see this directly — useSessionStream emits the toast + refetches
|
||||||
|
// on the 'compacted' frame).
|
||||||
|
interface CompletionResult {
|
||||||
|
content: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callLlamaSwap(
|
||||||
|
config: Config,
|
||||||
|
model: string,
|
||||||
|
messages: OpenAiMessage[],
|
||||||
|
log: FastifyBaseLogger,
|
||||||
|
): Promise<CompletionResult> {
|
||||||
|
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model, messages, stream: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`llama-swap returned ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
choices?: Array<{ message?: { content?: string } }>;
|
||||||
|
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||||
|
};
|
||||||
|
// v1.11.3: removed the dead `json.timings?.n_ctx` read — llama-server's
|
||||||
|
// completions don't emit n_ctx in timings. ctx_max on the summary row
|
||||||
|
// comes from model-context.getModelContext below in process().
|
||||||
|
const content = json.choices?.[0]?.message?.content ?? '';
|
||||||
|
const promptTokens = json.usage?.prompt_tokens ?? 0;
|
||||||
|
const completionTokens = json.usage?.completion_tokens ?? 0;
|
||||||
|
log.debug({ promptTokens, completionTokens, chars: content.length }, 'compaction llm complete');
|
||||||
|
return { content, promptTokens, completionTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
// === entry point ===
|
||||||
|
|
||||||
|
export interface ProcessInput {
|
||||||
|
sql: Sql;
|
||||||
|
config: Config;
|
||||||
|
log: FastifyBaseLogger;
|
||||||
|
broker: Broker;
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs one round of anchored rolling compaction on `chatId`. No-ops cleanly
|
||||||
|
// (clearing needs_compaction) when there's nothing reasonable to compact.
|
||||||
|
// Throws on LLM failure — callers decide whether to log+swallow or surface.
|
||||||
|
export async function process(input: ProcessInput): Promise<void> {
|
||||||
|
const { sql, config, log, broker, chatId } = input;
|
||||||
|
|
||||||
|
// 1. Resolve chat → session for model + WS publish channel.
|
||||||
|
const chatRows = await sql<{ id: string; session_id: string }[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${chatId}
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
log.warn({ chatId }, 'compaction: chat not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chat = chatRows[0]!;
|
||||||
|
const sessionId = chat.session_id;
|
||||||
|
|
||||||
|
const sessRows = await sql<{ id: string; model: string }[]>`
|
||||||
|
SELECT id, model FROM sessions WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
if (sessRows.length === 0) {
|
||||||
|
log.warn({ chatId, sessionId }, 'compaction: session not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = sessRows[0]!;
|
||||||
|
|
||||||
|
// 2. All currently-active messages in this chat (compacted_at IS NULL).
|
||||||
|
// ORDER BY (created_at, id) matches loadContext in inference.ts so the
|
||||||
|
// turns() boundary logic sees the same sequence the LLM will.
|
||||||
|
const messages = await sql<CompactionMessage[]>`
|
||||||
|
SELECT id, role, content, kind, summary, status, tool_calls, tool_results, metadata, created_at
|
||||||
|
FROM messages
|
||||||
|
WHERE chat_id = ${chatId} AND compacted_at IS NULL
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
`;
|
||||||
|
if (messages.length === 0) {
|
||||||
|
await sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Find the prior anchored summary (newest summary=true row). Its content
|
||||||
|
// becomes previousSummary — the anchor in the prompt. Filter it out of the
|
||||||
|
// select-input so we don't double-encode (it's already in the anchor text).
|
||||||
|
const previousSummary = messages.filter((m) => m.summary).at(-1)?.content;
|
||||||
|
const forSelect = messages.filter((m) => !m.summary);
|
||||||
|
|
||||||
|
// 4. Resolve a recent context limit. llama-swap reports timings.n_ctx per
|
||||||
|
// completion; we cache it on messages.ctx_max. Use the most recent value
|
||||||
|
// from any message in this chat (oldest assumption is the same model is
|
||||||
|
// still running). When unknown, fall back to model.context_limit-less
|
||||||
|
// defaults via the buffer-only path (see usable()).
|
||||||
|
const ctxRows = await sql<{ ctx_max: number | null }[]>`
|
||||||
|
SELECT ctx_max FROM messages
|
||||||
|
WHERE chat_id = ${chatId} AND ctx_max IS NOT NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
`;
|
||||||
|
const contextLimit = ctxRows[0]?.ctx_max ?? 0;
|
||||||
|
|
||||||
|
// 5. Decide head / tail.
|
||||||
|
const sel = select(forSelect, contextLimit);
|
||||||
|
if (!sel.tail_start_id || sel.head.length === 0) {
|
||||||
|
// Full preserve — nothing to compact this round. Clear the flag so we
|
||||||
|
// don't loop. (Could happen when the chat is short or the budget swung
|
||||||
|
// wider after a model context bump.)
|
||||||
|
await sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
|
||||||
|
log.info({ chatId, contextLimit, msgCount: messages.length }, 'compaction: nothing to compact');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Build the OpenAI request: head as user/assistant/tool turns + a final
|
||||||
|
// user message carrying buildPrompt(previousSummary, []). No system prompt
|
||||||
|
// — matches opencode (`system: []`); the template + anchor are sufficient.
|
||||||
|
const headPayload = buildHeadPayload(sel.head);
|
||||||
|
const finalUser: OpenAiMessage = { role: 'user', content: buildPrompt(previousSummary, []) };
|
||||||
|
const payload = [...headPayload, finalUser];
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
chatId,
|
||||||
|
contextLimit,
|
||||||
|
headLen: sel.head.length,
|
||||||
|
tailStartId: sel.tail_start_id,
|
||||||
|
hadPrevSummary: previousSummary !== undefined,
|
||||||
|
},
|
||||||
|
'compaction: invoking model',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6a. Flip the chat dot amber for the duration of the LLM call + DB writes.
|
||||||
|
// Same { type: 'chat_status', status: 'working', at } shape inference.ts
|
||||||
|
// emits at runner enqueue. publishUser → broadcasts on the per-user channel
|
||||||
|
// (all devices / tabs see it) since chat_status is a user-channel frame in
|
||||||
|
// BooCode (see useChatStatus.ts, which is the consumer).
|
||||||
|
broker.publishUser('default', {
|
||||||
|
type: 'chat_status',
|
||||||
|
chat_id: chatId,
|
||||||
|
status: 'working',
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// try/finally so the dot ALWAYS drops back to idle, even if the LLM call
|
||||||
|
// throws or a downstream DB write fails. The succeeded flag gates the
|
||||||
|
// 'compacted' frame + final log: we only signal completion to the UI when
|
||||||
|
// the new summary row actually landed.
|
||||||
|
let succeeded = false;
|
||||||
|
let newId = '';
|
||||||
|
let result: CompletionResult | undefined;
|
||||||
|
try {
|
||||||
|
// 7. Single completion (no tools). Throws on llama-swap failure.
|
||||||
|
result = await callLlamaSwap(config, session.model, payload, log);
|
||||||
|
|
||||||
|
// 7b. v1.11.3: fetch the model's true context window from llama-swap's
|
||||||
|
// /upstream/<model>/props (the streaming completion doesn't carry it).
|
||||||
|
// Same pattern as inference.ts; the cache makes repeated calls free.
|
||||||
|
const mctx = await modelContextLookup.getModelContext(session.model);
|
||||||
|
const nCtx = mctx?.n_ctx ?? null;
|
||||||
|
|
||||||
|
// 8. Insert the new anchored summary row. role='assistant' per spec; the
|
||||||
|
// UI distinguishes via summary=true. tail_start_id points at the first
|
||||||
|
// preserved tail message so debug surfaces / future tools can reason
|
||||||
|
// about the boundary without re-deriving from compacted_at.
|
||||||
|
const insertRows = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (
|
||||||
|
session_id, chat_id, role, content, kind, status,
|
||||||
|
summary, tail_start_id,
|
||||||
|
tokens_used, ctx_used, ctx_max,
|
||||||
|
created_at, finished_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${sessionId}, ${chatId}, 'assistant', ${result.content}, 'message', 'complete',
|
||||||
|
true, ${sel.tail_start_id},
|
||||||
|
${result.completionTokens}, ${result.promptTokens}, ${nCtx},
|
||||||
|
clock_timestamp(), clock_timestamp()
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
newId = insertRows[0]!.id;
|
||||||
|
|
||||||
|
// 9. Mark every prior live message (head + prior summary) as compacted.
|
||||||
|
// Bound by "created_at strictly less than tail_start_id's created_at" so
|
||||||
|
// the preserved tail stays compacted_at=NULL. Exclude the new summary
|
||||||
|
// row we just inserted (it's "now", which is >= tail_start_id's
|
||||||
|
// created_at anyway, but defensive).
|
||||||
|
await sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET compacted_at = clock_timestamp()
|
||||||
|
WHERE chat_id = ${chatId}
|
||||||
|
AND compacted_at IS NULL
|
||||||
|
AND id != ${newId}
|
||||||
|
AND created_at < (SELECT created_at FROM messages WHERE id = ${sel.tail_start_id})
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 10. Clear the flag and bump the chat's updated_at so the sidebar
|
||||||
|
// reflects recent activity.
|
||||||
|
await sql`
|
||||||
|
UPDATE chats
|
||||||
|
SET needs_compaction = false, updated_at = clock_timestamp()
|
||||||
|
WHERE id = ${chatId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
succeeded = true;
|
||||||
|
} finally {
|
||||||
|
// Always restore the dot. Status='idle' (not 'error') even on failure —
|
||||||
|
// the caller logs/re-surfaces the error separately; the dot doesn't
|
||||||
|
// need to stay red across reloads for a transient compaction blip.
|
||||||
|
broker.publishUser('default', {
|
||||||
|
type: 'chat_status',
|
||||||
|
chat_id: chatId,
|
||||||
|
status: 'idle',
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Tell the client. useSessionStream subscribes to the per-session WS
|
||||||
|
// channel; the handler refetches messages (so the new summary row + the
|
||||||
|
// compacted_at-stamped older rows render correctly) and fires a sonner
|
||||||
|
// toast. Order matters: idle must precede 'compacted' so the dot is
|
||||||
|
// already green by the time the refetch toast appears.
|
||||||
|
if (succeeded) {
|
||||||
|
broker.publish(sessionId, {
|
||||||
|
type: 'compacted',
|
||||||
|
session_id: sessionId,
|
||||||
|
chat_id: chatId,
|
||||||
|
summary_message_id: newId,
|
||||||
|
});
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
chatId,
|
||||||
|
newId,
|
||||||
|
completionTokens: result?.completionTokens,
|
||||||
|
promptTokens: result?.promptTokens,
|
||||||
|
},
|
||||||
|
'compaction: complete',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
import { PathScopeError, resolveProjectRoot } from './path_guard.js';
|
import { PathScopeError, resolveProjectRoot } from './path_guard.js';
|
||||||
import { maybeAutoNameChat } from './auto_name.js';
|
import { maybeAutoNameChat } from './auto_name.js';
|
||||||
import { getAgentById } from './agents.js';
|
import { getAgentById } from './agents.js';
|
||||||
|
import * as compaction from './compaction.js';
|
||||||
|
import * as modelContext from './model-context.js';
|
||||||
|
import type { Broker } from './broker.js';
|
||||||
|
|
||||||
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
||||||
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
||||||
@@ -136,9 +139,6 @@ interface ChatCompletionChunk {
|
|||||||
completion_tokens?: number;
|
completion_tokens?: number;
|
||||||
total_tokens?: number;
|
total_tokens?: number;
|
||||||
};
|
};
|
||||||
timings?: {
|
|
||||||
n_ctx?: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InferenceContext {
|
export interface InferenceContext {
|
||||||
@@ -147,6 +147,12 @@ export interface InferenceContext {
|
|||||||
log: FastifyBaseLogger;
|
log: FastifyBaseLogger;
|
||||||
publish: FramePublisher;
|
publish: FramePublisher;
|
||||||
publishUser: (frame: UserStreamFrame) => void;
|
publishUser: (frame: UserStreamFrame) => void;
|
||||||
|
// v1.11: passed through so compaction.process can publish 'compacted'
|
||||||
|
// frames on the same session WS channel useSessionStream subscribes to.
|
||||||
|
// Compaction is the only path that needs the raw broker handle (regular
|
||||||
|
// inference goes through `publish`); keeping a separate field avoids
|
||||||
|
// tempting other code paths into bypassing the session-id binding.
|
||||||
|
broker: Broker;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolution order: base prompt < agent.system_prompt < user prompt, where
|
// Resolution order: base prompt < agent.system_prompt < user prompt, where
|
||||||
@@ -260,17 +266,48 @@ async function loadContext(
|
|||||||
if (projectRows.length === 0) return null;
|
if (projectRows.length === 0) return null;
|
||||||
const project = projectRows[0]!;
|
const project = projectRows[0]!;
|
||||||
|
|
||||||
|
// v1.11: filter compacted messages out of the inference assembly. The GET
|
||||||
|
// /api/sessions/:id/messages endpoint still returns everything (so the UI
|
||||||
|
// can show history with the summary card inline); only LLM payloads skip
|
||||||
|
// compacted rows. compacted_at IS NULL keeps the active summary + tail.
|
||||||
const history = await sql<Message[]>`
|
const history = await sql<Message[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE chat_id = ${chatId}
|
WHERE chat_id = ${chatId} AND compacted_at IS NULL
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return { session, project, history };
|
return { session, project, history };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.11: shared helper used after both finalizeCompletion and executeToolPhase
|
||||||
|
// persist their token counts. Reads tokens off the just-UPDATEd row (which
|
||||||
|
// the caller returns from RETURNING), runs compaction.isOverflow, and flips
|
||||||
|
// chats.needs_compaction. The next runAssistantTurn invocation acts on it.
|
||||||
|
// Silent on missing tokens — llama-swap occasionally omits usage on truncated
|
||||||
|
// streams, and we'd rather miss one overflow than crash the inference path.
|
||||||
|
async function maybeFlagForCompaction(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
chatId: string,
|
||||||
|
updated: { tokens_used: number | null; ctx_used: number | null; ctx_max: number | null } | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!updated) return;
|
||||||
|
const promptTokens = updated.ctx_used;
|
||||||
|
const completionTokens = updated.tokens_used;
|
||||||
|
const contextLimit = updated.ctx_max;
|
||||||
|
if (typeof promptTokens !== 'number') return;
|
||||||
|
if (typeof completionTokens !== 'number') return;
|
||||||
|
if (typeof contextLimit !== 'number') return;
|
||||||
|
const overflow = compaction.isOverflow(
|
||||||
|
{ prompt_tokens: promptTokens, completion_tokens: completionTokens },
|
||||||
|
contextLimit,
|
||||||
|
);
|
||||||
|
if (!overflow) return;
|
||||||
|
await ctx.sql`UPDATE chats SET needs_compaction = true WHERE id = ${chatId}`;
|
||||||
|
ctx.log.info({ chatId, promptTokens, completionTokens, contextLimit }, 'inference: flagged for compaction');
|
||||||
|
}
|
||||||
|
|
||||||
async function* sseLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
async function* sseLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
||||||
const reader = stream.getReader();
|
const reader = stream.getReader();
|
||||||
const decoder = new TextDecoder('utf-8');
|
const decoder = new TextDecoder('utf-8');
|
||||||
@@ -300,7 +337,6 @@ interface StreamResult {
|
|||||||
toolCalls: ToolCall[];
|
toolCalls: ToolCall[];
|
||||||
promptTokens: number | null;
|
promptTokens: number | null;
|
||||||
completionTokens: number | null;
|
completionTokens: number | null;
|
||||||
nCtx: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StreamOptions {
|
interface StreamOptions {
|
||||||
@@ -310,6 +346,70 @@ interface StreamOptions {
|
|||||||
temperature?: number;
|
temperature?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10.5 Qwen-coder XML fallback. Some local models (notably qwen3-coder via
|
||||||
|
// llama-swap) emit tool calls as inline XML inside delta.content rather than
|
||||||
|
// the structured delta.tool_calls field. The XML shape is:
|
||||||
|
// <tool_call>
|
||||||
|
// <function=NAME>
|
||||||
|
// <parameter=KEY>
|
||||||
|
// VALUE
|
||||||
|
// </parameter>
|
||||||
|
// ...more parameters...
|
||||||
|
// </function>
|
||||||
|
// </tool_call>
|
||||||
|
// Multiple <tool_call> blocks may appear back-to-back; they never nest.
|
||||||
|
// streamCompletion buffers delta.content, extracts complete blocks, parses
|
||||||
|
// them via parseXmlToolCall, and pushes synthetic entries into the existing
|
||||||
|
// toolCallsBuffer alongside any native JSON-format tool calls.
|
||||||
|
const XML_TOOL_OPEN = '<tool_call>';
|
||||||
|
const XML_TOOL_CLOSE = '</tool_call>';
|
||||||
|
|
||||||
|
function parseXmlToolCall(
|
||||||
|
block: string,
|
||||||
|
): { name: string; args: Record<string, unknown> } | null {
|
||||||
|
const nameMatch = block.match(/<function=([^>]+)>/);
|
||||||
|
if (!nameMatch || !nameMatch[1]) return null;
|
||||||
|
const name = nameMatch[1].trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
// Non-greedy body so each <parameter=…>…</parameter> pair is matched
|
||||||
|
// independently even when multiple appear in the same block.
|
||||||
|
const paramRe = /<parameter=([^>]+)>([\s\S]*?)<\/parameter>/g;
|
||||||
|
for (const m of block.matchAll(paramRe)) {
|
||||||
|
const key = (m[1] ?? '').trim();
|
||||||
|
if (!key) continue;
|
||||||
|
const raw = (m[2] ?? '').trim();
|
||||||
|
try {
|
||||||
|
args[key] = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
args[key] = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { name, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the first character that begins (or completely contains) an
|
||||||
|
// unfinished <tool_call> opener in `s`. Returns -1 when `s` can be flushed
|
||||||
|
// to the client in full without risking a partial tag leak.
|
||||||
|
// Case 1: a full `<tool_call>` opener with no matching closer — caller
|
||||||
|
// must keep everything from that index forward until the next
|
||||||
|
// chunk arrives with the closer.
|
||||||
|
// Case 2: `s` ends with a strict prefix of `<tool_call>` (e.g. `<tool_c`).
|
||||||
|
// Caller must keep just that suffix in the buffer.
|
||||||
|
// Note: case 1 assumes the calling loop already extracted every complete
|
||||||
|
// <tool_call>…</tool_call> pair before reaching this check.
|
||||||
|
function partialXmlOpenerStart(s: string): number {
|
||||||
|
const fullOpener = s.indexOf(XML_TOOL_OPEN);
|
||||||
|
if (fullOpener !== -1) return fullOpener;
|
||||||
|
const lastLt = s.lastIndexOf('<');
|
||||||
|
if (lastLt === -1) return -1;
|
||||||
|
const suffix = s.slice(lastLt);
|
||||||
|
if (XML_TOOL_OPEN.startsWith(suffix) && suffix.length < XML_TOOL_OPEN.length) {
|
||||||
|
return lastLt;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
async function streamCompletion(
|
async function streamCompletion(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
model: string,
|
model: string,
|
||||||
@@ -344,10 +444,13 @@ async function streamCompletion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content = '';
|
let content = '';
|
||||||
|
// v1.10.5: holds delta.content bytes that may contain a partial XML tool
|
||||||
|
// call. Anything not part of a (possibly forming) <tool_call>…</tool_call>
|
||||||
|
// pair is flushed to content + onDelta as soon as we know it's safe.
|
||||||
|
let pendingBuffer = '';
|
||||||
let finishReason: string | null = null;
|
let finishReason: string | null = null;
|
||||||
let promptTokens: number | null = null;
|
let promptTokens: number | null = null;
|
||||||
let completionTokens: number | null = null;
|
let completionTokens: number | null = null;
|
||||||
let nCtx: number | null = null;
|
|
||||||
const toolCallsBuffer = new Map<number, { id: string; name: string; argsText: string }>();
|
const toolCallsBuffer = new Map<number, { id: string; name: string; argsText: string }>();
|
||||||
|
|
||||||
for await (const line of sseLines(res.body)) {
|
for await (const line of sseLines(res.body)) {
|
||||||
@@ -369,16 +472,60 @@ async function streamCompletion(
|
|||||||
completionTokens = parsed.usage.completion_tokens;
|
completionTokens = parsed.usage.completion_tokens;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (parsed.timings && typeof parsed.timings.n_ctx === 'number') {
|
// v1.11.3: removed dead `parsed.timings.n_ctx` read. llama-server's
|
||||||
nCtx = parsed.timings.n_ctx;
|
// streaming completion does NOT emit n_ctx in timings (verified
|
||||||
}
|
// empirically); the authoritative source is llama-swap's
|
||||||
|
// /upstream/<model>/props endpoint, fetched per-turn via
|
||||||
|
// model-context.getModelContext() at the finalization sites below.
|
||||||
|
|
||||||
const choice = parsed.choices?.[0];
|
const choice = parsed.choices?.[0];
|
||||||
if (!choice) continue;
|
if (!choice) continue;
|
||||||
const delta = choice.delta ?? {};
|
const delta = choice.delta ?? {};
|
||||||
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
||||||
content += delta.content;
|
// v1.10.5 XML fallback. Append, then extract any complete tool_call
|
||||||
onDelta(delta.content);
|
// blocks before deciding what's safe to flush as visible content.
|
||||||
|
pendingBuffer += delta.content;
|
||||||
|
while (true) {
|
||||||
|
const startIdx = pendingBuffer.indexOf(XML_TOOL_OPEN);
|
||||||
|
if (startIdx === -1) break;
|
||||||
|
const closeIdx = pendingBuffer.indexOf(XML_TOOL_CLOSE, startIdx);
|
||||||
|
if (closeIdx === -1) break;
|
||||||
|
const blockEnd = closeIdx + XML_TOOL_CLOSE.length;
|
||||||
|
const block = pendingBuffer.slice(startIdx, blockEnd);
|
||||||
|
// Any text before the opener is plain content — flush it now.
|
||||||
|
if (startIdx > 0) {
|
||||||
|
const before = pendingBuffer.slice(0, startIdx);
|
||||||
|
content += before;
|
||||||
|
onDelta(before);
|
||||||
|
}
|
||||||
|
const parsedCall = parseXmlToolCall(block);
|
||||||
|
if (parsedCall) {
|
||||||
|
const synthIdx = toolCallsBuffer.size;
|
||||||
|
toolCallsBuffer.set(synthIdx, {
|
||||||
|
id: `xml_call_${synthIdx}`,
|
||||||
|
name: parsedCall.name,
|
||||||
|
argsText: JSON.stringify(parsedCall.args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If parsing failed we still drop the block — emitting unparseable
|
||||||
|
// XML to the chat would look worse than silently swallowing it.
|
||||||
|
pendingBuffer = pendingBuffer.slice(blockEnd);
|
||||||
|
}
|
||||||
|
// After all complete blocks are out, hold back any (partial or full)
|
||||||
|
// unclosed opener; flush the rest.
|
||||||
|
const partialIdx = partialXmlOpenerStart(pendingBuffer);
|
||||||
|
if (partialIdx >= 0) {
|
||||||
|
if (partialIdx > 0) {
|
||||||
|
const flush = pendingBuffer.slice(0, partialIdx);
|
||||||
|
content += flush;
|
||||||
|
onDelta(flush);
|
||||||
|
}
|
||||||
|
pendingBuffer = pendingBuffer.slice(partialIdx);
|
||||||
|
} else if (pendingBuffer.length > 0) {
|
||||||
|
content += pendingBuffer;
|
||||||
|
onDelta(pendingBuffer);
|
||||||
|
pendingBuffer = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(delta.tool_calls)) {
|
if (Array.isArray(delta.tool_calls)) {
|
||||||
for (const tc of delta.tool_calls) {
|
for (const tc of delta.tool_calls) {
|
||||||
@@ -393,6 +540,15 @@ async function streamCompletion(
|
|||||||
if (choice.finish_reason) finishReason = choice.finish_reason;
|
if (choice.finish_reason) finishReason = choice.finish_reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10.5: if the stream ended mid-XML (e.g. model truncated, no closer
|
||||||
|
// ever arrived), flush whatever was buffered as plain content so it isn't
|
||||||
|
// silently dropped. Better to show a stray `<tool_call>` than vanish text.
|
||||||
|
if (pendingBuffer.length > 0) {
|
||||||
|
content += pendingBuffer;
|
||||||
|
onDelta(pendingBuffer);
|
||||||
|
pendingBuffer = '';
|
||||||
|
}
|
||||||
|
|
||||||
const toolCalls: ToolCall[] = [];
|
const toolCalls: ToolCall[] = [];
|
||||||
for (const [, t] of [...toolCallsBuffer.entries()].sort(([a], [b]) => a - b)) {
|
for (const [, t] of [...toolCallsBuffer.entries()].sort(([a], [b]) => a - b)) {
|
||||||
let args: Record<string, unknown> = {};
|
let args: Record<string, unknown> = {};
|
||||||
@@ -406,7 +562,7 @@ async function streamCompletion(
|
|||||||
toolCalls.push({ id: t.id || `call_${toolCalls.length}`, name: t.name, args });
|
toolCalls.push({ id: t.id || `call_${toolCalls.length}`, name: t.name, args });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { finishReason, content, toolCalls, promptTokens, completionTokens, nCtx };
|
return { finishReason, content, toolCalls, promptTokens, completionTokens };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeToolCall(
|
async function executeToolCall(
|
||||||
@@ -623,7 +779,14 @@ async function executeToolPhase(
|
|||||||
projectRoot: string
|
projectRoot: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId, assistantMessageId, toolsUsed, signal } = args;
|
const { sessionId, chatId, assistantMessageId, toolsUsed, signal } = args;
|
||||||
const { content, toolCalls, promptTokens, completionTokens, nCtx } = result;
|
const { content, toolCalls, promptTokens, completionTokens } = result;
|
||||||
|
|
||||||
|
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
||||||
|
// streaming completion (which doesn't emit n_ctx). getModelContext caches
|
||||||
|
// the positive lookup for the process lifetime, so this is a single Map
|
||||||
|
// hit after the first invocation per model.
|
||||||
|
const mctx = await modelContext.getModelContext(session.model);
|
||||||
|
const nCtx = mctx?.n_ctx ?? null;
|
||||||
|
|
||||||
const [updated] = await ctx.sql<
|
const [updated] = await ctx.sql<
|
||||||
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
||||||
@@ -639,6 +802,10 @@ async function executeToolPhase(
|
|||||||
WHERE id = ${assistantMessageId}
|
WHERE id = ${assistantMessageId}
|
||||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||||
`;
|
`;
|
||||||
|
// v1.11: flag for compaction if this turn pushed us over the usable budget.
|
||||||
|
// We never compact mid-loop (the recursive runAssistantTurn keeps tools
|
||||||
|
// flowing); the flag fires on the NEXT turn's pre-fetch hook above.
|
||||||
|
await maybeFlagForCompaction(ctx, chatId, updated);
|
||||||
const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
||||||
UPDATE sessions SET updated_at = clock_timestamp()
|
UPDATE sessions SET updated_at = clock_timestamp()
|
||||||
WHERE id = ${sessionId}
|
WHERE id = ${sessionId}
|
||||||
@@ -755,7 +922,11 @@ async function finalizeCompletion(
|
|||||||
session: Session
|
session: Session
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId, assistantMessageId } = args;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const { content, finishReason, promptTokens, completionTokens, nCtx } = result;
|
const { content, finishReason, promptTokens, completionTokens } = result;
|
||||||
|
|
||||||
|
// v1.11.3: see executeToolPhase for the rationale.
|
||||||
|
const mctx = await modelContext.getModelContext(session.model);
|
||||||
|
const nCtx = mctx?.n_ctx ?? null;
|
||||||
|
|
||||||
const [updated] = await ctx.sql<
|
const [updated] = await ctx.sql<
|
||||||
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
||||||
@@ -770,6 +941,9 @@ async function finalizeCompletion(
|
|||||||
WHERE id = ${assistantMessageId}
|
WHERE id = ${assistantMessageId}
|
||||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||||
`;
|
`;
|
||||||
|
// v1.11: flag for compaction on the terminal turn too. Catches the common
|
||||||
|
// case of a turn that hit the limit without invoking tools.
|
||||||
|
await maybeFlagForCompaction(ctx, chatId, updated);
|
||||||
const [completeSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
const [completeSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
||||||
UPDATE sessions SET updated_at = clock_timestamp()
|
UPDATE sessions SET updated_at = clock_timestamp()
|
||||||
WHERE id = ${sessionId}
|
WHERE id = ${sessionId}
|
||||||
@@ -808,6 +982,29 @@ async function runAssistantTurn(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId } = args;
|
const { sessionId, chatId } = args;
|
||||||
|
|
||||||
|
// v1.11: if the prior turn flagged this chat for compaction, run it first
|
||||||
|
// so loadContext below reads the post-compaction history. We swallow
|
||||||
|
// compaction failures (clearing the flag so we don't loop) and proceed
|
||||||
|
// with the un-compacted history — a slow turn that hits the model's
|
||||||
|
// hard limit is recoverable; a dead session is not.
|
||||||
|
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
|
||||||
|
SELECT needs_compaction FROM chats WHERE id = ${chatId}
|
||||||
|
`;
|
||||||
|
if (chatFlag[0]?.needs_compaction) {
|
||||||
|
try {
|
||||||
|
await compaction.process({
|
||||||
|
sql: ctx.sql,
|
||||||
|
config: ctx.config,
|
||||||
|
log: ctx.log,
|
||||||
|
broker: ctx.broker,
|
||||||
|
chatId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
|
||||||
|
await ctx.sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
||||||
@@ -962,6 +1159,9 @@ async function runCapHitSummary(
|
|||||||
// even on a partial / failed summary the chat history shows where the
|
// even on a partial / failed summary the chat history shows where the
|
||||||
// budget was hit.
|
// budget was hit.
|
||||||
if (summaryOk && result) {
|
if (summaryOk && result) {
|
||||||
|
// v1.11.3: see executeToolPhase for the rationale.
|
||||||
|
const mctx = await modelContext.getModelContext(session.model);
|
||||||
|
const nCtx = mctx?.n_ctx ?? null;
|
||||||
const [updated] = await ctx.sql<
|
const [updated] = await ctx.sql<
|
||||||
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
||||||
>`
|
>`
|
||||||
@@ -970,7 +1170,7 @@ async function runCapHitSummary(
|
|||||||
status = 'complete',
|
status = 'complete',
|
||||||
tokens_used = ${result.completionTokens},
|
tokens_used = ${result.completionTokens},
|
||||||
ctx_used = ${result.promptTokens},
|
ctx_used = ${result.promptTokens},
|
||||||
ctx_max = ${result.nCtx},
|
ctx_max = ${nCtx},
|
||||||
finished_at = clock_timestamp()
|
finished_at = clock_timestamp()
|
||||||
WHERE id = ${assistantMessageId}
|
WHERE id = ${assistantMessageId}
|
||||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||||
@@ -1118,81 +1318,6 @@ async function insertCapHitSentinel(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPACT_SYSTEM_PROMPT =
|
|
||||||
'Summarize the preceding conversation into a dense but complete context paragraph. Preserve all key facts, decisions, file paths, code patterns, and action items. Do not add any new information. Output only the summary paragraph.';
|
|
||||||
|
|
||||||
async function runCompact(
|
|
||||||
ctx: InferenceContext,
|
|
||||||
sessionId: string,
|
|
||||||
chatId: string,
|
|
||||||
compactMessageId: string
|
|
||||||
): Promise<void> {
|
|
||||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
|
||||||
if (!loaded) return;
|
|
||||||
const { session, project, history } = loaded;
|
|
||||||
|
|
||||||
const messagesForSummary = buildMessagesPayload(session, project,
|
|
||||||
history.filter((m) => m.id !== compactMessageId)
|
|
||||||
);
|
|
||||||
messagesForSummary.push({
|
|
||||||
role: 'system',
|
|
||||||
content: COMPACT_SYSTEM_PROMPT,
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.publish(sessionId, {
|
|
||||||
type: 'message_started',
|
|
||||||
message_id: compactMessageId,
|
|
||||||
chat_id: chatId,
|
|
||||||
role: 'assistant',
|
|
||||||
});
|
|
||||||
|
|
||||||
let content = '';
|
|
||||||
try {
|
|
||||||
const result = await streamCompletion(
|
|
||||||
ctx,
|
|
||||||
session.model,
|
|
||||||
messagesForSummary,
|
|
||||||
{ tools: null },
|
|
||||||
(delta) => {
|
|
||||||
content += delta;
|
|
||||||
ctx.publish(sessionId, {
|
|
||||||
type: 'delta',
|
|
||||||
message_id: compactMessageId,
|
|
||||||
chat_id: chatId,
|
|
||||||
content: delta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
content = result.content;
|
|
||||||
} catch (err) {
|
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
await ctx.sql`
|
|
||||||
UPDATE messages SET status = 'failed', content = ${content}, finished_at = clock_timestamp()
|
|
||||||
WHERE id = ${compactMessageId}
|
|
||||||
`;
|
|
||||||
ctx.publish(sessionId, {
|
|
||||||
type: 'error',
|
|
||||||
message_id: compactMessageId,
|
|
||||||
chat_id: chatId,
|
|
||||||
error: errMsg,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preCompactCount = history.filter((m) => m.id !== compactMessageId && m.kind !== 'compact').length;
|
|
||||||
const summary = `[Context compacted — ${preCompactCount} messages summarized]\n\n${content}`;
|
|
||||||
|
|
||||||
await ctx.sql`
|
|
||||||
UPDATE messages SET content = ${summary}, status = 'complete', finished_at = clock_timestamp()
|
|
||||||
WHERE id = ${compactMessageId}
|
|
||||||
`;
|
|
||||||
ctx.publish(sessionId, {
|
|
||||||
type: 'message_complete',
|
|
||||||
message_id: compactMessageId,
|
|
||||||
chat_id: chatId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InferenceRegistration {
|
interface InferenceRegistration {
|
||||||
controller: AbortController;
|
controller: AbortController;
|
||||||
completed: Promise<void>;
|
completed: Promise<void>;
|
||||||
@@ -1209,6 +1334,10 @@ export function createInferenceRunner(
|
|||||||
const callCtx: InferenceContext = {
|
const callCtx: InferenceContext = {
|
||||||
...ctx,
|
...ctx,
|
||||||
publishUser: (frame) => publishUserFn(user, frame),
|
publishUser: (frame) => publishUserFn(user, frame),
|
||||||
|
// v1.11: broker comes in via ctx (set at registration time). Repeated
|
||||||
|
// here so the destructure carries it onto the per-call ctx without
|
||||||
|
// having to add it to every enqueue/cancel signature individually.
|
||||||
|
broker: ctx.broker,
|
||||||
};
|
};
|
||||||
// v1.8 mobile-tabs: announce working before the async loop starts so
|
// v1.8 mobile-tabs: announce working before the async loop starts so
|
||||||
// every device subscribed to the user channel sees the amber dot.
|
// every device subscribed to the user channel sees the amber dot.
|
||||||
@@ -1238,20 +1367,6 @@ export function createInferenceRunner(
|
|||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
|
|
||||||
enqueueCompact(sessionId: string, chatId: string, compactMessageId: string, user: string) {
|
|
||||||
const callCtx: InferenceContext = {
|
|
||||||
...ctx,
|
|
||||||
publishUser: (frame) => publishUserFn(user, frame),
|
|
||||||
};
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await runCompact(callCtx, sessionId, chatId, compactMessageId);
|
|
||||||
} catch (err) {
|
|
||||||
callCtx.log.error({ err }, 'unhandled compact error');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
|
|
||||||
async cancel(_sessionId: string, chatId: string): Promise<boolean> {
|
async cancel(_sessionId: string, chatId: string): Promise<boolean> {
|
||||||
const reg = registry.get(chatId);
|
const reg = registry.get(chatId);
|
||||||
if (!reg) return false;
|
if (!reg) return false;
|
||||||
|
|||||||
113
apps/server/src/services/model-context.ts
Normal file
113
apps/server/src/services/model-context.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// v1.11.3: llama-swap model-context cache. Replaces the dead
|
||||||
|
// `parsed.timings.n_ctx` capture in inference.ts / compaction.ts —
|
||||||
|
// llama-server's streaming completion never emits n_ctx in timings (verified
|
||||||
|
// empirically: timings carries prompt_n / predicted_n / *_ms / *_per_second
|
||||||
|
// only). The authoritative source is llama-swap's
|
||||||
|
// /upstream/<model>/props endpoint at .default_generation_settings.n_ctx.
|
||||||
|
//
|
||||||
|
// Cache design:
|
||||||
|
// - Positive entries (n_ctx + total_slots) have no TTL. A model's context
|
||||||
|
// size doesn't change while llama-swap is running; an admin endpoint
|
||||||
|
// can invalidateModelContext() if it ever does.
|
||||||
|
// - Negative entries (failed fetch) have a 60s TTL so a misconfigured or
|
||||||
|
// down model doesn't get hammered every inference turn, but recovers
|
||||||
|
// within a minute once the upstream comes back.
|
||||||
|
// - 3s AbortController timeout on the fetch — long enough for a healthy
|
||||||
|
// upstream, short enough that a stuck upstream doesn't block the
|
||||||
|
// ctx_max UPDATE that follows.
|
||||||
|
|
||||||
|
export interface ModelContext {
|
||||||
|
n_ctx: number;
|
||||||
|
total_slots: number;
|
||||||
|
fetched_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEGATIVE_TTL_MS = 60_000;
|
||||||
|
const FETCH_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
|
const positiveCache = new Map<string, ModelContext>();
|
||||||
|
// Value is the unix-ms timestamp of the last failed fetch. Used to gate
|
||||||
|
// re-fetches within the 60s window.
|
||||||
|
const negativeCache = new Map<string, number>();
|
||||||
|
|
||||||
|
// Set once at startup by index.ts. We don't import loadConfig() directly
|
||||||
|
// here to keep this module trivially mockable in tests (set the URL in
|
||||||
|
// beforeEach instead of stubbing process.env + loadConfig's cache).
|
||||||
|
let llamaSwapUrl: string | null = null;
|
||||||
|
|
||||||
|
export function configureModelContext(opts: { llamaSwapUrl: string }): void {
|
||||||
|
llamaSwapUrl = opts.llamaSwapUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModelContext(model: string): Promise<ModelContext | null> {
|
||||||
|
// 1. Positive cache hit — no TTL check, model n_ctx is invariant.
|
||||||
|
const pos = positiveCache.get(model);
|
||||||
|
if (pos) return pos;
|
||||||
|
|
||||||
|
// 2. Negative cache hit within TTL — return null without refetching.
|
||||||
|
// Stale negative entries (older than the TTL) fall through to a fresh
|
||||||
|
// attempt below; we don't delete them eagerly because the next successful
|
||||||
|
// fetch will overwrite via the positive map and the negative entry
|
||||||
|
// becomes irrelevant.
|
||||||
|
const negTs = negativeCache.get(model);
|
||||||
|
if (negTs !== undefined && Date.now() - negTs < NEGATIVE_TTL_MS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Module not initialized. Defensive — index.ts calls
|
||||||
|
// configureModelContext at startup; if a test forgets, fail closed so
|
||||||
|
// the chat still works (ctx_max stays null, UI degrades gracefully).
|
||||||
|
if (!llamaSwapUrl) {
|
||||||
|
negativeCache.set(model, Date.now());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch with timeout. AbortController fires after FETCH_TIMEOUT_MS;
|
||||||
|
// both the timeout path and a fetch reject end up in the catch below
|
||||||
|
// and produce a negative cache entry.
|
||||||
|
const url = `${llamaSwapUrl}/upstream/${encodeURIComponent(model)}/props`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!res.ok) {
|
||||||
|
negativeCache.set(model, Date.now());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
default_generation_settings?: { n_ctx?: number };
|
||||||
|
total_slots?: number;
|
||||||
|
};
|
||||||
|
const n_ctx = body?.default_generation_settings?.n_ctx;
|
||||||
|
if (typeof n_ctx !== 'number' || n_ctx <= 0) {
|
||||||
|
negativeCache.set(model, Date.now());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// total_slots is informational; default to 1 if missing rather than
|
||||||
|
// reject the whole response. Most local llama-swap setups run a
|
||||||
|
// single slot anyway.
|
||||||
|
const total_slots =
|
||||||
|
typeof body?.total_slots === 'number' && body.total_slots > 0 ? body.total_slots : 1;
|
||||||
|
const entry: ModelContext = { n_ctx, total_slots, fetched_at: Date.now() };
|
||||||
|
positiveCache.set(model, entry);
|
||||||
|
// Clear any stale negative entry so a future query sees the positive
|
||||||
|
// hit cleanly (otherwise the negative TTL never expires from the map).
|
||||||
|
negativeCache.delete(model);
|
||||||
|
return entry;
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timer);
|
||||||
|
negativeCache.set(model, Date.now());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateModelContext(model?: string): void {
|
||||||
|
if (model === undefined) {
|
||||||
|
positiveCache.clear();
|
||||||
|
negativeCache.clear();
|
||||||
|
} else {
|
||||||
|
positiveCache.delete(model);
|
||||||
|
negativeCache.delete(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,12 @@ export interface Message {
|
|||||||
// v1.8.2: per-message metadata. See MessageMetadata for the discriminated
|
// v1.8.2: per-message metadata. See MessageMetadata for the discriminated
|
||||||
// shapes currently in use.
|
// shapes currently in use.
|
||||||
metadata: MessageMetadata | null;
|
metadata: MessageMetadata | null;
|
||||||
|
// v1.11: anchored rolling compaction. Optional so consumers that SELECT
|
||||||
|
// the pre-v1.11 column set still type-check. See compaction.ts +
|
||||||
|
// schema.sql for semantics.
|
||||||
|
summary?: boolean;
|
||||||
|
tail_start_id?: string | null;
|
||||||
|
compacted_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
|
"@xterm/addon-fit": "0.10.0",
|
||||||
|
"@xterm/addon-search": "^0.15.0",
|
||||||
|
"@xterm/addon-web-links": "0.11.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
|
"@xterm/xterm": "5.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
|||||||
@@ -68,8 +68,13 @@ function AppShell() {
|
|||||||
// theme class on <html> is correct before any child renders.
|
// theme class on <html> is correct before any child renders.
|
||||||
useTheme();
|
useTheme();
|
||||||
useUserEvents();
|
useUserEvents();
|
||||||
|
// v1.10.8c: h-dvh (dynamic viewport) instead of h-screen (100vh) so the
|
||||||
|
// root height excludes the iOS URL-bar overlay area. Without this, every
|
||||||
|
// descendant — including the terminal pane — measures itself against a
|
||||||
|
// height that extends behind the URL bar, and xterm allocates extra rows
|
||||||
|
// that scroll out of reach on iPhone.
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex bg-background text-foreground">
|
<div className="h-dvh flex bg-background text-foreground">
|
||||||
<ProjectSidebar />
|
<ProjectSidebar />
|
||||||
<MobileBackdrop />
|
<MobileBackdrop />
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
|
|||||||
@@ -168,8 +168,11 @@ export const api = {
|
|||||||
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
|
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
|
||||||
messages: (chatId: string) =>
|
messages: (chatId: string) =>
|
||||||
request<Message[]>(`/api/chats/${chatId}/messages`),
|
request<Message[]>(`/api/chats/${chatId}/messages`),
|
||||||
|
// v1.11: anchored-rolling compaction. POST awaits the LLM call inside
|
||||||
|
// the route's lifecycle; the new summary row arrives via the 'compacted'
|
||||||
|
// WS frame (useSessionStream refetches + toasts).
|
||||||
compact: (chatId: string) =>
|
compact: (chatId: string) =>
|
||||||
request<{ compact_message_id: string }>(`/api/chats/${chatId}/compact`, { method: 'POST' }),
|
request<{ ok: true }>(`/api/chats/${chatId}/compact`, { method: 'POST' }),
|
||||||
stop: (chatId: string) =>
|
stop: (chatId: string) =>
|
||||||
request<{ stopped: boolean }>(`/api/chats/${chatId}/stop`, { method: 'POST' }),
|
request<{ stopped: boolean }>(`/api/chats/${chatId}/stop`, { method: 'POST' }),
|
||||||
forceSend: (chatId: string, content: string) =>
|
forceSend: (chatId: string, content: string) =>
|
||||||
@@ -261,4 +264,31 @@ export const api = {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
get: () => request<SidebarResponse>('/api/sidebar'),
|
get: () => request<SidebarResponse>('/api/sidebar'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// v1.10 booterm: REST control plane for terminal panes. WebSocket attach
|
||||||
|
// lives at /ws/term/sessions/:sid/panes/:pid (handled directly by
|
||||||
|
// TerminalPane). v1.10.8c: resize moved in-band onto the WebSocket as a
|
||||||
|
// `{type:"resize",cols,rows}` text frame — the old /resize HTTP endpoint is
|
||||||
|
// gone, eliminating the race between WS attach and PTY-map registration.
|
||||||
|
terminals: {
|
||||||
|
// cols/rows are optional. When passed, booterm sizes the per-pane tmux
|
||||||
|
// session at creation time so the inner bash (and any TUI it spawns) is
|
||||||
|
// born with the correct PTY dimensions instead of tmux's 80x24 default.
|
||||||
|
start: (sessionId: string, paneId: string, cols?: number, rows?: number) =>
|
||||||
|
request<{ tmux_session: string }>(
|
||||||
|
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body:
|
||||||
|
cols !== undefined && rows !== undefined
|
||||||
|
? JSON.stringify({ cols, rows })
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
kill: (sessionId: string, paneId: string) =>
|
||||||
|
request<{ ok: true }>(
|
||||||
|
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -145,6 +145,19 @@ export interface Message {
|
|||||||
// v1.8.2: per-message metadata; see MessageMetadata. null for the vast
|
// v1.8.2: per-message metadata; see MessageMetadata. null for the vast
|
||||||
// majority of messages.
|
// majority of messages.
|
||||||
metadata: MessageMetadata | null;
|
metadata: MessageMetadata | null;
|
||||||
|
// v1.11: anchored rolling compaction fields. Optional on the wire so that
|
||||||
|
// older API responses (or test fixtures) parse without explicit nulls.
|
||||||
|
// summary — true on the assistant row that holds the active
|
||||||
|
// anchored summary. Render via SummaryCard.
|
||||||
|
// tail_start_id — first preserved tail message the summary covers up to
|
||||||
|
// (exclusive). Diagnostic only on the client.
|
||||||
|
// compacted_at — set on rows that are "behind the curtain" of the
|
||||||
|
// current summary. Returned by the GET endpoint so the
|
||||||
|
// UI can show history, but the server-side inference
|
||||||
|
// assembly filters these out.
|
||||||
|
summary?: boolean;
|
||||||
|
tail_start_id?: string | null;
|
||||||
|
compacted_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
@@ -305,6 +318,11 @@ export type WsFrame =
|
|||||||
}
|
}
|
||||||
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
|
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
|
||||||
| { type: 'chat_renamed'; chat_id: string; name: string }
|
| { type: 'chat_renamed'; chat_id: string; name: string }
|
||||||
|
// v1.11: published by services/compaction.ts after the new anchored
|
||||||
|
// summary row lands. Carries the new summary row id for diagnostics; the
|
||||||
|
// session-stream handler ignores the id and re-fetches the full message
|
||||||
|
// list (the cohort of compacted_at-stamped rows changed too).
|
||||||
|
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
|
||||||
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
|
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
|
||||||
// over `error` text when present).
|
// over `error` text when present).
|
||||||
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason };
|
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason };
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { AgentPicker } from '@/components/AgentPicker';
|
|||||||
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { chatInputsRegistry, sendToChat } from '@/lib/events';
|
||||||
import { useSkills } from '@/hooks/useSkills';
|
import { useSkills } from '@/hooks/useSkills';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
@@ -51,9 +52,16 @@ interface Props {
|
|||||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||||
// disables slash-command dispatch (input is sent as literal text).
|
// disables slash-command dispatch (input is sent as literal text).
|
||||||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||||||
|
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
||||||
|
// registers in chatInputsRegistry so the terminal floating menu can list
|
||||||
|
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
||||||
|
// an event appends the text to the current draft (with a newline separator
|
||||||
|
// when non-empty) and focuses — no auto-send.
|
||||||
|
chatId?: string;
|
||||||
|
chatLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand }: Props) {
|
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -107,6 +115,35 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// v1.10.4: register this input in the chat-input registry so the terminal
|
||||||
|
// pane's "Send to chat" menu can list it. Re-registers when chatLabel
|
||||||
|
// changes (e.g. rename) so the menu reflects the current name.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatId) return;
|
||||||
|
return chatInputsRegistry.register(chatId, chatLabel ?? 'Chat', () => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
});
|
||||||
|
}, [chatId, chatLabel]);
|
||||||
|
|
||||||
|
// v1.10.4: subscribe to send_to_chat events scoped by chatId. Appends the
|
||||||
|
// payload text to the current draft (with a newline separator if the
|
||||||
|
// draft is non-empty) and focuses the textarea. Does NOT auto-submit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatId) return;
|
||||||
|
return sendToChat.subscribe(({ chat_id, text }) => {
|
||||||
|
if (chat_id !== chatId) return;
|
||||||
|
setValue((prev) => (prev.length === 0 ? text : `${prev}\n${text}`));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
ta.focus();
|
||||||
|
// Put caret at end so the user can keep typing immediately.
|
||||||
|
const end = ta.value.length;
|
||||||
|
ta.selectionStart = ta.selectionEnd = end;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
function removeAttachment(id: string) {
|
function removeAttachment(id: string) {
|
||||||
setAttachments(prev => prev.filter(a => a.id !== id));
|
setAttachments(prev => prev.filter(a => a.id !== id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { History, MessageSquare, Plus, X } from 'lucide-react';
|
import { Bot, History, MessageSquare, Plus, Terminal, X } from 'lucide-react';
|
||||||
import type { Chat, WorkspacePane } from '@/api/types';
|
import type { Chat, WorkspacePane } from '@/api/types';
|
||||||
import { StatusDot } from '@/components/StatusDot';
|
import { StatusDot } from '@/components/StatusDot';
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +9,12 @@ import {
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useLongPress } from '@/hooks/useLongPress';
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -20,7 +26,7 @@ interface Props {
|
|||||||
onCloseOthers: (chatId: string) => void;
|
onCloseOthers: (chatId: string) => void;
|
||||||
onCloseToRight: (chatId: string) => void;
|
onCloseToRight: (chatId: string) => void;
|
||||||
onCloseAll: () => void;
|
onCloseAll: () => void;
|
||||||
onNewChat: () => void;
|
onAddPane: (kind: 'chat' | 'terminal' | 'agent') => void;
|
||||||
onShowHistory: () => void;
|
onShowHistory: () => void;
|
||||||
onRename: (chatId: string, name: string) => Promise<void>;
|
onRename: (chatId: string, name: string) => Promise<void>;
|
||||||
onRemovePane?: () => void;
|
onRemovePane?: () => void;
|
||||||
@@ -34,7 +40,7 @@ export function ChatTabBar({
|
|||||||
onCloseOthers,
|
onCloseOthers,
|
||||||
onCloseToRight,
|
onCloseToRight,
|
||||||
onCloseAll,
|
onCloseAll,
|
||||||
onNewChat,
|
onAddPane,
|
||||||
onShowHistory,
|
onShowHistory,
|
||||||
onRename,
|
onRename,
|
||||||
onRemovePane,
|
onRemovePane,
|
||||||
@@ -125,7 +131,7 @@ export function ChatTabBar({
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem onSelect={() => onNewChat()}>
|
<ContextMenuItem onSelect={() => onAddPane('chat')}>
|
||||||
New chat
|
New chat
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
@@ -164,15 +170,29 @@ export function ChatTabBar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNewChat}
|
|
||||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="New chat"
|
aria-label="New pane"
|
||||||
title="New chat"
|
title="New pane"
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-40">
|
||||||
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||||
|
<MessageSquare size={14} /> New chat
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||||
|
<Terminal size={14} /> New terminal
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => onAddPane('agent')}>
|
||||||
|
<Bot size={14} /> New agent
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onShowHistory}
|
onClick={onShowHistory}
|
||||||
|
|||||||
86
apps/web/src/components/ContextBar.tsx
Normal file
86
apps/web/src/components/ContextBar.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Message } from '@/api/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.11.2: persistent context-usage indicator above MessageList. Mirrors the
|
||||||
|
// server-side compaction.usable() formula — color thresholds are computed
|
||||||
|
// against (max - 20k buffer), not raw max, so the bar turns amber/orange
|
||||||
|
// /red at the same boundaries auto-compaction will fire. The popover above
|
||||||
|
// the input (ChatContextPopover) uses raw-% thresholds and is intentionally
|
||||||
|
// kept separate (it's a different surface and a different signal).
|
||||||
|
const COMPACTION_BUFFER = 20_000;
|
||||||
|
|
||||||
|
// Walk newest-first; first message with both ctx_used and ctx_max non-null
|
||||||
|
// AND ctx_max > 0 wins. Older messages may have ctx_used but missing ctx_max
|
||||||
|
// (early v1 before llama-swap's n_ctx capture worked) — skip them and keep
|
||||||
|
// walking. If nothing usable in the chat, caller renders null.
|
||||||
|
function latestPair(messages: Message[]): { used: number; max: number } | null {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const m = messages[i]!;
|
||||||
|
if (m.ctx_used == null || m.ctx_max == null) continue;
|
||||||
|
if (m.ctx_max <= 0) continue;
|
||||||
|
return { used: m.ctx_used, max: m.ctx_max };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorTier {
|
||||||
|
// Tailwind utility for the label / numbers. Uses literal palette names
|
||||||
|
// rather than design tokens because we want three distinct severities
|
||||||
|
// (amber → orange → red) and BooCode only defines one warning token
|
||||||
|
// (`destructive`). Literal classes keep the gradation explicit.
|
||||||
|
text: string;
|
||||||
|
bar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierFor(usablePct: number): ColorTier {
|
||||||
|
if (usablePct >= 0.95) return { text: 'text-red-600 dark:text-red-400', bar: 'bg-red-500' };
|
||||||
|
if (usablePct >= 0.80) return { text: 'text-orange-600 dark:text-orange-400', bar: 'bg-orange-500' };
|
||||||
|
if (usablePct >= 0.60) return { text: 'text-amber-600 dark:text-amber-400', bar: 'bg-amber-500' };
|
||||||
|
return { text: 'text-muted-foreground', bar: 'bg-muted-foreground/40' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextBar({ messages }: Props) {
|
||||||
|
const pair = latestPair(messages);
|
||||||
|
if (!pair) return null;
|
||||||
|
|
||||||
|
const { used, max } = pair;
|
||||||
|
const usable = Math.max(0, max - COMPACTION_BUFFER);
|
||||||
|
const pct = used / max;
|
||||||
|
const usablePct = usable > 0 ? used / usable : 0;
|
||||||
|
const tier = tierFor(usablePct);
|
||||||
|
|
||||||
|
// Bar fill is clamped to [0, 100] — over-budget cases (usable < used) still
|
||||||
|
// show the bar at 100% red rather than overflowing the track visually.
|
||||||
|
const fillPct = Math.min(100, Math.max(0, pct * 100));
|
||||||
|
const compactionThresholdPct = max > 0 ? Math.round((usable / max) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b px-4 py-1 shrink-0">
|
||||||
|
<div className="max-w-[1000px] mx-auto w-full">
|
||||||
|
<div className="flex items-baseline justify-between text-[10px] font-mono leading-tight">
|
||||||
|
{/* "Context" on >=sm, "Ctx" on phones to save horizontal space. */}
|
||||||
|
<span className={tier.text}>
|
||||||
|
<span className="hidden sm:inline">Context</span>
|
||||||
|
<span className="sm:hidden">Ctx</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={tier.text}
|
||||||
|
title={`Auto-compaction at ~${compactionThresholdPct}%`}
|
||||||
|
>
|
||||||
|
{used.toLocaleString()} / {max.toLocaleString()}{' '}
|
||||||
|
<span className="max-[380px]:hidden">({Math.round(pct * 100)}%)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-1 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${tier.bar} transition-[width] duration-300`}
|
||||||
|
style={{ width: `${fillPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Children, cloneElement, isValidElement, useState } from 'react';
|
import { Children, cloneElement, isValidElement, useEffect, useState } from 'react';
|
||||||
import type { ReactElement, ReactNode } from 'react';
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
import Markdown from 'react-markdown';
|
import Markdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
@@ -7,9 +7,19 @@ import { toast } from 'sonner';
|
|||||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||||
import { CapHitSentinel } from './CapHitSentinel';
|
import { CapHitSentinel } from './CapHitSentinel';
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from '@/components/ui/context-menu';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -19,6 +29,57 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
|
||||||
|
// Used by the right-click "Send to terminal" submenu so it always reflects
|
||||||
|
// currently-open terminal panes without prop drilling from Workspace.
|
||||||
|
function useTerminals(): TerminalRegistration[] {
|
||||||
|
const [list, setList] = useState(() => terminalsRegistry.list());
|
||||||
|
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap a message body with a right-click context menu offering "Send to
|
||||||
|
// terminal → <pane name>". The submenu is disabled when nothing is selected
|
||||||
|
// or no terminal panes are open; clicking a target emits a sendToTerminal
|
||||||
|
// event that TerminalPane subscribes to (filtered by pane_id).
|
||||||
|
function SendToTerminalMenu({ children }: { children: ReactNode }) {
|
||||||
|
const [selection, setSelection] = useState('');
|
||||||
|
const terminals = useTerminals();
|
||||||
|
const canSend = selection.length > 0 && terminals.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : '';
|
||||||
|
setSelection(sel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuSub>
|
||||||
|
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
|
||||||
|
<ContextMenuSubContent>
|
||||||
|
{terminals.length === 0 ? (
|
||||||
|
<ContextMenuItem disabled>No terminal panes open</ContextMenuItem>
|
||||||
|
) : (
|
||||||
|
terminals.map((t) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={t.paneId}
|
||||||
|
onSelect={() => sendToTerminal.emit({ pane_id: t.paneId, text: selection })}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</ContextMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ContextMenuSubContent>
|
||||||
|
</ContextMenuSub>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// v1.8.2: human labels for the machine-readable error reasons that ride on
|
// v1.8.2: human labels for the machine-readable error reasons that ride on
|
||||||
// failed assistant messages via metadata.kind === 'error'. Kept short so the
|
// failed assistant messages via metadata.kind === 'error'. Kept short so the
|
||||||
// inline render under "message failed" stays a single muted line.
|
// inline render under "message failed" stays a single muted line.
|
||||||
@@ -476,7 +537,70 @@ function CompactCard({ message, sessionChats }: { message: Message; sessionChats
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.11 anchored rolling summary. Inserted by services/compaction.ts as a
|
||||||
|
// role='assistant', summary=true row. Distinct from legacy CompactCard
|
||||||
|
// (which renders the kind='compact' system rows produced by v1.10 /compact).
|
||||||
|
// Collapsed by default; header shows the timestamp; body renders the
|
||||||
|
// summary markdown when expanded. Copy button matches CompactCard's affordance.
|
||||||
|
function SummaryCard({ message }: { message: Message }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Use finished_at when available (that's when the summary actually landed);
|
||||||
|
// fall back to created_at for any row missing it. Both are ISO strings.
|
||||||
|
const ts = message.finished_at ?? message.created_at;
|
||||||
|
const headerTs = ts ? new Date(ts).toLocaleString() : '';
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(message.content);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1200);
|
||||||
|
toast.success('Summary copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Copy failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 text-sm">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span className="text-xs font-medium truncate">
|
||||||
|
Compacted summary — {headerTs}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||||
|
aria-label="Copy summary"
|
||||||
|
title="Copy summary"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
|
||||||
|
<MarkdownBody content={message.content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||||
|
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
|
||||||
|
// branch because summary=true never coexists with kind='compact' (new
|
||||||
|
// compactions emit role='assistant' rows with kind='message'+summary=true).
|
||||||
|
if (message.summary) {
|
||||||
|
return <SummaryCard message={message} />;
|
||||||
|
}
|
||||||
if (message.kind === 'compact') {
|
if (message.kind === 'compact') {
|
||||||
return <CompactCard message={message} sessionChats={sessionChats} />;
|
return <CompactCard message={message} sessionChats={sessionChats} />;
|
||||||
}
|
}
|
||||||
@@ -507,9 +631,11 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
|||||||
if (message.role === 'user') {
|
if (message.role === 'user') {
|
||||||
return (
|
return (
|
||||||
<div className="group flex flex-col items-end gap-1">
|
<div className="group flex flex-col items-end gap-1">
|
||||||
|
<SendToTerminalMenu>
|
||||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
|
</SendToTerminalMenu>
|
||||||
<ActionRow message={message} />
|
<ActionRow message={message} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -529,12 +655,14 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="group flex flex-col gap-2">
|
<div className="group flex flex-col gap-2">
|
||||||
{(hasContent || isStreaming) && (
|
{(hasContent || isStreaming) && (
|
||||||
|
<SendToTerminalMenu>
|
||||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||||
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SendToTerminalMenu>
|
||||||
)}
|
)}
|
||||||
{failed && (
|
{failed && (
|
||||||
<div className="text-xs text-destructive">
|
<div className="text-xs text-destructive">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -31,6 +31,15 @@ interface Props {
|
|||||||
onRenameChat: (chatId: string, name: string) => Promise<void>;
|
onRenameChat: (chatId: string, name: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10.4: swipe-left-to-close on the pane pill. Threshold matches the spec
|
||||||
|
// (80px). Vertical bail-out at 30px because the pill sits inside a vertical
|
||||||
|
// scrollable header — diagonal-ish swipes shouldn't accidentally close panes.
|
||||||
|
const SWIPE_CLOSE_PX = 80;
|
||||||
|
const SWIPE_VERTICAL_BAIL_PX = 30;
|
||||||
|
// Visual cap: pill translates left up to this much. Past this, dragX stays
|
||||||
|
// pinned so the user has a clear "release to close" indicator.
|
||||||
|
const SWIPE_VISUAL_CAP = 120;
|
||||||
|
|
||||||
function paneIcon(kind: WorkspacePane['kind']) {
|
function paneIcon(kind: WorkspacePane['kind']) {
|
||||||
if (kind === 'terminal') return <Terminal size={14} />;
|
if (kind === 'terminal') return <Terminal size={14} />;
|
||||||
if (kind === 'agent') return <Bot size={14} />;
|
if (kind === 'agent') return <Bot size={14} />;
|
||||||
@@ -70,11 +79,66 @@ export function MobileTabSwitcher({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
|
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
// v1.10.4: swipe-left state. dragX is the (clamped, negative) drag offset
|
||||||
|
// in px. suppressClick latches when a swipe completes so the trailing click
|
||||||
|
// doesn't pop open the BottomSheet on the just-closed pane.
|
||||||
|
const [dragX, setDragX] = useState(0);
|
||||||
|
const swipeStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const swipeBailed = useRef(false);
|
||||||
|
const suppressClick = useRef(false);
|
||||||
|
|
||||||
const active = panes[activePaneIdx];
|
const active = panes[activePaneIdx];
|
||||||
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
|
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
|
||||||
const activeChatId = paneActiveChatId(active);
|
const activeChatId = paneActiveChatId(active);
|
||||||
|
|
||||||
|
function onPillTouchStart(e: React.TouchEvent<HTMLDivElement>): void {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const t = e.touches[0]!;
|
||||||
|
swipeStart.current = { x: t.clientX, y: t.clientY };
|
||||||
|
swipeBailed.current = false;
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
function onPillTouchMove(e: React.TouchEvent<HTMLDivElement>): void {
|
||||||
|
if (!swipeStart.current || swipeBailed.current) return;
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const t = e.touches[0]!;
|
||||||
|
const dx = t.clientX - swipeStart.current.x;
|
||||||
|
const dy = t.clientY - swipeStart.current.y;
|
||||||
|
// Bail to scroll if vertical motion dominates before horizontal.
|
||||||
|
if (Math.abs(dy) > SWIPE_VERTICAL_BAIL_PX && Math.abs(dy) > Math.abs(dx)) {
|
||||||
|
swipeBailed.current = true;
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only allow leftward drag (negative). Cap visual displacement.
|
||||||
|
const clamped = Math.max(-SWIPE_VISUAL_CAP, Math.min(0, dx));
|
||||||
|
setDragX(clamped);
|
||||||
|
}
|
||||||
|
function onPillTouchEnd(): void {
|
||||||
|
const finalDx = dragX;
|
||||||
|
swipeStart.current = null;
|
||||||
|
if (swipeBailed.current) {
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (finalDx <= -SWIPE_CLOSE_PX && panes.length > 1) {
|
||||||
|
suppressClick.current = true;
|
||||||
|
// Reset dragX after the close so subsequent re-renders look right.
|
||||||
|
setDragX(0);
|
||||||
|
onRemovePane(activePaneIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
function onPillClick(): void {
|
||||||
|
if (suppressClick.current) {
|
||||||
|
suppressClick.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
const swipeProgress = Math.min(1, Math.abs(dragX) / SWIPE_CLOSE_PX);
|
||||||
|
|
||||||
// Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row
|
// Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row
|
||||||
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
|
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
|
||||||
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
||||||
@@ -113,17 +177,39 @@ export function MobileTabSwitcher({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div
|
||||||
|
className="flex-1 relative min-w-0"
|
||||||
|
onTouchStart={onPillTouchStart}
|
||||||
|
onTouchMove={onPillTouchMove}
|
||||||
|
onTouchEnd={onPillTouchEnd}
|
||||||
|
onTouchCancel={onPillTouchEnd}
|
||||||
|
>
|
||||||
|
{/* v1.10.4: red "Close" hint behind the pill. Opacity tracks the
|
||||||
|
swipe progress (0 at rest, 1 at the close threshold). aria-hidden
|
||||||
|
because the actionable affordance is the swipe, not this label. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 flex items-center justify-end pr-4 rounded-full bg-destructive/80 text-destructive-foreground text-xs font-medium"
|
||||||
|
style={{ opacity: swipeProgress, pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={onPillClick}
|
||||||
className="flex-1 inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0"
|
className="flex-1 w-full inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0 relative"
|
||||||
aria-label="Switch pane"
|
aria-label="Switch pane"
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${dragX}px)`,
|
||||||
|
transition: dragX === 0 ? 'transform 180ms ease-out' : 'none',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
|
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
|
||||||
<StatusDot chatId={activeChatId} />
|
<StatusDot chatId={activeChatId} />
|
||||||
<span className="truncate flex-1 text-left">{activeLabel}</span>
|
<span className="truncate flex-1 text-left">{activeLabel}</span>
|
||||||
<ChevronDown size={14} className="opacity-60 shrink-0" />
|
<ChevronDown size={14} className="opacity-60 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
|
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
|
||||||
<ul className="px-2 py-2 space-y-1">
|
<ul className="px-2 py-2 space-y-1">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
import { PanelRight, MessageSquare, Terminal, Bot, Clipboard, Plus, X } from 'lucide-react';
|
||||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||||
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
import { terminalsRegistry } from '@/lib/events';
|
||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { SettingsPane } from '@/components/panes/SettingsPane';
|
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||||
|
import { TerminalPane } from '@/components/panes/TerminalPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||||
import {
|
import {
|
||||||
@@ -115,6 +117,20 @@ export function Workspace({
|
|||||||
.filter((c): c is Chat => c !== undefined);
|
.filter((c): c is Chat => c !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10 booterm: per-terminal label used by the registry that powers the
|
||||||
|
// MessageBubble "Send to terminal" submenu. Numbered in workspace order.
|
||||||
|
const terminalLabels = useMemo(() => {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
let n = 0;
|
||||||
|
for (const p of panes) {
|
||||||
|
if (p.kind === 'terminal') {
|
||||||
|
n += 1;
|
||||||
|
out.set(p.id, `Terminal ${n}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [panes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
@@ -165,6 +181,7 @@ export function Workspace({
|
|||||||
>
|
>
|
||||||
{panes.map((pane, idx) => {
|
{panes.map((pane, idx) => {
|
||||||
const isSettings = pane.kind === 'settings';
|
const isSettings = pane.kind === 'settings';
|
||||||
|
const isTerminal = pane.kind === 'terminal';
|
||||||
// v1.9: when maximized, hide every pane except the settings one.
|
// v1.9: when maximized, hide every pane except the settings one.
|
||||||
// display:none keeps the React tree mounted so streams / drafts
|
// display:none keeps the React tree mounted so streams / drafts
|
||||||
// survive the toggle without re-mount cost.
|
// survive the toggle without re-mount cost.
|
||||||
@@ -176,6 +193,9 @@ export function Workspace({
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
|
||||||
|
// are not drag-reorderable for now — keeps the layout grid simple.
|
||||||
|
const isChromeless = isSettings || isTerminal;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pane.id}
|
key={pane.id}
|
||||||
@@ -187,19 +207,18 @@ export function Workspace({
|
|||||||
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
||||||
)}
|
)}
|
||||||
onClick={() => setActivePaneIdx(idx)}
|
onClick={() => setActivePaneIdx(idx)}
|
||||||
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
onDragOver={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||||
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
|
onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||||
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
draggable={!isMobile && !isSettings && panes.length > 1}
|
draggable={!isMobile && !isChromeless && panes.length > 1}
|
||||||
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||||
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
|
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||||
>
|
>
|
||||||
{/* Hidden on mobile per v1.8; settings panes own their own
|
{/* Hidden on mobile per v1.8; settings + terminal panes own
|
||||||
section nav / maximize toggle so they skip ChatTabBar
|
their own header (no chats, so no ChatTabBar). */}
|
||||||
entirely. */}
|
{!isMobile && !isChromeless && (
|
||||||
{!isMobile && !isSettings && (
|
|
||||||
<ChatTabBar
|
<ChatTabBar
|
||||||
pane={pane}
|
pane={pane}
|
||||||
tabs={chatsForPane(pane)}
|
tabs={chatsForPane(pane)}
|
||||||
@@ -208,12 +227,78 @@ export function Workspace({
|
|||||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||||
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
||||||
onCloseAll={() => closeAllTabs(idx)}
|
onCloseAll={() => closeAllTabs(idx)}
|
||||||
onNewChat={() => void createChat(idx)}
|
onAddPane={(kind) => {
|
||||||
|
if (kind === 'chat') void createChat(idx);
|
||||||
|
else addSplitPane(kind);
|
||||||
|
}}
|
||||||
onShowHistory={() => showLandingPage(idx)}
|
onShowHistory={() => showLandingPage(idx)}
|
||||||
onRename={renameChat}
|
onRename={renameChat}
|
||||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isTerminal && (
|
||||||
|
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||||
|
<Terminal size={12} className="text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
|
</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||||
|
aria-label="New pane"
|
||||||
|
title="New pane"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-40">
|
||||||
|
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
||||||
|
<MessageSquare size={14} /> New chat
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||||
|
<Terminal size={14} /> New terminal
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
|
||||||
|
<Bot size={14} /> New agent
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{/* v1.10.4: iOS Safari restricts navigator.clipboard.readText
|
||||||
|
outside direct user gestures. A real button click IS a
|
||||||
|
gesture, so this works where keystroke-driven paste may
|
||||||
|
not on iOS. The action lives in TerminalPane behind the
|
||||||
|
registry's paste() callback. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
terminalsRegistry.get(pane.id)?.paste();
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||||
|
aria-label="Paste from clipboard"
|
||||||
|
title="Paste from clipboard"
|
||||||
|
>
|
||||||
|
<Clipboard size={12} />
|
||||||
|
</button>
|
||||||
|
{panes.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removePane(idx);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||||
|
aria-label="Close terminal pane"
|
||||||
|
title="Close terminal pane"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
@@ -226,6 +311,13 @@ export function Workspace({
|
|||||||
onClose={() => removePane(idx)}
|
onClose={() => removePane(idx)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
) : isTerminal ? (
|
||||||
|
<TerminalPane
|
||||||
|
sessionId={sessionId}
|
||||||
|
paneId={pane.id}
|
||||||
|
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
|
active={idx === activePaneIdx}
|
||||||
|
/>
|
||||||
) : pane.kind === 'chat' && pane.chatId ? (
|
) : pane.kind === 'chat' && pane.chatId ? (
|
||||||
<ChatPane
|
<ChatPane
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useChatContextStats } from '@/hooks/useChatContextStats';
|
|||||||
import { MessageList } from '@/components/MessageList';
|
import { MessageList } from '@/components/MessageList';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
import { ChatContextPopover } from '@/components/ChatContextPopover';
|
import { ChatContextPopover } from '@/components/ChatContextPopover';
|
||||||
|
import { ContextBar } from '@/components/ContextBar';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -125,6 +126,10 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* v1.11.2: persistent context-usage indicator. Renders null when there
|
||||||
|
are no assistant messages yet (fresh chat). shrink-0 keeps it out of
|
||||||
|
the MessageList scroll region — bar stays pinned, list scrolls. */}
|
||||||
|
<ContextBar messages={chatMessages} />
|
||||||
<MessageList messages={chatMessages} sessionChats={sessionChats} />
|
<MessageList messages={chatMessages} sessionChats={sessionChats} />
|
||||||
|
|
||||||
{/* Queued messages */}
|
{/* Queued messages */}
|
||||||
@@ -196,6 +201,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onForceSend={streaming ? handleForceSend : undefined}
|
onForceSend={streaming ? handleForceSend : undefined}
|
||||||
onSlashCommand={handleSlashCommand}
|
onSlashCommand={handleSlashCommand}
|
||||||
|
chatId={chatId}
|
||||||
|
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1298
apps/web/src/components/panes/TerminalPane.tsx
Normal file
1298
apps/web/src/components/panes/TerminalPane.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import type { Message, WsFrame } from '@/api/types';
|
import type { Message, WsFrame } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from './sessionEvents';
|
import { sessionEvents } from './sessionEvents';
|
||||||
|
|
||||||
// session_renamed frame removed from WsFrame — it was declared but never
|
// session_renamed frame removed from WsFrame — it was declared but never
|
||||||
@@ -161,6 +163,12 @@ function applyFrame(state: State, frame: WsFrame): State {
|
|||||||
: state.messages;
|
: state.messages;
|
||||||
return { ...state, messages: next, error: frame.error };
|
return { ...state, messages: next, error: frame.error };
|
||||||
}
|
}
|
||||||
|
case 'compacted': {
|
||||||
|
// v1.11: side effects (refetch + toast) live in ws.onmessage; the
|
||||||
|
// reducer just no-ops so TS exhaustiveness is satisfied without
|
||||||
|
// duplicating async work inside a synchronous reducer.
|
||||||
|
return state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +204,25 @@ export function useSessionStream(sessionId: string | undefined) {
|
|||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
const frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
|
const frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
|
||||||
|
// v1.11: on a compaction completion, re-fetch the message list so
|
||||||
|
// the new summary row + the cohort of compacted_at-stamped older
|
||||||
|
// rows render correctly. We dispatch the fresh list as a synthetic
|
||||||
|
// 'snapshot' frame so the reducer's existing path handles state
|
||||||
|
// replacement (no need for a parallel "refetched" path).
|
||||||
|
// The toast is purely UX feedback; missing it would still leave
|
||||||
|
// the chat in a valid state.
|
||||||
|
if (frame.type === 'compacted') {
|
||||||
|
toast.success('Context compacted to free space');
|
||||||
|
void api.messages
|
||||||
|
.list(frame.session_id)
|
||||||
|
.then((messages) => {
|
||||||
|
setState((s) => applyFrame(s, { type: 'snapshot', messages }));
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.warn('compacted refetch failed', err);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState((s) => applyFrame(s, frame));
|
setState((s) => applyFrame(s, frame));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('bad ws frame', err);
|
console.warn('bad ws frame', err);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { DragEvent } from 'react';
|
import type { DragEvent } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/api/client';
|
||||||
import type { WorkspacePane } from '@/api/types';
|
import type { WorkspacePane } from '@/api/types';
|
||||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||||
|
|
||||||
@@ -11,14 +12,25 @@ function generateId(): string {
|
|||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyPane(): WorkspacePane {
|
// v1.10.3: optional id arg lets addSplitPane lift id generation out of the
|
||||||
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
// setPanes updater so the new pane's id can be returned synchronously to the
|
||||||
|
// caller (needed for mobile URL state).
|
||||||
|
function emptyPane(id: string = generateId()): WorkspacePane {
|
||||||
|
return { id, kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatPane(chatId: string): WorkspacePane {
|
function chatPane(chatId: string): WorkspacePane {
|
||||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10 booterm: terminal panes carry no chats. Their `id` is used as the
|
||||||
|
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They
|
||||||
|
// persist in localStorage along with chat panes so a refresh resumes the
|
||||||
|
// same tmux window via the idempotent start endpoint.
|
||||||
|
function terminalPane(id: string = generateId()): WorkspacePane {
|
||||||
|
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||||
// SettingsPane component renders Session/Project sections from the
|
// SettingsPane component renders Session/Project sections from the
|
||||||
// surrounding session/project.
|
// surrounding session/project.
|
||||||
@@ -72,7 +84,11 @@ export interface UseWorkspacePanesResult {
|
|||||||
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
|
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
|
||||||
closeAllTabs: (paneIdx: number) => void;
|
closeAllTabs: (paneIdx: number) => void;
|
||||||
showLandingPage: (paneIdx: number) => void;
|
showLandingPage: (paneIdx: number) => void;
|
||||||
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
|
// v1.10.3: returns the new pane's id (or null if the operation was a no-op:
|
||||||
|
// 'agent' kind is a toast stub, or max panes reached). Callers can use the
|
||||||
|
// id to update mobile URL state so the URL-sync effect doesn't fight the
|
||||||
|
// freshly-set activePaneIdx.
|
||||||
|
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => string | null;
|
||||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
||||||
// falls back to an empty pane to preserve the "always one pane" invariant.
|
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||||
@@ -233,25 +249,29 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
|
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent'): string | null => {
|
||||||
if (kind === 'terminal') {
|
|
||||||
toast('Terminal panes coming in BooTerm');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === 'agent') {
|
if (kind === 'agent') {
|
||||||
toast('Agent panes coming in BooCoder');
|
toast('Agent panes coming in BooCoder');
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Generate the id outside the updater so we can return it deterministically.
|
||||||
|
// setPanes's updater can be invoked twice in strict mode; using a fixed id
|
||||||
|
// ensures both invocations agree and the returned id matches what landed.
|
||||||
|
const newPaneId = generateId();
|
||||||
|
let success = false;
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
// v1.9: settings panes are excluded from the MAX cap (decision c).
|
// v1.9: settings panes are excluded from the MAX cap (decision c).
|
||||||
if (nonSettingsCount(prev) >= MAX_PANES) {
|
if (nonSettingsCount(prev) >= MAX_PANES) {
|
||||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const next = [...prev, emptyPane()];
|
const newPane = kind === 'terminal' ? terminalPane(newPaneId) : emptyPane(newPaneId);
|
||||||
|
const next = [...prev, newPane];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
|
success = true;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
return success ? newPaneId : null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleSettingsPane = useCallback(() => {
|
const toggleSettingsPane = useCallback(() => {
|
||||||
@@ -283,11 +303,19 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
// v1.10.8c: with per-pane tmux sessions, an unkilled session leaks until
|
||||||
|
// the next `tmux kill-server`. Fire-and-forget /kill on terminal removal.
|
||||||
|
// The endpoint is idempotent (404 on missing session) so a strict-mode
|
||||||
|
// double-invoke of the updater is safe.
|
||||||
|
const removed = prev[idx];
|
||||||
|
if (removed?.kind === 'terminal') {
|
||||||
|
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
|
||||||
|
}
|
||||||
const next = prev.filter((_, i) => i !== idx);
|
const next = prev.filter((_, i) => i !== idx);
|
||||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [sessionId]);
|
||||||
|
|
||||||
// Replaces a single empty default pane with a chat pane. Used by the initial
|
// Replaces a single empty default pane with a chat pane. Used by the initial
|
||||||
// chat fetch to land on the most-recent open chat if no saved pane state.
|
// chat fetch to land on the most-recent open chat if no saved pane state.
|
||||||
|
|||||||
151
apps/web/src/lib/events.ts
Normal file
151
apps/web/src/lib/events.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// Minimal pub/sub for ephemeral UI events that don't belong on the sessionEvents
|
||||||
|
// bus (sessionEvents is for DB-state changes; this file is for UI-only signals
|
||||||
|
// like "user clicked send-to-terminal on selected text").
|
||||||
|
//
|
||||||
|
// Also exposes a tiny registry of currently-mounted terminal panes so the
|
||||||
|
// MessageBubble context menu can list them. TerminalPane registers on mount,
|
||||||
|
// unregisters on unmount. v1.10.4 adds a parallel ChatInput registry used by
|
||||||
|
// the terminal floating menu's "Send to chat" submenu.
|
||||||
|
|
||||||
|
type Listener<T> = (payload: T) => void;
|
||||||
|
|
||||||
|
interface EventBus<T> {
|
||||||
|
emit(payload: T): void;
|
||||||
|
subscribe(listener: Listener<T>): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvent<T>(): EventBus<T> {
|
||||||
|
const listeners = new Set<Listener<T>>();
|
||||||
|
return {
|
||||||
|
emit(payload) {
|
||||||
|
for (const l of listeners) {
|
||||||
|
try {
|
||||||
|
l(payload);
|
||||||
|
} catch {
|
||||||
|
/* one bad listener shouldn't break others */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendToTerminalPayload {
|
||||||
|
pane_id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendToTerminal = createEvent<SendToTerminalPayload>();
|
||||||
|
|
||||||
|
// v1.10.4: reverse direction. Terminal floating menu "Send to chat" emits this
|
||||||
|
// with the target chat's chat_id; ChatInput subscribes and appends to its draft.
|
||||||
|
export interface SendToChatPayload {
|
||||||
|
chat_id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendToChat = createEvent<SendToChatPayload>();
|
||||||
|
|
||||||
|
export interface TerminalRegistration {
|
||||||
|
paneId: string;
|
||||||
|
label: string;
|
||||||
|
// v1.10.3 kbd-shortcuts: Cmd+` needs to focus the active terminal's xterm
|
||||||
|
// input layer. TerminalPane binds this to term.focus().
|
||||||
|
focus: () => void;
|
||||||
|
// v1.10.4: Cmd+F opens the search bar over the active terminal. Workspace
|
||||||
|
// also binds a "Paste" button in the terminal pane header to paste().
|
||||||
|
openSearch: () => void;
|
||||||
|
paste: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalRegistry = new Map<string, TerminalRegistration>();
|
||||||
|
const registryListeners = new Set<Listener<void>>();
|
||||||
|
|
||||||
|
function notifyRegistry(): void {
|
||||||
|
for (const l of registryListeners) {
|
||||||
|
try {
|
||||||
|
l();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const terminalsRegistry = {
|
||||||
|
register(
|
||||||
|
paneId: string,
|
||||||
|
label: string,
|
||||||
|
focus: () => void,
|
||||||
|
openSearch: () => void,
|
||||||
|
paste: () => void,
|
||||||
|
): () => void {
|
||||||
|
terminalRegistry.set(paneId, { paneId, label, focus, openSearch, paste });
|
||||||
|
notifyRegistry();
|
||||||
|
return () => {
|
||||||
|
terminalRegistry.delete(paneId);
|
||||||
|
notifyRegistry();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
list(): TerminalRegistration[] {
|
||||||
|
return Array.from(terminalRegistry.values());
|
||||||
|
},
|
||||||
|
get(paneId: string): TerminalRegistration | undefined {
|
||||||
|
return terminalRegistry.get(paneId);
|
||||||
|
},
|
||||||
|
subscribe(listener: Listener<void>): () => void {
|
||||||
|
registryListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
registryListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// v1.10.4: parallel registry of mounted ChatInput components so the terminal
|
||||||
|
// floating menu's "Send to chat" submenu can list open chats. Mirrors
|
||||||
|
// terminalsRegistry exactly; same subscriber pattern.
|
||||||
|
export interface ChatInputRegistration {
|
||||||
|
chatId: string;
|
||||||
|
label: string;
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatInputRegistry = new Map<string, ChatInputRegistration>();
|
||||||
|
const chatInputListeners = new Set<Listener<void>>();
|
||||||
|
|
||||||
|
function notifyChatInputs(): void {
|
||||||
|
for (const l of chatInputListeners) {
|
||||||
|
try {
|
||||||
|
l();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatInputsRegistry = {
|
||||||
|
register(chatId: string, label: string, focus: () => void): () => void {
|
||||||
|
chatInputRegistry.set(chatId, { chatId, label, focus });
|
||||||
|
notifyChatInputs();
|
||||||
|
return () => {
|
||||||
|
chatInputRegistry.delete(chatId);
|
||||||
|
notifyChatInputs();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
list(): ChatInputRegistration[] {
|
||||||
|
return Array.from(chatInputRegistry.values());
|
||||||
|
},
|
||||||
|
get(chatId: string): ChatInputRegistration | undefined {
|
||||||
|
return chatInputRegistry.get(chatId);
|
||||||
|
},
|
||||||
|
subscribe(listener: Listener<void>): () => void {
|
||||||
|
chatInputListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
chatInputListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// Fonts imported as JS side-effect modules (boolab pattern, adapted for
|
||||||
|
// Tailwind v4 + Vite asset-pipeline URL rewriting). Must precede the React
|
||||||
|
// imports so the @font-face CSS lands before any component-tree render.
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
|
import '@fontsource-variable/jetbrains-mono';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ChevronRight, FolderTree, Menu } from 'lucide-react';
|
|||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Project, Session as SessionType } from '@/api/types';
|
import type { Project, Session as SessionType } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { terminalsRegistry } from '@/lib/events';
|
||||||
import { useActivePane } from '@/hooks/useActivePane';
|
import { useActivePane } from '@/hooks/useActivePane';
|
||||||
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||||
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||||
@@ -170,6 +171,122 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
|
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v1.10.3 fix: addSplitPane sets activePaneIdx, but on mobile the URL-sync
|
||||||
|
// effect below sees a stale ?pane= and immediately resets the index. Push
|
||||||
|
// the new pane's id to the URL atomically so the effect's next pass sees a
|
||||||
|
// matching id and is a no-op. Desktop has no URL pane state — fall through.
|
||||||
|
const addPaneAndSwitch = useCallback(
|
||||||
|
(kind: 'chat' | 'terminal' | 'agent') => {
|
||||||
|
const newPaneId = addSplitPane(kind);
|
||||||
|
if (newPaneId === null) return;
|
||||||
|
if (isMobile) {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
params.set('pane', newPaneId);
|
||||||
|
navigate(`${location.pathname}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addSplitPane, isMobile, navigate, location.pathname, location.search],
|
||||||
|
);
|
||||||
|
|
||||||
|
// v1.10.3 keyboard shortcuts. Window-level keydown so they fire from
|
||||||
|
// anywhere in the session view. Only Cmd/Ctrl-Shift-C defers to the xterm
|
||||||
|
// (which has its own copy binding for that combo); everything else fires
|
||||||
|
// regardless of focus. Cmd-W and Cmd-T are typically reserved by the
|
||||||
|
// browser — preventDefault() works in most browsers but not all.
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (!mod) return;
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
const target = e.target;
|
||||||
|
const inXterm = target instanceof Element && target.closest('.xterm') !== null;
|
||||||
|
|
||||||
|
// Cmd/Ctrl + ` — focus the active terminal or jump to the most recent
|
||||||
|
// terminal pane and focus it. No-op if there are no terminal panes.
|
||||||
|
if (key === '`') {
|
||||||
|
e.preventDefault();
|
||||||
|
const activePane = panes[activePaneIdx];
|
||||||
|
if (activePane?.kind === 'terminal') {
|
||||||
|
terminalsRegistry.get(activePane.id)?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let lastTermIdx = -1;
|
||||||
|
for (let i = panes.length - 1; i >= 0; i--) {
|
||||||
|
if (panes[i]?.kind === 'terminal') {
|
||||||
|
lastTermIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastTermIdx < 0) return;
|
||||||
|
const target = panes[lastTermIdx];
|
||||||
|
switchActivePane(lastTermIdx);
|
||||||
|
if (target) {
|
||||||
|
// The terminal may have just mounted on mobile (it was return-null
|
||||||
|
// before the switch). Defer focus until the new render commits.
|
||||||
|
setTimeout(() => terminalsRegistry.get(target.id)?.focus(), 80);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + Shift + T — new terminal pane and switch to it.
|
||||||
|
if (key === 't' && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
addPaneAndSwitch('terminal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + Shift + C — new chat pane and switch to it. The xterm's
|
||||||
|
// own Shift-C binding is "copy selection" — defer to it when in xterm.
|
||||||
|
if (key === 'c' && e.shiftKey) {
|
||||||
|
if (inXterm) return;
|
||||||
|
e.preventDefault();
|
||||||
|
addPaneAndSwitch('chat');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + W — close the active pane.
|
||||||
|
if (key === 'w' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
removePane(activePaneIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.10.4: Cmd/Ctrl + F — when the active pane is a terminal, open the
|
||||||
|
// scrollback search bar. When it isn't, fall through to the browser's
|
||||||
|
// native find (no preventDefault, no early return).
|
||||||
|
if (key === 'f' && !e.shiftKey) {
|
||||||
|
const activePane = panes[activePaneIdx];
|
||||||
|
if (activePane?.kind === 'terminal') {
|
||||||
|
e.preventDefault();
|
||||||
|
terminalsRegistry.get(activePane.id)?.openSearch();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + Tab / Shift+Tab — cycle through panes.
|
||||||
|
if (key === 'tab') {
|
||||||
|
if (panes.length <= 1) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const dir = e.shiftKey ? -1 : 1;
|
||||||
|
const next = (activePaneIdx + dir + panes.length) % panes.length;
|
||||||
|
switchActivePane(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + 1..9 — direct jump to pane N.
|
||||||
|
if (/^[1-9]$/.test(key)) {
|
||||||
|
const idx = parseInt(key, 10) - 1;
|
||||||
|
if (idx < panes.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
switchActivePane(idx);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [panes, activePaneIdx, switchActivePane, addPaneAndSwitch, removePane]);
|
||||||
|
|
||||||
async function saveName() {
|
async function saveName() {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
@@ -264,7 +381,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
onRenameChat={renameChat}
|
onRenameChat={renameChat}
|
||||||
/>
|
/>
|
||||||
<NewPaneMenu
|
<NewPaneMenu
|
||||||
onAddPane={addSplitPane}
|
onAddPane={addPaneAndSwitch}
|
||||||
disabled={panes.length >= MAX_PANES}
|
disabled={panes.length >= MAX_PANES}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "@fontsource-variable/inter";
|
/* @fontsource-variable JBM + Inter imported from main.tsx as JS modules. */
|
||||||
@import "@fontsource-variable/jetbrains-mono";
|
|
||||||
|
|
||||||
/* themes-v1: 18 preset palettes. Order matches docs/themes_v1.md §1 with
|
/* themes-v1: 18 preset palettes. Order matches docs/themes_v1.md §1 with
|
||||||
obsidian first (default). Each file declares .theme-<id> for the light
|
obsidian first (default). Each file declares .theme-<id> for the light
|
||||||
@@ -152,3 +151,96 @@
|
|||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* iOS Safari auto-enlarges text in narrow viewports (anti-zoom). On its own
|
||||||
|
* that's fine for HTML chrome, but xterm.js measures its cell width from a
|
||||||
|
* hidden text-measure element — so when iOS up-sizes that element, xterm
|
||||||
|
* computes wider cells and the terminal ends up at fewer cols than it should.
|
||||||
|
* In opencode this surfaces as the small fragmented banner instead of the
|
||||||
|
* big chunky one (opencode picks the banner glyph set based on terminal
|
||||||
|
* width). 100% disables the auto-adjust and keeps boocode at the same
|
||||||
|
* effective cols as boolab on the same iPhone.
|
||||||
|
*/
|
||||||
|
html, body {
|
||||||
|
-webkit-text-size-adjust: 100% !important;
|
||||||
|
-ms-text-size-adjust: 100% !important;
|
||||||
|
text-size-adjust: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS Safari auto-zooms when a user taps an input/textarea whose font-size
|
||||||
|
* is under 16px. Pin every input/textarea/select to 16px (boolab pattern)
|
||||||
|
* to suppress the zoom — applies globally; specific components can override
|
||||||
|
* with `text-base` or inline if a smaller visual is intentional. */
|
||||||
|
input, textarea, select {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* xterm.js overrides (boolab pattern — see /opt/boolab/frontend/src/styles/globals.css).
|
||||||
|
*
|
||||||
|
* Why these live in a global stylesheet, not in an inline <style> inside the
|
||||||
|
* component: an inline <style> inserted at component-mount time races the
|
||||||
|
* upstream @xterm/xterm/css/xterm.css that ships with the addon. We saw the
|
||||||
|
* right-edge stripe persist on iOS even though the override was identical to
|
||||||
|
* boolab's — moving the rules here so they're parsed alongside index.css
|
||||||
|
* eliminates that race.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm,
|
||||||
|
.xterm *,
|
||||||
|
.xterm .xterm-rows,
|
||||||
|
.xterm .xterm-rows * {
|
||||||
|
font-family: 'JetBrains Mono Variable', 'JetBrains Mono', 'Fira Code', Menlo, monospace !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fill the host node — xterm's only non-absolute sizing comes from the canvas,
|
||||||
|
* and fractional rounding would otherwise leave a phantom right-edge stripe.
|
||||||
|
*/
|
||||||
|
.xterm {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lock cell metrics so block-element glyphs (U+2580..U+259F) tile without
|
||||||
|
* subpixel gaps. Any non-zero letter-spacing or line-height ≠ 1 leaves
|
||||||
|
* fractional space between cells that paints as a horizontal/vertical
|
||||||
|
* stripe through the opencode banner on iOS. Disabling ligatures
|
||||||
|
* (font-feature-settings + font-variant-ligatures) prevents the renderer
|
||||||
|
* from collapsing adjacent block chars into shaped glyphs at unpredictable
|
||||||
|
* widths.
|
||||||
|
*/
|
||||||
|
.xterm,
|
||||||
|
.xterm .xterm-rows {
|
||||||
|
letter-spacing: 0 !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
font-feature-settings: "liga" 0, "calt" 0 !important;
|
||||||
|
font-variant-ligatures: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
/*
|
||||||
|
* xterm.css ships `background-color: #000` on the viewport (kept for OS X
|
||||||
|
* scrollbar opacity in the upstream default). FitAddon rounds cols down
|
||||||
|
* to integer cells, so .xterm-screen is up to `cellWidth - 1` pixels
|
||||||
|
* narrower than .xterm-viewport — the strip between the canvas right
|
||||||
|
* edge and the viewport right edge then paints viewport's #000, which
|
||||||
|
* differs from the theme background (#0b0f14, set on the host wrapper in
|
||||||
|
* TerminalPane.tsx + via Terminal options.theme.background) and shows up
|
||||||
|
* as a visible right-edge gap.
|
||||||
|
*
|
||||||
|
* Setting viewport's background transparent lets the host wrapper's
|
||||||
|
* #0b0f14 show through, hiding the sub-cell remainder. Single source of
|
||||||
|
* truth for the bg color: the host.
|
||||||
|
*/
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,24 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// Booterm runs on a separate port (9501 in compose). Order matters:
|
||||||
|
// /api/term/* and /ws/term/* must be listed before the broader /api
|
||||||
|
// entry so Vite matches the more specific prefix first.
|
||||||
|
'/api/term': {
|
||||||
|
target: process.env.BOOTERM_DEV_URL ?? 'http://127.0.0.1:9501',
|
||||||
|
changeOrigin: true,
|
||||||
|
headers: {
|
||||||
|
'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/ws/term': {
|
||||||
|
target: process.env.BOOTERM_DEV_URL ?? 'http://127.0.0.1:9501',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
headers: {
|
||||||
|
'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam',
|
||||||
|
},
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:3000',
|
target: 'http://127.0.0.1:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
269
boocode_batch10.md
Normal file
269
boocode_batch10.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# 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):
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
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.
|
||||||
@@ -19,6 +19,27 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- boocode_net
|
- boocode_net
|
||||||
|
|
||||||
|
booterm:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/booterm/Dockerfile
|
||||||
|
container_name: booterm
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "100.114.205.53:9501:3000"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
||||||
|
volumes:
|
||||||
|
- /opt:/opt:rw
|
||||||
|
- /home/samkintop:/home/samkintop:rw
|
||||||
|
depends_on:
|
||||||
|
- boocode_db
|
||||||
|
networks:
|
||||||
|
- boocode_net
|
||||||
|
|
||||||
boocode_db:
|
boocode_db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: boocode_db
|
container_name: boocode_db
|
||||||
|
|||||||
208
pnpm-lock.yaml
generated
208
pnpm-lock.yaml
generated
@@ -12,6 +12,40 @@ importers:
|
|||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
apps/booterm:
|
||||||
|
dependencies:
|
||||||
|
'@fastify/websocket':
|
||||||
|
specifier: ^10.0.1
|
||||||
|
version: 10.0.1
|
||||||
|
fastify:
|
||||||
|
specifier: ^4.28.1
|
||||||
|
version: 4.29.1
|
||||||
|
node-pty:
|
||||||
|
specifier: ^1.0.0
|
||||||
|
version: 1.1.0
|
||||||
|
pg:
|
||||||
|
specifier: ^8.13.0
|
||||||
|
version: 8.20.0
|
||||||
|
tslib:
|
||||||
|
specifier: ^2.6.3
|
||||||
|
version: 2.8.1
|
||||||
|
zod:
|
||||||
|
specifier: ^3.23.8
|
||||||
|
version: 3.25.76
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^20.14.10
|
||||||
|
version: 20.19.41
|
||||||
|
'@types/pg':
|
||||||
|
specifier: ^8.11.10
|
||||||
|
version: 8.20.0
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.16.2
|
||||||
|
version: 4.22.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.5.0
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
apps/server:
|
apps/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
@@ -57,6 +91,21 @@ importers:
|
|||||||
'@fontsource-variable/jetbrains-mono':
|
'@fontsource-variable/jetbrains-mono':
|
||||||
specifier: ^5.2.8
|
specifier: ^5.2.8
|
||||||
version: 5.2.8
|
version: 5.2.8
|
||||||
|
'@xterm/addon-fit':
|
||||||
|
specifier: 0.10.0
|
||||||
|
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||||
|
'@xterm/addon-search':
|
||||||
|
specifier: ^0.15.0
|
||||||
|
version: 0.15.0(@xterm/xterm@5.5.0)
|
||||||
|
'@xterm/addon-web-links':
|
||||||
|
specifier: 0.11.0
|
||||||
|
version: 0.11.0(@xterm/xterm@5.5.0)
|
||||||
|
'@xterm/addon-webgl':
|
||||||
|
specifier: ^0.19.0
|
||||||
|
version: 0.19.0
|
||||||
|
'@xterm/xterm':
|
||||||
|
specifier: 5.5.0
|
||||||
|
version: 5.5.0
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -1727,6 +1776,9 @@ packages:
|
|||||||
'@types/node@20.19.41':
|
'@types/node@20.19.41':
|
||||||
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
||||||
|
|
||||||
|
'@types/pg@8.20.0':
|
||||||
|
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
||||||
|
|
||||||
'@types/prop-types@15.7.15':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
@@ -1794,6 +1846,27 @@ packages:
|
|||||||
'@vitest/utils@3.2.4':
|
'@vitest/utils@3.2.4':
|
||||||
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
||||||
|
|
||||||
|
'@xterm/addon-fit@0.10.0':
|
||||||
|
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^5.0.0
|
||||||
|
|
||||||
|
'@xterm/addon-search@0.15.0':
|
||||||
|
resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^5.0.0
|
||||||
|
|
||||||
|
'@xterm/addon-web-links@0.11.0':
|
||||||
|
resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^5.0.0
|
||||||
|
|
||||||
|
'@xterm/addon-webgl@0.19.0':
|
||||||
|
resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==}
|
||||||
|
|
||||||
|
'@xterm/xterm@5.5.0':
|
||||||
|
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
||||||
|
|
||||||
abstract-logging@2.0.1:
|
abstract-logging@2.0.1:
|
||||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||||
|
|
||||||
@@ -2964,6 +3037,9 @@ packages:
|
|||||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
|
node-addon-api@7.1.1:
|
||||||
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
|
||||||
node-domexception@1.0.0:
|
node-domexception@1.0.0:
|
||||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||||
engines: {node: '>=10.5.0'}
|
engines: {node: '>=10.5.0'}
|
||||||
@@ -2973,6 +3049,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
|
node-pty@1.1.0:
|
||||||
|
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
|
||||||
|
|
||||||
node-releases@2.0.44:
|
node-releases@2.0.44:
|
||||||
resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
|
resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
|
||||||
|
|
||||||
@@ -3079,6 +3158,40 @@ packages:
|
|||||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||||
engines: {node: '>= 14.16'}
|
engines: {node: '>= 14.16'}
|
||||||
|
|
||||||
|
pg-cloudflare@1.3.0:
|
||||||
|
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
||||||
|
|
||||||
|
pg-connection-string@2.12.0:
|
||||||
|
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
|
||||||
|
|
||||||
|
pg-int8@1.0.1:
|
||||||
|
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
pg-pool@3.13.0:
|
||||||
|
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
|
||||||
|
peerDependencies:
|
||||||
|
pg: '>=8.0'
|
||||||
|
|
||||||
|
pg-protocol@1.13.0:
|
||||||
|
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
pg@8.20.0:
|
||||||
|
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
|
||||||
|
engines: {node: '>= 16.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
pg-native: '>=3.0.1'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
pg-native:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -3112,6 +3225,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
postgres-array@2.0.0:
|
||||||
|
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.1:
|
||||||
|
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-date@1.0.7:
|
||||||
|
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
postgres@3.4.9:
|
postgres@3.4.9:
|
||||||
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
|
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3797,6 +3926,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
xtend@4.0.2:
|
||||||
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5380,6 +5513,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/pg@8.20.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.41
|
||||||
|
pg-protocol: 1.13.0
|
||||||
|
pg-types: 2.2.0
|
||||||
|
|
||||||
'@types/prop-types@15.7.15': {}
|
'@types/prop-types@15.7.15': {}
|
||||||
|
|
||||||
'@types/react-dom@18.3.7(@types/react@18.3.28)':
|
'@types/react-dom@18.3.7(@types/react@18.3.28)':
|
||||||
@@ -5464,6 +5603,22 @@ snapshots:
|
|||||||
loupe: 3.2.1
|
loupe: 3.2.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
||||||
|
dependencies:
|
||||||
|
'@xterm/xterm': 5.5.0
|
||||||
|
|
||||||
|
'@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)':
|
||||||
|
dependencies:
|
||||||
|
'@xterm/xterm': 5.5.0
|
||||||
|
|
||||||
|
'@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)':
|
||||||
|
dependencies:
|
||||||
|
'@xterm/xterm': 5.5.0
|
||||||
|
|
||||||
|
'@xterm/addon-webgl@0.19.0': {}
|
||||||
|
|
||||||
|
'@xterm/xterm@5.5.0': {}
|
||||||
|
|
||||||
abstract-logging@2.0.1: {}
|
abstract-logging@2.0.1: {}
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
@@ -6817,6 +6972,8 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
|
node-addon-api@7.1.1: {}
|
||||||
|
|
||||||
node-domexception@1.0.0: {}
|
node-domexception@1.0.0: {}
|
||||||
|
|
||||||
node-fetch@3.3.2:
|
node-fetch@3.3.2:
|
||||||
@@ -6825,6 +6982,10 @@ snapshots:
|
|||||||
fetch-blob: 3.2.0
|
fetch-blob: 3.2.0
|
||||||
formdata-polyfill: 4.0.10
|
formdata-polyfill: 4.0.10
|
||||||
|
|
||||||
|
node-pty@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
node-addon-api: 7.1.1
|
||||||
|
|
||||||
node-releases@2.0.44: {}
|
node-releases@2.0.44: {}
|
||||||
|
|
||||||
npm-run-path@4.0.1:
|
npm-run-path@4.0.1:
|
||||||
@@ -6935,6 +7096,41 @@ snapshots:
|
|||||||
|
|
||||||
pathval@2.0.1: {}
|
pathval@2.0.1: {}
|
||||||
|
|
||||||
|
pg-cloudflare@1.3.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pg-connection-string@2.12.0: {}
|
||||||
|
|
||||||
|
pg-int8@1.0.1: {}
|
||||||
|
|
||||||
|
pg-pool@3.13.0(pg@8.20.0):
|
||||||
|
dependencies:
|
||||||
|
pg: 8.20.0
|
||||||
|
|
||||||
|
pg-protocol@1.13.0: {}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
pg-int8: 1.0.1
|
||||||
|
postgres-array: 2.0.0
|
||||||
|
postgres-bytea: 1.0.1
|
||||||
|
postgres-date: 1.0.7
|
||||||
|
postgres-interval: 1.2.0
|
||||||
|
|
||||||
|
pg@8.20.0:
|
||||||
|
dependencies:
|
||||||
|
pg-connection-string: 2.12.0
|
||||||
|
pg-pool: 3.13.0(pg@8.20.0)
|
||||||
|
pg-protocol: 1.13.0
|
||||||
|
pg-types: 2.2.0
|
||||||
|
pgpass: 1.0.5
|
||||||
|
optionalDependencies:
|
||||||
|
pg-cloudflare: 1.3.0
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.2: {}
|
picomatch@2.3.2: {}
|
||||||
@@ -6974,6 +7170,16 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
postgres-array@2.0.0: {}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.1: {}
|
||||||
|
|
||||||
|
postgres-date@1.0.7: {}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
postgres@3.4.9: {}
|
postgres@3.4.9: {}
|
||||||
|
|
||||||
powershell-utils@0.1.0: {}
|
powershell-utils@0.1.0: {}
|
||||||
@@ -7782,6 +7988,8 @@ snapshots:
|
|||||||
is-wsl: 3.1.1
|
is-wsl: 3.1.1
|
||||||
powershell-utils: 0.1.0
|
powershell-utils: 0.1.0
|
||||||
|
|
||||||
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user