import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { getSessionInfo } from '../db.js'; import { sanitizeId, tmuxSessionName, ensureSession, killSession, hasSession, } from '../pty/manager.js'; const ParamsSchema = z.object({ sid: z.string(), pid: z.string() }); // v1.10.8c: optional cols/rows on /start so the per-pane tmux session is // born at the right dimensions. Bodyless POSTs remain valid (Fastify's // tolerant parser). const StartBodySchema = z .object({ cols: z.coerce.number().int().min(1).max(2000).optional(), rows: z.coerce.number().int().min(1).max(2000).optional(), }) .partial() .optional(); export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void { // v1.10.8c: /start creates the per-pane tmux session. Idempotent — a second // /start on the same paneId is a no-op (hasSession returns true). The WS // attach handler also calls ensureSession as belt-and-suspenders, so /start // is technically optional, but having it as a separate step surfaces tmux // errors as HTTP responses (vs WS 1011 close codes). app.post<{ Params: { sid: string; pid: string }; Body: { cols?: number; rows?: number } | undefined; }>( '/api/term/sessions/:sid/panes/:pid/start', async (req, reply) => { const p = ParamsSchema.safeParse(req.params); if (!p.success) return reply.code(400).send({ error: 'bad_params' }); const sid = sanitizeId(p.data.sid); const pid = sanitizeId(p.data.pid); if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' }); const b = StartBodySchema.safeParse(req.body ?? {}); const cols = b.success ? b.data?.cols : undefined; const rows = b.success ? b.data?.rows : undefined; const session = await getSessionInfo(sid); if (!session) return reply.code(404).send({ error: 'unknown_session' }); const sessionName = tmuxSessionName(pid); try { await ensureSession( tmuxConfPath, sessionName, session.project_path, req.log, cols, rows, ); } catch (err) { req.log.error({ err }, 'ensureSession failed'); return reply.code(500).send({ error: 'tmux_failed' }); } return reply.code(200).send({ tmux_session: sessionName }); }, ); // v1.10.8c: explicit pane teardown. Frontend calls this when the user // intentionally closes a terminal pane (vs an implicit WS disconnect, which // leaves the tmux session intact for refresh-driven resume). app.post<{ Params: { sid: string; pid: string } }>( '/api/term/sessions/:sid/panes/:pid/kill', async (req, reply) => { const p = ParamsSchema.safeParse(req.params); if (!p.success) return reply.code(400).send({ error: 'bad_params' }); const sid = sanitizeId(p.data.sid); const pid = sanitizeId(p.data.pid); if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' }); const sessionName = tmuxSessionName(pid); if (!(await hasSession(tmuxConfPath, sessionName))) { return reply.code(404).send({ error: 'unknown_pane' }); } const killed = await killSession(tmuxConfPath, sessionName); if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' }); return reply.code(200).send({ ok: true }); }, ); // Resize endpoint removed in v1.10.8c. Resize now flows in-band via the // WebSocket as a `{type:"resize",cols,rows}` text frame — no more race // between active-PTY-map registration and HTTP POST lookup. See ws/attach.ts. }