// @ts-nocheck
import express from 'express';
import multer from 'multer';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { composeSystemPrompt } from './prompts/system.js';
import { createCommandInvocation } from '@open-design/platform';
import {
checkPromptArgvBudget,
checkWindowsCmdShimCommandLineBudget,
checkWindowsDirectExeCommandLineBudget,
detectAgents,
getAgentDef,
isKnownModel,
resolveAgentBin,
sanitizeCustomModel,
spawnEnvForAgent,
} from './agents.js';
import { findSkillById, listSkills } from './skills.js';
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
import { syncCommunityPets } from './community-pets-sync.js';
import { listDesignSystems, readDesignSystem } from './design-systems.js';
import { attachAcpSession } from './acp.js';
import { attachPiRpcSession } from './pi-rpc.js';
import { createClaudeStreamHandler } from './claude-stream.js';
import { createCopilotStreamHandler } from './copilot-stream.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { subscribe as subscribeFileEvents } from './project-watchers.js';
import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
import { createChatRunService } from './runs.js';
import { importClaudeDesignZip } from './claude-design-import.js';
import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js';
import { buildDocumentPreview } from './document-preview.js';
import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js';
import { loadCraftSections } from './craft.js';
import { stageActiveSkill } from './cwd-aliases.js';
import { generateMedia } from './media.js';
import {
AUDIO_DURATIONS_SEC,
AUDIO_MODELS_BY_KIND,
IMAGE_MODELS,
MEDIA_ASPECTS,
MEDIA_PROVIDERS,
VIDEO_LENGTHS_SEC,
VIDEO_MODELS,
} from './media-models.js';
import { readMaskedConfig, writeConfig } from './media-config.js';
import { readAppConfig, writeAppConfig } from './app-config.js';
import {
buildProjectArchive,
buildBatchArchive,
decodeMultipartFilename,
deleteProjectFile,
ensureProject,
listFiles,
mimeFor,
projectDir,
readProjectFile,
removeProjectDir,
sanitizeName,
searchProjectFiles,
writeProjectFile,
} from './projects.js';
import { validateArtifactManifestInput } from './artifact-manifest.js';
import { readCurrentAppVersionInfo } from './app-version.js';
import {
deleteConversation,
deletePreviewComment,
deleteProject as dbDeleteProject,
deleteTemplate,
getConversation,
getDeployment,
getDeploymentById,
getProject,
getTemplate,
insertConversation,
insertProject,
insertTemplate,
listProjectsAwaitingInput,
listConversations,
listDeployments,
listLatestProjectRunStatuses,
listMessages,
listPreviewComments,
listProjects,
listTabs,
listTemplates,
openDatabase,
setTabs,
updateConversation,
updatePreviewCommentStatus,
updateProject,
upsertDeployment,
upsertMessage,
upsertPreviewComment,
} from './db.js';
import {
buildDeployFileSet,
checkDeploymentUrl,
DeployError,
deployToVercel,
prepareDeployPreflight,
publicDeployConfig,
readVercelConfig,
VERCEL_PROVIDER_ID,
writeVercelConfig,
} from './deploy.js';
/** @typedef {import('@open-design/contracts').ApiErrorCode} ApiErrorCode */
/** @typedef {import('@open-design/contracts').ApiError} ApiError */
/** @typedef {import('@open-design/contracts').ApiErrorResponse} ApiErrorResponse */
/** @typedef {import('@open-design/contracts').ChatRequest} ChatRequest */
/** @typedef {import('@open-design/contracts').ChatSseEvent} ChatSseEvent */
/** @typedef {import('@open-design/contracts').ProxyStreamRequest} ProxyStreamRequest */
/** @typedef {import('@open-design/contracts').ProxySseEvent} ProxySseEvent */
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export function resolveProjectRoot(moduleDir: string): string {
const base = path.basename(moduleDir);
const daemonDir =
base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir;
return path.resolve(daemonDir, '../..');
}
const PROJECT_ROOT = resolveProjectRoot(__dirname);
const RESOURCE_ROOT_ENV = 'OD_RESOURCE_ROOT';
export function normalizeCommentAttachments(input) {
if (!Array.isArray(input)) return [];
return input
.map((raw, index) => {
if (!raw || typeof raw !== 'object') return null;
const filePath = cleanString(raw.filePath);
const elementId = cleanString(raw.elementId);
const selector = cleanString(raw.selector);
const label = cleanString(raw.label);
const comment = cleanString(raw.comment);
if (!filePath || !elementId || !selector || !comment) return null;
return {
id: cleanString(raw.id) || `comment-${index + 1}`,
order: Number.isFinite(raw.order)
? Math.max(1, Math.round(raw.order))
: index + 1,
filePath,
elementId,
selector,
label,
comment,
currentText: compactString(raw.currentText, 160),
pagePosition: normalizeAttachmentPosition(raw.pagePosition),
htmlHint: compactString(raw.htmlHint, 180),
};
})
.filter(Boolean)
.sort((a, b) => a.order - b.order);
}
export function renderCommentAttachmentHint(commentAttachments) {
if (!commentAttachments.length) return '';
const lines = [
'',
'',
'',
'Scope: edit the target element by default. Use the smallest necessary parent wrapper only if the target cannot satisfy the comment. Preserve stable ids and unrelated siblings.',
];
for (const item of commentAttachments) {
lines.push(
'',
`${item.order}. ${item.elementId}`,
`file: ${item.filePath}`,
`selector: ${item.selector}`,
`label: ${item.label || '(unlabeled)'}`,
`position: ${formatAttachmentPosition(item.pagePosition)}`,
`currentText: ${item.currentText || '(empty)'}`,
`htmlHint: ${item.htmlHint || '(none)'}`,
`comment: ${item.comment}`,
);
}
lines.push('');
return lines.join('\n');
}
function cleanString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function compactString(value, max) {
const text = cleanString(value).replace(/\s+/g, ' ');
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
}
function normalizeAttachmentPosition(input) {
const value = input && typeof input === 'object' ? input : {};
return {
x: finiteAttachmentNumber(value.x),
y: finiteAttachmentNumber(value.y),
width: finiteAttachmentNumber(value.width),
height: finiteAttachmentNumber(value.height),
};
}
function finiteAttachmentNumber(value) {
return Number.isFinite(value) ? Math.round(value) : 0;
}
function formatAttachmentPosition(position) {
return `x=${position.x}, y=${position.y}, width=${position.width}, height=${position.height}`;
}
function isPathWithin(base, target) {
const relativePath = path.relative(path.resolve(base), path.resolve(target));
return (
relativePath === '' ||
(relativePath.length > 0 &&
!relativePath.startsWith('..') &&
!path.isAbsolute(relativePath))
);
}
function resolveProcessResourcesPath() {
if (
typeof process.resourcesPath === 'string' &&
process.resourcesPath.length > 0
) {
return process.resourcesPath;
}
// Packaged daemon sidecars run under the bundled Node binary rather than the
// Electron root process, so `process.resourcesPath` is unavailable there.
// Infer the macOS app Resources directory from that bundled Node path.
const resourcesMarker = `${path.sep}Contents${path.sep}Resources${path.sep}`;
const markerIndex = process.execPath.indexOf(resourcesMarker);
if (markerIndex !== -1) {
return process.execPath.slice(0, markerIndex + resourcesMarker.length - 1);
}
const normalizedExecPath = process.execPath.toLowerCase();
const windowsResourceBinMarker =
`${path.sep}resources${path.sep}open-design${path.sep}bin${path.sep}`.toLowerCase();
const windowsMarkerIndex = normalizedExecPath.indexOf(
windowsResourceBinMarker,
);
if (windowsMarkerIndex !== -1) {
return process.execPath.slice(
0,
windowsMarkerIndex + `${path.sep}resources`.length,
);
}
return null;
}
export function resolveDaemonResourceRoot({
configured = process.env[RESOURCE_ROOT_ENV],
safeBases = [PROJECT_ROOT, resolveProcessResourcesPath()],
} = {}) {
if (!configured || configured.length === 0) return null;
const resolved = path.resolve(configured);
const normalizedSafeBases = safeBases
.filter((base) => typeof base === 'string' && base.length > 0)
.map((base) => path.resolve(base));
if (!normalizedSafeBases.some((base) => isPathWithin(base, resolved))) {
throw new Error(
`${RESOURCE_ROOT_ENV} must be under the workspace root or app resources path`,
);
}
return resolved;
}
function resolveDaemonResourceDir(resourceRoot, segment, fallback) {
return resourceRoot ? path.join(resourceRoot, segment) : fallback;
}
const DAEMON_RESOURCE_ROOT = resolveDaemonResourceRoot();
// Built web app lives in `out/` — that's where Next.js writes the static
// export configured in next.config.ts. The folder name used to be `dist/`
// when this project shipped with Vite; the daemon serves whatever the
// frontend toolchain emits, no further config needed.
const STATIC_DIR = path.join(PROJECT_ROOT, 'apps', 'web', 'out');
const OD_BIN = path.join(PROJECT_ROOT, 'apps', 'daemon', 'dist', 'cli.js');
const SKILLS_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'skills',
path.join(PROJECT_ROOT, 'skills'),
);
const DESIGN_SYSTEMS_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'design-systems',
path.join(PROJECT_ROOT, 'design-systems'),
);
const CRAFT_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'craft',
path.join(PROJECT_ROOT, 'craft'),
);
const FRAMES_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'frames',
path.join(PROJECT_ROOT, 'assets', 'frames'),
);
// Curated pets baked into the repo via `scripts/bake-community-pets.ts`.
// `listCodexPets` scans this in addition to `~/.codex/pets/` so the
// "Recently hatched" grid is non-empty out-of-the-box and users do not
// need to hit the "Download community pets" button to try a few pets.
const BUNDLED_PETS_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'community-pets',
path.join(PROJECT_ROOT, 'assets', 'community-pets'),
);
const PROMPT_TEMPLATES_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'prompt-templates',
path.join(PROJECT_ROOT, 'prompt-templates'),
);
export function resolveDataDir(raw, projectRoot) {
if (!raw) return path.join(projectRoot, '.od');
const expanded = raw.startsWith('~/')
? path.join(os.homedir(), raw.slice(2))
: raw;
const resolved = path.isAbsolute(expanded)
? expanded
: path.resolve(projectRoot, expanded);
try {
fs.mkdirSync(resolved, { recursive: true });
fs.accessSync(resolved, fs.constants.W_OK);
} catch (err) {
const e = err;
throw new Error(
`OD_DATA_DIR "${resolved}" is not writable: ${e.message}`,
);
}
return resolved;
}
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT);
const ARTIFACTS_DIR = path.join(RUNTIME_DATA_DIR, 'artifacts');
const PROJECTS_DIR = path.join(RUNTIME_DATA_DIR, 'projects');
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
export const SSE_KEEPALIVE_INTERVAL_MS = 25_000;
export function normalizeProjectDisplayStatus(status) {
return status === 'starting' || status === 'queued' ? 'running' : status;
}
export function composeProjectDisplayStatus(
baseStatus,
awaitingInputProjects,
projectId,
) {
if (
baseStatus.value === 'succeeded' &&
awaitingInputProjects.has(projectId)
) {
return { ...baseStatus, value: 'awaiting_input' };
}
return {
...baseStatus,
value: normalizeProjectDisplayStatus(baseStatus.value),
};
}
/**
* @param {ApiErrorCode} code
* @param {string} message
* @param {Omit} [init]
* @returns {ApiError}
*/
export function createCompatApiError(code, message, init = {}) {
return { code, message, ...init };
}
/**
* @param {ApiErrorCode} code
* @param {string} message
* @param {Omit} [init]
* @returns {ApiErrorResponse}
*/
export function createCompatApiErrorResponse(code, message, init = {}) {
return { error: createCompatApiError(code, message, init) };
}
/**
* @param {import('express').Response} res
* @param {number} status
* @param {ApiErrorCode} code
* @param {string} message
* @param {Omit} [init]
*/
function sendApiError(res, status, code, message, init = {}) {
return res
.status(status)
.json(createCompatApiErrorResponse(code, message, init));
}
// Filename slug for the Content-Disposition header on archive downloads.
// Browsers reject quotes and control bytes; we keep Unicode letters/digits
// so a project name with non-ASCII characters (e.g. "café-design")
// survives instead of becoming a row of underscores.
function sanitizeArchiveFilename(raw) {
const cleaned = String(raw ?? '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/\s+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return cleaned;
}
/**
* @param {ApiErrorCode} code
* @param {string} message
* @param {Omit} [init]
*/
function createSseErrorPayload(code, message, init = {}) {
return { message, error: createCompatApiError(code, message, init) };
}
const UPLOAD_DIR = path.join(os.tmpdir(), 'od-uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
const upload = multer({
storage: multer.diskStorage({
destination: UPLOAD_DIR,
filename: (_req, file, cb) => {
file.originalname = decodeMultipartFilename(file.originalname);
const safe = sanitizeName(file.originalname);
cb(
null,
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safe}`,
);
},
}),
limits: { fileSize: 20 * 1024 * 1024 },
});
const importUpload = multer({
storage: multer.diskStorage({
destination: UPLOAD_DIR,
filename: (_req, file, cb) => {
file.originalname = decodeMultipartFilename(file.originalname);
const safe = sanitizeName(file.originalname);
cb(
null,
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safe}`,
);
},
}),
limits: { fileSize: 100 * 1024 * 1024 },
});
// Project-scoped multi-file upload. Lands files directly in the project
// folder (flat — same shape FileWorkspace expects), so the composer's
// pasted/dropped/picked images become referenceable filenames the agent
// can Read or @-mention without any cross-folder gymnastics.
const projectUpload = multer({
storage: multer.diskStorage({
destination: async (req, _file, cb) => {
try {
const dir = await ensureProject(PROJECTS_DIR, req.params.id);
cb(null, dir);
} catch (err) {
cb(err, '');
}
},
filename: (_req, file, cb) => {
// multer@1 hands us latin1-decoded multipart filenames; restore the
// original UTF-8 so the response (and the on-disk name) preserves
// non-ASCII characters instead of mangling them. Then run the
// shared sanitiser and prepend a base36 timestamp so multiple
// uploads with the same original name don't clobber each other.
file.originalname = decodeMultipartFilename(file.originalname);
const safe = sanitizeName(file.originalname);
cb(null, `${Date.now().toString(36)}-${safe}`);
},
}),
limits: { fileSize: 200 * 1024 * 1024 }, // 200MB — covers the largest design assets we expect (PPTX/PDF/raw images)
});
function handleProjectUpload(req, res, next) {
projectUpload.array('files', 12)(req, res, (err) => {
if (err) {
return sendMulterError(res, err);
}
next();
});
}
function sendMulterError(res, err) {
if (err instanceof multer.MulterError) {
const code = err.code || 'UPLOAD_ERROR';
const statusByCode = {
LIMIT_FILE_SIZE: 413,
LIMIT_FILE_COUNT: 400,
LIMIT_UNEXPECTED_FILE: 400,
LIMIT_PART_COUNT: 400,
LIMIT_FIELD_KEY: 400,
LIMIT_FIELD_VALUE: 400,
LIMIT_FIELD_COUNT: 400,
};
const errorByCode = {
LIMIT_FILE_SIZE: 'file too large',
LIMIT_FILE_COUNT: 'too many files',
LIMIT_UNEXPECTED_FILE: 'unexpected file field',
LIMIT_PART_COUNT: 'too many form parts',
LIMIT_FIELD_KEY: 'field name too long',
LIMIT_FIELD_VALUE: 'field value too long',
LIMIT_FIELD_COUNT: 'too many form fields',
};
const status = statusByCode[code] ?? 400;
const message = errorByCode[code] ?? 'upload failed';
return sendApiError(
res,
status,
code === 'LIMIT_FILE_SIZE' ? 'PAYLOAD_TOO_LARGE' : 'BAD_REQUEST',
message,
{ details: { legacyCode: code } },
);
}
if (err) {
return sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
}
return sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
}
const mediaTasks = new Map();
const TASK_TTL_AFTER_DONE_MS = 10 * 60 * 1000;
function createMediaTask(taskId, projectId, info = {}) {
const task = {
id: taskId,
projectId,
status: 'queued',
surface: info.surface,
model: info.model,
progress: [],
file: null,
error: null,
startedAt: Date.now(),
endedAt: null,
waiters: new Set(),
};
mediaTasks.set(taskId, task);
return task;
}
function appendTaskProgress(task, line) {
task.progress.push(line);
notifyTaskWaiters(task);
}
function notifyTaskWaiters(task) {
const wakers = Array.from(task.waiters);
for (const w of wakers) {
try {
w();
} catch {
// Never let one bad waiter block the rest.
}
}
if (
(task.status === 'done' || task.status === 'failed') &&
!task._gcScheduled
) {
task._gcScheduled = true;
setTimeout(() => {
if (task.waiters.size === 0) mediaTasks.delete(task.id);
}, TASK_TTL_AFTER_DONE_MS).unref?.();
}
}
export function createSseResponse(
res,
{ keepAliveIntervalMs = SSE_KEEPALIVE_INTERVAL_MS } = {},
) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
const canWrite = () => !res.destroyed && !res.writableEnded;
const writeKeepAlive = () => {
if (canWrite()) {
res.write(': keepalive\n\n');
return true;
}
return false;
};
let heartbeat = null;
if (keepAliveIntervalMs > 0) {
heartbeat = setInterval(writeKeepAlive, keepAliveIntervalMs);
heartbeat.unref?.();
}
const cleanup = () => {
if (heartbeat) {
clearInterval(heartbeat);
heartbeat = null;
}
};
res.on('close', cleanup);
res.on('finish', cleanup);
return {
/** @param {ChatSseEvent['event'] | ProxySseEvent['event'] | string} event */
send(event, data, id = null) {
if (!canWrite()) return false;
if (id !== null && id !== undefined) res.write(`id: ${id}\n`);
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
return true;
},
writeKeepAlive,
cleanup,
end() {
cleanup();
if (canWrite()) {
res.end();
}
},
};
}
export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST || '127.0.0.1', returnServer = false } = {}) {
let resolvedPort = port;
const app = express();
app.use(express.json({ limit: '4mb' }));
// Build the set of allowed browser origins for the current bind config.
// Shared by the global origin middleware and isLocalSameOrigin() so
// both use the same policy (loopback + explicit bind host, HTTP + HTTPS,
// OD_WEB_PORT support).
function buildAllowedOrigins() {
const ports = [resolvedPort];
const webPort = Number(process.env.OD_WEB_PORT);
if (webPort && webPort !== resolvedPort) ports.push(webPort);
const schemes = ['http', 'https'];
const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]'];
return new Set(
ports.flatMap((p) => [
...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)),
// When bound to a specific non-loopback address (e.g. Tailscale,
// LAN IP, or 0.0.0.0), allow browser requests from that address
// too so the documented --host escape hatch remains usable.
...schemes.map((s) => `${s}://${host}:${p}`),
]),
);
}
// Routes that serve content to sandboxed iframes (Origin: null) for
// read-only purposes. All other /api routes reject Origin: null.
const _NULL_ORIGIN_SAFE_GET_RE =
/^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/;
// Reject cross-origin requests to API endpoints.
// Health/version remain open for monitoring probes.
// Non-browser clients (no Origin header) are always allowed.
app.use('/api', (req, res, next) => {
const origin = req.headers.origin;
// Non-browser client → allow.
if (origin == null || origin === '') return next();
// Origin: null (sandboxed iframes). Only allowed for safe, read-only
// routes that set their own CORS headers for canvas drawing.
if (origin === 'null') {
const isSafeReadOnly =
req.method === 'GET' && _NULL_ORIGIN_SAFE_GET_RE.test(req.path);
if (!isSafeReadOnly) {
return res.status(403).json({ error: 'Origin: null not allowed for this route' });
}
return next();
}
// Fail-closed: block all browser origins until port is resolved.
if (!resolvedPort) {
return res.status(403).json({ error: 'Server initializing' });
}
if (!buildAllowedOrigins().has(String(origin))) {
return res.status(403).json({ error: 'Cross-origin requests are not allowed' });
}
next();
});
const db = openDatabase(PROJECT_ROOT, { dataDir: RUNTIME_DATA_DIR });
if (process.env.OD_CODEX_DISABLE_PLUGINS === '1') {
console.log('[od] Codex plugins disabled via OD_CODEX_DISABLE_PLUGINS=1');
}
// Warm agent-capability probes (e.g. whether the installed Claude Code
// build advertises --include-partial-messages) so the first /api/chat
// hits a populated cache even if /api/agents hasn't been called yet.
void detectAgents().catch(() => {});
if (fs.existsSync(STATIC_DIR)) {
app.use(express.static(STATIC_DIR));
}
app.get('/api/health', async (_req, res) => {
const versionInfo = await readCurrentAppVersionInfo();
res.json({ ok: true, version: versionInfo.version });
});
app.get('/api/version', async (_req, res) => {
const version = await readCurrentAppVersionInfo();
res.json({ version });
});
// ---- Projects (DB-backed) -------------------------------------------------
// Soft "what is the user looking at right now in Open Design?" channel. The
// web UI POSTs the current project + file on every route change;
// the MCP surface reads it so a coding agent in another repo can
// resolve "the design I have open" without the user typing the
// project id. In-memory only - daemon restart clears it.
/** @type {{ projectId: string; fileName: string | null; ts: number } | null} */
let activeContext = null;
const ACTIVE_CONTEXT_TTL_MS = 5 * 60 * 1000;
// Active context is private to the local machine. The daemon binds
// 0.0.0.0 by default, so without an origin check a peer on the LAN
// could read what the user is currently looking at (GET) or spoof
// it to redirect MCP fallbacks (POST). The web proxies same-origin
// and the MCP runs in-process via 127.0.0.1, so both legitimate
// callers pass the check.
app.post('/api/active', (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
const body = req.body || {};
if (body.active === false) {
activeContext = null;
res.json({ active: false });
return;
}
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
if (!projectId) {
sendApiError(res, 400, 'BAD_REQUEST', 'projectId is required');
return;
}
const fileName =
typeof body.fileName === 'string' && body.fileName.length > 0
? body.fileName
: null;
activeContext = { projectId, fileName, ts: Date.now() };
res.json({ active: true, ...activeContext });
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.get('/api/active', (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
if (!activeContext || Date.now() - activeContext.ts > ACTIVE_CONTEXT_TTL_MS) {
activeContext = null;
res.json({ active: false });
return;
}
const project = getProject(db, activeContext.projectId);
res.json({
active: true,
projectId: activeContext.projectId,
projectName: project?.name ?? null,
fileName: activeContext.fileName,
ts: activeContext.ts,
ageMs: Date.now() - activeContext.ts,
});
});
// Surfaces the absolute paths to `node` + `apps/daemon/dist/cli.js`
// so the Settings → MCP server panel can render snippets that work
// even when `od` isn't on the user's PATH (the common case for
// source clones - and macOS/Linux ship a /usr/bin/od octal-dump
// tool that shadows ours anyway). Computed from import.meta.url so
// both src/ (tsx dev) and dist/ (built) launches resolve to the
// same dist/cli.js path. Cached for 5s because the panel pings on
// every open and the path lookup + two existsSync calls are cheap
// but not free, and these paths cannot change without a daemon
// restart anyway.
const INSTALL_INFO_TTL_MS = 5000;
let installInfoCache: { t: number; payload: object } | null = null;
app.get('/api/mcp/install-info', (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
const now = Date.now();
if (installInfoCache && now - installInfoCache.t < INSTALL_INFO_TTL_MS) {
return res.json(installInfoCache.payload);
}
let cliPath;
try {
cliPath = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
} catch (err) {
return sendApiError(res, 500, 'CLI_RESOLVE_FAILED', String(err));
}
const cliExists = fs.existsSync(cliPath);
// process.execPath is the absolute path to the node binary that
// is running the daemon RIGHT NOW. We prefer it over bare `node`
// because IDE-spawned MCP clients inherit a minimal PATH from the
// OS launcher (Spotlight, Dock, etc.) that often does not see
// user-level node installs (nvm, fnm, asdf). On rare occasions
// (uninstall mid-session, exotic embeds) the path may not exist
// by the time the user copies the snippet; catch that and warn.
const nodeExists = fs.existsSync(process.execPath);
const hints: string[] = [];
if (!cliExists) {
hints.push(
'apps/daemon/dist/cli.js is missing. Run `pnpm --filter @open-design/daemon build` (or just `pnpm build`) and refresh.',
);
}
if (!nodeExists) {
hints.push(
`Node binary at ${process.execPath} no longer exists. Reinstall Node and restart the daemon.`,
);
}
const payload = {
command: process.execPath,
args: [cliPath, 'mcp', '--daemon-url', `http://127.0.0.1:${resolvedPort}`],
daemonUrl: `http://127.0.0.1:${resolvedPort}`,
// Surface platform so the install panel can localize path hints
// (~/.cursor vs %USERPROFILE%\.cursor) and keyboard shortcuts
// (Cmd vs Ctrl). One of 'darwin' | 'linux' | 'win32' in
// practice; the panel falls back to POSIX wording for anything
// else.
platform: process.platform,
cliExists,
nodeExists,
buildHint: hints.length ? hints.join(' ') : null,
};
installInfoCache = { t: now, payload };
res.json(payload);
});
app.get('/api/projects', (_req, res) => {
try {
const latestRunStatuses = listLatestProjectRunStatuses(db);
const awaitingInputProjects = listProjectsAwaitingInput(db);
const activeRunStatuses = new Map();
for (const run of design.runs.list()) {
if (!run.projectId) continue;
const runStatus = projectStatusFromRun(run);
if (design.runs.isTerminal(run.status)) {
const existing = latestRunStatuses.get(run.projectId);
if (!existing || run.updatedAt > (existing.updatedAt ?? 0)) {
latestRunStatuses.set(run.projectId, runStatus);
}
} else {
const existing = activeRunStatuses.get(run.projectId);
if (!existing || run.updatedAt > (existing.updatedAt ?? 0)) {
activeRunStatuses.set(run.projectId, runStatus);
}
}
}
/** @type {import('@open-design/contracts').ProjectsResponse} */
const body = {
projects: listProjects(db).map((project) => ({
...project,
status: composeProjectDisplayStatus(
activeRunStatuses.get(project.id) ??
latestRunStatuses.get(project.id) ?? { value: 'not_started' },
awaitingInputProjects,
project.id,
),
})),
};
res.json(body);
} catch (err) {
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
function projectStatusFromRun(run) {
return {
value: normalizeProjectDisplayStatus(run.status),
updatedAt: run.updatedAt,
runId: run.id,
};
}
app.post('/api/projects', async (req, res) => {
try {
const { id, name, skillId, designSystemId, pendingPrompt, metadata } =
req.body || {};
if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
if (typeof name !== 'string' || !name.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'name required');
}
const now = Date.now();
const project = insertProject(db, {
id,
name: name.trim(),
skillId: skillId ?? null,
designSystemId: designSystemId ?? null,
pendingPrompt: pendingPrompt || null,
metadata: metadata && typeof metadata === 'object' ? metadata : null,
createdAt: now,
updatedAt: now,
});
// Seed a default conversation so the UI always has somewhere to write.
const cid = randomId();
insertConversation(db, {
id: cid,
projectId: id,
title: null,
createdAt: now,
updatedAt: now,
});
// For "from template" projects, seed the chosen template's snapshot
// HTML into the new project folder so the agent can Read/edit files
// on disk (the system prompt also embeds them, but a real on-disk
// copy lets the agent treat them as the project's working state).
if (
metadata &&
typeof metadata === 'object' &&
metadata.kind === 'template' &&
typeof metadata.templateId === 'string'
) {
const tpl = getTemplate(db, metadata.templateId);
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
await ensureProject(PROJECTS_DIR, id);
for (const f of tpl.files) {
if (
!f ||
typeof f.name !== 'string' ||
typeof f.content !== 'string'
) {
continue;
}
try {
await writeProjectFile(
PROJECTS_DIR,
id,
f.name,
Buffer.from(f.content, 'utf8'),
);
} catch {
// Skip individual file failures — the template snapshot is
// best-effort; the agent still has the embedded copy.
}
}
}
}
/** @type {import('@open-design/contracts').CreateProjectResponse} */
const body = { project, conversationId: cid };
res.json(body);
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.post(
'/api/import/claude-design',
importUpload.single('file'),
async (req, res) => {
try {
if (!req.file)
return res.status(400).json({ error: 'zip file required' });
const originalName =
req.file.originalname || 'Claude Design export.zip';
if (!/\.zip$/i.test(originalName)) {
fs.promises.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'expected a .zip file' });
}
const id = randomId();
const now = Date.now();
const baseName =
originalName.replace(/\.zip$/i, '').trim() || 'Claude Design import';
const imported = await importClaudeDesignZip(
req.file.path,
projectDir(PROJECTS_DIR, id),
);
fs.promises.unlink(req.file.path).catch(() => {});
const project = insertProject(db, {
id,
name: baseName,
skillId: null,
designSystemId: null,
pendingPrompt: `Imported from Claude Design ZIP: ${originalName}. Continue editing ${imported.entryFile}.`,
metadata: {
kind: 'prototype',
importedFrom: 'claude-design',
entryFile: imported.entryFile,
sourceFileName: originalName,
},
createdAt: now,
updatedAt: now,
});
const cid = randomId();
insertConversation(db, {
id: cid,
projectId: id,
title: 'Imported Claude Design project',
createdAt: now,
updatedAt: now,
});
setTabs(db, id, [imported.entryFile], imported.entryFile);
res.json({
project,
conversationId: cid,
entryFile: imported.entryFile,
files: imported.files,
});
} catch (err) {
if (req.file?.path) fs.promises.unlink(req.file.path).catch(() => {});
res.status(400).json({ error: String(err) });
}
},
);
app.get('/api/projects/:id', (req, res) => {
const project = getProject(db, req.params.id);
if (!project)
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
/** @type {import('@open-design/contracts').ProjectResponse} */
const body = { project };
res.json(body);
});
app.patch('/api/projects/:id', (req, res) => {
try {
const patch = req.body || {};
const project = updateProject(db, req.params.id, patch);
if (!project)
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
/** @type {import('@open-design/contracts').ProjectResponse} */
const body = { project };
res.json(body);
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.delete('/api/projects/:id', async (req, res) => {
try {
dbDeleteProject(db, req.params.id);
await removeProjectDir(PROJECTS_DIR, req.params.id).catch(() => {});
/** @type {import('@open-design/contracts').OkResponse} */
const body = { ok: true };
res.json(body);
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
// SSE stream of file-changed events for a project. Drives preview live-reload.
// Receipt of a `file-changed` event triggers a file-list refresh, which
// propagates new mtimes through to FileViewer iframes (the URL-load
// `?v=${mtime}` cache-bust from PR #384 then reloads the iframe automatically).
// Subscribers come and go as users open/close project tabs; the underlying
// chokidar watcher is refcounted in project-watchers.ts so we never hold
// descriptors for projects no UI is looking at.
app.get('/api/projects/:id/events', (req, res) => {
if (!getProject(db, req.params.id)) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
}
let sub;
try {
const sse = createSseResponse(res);
sub = subscribeFileEvents(PROJECTS_DIR, req.params.id, (evt) => {
sse.send('file-changed', evt);
});
sub.ready.then(() => sse.send('ready', { projectId: req.params.id })).catch(() => {});
const cleanup = () => {
if (sub) {
const { unsubscribe } = sub;
sub = null;
Promise.resolve(unsubscribe()).catch(() => {});
}
};
res.on('close', cleanup);
res.on('finish', cleanup);
} catch (err) {
if (sub) Promise.resolve(sub.unsubscribe()).catch(() => {});
if (!res.headersSent) sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
}
});
// ---- Conversations --------------------------------------------------------
app.get('/api/projects/:id/conversations', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
res.json({ conversations: listConversations(db, req.params.id) });
});
app.post('/api/projects/:id/conversations', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
const { title } = req.body || {};
const now = Date.now();
const conv = insertConversation(db, {
id: randomId(),
projectId: req.params.id,
title: typeof title === 'string' ? title.trim() || null : null,
createdAt: now,
updatedAt: now,
});
res.json({ conversation: conv });
});
app.patch('/api/projects/:id/conversations/:cid', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'not found' });
}
const updated = updateConversation(db, req.params.cid, req.body || {});
res.json({ conversation: updated });
});
app.delete('/api/projects/:id/conversations/:cid', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'not found' });
}
deleteConversation(db, req.params.cid);
res.json({ ok: true });
});
// ---- Messages -------------------------------------------------------------
app.get('/api/projects/:id/conversations/:cid/messages', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
res.json({ messages: listMessages(db, req.params.cid) });
});
app.put('/api/projects/:id/conversations/:cid/messages/:mid', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
const m = req.body || {};
if (m.id && m.id !== req.params.mid) {
return res.status(400).json({ error: 'id mismatch' });
}
const saved = upsertMessage(db, req.params.cid, {
...m,
id: req.params.mid,
});
// Bump the parent project's updatedAt so the project list re-orders.
updateProject(db, req.params.id, {});
res.json({ message: saved });
});
// ---- Preview comments ----------------------------------------------------
app.get('/api/projects/:id/conversations/:cid/comments', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
res.json({
comments: listPreviewComments(db, req.params.id, req.params.cid),
});
});
app.post('/api/projects/:id/conversations/:cid/comments', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
try {
const comment = upsertPreviewComment(
db,
req.params.id,
req.params.cid,
req.body || {},
);
updateProject(db, req.params.id, {});
res.json({ comment });
} catch (err) {
res.status(400).json({ error: String(err?.message || err) });
}
});
app.patch(
'/api/projects/:id/conversations/:cid/comments/:commentId',
(req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
try {
const comment = updatePreviewCommentStatus(
db,
req.params.id,
req.params.cid,
req.params.commentId,
req.body?.status,
);
if (!comment)
return res.status(404).json({ error: 'comment not found' });
updateProject(db, req.params.id, {});
res.json({ comment });
} catch (err) {
res.status(400).json({ error: String(err?.message || err) });
}
},
);
app.delete(
'/api/projects/:id/conversations/:cid/comments/:commentId',
(req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
const ok = deletePreviewComment(
db,
req.params.id,
req.params.cid,
req.params.commentId,
);
if (!ok) return res.status(404).json({ error: 'comment not found' });
updateProject(db, req.params.id, {});
res.json({ ok: true });
},
);
// ---- Tabs -----------------------------------------------------------------
app.get('/api/projects/:id/tabs', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
res.json(listTabs(db, req.params.id));
});
app.put('/api/projects/:id/tabs', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
const { tabs = [], active = null } = req.body || {};
if (!Array.isArray(tabs) || !tabs.every((t) => typeof t === 'string')) {
return res.status(400).json({ error: 'tabs must be string[]' });
}
const result = setTabs(
db,
req.params.id,
tabs,
typeof active === 'string' ? active : null,
);
res.json(result);
});
// ---- Templates ----------------------------------------------------------
// User-saved snapshots of a project's HTML files. Surfaced in the
// "From template" tab of the new-project panel so a user can spin up
// a fresh project pre-seeded with another project's design as a
// starting point. Created via the project's Share menu (snapshots
// every .html file in the project folder at the moment of save).
app.get('/api/templates', (_req, res) => {
res.json({ templates: listTemplates(db) });
});
app.get('/api/templates/:id', (req, res) => {
const t = getTemplate(db, req.params.id);
if (!t) return res.status(404).json({ error: 'not found' });
res.json({ template: t });
});
app.post('/api/templates', async (req, res) => {
try {
const { name, description, sourceProjectId } = req.body || {};
if (typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ error: 'name required' });
}
if (typeof sourceProjectId !== 'string') {
return res.status(400).json({ error: 'sourceProjectId required' });
}
if (!getProject(db, sourceProjectId)) {
return res.status(404).json({ error: 'source project not found' });
}
// Snapshot every HTML / sketch / text file in the source project.
// We deliberately skip binary uploads — templates are about the
// generated design, not the user's reference imagery.
const files = await listFiles(PROJECTS_DIR, sourceProjectId);
const snapshot = [];
for (const f of files) {
if (f.kind !== 'html' && f.kind !== 'text' && f.kind !== 'code')
continue;
const entry = await readProjectFile(
PROJECTS_DIR,
sourceProjectId,
f.name,
);
if (entry && Buffer.isBuffer(entry.buffer)) {
snapshot.push({
name: f.name,
content: entry.buffer.toString('utf8'),
});
}
}
const t = insertTemplate(db, {
id: randomId(),
name: name.trim(),
description: typeof description === 'string' ? description : null,
sourceProjectId,
files: snapshot,
createdAt: Date.now(),
});
res.json({ template: t });
} catch (err) {
res.status(400).json({ error: String(err) });
}
});
app.delete('/api/templates/:id', (req, res) => {
deleteTemplate(db, req.params.id);
res.json({ ok: true });
});
app.get('/api/agents', async (_req, res) => {
try {
const list = await detectAgents();
res.json({ agents: list });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/skills', async (_req, res) => {
try {
const skills = await listSkills(SKILLS_DIR);
// Strip full body + on-disk dir from the listing — frontend fetches the
// body via /api/skills/:id when needed (keeps the listing payload small).
res.json({
skills: skills.map(({ body, dir: _dir, ...rest }) => ({
...rest,
hasBody: typeof body === 'string' && body.length > 0,
})),
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/skills/:id', async (req, res) => {
try {
const skills = await listSkills(SKILLS_DIR);
const skill = findSkillById(skills, req.params.id);
if (!skill) return res.status(404).json({ error: 'skill not found' });
const { dir: _dir, ...serializable } = skill;
res.json(serializable);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Codex hatch-pet registry — pets packaged by the upstream `hatch-pet`
// skill under `${CODEX_HOME:-$HOME/.codex}/pets/`. Surfaced so the web
// pet settings can offer one-click adoption of recently-hatched pets.
app.get('/api/codex-pets', async (_req, res) => {
try {
const result = await listCodexPets({
baseUrl: '',
bundledRoot: BUNDLED_PETS_DIR,
});
res.json(result);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// One-click community sync. Hits the Codex Pet Share + j20 Hatchery
// catalogs and drops every pet into `${CODEX_HOME:-$HOME/.codex}/pets/`
// so `GET /api/codex-pets` (and the web Pet settings) pick them up
// immediately. The body is intentionally tiny — we keep the heavier
// tuning knobs (`--limit`, `--concurrency`) on the CLI script and
// only surface `force` + `source` here.
app.post('/api/codex-pets/sync', async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const sourceRaw = typeof body.source === 'string' ? body.source : 'all';
const source =
sourceRaw === 'petshare' || sourceRaw === 'hatchery'
? sourceRaw
: 'all';
const result = await syncCommunityPets({
source,
force: Boolean(body.force),
});
res.json(result);
} catch (err) {
res.status(500).json({ error: String((err && err.message) || err) });
}
});
app.get('/api/codex-pets/:id/spritesheet', async (req, res) => {
try {
const sheet = await readCodexPetSpritesheet(req.params.id, {
bundledRoot: BUNDLED_PETS_DIR,
});
if (!sheet) {
return res
.status(404)
.type('text/plain')
.send('codex pet spritesheet not found');
}
const mime =
sheet.ext === 'webp'
? 'image/webp'
: sheet.ext === 'gif'
? 'image/gif'
: 'image/png';
res.type(mime);
// Same-origin callers (the web app proxies `/api/*` through to
// the daemon, so PetSettings adoption fetches arrive same-origin)
// do not need any CORS header here. We only echo
// `Access-Control-Allow-Origin` for sandboxed iframes / data:
// URIs (Origin: null) which need it to draw the bytes onto a
// canvas without tainting. Local pet bytes should not be exposed
// to arbitrary third-party origins via a wildcard ACAO.
if (req.headers.origin === 'null') {
res.setHeader('Access-Control-Allow-Origin', 'null');
}
res.setHeader('Cache-Control', 'no-store');
res.sendFile(sheet.absPath);
} catch (err) {
res.status(500).type('text/plain').send(String(err));
}
});
app.get('/api/design-systems', async (_req, res) => {
try {
const systems = await listDesignSystems(DESIGN_SYSTEMS_DIR);
res.json({
designSystems: systems.map(({ body, ...rest }) => rest),
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/design-systems/:id', async (req, res) => {
try {
const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id);
if (body === null)
return res.status(404).json({ error: 'design system not found' });
res.json({ id: req.params.id, body });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/prompt-templates', async (_req, res) => {
try {
const templates = await listPromptTemplates(PROMPT_TEMPLATES_DIR);
res.json({
promptTemplates: templates.map(({ prompt: _prompt, ...rest }) => rest),
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/prompt-templates/:surface/:id', async (req, res) => {
try {
const tpl = await readPromptTemplate(
PROMPT_TEMPLATES_DIR,
req.params.surface,
req.params.id,
);
if (!tpl)
return res.status(404).json({ error: 'prompt template not found' });
res.json({ promptTemplate: tpl });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Showcase HTML for a design system — palette swatches, typography
// samples, sample components, and the full DESIGN.md rendered as prose.
// Built at request time from the on-disk DESIGN.md so any update to the
// file shows up on the next view, no rebuild needed.
app.get('/api/design-systems/:id/preview', async (req, res) => {
try {
const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id);
if (body === null)
return res.status(404).type('text/plain').send('not found');
const html = renderDesignSystemPreview(req.params.id, body);
res.type('text/html').send(html);
} catch (err) {
res.status(500).type('text/plain').send(String(err));
}
});
// Marketing-style showcase derived from the same DESIGN.md — full landing
// page parameterised by the system's tokens. Same lazy-render strategy as
// /preview: built at request time, no caching.
app.get('/api/design-systems/:id/showcase', async (req, res) => {
try {
const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id);
if (body === null)
return res.status(404).type('text/plain').send('not found');
const html = renderDesignSystemShowcase(req.params.id, body);
res.type('text/html').send(html);
} catch (err) {
res.status(500).type('text/plain').send(String(err));
}
});
// Pre-built example HTML for a skill — what a typical artifact from this
// skill looks like. Lets users browse skills without running an agent.
//
// The skill's `id` (from SKILL.md frontmatter `name`) can differ from its
// on-disk folder name (e.g. id `magazine-web-ppt` lives in `skills/guizang-ppt/`),
// so we resolve the actual directory via listSkills() rather than guessing.
//
// Resolution order:
// 1. /example.html — fully-baked static example (preferred)
// 2. /assets/template.html +
// /assets/example-slides.html — assemble at request time
// by replacing the `` marker with the snippet
// and patching the placeholder . Lets a skill ship one
// canonical seed plus a small content fragment, so the example
// never drifts from the seed.
// 3. /assets/template.html — raw template, no content slides
// 4. /assets/index.html — generic fallback
app.get('/api/skills/:id/example', async (req, res) => {
try {
const skills = await listSkills(SKILLS_DIR);
const skill = findSkillById(skills, req.params.id);
if (!skill) {
return res.status(404).type('text/plain').send('skill not found');
}
const baked = path.join(skill.dir, 'example.html');
if (fs.existsSync(baked)) {
const html = await fs.promises.readFile(baked, 'utf8');
return res
.type('text/html')
.send(rewriteSkillAssetUrls(html, skill.id));
}
const tpl = path.join(skill.dir, 'assets', 'template.html');
const slides = path.join(skill.dir, 'assets', 'example-slides.html');
if (fs.existsSync(tpl) && fs.existsSync(slides)) {
try {
const tplHtml = await fs.promises.readFile(tpl, 'utf8');
const slidesHtml = await fs.promises.readFile(slides, 'utf8');
const assembled = assembleExample(tplHtml, slidesHtml, skill.name);
return res
.type('text/html')
.send(rewriteSkillAssetUrls(assembled, skill.id));
} catch {
// Fall through to raw template on read failure.
}
}
if (fs.existsSync(tpl)) {
const html = await fs.promises.readFile(tpl, 'utf8');
return res
.type('text/html')
.send(rewriteSkillAssetUrls(html, skill.id));
}
const idx = path.join(skill.dir, 'assets', 'index.html');
if (fs.existsSync(idx)) {
const html = await fs.promises.readFile(idx, 'utf8');
return res
.type('text/html')
.send(rewriteSkillAssetUrls(html, skill.id));
}
res
.status(404)
.type('text/plain')
.send(
'no example.html, assets/template.html, or assets/index.html for this skill',
);
} catch (err) {
res.status(500).type('text/plain').send(String(err));
}
});
// Static assets shipped beside a skill's example/template HTML. Lets the
// example HTML reference `./assets/foo.png`-style paths that resolve
// correctly when the response is loaded into a sandboxed `srcdoc` iframe
// (where relative URLs would otherwise resolve against `about:srcdoc`).
// The example response above rewrites `./assets/` into a request
// against this route; we still keep the on-disk paths human-friendly so
// contributors can preview `example.html` straight from disk.
app.get('/api/skills/:id/assets/*', async (req, res) => {
try {
const skills = await listSkills(SKILLS_DIR);
const skill = findSkillById(skills, req.params.id);
if (!skill) {
return res.status(404).type('text/plain').send('skill not found');
}
const relPath = String(req.params[0] || '');
const assetsRoot = path.resolve(skill.dir, 'assets');
const target = path.resolve(assetsRoot, relPath);
if (target !== assetsRoot && !target.startsWith(assetsRoot + path.sep)) {
return res.status(400).type('text/plain').send('invalid asset path');
}
if (!fs.existsSync(target)) {
return res.status(404).type('text/plain').send('asset not found');
}
// The example HTML is rendered inside a sandboxed iframe (Origin: null).
// Mirror the project /raw route's allowance so the iframe can fetch the
// image bytes; same-origin web callers do not need this header.
if (req.headers.origin === 'null') {
res.header('Access-Control-Allow-Origin', '*');
}
res.type(mimeFor(target)).sendFile(target);
} catch (err) {
res.status(500).type('text/plain').send(String(err));
}
});
app.post('/api/upload', upload.array('images', 8), (req, res) => {
const files = (req.files || []).map((f) => ({
name: f.originalname,
path: f.path,
size: f.size,
}));
res.json({ files });
});
// Persist a generated artifact (HTML) to disk so the user can re-open it
// in their browser or hand it off. Returns the on-disk path + a served URL.
// The body is also passed through the anti-slop linter; findings are
// returned alongside the path so the UI can render a P0/P1 badge and the
// chat layer can splice them into a system reminder for the agent.
app.post('/api/artifacts/save', (req, res) => {
try {
const { identifier, title, html } = req.body || {};
if (typeof html !== 'string' || html.length === 0) {
return res.status(400).json({ error: 'html required' });
}
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
const slug = sanitizeSlug(identifier || title || 'artifact');
const dir = path.join(ARTIFACTS_DIR, `${stamp}-${slug}`);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'index.html');
fs.writeFileSync(file, html, 'utf8');
const findings = lintArtifact(html);
res.json({
path: file,
url: `/artifacts/${path.basename(dir)}/index.html`,
lint: findings,
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Standalone lint endpoint — POST raw HTML, get findings back.
// The chat layer uses this to lint streamed-in artifacts without writing
// them to disk first, so a P0 issue can be surfaced before save.
app.post('/api/artifacts/lint', (req, res) => {
try {
const { html } = req.body || {};
if (typeof html !== 'string' || html.length === 0) {
return res.status(400).json({ error: 'html required' });
}
const findings = lintArtifact(html);
res.json({
findings,
agentMessage: renderFindingsForAgent(findings),
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.use('/artifacts', express.static(ARTIFACTS_DIR));
// ---- Deploy --------------------------------------------------------------
app.get('/api/deploy/config', async (_req, res) => {
try {
/** @type {import('@open-design/contracts').DeployConfigResponse} */
const body = publicDeployConfig(await readVercelConfig());
res.json(body);
} catch (err) {
sendApiError(res, 500, 'INTERNAL_ERROR', String(err?.message || err));
}
});
app.put('/api/deploy/config', async (req, res) => {
try {
/** @type {import('@open-design/contracts').DeployConfigResponse} */
const body = await writeVercelConfig(req.body || {});
res.json(body);
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
}
});
app.get('/api/projects/:id/deployments', (req, res) => {
try {
/** @type {import('@open-design/contracts').ProjectDeploymentsResponse} */
const body = { deployments: listDeployments(db, req.params.id) };
res.json(body);
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
}
});
app.post('/api/projects/:id/deploy', async (req, res) => {
try {
const { fileName, providerId = VERCEL_PROVIDER_ID } = req.body || {};
if (providerId !== VERCEL_PROVIDER_ID) {
return sendApiError(
res,
400,
'BAD_REQUEST',
'unsupported deploy provider',
);
}
if (typeof fileName !== 'string' || !fileName.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'fileName required');
}
const prior = getDeployment(db, req.params.id, fileName, providerId);
const files = await buildDeployFileSet(
PROJECTS_DIR,
req.params.id,
fileName,
);
const result = await deployToVercel({
config: await readVercelConfig(),
files,
projectId: req.params.id,
});
const now = Date.now();
/** @type {import('@open-design/contracts').DeployProjectFileResponse} */
const body = upsertDeployment(db, {
id: prior?.id ?? randomUUID(),
projectId: req.params.id,
fileName,
providerId,
url: result.url,
deploymentId: result.deploymentId,
deploymentCount: (prior?.deploymentCount ?? 0) + 1,
target: 'preview',
status: result.status,
statusMessage: result.statusMessage,
reachableAt: result.reachableAt,
createdAt: prior?.createdAt ?? now,
updatedAt: now,
});
res.json(body);
} catch (err) {
const status = err instanceof DeployError ? err.status : 400;
const init =
err instanceof DeployError && err.details
? { details: err.details }
: {};
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err?.message || err),
init,
);
}
});
app.post('/api/projects/:id/deploy/preflight', async (req, res) => {
try {
const { fileName, providerId = VERCEL_PROVIDER_ID } = req.body || {};
if (providerId !== VERCEL_PROVIDER_ID) {
return sendApiError(
res,
400,
'BAD_REQUEST',
'unsupported deploy provider',
);
}
if (typeof fileName !== 'string' || !fileName.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'fileName required');
}
/** @type {import('@open-design/contracts').DeployPreflightResponse} */
const body = await prepareDeployPreflight(
PROJECTS_DIR,
req.params.id,
fileName,
);
res.json(body);
} catch (err) {
// DeployError is a known/expected outcome (validation, missing file).
// Anything else points at a bug or an unexpected runtime state, so
// surface it in the daemon log without leaking internals to the
// client which still gets a generic 400.
if (!(err instanceof DeployError)) {
console.error('[deploy/preflight]', err);
}
const status = err instanceof DeployError ? err.status : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err?.message || err),
);
}
});
app.post(
'/api/projects/:id/deployments/:deploymentId/check-link',
async (req, res) => {
try {
const existing = getDeploymentById(
db,
req.params.id,
req.params.deploymentId,
);
if (!existing) {
return sendApiError(
res,
404,
'FILE_NOT_FOUND',
'deployment not found',
);
}
const result = await checkDeploymentUrl(existing.url);
const now = Date.now();
/** @type {import('@open-design/contracts').CheckDeploymentLinkResponse} */
const body = upsertDeployment(db, {
...existing,
status: result.reachable ? 'ready' : result.status || 'link-delayed',
statusMessage: result.reachable
? 'Public link is ready.'
: result.statusMessage ||
'Vercel is still preparing the public link.',
reachableAt: result.reachable ? now : existing.reachableAt,
updatedAt: now,
});
res.json(body);
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
}
},
);
// Shared device frames (iPhone, Android, iPad, MacBook, browser chrome).
// Skills can compose multi-screen / multi-device layouts by pointing at
// these files via `