/** * S-09 — multi-session CRUD routes. * * POST /sessions → { id, name, state, lastOutputAt } * GET /sessions → [{ id, name, description, state, lastOutputAt }] * PATCH /sessions/:id → updates @description * DELETE /sessions/:id → kills tmux session, optionally clears buffer * GET /sessions/:id/thumbnail → text/plain capture-pane (40×12) * * Owner: T-1.6 */ import type { IncomingMessage, ServerResponse } from "node:http"; import { BufferWriter } from "../../buffer/writer.js"; import { getSession, killSession, listSessions, setDescription, spawnSession, } from "../../tmux/manager.js"; import { captureThumbnail } from "../../tmux/snapshot.js"; import { readBody, sendJson } from "../util.js"; export async function handleSessions( req: IncomingMessage, res: ServerResponse, sessionId?: string, sub?: string, ): Promise { const method = req.method?.toUpperCase(); // GET /sessions/:id/thumbnail if (sessionId && sub === "thumbnail" && method === "GET") { const session = await getSession(sessionId).catch(() => null); if (!session) { sendJson(res, 404, { error: "session_not_found" }); return; } const text = await captureThumbnail(sessionId).catch(() => ""); res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); res.end(text); return; } // /sessions/:id — PATCH / DELETE if (sessionId && !sub) { if (method === "PATCH") { await handlePatch(req, res, sessionId); return; } if (method === "DELETE") { await handleDelete(req, res, sessionId); return; } sendJson(res, 405, { error: "method_not_allowed" }); return; } // /sessions — GET / POST if (!sessionId) { if (method === "GET") { await handleList(res); return; } if (method === "POST") { await handleCreate(req, res); return; } sendJson(res, 405, { error: "method_not_allowed" }); return; } sendJson(res, 404, { error: "not_found" }); } async function handleList(res: ServerResponse): Promise { try { const sessions = await listSessions(); const payload = sessions.map((s) => ({ id: s.id, name: s.name, description: s.description, state: "idle", // T-1.4 events will feed real state in Phase 2 lastOutputAt: s.lastActivityAt, })); sendJson(res, 200, payload); } catch (err) { sendJson(res, 500, { error: "internal_error", message: String(err) }); } } async function handleCreate( req: IncomingMessage, res: ServerResponse, ): Promise { let body: Record = {}; try { const raw = await readBody(req); if (raw.trim()) body = JSON.parse(raw) as Record; } catch { sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" }); return; } const name = typeof body.name === "string" && body.name.trim() ? body.name.trim() : `session-${Date.now()}`; try { const id = await spawnSession({ name }); // Include state + lastOutputAt to match the GET /sessions response shape // so iOS clients can decode the response with the same type. sendJson(res, 201, { id, name, state: "idle", lastOutputAt: "" }); } catch (err) { sendJson(res, 500, { error: "internal_error", message: String(err) }); } } async function handlePatch( req: IncomingMessage, res: ServerResponse, sessionId: string, ): Promise { const session = await getSession(sessionId).catch(() => null); if (!session) { sendJson(res, 404, { error: "session_not_found" }); return; } let body: Record = {}; try { const raw = await readBody(req); if (raw.trim()) body = JSON.parse(raw) as Record; } catch { sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" }); return; } if (typeof body.description === "string") { await setDescription(sessionId, body.description); } sendJson(res, 200, { id: sessionId, description: body.description }); } async function handleDelete( _req: IncomingMessage, res: ServerResponse, sessionId: string, ): Promise { const session = await getSession(sessionId).catch(() => null); if (!session) { sendJson(res, 404, { error: "session_not_found" }); return; } try { await killSession(sessionId); // Optionally clear buffer const writer = new BufferWriter(sessionId); await writer.delete().catch(() => {}); // best-effort res.writeHead(204).end(); } catch (err) { sendJson(res, 500, { error: "internal_error", message: String(err) }); } }