/** * File-based persistence for user-defined apps and their records. * * Directory layout: * ~/.vellum/apps/ * .json # app definition * / * records/ * .json # individual record * * `dirName` is a human-readable slug derived from the app name at creation * time and frozen thereafter (renaming an app does NOT rename its directory). * Pre-migration apps (no `dirName` in JSON) fall back to using `id` (UUID). */ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path"; import { resolveConversationLineage } from "../daemon/conversation-lineage.js"; import { rawAll } from "../persistence/raw-query.js"; import { isPluginDisabled } from "../plugins/disabled-state.js"; import type { EditEngineResult } from "../tools/shared/filesystem/edit-engine.js"; import { applyEdit } from "../tools/shared/filesystem/edit-engine.js"; import { getLogger } from "../util/logger.js"; import { getDataDir, getWorkspacePluginsDir } from "../util/platform.js"; const log = getLogger("app-store"); export interface AppDefinition { id: string; name: string; description?: string; icon?: string; preview?: string; schemaJson: string; htmlDefinition: string; version?: string; /** Additional pages keyed by filename (e.g. "settings.html" -> HTML content). */ pages?: Record; createdAt: number; updatedAt: number; /** App format version; 2 = multi-file TSX (the only supported format). */ formatVersion?: number; /** Filesystem directory/file stem. Frozen at creation -- never changes on rename. */ dirName?: string; /** Conversation IDs that have interacted with this app (create/open/refresh). */ conversationIds?: string[]; /** * The subset of {@link conversationIds} the app reached only by lineage — * ancestors of the conversation that actually interacted with it. Absent * means every association is direct. */ inheritedConversationIds?: string[]; } /** * Fallback body served for apps in the retired single-file HTML format. * These apps have no compilable `src/`, so there is nothing to build or serve. */ export const UNSUPPORTED_LEGACY_APP_HTML = `

This app uses the retired single-file format and can no longer be opened. Ask the assistant to recreate it.

`; /** * Detect an app directory in the retired single-file HTML format: a root * `index.html` with no `src/` to compile. Used to serve a clear message * instead of a misleading "compilation failed" fallback. */ export function isLegacySingleFileDir(sourceDir: string): boolean { return ( !existsSync(join(sourceDir, "src")) && existsSync(join(sourceDir, "index.html")) ); } /** * Resolve the effective HTML for an app by reading the compiled * `dist/index.html` and inlining JS/CSS assets so the result is a * self-contained HTML string suitable for `loadHTMLString`. */ export function resolveEffectiveAppHtml(app: AppDefinition): string { return resolveEffectiveAppHtmlFromDir(getAppDirPath(app.id)); } /** * Resolve the effective, self-contained HTML for an app given its source * directory, without needing a loaded {@link AppDefinition}. Used to render * apps addressed by path (e.g. plugin-bundled apps). Serves the compiled * dist/index.html with JS/CSS inlined. */ export function resolveEffectiveAppHtmlFromDir(sourceDir: string): string { const distIndex = join(sourceDir, "dist", "index.html"); if (existsSync(distIndex)) { return inlineDistAssets(sourceDir, readFileSync(distIndex, "utf-8")); } if (isLegacySingleFileDir(sourceDir)) { return UNSUPPORTED_LEGACY_APP_HTML; } return `

App compilation failed. Edit a source file to trigger a rebuild.

`; } /** * Inline dist assets (main.js, main.css) into the compiled HTML so it can be * delivered as a self-contained string via loadHTMLString/SSE without needing * the client to resolve external script/stylesheet URLs. */ export function inlineDistAssets(appDir: string, html: string): string { const distDir = join(appDir, "dist"); // Inline main.js const jsPath = join(distDir, "main.js"); if (existsSync(jsPath)) { const js = readFileSync(jsPath, "utf-8").replace( /<\/script>/g, "<\\/script>", ); html = html.replace( /<\/script>/, () => ``, ); } // Inline main.css const cssPath = join(distDir, "main.css"); if (existsSync(cssPath)) { const css = readFileSync(cssPath, "utf-8"); html = html.replace( //, () => ``, ); } return html; } export interface AppRecord { id: string; appId: string; data: Record; createdAt: number; updatedAt: number; } function validateId(id: string): void { if ( !id || id.includes("/") || id.includes("\\") || id.includes("..") || id !== id.trim() ) { throw new Error(`Invalid ID: ${id}`); } } /** * Validate a dirName read from persisted JSON. * Superset of validateId rules plus git pathspec metacharacters, since * dirName becomes a filesystem path segment under the apps directory. */ export function validateDirName(dirName: string): void { if ( !dirName || dirName.includes("/") || dirName.includes("\\") || dirName.includes("..") || dirName !== dirName.trim() ) { throw new Error(`Invalid dirName: ${dirName}`); } // Reject git pathspec metacharacters if (/[*?[\]:()]/.test(dirName)) { throw new Error( `Invalid dirName: contains git pathspec characters: ${dirName}`, ); } } /** * Validate a page filename to prevent path traversal and ensure it is a safe * relative filename (e.g. "settings.html"). */ function validatePageFilename(filename: string): void { if ( !filename || filename.includes("/") || filename.includes("\\") || filename.includes("..") || filename !== filename.trim() || filename === "index.html" || filename === "manifest.json" || filename === "signature.json" ) { throw new Error(`Invalid page filename: ${filename}`); } } export function getAppsDir(): string { const dir = join(getDataDir(), "apps"); mkdirSync(dir, { recursive: true }); return dir; } // --------------------------------------------------------------------------- // Slug generation // --------------------------------------------------------------------------- /** * Convert a name to a filesystem-safe slug. * - Lowercase * - Replace non-alphanumeric (except hyphens) with hyphens * - Collapse consecutive hyphens, trim leading/trailing * - Truncate to 60 chars (re-trim trailing hyphen) * - Fall back to "app-" if result is empty (e.g. emoji-only names) */ export function slugify(name: string): string { let slug = name .toLowerCase() .replace(/[^a-z0-9-]/g, "-") .replace(/-{2,}/g, "-") .replace(/^-+|-+$/g, ""); if (slug.length > 60) { slug = slug.slice(0, 60).replace(/-+$/, ""); } if (!slug) { slug = `app-${randomUUID().slice(0, 8)}`; } return slug; } /** * Generate a unique directory name from an app name. * Appends -2, -3, etc. if the base slug collides with existing names. */ export function generateAppDirName( name: string, existingNames: Set, ): string { const base = slugify(name); if (!existingNames.has(base)) { return base; } let counter = 2; while (existingNames.has(`${base}-${counter}`)) { counter++; } return `${base}-${counter}`; } // --------------------------------------------------------------------------- // App directory resolution (id -> dirName) // --------------------------------------------------------------------------- /** Cache of id -> dirName mappings to avoid repeated filesystem scans. */ const idToDirNameCache = new Map(); /** Reverse cache: dirName -> id. */ const dirNameToIdCache = new Map(); /** * Resolve an app's directory name and path from its ID. * Scans JSON files if not cached. Falls back to `id` for pre-migration apps. */ export function resolveAppDir(id: string): { dirName: string; appDir: string; } { validateId(id); // Check cache first const cached = idToDirNameCache.get(id); if (cached) { return { dirName: cached, appDir: join(getAppsDir(), cached) }; } const dir = getAppsDir(); const entries = readdirSync(dir); for (const entry of entries) { if (!entry.endsWith(".json")) { continue; } const filePath = join(dir, entry); try { const raw = readFileSync(filePath, "utf-8"); const parsed = JSON.parse(raw) as { id?: string; dirName?: string }; if (parsed.id === id) { const dirName = parsed.dirName || id; if (parsed.dirName) { validateDirName(dirName); } idToDirNameCache.set(id, dirName); return { dirName, appDir: join(dir, dirName) }; } } catch { // skip malformed files } } // If no JSON file found, fall back to id (backward compat) idToDirNameCache.set(id, id); return { dirName: id, appDir: join(dir, id) }; } /** Convenience wrapper: returns the app directory path for the given app ID. */ export function getAppDirPath(appId: string): string { return resolveAppDir(appId).appDir; } /** * Resolve an app ID from its directory name (slug). * Checks caches first, then reads the JSON definition file directly. */ export function resolveAppIdByDirName(dirName: string): string | null { const cached = dirNameToIdCache.get(dirName); if (cached) { return cached; } // Check forward cache (reverse iteration) for (const [id, dn] of idToDirNameCache) { if (dn === dirName) { dirNameToIdCache.set(dirName, id); return id; } } // Read the JSON definition file directly const dir = getAppsDir(); const jsonPath = join(dir, `${dirName}.json`); if (existsSync(jsonPath)) { try { const raw = readFileSync(jsonPath, "utf-8"); const parsed = JSON.parse(raw) as { id?: string; dirName?: string }; if (parsed.id) { dirNameToIdCache.set(dirName, parsed.id); idToDirNameCache.set(parsed.id, dirName); return parsed.id; } } catch { // skip malformed files } } return null; } /** * Extract app ID from an absolute file path if it falls within the apps * directory and targets a source file (not records/ or dist/). */ export function resolveAppIdFromPath(filePath: string): string | null { let appsDir: string; try { appsDir = getAppsDir(); } catch { return null; } if (!filePath.startsWith(appsDir + "/")) { return null; } const relPath = filePath.slice(appsDir.length + 1); const slashIdx = relPath.indexOf("/"); if (slashIdx === -1) { return null; } // file directly in apps/ (e.g. the .json definition) const dirName = relPath.slice(0, slashIdx); const innerPath = relPath.slice(slashIdx + 1); // Skip non-source directories if (innerPath.startsWith("records/") || innerPath.startsWith("dist/")) { return null; } return resolveAppIdByDirName(dirName); } /** Invalidate the id->dirName cache for a specific app or all apps. */ function invalidateDirNameCache(appId?: string): void { if (appId) { const dirName = idToDirNameCache.get(appId); idToDirNameCache.delete(appId); if (dirName) { dirNameToIdCache.delete(dirName); } } else { idToDirNameCache.clear(); dirNameToIdCache.clear(); } } // --------------------------------------------------------------------------- // File path validation // --------------------------------------------------------------------------- /** * Validate a relative file path within an app directory. * Prevents path traversal and access to protected directories. * Returns the resolved absolute path. */ function validateFilePath(appId: string, path: string): string { return validateFilePathInDir(getAppDirPath(appId), path); } /** * Validate a relative file path within a known app directory (absolute path). * Prevents path traversal and access to protected directories. Used when the * app directory is already resolved (e.g. plugin-bundled apps addressed by * path rather than by a workspace app id). */ function validateFilePathInDir(appDir: string, path: string): string { if (!path || path.trim() === "") { throw new Error(`Invalid file path: path is empty`); } if (isAbsolute(path)) { throw new Error(`Invalid file path: absolute paths are not allowed`); } if (path.includes("..")) { throw new Error(`Invalid file path: '..' is not allowed`); } // Reject paths targeting records/ directory const normalized = path.replace(/\\/g, "/"); if (normalized === "records" || normalized.startsWith("records/")) { throw new Error(`Invalid file path: 'records/' directory is protected`); } const resolved = resolve(appDir, path); // Ensure the resolved path is still within the app directory if (!resolved.startsWith(appDir + "/") && resolved !== appDir) { throw new Error(`Invalid file path: resolves outside app directory`); } // Follow symlinks to the real path so a symlink inside the app directory // cannot escape the boundary. For non-existent paths, walk up to the // nearest existing ancestor and resolve it, then re-append trailing // components -- catches symlinked parent directories on new file writes. let realResolved: string; if (existsSync(resolved)) { realResolved = realpathSync(resolved); } else { let current = resolved; const trailing: string[] = []; realResolved = resolved; while (current !== dirname(current)) { try { const real = realpathSync(current); realResolved = trailing.length > 0 ? join(real, ...trailing) : real; break; } catch { trailing.unshift(basename(current)); current = dirname(current); } } } const realAppDir = existsSync(appDir) ? realpathSync(appDir) : appDir; if ( !realResolved.startsWith(realAppDir + "/") && realResolved !== realAppDir ) { throw new Error( `Invalid file path: symlink resolves outside app directory`, ); } return resolved; } // --------------------------------------------------------------------------- // Pages helpers // --------------------------------------------------------------------------- /** Persist pages as individual files under the app's pages/ subdirectory. */ function savePages(appDirPath: string, pages: Record): void { const pagesDir = join(appDirPath, "pages"); mkdirSync(pagesDir, { recursive: true }); for (const [filename, content] of Object.entries(pages)) { validatePageFilename(filename); if (typeof content !== "string") { throw new Error( `Page content for "${filename}" must be a string, got ${typeof content}`, ); } writeFileSync(join(pagesDir, filename), content, "utf-8"); } } /** Load pages from disk. Returns undefined if no pages directory exists. */ function loadPages(appDirPath: string): Record | undefined { const pagesDir = join(appDirPath, "pages"); if (!existsSync(pagesDir)) { return undefined; } const entries = readdirSync(pagesDir); if (entries.length === 0) { return undefined; } const pages: Record = {}; for (const entry of entries) { pages[entry] = readFileSync(join(pagesDir, entry), "utf-8"); } return pages; } // --------------------------------------------------------------------------- // Existing dirName collector (for dedup) // --------------------------------------------------------------------------- /** Scan all JSON files and build a set of existing dirName values. */ function collectExistingDirNames(): Set { const dir = getAppsDir(); const entries = readdirSync(dir); const names = new Set(); for (const entry of entries) { if (!entry.endsWith(".json")) { continue; } try { const raw = readFileSync(join(dir, entry), "utf-8"); const parsed = JSON.parse(raw) as { id?: string; dirName?: string }; // Use dirName if present, otherwise fall back to id (pre-migration) names.add(parsed.dirName ?? parsed.id ?? entry.replace(/\.json$/, "")); } catch { // skip malformed } } return names; } // --------------------------------------------------------------------------- // CRUD operations // --------------------------------------------------------------------------- export function createApp(params: { name: string; description?: string; icon?: string; preview?: string; schemaJson: string; htmlDefinition: string; version?: string; pages?: Record; }): AppDefinition { const dir = getAppsDir(); const now = Date.now(); // Generate a unique dirName from the app name const existingNames = collectExistingDirNames(); const dirName = generateAppDirName(params.name, existingNames); const app: AppDefinition = { id: randomUUID(), name: params.name, description: params.description, icon: params.icon, preview: params.preview, schemaJson: params.schemaJson, htmlDefinition: params.htmlDefinition, version: params.version, createdAt: now, updatedAt: now, formatVersion: 2, dirName, }; // Write htmlDefinition to {dirName}/index.html on disk const appDir = join(dir, dirName); mkdirSync(appDir, { recursive: true }); if (typeof params.htmlDefinition !== "string") { throw new Error( `htmlDefinition must be a string, got ${typeof params.htmlDefinition}`, ); } writeFileSync(join(appDir, "index.html"), params.htmlDefinition, "utf-8"); // Write preview to companion file to keep the JSON small if (params.preview) { writeFileSync(join(dir, `${dirName}.preview`), params.preview, "utf-8"); } // Strip htmlDefinition, pages, and preview from the JSON file -- only store metadata const { htmlDefinition: _html, pages: _pages, preview: _preview, ...jsonDef } = app; writeFileSync(join(dir, `${dirName}.json`), JSON.stringify(jsonDef, null, 2)); // Update cache idToDirNameCache.set(app.id, dirName); // Persist additional pages as separate files if (params.pages && Object.keys(params.pages).length > 0) { savePages(appDir, params.pages); app.pages = params.pages; } return app; } export function getApp(id: string): AppDefinition | null { validateId(id); const { dirName, appDir } = resolveAppDir(id); const dir = getAppsDir(); const filePath = join(dir, `${dirName}.json`); if (!existsSync(filePath)) { return null; } const raw = readFileSync(filePath, "utf-8"); const app = JSON.parse(raw) as AppDefinition; // Read htmlDefinition from {dirName}/index.html on disk const indexPath = join(appDir, "index.html"); app.htmlDefinition = existsSync(indexPath) ? readFileSync(indexPath, "utf-8") : (app.htmlDefinition ?? ""); // Load preview from companion file const previewPath = join(dir, `${dirName}.preview`); if (existsSync(previewPath)) { app.preview = readFileSync(previewPath, "utf-8"); } // Load pages from disk const pages = loadPages(appDir); if (pages) { app.pages = pages; } return app; } /** * Load just the preview data for an app without reading the full definition. * Returns the base64 preview string or null if not available. */ export function getAppPreview(id: string): string | null { validateId(id); const { dirName } = resolveAppDir(id); const dir = getAppsDir(); const previewPath = join(dir, `${dirName}.preview`); if (existsSync(previewPath)) { return readFileSync(previewPath, "utf-8"); } return null; } export function listApps(): AppDefinition[] { const dir = getAppsDir(); const entries = readdirSync(dir); const apps: AppDefinition[] = []; for (const entry of entries) { if (!entry.endsWith(".json")) { continue; } const filePath = join(dir, entry); try { const raw = readFileSync(filePath, "utf-8"); const app = JSON.parse(raw) as AppDefinition; apps.push(app); } catch { // skip malformed files } } apps.sort((a, b) => b.updatedAt - a.updatedAt); return apps; } // --------------------------------------------------------------------------- // Cross-source enumeration (workspace + plugin-bundled apps) // --------------------------------------------------------------------------- /** * Where an app is provided from. Workspace apps are user-created and live under * `/data/apps/`; plugin apps are bundled by an installed plugin * under `/plugins//apps/`. */ export type AppOrigin = | { readonly kind: "workspace" } | { readonly kind: "plugin"; readonly pluginName: string }; /** * Id prefix marking a plugin-bundled app. A plugin app's id encodes its * location: `plugins~~`, which maps to * `/plugins//apps/` (the `apps/` segment is * implied). Unlike workspace apps — whose id is an opaque UUID looked up by * scanning — a plugin app is resolved by a direct path build. * * The delimiter is `~` (a URL-unreserved character), not `/`, so the id is a * single URL path segment: it survives `:id` route params and proxies without * percent-encoding (`%2F` in a path is widely mishandled). */ const PLUGIN_APP_ID_PREFIX = "plugins~"; const PLUGIN_APP_ID_SEP = "~"; /** Build the addressable id for a plugin-bundled app. */ function pluginAppId(pluginName: string, appDir: string): string { return `${PLUGIN_APP_ID_PREFIX}${pluginName}${PLUGIN_APP_ID_SEP}${appDir}`; } /** True when an app id addresses a plugin-bundled app (vs a workspace app). */ export function isPluginAppId(id: string): boolean { return id.startsWith(PLUGIN_APP_ID_PREFIX); } /** An app paired with the absolute path to its source and its origin. */ export interface EnumeratedApp { /** * Addressable app id: an opaque UUID for workspace apps, or * `plugins~~` for plugin-bundled apps. */ readonly id: string; /** Human-readable app name. */ readonly name: string; /** Absolute path to the app's source directory. */ readonly sourcePath: string; /** Where the app is provided from. */ readonly origin: AppOrigin; } /** * Enumerate apps bundled under a single plugin's `apps/` directory. Each * immediate subdirectory is one app: its directory name is the app name and * its path is the source. */ function listAppsForPlugin( pluginName: string, pluginDir: string, ): EnumeratedApp[] { const appsDir = join(pluginDir, "apps"); let entries: string[]; try { entries = readdirSync(appsDir); } catch { // No apps/ directory (or unreadable) — this plugin bundles no apps. return []; } const apps: EnumeratedApp[] = []; for (const entry of entries) { const appDir = join(appsDir, entry); try { // statSync follows symlinks so a symlinked app directory still counts. if (!statSync(appDir).isDirectory()) { continue; } } catch { continue; } apps.push({ id: pluginAppId(pluginName, entry), name: entry, sourcePath: appDir, origin: { kind: "plugin", pluginName }, }); } return apps; } /** * Enumerate apps bundled by installed, enabled plugins under * `/plugins//apps/`. * * Plugin discovery mirrors the plugin loader's `scanPlugins`: a plugin is an * entry that resolves to a directory (following symlinks) and carries a * `package.json` manifest. Stray directories without a manifest are ignored, * and disabled plugins (those with a `.disabled` sentinel) contribute nothing, * matching how their other surfaces (tools, hooks, routes) are gated. */ export function listPluginApps(): EnumeratedApp[] { const pluginsRoot = getWorkspacePluginsDir(); if (!existsSync(pluginsRoot)) { return []; } let entries: string[]; try { entries = readdirSync(pluginsRoot); } catch { return []; } const apps: EnumeratedApp[] = []; for (const name of entries) { const pluginDir = join(pluginsRoot, name); try { if (!statSync(pluginDir).isDirectory()) { continue; } } catch { continue; } if (!existsSync(join(pluginDir, "package.json"))) { continue; } if (isPluginDisabled(name)) { continue; } apps.push(...listAppsForPlugin(name, pluginDir)); } return apps; } /** * Enumerate every app the assistant can surface, tagged with its origin: * workspace apps (user-created) followed by apps bundled by installed plugins. */ export function listAllApps(): EnumeratedApp[] { const workspaceApps: EnumeratedApp[] = listApps().map((definition) => ({ id: definition.id, name: definition.name, sourcePath: getAppDirPath(definition.id), origin: { kind: "workspace" as const }, })); return [...workspaceApps, ...listPluginApps()]; } /** An app id resolved to its on-disk source and metadata. */ export interface ResolvedAppSource { /** The id that was resolved (echoed back for the caller). */ readonly id: string; /** Human-readable app name. */ readonly name: string; /** Filesystem directory stem — the workspace slug or the plugin app dir. */ readonly dirName: string; /** Absolute path to the app's source directory. */ readonly sourceDir: string; /** Where the app is provided from. */ readonly origin: AppOrigin; } /** * Validate a single plugin-app id path segment (plugin name or app directory). * Rejects empty, traversal, separators, and untrimmed values so an id can be * mapped to a filesystem path without escaping the plugins root. */ function isSafeIdSegment(segment: string): boolean { return ( segment.length > 0 && segment === segment.trim() && !segment.includes("/") && !segment.includes("\\") && !segment.includes("..") && segment !== "." ); } /** * Resolve an app id to its on-disk source, for both workspace apps (opaque * UUID, looked up via {@link getApp}) and plugin-bundled apps * (`plugins~~`, resolved by direct path build). Returns null when * the app does not exist, or when a plugin id fails the same installed-plugin * gates as discovery (directory, `package.json` manifest, not disabled). */ export function resolveAppSource(id: string): ResolvedAppSource | null { if (id.startsWith(PLUGIN_APP_ID_PREFIX)) { const rest = id.slice(PLUGIN_APP_ID_PREFIX.length); // Split on the first delimiter: the plugin name is kebab-case (no `~`), so // everything after it is the app directory (which may itself contain `~`). const sep = rest.indexOf(PLUGIN_APP_ID_SEP); if (sep === -1) { return null; } const pluginName = rest.slice(0, sep); const appDirName = rest.slice(sep + PLUGIN_APP_ID_SEP.length); // Both must be safe single path components — the app segment, used as a // directory name, must not smuggle in separators or traversal. if (!isSafeIdSegment(pluginName) || !isSafeIdSegment(appDirName)) { return null; } const pluginDir = join(getWorkspacePluginsDir(), pluginName); try { if (!statSync(pluginDir).isDirectory()) { return null; } } catch { return null; } if (!existsSync(join(pluginDir, "package.json"))) { return null; } if (isPluginDisabled(pluginName)) { return null; } const sourceDir = join(pluginDir, "apps", appDirName); try { if (!statSync(sourceDir).isDirectory()) { return null; } } catch { return null; } return { id, name: appDirName, dirName: appDirName, sourceDir, origin: { kind: "plugin", pluginName }, }; } const app = getApp(id); if (!app) { return null; } const { dirName } = resolveAppDir(id); return { id, name: app.name, dirName, sourceDir: getAppDirPath(id), origin: { kind: "workspace" }, }; } export function updateApp( id: string, updates: Partial< Pick< AppDefinition, | "name" | "description" | "icon" | "preview" | "schemaJson" | "htmlDefinition" | "version" | "pages" > >, ): AppDefinition { validateId(id); const existing = getApp(id); if (!existing) { throw new Error(`App not found: ${id}`); } const { dirName, appDir } = resolveAppDir(id); // Extract pages, htmlDefinition, and preview before spreading into the JSON-persisted definition const { pages, htmlDefinition: htmlUpdate, preview: previewUpdate, ...jsonUpdates } = updates; const updated: AppDefinition = { ...existing, ...jsonUpdates, updatedAt: Date.now(), }; // Write htmlDefinition to {dirName}/index.html if provided in updates const dir = getAppsDir(); if (htmlUpdate !== undefined) { updated.htmlDefinition = htmlUpdate; mkdirSync(appDir, { recursive: true }); writeFileSync(join(appDir, "index.html"), htmlUpdate, "utf-8"); } // Write preview to companion file if (previewUpdate !== undefined) { updated.preview = previewUpdate; writeFileSync(join(dir, `${dirName}.preview`), previewUpdate, "utf-8"); } // Don't persist htmlDefinition, pages, or preview in the JSON file -- they live as separate files const { pages: _existingPages, htmlDefinition: _html, preview: _preview, ...jsonDef } = updated; writeFileSync(join(dir, `${dirName}.json`), JSON.stringify(jsonDef, null, 2)); // Clear existing pages directory before writing new pages to prevent stale files if (pages && Object.keys(pages).length > 0) { const pagesDir = join(appDir, "pages"); if (existsSync(pagesDir)) { rmSync(pagesDir, { recursive: true, force: true }); } savePages(appDir, pages); } // Re-attach pages to the returned object const loadedPages = loadPages(appDir); if (loadedPages) { updated.pages = loadedPages; } return updated; } export function deleteApp(id: string): void { validateId(id); const { dirName, appDir } = resolveAppDir(id); const dir = getAppsDir(); const filePath = join(dir, `${dirName}.json`); if (existsSync(filePath)) { unlinkSync(filePath); } const previewPath = join(dir, `${dirName}.preview`); if (existsSync(previewPath)) { unlinkSync(previewPath); } rmSync(appDir, { recursive: true, force: true }); invalidateDirNameCache(id); } export function createAppRecord( appId: string, data: Record, ): AppRecord { validateId(appId); const app = getApp(appId); if (!app) { throw new Error(`App not found: ${appId}`); } const recordsDir = join(getAppDirPath(appId), "records"); mkdirSync(recordsDir, { recursive: true }); const now = Date.now(); const record: AppRecord = { id: randomUUID(), appId, data, createdAt: now, updatedAt: now, }; writeFileSync( join(recordsDir, `${record.id}.json`), JSON.stringify(record, null, 2), ); return record; } export function getAppRecord( appId: string, recordId: string, ): AppRecord | null { validateId(appId); validateId(recordId); const filePath = join(getAppDirPath(appId), "records", `${recordId}.json`); if (!existsSync(filePath)) { return null; } const raw = readFileSync(filePath, "utf-8"); return JSON.parse(raw) as AppRecord; } export function queryAppRecords(appId: string): AppRecord[] { validateId(appId); const recordsDir = join(getAppDirPath(appId), "records"); if (!existsSync(recordsDir)) { return []; } const entries = readdirSync(recordsDir); const records: AppRecord[] = []; for (const entry of entries) { if (!entry.endsWith(".json")) { continue; } try { const raw = readFileSync(join(recordsDir, entry), "utf-8"); records.push(JSON.parse(raw) as AppRecord); } catch { // skip malformed files } } return records; } export function updateAppRecord( appId: string, recordId: string, data: Record, ): AppRecord { validateId(appId); validateId(recordId); const existing = getAppRecord(appId, recordId); if (!existing) { throw new Error(`AppRecord not found: ${appId}/${recordId}`); } const updated: AppRecord = { ...existing, data, updatedAt: Date.now(), }; writeFileSync( join(getAppDirPath(appId), "records", `${recordId}.json`), JSON.stringify(updated, null, 2), ); return updated; } export function deleteAppRecord(appId: string, recordId: string): void { validateId(appId); validateId(recordId); const filePath = join(getAppDirPath(appId), "records", `${recordId}.json`); if (existsSync(filePath)) { unlinkSync(filePath); } } // --------------------------------------------------------------------------- // File-based app storage // --------------------------------------------------------------------------- /** * Recursively list all files under the app's directory, excluding `records/` * subdirectory and `app.json`. Returns relative paths like `index.html`, * `styles.css`, `js/app.js`. */ export function listAppFiles(appId: string): string[] { validateId(appId); const appDir = getAppDirPath(appId); if (!existsSync(appDir)) { return []; } const results: string[] = []; function walk(dir: string): void { const entries = readdirSync(dir); for (const entry of entries) { const fullPath = join(dir, entry); const relPath = relative(appDir, fullPath); // Skip records/ directory const normalized = relPath.replace(/\\/g, "/"); if (normalized === "records" || normalized.startsWith("records/")) { continue; } // Skip app.json if (normalized === "app.json") { continue; } const stat = statSync(fullPath); if (stat.isDirectory()) { walk(fullPath); } else { results.push(normalized); } } } walk(appDir); return results.sort(); } /** * Check whether a file exists in the app directory. * Path is validated to prevent traversal. */ export function appFileExists(appId: string, path: string): boolean { validateId(appId); const resolved = validateFilePath(appId, path); return existsSync(resolved); } /** * Read a file from the app directory. * Path is validated to prevent traversal. */ export function readAppFile(appId: string, path: string): string { validateId(appId); const resolved = validateFilePath(appId, path); if (!existsSync(resolved)) { throw new Error(`File not found: ${path}`); } return readFileSync(resolved, "utf-8"); } /** * Read a file from the app directory as raw bytes. * Path is validated to prevent traversal. Use for binary assets (images, * audio, video, fonts) where {@link readAppFile}'s utf-8 decoding corrupts * the data. */ export function readAppFileBytes(appId: string, path: string): Buffer { validateId(appId); const resolved = validateFilePath(appId, path); if (!existsSync(resolved)) { throw new Error(`File not found: ${path}`); } return readFileSync(resolved); } /** * Read a file as raw bytes from an app directory addressed by its absolute * path (rather than by a workspace app id). Path is validated to prevent * traversal. Used to serve assets for plugin-bundled apps, whose source dir is * resolved via {@link resolveAppSource}. */ export function readAppFileBytesFromDir( sourceDir: string, path: string, ): Buffer { const resolved = validateFilePathInDir(sourceDir, path); if (!existsSync(resolved)) { throw new Error(`File not found: ${path}`); } return readFileSync(resolved); } /** * Write a file to the app directory. * Auto-creates intermediate directories. Path is validated to prevent traversal. */ export function writeAppFile( appId: string, path: string, content: string, ): void { validateId(appId); const resolved = validateFilePath(appId, path); const dir = join(resolved, ".."); mkdirSync(dir, { recursive: true }); writeFileSync(resolved, content, "utf-8"); } /** * Edit a file in the app directory using the edit engine (match/replace). * Returns the EditEngineResult from applyEdit. */ export function editAppFile( appId: string, path: string, oldString: string, newString: string, replaceAll?: boolean, ): EditEngineResult { validateId(appId); const resolved = validateFilePath(appId, path); if (!existsSync(resolved)) { throw new Error(`File not found: ${path}`); } const content = readFileSync(resolved, "utf-8"); const result = applyEdit(content, oldString, newString, replaceAll ?? false); if (result.ok) { writeFileSync(resolved, result.updatedContent, "utf-8"); } return result; } // --------------------------------------------------------------------------- // Conversation association helpers // --------------------------------------------------------------------------- /** * Associate a conversation with an app. Writes directly to the JSON metadata * file without bumping `updatedAt` so the app list ordering is preserved. * * `inherited` marks an association the app reached through a descendant's * lineage rather than through work done in the conversation itself. A direct * association supersedes an inherited one for the same conversation. * * @returns `true` if the association was added, `false` if the app was not * found or the conversationId was already present (dedup). */ export function addAppConversationId( appId: string, conversationId: string, options?: { inherited?: boolean }, ): boolean { const app = getApp(appId); if (!app) { return false; } const { dirName } = resolveAppDir(appId); const dir = getAppsDir(); const jsonPath = join(dir, `${dirName}.json`); // Atomic read-modify-write: re-read the file immediately before writing // so concurrent callers (e.g. two tool_result handlers for the same app // in different conversations) merge against the latest on-disk state. // Because readFileSync → JSON.parse → writeFileSync is a synchronous // chain with no async gaps, Node/Bun's single-threaded event loop // guarantees no interleaving between the read and the write. const raw = readFileSync(jsonPath, "utf-8"); const parsed = JSON.parse(raw) as Record; const onDiskIds = Array.isArray(parsed.conversationIds) ? (parsed.conversationIds as string[]) : []; // An absent list means the record holds only direct associations. const onDiskInherited = Array.isArray(parsed.inheritedConversationIds) ? (parsed.inheritedConversationIds as string[]) : []; const inherited = options?.inherited === true; if (onDiskIds.includes(conversationId)) { if (inherited || !onDiskInherited.includes(conversationId)) { return false; } parsed.inheritedConversationIds = onDiskInherited.filter( (id) => id !== conversationId, ); writeFileSync(jsonPath, JSON.stringify(parsed, null, 2)); return false; } parsed.conversationIds = [...onDiskIds, conversationId]; if (inherited) { parsed.inheritedConversationIds = [...onDiskInherited, conversationId]; } writeFileSync(jsonPath, JSON.stringify(parsed, null, 2)); return true; } /** * Associate an app with the conversation that produced it and every subagent * ancestor above it. A background subagent (e.g. the live-voice duplex * continuation) runs in a forked conversation the user never sees, so linking * only the fork leaves the app unopenable from the thread the user actually * has in front of them. Associations are additive and de-duplicated, so * linking the whole chain is safe. * * The seed conversation's association is direct; every ancestor's is * inherited, so an implicit "the app I am working on" lookup in an ancestor * thread is not captured by a background run's app. * * Best-effort per id: a link failure must never break app creation or opening. * Returns the number of associations added. */ export function linkAppToConversationLineage( appId: string, conversationId: string, ): number { // Lineage resolution reaches the conversation store; an unexpected throw // there degrades to the conversation itself rather than breaking the link. let lineage: string[]; try { lineage = resolveConversationLineage(conversationId); } catch (err) { log.warn( { err, appId, conversationId }, "Failed to resolve conversation lineage for app association", ); lineage = [conversationId]; } let associationsAdded = 0; for (const [index, id] of lineage.entries()) { try { if (addAppConversationId(appId, id, { inherited: index > 0 })) { associationsAdded += 1; } } catch (err) { log.warn( { err, appId, conversationId: id }, "Failed to associate app with conversation", ); } } return associationsAdded; } /** * Return all apps associated with a given conversation ID, whether the * association is direct or inherited from a descendant subagent run — a user * finds and opens a background run's app from the thread they can see. */ export function listAppsByConversation( conversationId: string, ): AppDefinition[] { return listApps().filter((app) => app.conversationIds?.includes(conversationId), ); } /** * Whether an app's association with a conversation is direct — the app was * created or operated on in that conversation itself, rather than reaching it * through a descendant's lineage. A record carrying no * `inheritedConversationIds` holds only direct associations. */ export function isDirectAppConversation( app: AppDefinition, conversationId: string, ): boolean { return app.inheritedConversationIds?.includes(conversationId) !== true; } // --------------------------------------------------------------------------- // Backfill: scan message history for ui_surface blocks referencing apps // --------------------------------------------------------------------------- /** * Single-pass scan over the messages table to populate `conversationIds` on * existing app definitions. Finds messages containing `ui_surface` blocks * with a `data.appId`, then calls `addAppConversationId` for each pair. * * Runs once per workspace: after a successful backfill a sentinel file * (`/.conversation-ids-backfilled`) is written. Subsequent daemon * startups skip the scan entirely. If the apps directory is wiped the * sentinel disappears and the backfill re-runs — which is correct behavior. * * Wrapped in try/catch so failures never block daemon start. */ export function backfillAppConversationIds(): void { // Check sentinel — skip the potentially expensive scan when already done. const sentinelPath = join(getAppsDir(), ".conversation-ids-backfilled"); if (existsSync(sentinelPath)) { log.debug("Skipping backfillAppConversationIds — sentinel exists"); return; } try { // Unfinalized rows cannot match: their content is a { ref } pointer, // never inline surface JSON, so this scan sees finalized content only by // shape. No completeness predicate needed. const rows = rawAll<{ conversation_id: string; content: string }>( "apps:backfillConversationIds", `SELECT conversation_id, content FROM messages WHERE content LIKE '%"type":"ui_surface"%'`, ); // Build appId → Set map in a single pass const appConvMap = new Map>(); for (const row of rows) { let parsed: unknown; try { parsed = JSON.parse(row.content); } catch { // Skip rows that fail to parse continue; } if (!Array.isArray(parsed)) { continue; } for (const block of parsed) { if ( block && typeof block === "object" && (block as Record).type === "ui_surface" ) { const data = (block as Record).data; if (data && typeof data === "object") { const appId = (data as Record).appId; if (typeof appId === "string" && appId.length > 0) { let convIds = appConvMap.get(appId); if (!convIds) { convIds = new Set(); appConvMap.set(appId, convIds); } convIds.add(row.conversation_id); } } } } } // Apply associations let appsUpdated = 0; let associationsAdded = 0; for (const [appId, conversationIds] of appConvMap) { let appHadNewAssociation = false; for (const conversationId of conversationIds) { const added = addAppConversationId(appId, conversationId); if (added) { associationsAdded++; appHadNewAssociation = true; } } if (appHadNewAssociation) { appsUpdated++; } } log.info( { appsUpdated, associationsAdded }, `Backfilled app conversationIds: ${appsUpdated} apps, ${associationsAdded} associations`, ); // Write sentinel so subsequent startups skip this scan. writeFileSync(sentinelPath, new Date().toISOString(), "utf-8"); } catch (err) { log.error({ err }, "Failed to backfill app conversationIds"); } } export type { EditEngineResult };