import { Hono } from 'hono' import { serveStatic } from 'hono/bun' import { createMiddleware } from 'hono/factory' import { existsSync } from 'node:fs' import { basename, join, resolve } from 'node:path' import { UPDATE_ACTIVE_AGENT_MESSAGE } from '@/lib/update' import type { AppletKind, AppletThumbnailBatch, AppSettings, HarnessAvailability, SessionInfo, UploadInfo, ViewBuilderInput, WorkspaceAgent, WorkspaceEntry, WorkspaceType } from '@/lib/types' import type { MoiContext } from '@/lib/moi-context' import { viewBuilderDirectives } from '@/lib/view-builder-directives' import { agentStore } from './agent' import { clientAppConfig, getAppConfig } from './app-config' import { getAppSettings, pickAppSettingsPatch, saveAppSettings } from './app-settings' import { appletForModule, recordAppletError } from './applet-log' import { apiBaseFor, parseAppletTail, serveWorkspaceFile } from './applets' import { applyEnvChanged } from './env-apply' import { publishEvent } from './events' import { callFunction, parseFunctionPath } from './functions' import { processIcon } from './icon' import { getWorkspacePreview, loadLayout, mergeLayoutForSave, saveLayout } from './layout' import { getAppletThumbnailRecords, isValidAppletId, saveAppletThumbnails, serveAppletThumbnail } from './thumbnails' import { getClientFrameLog, getWireLog } from './harness/debug' import { allHarnesses, harnessFor, isHarnessType } from './harness/registry' import { broadcast } from './state' import { discoverWorkspaces, getWorkspace, listWorkspaces, registerWorkspace, removeWorkspace, reorderWorkspaces, tildify } from './registry' import { loadScratchpadDoc, saveScratchpadDoc } from './scratchpad' import { MAX_ASSET_BYTES, scratchpadAssetFile, storeScratchpadAsset } from './scratchpad-assets' import { clearSelectedSession, getSelectedSession, initializeSelectedSession, saveSelectedSession } from './selected-session' import type { SelectedSessionUpdate } from './selected-session' import { getSessionConfig, saveSessionConfig } from './session-config' import type { SessionConfigPatch } from './session-config' import { DIST_DIR, prebuilt } from './static' import { getWorkspaceSkillsStatus, updateWorkspaceSkills } from './skill-update' import { serveWorkspaceImagePreview } from './preview' import { MAX_UPLOAD_BYTES, addUpload, getUpload } from './uploads' import { requiredEnvFor } from './required-env' import { getViewList, listViews, serveView } from './views' import { ViewBuilderError, beginViewBuilder, createViewBuilder, deleteViewBuilder, markViewBuilderWaiting, reconcileViewBuilders, updateViewBuilderInput } from './view-builders' import { listWidgets, serveWidget } from './widgets' import { getWorkspaceConfig, setWorkspaceConfig } from './workspace-config' import { CREATED_WORKSPACES_ROOT, provisionWorkspace, validateWorkspaceFolderName } from './workspace-init' import { resolveWorkspaceImportMetadata } from './workspace-import' import type { WorkspaceImportMetadata } from './workspace-import' import { getWorkspaceEnvView, isValidEnvKey, updateWorkspaceEnv } from './workspace-env' import type { EnvUpdate } from './workspace-env' import { getCachedUpdateStatus, hasRunningAgentSessions, installUpdate, restartPendingForUpdate, scheduleRestartForUpdate, updateInProgress } from './update' // The resolved workspace is stashed on the context by `withWorkspace`, so every // `/api/workspaces/:id/*` handler can read it without re-querying the registry. type ApiEnv = { Variables: { ws: WorkspaceEntry } } function publishSelectedSession(workspaceId: string, update: SelectedSessionUpdate): void { if (!update.changed) return publishEvent({ type: 'selected-session:updated', workspaceId, sessionId: update.sessionId }) } async function handleFunctionCall( req: Request, tail: string, workspacePath: string ): Promise { // Module keys may contain slashes (`widgets/hello/getWeather`), so the // tail is parsed on the last slash — see parseFunctionPath. const parsed = parseFunctionPath(tail) if (!parsed) { return new Response('Invalid module or function name', { status: 400 }) } const { module, name } = parsed try { const contentLength = Number(req.headers.get('content-length') ?? 0) if (contentLength > 1_000_000) { return new Response('Request body too large', { status: 413 }) } const args = await req.text() const result = await callFunction(module, name, args, workspacePath) return new Response(result, { headers: { 'Content-Type': 'application/json' } }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' // Journal the failure so the agent can find it via `moi debug logs` — the // 500 body below only reaches the browser (see docs/self-correction.md). recordAppletError(workspacePath, { source: 'rpc', ...(appletForModule(module) ?? {}), module, fn: name, message }) return new Response(message, { status: 500 }) } } // Resolve `:id` to a registered workspace once, 404 if unknown, and stash it on // the context. Mounted on the single-workspace sub-app so every nested route // shares the lookup instead of repeating `getWorkspace(...) ?? 404`. const withWorkspace = createMiddleware(async (c, next) => { const ws = await getWorkspace(c.req.param('id')) if (!ws) return c.text('Workspace not found', 404) c.set('ws', ws) await next() }) // ---- single workspace: /api/workspaces/:id/* -------------------------------- const one = new Hono() one.use('*', withWorkspace) one.get('/preview', async c => { const ws = c.get('ws') const wsId = c.req.param('id') const views = await getViewList(ws.path) return c.json( await getWorkspacePreview(ws.path, { getProviderPreview: includeFirstUserMessage => harnessFor(ws).workspacePreview(ws, includeFirstUserMessage), viewIds: views.map(view => view.id), thumbnailUrl: (kind, id) => `/api/workspaces/${wsId}/applet-thumbnails/${kind}/${encodeURIComponent(id)}` }) ) }) one.get('/widgets', c => listWidgets(c.get('ws').path)) // Widget bundle: the compiled ESM for one widget, dynamically imported by the // client (useWidget). Sits beside the GET .../widgets list above — the exact // path lists, `/*` serves a file from the bundle dir (`/`: // index.js, a chunk, or a hashed asset). one.get('/widgets/*', c => { const id = c.req.param('id') const { name, file } = parseAppletTail(c.req.url, id, 'widgets') if (!name) return c.text('Not found', 404) return serveWidget(name, file, c.get('ws').path, apiBaseFor(id), c.req.header('if-none-match')) }) // Views — full-screen agent apps. Mirrors the widget pair above: the exact path // lists (in manifest/nav order), `/*` serves one bundle file. one.get('/views', c => listViews(c.get('ws').path)) one.get('/views/*', c => { const id = c.req.param('id') const { name, file } = parseAppletTail(c.req.url, id, 'views') if (!name) return c.text('Not found', 404) return serveView(name, file, c.get('ws').path, apiBaseFor(id), c.req.header('if-none-match')) }) function viewBuilderError(err: unknown): { message: string; status: 400 | 404 | 409 } | null { return err instanceof ViewBuilderError ? { message: err.message, status: err.status } : null } function parseAvailableViewIcons(value: unknown): string[] | null { if (!Array.isArray(value)) return null const icons = value.filter( (icon): icon is string => typeof icon === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(icon) && icon.length <= 64 ) return icons.length > 0 ? [...new Set(icons)] : null } one.get('/view-builders', async c => { const ws = c.get('ws') const activeSessionIds = new Set( allHarnesses() .flatMap(h => h.activeSessions()) .filter(session => session.workspaceId === ws.id) .map(session => session.sessionId) ) const builders = await reconcileViewBuilders( ws.id, ws.path, await getViewList(ws.path), activeSessionIds ) // Widget builders are record-only for now — reconciled server-side but kept // out of the host's view-builder tab list until their UI lands. return c.json({ builders: builders.filter(builder => builder.kind !== 'widget') }) }) one.post('/view-builders', async c => { const ws = c.get('ws') return c.json(await createViewBuilder(ws.id, ws.path), 201) }) one.patch('/view-builders/:builderId', async c => { const ws = c.get('ws') const body = await c.req.json<{ input?: Partial }>() if (typeof body?.input?.requirements !== 'string') { return c.text('Expected { input: { requirements: string } }', 400) } try { return c.json( await updateViewBuilderInput( ws.id, ws.path, c.req.param('builderId'), body.input.requirements ) ) } catch (err) { const known = viewBuilderError(err) if (known) return c.text(known.message, known.status) throw err } }) one.post('/view-builders/:builderId/submit', async c => { const ws = c.get('ws') const body = await c.req.json<{ input?: Partial optimisticId?: string model?: string effort?: string fastMode?: boolean stream?: boolean availableIcons?: unknown attachments?: unknown }>() if (typeof body?.input?.requirements !== 'string') { return c.text('Expected { input: { requirements: string } }', 400) } if (body.optimisticId !== undefined && typeof body.optimisticId !== 'string') { return c.text('Invalid optimisticId', 400) } const availableIcons = parseAvailableViewIcons(body.availableIcons) if (!availableIcons) return c.text('Available view icons are required', 400) const attachments = body.attachments ?? [] if ( !Array.isArray(attachments) || attachments.length > 1 || !attachments.every(id => typeof id === 'string' && /^[a-f0-9]{64}$/.test(id)) ) { return c.text('Invalid sketch attachment', 400) } if (attachments.some(id => getUpload(ws.id, id)?.kind !== 'image')) { return c.text('Sketch attachment not found or expired', 400) } const availability = await workspaceTypeAvailability(ws.type ?? 'claude-code') if (!availability.available) return c.text(availability.reason, 400) try { const builder = await beginViewBuilder( ws.id, ws.path, c.req.param('builderId'), body.input.requirements, attachments.length > 0 ) // The bootstrap instructions ride the moi-context envelope, injected by // the harness like any other ambient context; the user text stays bare. // The user submits from the builder's own tab, so that's the active tab. const context: MoiContext = { activeTab: `view-builder:${builder.id}`, directives: [ ...viewBuilderDirectives(builder.id, availableIcons), ...(attachments.length > 0 ? ["The attached image is the user's sketch of the intended view layout."] : []) ] } try { publishSelectedSession(ws.id, await saveSelectedSession(ws.path, builder.sessionId)) await harnessFor(ws).sendMessage({ workspaceId: ws.id, workspacePath: ws.path, sessionId: builder.sessionId, isNew: true, content: builder.input.requirements, attachments, context, optimisticId: body.optimisticId, model: typeof body.model === 'string' ? body.model : undefined, effort: typeof body.effort === 'string' ? body.effort : undefined, fastMode: typeof body.fastMode === 'boolean' ? body.fastMode : undefined, stream: body.stream === true ? true : undefined, agentId: ws.agentId }) } catch (err) { const message = err instanceof Error ? err.message : 'Could not start view builder' await markViewBuilderWaiting(ws.id, ws.path, builder.id, message) return c.text(message, 500) } return c.json(builder) } catch (err) { const known = viewBuilderError(err) if (known) return c.text(known.message, known.status) throw err } }) one.delete('/view-builders/:builderId', async c => { const ws = c.get('ws') try { await deleteViewBuilder(ws.id, ws.path, c.req.param('builderId')) return c.body(null, 204) } catch (err) { const known = viewBuilderError(err) if (known) return c.text(known.message, known.status) throw err } }) // Workspace file stream — an applet's `fileUrl(path)` resolves here. Streams a // media file from the workspace root (range-enabled). Guarded: traversal and // dotfiles (`.env`, `.moi`, `.git`) are rejected and only media/asset extensions // are allowed — the workspace holds secrets, and this route is unauthenticated. // localhost binding is NOT the guard. one.get('/fs/*', c => { const id = c.req.param('id') const tail = new URL(c.req.url).pathname.split(`/api/workspaces/${id}/fs/`)[1] ?? '' return serveWorkspaceFile( c.get('ws').path, tail, c.req.header('range'), c.req.header('if-none-match') ) }) // Applet RPC — the home for server-function calls from a bundle. The bundle's // sentinel base resolves to `/api/workspaces/`, so it POSTs to // `…/rpc//`. one.post('/rpc/*', c => { const id = c.req.param('id') const tail = new URL(c.req.url).pathname.split(`/api/workspaces/${id}/rpc/`)[1] ?? '' return handleFunctionCall(c.req.raw, tail, c.get('ws').path) }) // Browser-side applet errors (module load failures, render crashes, window // errors attributed to a bundle) reported into the workspace's error journal — // `moi debug logs` reads it back (docs/self-correction.md). Unauthenticated // localhost route, so treat the payload as hostile: whitelist the browser-only // sources (`build`/`rpc` are server-recorded and can't be spoofed by a tab), // pattern-check the applet name, cap the batch size; applet-log.ts caps string // lengths. one.post('/applet-log', async c => { let body: { events?: unknown } try { body = await c.req.json() } catch { return c.text('Invalid JSON', 400) } const events = Array.isArray(body.events) ? body.events.slice(0, 10) : [] for (const raw of events) { const e = raw as { source?: unknown kind?: unknown name?: unknown message?: unknown stack?: unknown } if ( e.source !== 'load' && e.source !== 'render' && e.source !== 'window' && e.source !== 'runtime' ) continue // `runtime` entries may be unattributed (a stale link to a deleted view // names no applet), so attribution is optional there and dropped when // malformed — every other source must name the applet that broke. const attributed = (e.kind === 'widget' || e.kind === 'view') && typeof e.name === 'string' const named = attributed && /^[a-zA-Z0-9_-]+$/.test(e.name as string) if (!named && e.source !== 'runtime') continue if (typeof e.message !== 'string' || !e.message) continue recordAppletError(c.get('ws').path, { source: e.source, ...(named ? { kind: e.kind as AppletKind, name: e.name as string } : {}), message: e.message, ...(typeof e.stack === 'string' ? { stack: e.stack } : {}) }) } return c.body(null, 204) }) // Downscaled image preview of a workspace file. The chat's expanded tool rows // use this to show the picture an agent `Read` — same guards as /fs/ above, // images only, resized server-side (see server/preview.ts). one.get('/preview/*', c => { const id = c.req.param('id') const tail = new URL(c.req.url).pathname.split(`/api/workspaces/${id}/preview/`)[1] ?? '' return serveWorkspaceImagePreview(c.get('ws').path, tail, c.req.header('if-none-match')) }) // Chat attachments. The composer POSTs files here (drag/drop, paste, or the // attach button) ahead of sending; we process + stash them and hand back opaque // upload ids the chat WS frame references. Images are downscaled and inlined as // vision blocks at send time; other files are referenced by a temp path. See // server/uploads.ts and dev/file-uploads.md. one.post('/uploads', async c => { const id = c.req.param('id') let form: FormData try { form = await c.req.formData() } catch { return c.text('Expected multipart/form-data', 400) } const files = form.getAll('files').filter((f): f is File => f instanceof File) if (files.length === 0) return c.text('No files', 400) if (files.length > 20) return c.text('Too many files (max 20)', 400) const out: UploadInfo[] = [] for (const file of files) { if (file.size > MAX_UPLOAD_BYTES) { return c.text(`"${file.name}" is too large (max ${MAX_UPLOAD_BYTES / (1024 * 1024)} MB)`, 413) } try { const bytes = Buffer.from(await file.arrayBuffer()) out.push( await addUpload({ workspaceId: id, filename: file.name || 'file', mediaType: file.type || 'application/octet-stream', bytes }) ) } catch (err) { const message = err instanceof Error ? err.message : 'Failed to process upload' return c.text(`"${file.name}": ${message}`, 400) } } return c.json(out) }) // Serve an upload's bytes back. Display parts reference this URL instead of a // base64 data URL so the transcript broadcast / client cache stay small. The id // is content-addressed (sha256), so the response is immutable while it lives — // let the browser cache it for the store's TTL. one.get('/uploads/:uploadId', c => { const u = getUpload(c.req.param('id'), c.req.param('uploadId')) if (!u) return c.text('Not found or expired', 404) const body: BodyInit | null = u.data ?? (u.path ? Bun.file(u.path) : null) if (!body) return c.text('Not found or expired', 404) return new Response(body, { headers: { 'Content-Type': u.mediaType, 'Content-Disposition': `inline; filename="${u.filename.replaceAll('"', '')}"`, 'Cache-Control': 'private, max-age=1800, immutable' } }) }) one.get('/sessions', async c => { const ws = c.get('ws') return c.json(await harnessFor(ws).listSessions(ws)) }) one.get('/selected-session', async c => { const ws = c.get('ws') let sessionId = await getSelectedSession(ws.path) if (sessionId === undefined) { const sessions = await harnessFor(ws).listSessions(ws) const latest = sessions.reduce( (current, session) => !current || session.lastModified > current.lastModified || (session.lastModified === current.lastModified && session.sessionId.localeCompare(current.sessionId) < 0) ? session : current, undefined ) sessionId = await initializeSelectedSession(ws.path, latest?.sessionId ?? null) } return c.json({ sessionId }) }) one.put('/selected-session', async c => { const ws = c.get('ws') const body: unknown = await c.req.json().catch(() => null) if (!body || typeof body !== 'object') return c.text('Bad request', 400) const input = body as { sessionId?: unknown; previousSessionId?: unknown } const validSessionId = input.sessionId === null || typeof input.sessionId === 'string' const hasPreviousSessionId = Object.prototype.hasOwnProperty.call(input, 'previousSessionId') const validPreviousSessionId = !hasPreviousSessionId || input.previousSessionId === null || typeof input.previousSessionId === 'string' if (!validSessionId || !validPreviousSessionId) return c.text('Bad request', 400) const update = await saveSelectedSession( ws.path, input.sessionId as string | null, hasPreviousSessionId ? (input.previousSessionId as string | null) : undefined ) publishSelectedSession(ws.id, update) return c.json({ sessionId: update.sessionId }) }) one.post('/sessions/:sessionId/archive', async c => { const ws = c.get('ws') const harness = harnessFor(ws) const sessionId = c.req.param('sessionId') if (!harness.archiveSession) return c.text('Chat archiving is not supported', 501) try { await harness.interrupt(ws.id, sessionId) await harness.archiveSession(ws, sessionId) publishSelectedSession(ws.id, await clearSelectedSession(ws.path, sessionId)) broadcast(ws.id, { type: 'sessions_changed', sessionId }) return c.body(null, 204) } catch (error) { console.error(`[api] archive chat failed for ${harness.id}`, error) // Harnesses translate backend refusals into actionable one-liners (e.g. // OpenClaw: main chat not archivable, gateway too old) — surface them. const message = error instanceof Error && error.message ? error.message : 'Couldn’t archive chat' return c.text(message, 500) } }) one.get('/sessions/:sessionId/events', async c => { const ws = c.get('ws') return c.json(await harnessFor(ws).sessionEvents(ws, c.req.param('sessionId'))) }) // Per-session agent settings (model, reasoning effort, and Fast mode). GET // returns the stored config ({} for sessions that never overrode the workspace // defaults); PUT patches it (a field as `null` clears it, omitted leaves it). // The change takes effect on the session's next message. one.get('/sessions/:sessionId/config', async c => { return c.json(await getSessionConfig(c.get('ws').path, c.req.param('sessionId'))) }) one.put('/sessions/:sessionId/config', async c => { const body = await c.req.json().catch(() => null) if (typeof body !== 'object' || body === null) { return c.text('Expected a JSON object', 400) } const patch: SessionConfigPatch = {} const record = body as Record for (const key of ['model', 'effort'] as const) { if (!(key in record)) continue const value = record[key] if (value !== null && typeof value !== 'string') { return c.text(`${key} must be a string or null`, 400) } patch[key] = value } if ('fastMode' in record) { const value = record.fastMode if (value !== null && typeof value !== 'boolean') { return c.text('fastMode must be a boolean or null', 400) } patch.fastMode = value } return c.json(await saveSessionConfig(c.get('ws').path, c.req.param('sessionId'), patch)) }) one.get('/mcp', async c => { const ws = c.get('ws') return c.json((await harnessFor(ws).mcpStatus?.(ws)) ?? []) }) // Harness debug tap for /playground/harness: the backend's native wire frames // (Codex: app-server JSON-RPC, both directions; Claude Code: raw SDK messages // + enqueued inputs) and the exact frames the server pushed to chat clients. // `sinceWire`/`sinceBroadcast` are seq cursors so the page can poll deltas. one.get('/harness/debug', async c => { const ws = c.get('ws') const harness = harnessFor(ws) const sinceWire = Number(c.req.query('sinceWire') ?? 0) || 0 const sinceBroadcast = Number(c.req.query('sinceBroadcast') ?? 0) || 0 return c.json({ provider: harness.id, process: (await harness.debugInfo?.(ws)) ?? null, wire: getWireLog(harness.wireScope?.(ws) ?? ws.id, sinceWire), broadcasts: getClientFrameLog(ws.id, sinceBroadcast) }) }) // Per-workspace env vars. GET returns the effective view (discovered `.env` + UI // custom secrets + scopes + declared-required keys; values masked). one.get('/env', async c => { const ws = c.get('ws') // Unawaited on purpose: getWorkspaceEnvView loads it in parallel with the // env stores (manifest reads and env reads are independent). return c.json(await getWorkspaceEnvView(ws.path, requiredEnvFor(ws.path))) }) // PUT patches custom secrets (set/remove) and/or the inheritDotenv mode, // then reaps the workspace's function worker and idle agent sessions so the next // call/message picks up the change (env is frozen at spawn — a hard restart is // the only way). one.put('/env', async c => { const ws = c.get('ws') const body = await c.req.json().catch(() => null) if (!body || typeof body !== 'object') { return c.text('Bad request', 400) } const patch: EnvUpdate = {} if (body.set !== undefined) { if (typeof body.set !== 'object' || body.set === null) { return c.text('set must be an object', 400) } for (const [k, v] of Object.entries(body.set)) { if (!isValidEnvKey(k)) return c.text(`Invalid env key: ${k}`, 400) if (typeof v !== 'string') { return c.text(`Value for ${k} must be a string`, 400) } } patch.set = body.set as Record } if (body.remove !== undefined) { if (!Array.isArray(body.remove) || body.remove.some((k: unknown) => typeof k !== 'string')) { return c.text('remove must be an array of strings', 400) } patch.remove = body.remove as string[] } if (body.inheritDotenv !== undefined) { if (typeof body.inheritDotenv !== 'boolean') { return c.text('inheritDotenv must be a boolean', 400) } patch.inheritDotenv = body.inheritDotenv } // Skip the write + reaps for a no-op PUT (no recognized fields) so an empty // body doesn't needlessly kill warm workers / idle sessions. const hasChange = patch.set !== undefined || patch.remove !== undefined || patch.inheritDotenv !== undefined if (hasChange) { await updateWorkspaceEnv(ws.path, patch) // Frozen-at-spawn: reap workers/idle sessions and tell other clients. applyEnvChanged(ws) } return c.json(await getWorkspaceEnvView(ws.path, requiredEnvFor(ws.path))) }) // Start a provider login ceremony. Codex returns a browser OAuth URL for the // host to open; Claude launches its browser flow through the CLI itself. // Idempotent per workspace: a second call joins the pending ceremony instead // of starting another provider flow. Completion is pushed as `agent:updated`. one.post('/auth/login', async c => { const ws = c.get('ws') if (!harnessFor(ws).startLogin) return c.text('This agent requires terminal sign-in', 409) try { return c.json(await agentStore.startLogin(ws)) } catch (err) { return c.text(err instanceof Error ? err.message : 'Could not start sign-in', 500) } }) one.get('/skills', async c => { const ws = c.get('ws') return c.json(await getWorkspaceSkillsStatus(ws.path, ws.type)) }) one.post('/skills/update', async c => { const ws = c.get('ws') const result = await updateWorkspaceSkills(ws.path, ws.type ?? 'claude-code') return c.json(result.status) }) // The workspace's agent backend in one snapshot: provider, availability // (runtime presence + auth, from the server-side cache), any in-flight login // ceremony, the model catalog, and capabilities. One request at workspace // open; the volatile parts stay fresh afterwards via `agent:updated` events, // not refetches. one.get('/agent', async c => { const ws = c.get('ws') const harness = harnessFor(ws) // A backend that can't answer (codex CLI missing, gateway down) degrades to // an empty catalog — the picker hides and chat surfaces the real problem via // the availability banner, instead of this endpoint 500ing on page load. const [availability, models] = await Promise.all([ agentStore.getAvailability(ws), harness.listModels(ws).catch(err => { console.error(`[api] listModels failed for ${harness.id}`, err) return [] }) ]) const login = agentStore.getLogin(ws.id) return c.json({ provider: harness.id, availability, ...(login ? { login } : {}), models, supportsStreaming: harness.capabilities.supportsStreaming, supportsArchiving: Boolean(harness.archiveSession) } satisfies WorkspaceAgent) }) // Workspace identity (name). GET returns the current {name, icon}; PUT a JSON // `{ name }` sets it (or `null` clears it). Broadcasts so the sidebar and header // update live. Icon is handled by the binary route below. one.get('/config', async c => { return c.json(await getWorkspaceConfig(c.get('ws').path)) }) one.put('/config', async c => { const ws = c.get('ws') const body = await c.req.json().catch(() => null) const name = body?.name if (name !== null && typeof name !== 'string') { return c.text('Expected { name: string | null }', 400) } await setWorkspaceConfig(ws.path, { name }) publishEvent({ type: 'workspace:updated' }) return c.json(await getWorkspaceConfig(ws.path)) }) // Scratchpad canvas. GET returns the persisted tldraw document snapshot // ({ document } or { document: null } when empty) for hydration; PUT saves a new // snapshot ({ document, origin }) and broadcasts so other open tabs reload. // `origin` is the writing tab's id — echoed in the broadcast so that tab can // ignore its own save. This is the browser's write path; the agent's draws are // written server-side (see scratchpad-executor.ts). See docs/moi-scratchpad.md. one.get('/scratchpad', async c => { return c.json(await loadScratchpadDoc(c.get('ws').path)) }) one.put('/scratchpad', async c => { const ws = c.get('ws') const body = await c.req.json().catch(() => null) if (!body || typeof body !== 'object' || !body.document) { return c.text('Expected { document }', 400) } await saveScratchpadDoc(body.document, ws.path) publishEvent({ type: 'scratchpad:updated', workspaceId: c.req.param('id'), origin: typeof body.origin === 'string' ? body.origin : undefined }) return c.body(null, 204) }) // Scratchpad assets: pasted/dropped image bytes live as content-addressed files // in `.moi/.scratchpad/`, referenced from the snapshot by `asset:` srcs — // never inlined as base64 in the JSON (see server/scratchpad-assets.ts). The // browser's TLAssetStore POSTs the raw file here on paste and resolves `asset:` // srcs back through the GET when rendering. one.post('/scratchpad/assets', async c => { const length = Number(c.req.header('content-length') ?? 0) if (length > MAX_ASSET_BYTES) { return c.text(`Asset too large (max ${MAX_ASSET_BYTES / (1024 * 1024)} MB)`, 413) } const bytes = new Uint8Array(await c.req.arrayBuffer()) if (bytes.length === 0) return c.text('Empty body', 400) if (bytes.length > MAX_ASSET_BYTES) { return c.text(`Asset too large (max ${MAX_ASSET_BYTES / (1024 * 1024)} MB)`, 413) } const mimeType = c.req.header('content-type') ?? 'application/octet-stream' return c.json(await storeScratchpadAsset(c.get('ws').path, bytes, mimeType)) }) // The file name is content-addressed (sha256 of the bytes), so a hit is // immutable — let the browser cache it indefinitely. one.get('/scratchpad/assets/:file', async c => { const resolved = scratchpadAssetFile(c.get('ws').path, c.req.param('file')) if (!resolved || !(await resolved.file.exists())) return c.text('Not found', 404) return new Response(resolved.file, { headers: { 'Content-Type': resolved.mimeType, 'Cache-Control': 'public, max-age=31536000, immutable', // Assets are only ever consumed via /