Repository Public
1/**2 * WebSocket wire formats.3 *4 * Terminal stream (browser <-> server <-> runtime): /api/v1/sessions/:id/stream?cols=&rows=5 * - BINARY frames: raw PTY bytes (server->client output, client->server input). Never JSON.6 * - TEXT frames: JSON control messages below.7 * Event stream (server -> browser): /api/v1/events — TEXT JSON `ServerEvent` only.8 */9import { z } from 'zod';10import { AgentTask, Notification, TerminalSession } from './dto.js';1112export const TermClientMsg = z.discriminatedUnion('t', [13 z.object({ t: z.literal('resize'), cols: z.number().int().min(20).max(500), rows: z.number().int().min(5).max(200) }),14 z.object({ t: z.literal('ping'), n: z.number() }),15]);16export type TermClientMsg = z.infer<typeof TermClientMsg>;17export const TermServerMsg = z.discriminatedUnion('t', [18 z.object({ t: z.literal('ready'), sessionId: z.string(), cols: z.number(), rows: z.number() }),19 z.object({ t: z.literal('pong'), n: z.number() }),20 z.object({ t: z.literal('exit'), code: z.number().nullable() }), // the session process ended21 z.object({ t: z.literal('error'), code: z.string(), message: z.string() }),22]);23export type TermServerMsg = z.infer<typeof TermServerMsg>;2425/** WS close codes used by the terminal stream. */26export const WS_CLOSE = {27 UNAUTHORIZED: 4401,28 FORBIDDEN: 4403,29 NOT_FOUND: 4404,30 SESSION_ENDED: 4410,31 BACKPRESSURE: 4429,32 RUNTIME_UNAVAILABLE: 4503,33} as const;3435export const ServerEvent = z.discriminatedUnion('type', [36 z.object({ type: z.literal('hello'), serverTime: z.string() }),37 z.object({ type: z.literal('session.updated'), session: TerminalSession }),38 z.object({ type: z.literal('session.removed'), id: z.string() }),39 z.object({ type: z.literal('task.updated'), task: AgentTask }),40 z.object({ type: z.literal('notification'), notification: Notification }),41 z.object({ type: z.literal('auth.expired') }),42]);43export type ServerEvent = z.infer<typeof ServerEvent>;4445// ---------------- runtime IPC (server <-> runtime over a 0600 unix socket) ----------------46// HTTP+JSON and WebSocket on the same unix socket. Every request carries47// `Authorization: Bearer <CR_RUNTIME_TOKEN>` (random, generated at deploy, shared via env).48// All `root` values are ABSOLUTE canonical directories chosen by the server from the49// configured workspace roots / project table; `rel` values are untrusted relative paths50// that the runtime must resolve + realpath + verify stay inside `root` (symlink-safe).5152export const RtSessionSpec = z.object({53 id: z.string().uuid(),54 kind: z.enum(['claude', 'shell']),55 cwd: z.string(), // absolute, already validated by server; runtime re-validates against roots56 args: z.array(z.string()).default([]), // extra CLI args for claude (e.g. ["--resume", id])57 env: z.record(z.string(), z.string()).default({}),58 cols: z.number().int(),59 rows: z.number().int(),60});61export type RtSessionSpec = z.infer<typeof RtSessionSpec>;62export const RtSessionInfo = z.object({63 id: z.string(),64 alive: z.boolean(), // tmux session exists and pane process not dead65 pid: z.number().nullable(),66 attached: z.number(), // attached tmux clients67 lastActivityMs: z.number().nullable(), // tmux window_activity68 exitCode: z.number().nullable(), // when pane is dead69 cols: z.number().nullable(),70 rows: z.number().nullable(),71});72export type RtSessionInfo = z.infer<typeof RtSessionInfo>;7374export const RtTaskSpec = z.object({75 id: z.string().uuid(),76 command: z.string(),77 cwd: z.string(), // absolute local dir (ignored for remote hosts unless remoteCwd given)78 host: z.string(), // "local" or allow-listed alias79 timeoutSec: z.number().nullable(),80 env: z.record(z.string(), z.string()).default({}),81});82export type RtTaskSpec = z.infer<typeof RtTaskSpec>;83export const RtTaskInfo = z.object({84 id: z.string(),85 state: z.enum(['RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'TIMED_OUT', 'LOST']),86 exitCode: z.number().nullable(),87 startedAtMs: z.number().nullable(),88 endedAtMs: z.number().nullable(),89 logBytes: z.number(),90 cpuSec: z.number().nullable(),91 maxRssMb: z.number().nullable(),92});93export type RtTaskInfo = z.infer<typeof RtTaskInfo>;9495export const RtEvent = z.discriminatedUnion('type', [96 z.object({ type: z.literal('session.exit'), id: z.string(), exitCode: z.number().nullable() }),97 z.object({ type: z.literal('session.activity'), id: z.string(), atMs: z.number(), attached: z.number() }),98 /** From Claude Code hooks (Notification / Stop / UserPromptSubmit / SessionStart). */99 z.object({100 type: z.literal('claude.hook'),101 id: z.string(), // CR session id (from CR_SESSION_ID env inside the tmux session)102 event: z.string(),103 conversationId: z.string().nullable(),104 message: z.string().nullable(),105 }),106 z.object({ type: z.literal('task.updated'), task: RtTaskInfo }),107]);108export type RtEvent = z.infer<typeof RtEvent>;109