Repository Public
1/** Terminal session REST endpoints. Claude CLI arguments are built here from validated fields only. */2import type { FastifyInstance } from 'fastify';3import { z } from 'zod';4import { CONVERSATION_ID_RE, ClaudeConversation, CreateSession, Id, type AgentId, TerminalSession, UpdateSession, type RtSessionInfo, type RtSessionSpec } from '@cr/shared';5import { HttpError, authOf, notFound, parse, type Ctx } from '../../http.js';6import { absUnder, rootByLabel, runtimeToHttp, validateRel } from '../../paths.js';7import { RuntimeError } from '../../runtime-client.js';8import { SESSION_SELECT, isLive, type SessionRow } from './service.js';910/**11 * Conversation ids are placed right after `--resume` in the claude argv, so they must never be12 * parseable as an option: the first character has to be alphanumeric (a bare `[A-Za-z0-9-]{8,64}`13 * would accept `--dangerously-skip-permissions`).14 */15export const CONVERSATION_ID = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;16const IdParams = z.object({ id: Id });17const ListQuery = z.object({ archived: z.enum(['0', '1', 'true', 'false']).optional() });18const ReconnectBody = z19 .object({20 restart: z.boolean().optional(),21 cols: z.number().int().min(20).max(500).default(120),22 rows: z.number().int().min(5).max(200).default(32),23 })24 .default({ cols: 120, rows: 32 });2526/**27 * The ONLY place agent CLI arguments are produced. The browser can never pass raw CLI args.28 * `kind: 'claude'` is the wire name of "the instance's agent"; `agent` says which CLI that is:29 * claude → `--resume <id>` / `--continue` codex → `resume <uuid>` / `resume --last`30 * (Codex session ids are UUIDs: a session *name* or an option can never be passed.)31 */32export function buildClaudeArgs(kind: 'claude' | 'shell', o: { resumeConversationId?: string | null; continueLast?: boolean }, agent: AgentId = 'claude'): string[] {33 if (kind !== 'claude') {34 if (o.resumeConversationId || o.continueLast) throw new HttpError(400, 'invalid_request', `resume/continue only apply to ${agent} sessions`);35 return [];36 }37 if (o.resumeConversationId && o.continueLast) throw new HttpError(400, 'invalid_request', 'choose either resume or continue');38 if (o.resumeConversationId) {39 if (!CONVERSATION_ID_RE[agent].test(o.resumeConversationId)) throw new HttpError(400, 'invalid_request', 'invalid conversation id');40 return agent === 'codex' ? ['resume', o.resumeConversationId] : ['--resume', o.resumeConversationId];41 }42 if (!o.continueLast) return [];43 return agent === 'codex' ? ['resume', '--last'] : ['--continue'];44}4546export async function projectDir(ctx: Ctx, ownerId: string, projectId: string | null): Promise<{ abs: string; name: string | null }> {47 if (!projectId) return { abs: rootByLabel(ctx.config).path, name: null };48 const p = await ctx.db.one<{ name: string; root_label: string; rel_path: string }>(49 'SELECT name, root_label, rel_path FROM projects WHERE id = $1 AND owner_id = $2', [projectId, ownerId],50 );51 if (!p) throw notFound('project');52 return { abs: absUnder(rootByLabel(ctx.config, p.root_label).path, validateRel(p.rel_path)), name: p.name };53}5455export default async function sessionsPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {56 const { db, rt, audit } = ctx;57 const svc = ctx.sessions;58 const owned = async (id: string, ownerId: string): Promise<SessionRow> => {59 const row = await svc.owned(id, ownerId);60 if (!row) throw notFound('session');61 return row;62 };6364 /** Starts the runtime process for a row in STARTING and records the outcome. */65 async function start(row: SessionRow, args: string[], cols: number, rows: number): Promise<SessionRow> {66 const spec: RtSessionSpec = { id: row.id, kind: row.kind, cwd: row.cwd_abs, args, env: {}, cols, rows };67 let info: RtSessionInfo;68 try {69 info = await rt.json<RtSessionInfo>('POST', '/sessions', spec);70 } catch (err) {71 if (err instanceof RuntimeError && err.status === 409) {72 // Already running under this id (e.g. restart raced with the reconciler): adopt it.73 info = await rt.json<RtSessionInfo>('GET', `/sessions/${row.id}`).catch(runtimeToHttp);74 } else {75 await svc.transition(row.id, 'FAILED', {}, { source: 'start', reason: err instanceof RuntimeError ? err.code : 'error' });76 return runtimeToHttp(err);77 }78 }79 svc.attached.set(row.id, info.attached ?? 0);80 const next = await svc.transition(row.id, (info.attached ?? 0) > 0 ? 'RUNNING' : 'DETACHED', { pid: info.pid ?? null, lastActivityMs: Date.now() }, { source: 'start' });81 return next ?? row;82 }8384 app.get('/sessions', { config: { doc: { summary: 'List terminal sessions', query: ListQuery, response: z.array(TerminalSession), responseName: 'TerminalSessionList' } } }, async (req) => {85 const a = authOf(req);86 const q = parse(ListQuery, req.query);87 const archived = q.archived === '1' || q.archived === 'true';88 const rows = await db.q<SessionRow>(89 `${SESSION_SELECT} WHERE s.owner_id = $1 AND s.archived = $2 ORDER BY s.pinned DESC, coalesce(s.last_activity_at, s.created_at) DESC LIMIT 500`,90 [a.userId, archived],91 );92 return rows.map((r) => svc.dto(r));93 });9495 app.post('/sessions', { config: { doc: { summary: 'Create and start a terminal session', body: CreateSession, bodyName: 'CreateSession', response: TerminalSession, responseName: 'TerminalSession' } } }, async (req, reply) => {96 const a = authOf(req);97 const body = parse(CreateSession, req.body);98 const args = buildClaudeArgs(body.kind, body, ctx.config.agent.id);99 const dir = await projectDir(ctx, a.userId, body.projectId);100 const rel = validateRel(body.cwd);101 const title = body.title?.trim() || `${body.kind === 'claude' ? ctx.config.agent.shortName : 'Shell'}${dir.name ? ` · ${dir.name}` : ''}`;102 const created = await db.withTx(async (tx) => {103 const r = await tx.one<{ id: string }>(104 `INSERT INTO terminal_sessions (owner_id, project_id, kind, title, state, cwd_abs, cwd_rel, claude_conversation_id, launch_args)105 VALUES ($1, $2, $3, $4, 'CREATED', $5, $6, $7, $8) RETURNING id`,106 [a.userId, body.projectId, body.kind, title, absUnder(dir.abs, rel), rel, body.resumeConversationId ?? null, JSON.stringify(args)],107 );108 if (!r) throw new Error('insert failed');109 await tx.q("INSERT INTO terminal_events (session_id, kind, detail) VALUES ($1, 'state', $2)", [r.id, JSON.stringify({ from: null, to: 'CREATED' })]);110 if (body.projectId) await tx.q('UPDATE projects SET last_activity_at = now() WHERE id = $1', [body.projectId]);111 return r.id;112 });113 const starting = await svc.transition(created, 'STARTING', {}, { source: 'create' });114 if (!starting) throw new HttpError(500, 'internal', 'could not start session');115 let row: SessionRow;116 try {117 row = await start(starting, args, body.cols, body.rows);118 } catch (err) {119 await audit.req(req, 'session.create', { target: created, ok: false });120 throw err;121 }122 await audit.req(req, 'session.create', { target: created, detail: { kind: body.kind, projectId: body.projectId } });123 void reply.code(201);124 return svc.dto(row);125 });126127 app.get('/sessions/:id', { config: { doc: { summary: 'Get one session', response: TerminalSession, responseName: 'TerminalSession' } } }, async (req) => {128 const { id } = parse(IdParams, req.params);129 return svc.dto(await owned(id, authOf(req).userId));130 });131132 app.patch('/sessions/:id', { config: { doc: { summary: 'Rename / pin / archive (archive only when not live)', body: UpdateSession, bodyName: 'UpdateSession', response: TerminalSession, responseName: 'TerminalSession' } } }, async (req) => {133 const a = authOf(req);134 const { id } = parse(IdParams, req.params);135 const body = parse(UpdateSession, req.body);136 const row = await owned(id, a.userId);137 if (body.archived === true && (isLive(row.state) || row.state === 'CREATED')) {138 throw new HttpError(409, 'session_live', 'a live session cannot be archived; terminate it first');139 }140 await db.q(141 `UPDATE terminal_sessions SET title = coalesce($2, title), pinned = coalesce($3, pinned), archived = coalesce($4, archived) WHERE id = $1`,142 [id, body.title ?? null, body.pinned ?? null, body.archived ?? null],143 );144 const next = await owned(id, a.userId);145 svc.emit(next);146 await audit.req(req, 'session.update', { target: id, detail: { fields: Object.keys(body) } });147 return svc.dto(next);148 });149150 app.post('/sessions/:id/terminate', { config: { doc: { summary: 'Stop the session process', response: TerminalSession, responseName: 'TerminalSession' } } }, async (req) => {151 const a = authOf(req);152 const { id } = parse(IdParams, req.params);153 const row = await owned(id, a.userId);154 let next: SessionRow | null = row;155 if (row.state === 'RECOVERABLE' || row.state === 'CREATED') {156 next = await svc.transition(id, 'STOPPED', {}, { source: 'user' });157 } else if (isLive(row.state)) {158 // Refuse (503) rather than record a stop we cannot perform.159 await rt.json('GET', '/health', undefined, { timeoutMs: 5000 }).catch(runtimeToHttp);160 const stopping = await svc.transition(id, 'STOPPING', {}, { source: 'user' });161 if (!stopping) throw new HttpError(409, 'invalid_state', 'session cannot be stopped in its current state');162 try {163 await rt.json('DELETE', `/sessions/${id}`);164 } catch (err) {165 if (!(err instanceof RuntimeError) || err.status !== 404) {166 // Stays STOPPING; the reconciler settles it once the runtime answers again.167 await audit.req(req, 'session.terminate', { target: id, ok: false });168 return runtimeToHttp(err);169 }170 }171 next = await svc.transition(id, 'STOPPED', {}, { source: 'user' });172 }173 await audit.req(req, 'session.terminate', { target: id });174 return svc.dto(next ?? (await owned(id, a.userId)));175 });176177 app.post('/sessions/:id/reconnect', { config: { doc: { summary: 'Re-sync with the runtime; {restart:true} restarts a RECOVERABLE session', body: ReconnectBody, bodyName: 'ReconnectSession', response: TerminalSession, responseName: 'TerminalSession' } } }, async (req) => {178 const a = authOf(req);179 const { id } = parse(IdParams, req.params);180 const body = parse(ReconnectBody, req.body ?? undefined);181 let row = await owned(id, a.userId);182 if (isLive(row.state)) {183 try {184 const info = await rt.json<RtSessionInfo>('GET', `/sessions/${id}`, undefined, { timeoutMs: 5000 });185 svc.markRuntime(true);186 row = (await svc.applyInfo(row, info)) ?? row;187 } catch (err) {188 if (err instanceof RuntimeError && err.status === 404) {189 svc.markRuntime(true);190 row = (await svc.applyMissing(row)) ?? row;191 } else {192 if (err instanceof RuntimeError && err.unreachable) svc.markRuntime(false);193 return runtimeToHttp(err); // unreachable ⇒ 503, state untouched194 }195 }196 row = await owned(id, a.userId);197 }198 if (body.restart && row.state === 'RECOVERABLE') {199 // A fresh process in the same cwd; process memory never survives, only the conversation.200 // A shell session can carry a conversation id too (hooks of a `claude` typed inside it):201 // only claude sessions are restarted with --resume / --continue.202 const args = row.kind === 'claude' ? buildClaudeArgs('claude', row.claude_conversation_id ? { resumeConversationId: row.claude_conversation_id } : { continueLast: true }, ctx.config.agent.id) : [];203 await rt.json('GET', '/health', undefined, { timeoutMs: 5000 }).catch(runtimeToHttp);204 const starting = await svc.transition(id, 'STARTING', {}, { source: 'restart' });205 if (!starting) throw new HttpError(409, 'invalid_state', 'session cannot be restarted');206 await db.q('UPDATE terminal_sessions SET launch_args = $2, last_activity_at = now() WHERE id = $1', [id, JSON.stringify(args)]);207 row = await start(starting, args, body.cols, body.rows);208 await audit.req(req, 'session.restart', { target: id });209 }210 return svc.dto(row);211 });212213 app.get('/sessions/:id/snapshot', { config: { doc: { summary: 'Last screen lines (explicit user request only)', response: z.object({ text: z.string() }), responseName: 'SessionSnapshot' } } }, async (req) => {214 const a = authOf(req);215 const { id } = parse(IdParams, req.params);216 const row = await owned(id, a.userId);217 if (!isLive(row.state)) throw new HttpError(409, 'session_not_live', 'session is not running');218 const out = await rt.json<{ text?: unknown }>('GET', `/sessions/${id}/snapshot`).catch(runtimeToHttp);219 await audit.req(req, 'session.snapshot', { target: id });220 return { text: typeof out?.text === 'string' ? out.text : '' };221 });222223 app.get('/projects/:id/conversations', { config: { doc: { summary: 'Claude conversations recorded for the project directory', response: z.array(ClaudeConversation), responseName: 'ClaudeConversationList' } } }, async (req) => {224 const a = authOf(req);225 const { id } = parse(IdParams, req.params);226 const dir = await projectDir(ctx, a.userId, id);227 const list = await rt.json<unknown>('POST', '/claude/conversations', { cwd: dir.abs }).catch(runtimeToHttp);228 return parse(z.array(ClaudeConversation), list);229 });230}231