Repository Public
1/**2 * Browser-facing DTOs (REST /api/v1). The server validates every request body with these3 * schemas; the web app imports the inferred types. Timestamps are ISO-8601 strings.4 * Filesystem paths exposed to the browser are always RELATIVE to a project root5 * ("" = project root), never absolute host paths, except Project.path which is6 * relative to a workspace root and shown as `<rootLabel>/<rel>`.7 */8import { z } from 'zod';9import { Health, SessionState, TaskState } from './states.js';10import { Branding } from './agent.js';1112const iso = z.string();13export const Id = z.string().uuid();1415// ---------- auth ----------16export const AuthStatus = z.object({17 setupRequired: z.boolean(), // no administrator exists yet -> setup flow with one-time token18 authenticated: z.boolean(),19 user: z.object({ id: Id, username: z.string() }).nullable(),20 methods: z.object({ password: z.boolean(), passkeys: z.number(), totp: z.boolean() }),21 csrfToken: z.string().nullable(), // required as X-CSRF-Token on every mutating request22 sessionExpiresAt: iso.nullable(),23 /** Which product this instance is (Claude Remote / Codex Remote). Optional: older servers omit it. */24 branding: Branding.optional(),25});26export type AuthStatus = z.infer<typeof AuthStatus>;2728export const SetupRequest = z.object({29 setupToken: z.string().min(16),30 username: z.string().min(3).max(32).regex(/^[a-z0-9_.-]+$/i),31 password: z.string().min(12).max(256),32});33export const LoginPasswordRequest = z.object({34 username: z.string().max(64),35 password: z.string().max(256),36 totp: z.string().regex(/^\d{6}$/).optional(),37});38/** Password OK but second factor still needed. */39export const LoginStep = z.object({40 ok: z.boolean(),41 next: z.enum(['done', 'totp', 'passkey', 'enroll_second_factor']),42 retryAfterSec: z.number().optional(),43});44export type LoginStep = z.infer<typeof LoginStep>;45export const TotpEnrollStart = z.object({ secret: z.string(), otpauthUrl: z.string() });46export const TotpEnrollFinish = z.object({ code: z.string().regex(/^\d{6}$/) });47export const Device = z.object({48 id: Id,49 kind: z.enum(['passkey', 'session']),50 label: z.string(),51 createdAt: iso,52 lastUsedAt: iso.nullable(),53 current: z.boolean(),54 userAgent: z.string().nullable(),55 ip: z.string().nullable(),56});57export type Device = z.infer<typeof Device>;5859// ---------- projects ----------60export const Project = z.object({61 id: Id,62 name: z.string(),63 description: z.string(),64 rootLabel: z.string(), // label of the workspace root, e.g. "workspace"65 path: z.string(), // relative to that root66 gitRemote: z.string().nullable(),67 gitBranch: z.string().nullable(),68 gitDirty: z.boolean().nullable(),69 tags: z.array(z.string()),70 pinned: z.boolean(),71 settings: z.record(z.string(), z.unknown()),72 activeSessions: z.number(),73 lastActivityAt: iso.nullable(),74 createdAt: iso,75});76export type Project = z.infer<typeof Project>;77export const CreateProject = z.object({78 name: z.string().min(1).max(80),79 description: z.string().max(2000).default(''),80 mode: z.enum(['create', 'existing', 'clone']),81 rootLabel: z.string().optional(), // default: first workspace root82 path: z.string().max(400).optional(), // relative dir (create/existing); default slug(name)83 gitUrl: z.string().max(500).optional(), // required when mode=clone84 tags: z.array(z.string().max(40)).max(20).default([]),85});86export type CreateProject = z.infer<typeof CreateProject>;87export const UpdateProject = z.object({88 name: z.string().min(1).max(80).optional(),89 description: z.string().max(2000).optional(),90 tags: z.array(z.string().max(40)).max(20).optional(),91 pinned: z.boolean().optional(),92 settings: z.record(z.string(), z.unknown()).optional(),93});9495// ---------- terminal sessions ----------96/**97 * `claude` = the instance's agent CLI (Claude Code, or Codex when the server runs with98 * `CR_AGENT=codex`); the value is historical and kept for wire / database compatibility.99 */100export const SessionKind = z.enum(['claude', 'shell']);101export const TerminalSession = z.object({102 id: Id,103 projectId: Id.nullable(),104 projectName: z.string().nullable(),105 kind: SessionKind,106 title: z.string(),107 state: SessionState,108 cwd: z.string(), // relative to project root ("" = root)109 pid: z.number().nullable(),110 claudeConversationId: z.string().nullable(), // set when started with resume, or learned from hook111 pinned: z.boolean(),112 archived: z.boolean(),113 attachedClients: z.number(),114 exitCode: z.number().nullable(),115 createdAt: iso,116 lastActivityAt: iso.nullable(),117 endedAt: iso.nullable(),118 waitingReason: z.string().nullable(), // short hook message; never terminal content119});120export type TerminalSession = z.infer<typeof TerminalSession>;121export const CreateSession = z.object({122 projectId: Id.nullable().default(null),123 kind: SessionKind.default('claude'),124 title: z.string().max(120).optional(),125 cwd: z.string().max(400).default(''),126 resumeConversationId: z.string().max(100).optional(), // claude --resume <id>127 continueLast: z.boolean().optional(), // claude --continue128 cols: z.number().int().min(20).max(500).default(120),129 rows: z.number().int().min(5).max(200).default(32),130});131export type CreateSession = z.infer<typeof CreateSession>;132export const UpdateSession = z.object({133 title: z.string().min(1).max(120).optional(),134 pinned: z.boolean().optional(),135 archived: z.boolean().optional(),136});137export const ClaudeConversation = z.object({138 id: z.string(),139 summary: z.string(), // first user prompt, truncated to 160 chars140 updatedAt: iso,141 messageCount: z.number(),142});143export type ClaudeConversation = z.infer<typeof ClaudeConversation>;144145// ---------- files ----------146export const FileEntry = z.object({147 name: z.string(),148 path: z.string(), // project-relative149 type: z.enum(['file', 'dir', 'symlink', 'other']),150 size: z.number(),151 mtimeMs: z.number(),152 binary: z.boolean().optional(),153});154export type FileEntry = z.infer<typeof FileEntry>;155export const FileContent = z.object({156 path: z.string(),157 size: z.number(),158 mtimeMs: z.number(),159 sha256: z.string(),160 encoding: z.enum(['utf8']),161 content: z.string(),162});163export type FileContent = z.infer<typeof FileContent>;164export const WriteFile = z.object({165 path: z.string(),166 content: z.string(),167 /** sha256 of the content the editor loaded; omitted when creating. Mismatch -> 409 conflict. */168 baseSha256: z.string().optional(),169 create: z.boolean().optional(),170});171export const FsOp = z.discriminatedUnion('op', [172 z.object({ op: z.literal('mkdir'), path: z.string() }),173 z.object({ op: z.literal('rename'), from: z.string(), to: z.string() }),174 z.object({ op: z.literal('delete'), path: z.string(), confirm: z.literal(true) }),175]);176export const SearchHit = z.object({ path: z.string(), line: z.number().nullable(), preview: z.string().nullable() });177export type SearchHit = z.infer<typeof SearchHit>;178export const GitStatus = z.object({179 isRepo: z.boolean(),180 branch: z.string().nullable(),181 ahead: z.number(),182 behind: z.number(),183 files: z.array(z.object({ path: z.string(), index: z.string(), worktree: z.string() })),184});185export type GitStatus = z.infer<typeof GitStatus>;186187// ---------- tasks ----------188export const AgentTask = z.object({189 id: Id,190 projectId: Id.nullable(),191 title: z.string(),192 command: z.string(),193 host: z.string(), // "local" or an allow-listed node alias194 cwd: z.string(),195 state: TaskState,196 exitCode: z.number().nullable(),197 timeoutSec: z.number().nullable(),198 retryOf: Id.nullable(),199 idempotent: z.boolean(), // retry is only offered when true200 createdAt: iso,201 startedAt: iso.nullable(),202 endedAt: iso.nullable(),203 logBytes: z.number(),204 cpuSec: z.number().nullable(),205 maxRssMb: z.number().nullable(),206 artifacts: z.array(z.object({ id: Id, name: z.string(), size: z.number(), createdAt: iso })),207});208export type AgentTask = z.infer<typeof AgentTask>;209export const CreateTask = z.object({210 projectId: Id.nullable().default(null),211 title: z.string().min(1).max(160),212 command: z.string().min(1).max(8000),213 host: z.string().max(40).default('local'),214 cwd: z.string().max(400).default(''),215 timeoutSec: z.number().int().min(1).max(7 * 24 * 3600).nullable().default(null),216 idempotent: z.boolean().default(false),217 artifactGlobs: z.array(z.string().max(200)).max(20).default([]),218});219export type CreateTask = z.infer<typeof CreateTask>;220221// ---------- cluster ----------222export const ClusterNode = z.object({223 id: z.string(), // alias, e.g. "M5M36"224 model: z.string().nullable(),225 chip: z.string().nullable(),226 kind: z.enum(['mac', 'server', 'mobile', 'storage', 'other']),227 role: z.string().nullable(),228 cpuCores: z.number().nullable(),229 gpuCores: z.number().nullable(),230 memoryMb: z.number().nullable(),231 os: z.string().nullable(),232 health: Health,233 metricsAt: iso.nullable(), // when the metrics below were collected; null => never234 stale: z.boolean(),235 cpuPct: z.number().nullable(),236 memUsedMb: z.number().nullable(),237 diskFreeGb: z.number().nullable(),238 diskTotalGb: z.number().nullable(),239 load1: z.number().nullable(),240 apps: z.array(z.string()),241 execAllowed: z.boolean(), // on the remote-execution allowlist AND reachable with the runtime key242 isHost: z.boolean(), // this is the node Claude Remote runs on243});244export type ClusterNode = z.infer<typeof ClusterNode>;245export const ClusterOverview = z.object({246 source: z.enum(['agentd', 'none']),247 collectedAt: iso.nullable(),248 health: Health,249 nodes: z.array(ClusterNode),250 edges: z.array(z.object({ from: z.string(), to: z.string(), kind: z.enum(['lan', 'tunnel', 'service']) })),251 totals: z.object({ nodes: z.number(), online: z.number(), cores: z.number(), memoryGb: z.number() }),252});253export type ClusterOverview = z.infer<typeof ClusterOverview>;254export const LocalModel = z.object({255 id: z.string(),256 name: z.string(),257 host: z.string(),258 engine: z.string().nullable(),259 endpoint: z.string(), // server-side only reachable; shown as label260 health: Health,261 checkedAt: iso.nullable(),262 latencyMs: z.number().nullable(),263 tokensPerSec: z.number().nullable(),264});265export type LocalModel = z.infer<typeof LocalModel>;266267// ---------- skills / MCP ----------268export const SkillInfo = z.object({269 name: z.string(),270 description: z.string(),271 source: z.enum(['user', 'project', 'plugin', 'registry']),272 kind: z.enum(['skill', 'command', 'agent']),273 version: z.string().nullable(),274 location: z.string(), // display label like "~/.claude/skills/foo"275 enabled: z.boolean(),276 updateAvailable: z.boolean().nullable(), // null => registry not connected277});278export type SkillInfo = z.infer<typeof SkillInfo>;279export const McpServerInfo = z.object({280 name: z.string(),281 scope: z.enum(['user', 'project', 'local']),282 transport: z.enum(['stdio', 'http', 'sse']),283 target: z.string(), // command or URL — secrets (headers/env values) are NEVER included284 envKeys: z.array(z.string()), // names only285 status: z.enum(['connected', 'failed', 'needs_auth', 'unknown']),286 lastError: z.string().nullable(),287 checkedAt: iso.nullable(),288});289export type McpServerInfo = z.infer<typeof McpServerInfo>;290export const AddMcpServer = z.object({291 name: z.string().regex(/^[a-zA-Z0-9_-]{1,40}$/),292 transport: z.enum(['stdio', 'http', 'sse']),293 target: z.string().min(1).max(1000), // command (stdio) or URL294 args: z.array(z.string().max(1000)).max(40).default([]),295 env: z.record(z.string(), z.string()).default({}), // write-only; never echoed back296 headers: z.record(z.string(), z.string()).default({}), // write-only297 scope: z.enum(['user', 'project']).default('user'),298 projectId: Id.optional(),299});300301// ---------- observability / notifications / settings ----------302export const ComponentHealth = z.object({303 component: z.string(),304 health: Health,305 detail: z.string().nullable(),306 checkedAt: iso,307});308export const SystemStatus = z.object({309 collectedAt: iso,310 version: z.string(),311 components: z.array(ComponentHealth),312 host: z.object({313 name: z.string(),314 cpuPct: z.number().nullable(),315 load: z.array(z.number()),316 memUsedMb: z.number().nullable(),317 memTotalMb: z.number().nullable(),318 diskFreeGb: z.number().nullable(),319 diskTotalGb: z.number().nullable(),320 uptimeSec: z.number().nullable(),321 }),322 counts: z.object({323 liveSessions: z.number(),324 wsClients: z.number(),325 runningTasks: z.number(),326 queuedTasks: z.number(),327 }),328 /** The instance's agent CLI (historical key). `agent`/`agentName` say which one it is. */329 claude: z.object({330 version: z.string().nullable(),331 authenticated: z.boolean().nullable(),332 agent: z.enum(['claude', 'codex']).optional(),333 agentName: z.string().optional(),334 }),335});336export type SystemStatus = z.infer<typeof SystemStatus>;337export const NotificationKind = z.enum([338 'session.waiting',339 'session.exited',340 'task.succeeded',341 'task.failed',342 'node.offline',343 'storage.low',344 'system',345]);346export const Notification = z.object({347 id: Id,348 kind: NotificationKind,349 title: z.string(),350 body: z.string(), // generic by default; detail level is a user setting351 link: z.string().nullable(),352 readAt: iso.nullable(),353 createdAt: iso,354});355export type Notification = z.infer<typeof Notification>;356export const AuditEvent = z.object({357 id: z.string(),358 at: iso,359 actor: z.string(),360 action: z.string(),361 target: z.string().nullable(),362 ip: z.string().nullable(),363 ok: z.boolean(),364 detail: z.record(z.string(), z.unknown()),365});366export type AuditEvent = z.infer<typeof AuditEvent>;367export const UserSettings = z.object({368 theme: z.enum(['light', 'dark', 'system']).default('light'),369 /** Terminal colours: 'auto' follows the app theme. */370 terminalTheme: z.enum(['auto', 'light', 'dark']).default('auto'),371 terminalFont: z.string().default('JetBrains Mono'),372 terminalFontSize: z.number().min(8).max(28).default(13),373 mobileFontSize: z.number().min(8).max(28).default(12),374 scrollback: z.number().min(500).max(50000).default(5000),375 defaultProjectId: Id.nullable().default(null),376 defaultLayout: z.enum(['terminal', 'terminal+files', 'terminal+editor', 'terminal+monitoring', 'research']).default('terminal+files'),377 notifications: z378 .object({ inApp: z.boolean(), webPush: z.boolean(), detail: z.enum(['generic', 'titles']) })379 .default({ inApp: true, webPush: false, detail: 'generic' }),380 sessionRetentionDays: z.number().min(1).max(3650).default(90),381 mobileToolbar: z.array(z.string()).default(['esc', 'tab', 'ctrl', 'alt', 'shift', 'up', 'down', 'left', 'right', 'enter', '/', '|', '~', '-']),382 autosave: z.boolean().default(false),383 layout: z.record(z.string(), z.unknown()).default({}), // persisted panel sizes etc.384});385export type UserSettings = z.infer<typeof UserSettings>;386387export const ApiError = z.object({388 error: z.object({ code: z.string(), message: z.string(), details: z.unknown().optional() }),389});390export type ApiError = z.infer<typeof ApiError>;391