Compare commits

..

14 Commits

Author SHA1 Message Date
ead7cb9d01 merge v1.10.1-booterm-user 2026-05-19 13:07:59 +00:00
d04b30687f v1.10.1: booterm runs shells as samkintop with login bash 2026-05-19 13:07:59 +00:00
9250632ac3 merge v1.10-booterm 2026-05-18 14:06:46 +00:00
7486e7d3e0 v1.10: booterm container — xterm.js + tmux + node-pty 2026-05-18 14:06:46 +00:00
d85b17081e v1.9.7: ask_user_input elicitation tool 2026-05-18 02:15:18 +00:00
adb5d7b3bb Merge v1.9-skills: skills + /skill slash command 2026-05-18 01:52:15 +00:00
80fd3d9fa9 feat(web): /skill slash command with autocomplete
Trigger /<name>, dropdown lists all skills filtered by name prefix,
arg passthrough sends the rest as the user message. Synthetic
skill_use tool_use renders identically to model-invoked skills.
2026-05-18 01:10:51 +00:00
eaacd432e8 feat(web): skills API types + client methods 2026-05-18 01:10:51 +00:00
529a77c959 feat(server): skills v1 — parser, tools, /api/skills, mount
- /data/skills mount (host: /opt/skills)
- skill_find, skill_use, skill_resource added to default read-only
  tool set; opt-in for agents with explicit tools: whitelist
- AGENTS.md builtin agents drop explicit tools: arrays to inherit
  the new default (now includes skill tools)
- POST /api/chats/:id/skill_invoke for slash-command flow
- 19 SKILL.md files seeded at /opt/skills/ across 6 source groups
2026-05-18 01:10:51 +00:00
9a7b35b677 build: harden .dockerignore (secrets/, data/)
The host-side docker-compose mounts secrets/ and data/ read-only at
runtime, but the build context still slurped them in. Add secrets/,
data/, and general SSH key patterns (*.pem, *.key, id_rsa*,
id_ed25519*, known_hosts, .ssh/) so private material can never be
baked into the image even by accident.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:37 +00:00
98b432ebce refactor: drop type-to-confirm gate on chat delete
The chat-delete dialog required typing the chat name to confirm
deletion. Single-user app — typing friction is annoying, not safety.
Match the archive dialog pattern in SettingsPane.tsx: title +
description naming the chat in mono font, plain Cancel + destructive
Delete button.

Removes the deleteInput state, deleteExpected / deleteEnabled
deriveds, the <Input> field, and its lone <Input> import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:30 +00:00
1ecccc112f fix: settings pane close affordance + sidebar toggle
The v1.9 settings pane had no way to dismiss once opened. ChatTabBar
(which owns the per-pane close X for chat panes) is skipped for
settings panes, and the pane header itself only rendered the maximize
toggle (desktop-only). Mobile users had zero controls beyond the
section tabs.

Add three close paths:

- X button in SettingsPane header, visible on mobile + desktop, sits
  next to the maximize toggle. Tap-target sized per the v1.6 mobile
  convention (max-md:min-h-[44px]).
- Esc when the settings pane is the active pane and no input/textarea/
  dialog has focus. Maximize-restore still wins when maximized.
- Sidebar Settings button is now a strict toggle: opens on first click,
  closes on second. Renamed openOrFocusSettingsPane →
  toggleSettingsPane in the panes hook.

Edge case: removing the settings pane when it's the only pane left
falls back to an empty pane to preserve the "always one pane"
invariant. In normal flow this is unreachable (the toggle only
appends), but defensive against future entry points.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:25 +00:00
b6469055d8 docs: reconcile roadmap with merged state
v1.8.3 (tool-call compaction), themes-v1, v1.9 (settings pane +
per-project defaults + bulk archive), and v1.11 (agents Tier 2) were
all marked Planned/in-flight in the roadmap despite being merged on
main. Reconcile the Batch summary table and reorder the Order of
operations to start at v1.10. Drop the stale "Active work" section —
themes-v1 description belongs in the past tense now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:16 +00:00
4bf2cd40c3 Merge v1.9 2026-05-17 17:37:38 +00:00
47 changed files with 3435 additions and 407 deletions

View File

@@ -10,3 +10,13 @@ dist
.vite
coverage
/tmp
# Secrets and runtime data
secrets/
data/
*.pem
*.key
id_rsa*
id_ed25519*
known_hosts
.ssh/

View File

@@ -3,7 +3,6 @@
## Code Reviewer
---
temperature: 0.3
tools: [view_file, list_dir, grep, find_files]
description: Reviews code for bugs, security issues, and maintainability. Read-only.
---
You review code. Find real problems, not style nits.
@@ -33,7 +32,6 @@ If nothing critical or major, say so in one line. Do not pad.
## Debugger
---
temperature: 0.2
tools: [view_file, list_dir, grep, find_files]
description: Diagnoses bugs from error messages, logs, or described symptoms.
---
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
@@ -62,7 +60,6 @@ Output:
## Refactorer
---
temperature: 0.3
tools: [view_file, list_dir, grep, find_files]
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
---
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
@@ -95,7 +92,6 @@ Output:
## Architect
---
temperature: 0.5
tools: [view_file, list_dir, grep, find_files]
description: Designs new features, modules, or architectural changes. Outputs a build plan.
---
You design. You produce build plans, not code.
@@ -128,7 +124,6 @@ Output:
## Security Auditor
---
temperature: 0.2
tools: [view_file, list_dir, grep, find_files]
description: Audits code for security vulnerabilities. Read-only.
---
You audit for security issues. Concrete findings only, no generic warnings.
@@ -165,7 +160,6 @@ If the code is clean, say so. Do not invent findings.
## Prompt Builder
---
temperature: 0.4
tools: [view_file, list_dir, grep, find_files]
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
---
You write prompts that another coding agent will execute. Your output is the prompt, not the work.

47
apps/booterm/Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# 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 ----
FROM node:20-alpine AS proddeps
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 /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-alpine 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 ----
FROM node:20-alpine AS runtime
RUN apk add --no-cache tmux libstdc++ bash su-exec shadow
# v1.10.1: terminal shells inside tmux drop privs to samkintop via su-exec.
# 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.
RUN deluser --remove-home node 2>/dev/null; delgroup node 2>/dev/null; \
addgroup -g 1000 samkintop && \
adduser -D -u 1000 -G samkintop -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
View 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
View 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';
}

View 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
View 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
View 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);
});

View File

@@ -0,0 +1,102 @@
import { spawn } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify';
// UUIDs already match [0-9a-f-]; allow uppercase and longer just in case.
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
export function sanitizeId(raw: string): string | null {
if (!ID_RE.test(raw)) return null;
return raw.toLowerCase();
}
export function tmuxSessionName(sessionId: string): string {
return `bc-${sessionId}`;
}
export function tmuxWindowName(paneId: string): string {
return `term-${paneId}`;
}
interface CmdResult {
stdout: string;
stderr: string;
code: number;
}
// Wrap child_process.spawn with shell:false so each argv element is passed
// as a separate argument — no shell interpolation, no injection surface.
function runTmux(tmuxConfPath: string, args: string[]): Promise<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;
}
export async function listWindows(tmuxConfPath: string, sessionName: string): Promise<string[]> {
const res = await runTmux(tmuxConfPath, ['list-windows', '-t', sessionName, '-F', '#{window_name}']);
if (res.code !== 0) return [];
return res.stdout.trim().split('\n').filter(Boolean);
}
export async function killWindow(
tmuxConfPath: string,
sessionName: string,
windowName: string,
): Promise<boolean> {
const res = await runTmux(tmuxConfPath, ['kill-window', '-t', `${sessionName}:${windowName}`]);
return res.code === 0;
}
// Idempotent. Creates the tmux session if it doesn't exist, then ensures the
// named window is present. The session's initial window is created with the
// target name (via `-n`) so we don't need a separate rename step.
export async function ensureWindow(
tmuxConfPath: string,
sessionName: string,
windowName: string,
projectRoot: string,
log: FastifyBaseLogger,
): Promise<void> {
if (!(await hasSession(tmuxConfPath, sessionName))) {
log.info({ sessionName, windowName, projectRoot }, 'creating tmux session');
const res = await runTmux(tmuxConfPath, [
'new-session', '-d',
'-s', sessionName,
'-n', windowName,
'-c', projectRoot,
]);
if (res.code !== 0) {
log.error({ res }, 'tmux new-session failed');
throw new Error(`tmux new-session failed: ${res.stderr}`);
}
return;
}
const windows = await listWindows(tmuxConfPath, sessionName);
if (windows.includes(windowName)) return;
const res = await runTmux(tmuxConfPath, [
'new-window',
'-t', sessionName,
'-n', windowName,
'-c', projectRoot,
]);
if (res.code !== 0) {
log.error({ res }, 'tmux new-window failed');
throw new Error(`tmux new-window failed: ${res.stderr}`);
}
}

View File

@@ -0,0 +1,41 @@
import * as pty from 'node-pty';
import type { IPty } from 'node-pty';
export interface AttachPtyOptions {
sessionName: string;
windowName: 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;
}
// Spawns a tmux client attached to the given session+window. `-d` detaches any
// other client so a browser refresh takes over the same window without
// duplicate input. tmux server (and the window) persists across PTY exits.
export function attachPty(opts: AttachPtyOptions): IPty {
return pty.spawn(
'tmux',
[
'-f', opts.tmuxConfPath,
'attach-session', '-d',
'-t', `${opts.sessionName}:${opts.windowName}`,
],
{
name: 'xterm-256color',
cols: opts.cols,
rows: opts.rows,
cwd: opts.projectRoot,
env: cleanEnv(),
},
);
}

View 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 };
});
}

View File

@@ -0,0 +1,88 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getSessionInfo } from '../db.js';
import {
sanitizeId,
tmuxSessionName,
tmuxWindowName,
ensureWindow,
killWindow,
hasSession,
listWindows,
} from '../pty/manager.js';
import { resizePane } from '../ws/attach.js';
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
const ResizeBodySchema = z.object({
cols: z.coerce.number().int().min(1).max(2000),
rows: z.coerce.number().int().min(1).max(2000),
});
export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void {
app.post<{ Params: { sid: string; pid: string } }>(
'/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 session = await getSessionInfo(sid);
if (!session) return reply.code(404).send({ error: 'unknown_session' });
const sessionName = tmuxSessionName(sid);
const windowName = tmuxWindowName(pid);
try {
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
} catch (err) {
req.log.error({ err }, 'ensureWindow failed');
return reply.code(500).send({ error: 'tmux_failed' });
}
return reply.code(200).send({ tmux_window: windowName });
},
);
app.post<{ Params: { sid: string; pid: string }; Body: { cols: number; rows: number } }>(
'/api/term/sessions/:sid/panes/:pid/resize',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
const b = ResizeBodySchema.safeParse(req.body);
if (!b.success) return reply.code(400).send({ error: 'bad_body' });
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 ok = resizePane(pid, b.data.cols, b.data.rows);
if (!ok) return reply.code(404).send({ error: 'no_active_pty' });
return reply.code(200).send({ ok: true });
},
);
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(sid);
const windowName = tmuxWindowName(pid);
if (!(await hasSession(tmuxConfPath, sessionName))) {
return reply.code(404).send({ error: 'unknown_session' });
}
const windows = await listWindows(tmuxConfPath, sessionName);
if (!windows.includes(windowName)) {
return reply.code(404).send({ error: 'unknown_pane' });
}
const killed = await killWindow(tmuxConfPath, sessionName, windowName);
if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' });
return reply.code(200).send({ ok: true });
},
);
}

View File

@@ -0,0 +1,128 @@
import type { FastifyInstance } from 'fastify';
import type { IPty } from 'node-pty';
import { getSessionInfo } from '../db.js';
import { sanitizeId, tmuxSessionName, tmuxWindowName, ensureWindow } from '../pty/manager.js';
import { attachPty } from '../pty/pty.js';
import { getUser } from '../auth.js';
// Registry of currently-attached PTYs keyed by paneId. Used by the resize REST
// route to find the active node-pty handle so it can call pty.resize(cols, rows).
const active = new Map<string, IPty>();
export function resizePane(paneId: string, cols: number, rows: number): boolean {
const handle = active.get(paneId);
if (!handle) return false;
try {
handle.resize(cols, rows);
return true;
} catch {
return false;
}
}
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(sid);
const windowName = tmuxWindowName(pid);
try {
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
} catch (err) {
req.log.error({ err }, 'ensureWindow failed in WS handler');
socket.close(1011, 'tmux_failed');
return;
}
const cols = parseInt(req.query.cols ?? '', 10) || 80;
const rows = parseInt(req.query.rows ?? '', 10) || 24;
let handle: IPty;
try {
handle = attachPty({
sessionName,
windowName,
projectRoot: session.project_path,
cols,
rows,
tmuxConfPath,
});
} catch (err) {
req.log.error({ err }, 'attachPty failed');
socket.close(1011, 'pty_spawn_failed');
return;
}
active.set(pid, handle);
const onData = (data: string) => {
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', (data: Buffer | string) => {
try {
if (typeof data === 'string') {
handle.write(data);
} else {
handle.write(data.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 */
}
if (active.get(pid) === handle) active.delete(pid);
});
// WS close kills the local PTY (the tmux client). The tmux server and
// window persist so a refresh resumes with full scrollback.
socket.on('close', () => {
if (active.get(pid) === handle) active.delete(pid);
try {
handle.kill();
} catch {
/* ignore */
}
});
},
);
}

13
apps/booterm/tmux.conf Normal file
View File

@@ -0,0 +1,13 @@
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
# 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
# su-exec only changes uid/gid — it leaves env intact, and 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).
set -g default-command "su-exec samkintop:samkintop env HOME=/home/samkintop USER=samkintop SHELL=/bin/bash bash -l"

View 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"]
}

View File

@@ -15,8 +15,10 @@ import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js';
import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js';
import { createInferenceRunner } from './services/inference.js';
import { createBroker } from './services/broker.js';
import { listSkills } from './services/skills.js';
async function main() {
const config = loadConfig();
@@ -62,6 +64,15 @@ async function main() {
registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker);
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
// missing /data/skills is non-fatal — the skill tools just return empty.
try {
const skills = await listSkills();
app.log.info(`skills loaded: ${skills.length}`);
} catch (err) {
app.log.warn({ err }, 'skills boot walk failed');
}
const inference = createInferenceRunner(
{
sql,
@@ -112,6 +123,36 @@ async function main() {
chat_id: chatId,
});
},
publishSessionFrame: (sessionId, frame) => {
broker.publish(sessionId, frame);
},
});
registerSkillsRoutes(app, sql, {
enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);
},
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, {
type: 'message_started',
message_id: userMessageId,
chat_id: chatId,
role: 'user',
});
broker.publish(sessionId, {
type: 'delta',
message_id: userMessageId,
chat_id: chatId,
content,
});
broker.publish(sessionId, {
type: 'message_complete',
message_id: userMessageId,
chat_id: chatId,
});
},
publishSessionFrame: (sessionId, frame) => {
broker.publish(sessionId, frame);
},
});
registerWebSocket(app, sql, broker);

View File

@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Chat, Message, Session } from '../types/api.js';
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
@@ -14,6 +14,39 @@ const ContinueBody = z.object({
sentinel_message_id: z.string().uuid(),
});
// Batch 9.7: ask_user_input answer submission. Defensive shape — the question
// content is echoed back for traceability but the server does NOT trust it
// (the source of truth is the assistant message's tool_calls.args.questions).
const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1),
answers: z
.array(
z.object({
question: z.string(),
selected_options: z.array(z.string()),
free_text: z.string().nullable(),
}),
)
.min(1)
.max(3),
});
// Same shape the model declared via the tool's zod input. Re-derived here so
// the route can validate args without depending on services/tools.ts (which
// would pull in fs/path_guard for nothing).
const AskUserInputArgs = z.object({
questions: z
.array(
z.object({
question: z.string(),
type: z.enum(['single_select', 'multi_select']),
options: z.array(z.string()).min(1),
}),
)
.min(1)
.max(3),
});
interface MessageHandlers {
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
@@ -24,6 +57,13 @@ interface MessageHandlers {
content: string
) => void;
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
// Batch 9.7: lets the answer endpoint emit the tool_result frame that the
// pause path intentionally skipped. Matches SkillInvokeHandlers in
// routes/skills.ts so index.ts can pass the same broker.publish adapter.
publishSessionFrame: (
sessionId: string,
frame: Record<string, unknown> & { type: string }
) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
hasActiveInference: (chatId: string) => boolean;
}
@@ -389,4 +429,169 @@ export function registerMessageRoutes(
return result;
}
);
// Batch 9.7: resume an ask_user_input pause. Validates the body matches the
// question shape the model declared, UPDATEs the pending tool row's
// tool_results to the AnswerSet, publishes the deferred tool_result frame,
// and enqueues the next assistant turn. Error codes per spec:
// 400 invalid_body / mismatched_answer_shape
// 404 chat_not_found / unknown_tool_call_id
// 409 tool_call_already_answered
app.post<{ Params: { id: string } }>(
'/api/chats/:id/answer_user_input',
async (req, reply) => {
const parsed = AnswerUserInputBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid_body', details: parsed.error.flatten() };
}
const { tool_call_id, answers } = parsed.data;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat_not_found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
// Find the assistant message that emitted this tool_call. Scoped by
// chat_id + role to avoid cross-chat lookups; ordered by created_at DESC
// because the most recent issuance wins when an LLM reuses call IDs
// across turns (the older, already-answered one is a different row with
// populated tool_results downstream).
const callerRows = await sql<{ id: string; tool_calls: ToolCall[] | null }[]>`
SELECT id, tool_calls FROM messages
WHERE chat_id = ${chat.id}
AND role = 'assistant'
AND tool_calls IS NOT NULL
ORDER BY created_at DESC
`;
let foundCall: ToolCall | null = null;
for (const row of callerRows) {
const match = row.tool_calls?.find((tc) => tc.id === tool_call_id);
if (match) {
foundCall = match;
break;
}
}
if (!foundCall) {
reply.code(404);
return { error: 'unknown_tool_call_id' };
}
if (foundCall.name !== 'ask_user_input') {
reply.code(400);
return { error: 'tool_call_not_ask_user_input' };
}
// Validate the args themselves — the LLM could have emitted bad JSON.
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
if (!argsParsed.success) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
}
const questions = argsParsed.data.questions;
if (answers.length !== questions.length) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `expected ${questions.length} answer(s), got ${answers.length}`,
};
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i]!;
const a = answers[i]!;
for (const sel of a.selected_options) {
if (!q.options.includes(sel)) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `answer ${i + 1} contains option not in question: ${sel}`,
};
}
}
if (q.type === 'single_select' && a.selected_options.length > 1) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `answer ${i + 1} has multiple selections on single_select`,
};
}
const hasOpt = a.selected_options.length > 0;
const hasText = a.free_text !== null && a.free_text.trim().length > 0;
if (!hasOpt && !hasText) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
}
}
// Find the pending tool row. ORDER BY created_at DESC + LIMIT 1 picks
// the most recent row with this tool_call_id; the already-answered
// check below guards against UPDATE-ing a stale answer.
const toolRows = await sql<{
id: string;
tool_results: { tool_call_id: string; output: unknown } | null;
}[]>`
SELECT id, tool_results FROM messages
WHERE chat_id = ${chat.id}
AND role = 'tool'
AND tool_results->>'tool_call_id' = ${tool_call_id}
ORDER BY created_at DESC
LIMIT 1
`;
const toolRow = toolRows[0];
if (!toolRow) {
reply.code(404);
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
}
if (toolRow.tool_results && toolRow.tool_results.output !== null) {
reply.code(409);
return { error: 'tool_call_already_answered' };
}
const answerSet = { answers };
const newToolResults = {
tool_call_id,
output: answerSet,
truncated: false,
};
const result = await sql.begin(async (tx) => {
await tx`
UPDATE messages
SET tool_results = ${tx.json(newToolResults as never)}
WHERE id = ${toolRow.id}
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return {
tool_message_id: toolRow.id,
assistant_message_id: assistantMsg!.id,
};
});
// Publish the deferred tool_result frame. useSessionStream's reducer
// updates the matching tool_run.result so AskUserInputCard flips into
// its read-only "answered" mode without a refetch.
handlers.publishSessionFrame(sessionId, {
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id,
chat_id: chat.id,
output: answerSet,
truncated: false,
});
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
}

View File

@@ -0,0 +1,156 @@
import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Chat } from '../types/api.js';
import { getSkillBody, listSkills } from '../services/skills.js';
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
// routes/messages.ts so index.ts can pass thin adapters around broker +
// inference runner without skills.ts importing them directly.
export interface SkillInvokeHandlers {
enqueueInference: (
sessionId: string,
chatId: string,
assistantMessageId: string,
user: string,
) => void;
publishUserMessage: (
sessionId: string,
chatId: string,
userMessageId: string,
content: string,
) => void;
publishSessionFrame: (
sessionId: string,
frame: Record<string, unknown> & { type: string },
) => void;
}
const SkillInvokeBody = z.object({
skill_name: z.string().min(1),
// Optional — server fills in a default if absent or whitespace-only so the
// model always has something to act on (matches the spec's "Apply this
// skill." filler).
user_message: z.string().max(64_000).nullable().optional(),
});
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
export function registerSkillsRoutes(
app: FastifyInstance,
sql: Sql,
handlers: SkillInvokeHandlers,
): void {
// Debug/admin surface — the model interacts with skills via the three
// skill_* tools, not through this endpoint.
app.get('/api/skills', async () => {
return { skills: await listSkills() };
});
// POST /api/chats/:id/skill_invoke — slash-command entry point. Loads the
// skill body server-side (clients never get to forge file content),
// persists 4 messages in one transaction (synthetic assistant tool_use,
// synthetic tool result, real user message, streaming assistant), and
// enqueues inference against the updated history.
app.post<{ Params: { id: string } }>(
'/api/chats/:id/skill_invoke',
async (req, reply) => {
const parsed = SkillInvokeBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { skill_name } = parsed.data;
const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const body = await getSkillBody(skill_name);
if (body === null) {
reply.code(404);
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
}
const toolCallId = randomUUID();
const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }];
const toolResults = { tool_call_id: toolCallId, output: body, truncated: false };
const result = await sql.begin(async (tx) => {
const [synthAssistant] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
RETURNING id
`;
const [toolMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
RETURNING id
`;
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return {
synth_assistant_id: synthAssistant!.id,
tool_message_id: toolMsg!.id,
user_message_id: userMsg!.id,
assistant_message_id: assistantMsg!.id,
};
});
// Synthetic frames so useSessionStream's reducer reflects the new
// history without a refetch. Frame shapes match the streaming-inference
// protocol (see services/inference.ts InferenceFrame).
handlers.publishSessionFrame(sessionId, {
type: 'message_started',
message_id: result.synth_assistant_id,
chat_id: chat.id,
role: 'assistant',
});
handlers.publishSessionFrame(sessionId, {
type: 'tool_call',
message_id: result.synth_assistant_id,
chat_id: chat.id,
tool_call: toolCalls[0]!,
});
handlers.publishSessionFrame(sessionId, {
type: 'message_complete',
message_id: result.synth_assistant_id,
chat_id: chat.id,
});
// The tool_result frame's reducer branch creates the tool-role message
// in-place when it doesn't already exist — no separate message_started
// is needed for the tool side.
handlers.publishSessionFrame(sessionId, {
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id: toolCallId,
chat_id: chat.id,
output: body,
truncated: false,
});
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
}

View File

@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS session_panes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
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 '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (session_id, position)

View File

@@ -11,7 +11,17 @@ const GLOBAL_AGENTS_PATH = '/data/AGENTS.md';
const CACHE_TTL_MS = 60_000;
// Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync.
const ALL_TOOL_NAMES = ['view_file', 'list_dir', 'grep', 'find_files', 'git_status'] as const;
// Batch 9.6: skill_find / skill_use / skill_resource added. Agents without an
// explicit `tools:` field inherit the full default set (which now includes
// the skill tools); agents with an explicit `tools:` array must list any
// skill tool they want to use — strict opt-in.
// Batch 9.7: ask_user_input added — same opt-in semantics. Agents with an
// explicit tools list that omits it cannot trigger the interactive picker.
const ALL_TOOL_NAMES = [
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
'skill_find', 'skill_use', 'skill_resource',
'ask_user_input',
] as const;
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
const DEFAULT_TEMPERATURE = 0.7;

View File

@@ -665,6 +665,12 @@ async function executeToolPhase(
model: session.model,
});
// Batch 9.7: ask_user_input pauses the loop. The tool row is still inserted
// (the answer endpoint needs a target row to UPDATE), but tool_results is
// pre-stamped with output=null as a "pending" sentinel and no tool_result
// frame goes out — the card renders from the tool_call frame alone. Mixed
// batches still execute the other tools normally.
let pausingForUserInput = false;
await Promise.all(
toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>`
@@ -673,6 +679,16 @@ async function executeToolPhase(
RETURNING id
`;
const toolMessageId = toolRow!.id;
if (tc.name === 'ask_user_input') {
pausingForUserInput = true;
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(sentinel as never)}
WHERE id = ${toolMessageId}
`;
return;
}
const tres = await executeToolCall(projectRoot, tc);
const stored = {
tool_call_id: tc.id,
@@ -697,6 +713,23 @@ async function executeToolPhase(
})
);
if (pausingForUserInput) {
// Drop the dot back to idle — the card is the actionable surface now.
// The next inference turn fires from POST /api/chats/:id/answer_user_input
// once the user submits their answers.
ctx.publishUser({
type: 'chat_status',
chat_id: chatId,
status: 'idle',
at: new Date().toISOString(),
});
ctx.log.info(
{ sessionId, chatId, assistantMessageId },
'inference paused awaiting user input',
);
return;
}
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())

View File

@@ -0,0 +1,321 @@
import { promises as fs } from 'node:fs';
import { join, isAbsolute, basename } from 'node:path';
import { pathGuard, PathScopeError } from './path_guard.js';
// Batch 9.6: read-only skill library. Folders under /data/skills/<group>/<skill>/
// contain a SKILL.md with YAML frontmatter (name + description) and a markdown
// body. Three tools expose the library: skill_find (search), skill_use (load
// body), skill_resource (read a support file inside the folder).
//
// Layout is intentionally uniform — scan /data/skills/*/*/SKILL.md at fixed
// depth 3. Group folders (depth 1) hold LICENSE + ATTRIBUTION.md + skill
// subfolders and are NOT themselves skills. Support files inside skill
// folders are reachable via skill_resource, never auto-parsed.
//
// Cache model mirrors agents.ts: walk on first access, TTL re-walk to pick up
// new skills, per-entry mtime check on body access so a hot-edited SKILL.md
// is re-read without a restart. No watcher.
const SKILLS_ROOT = '/data/skills';
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
const LIST_CACHE_TTL_MS = 60_000;
export interface Skill {
name: string;
description: string;
path: string;
mtime: number;
}
interface CachedSkill extends Skill {
body: string;
}
const cache = new Map<string, CachedSkill>();
let lastWalkedAt = 0;
// ---- Frontmatter parser ----------------------------------------------------
// Minimal `---\n...\n---` extractor. Only `name` and `description` keys are
// honored; other frontmatter keys are silently ignored for forward-compat
// with the anthropics/skills upstream spec.
interface Frontmatter {
name?: string;
description?: string;
}
function stripQuotes(s: string): string {
if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1]) {
return s.slice(1, -1);
}
return s;
}
function parseFrontmatter(yaml: string): Frontmatter {
const fm: Frontmatter = {};
for (const raw of yaml.split('\n')) {
const line = raw.trim();
if (line.length === 0) continue;
const colon = line.indexOf(':');
if (colon < 0) continue;
const key = line.slice(0, colon).trim();
const val = stripQuotes(line.slice(colon + 1).trim());
if (key === 'name') fm.name = val;
else if (key === 'description') fm.description = val;
}
return fm;
}
interface ParsedSkillFile {
name: string;
description: string;
body: string;
}
function parseSkillFile(content: string): ParsedSkillFile {
const lines = content.split('\n');
let openIdx = -1;
for (let i = 0; i < lines.length; i++) {
const t = lines[i]!.trim();
if (t === '') continue;
if (t === '---') openIdx = i;
break;
}
if (openIdx < 0) throw new Error('missing opening --- fence');
let closeIdx = -1;
for (let i = openIdx + 1; i < lines.length; i++) {
if (lines[i]!.trim() === '---') { closeIdx = i; break; }
}
if (closeIdx < 0) throw new Error('missing closing --- fence');
const yamlText = lines.slice(openIdx + 1, closeIdx).join('\n');
const body = lines.slice(closeIdx + 1).join('\n');
const fm = parseFrontmatter(yamlText);
if (!fm.name) throw new Error('frontmatter missing name');
if (!fm.description) throw new Error('frontmatter missing description');
return { name: fm.name, description: fm.description, body };
}
// ---- Tree walk -------------------------------------------------------------
// Fixed depth-3 scan: /data/skills/<group>/<skill>/SKILL.md. Two layers of
// readdir, no recursion. Group folders without SKILL.md are skipped silently;
// LICENSE / ATTRIBUTION.md / other non-SKILL.md files are ignored entirely.
// Returns all parseable skills as-found — dedup + collision logging happens
// in ensureCache where the sort order is established.
async function walkSkills(root: string): Promise<CachedSkill[]> {
const found: CachedSkill[] = [];
let groups;
try {
groups = await fs.readdir(root, { withFileTypes: true });
} catch {
return found;
}
for (const group of groups) {
if (!group.isDirectory() || group.name.startsWith('.')) continue;
const groupPath = join(root, group.name);
let entries;
try {
entries = await fs.readdir(groupPath, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const skillFolder = join(groupPath, entry.name);
const skillFile = join(skillFolder, 'SKILL.md');
let stat;
try {
stat = await fs.stat(skillFile);
} catch {
continue; // folder without SKILL.md — silent skip
}
if (!stat.isFile()) continue;
try {
const content = await fs.readFile(skillFile, 'utf8');
const parsed = parseSkillFile(content);
found.push({
name: parsed.name,
description: parsed.description,
path: skillFolder,
mtime: stat.mtimeMs,
body: parsed.body,
});
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.warn(`skills: failed to parse ${skillFile}${reason}`);
}
}
}
return found;
}
// ---- Cache ----------------------------------------------------------------
async function ensureCache(): Promise<void> {
const now = Date.now();
if (cache.size > 0 && now - lastWalkedAt < LIST_CACHE_TTL_MS) return;
let stat;
try {
stat = await fs.stat(SKILLS_ROOT);
} catch {
cache.clear();
lastWalkedAt = now;
return;
}
if (!stat.isDirectory()) {
cache.clear();
lastWalkedAt = now;
return;
}
const found = await walkSkills(SKILLS_ROOT);
// Sort by name asc, then path asc — gives alphabetically-first-wins on
// collision and stable, deterministic ordering for /api/skills + skill_find.
found.sort((a, b) => {
const n = a.name.localeCompare(b.name);
return n !== 0 ? n : a.path.localeCompare(b.path);
});
cache.clear();
const winnerPath = new Map<string, string>();
for (const skill of found) {
const prev = winnerPath.get(skill.name);
if (prev) {
console.warn(
`skills: name collision "${skill.name}" — kept ${prev}, skipped ${skill.path}`,
);
continue;
}
winnerPath.set(skill.name, skill.path);
cache.set(skill.name, skill);
}
lastWalkedAt = now;
}
// ---- Public API -----------------------------------------------------------
export async function listSkills(): Promise<Skill[]> {
await ensureCache();
return Array.from(cache.values()).map((s) => ({
name: s.name,
description: s.description,
path: s.path,
mtime: s.mtime,
}));
}
export interface SkillSummary {
name: string;
description: string;
}
export async function findSkills(query: string): Promise<SkillSummary[]> {
await ensureCache();
const all = Array.from(cache.values());
const q = (query ?? '').trim().toLowerCase();
if (q === '' || q === '*') {
return all.map((s) => ({ name: s.name, description: s.description }));
}
// name match weighted 2x description match. No fancy ranking — substring
// scoring is enough for ≤20 skills.
const scored = all
.map((s) => {
let score = 0;
if (s.name.toLowerCase().includes(q)) score += 2;
if (s.description.toLowerCase().includes(q)) score += 1;
return { s, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
return scored.map(({ s }) => ({ name: s.name, description: s.description }));
}
// Returns the SKILL.md body with frontmatter stripped, or null if the skill
// is unknown. Single-entry mtime refresh: a hot edit shows up on next call.
export async function getSkillBody(name: string): Promise<string | null> {
await ensureCache();
const cached = cache.get(name);
if (!cached) return null;
let stat;
try {
stat = await fs.stat(join(cached.path, 'SKILL.md'));
} catch {
cache.delete(name);
return null;
}
if (stat.mtimeMs === cached.mtime) return cached.body;
try {
const raw = await fs.readFile(join(cached.path, 'SKILL.md'), 'utf8');
const parsed = parseSkillFile(raw);
if (parsed.name !== name) {
// Skill renamed itself; drop the stale entry. Next listSkills() walks.
cache.delete(name);
return null;
}
cached.body = parsed.body;
cached.description = parsed.description;
cached.mtime = stat.mtimeMs;
return cached.body;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.warn(`skills: re-parse failed for ${name}${reason}`);
cache.delete(name);
return null;
}
}
export type SkillResourceErrorCode = 'unknown_skill' | 'unknown_resource' | 'path_escape';
export type SkillResourceResult =
| { ok: true; content: string }
| { ok: false; code: SkillResourceErrorCode; message: string };
export async function getSkillResource(
name: string,
relativePath: string,
): Promise<SkillResourceResult> {
await ensureCache();
const cached = cache.get(name);
if (!cached) {
return { ok: false, code: 'unknown_skill', message: `unknown skill: ${name}` };
}
if (typeof relativePath !== 'string' || relativePath.trim() === '') {
return { ok: false, code: 'unknown_resource', message: 'path is required' };
}
// Syntactic pre-check — catches the common "../../etc/passwd" attempt
// before realpath dereferences any symlinks.
if (isAbsolute(relativePath) || relativePath.split(/[\\/]/).some((seg) => seg === '..')) {
return { ok: false, code: 'path_escape', message: `path escapes skill folder: ${relativePath}` };
}
// SKILL.md is the manifest — skill_use is the right tool to read it.
if (basename(relativePath) === 'SKILL.md') {
return { ok: false, code: 'unknown_resource', message: 'use skill_use to read SKILL.md' };
}
let real: string;
try {
real = await pathGuard(cached.path, relativePath);
} catch (err) {
if (err instanceof PathScopeError) {
const code: SkillResourceErrorCode = err.message.includes('escapes')
? 'path_escape'
: 'unknown_resource';
return { ok: false, code, message: err.message };
}
throw err;
}
const stat = await fs.stat(real);
if (!stat.isFile()) {
return { ok: false, code: 'unknown_resource', message: 'not a file' };
}
if (stat.size > MAX_RESOURCE_BYTES) {
return {
ok: false,
code: 'unknown_resource',
message: `file too large (${stat.size} bytes, max ${MAX_RESOURCE_BYTES})`,
};
}
const content = await fs.readFile(real, 'utf8');
return { ok: true, content };
}

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import { pathGuard, PathScopeError } from './path_guard.js';
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
import { getGitMeta } from './git_meta.js';
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200;
@@ -300,12 +301,195 @@ export const gitStatus: ToolDef<GitStatusInputT> = {
},
};
// Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown
// playbooks at /data/skills/. Three tools rather than one to keep each call
// cheap — the model lists, then loads, then optionally pulls support files.
const SkillFindInput = z.object({
query: z.string().optional(),
});
type SkillFindInputT = z.infer<typeof SkillFindInput>;
export const skillFind: ToolDef<SkillFindInputT> = {
name: 'skill_find',
description:
'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.',
inputSchema: SkillFindInput,
jsonSchema: {
type: 'function',
function: {
name: 'skill_find',
description:
'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'substring matched against skill name and description' },
},
additionalProperties: false,
},
},
},
async execute(input) {
return await findSkills(input.query ?? '');
},
};
const SkillUseInput = z.object({
name: z.string().min(1),
});
type SkillUseInputT = z.infer<typeof SkillUseInput>;
export const skillUse: ToolDef<SkillUseInputT> = {
name: 'skill_use',
description:
"Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.",
inputSchema: SkillUseInput,
jsonSchema: {
type: 'function',
function: {
name: 'skill_use',
description: "Load the full body of a skill's SKILL.md by name.",
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'skill name from skill_find' },
},
required: ['name'],
additionalProperties: false,
},
},
},
async execute(input) {
const body = await getSkillBody(input.name);
if (body === null) {
return { error: 'unknown_skill', message: `unknown skill: ${input.name}` };
}
return { body };
},
};
const SkillResourceInput = z.object({
name: z.string().min(1),
path: z.string().min(1),
});
type SkillResourceInputT = z.infer<typeof SkillResourceInput>;
export const skillResource: ToolDef<SkillResourceInputT> = {
name: 'skill_resource',
description:
"Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.",
inputSchema: SkillResourceInput,
jsonSchema: {
type: 'function',
function: {
name: 'skill_resource',
description: "Read a support file inside a skill's folder. Path is relative to the skill folder.",
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'skill name' },
path: { type: 'string', description: 'relative path under the skill folder' },
},
required: ['name', 'path'],
additionalProperties: false,
},
},
},
async execute(input) {
const result = await getSkillResource(input.name, input.path);
if (!result.ok) {
return { error: result.code, message: result.message };
}
return { content: result.content };
},
};
// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool
// call with 1-3 structured questions; the inference loop PAUSES (does not
// execute the tool server-side, does not recurse) and waits for the frontend
// to POST /api/chats/:id/answer_user_input with the user's selections. See
// routes/messages.ts for the resume path and services/inference.ts for the
// pause branch in executeToolPhase.
const AskUserInputInput = z.object({
questions: z
.array(
z.object({
question: z.string().min(1).max(200),
type: z.enum(['single_select', 'multi_select']),
options: z.array(z.string().min(1).max(80)).min(2).max(6),
}),
)
.min(1)
.max(3),
});
type AskUserInputInputT = z.infer<typeof AskUserInputInput>;
export const askUserInput: ToolDef<AskUserInputInputT> = {
name: 'ask_user_input',
description:
"Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.",
inputSchema: AskUserInputInput,
jsonSchema: {
type: 'function',
function: {
name: 'ask_user_input',
description:
'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.',
parameters: {
type: 'object',
properties: {
questions: {
type: 'array',
minItems: 1,
maxItems: 3,
items: {
type: 'object',
properties: {
question: { type: 'string', description: '<=200 chars, shown to the user' },
type: {
type: 'string',
enum: ['single_select', 'multi_select'],
description: 'single_select = at most one option; multi_select = any subset',
},
options: {
type: 'array',
minItems: 2,
maxItems: 6,
items: { type: 'string' },
description: '2-6 strings, each <=80 chars; free-text input is always available alongside',
},
},
required: ['question', 'type', 'options'],
additionalProperties: false,
},
},
},
required: ['questions'],
additionalProperties: false,
},
},
},
// Server-side no-op. The "execution" of ask_user_input is the user's
// response, captured client-side and posted to /api/chats/:id/answer_user_input.
// The inference loop detects this tool by name and pauses before reaching
// executeToolCall — this fallback only runs if something bypasses that
// branch, in which case the pending sentinel matches the pause-path shape.
async execute(input) {
return { _pending: true, questions: input.questions };
},
};
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
viewFile as ToolDef<unknown>,
listDir as ToolDef<unknown>,
grep as ToolDef<unknown>,
findFiles as ToolDef<unknown>,
gitStatus as ToolDef<unknown>,
skillFind as ToolDef<unknown>,
skillUse as ToolDef<unknown>,
skillResource as ToolDef<unknown>,
askUserInput as ToolDef<unknown>,
];
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
@@ -313,12 +497,19 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
// anything outside means the agent can mutate state and gets a tighter
// default (10). Every tool in v1.8.2 happens to be read-only, so the
// non-RO branch only takes effect once BooCoder lands write tools.
// Batch 9.6: skill_* added; all still read-only.
// Batch 9.7: ask_user_input added — it pauses execution but doesn't mutate
// project state, so it belongs in the read-only set for budget purposes.
export const READ_ONLY_TOOL_NAMES = [
'view_file',
'list_dir',
'grep',
'find_files',
'git_status',
'skill_find',
'skill_use',
'skill_resource',
'ask_user_input',
] as const;
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -26,7 +26,10 @@
"shiki": "^1.29.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",

View File

@@ -10,6 +10,8 @@ import type {
ViewFileResult,
AgentsResponse,
GitMeta,
Skill,
AskUserAnswer,
} from './types';
export class ApiError extends Error {
@@ -187,6 +189,31 @@ export const api = {
method: 'POST',
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
}),
// Batch 9.6: slash-command invocation. Server loads the skill body
// authoritatively (client doesn't get to forge file contents), persists
// a synthetic skill_use tool_use + tool_result + user message + streaming
// assistant, and enqueues inference. Returns all 4 new message IDs.
skillInvoke: (chatId: string, skillName: string, userMessage: string | null) =>
request<{
synth_assistant_id: string;
tool_message_id: string;
user_message_id: string;
assistant_message_id: string;
}>(`/api/chats/${chatId}/skill_invoke`, {
method: 'POST',
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
}),
// Batch 9.7: submit answers for a paused ask_user_input call. Server
// validates against the question shape, UPDATEs the pending tool row,
// publishes the deferred tool_result frame, and enqueues the next turn.
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
request<{ tool_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/answer_user_input`,
{
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
},
),
},
messages: {
@@ -218,6 +245,10 @@ export const api = {
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
},
skills: {
list: () => request<{ skills: Skill[] }>('/api/skills'),
},
settings: {
get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) =>
@@ -230,4 +261,26 @@ export const 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). All three endpoints are tolerant of empty bodies on the
// POSTs that don't take parameters.
terminals: {
start: (sessionId: string, paneId: string) =>
request<{ tmux_window: string }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
{ method: 'POST' },
),
resize: (sessionId: string, paneId: string, cols: number, rows: number) =>
request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/resize`,
{ method: 'POST', body: JSON.stringify({ cols, rows }) },
),
kill: (sessionId: string, paneId: string) =>
request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
{ method: 'POST' },
),
},
};

View File

@@ -231,9 +231,40 @@ export interface GitMeta {
behind: number;
}
// Batch 9.6: skill catalog row. Returned by GET /api/skills and consumed by
// the slash-command dropdown. `path` and `mtime` are exposed for debug surface
// (/api/skills) but the dropdown only renders name + description.
export interface Skill {
name: string;
description: string;
path: string;
mtime: number;
}
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
// same order. AskUserInputCard renders questions and POSTs answers.
export type AskUserQuestionType = 'single_select' | 'multi_select';
export interface AskUserQuestion {
question: string;
type: AskUserQuestionType;
options: string[];
}
export interface AskUserAnswer {
question: string;
selected_options: string[];
free_text: string | null;
}
export interface AskUserAnswerSet {
answers: AskUserAnswer[];
}
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
// singleton per workspace. The pane hook filters it out before writing to
// localStorage and dedupes on insertion via openOrFocusSettingsPane().
// localStorage and dedupes on insertion via toggleSettingsPane().
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
export interface WorkspacePane {

View File

@@ -0,0 +1,324 @@
import { useMemo, useState } from 'react';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import type {
AskUserAnswer,
AskUserAnswerSet,
AskUserQuestion,
ToolCall,
ToolResult,
} from '@/api/types';
// Batch 9.7. Inline interactive picker. Renders inside MessageList in place of
// the standard ToolCallLine when the assistant emits an ask_user_input tool
// call. While the tool result is null (server pre-stamps a sentinel with
// output=null), shows the form; once the WS tool_result frame arrives with a
// real AnswerSet, flips to read-only review mode.
interface Props {
toolCall: ToolCall;
toolResult: ToolResult | null;
chatId: string;
}
function parseQuestions(raw: unknown): AskUserQuestion[] {
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
const arr = (raw as { questions: unknown }).questions;
if (!Array.isArray(arr)) return [];
const out: AskUserQuestion[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const q = item as { question?: unknown; type?: unknown; options?: unknown };
if (typeof q.question !== 'string') continue;
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
if (!Array.isArray(q.options)) continue;
const opts = q.options.filter((o): o is string => typeof o === 'string');
if (opts.length < 2) continue;
out.push({ question: q.question, type: q.type, options: opts });
}
return out;
}
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
const arr = (raw as { answers: unknown }).answers;
if (!Array.isArray(arr)) return null;
const answers: AskUserAnswer[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
if (typeof a.question !== 'string') continue;
if (!Array.isArray(a.selected_options)) continue;
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
answers.push({
question: a.question,
selected_options: sel,
free_text: (a.free_text as string | null) ?? null,
});
}
return { answers };
}
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
if (questions.length === 0) {
return (
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
ask_user_input: malformed tool args
</div>
);
}
// Tool result with a non-null output means the answer is already submitted.
// The pending sentinel uses output=null, so this branch only triggers after
// the real WS tool_result frame lands.
const answered = toolResult && toolResult.output !== null;
if (answered) {
const answerSet = parseAnswerSet(toolResult!.output);
return <AnsweredView questions={questions} answers={answerSet} />;
}
return (
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
);
}
function PendingView({
questions,
toolCallId,
chatId,
}: {
questions: AskUserQuestion[];
toolCallId: string;
chatId: string;
}) {
// Per-question selections + free text. Selections are option arrays so the
// multi_select case is uniform; single_select just constrains to length 1.
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
const [submitting, setSubmitting] = useState(false);
const singleQuestion = questions.length === 1;
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
// Submit button shows when:
// - more than one question (always batched), OR
// - one question and the user has typed free text (committing it needs an
// explicit Submit so an accidental Tab/click doesn't lose it).
// For one question with no free text, clicking an option submits inline.
const showSubmitButton = !singleQuestion || anyFreeText;
// Every question must have at least one of (option, free text).
const allComplete = questions.every((_, i) => {
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
});
function buildAnswers(): AskUserAnswer[] {
return questions.map((q, i) => {
const freeText = freeTexts[i]!.trim();
return {
question: q.question,
selected_options: selections[i]!,
free_text: freeText.length > 0 ? freeText : null,
};
});
}
async function submit(answers: AskUserAnswer[]) {
if (submitting) return;
setSubmitting(true);
try {
await api.chats.answerUserInput(chatId, toolCallId, answers);
// Card stays mounted; the incoming WS tool_result frame will flip it
// into AnsweredView via the parent prop change.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'submit failed');
setSubmitting(false);
}
}
function pickSingle(qIdx: number, option: string) {
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
// Immediate submit for the single-question single-select shortcut. Only
// fires when no free text exists anywhere — once the user typed, the
// Submit button takes over so the typed text isn't silently dropped.
if (singleQuestion && !anyFreeText) {
const answers: AskUserAnswer[] = [
{
question: questions[0]!.question,
selected_options: [option],
free_text: null,
},
];
void submit(answers);
}
}
function toggleMulti(qIdx: number, option: string) {
setSelections((prev) =>
prev.map((arr, i) => {
if (i !== qIdx) return arr;
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
}),
);
}
function setFreeText(qIdx: number, value: string) {
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
}
return (
<div className="rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-4">
{questions.map((q, i) => (
<div key={i} className="space-y-2">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.type === 'single_select' ? (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={submitting}
className="gap-1.5"
>
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
) : (
<div className="grid gap-1.5">
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
const checked = selections[i]!.includes(opt);
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={submitting}
onChange={() => toggleMulti(i, opt)}
className="mt-1 size-3.5 rounded border-input accent-primary"
/>
<span>{opt}</span>
</label>
);
})}
</div>
)}
<div className="pt-1 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Or type a custom answer
</div>
<input
type="text"
value={freeTexts[i]}
disabled={submitting}
placeholder="Free text…"
onChange={(e) => setFreeText(i, e.target.value)}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
</div>
</div>
))}
</div>
{showSubmitButton && (
<div className="flex justify-end gap-2 border-t px-4 py-2">
<Button
type="button"
size="sm"
disabled={!allComplete || submitting}
onClick={() => void submit(buildAnswers())}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
function AnsweredView({
questions,
answers,
}: {
questions: AskUserQuestion[];
answers: AskUserAnswerSet | null;
}) {
if (!answers) {
return (
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
ask_user_input: answers unavailable
</div>
);
}
return (
<div className="rounded-lg border bg-muted/10 text-sm">
<div className="px-4 py-3 space-y-3">
{questions.map((q, i) => {
const a = answers.answers[i];
if (!a) return null;
return (
<div key={i} className="space-y-1.5">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
<div className="space-y-0.5">
{q.options.map((opt, j) => {
const selected = a.selected_options.includes(opt);
return (
<div
key={j}
className={
selected
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
}
>
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
{selected && <Check className="size-3 text-primary" />}
</span>
<span>{opt}</span>
</div>
);
})}
</div>
{a.free_text && (
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
{a.free_text}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Check, Plus, Send } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
@@ -22,8 +22,10 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker';
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSkills } from '@/hooks/useSkills';
import { useViewport } from '@/hooks/useViewport';
const MAX_ATTACHMENTS = 10;
@@ -44,9 +46,14 @@ interface Props {
webSearchEnabled?: boolean | null;
onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>;
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
// ChatInput calls this with the skill name + the post-name args (possibly
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
// disables slash-command dispatch (input is sent as literal text).
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
}
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend }: Props) {
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand }: Props) {
const { isMobile } = useViewport();
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -61,6 +68,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
atIdx: number;
anchorRect: { top: number; left: number };
} | null>(null);
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
// the input and stays open while the input is `/<word>` with no whitespace.
// Disabled entirely when the caller doesn't pass onSlashCommand.
const [slashState, setSlashState] = useState<{
query: string;
anchorRect: { top: number; left: number };
} | null>(null);
const { skills } = useSkills();
const skillsLookup = useMemo(() => {
const m = new Map<string, true>();
for (const s of skills) m.set(s.name, true);
return m;
}, [skills]);
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -95,6 +115,31 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
const text = value.trim();
if (!text && attachments.length === 0) return;
if (disabled || busy) return;
// Batch 9.6: slash-command dispatch. Only when no attachments and the
// input parses to a known skill. Falls through to onSend for unknown
// slash names (literal text) or when slash dispatch isn't wired.
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
if (match && skillsLookup.has(match[1]!)) {
const skillName = match[1]!;
const args = (match[2] ?? '').trim();
setBusy(true);
try {
await onSlashCommand(skillName, args);
setValue('');
setAttachments([]);
setSlashState(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally {
setBusy(false);
}
return;
}
// Unknown skill name — fall through and send as literal text.
}
setBusy(true);
try {
const body = flattenToMessage(attachments, text);
@@ -108,6 +153,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
}
}
function handleSlashSelect(skillName: string) {
const next = `/${skillName} `;
setValue(next);
setSlashState(null);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (ta) {
ta.selectionStart = ta.selectionEnd = next.length;
ta.focus();
}
});
}
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
const mirror = document.createElement('div');
const style = window.getComputedStyle(textarea);
@@ -158,6 +216,23 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
const ta = e.target;
const pos = ta.selectionStart;
// Batch 9.6: slash-command trigger. Active while the input is a single
// slash-prefixed token with no whitespace (i.e. user is still typing the
// skill name). Hand off to args mode the moment a space appears or the
// slash leaves position 0.
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) {
const query = newValue.slice(1);
if (!slashState) {
const rect = ta.getBoundingClientRect();
setSlashState({ query, anchorRect: { top: rect.top, left: rect.left } });
} else if (slashState.query !== query) {
setSlashState({ ...slashState, query });
}
if (mentionState?.open) setMentionState(null);
return;
}
if (slashState) setSlashState(null);
// Check for @ trigger
if (pos > 0 && newValue[pos - 1] === '@') {
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
@@ -374,6 +449,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) return;
// SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let
// it consume them so the textarea doesn't also submit on Enter.
if (slashState) return;
// IME safety: never act on Enter while an IME composition is in flight
// (CJK input methods commit composition via Enter). Without this, the
// first Enter of a Japanese/Chinese/Korean composition would submit
@@ -524,6 +602,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
onClose={closeMention}
/>
)}
{slashState && (
<SkillSlashCommand
query={slashState.query}
skills={skills}
anchorRect={slashState.anchorRect}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
/>
)}
</div>
);
}

View File

@@ -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 Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -7,9 +7,19 @@ import { toast } from 'sonner';
import type { Chat, ErrorReason, Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
import { CapHitSentinel } from './CapHitSentinel';
import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
@@ -19,6 +29,57 @@ import {
DialogTitle,
} 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
// failed assistant messages via metadata.kind === 'error'. Kept short so the
// inline render under "message failed" stays a single muted line.
@@ -507,9 +568,11 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
if (message.role === 'user') {
return (
<div className="group flex flex-col items-end gap-1">
<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}
</div>
<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">
{message.content}
</div>
</SendToTerminalMenu>
<ActionRow message={message} />
</div>
);
@@ -529,12 +592,14 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
return (
<div className="group flex flex-col gap-2">
{(hasContent || isStreaming) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
<SendToTerminalMenu>
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
</SendToTerminalMenu>
)}
{failed && (
<div className="text-xs text-destructive">

View File

@@ -3,6 +3,7 @@ import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine';
import { AskUserInputCard } from './AskUserInputCard';
interface Props {
messages: Message[];
@@ -12,9 +13,11 @@ interface Props {
// v1.8.2: pre-render units. The single linear `messages` array gets walked
// into a render-time list where each tool_call is a first-class item and
// tool_result messages are folded onto their matching tool_run by id.
// Batch 9.7: tool_run carries chat_id so AskUserInputCard can post the
// answer without threading the chat id through MessageList's parent.
type RenderItem =
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
| { kind: 'tool_run'; run: ToolRun; key: string }
| { kind: 'tool_run'; run: ToolRun; key: string; chatId: string }
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
const GROUP_THRESHOLD = 3;
@@ -50,7 +53,7 @@ function flatten(messages: Message[]): RenderItem[] {
for (const tc of m.tool_calls!) {
const run: ToolRun = { call: tc, result: null };
runsByCallId.set(tc.id, run);
items.push({ kind: 'tool_run', run, key: tc.id });
items.push({ kind: 'tool_run', run, key: tc.id, chatId: m.chat_id });
}
continue;
}
@@ -63,6 +66,9 @@ function flatten(messages: Message[]): RenderItem[] {
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items
// of the same tool name into a single tool_group. Any other render item
// (text bubble, sentinel, user message) breaks the chain.
// Batch 9.7: ask_user_input never groups — each pause has its own card so
// grouping would render them as collapsed ToolCallLines which can't surface
// the interactive form.
function group(items: RenderItem[]): RenderItem[] {
const out: RenderItem[] = [];
let i = 0;
@@ -74,6 +80,11 @@ function group(items: RenderItem[]): RenderItem[] {
continue;
}
const name = item.run.call.name;
if (name === 'ask_user_input') {
out.push(item);
i += 1;
continue;
}
let j = i + 1;
while (
j < items.length &&
@@ -82,7 +93,12 @@ function group(items: RenderItem[]): RenderItem[] {
) {
j += 1;
}
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
const run = items.slice(i, j) as Array<{
kind: 'tool_run';
run: ToolRun;
key: string;
chatId: string;
}>;
if (run.length >= GROUP_THRESHOLD) {
out.push({
kind: 'tool_group',
@@ -150,6 +166,16 @@ export function MessageList({ messages, sessionChats }: Props) {
);
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input') {
return (
<AskUserInputCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;

View File

@@ -3,7 +3,6 @@ import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Tra
import type { Chat } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import {
ContextMenu,
ContextMenuContent,
@@ -165,7 +164,6 @@ export function SessionLandingPage({
const [renameValue, setRenameValue] = useState('');
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
const [deleteInput, setDeleteInput] = useState('');
const openChats = chats
.filter((c) => c.status === 'open')
@@ -193,9 +191,6 @@ export function SessionLandingPage({
setRenamingId(null);
}
const deleteExpected = deleteConfirm?.name ?? '';
const deleteEnabled = deleteConfirm !== null && deleteInput === deleteExpected && deleteExpected.length > 0;
// TODO: Landing page chat counts are a snapshot at mount. New messages in
// visible chats won't update the per-row stats until next mount/navigation.
return (
@@ -217,7 +212,7 @@ export function SessionLandingPage({
onCancelRename={() => setRenamingId(null)}
onContextStartRename={() => startRename(chat)}
onContextArchive={() => setArchiveConfirm(chat)}
onContextDelete={() => { setDeleteConfirm(chat); setDeleteInput(''); }}
onContextDelete={() => setDeleteConfirm(chat)}
showContextMenu
actions={
<>
@@ -242,7 +237,6 @@ export function SessionLandingPage({
onClick={(e) => {
e.stopPropagation();
setDeleteConfirm(chat);
setDeleteInput('');
}}
>
<Trash2 size={14} />
@@ -352,36 +346,25 @@ export function SessionLandingPage({
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) { setDeleteConfirm(null); setDeleteInput(''); } }}>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
Type the chat name to confirm:
{' '}
<span className="font-mono font-medium text-foreground">{deleteExpected || '(unnamed — cannot type-confirm)'}</span>
Permanently delete{' '}
<span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
{' '}and all its messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<Input
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteExpected}
disabled={!deleteExpected}
/>
<div className="text-xs text-muted-foreground">
This will permanently delete this chat and all its messages. This cannot be undone.
</div>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => { setDeleteConfirm(null); setDeleteInput(''); }}>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!deleteEnabled}
onClick={() => {
if (deleteConfirm && deleteEnabled) void onDeleteChat(deleteConfirm.id);
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null);
setDeleteInput('');
}}
>
Delete

View File

@@ -0,0 +1,137 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import type { Skill } from '@/api/types';
interface Props {
query: string;
skills: Skill[];
anchorRect: { top: number; left: number };
onSelect: (skillName: string) => void;
onClose: () => void;
}
// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern —
// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn
// `Command` (cmdk) isn't installed in this project; per the addendum we use
// a plain div + Tailwind instead of pulling a new primitive autonomously.
// Case-insensitive prefix match on `name` only. Description is display-only
// in v1 (substring search across description is deferred to a polish batch).
function filterByPrefix(skills: Skill[], query: string): Skill[] {
const q = query.toLowerCase();
const filtered = q
? skills.filter((s) => s.name.toLowerCase().startsWith(q))
: skills;
// Stable alphabetical ordering matches the server's cache order (skills.ts
// sorts on name asc) but we re-sort here so a stale client cache doesn't
// surprise the user.
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
}
export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose }: Props) {
const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]);
useEffect(() => { setHighlightIndex(0); }, [query]);
// Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the
// textarea reach the popover even though focus stays in the textarea.
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (filtered.length === 0) return;
e.preventDefault();
const target = filtered[highlightIndex] ?? filtered[0];
if (target) onSelect(target.name);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [filtered, highlightIndex, onSelect, onClose]);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [onClose]);
useEffect(() => {
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
if (el) el.scrollIntoView({ block: 'nearest' });
}, [highlightIndex]);
// Anchor sits above the input — translate(-100%) on Y so the dropdown
// expands upward from the anchor point rather than over the textarea.
const style = {
top: anchorRect.top,
left: anchorRect.left,
transform: 'translateY(-100%)',
} as const;
if (filtered.length === 0) {
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
style={style}
>
<div className="text-xs text-muted-foreground px-2 py-1">
{query ? `No skill starts with "/${query}"` : 'No skills available'}
</div>
</div>
);
}
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
style={style}
>
{filtered.map((skill, i) => (
<button
key={skill.name}
type="button"
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left px-2.5 py-2 cursor-pointer block',
i === highlightIndex && 'bg-muted',
)}
onMouseEnter={() => setHighlightIndex(i)}
onMouseDown={(e) => {
// mousedown not click — click runs after blur/focus shuffles which
// can race with the textarea's onBlur close path.
e.preventDefault();
onSelect(skill.name);
}}
>
<div className="font-mono text-xs font-bold text-foreground">/{skill.name}</div>
<div
className="text-xs text-muted-foreground overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{skill.description}
</div>
</button>
))}
</div>
);
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot, X } from 'lucide-react';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { TerminalPane } from '@/components/panes/TerminalPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
@@ -81,14 +82,27 @@ export function Workspace({
const [maximized, setMaximized] = useState(false);
const settingsIdx = panes.findIndex((p) => p.kind === 'settings');
// Esc semantics: maximized → restore; otherwise → close settings pane (only
// when it's the active pane). Bail when the user is typing in a field or
// inside an open dialog so we don't eat their cancel keystroke.
useEffect(() => {
if (!maximized) return;
if (settingsIdx < 0) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setMaximized(false);
if (e.key !== 'Escape') return;
const t = e.target;
if (t instanceof HTMLElement) {
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) return;
if (t.closest('[role="dialog"]')) return;
}
if (maximized) {
setMaximized(false);
} else if (activePaneIdx === settingsIdx) {
removePane(settingsIdx);
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [maximized]);
}, [maximized, settingsIdx, activePaneIdx, removePane]);
// If the settings pane was closed (no longer in panes) while maximized,
// clear the maximize state so the grid renders normally.
@@ -102,6 +116,20 @@ export function Workspace({
.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 (
<div className="flex flex-col h-full min-h-0">
{!isMobile && (
@@ -152,6 +180,7 @@ export function Workspace({
>
{panes.map((pane, idx) => {
const isSettings = pane.kind === 'settings';
const isTerminal = pane.kind === 'terminal';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
// survive the toggle without re-mount cost.
@@ -163,6 +192,9 @@ export function Workspace({
}
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 (
<div
key={pane.id}
@@ -174,19 +206,18 @@ export function Workspace({
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
onDragOver={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={!isMobile && !isSettings && panes.length > 1}
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
draggable={!isMobile && !isChromeless && panes.length > 1}
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
>
{/* Hidden on mobile per v1.8; settings panes own their own
section nav / maximize toggle so they skip ChatTabBar
entirely. */}
{!isMobile && !isSettings && (
{/* Hidden on mobile per v1.8; settings + terminal panes own
their own header (no chats, so no ChatTabBar). */}
{!isMobile && !isChromeless && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
@@ -201,6 +232,28 @@ export function Workspace({
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>
{panes.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removePane(idx);
}}
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close terminal pane"
title="Close terminal pane"
>
<X size={12} />
</button>
)}
</div>
)}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
@@ -210,8 +263,15 @@ export function Workspace({
project={project}
maximized={maximized}
onToggleMaximize={() => setMaximized((v) => !v)}
onClose={() => removePane(idx)}
isMobile={isMobile}
/>
) : isTerminal ? (
<TerminalPane
sessionId={sessionId}
paneId={pane.id}
label={terminalLabels.get(pane.id) ?? 'Terminal'}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane
sessionId={sessionId}

View File

@@ -96,6 +96,18 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
}
}, [chatId]);
// Batch 9.6: slash-command dispatch. Sent regardless of streaming state —
// matches the existing /compact precedent (which also fires immediately).
// Empty args go to the server as null; the server fills in a default user
// message ("Apply this skill.") so the model has something to act on.
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
try {
await api.chats.skillInvoke(chatId, skillName, userMessage.length > 0 ? userMessage : null);
} catch (err) {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
}
}, [chatId]);
function removeQueued(idx: number) {
setQueue((prev) => prev.filter((_, i) => i !== idx));
}
@@ -183,6 +195,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
webSearchEnabled={webSearchEnabled}
onSend={handleSend}
onForceSend={streaming ? handleForceSend : undefined}
onSlashCommand={handleSlashCommand}
/>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Archive, Maximize2, Minimize2 } from 'lucide-react';
import { Archive, Maximize2, Minimize2, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Project, Session } from '@/api/types';
@@ -24,6 +24,7 @@ interface Props {
project: Project;
maximized: boolean;
onToggleMaximize: () => void;
onClose: () => void;
isMobile: boolean;
}
@@ -65,7 +66,7 @@ function Switch({
);
}
export function SettingsPane({ session, project, maximized, onToggleMaximize, isMobile }: Props) {
export function SettingsPane({ session, project, maximized, onToggleMaximize, onClose, isMobile }: Props) {
const [activeSection, setActiveSection] = useState<Section>('session');
return (
@@ -99,6 +100,15 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, is
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
)}
<button
type="button"
onClick={onClose}
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="Close settings"
title="Close (Esc)"
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto">

View File

@@ -0,0 +1,167 @@
import { useEffect, useRef } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { WebLinksAddon } from 'xterm-addon-web-links';
import 'xterm/css/xterm.css';
import { api } from '@/api/client';
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
interface Props {
sessionId: string;
paneId: string;
label: string;
}
// Minimal dark theme. xterm.js renders against its own canvas; CSS variables
// don't reach it, so we hardcode. Matches the obsidian-dark base in spirit.
const XTERM_THEME = {
background: '#0b0f14',
foreground: '#d6deeb',
cursor: '#82aaff',
selectionBackground: '#1d3b53',
black: '#011627',
red: '#ef5350',
green: '#22da6e',
yellow: '#c5e478',
blue: '#82aaff',
magenta: '#c792ea',
cyan: '#7fdbca',
white: '#d6deeb',
brightBlack: '#575656',
brightRed: '#ef5350',
brightGreen: '#22da6e',
brightYellow: '#ffeb95',
brightBlue: '#82aaff',
brightMagenta: '#c792ea',
brightCyan: '#7fdbca',
brightWhite: '#ffffff',
};
export function TerminalPane({ sessionId, paneId, label }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let disposed = false;
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const term = new Terminal({
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace',
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
scrollback: 10_000,
theme: XTERM_THEME,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
try {
fit.fit();
} catch {
/* container not yet sized */
}
// POST start kicks the tmux window into existence before the WS upgrade.
// It's idempotent so a refresh just no-ops. Failures fall through to the
// WS handler which will also call ensureWindow.
api.terminals.start(sessionId, paneId).catch(() => {
/* surfaced by WS error if it matters */
});
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const initialCols = term.cols;
const initialRows = term.rows;
const wsUrl =
`${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` +
`?cols=${initialCols}&rows=${initialRows}`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onmessage = (e) => {
if (typeof e.data === 'string') {
// Control frame from server (e.g. {"type":"exit","code":0}).
try {
const parsed = JSON.parse(e.data) as { type?: string; code?: number };
if (parsed.type === 'exit') {
term.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`);
return;
}
} catch {
/* not JSON — fall through and write as text */
}
term.write(e.data);
} else {
term.write(new Uint8Array(e.data));
}
};
ws.onclose = () => {
if (disposed) return;
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
};
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
const fireResize = () => {
try {
fit.fit();
} catch {
return;
}
const cols = term.cols;
const rows = term.rows;
api.terminals.resize(sessionId, paneId, cols, rows).catch(() => {
/* transient — next resize will catch up */
});
};
const ro = new ResizeObserver(() => {
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
resizeDebounceTimer = setTimeout(fireResize, 100);
});
ro.observe(container);
const unregister = terminalsRegistry.register(paneId, label);
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
if (pane_id !== paneId) return;
if (ws.readyState !== WebSocket.OPEN) return;
const payload = text.endsWith('\n') ? text : `${text}\n`;
ws.send(payload);
});
return () => {
disposed = true;
unsubscribe();
unregister();
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
ro.disconnect();
try {
ws.close();
} catch {
/* ignore */
}
wsRef.current = null;
term.dispose();
};
}, [sessionId, paneId, label]);
return (
<div
ref={containerRef}
className="w-full h-full bg-[#0b0f14] overflow-hidden"
data-testid="terminal-pane"
/>
);
}

View File

@@ -62,10 +62,10 @@ export interface OpenChatInActivePaneEvent {
chat_id: string;
}
// v1.9: client-side event fired by the sidebar Settings button when a
// session is currently mounted. Session.tsx subscribes and calls
// panesHook.openOrFocusSettingsPane(). Sidebar handles the no-session case
// by navigating to /settings (themes page) directly.
// Client-side event fired by the sidebar Settings button when a session is
// currently mounted. Session.tsx subscribes and calls
// panesHook.toggleSettingsPane() (open on first click, close on second).
// Sidebar handles the no-session case by navigating to /settings directly.
export interface OpenSettingsPaneEvent {
type: 'open_settings_pane';
}

View File

@@ -152,8 +152,8 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'open_settings_pane':
// v1.9: consumed by Session.tsx (calls openOrFocusSettingsPane on its
// panesHook). Sidebar data is untouched.
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
// Sidebar data is untouched.
return prev;
case 'session_archived': {
let changed = false;

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
import type { Skill } from '@/api/types';
// Batch 9.6: shared in-memory cache for the slash-command dropdown. One fetch
// per process; subsequent mounts of useSkills() return the cached list and
// don't re-hit /api/skills. Matches the useSidebar / useChatStatus module-
// singleton pattern so the dropdown stays cheap even with many ChatInputs
// mounted at once.
let cachedSkills: Skill[] | null = null;
let inflight: Promise<Skill[]> | null = null;
const subscribers = new Set<(s: Skill[]) => void>();
async function loadSkills(): Promise<Skill[]> {
if (inflight) return inflight;
inflight = api.skills
.list()
.then((r) => {
cachedSkills = r.skills;
for (const sub of subscribers) {
try { sub(cachedSkills); } catch { /* swallow */ }
}
return cachedSkills;
})
.finally(() => { inflight = null; });
return inflight;
}
export function useSkills(): { skills: Skill[]; loaded: boolean } {
const [skills, setSkills] = useState<Skill[]>(cachedSkills ?? []);
const [loaded, setLoaded] = useState<boolean>(cachedSkills !== null);
useEffect(() => {
subscribers.add(setSkills);
if (cachedSkills === null) {
void loadSkills().then(() => setLoaded(true)).catch(() => setLoaded(true));
}
return () => { subscribers.delete(setSkills); };
}, []);
return { skills, loaded };
}

View File

@@ -19,6 +19,14 @@ function chatPane(chatId: string): WorkspacePane {
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(): WorkspacePane {
return { id: generateId(), kind: 'terminal', chatIds: [], activeChatIdx: -1 };
}
// v1.9: settings pane factory. No chats, no state beyond identity — the
// SettingsPane component renders Session/Project sections from the
// surrounding session/project.
@@ -73,10 +81,10 @@ export interface UseWorkspacePanesResult {
closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void;
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
// v1.9: idempotent open-or-focus for the settings pane singleton. Appends
// a new settings pane if none exists, otherwise just focuses the existing
// one. Always succeeds — settings panes don't count toward MAX_PANES.
openOrFocusSettingsPane: () => void;
// Open-on-first-click, close-on-second-click. Singleton — settings panes
// 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.
toggleSettingsPane: () => void;
removePane: (idx: number) => void;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
@@ -234,10 +242,6 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return;
}
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
@@ -248,28 +252,42 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...prev, emptyPane()];
const newPane = kind === 'terminal' ? terminalPane() : emptyPane();
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
const openOrFocusSettingsPane = useCallback(() => {
const toggleSettingsPane = useCallback(() => {
setPanes((prev) => {
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
if (existingIdx >= 0) {
setActivePaneIdx(existingIdx);
return prev;
if (existingIdx < 0) {
const next = [...prev, settingsPane()];
setActivePaneIdx(next.length - 1);
return next;
}
const next = [...prev, settingsPane()];
setActivePaneIdx(next.length - 1);
if (prev.length <= 1) {
setActivePaneIdx(0);
return [emptyPane()];
}
const next = prev.filter((_, i) => i !== existingIdx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, []);
const removePane = useCallback((idx: number) => {
setPanes((prev) => {
if (prev.length <= 1) return prev;
if (prev.length <= 1) {
// Settings is the only kind that can be the last pane and still need
// closing (X / Esc / sidebar toggle). Fall back to empty.
if (prev[idx]?.kind === 'settings') {
setActivePaneIdx(0);
return [emptyPane()];
}
return prev;
}
const next = prev.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
@@ -359,7 +377,6 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setActivePaneIdx,
activePaneIdxRef,
openChatInPane,
openOrFocusSettingsPane,
switchTab,
removeTab,
closeOtherTabs,
@@ -367,6 +384,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
closeAllTabs,
showLandingPage,
addSplitPane,
toggleSettingsPane,
removePane,
removeChatFromPanes,
initializeFirstChatIfEmpty,

View File

@@ -0,0 +1,80 @@
// 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.
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>();
export interface TerminalRegistration {
paneId: string;
label: string;
}
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): () => void {
terminalRegistry.set(paneId, { paneId, label });
notifyRegistry();
return () => {
terminalRegistry.delete(paneId);
notifyRegistry();
};
},
list(): TerminalRegistration[] {
return Array.from(terminalRegistry.values());
},
subscribe(listener: Listener<void>): () => void {
registryListeners.add(listener);
return () => {
registryListeners.delete(listener);
};
},
};

View File

@@ -134,11 +134,10 @@ function SessionInner({ sessionId }: { sessionId: string }) {
void api.projects.get(project.id).then(setProject).catch(() => {});
return;
}
// v1.9: sidebar Settings button broadcasts this when a session is
// mounted; we own the workspace pane state, so we open/focus the
// singleton settings pane here.
// Sidebar Settings button broadcasts this when a session is mounted;
// toggleSettingsPane opens on first click, closes on second.
if (event.type === 'open_settings_pane') {
panesHook.openOrFocusSettingsPane();
panesHook.toggleSettingsPane();
}
});
}, [sessionId, editingName, navigate, project, panesHook]);

View File

@@ -12,6 +12,24 @@ export default defineConfig({
server: {
port: 5173,
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': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,

269
boocode_batch10.md Normal file
View 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.

View File

@@ -1,341 +1,201 @@
# BooCode v1.x — Roadmap
# BooCode — Roadmap
Last updated: 2026-05-16
Last updated: 2026-05-17
## Overview
BooCode is a standalone code-chat tool at `/opt/boocode/`. Read-only by design — pick a project, chat with a local LLM that has file-inspection tools, get streaming responses over WebSocket.
BooCode is a standalone code-chat tool at `/opt/boocode/`. Read-only by design in v1.x — pick a project, chat with a local LLM that has file-inspection tools, get streaming responses over WebSocket.
Live at `https://code.indifferentketchup.com` (Caddy → Authelia → Tailscale → `100.114.205.53:9500`).
**Architectural commitments:**
- No embeddings. The model uses file-view tools (`view_file`, `list_dir`, `grep`, `find_files`) + sidecar analyzers (codecontext, codesight). Walked away from the RAG pipeline May 2026.
- No embeddings. File-view tools + sidecar analyzers replace RAG.
- Read-only in v1.x. Write tools land in BooCoder (separate container, post-v1.x).
- One Postgres (`boocode_db`), one frontend SPA, container-per-service for new capabilities.
External code lifted from / referenced in: see `boocode_code_review.md` for full inventory.
## Current state
-----
- **main:** v1.8.1 (`b09d0ff` was last known tip prior to v1.8.2).
- **Just merged / committed to main:** v1.8.2 — tool-loop fixes (read-only loop cap raised, "tool loop depth exceeded" error surfaced with continue button, `max_tool_calls` AGENTS.md frontmatter, `messages.metadata` column).
- **In flight RIGHT NOW:** **v1.x-themes** branch — Claude Code implementing 18-theme system. See "Active work" below.
## Active work
### v1.x-themes — Theme system (in flight)
**Spec source:** locked in this session. Anchors below derived from `/mnt/user-data/uploads/boocode-theme-previews.html` (16 themes extracted) + spec §3 family rules for the two missing (`fuchsia-noir`, `midnight-sapphire`).
**18 themes, grouped:**
| Family | IDs |
|---|---|
| Neutral dark | obsidian (default), gunmetal |
| Brown / warm | espresso, volcanic-brown |
| Orange / amber | copper, gold |
| Red | oxblood, crimson |
| Purple | elderflower, plum |
| Pink / magenta | steel-pink, fuchsia-noir |
| Green | matrix, sage |
| Blue | cobalt, midnight-sapphire |
| Light-only | ivory, chalk |
**Dark anchors (bg, card, border, muted-fg, accent):**
```
obsidian #0c0c0e #15151a #1f1f23 #6b6b75 #8b5cf6
gunmetal #0d1117 #161b22 #21262d #7d8590 #388bfd
espresso #1c1410 #241a14 #2e2218 #8a7058 #c8a880
volcanic-brown #140906 #1e0e0a #2e1610 #7a4030 #cc4a1a
copper #100800 #1c1408 #2e1f0a #8a6040 #b87333
gold #0e0800 #1a1200 #2a1f00 #a07c30 #d4af37
oxblood #0a0303 #180606 #2a0808 #7a3028 #8b1a1a
crimson #0e0404 #1a0808 #2e0a0a #8a3030 #dc143c
elderflower #100818 #1c1024 #2c1830 #8a78a0 #b89cd8
plum #0c0814 #180e20 #241830 #7a4878 #8e4585
steel-pink #0e0408 #1a080e #2e0c1a #9a4070 #cc33aa
fuchsia-noir #0a0610 #14081a #2a0c2e #8a3878 #ff1493
matrix #000a00 #031403 #0a200a #208030 #00ff41
sage #0a0e08 #141a10 #1e2e1a #7a8870 #9caf88
cobalt #020817 #061434 #0c2244 #3060a0 #0047ab
midnight-sapphire #02050e #060c1f #0e1a36 #4a6088 #1e3a8a
ivory #fdfcf8 #f5f2e8 #e8e4d8 #8a8478 #3a3328 (light-only)
chalk #fafaf7 #f0f0ec #e5e5e0 #75756e #2a2a28 (light-only)
```
**Light-variant derivation (for the 16 dark themes):**
- Lightest anchor → background
- Accent darkens ~15% (HSL L 15pp)
- Foreground = near-black tinted toward family hue
- Surfaces / borders scale up symmetrically
**Fallback:** `ivory` or `chalk` + dark mode → `obsidian` dark.
**Token map (shadcn nova set):**
```
background ← anchor 1
card / popover ← anchor 2
border / muted ← anchor 3
muted-foreground ← anchor 4
primary / accent ← anchor 5
foreground ← derived: anchor-5 hue, ~92% L, ~25% S
--destructive ← red family, unchanged across themes
--ring ← per-theme accent
--radius ← 0.5rem locked
fonts ← Inter + JetBrains Mono locked
```
**Wiring locked:**
- Schema: `settings.theme_id TEXT NOT NULL DEFAULT 'obsidian'`, `settings.theme_mode TEXT NOT NULL DEFAULT 'dark' CHECK IN ('dark','light','system')`
- API: GET `/api/settings` extended, PATCH whitelists 18 theme ids → 400 otherwise
- CSS: `apps/web/src/styles/themes/*.css` (18 + `_tokens.css`), imported from `globals.css` (NOT `index.css`)
- `.theme-<id>` + `.theme-<id>.dark` composed on `<html>`
- `apps/web/src/lib/theme.ts` (new): `THEMES` const, `applyTheme(id, mode)`, `useTheme()` hook. matchMedia subscribed only when `mode === 'system'`
- `apps/web/src/App.tsx`: `useTheme()` at top
- Settings page: card grid, mode toggle (radio: Dark/Light/System). No header dropdown.
- shadcn primitives: `card`, `radio-group` installed via `pnpm dlx shadcn@latest add`. `button`, `label` already present.
- FOUC mitigation: localStorage cache + inline `<script>` in `index.html` sets `<html>` class before React hydrates
**Out of scope (v1):**
- Custom user palettes (no color picker)
- Per-project / per-session themes
- Shiki syntax-highlighting themes
- Header quick-switcher
**Verify after Claude Code hands back:**
- `fuchsia-noir` and `midnight-sapphire` visual check — derived, not from preview. Swap hexes if they read wrong.
- Light variants of the 16 dark themes — algorithmic. Spot-check 3-4 across families (warm/cool/dark/saturated).
- FOUC on hard reload, theme-switch persistence, system-mode matchMedia teardown.
## Batch summary
|Batch |Theme |Status |Branch / Notes |
|------------------------------------------|-----------------------------------------------------------------------------------|-----------|---------------------------------------|
|1 |Markdown, Copy + Regen, tok/s + ctx, AI naming |✅ Done |`v1.1-batch1` merged |
|2 |Sidebar restructure |✅ Done |`v1.1-batch2` merged |
|3 |Pane system, FileBrowserPane + Shiki, cross-tab |✅ Done |`v1.1-batch3` merged |
|3.5 |Chip infrastructure, `@file`, line-select |✅ Done |merged |
|4 (v1.2) |Chats inside sessions, right-rail, `/compact`, archive, force-send |✅ Done |merged |
|4.14.4 |Project archive, sidebar context, Gitea API, bootstrap |✅ Done |merged |
|v1.5 cleanup |resolveProjectPath, BOOTSTRAP_ROOT, vitest pin |✅ Done |merged |
|v1.6 mobile |Drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close |✅ Done |merged |
|v1.6.1 |RightRail mobile wrapper fix |✅ Done |merged |
|Tool-loop bump |MAX_TOOL_LOOP_DEPTH 5→15 |✅ Done |merged |
|v1.6.2 |Workspace + Session+Project headers + ChatTabBar new-chat + RightRail mobile drawer|🔄 In flight|`v1.6.2-mobile-ui-fixes` |
|**v1.8 mobile tabs** |**Bottom-sheet pane switcher + cross-tab `pane_status` WS sync + StatusDot on tabs**|**Next up**|`v1.8-mobile-tabs`; hand-rolled sheet |
|9 (REORDERED, DECOUPLED) |Agents (Tier 2): `AGENTS.md`, per-agent temp/tools, picker in ChatInput toolbar |✅ Implemented, uncommitted|six builtins; on `main` awaiting commit|
|5 |Fork message, delete message, header polish |Planned | |
|6 |Drag-drop file + paste-as-attachment |Planned |thin extension of 3.5 chips |
|7 |Settings drawer: system prompt, web search toggle, agent entry |Planned |adds SettingsDrawer agent entry (Batch 9 deferred half) |
|8 |Web search backend: SearXNG `web_search` + `web_fetch` |Planned | |
|10 |BooTerm: separate container, xterm.js + node-pty + tmux |Planned | |
|11 — Architect: codebase map |codecontext sidecar + MCP tool wiring |Planned |from nmakod/codecontext |
|11b — Architect: repo health |call graph, circular deps, dead code |Planned |from spirituslab/codesight |
|12 — Tool approval + plan/act mode |Read-only invariant, per-tool gating |Planned |from cline |
|13 — Append-only event log |Replace messages-table semantics |Planned |from OpenHands V1 |
|14 — BooCoder: pending changes |Sandboxed edit queue, atomic apply |Post-v1.x |from plandex |
|15 — BooCoder runtime isolation |Per-session Docker sandbox |Post-v1.x |from OpenHands |
|16 — Multi-provider LLM |Optional litellm-style abstraction |Optional |from pi-ai |
|17 — Workflow graphs |Multi-agent coordination |Far future |from microsoft/agent-framework concepts|
**Old Batch 12 (codebase indexer w/ Harrier embeddings) — REMOVED.** Replaced by Batch 11/11b sidecar approach. See `boocode_code_review.md` decisions log.
**Batch 9 reordered ahead of 58, 10.** Picker mounts in `ChatInput.tsx` toolbar only. SettingsDrawer agent entry rolled into Batch 7 when it lands. No UI dependency on Batches 5/6/7, so it can ship anytime after v1.6.2.
-----
## Batch details (planned / new)
### Batch 9 — Agents (Tier 2, DECOUPLED)
**Spec:** `boocode_batch9.md` with the deltas below.
**Status:** Next up after v1.6.2 merges. Decoupled from Batch 7.
**Deltas from `boocode_batch9.md`:**
1. Builtin defaults in `agents.ts` OMIT the `model` field. Resolution order makes `session.model` win when `agent.model` is null. Spec line 30 example is misleading — do not hardcode any model in builtins.
2. Builtin defaults are the six agents shipped in `/opt/boocode/AGENTS.md`: **Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder.** If project root `AGENTS.md` exists, only its agents show. If absent, show the six builtins.
3. AgentPicker mounts in `ChatInput.tsx` toolbar between ModelPicker and the `+` button. **No `SettingsDrawer.tsx` or `Header.tsx` changes in this batch.**
4. SettingsDrawer agent entry + Header active-agent badge moved to Batch 7.
**Files to create:**
- `apps/server/src/services/agents.ts` — parser, six builtin defaults, mtime-keyed cache.
- `apps/server/src/routes/agents.ts``GET /api/projects/:id/agents`.
- `apps/web/src/components/AgentPicker.tsx` — dropdown, matches ModelPicker pattern.
**Files to modify:**
- `apps/server/src/schema.sql``ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;`
- `apps/server/src/services/inference.ts` — resolution order: `effective_system_prompt`, `effective_model`, `effective_temperature`, `effective_tools` from session + agent + project. Filter tools array against agent whitelist before sending to llama-swap.
- `apps/server/src/routes/sessions.ts` — PATCH accepts `agent_id`.
- `apps/server/src/types/api.ts` — Agent type, extend Session with `agent_id`.
- `apps/web/src/api/client.ts`, `apps/web/src/api/types.ts` — Agent type, `api.agents.list(projectId)`.
- `apps/web/src/components/ChatInput.tsx` — mount AgentPicker.
**Testing plan (manual, before locking temps):**
- Drop `/opt/boocode/AGENTS.md` (six agents, no `model` field on any).
- For each of the 7 keeper models, switch session model and run the same target prompt against each agent. Log tok/s, instruction-following quality.
- Adjust per-agent temperature in `AGENTS.md` based on results.
- A/B candidates: qwen3.6-35b-a3b-mxfp4 (daily), qwopus3.6-35b-a3b-q4 (reasoning), qwopus3.5-27b-q4, qwen3.6-27b-ud-q4-xl, nemotron-3-nano-30b, gemma-4-26b-a4b-mxfp4, qwen3-coder-30b-apex.
**Dependencies:** v1.6.2 merged.
-----
### Batch 11 — Architect: codebase map (REVISED)
**Inspiration / lift:** `nmakod/codecontext` (MIT, Go binary).
**What it gives BooCode:** an architect-grade codebase overview without embeddings. Codecontext parses the repo with tree-sitter, extracts symbols, builds import/dependency relationships, and exposes the result via an MCP server with 8 tools. The model gets a structural map of any codebase on demand.
**Why this replaces the original Batch 11 (aider PageRank port):** codecontext is a finished binary in our stack language (Go), with watch mode, incremental updates, framework detection, and git-co-change-based semantic neighborhoods (no embeddings). The aider port would be reimplementing what codecontext already ships.
**Scope:**
- Add `codecontext` sidecar container to `docker-compose.yml`. Mount the project root read-only. One sidecar per BooCode instance — projects are addressed by absolute path.
- Wire each codecontext MCP tool into BooCodes `inference/tools.ts` as a native tool the model can call:
- `repo_overview(project_id)` → codecontext `get_codebase_overview`
- `repo_file_analysis(project_id, path)``get_file_analysis`
- `repo_symbol_info(project_id, symbol)``get_symbol_info`
- `repo_search_symbols(project_id, query)``search_symbols`
- `repo_dependencies(project_id, path)``get_dependencies`
- `repo_semantic_neighborhoods(project_id, path)``get_semantic_neighborhoods` (git co-change)
- `repo_framework_analysis(project_id)``get_framework_analysis`
- `path_guard.ts` extension: incorporate `continuedev/continue` `DEFAULT_SECURITY_IGNORE_FILETYPES` so codecontext cant surface `.env`, `.pem`, keys, etc.
- Fallback grammars: drop `Aider-AI/aider`s `aider/queries/tree-sitter-*.scm` files for any language codecontext doesnt cover. Use them via an in-process tree-sitter wrapper *only if* a project needs an unsupported language. Defer wrapper build until thats an actual gap.
**Where it goes:** new `apps/server/src/architect/` directory. No new tables — codecontext maintains its own state on disk. New env: `CODECONTEXT_URL=http://codecontext:8765` (MCP endpoint).
**Decisions to make at recon time:**
- Bundle the binary directly in the BooCode Dockerfile, or run codecontext as its own service? Sidecar is cleaner. Bundle is one less container.
- How does the model discover codecontext tools — register them statically in the tools registry, or proxy MCP `tools/list` at startup?
**Dependencies:** none. Can ship before Batches 510.
-----
### Batch 11b — Architect: repo health (NEW)
**Inspiration / lift:** `spirituslab/codesight` (MIT-ish, TS/Node).
**What it gives BooCode:** complement to Batch 11. Where codecontext answers “what is this codebase,” repo health answers “whats wrong with this codebase.” Call graph, circular dependency detection, dead code flagging.
**Scope:**
- Port codesights `analyze.mjs` analyzer core into `apps/server/src/architect/repo_health.ts`. Drop the VS Code extension shell. Keep:
- Symbol extraction (already overlaps codecontext — call codecontext where possible, only redo whats needed for graph edges).
- Call graph builder (function-to-function edges).
- Circular dependency detector.
- Dead code detector (exported symbols never imported or called).
- New tool: `repo_health(project_id)` returning `{ circular_dependencies: [...], dead_code: [...] }`. Output respects codesights documented false-positive caveats (customElements.define, framework entry points, dynamic imports) — surface those in the tool description so the model doesnt trust dead-code flags blindly.
- Cache results in `boocode_db` keyed by `(project_id, file_hashes)`. Invalidate on file change via file-index hash check.
**Decisions:**
- Build it in-process (Node) vs spawn a CLI? In-process is simpler. Spawn matches codecontext sidecar pattern but adds latency.
**Dependencies:** Batch 11 merged (so we can reuse codecontexts parse output where possible). Can be deferred until after Batches 510.
-----
### Batch 12 — Tool approval gating + plan/act mode
**Inspiration / lift:** `cline/cline` (Apache-2.0).
**What it gives BooCode:** per-session control over which tools the model can call. Lays the groundwork for BooCoder by building the gating mechanism before there are any write tools to gate.
**Scope:**
- New column `sessions.tool_approval_mode TEXT` — values: `read_only` (v1.x default), `plan`, `act_auto`, `act_approve`.
- New column `sessions.approved_tools JSONB` — per-session whitelist for `act_approve` mode.
- Tool registry refactor: tools tagged `read_only` or `write`. In `read_only` mode (v1.x), write tools never appear in the models tools array. In `plan` mode, same — write tools hidden, model produces a plan only. `act_*` modes unlock writes (post-v1.x).
- UI: mode picker in SettingsDrawer (Batch 7 dependency). Inline indicator in chat header.
**Dependencies:** Batch 7 (SettingsDrawer).
-----
### Batch 13 — Append-only event log
**Inspiration / lift:** `OpenHands/OpenHands` V1 (MIT).
**What it gives BooCode:** replaces the ad-hoc `messages` table semantics with a typed event stream. Unlocks rewind, time-travel, and clean handoff semantics for multi-agent flows.
**Scope:**
- New `session_events` table: `(id, session_id, ts, kind, payload JSONB, parent_id)`. Event kinds: `user_message`, `assistant_message`, `tool_call`, `tool_result`, `pane_action`, `mode_change`, `system`.
- Existing `messages` table becomes a derived view over `session_events` for backward compatibility, then deprecated over a release.
- Inference loop emits events instead of mutating message rows.
- Frontend `useSessionStream` reducer rewritten to consume events.
**Migration is non-trivial.** Plan in a dedicated batch with explicit cutover window.
**Dependencies:** Batches 5 (fork/delete) and 7 (settings) merged. Must not be in flight with other backend work.
-----
### Batch 14 — BooCoder: pending changes
**Inspiration / lift:** `plandex-ai/plandex` (MIT).
**What it gives BooCode:** safe write tools. Edits queue in a virtual layer; nothing touches the filesystem until explicit `/apply`.
**Scope:**
- New container `boocoder` at `100.114.205.53:9502`. Owns write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`).
- New table `pending_changes (id, session_id, file_path, diff TEXT, status, created_at)`. Status: `pending`, `applied`, `rejected`.
- Tools execute against the pending-changes layer, not the filesystem. `apply_pending` is the only path that touches disk. `rewind` rolls back a `pending`-id back to disk state.
- BooCode chat container stays read-only (`/opt:/opt:ro`). BooCoder mounts `/opt/repos:/opt/repos:rw` and uses git worktree pattern from paseo for isolation.
- Frontend: new pane kind `pending_diff` shows the queued diff inline with Approve/Reject per chunk.
**Dependencies:** Batches 12 (gating) + 13 (events). Dont start until both are live.
-----
### Batch 15 — BooCoder runtime isolation
**Inspiration / lift:** `OpenHands/OpenHands` (MIT).
**What it gives BooCode:** per-session Docker sandbox for BooCoder writes. Closes the `/opt:ro` mount risk identified in v1.x open items.
**Scope:**
- Per-session container spawned by BooCoder on first write. Container has only the projects path mounted, not `/opt`.
- Container lifecycle: spawn on first write call, idle-timeout after 30 min, recreate on resume.
- Action execution server pattern: HTTP API inside the container, BooCoder calls in. Standard OpenHands runtime contract.
**Dependencies:** Batch 14.
-----
### Batch 16 — Multi-provider LLM abstraction
**Inspiration / lift:** `earendil-works/pi` `pi-ai` (MIT).
**What it gives BooCode:** optional non-llama-swap inference paths (Anthropic, OpenAI, Mistral direct). Currently we have one provider (llama-swap) and the existing `streamCompletion` is hardcoded to OpenAI-compatible at that endpoint.
**Scope:**
- Provider abstraction: `interface LLMProvider { stream(req): AsyncIterator<Frame> }`.
- Built-in: llama-swap (current), Anthropic, OpenAI (Codex-style).
- Per-session `provider_id` column.
**Status:** **Optional. Skip unless a concrete need surfaces.** llama-swap covers daily driver work.
-----
### Batch 17 — Workflow graphs
**Inspiration / lift:** `microsoft/agent-framework` (MIT) — concepts only.
**What it gives BooCode:** multi-agent coordination. Architect → Coder → Reviewer → Verifier handoffs orchestrated by a YAML-defined workflow.
**Status:** **Far future.** Read agent-frameworks `docs/decisions/` ADRs. Dont port code — Azure/.NET-heavy.
**Dependencies:** Batches 12 (modes), 13 (events). Realistically a v2.x topic.
-----
| Version | Theme | Status |
|---|---|---|
| v1.0 | Initial scaffold, read-only tools, WS streaming | ✅ Merged |
| v1.1-batch1 | Markdown, Copy + Regen, tok/s + ctx, AI naming | ✅ Merged |
| v1.1-batch2 | Sidebar restructure | ✅ Merged |
| v1.1-batch3 | Pane system, FileBrowserPane + Shiki, cross-tab | ✅ Merged |
| v1.1-batch3.5 | Chip infra, `@file`, line-select | ✅ Merged |
| v1.2 | Chats inside sessions, right-rail, `/compact`, archive, force-send | ✅ Merged |
| v1.2-project-ux | Project archive, sidebar context, Gitea API, bootstrap | ✅ Merged |
| v1.3 | Tab-close + chat-archive | ✅ Merged |
| v1.4 | Fork message, delete message, header polish (was original Batch 5) | ✅ Merged |
| v1.5 | resolveProjectPath, BOOTSTRAP_ROOT, vitest pin | ✅ Merged |
| v1.5.1 | Bootstrap hotfix (git in container, SSH keypair, known_hosts) | ✅ Merged (`4a9f207`) |
| v1.6 | Mobile pass: drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close | ✅ Merged |
| v1.6.1 | RightRail mobile wrapper fix | ✅ Merged |
| Tool-loop bump | MAX_TOOL_LOOP_DEPTH 5→15 | ✅ Merged |
| v1.6.2 | Workspace + Session+Project headers, ChatTabBar new-chat, RightRail mobile drawer | ✅ Merged |
| v1.7 | Drag-drop file + paste-as-attachment (was Batch 6) | ✅ Merged |
| v1.8 | Settings drawer + `git_status` added to ALL_TOOL_NAMES (was Batch 7) | ✅ Merged |
| v1.8.1 | WS reconnect toast tuning (silent/gray/red thresholds), pane status indicators | ✅ Merged |
| v1.8.2 | Tool-loop fixes: read-only cap raised, "depth exceeded" error + continue, `max_tool_calls` frontmatter, `messages.metadata` | ✅ Merged |
| **v1.x-themes** | **18 themes, settings page, dark/light/system, FOUC mitigation** | **🔄 Claude Code in flight** |
| v1.8.3 | Tool call UI compaction: collapse-by-default, group consecutive same-tool, result preview cap | Planned (small, frontend-only) |
| v1.9 | Settings pane (system prompt per project + session, web search toggle, `+` button) | Planned (spec locked, was on branch `v1.9-settings-pane`) |
| v1.10 | Web search backend: SearXNG `web_search` + `web_fetch` | Planned |
| v1.11 | Agents Tier 2: `AGENTS.md`, per-agent temp/tools whitelist, AgentPicker in ChatInput | Planned |
| v1.12 | BooTerm: separate container, xterm.js + node-pty + tmux | Planned |
| v1.13 | Architect: codecontext sidecar (MCP, tree-sitter, no embeddings) | Planned |
| v1.13b | Architect: repo health (call graph, circular deps, dead code) | Planned |
| v1.14 | Tool approval + plan/act mode (cline-style) | Planned |
| Post-v1.x | Append-only event log (OpenHands V1) | Planned |
| Post-v1.x | BooCoder pending-changes (plandex) | Planned |
| Post-v1.x | BooCoder runtime isolation (per-session Docker sandbox) | Planned |
| Optional | Multi-provider LLM abstraction (pi-ai) | Skip unless need surfaces |
| Far future | Workflow graphs (microsoft/agent-framework concepts) | v2.x topic |
## Flagged follow-ups (not in a batch yet)
- Agents in `/data/AGENTS.md` don't list `git_status` in their `tools:` blocks. Out of scope until pre-BooCoder cleanup pass.
- v1.9 dispatch had item (g): verify `useUserEvents` broadcasts `project_updated` on PATCH `/projects/:id`. Add if missing.
- v1.8.2 follow-up: confirm `messages.metadata` migration ran clean in prod DB after deploy.
## Order of operations
Two tracks. Pick one to drive next.
1. **v1.x-themes** finishes (Claude Code in flight). Audit + smoke test. Merge.
2. **v1.8.3** — tool call UI compaction. Small frontend batch, addresses current pain.
3. **v1.9** — settings pane. Branch already named `v1.9-settings-pane`. Spec locked.
4. **v1.10** — web search backend.
5. **v1.11** — agents.
6. **v1.12** — BooTerm.
**Track A — Finish v1.x mobile + polish then agents:**
- v1.6.2 ships (in flight)
- **Batch 9 (agents)** — decoupled, can land next; no UI dependency on 5/6/7
- Batches 5, 6, 7, 8 in order. Each is small, frontend-heavy, no architecture risk. Batch 7 absorbs SettingsDrawer agent entry.
**Track B — Begin architect capabilities in parallel:**
- Batch 11 (codecontext sidecar) — biggest single capability jump. Frontend stays the same; new tools appear to the model.
- Batch 11b (repo health) — follow-up.
- Batch 12 (gating) — sets up everything post-v1.x.
Recommendation: ship v1.6.2, then **Batch 9 (agents)** next so the test bed exists before Track A continues. Then Track A through Batch 7. Batch 11 can run in parallel with Batches 810 since 11 has no UI dependency.
-----
Track B (architect, no UI dep, can run parallel anytime): v1.13 → v1.13b → v1.14.
## Architecture target state
### Containers
| Container | Port | Mount | Purpose | Status |
|---|---|---|---|---|
| `boocode` | `100.114.205.53:9500` | `/opt:/opt:ro` | Chat + read-only tools + SPA | Live |
| `boocode_db` | `127.0.0.1:5500` | `boocode_pgdata` volume | Postgres 16-alpine | Live |
| `codecontext` | `100.114.205.53:8765` (internal) | project root :ro | MCP server for architect tools | v1.13 |
| `booterm` | `100.114.205.53:9501` | `/opt/repos:/opt/repos:rw` | Terminals (tmux + node-pty) | v1.12 |
| `boocoder` | `100.114.205.53:9502` | per-session sandbox | Write tools | Post-v1.x |
|Container |Port |Mount |Purpose |Status |
|-------------|--------------------------------|--------------------------|------------------------------|--------|
|`boocode` |`100.114.205.53:9500` |`/opt:/opt:ro` |Chat + read-only tools + SPA |Live |
|`boocode_db` |`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine |Live |
|`codecontext`|`100.114.205.53:8765` (internal)|project root :ro |MCP server for architect tools|Batch 11|
|`booterm` |`100.114.205.53:9501` |`/opt/repos:/opt/repos:rw`|Terminals (tmux + node-pty) |Batch 10|
|`boocoder` |`100.114.205.53:9502` |per-session sandbox |Write tools |Batch 14|
## Schema additions ahead
### Schema additions
**Batch 9:** `sessions.agent_id TEXT` (nullable; references AGENTS.md by slug).
**Batch 11:** none (codecontext stateless on disk).
**Batch 11b:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`.
**Batch 12:** `sessions.tool_approval_mode`, `sessions.approved_tools`.
**Batch 13:** `session_events`; deprecate `messages` long-tail.
**Batch 14:** `pending_changes`.
-----
## Lift sources (summary)
Full inventory in `boocode_code_review.md`. Headline items:
|Source |Used for |Where |
|--------------------------------------|----------------------------------------|---------------------|
|nmakod/codecontext (MIT, Go) |Architect: codebase map sidecar |Batch 11 |
|spirituslab/codesight (MIT-ish, TS) |Architect: repo health analyzer |Batch 11b |
|Aider-AI/aider (Apache-2.0) |Fallback `.scm` grammars (60+ languages)|Batch 11 (fallback) |
|continuedev/continue (Apache-2.0) |DEFAULT_SECURITY_IGNORE_FILETYPES |Batch 11 prep |
|cline/cline (Apache-2.0) |Plan/Act mode pattern |Batch 12 |
|plandex-ai/plandex (MIT) |Pending-changes data model |Batch 14 |
|OpenHands/OpenHands (MIT) |Event log + sandbox runtime |Batches 13, 15 |
|aimasteracc/tree-sitter-analyzer (MIT)|Outline-first response patterns |Reference |
|earendil-works/pi (MIT) |Multi-provider LLM |Batch 16 (optional) |
|rshah515/claude-code-subagents (MIT) |Reference for builtin agent prompts |Batch 9 (six builtins)|
|microsoft/agent-framework (MIT) |Workflow concepts |Batch 17 (far future)|
-----
- v1.x-themes (current): `settings.theme_id`, `settings.theme_mode`
- v1.9: `projects.default_system_prompt`, `projects.default_web_search_enabled`, `sessions.web_search_enabled`
- v1.11: `sessions.agent_id`
- v1.13b: `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
- v1.14: `sessions.tool_approval_mode`, `sessions.approved_tools`
- Post-v1.x: `session_events`; deprecate `messages` long-tail
- Post-v1.x: `pending_changes`
## Decisions log
- **Embeddings dropped from BooCode.** Replaced RAG with file-view tools + sidecar analyzers.
- **Original Batch 11 (aider PageRank port) replaced** by codecontext sidecar approach.
- **Original Batch 12 (codebase indexer w/ Harrier) removed** entirely. No embedding infrastructure in BooCode v1.x.
- **Globstar parked** — not an architect tool, future verify-before-commit candidate only.
- **codeprysm rejected** — embedding-based; node/edge taxonomy noted as reference if we ever build our own graph.
- **Batch 9 decoupled from Batch 7 (2026-05-16).** AgentPicker mounts in `ChatInput.tsx` toolbar only. SettingsDrawer agent entry and Header active-agent badge moved to Batch 7. Builtin defaults shipped: six agents (Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder) with no `model` field — session model wins by default.
## Follow-ups (post-ship docs / cleanup)
- **After v1.8.2 ships:** Add explicit `max_tool_calls: 30` to all 6 agents in `/data/AGENTS.md` and `/opt/boocode/AGENTS.md`. Purely for documentation/discoverability — defaults handle behavior identically (all 6 agents use only read-only tools, default is already 30).
-----
- Embeddings dropped from BooCode. File-view tools + sidecar analyzers replace RAG.
- Old Batch 11 (aider PageRank port) replaced by codecontext sidecar (v1.13).
- Old Batch 12 (Harrier indexer) → removed entirely.
- Batch 9 reordered ahead of 58, decoupled from Batch 7 (2026-05-16). Subsequently superseded — settings pane (v1.9) and themes (v1.x-themes) jumped ahead. Agents now slated as v1.11.
- Theme work split into its own version (v1.x-themes) rather than blocked behind v1.9 (2026-05-17). Branched off main after v1.8.2 committed.
## Workflow
Each batch:
1. Verify previous batch merged.
2. Dispatch via Paseo to Claude Code at `/opt/boocode`.
3. Claude Code recon → blocking questions → implement → hand back.
1. Verify previous merged.
2. Dispatch via Paseo to Claude Code at `/opt/boocode` (or OpenCode for smaller batches).
3. Recon → blocking questions → implement → hand back.
4. Compliance review in separate Claude chat.
5. Deploy: `docker compose up --build -d`.
6. Smoke test.

View File

@@ -9,15 +9,32 @@ services:
environment:
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
volumes:
# Read-only mount for legacy/existing project add-existing flow.
- /opt:/opt:ro
# Writable mount only for the create-new-project bootstrap target.
# Host must `mkdir -p /opt/projects` before container start.
- /opt:/opt
- /opt/projects:/opt/projects:rw
- ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro
# v1.8.1: global agents file. Host seeds it once before deploy:
# cp /opt/boocode/AGENTS.md /opt/boocode/data/AGENTS.md
- ./data:/data:ro
- ./data:/data
- /opt/skills:/data/skills
depends_on:
- boocode_db
networks:
- 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:

191
pnpm-lock.yaml generated
View File

@@ -12,6 +12,40 @@ importers:
specifier: ^5.5.0
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:
dependencies:
'@fastify/static':
@@ -102,6 +136,15 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
xterm:
specifier: ^5.3.0
version: 5.3.0
xterm-addon-fit:
specifier: ^0.8.0
version: 0.8.0(xterm@5.3.0)
xterm-addon-web-links:
specifier: ^0.9.0
version: 0.9.0(xterm@5.3.0)
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.3.0
@@ -1727,6 +1770,9 @@ packages:
'@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -2964,6 +3010,9 @@ packages:
react: ^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:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -2973,6 +3022,9 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
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:
resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
@@ -3079,6 +3131,40 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
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:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3112,6 +3198,22 @@ packages:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
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:
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
engines: {node: '>=12'}
@@ -3797,6 +3899,26 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
xterm-addon-fit@0.8.0:
resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==}
deprecated: This package is now deprecated. Move to @xterm/addon-fit instead.
peerDependencies:
xterm: ^5.0.0
xterm-addon-web-links@0.9.0:
resolution: {integrity: sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==}
deprecated: This package is now deprecated. Move to @xterm/addon-web-links instead.
peerDependencies:
xterm: ^5.0.0
xterm@5.3.0:
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -5380,6 +5502,12 @@ snapshots:
dependencies:
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/react-dom@18.3.7(@types/react@18.3.28)':
@@ -6817,6 +6945,8 @@ snapshots:
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-fetch@3.3.2:
@@ -6825,6 +6955,10 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
node-releases@2.0.44: {}
npm-run-path@4.0.1:
@@ -6935,6 +7069,41 @@ snapshots:
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: {}
picomatch@2.3.2: {}
@@ -6974,6 +7143,16 @@ snapshots:
picocolors: 1.1.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: {}
powershell-utils@0.1.0: {}
@@ -7782,6 +7961,18 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xtend@4.0.2: {}
xterm-addon-fit@0.8.0(xterm@5.3.0):
dependencies:
xterm: 5.3.0
xterm-addon-web-links@0.9.0(xterm@5.3.0):
dependencies:
xterm: 5.3.0
xterm@5.3.0: {}
y18n@5.0.8: {}
yallist@3.1.1: {}