viteboard 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +110 -61
  2. package/content/docs/guide/boards-in-markdown.md +28 -26
  3. package/content/docs/guide/boards.md +44 -82
  4. package/content/docs/guide/getting-started.md +36 -48
  5. package/content/docs/index.md +21 -32
  6. package/content/docs/product-roadmap.board.json +4 -4
  7. package/content/machine/board-v1.schema.json +138 -0
  8. package/content/machine/example.board.json +217 -0
  9. package/content/machine/guide.md +270 -0
  10. package/content/machine/llms.txt +31 -0
  11. package/content/machine/viteboard.json +62 -0
  12. package/dist/assets/index-D2W76uu9.js +96 -0
  13. package/dist/assets/index-rMkUjGXP.css +1 -0
  14. package/dist/index.html +4 -2
  15. package/package.json +10 -12
  16. package/vite.config.ts +635 -48
  17. package/content/docs/capabilities.md +0 -89
  18. package/content/docs/docs.md +0 -84
  19. package/content/docs/guide/test.md +0 -6
  20. package/content/docs/test.md +0 -219
  21. package/content/docs/tetst-dir/test-board.board.json +0 -15
  22. package/content/docs/tetst-dir/test-test.md +0 -1
  23. package/content/docs/workspace.md +0 -73
  24. package/dist/assets/index-CEpxLM2o.css +0 -1
  25. package/dist/assets/index-D4xvJdEQ.js +0 -60
  26. package/index.html +0 -13
  27. package/src/app/AppController.ts +0 -955
  28. package/src/app/BoardState.ts +0 -130
  29. package/src/app/ClipboardService.ts +0 -159
  30. package/src/app/CommandManager.ts +0 -66
  31. package/src/app/ElementActions.ts +0 -152
  32. package/src/app/EmbedRegionSelector.ts +0 -103
  33. package/src/app/ExportPng.ts +0 -107
  34. package/src/app/ImageInsertService.ts +0 -141
  35. package/src/app/KeyboardShortcuts.ts +0 -124
  36. package/src/app/main.ts +0 -6
  37. package/src/board/BoardPreview.ts +0 -189
  38. package/src/board/BoardService.ts +0 -222
  39. package/src/canvas/CanvasRenderer.ts +0 -253
  40. package/src/canvas/CanvasSurface.ts +0 -70
  41. package/src/canvas/HitTester.ts +0 -110
  42. package/src/canvas/ImageCache.ts +0 -123
  43. package/src/canvas/PerformanceMonitor.ts +0 -31
  44. package/src/canvas/RenderScheduler.ts +0 -26
  45. package/src/canvas/Viewport.ts +0 -77
  46. package/src/content/ContentApi.ts +0 -258
  47. package/src/content/Markdown.ts +0 -431
  48. package/src/content/MarkdownView.ts +0 -70
  49. package/src/editor/DocEditor.ts +0 -799
  50. package/src/editor/htmlToMarkdown.ts +0 -333
  51. package/src/elements/renderElement.ts +0 -509
  52. package/src/elements/types.ts +0 -118
  53. package/src/shell/Shell.ts +0 -2950
  54. package/src/shell/Sidebar.ts +0 -1352
  55. package/src/storage/AssetStore.ts +0 -86
  56. package/src/storage/BoardSerializer.ts +0 -114
  57. package/src/storage/ImportExportService.ts +0 -153
  58. package/src/storage/IndexedDbStore.ts +0 -92
  59. package/src/storage/LocalBoardStore.ts +0 -104
  60. package/src/styles.css +0 -3257
  61. package/src/templates/helpers.ts +0 -124
  62. package/src/templates/index.ts +0 -65
  63. package/src/templates/journeyMapTemplate.ts +0 -52
  64. package/src/templates/opportunityTreeTemplate.ts +0 -45
  65. package/src/templates/personaTemplate.ts +0 -41
  66. package/src/templates/prioritizationMatrixTemplate.ts +0 -44
  67. package/src/templates/requirementsFlowTemplate.ts +0 -45
  68. package/src/templates/screenshotReviewTemplate.ts +0 -52
  69. package/src/templates/systemDiagramTemplate.ts +0 -41
  70. package/src/testing/StressTestGenerator.ts +0 -134
  71. package/src/testing/StressTestPanel.ts +0 -64
  72. package/src/tools/ArrowTool.ts +0 -87
  73. package/src/tools/ImageTool.ts +0 -39
  74. package/src/tools/PanTool.ts +0 -34
  75. package/src/tools/SelectTool.ts +0 -377
  76. package/src/tools/ShapeTool.ts +0 -106
  77. package/src/tools/StickyTool.ts +0 -69
  78. package/src/tools/TaskTool.ts +0 -52
  79. package/src/tools/TextTool.ts +0 -45
  80. package/src/tools/ToolContext.ts +0 -44
  81. package/src/tools/ToolController.ts +0 -178
  82. package/src/ui/Modal.ts +0 -58
  83. package/src/ui/PerformanceOverlay.ts +0 -75
  84. package/src/ui/StorageManager.ts +0 -94
  85. package/src/ui/TemplatePicker.ts +0 -67
  86. package/src/ui/TextEditorOverlay.ts +0 -137
  87. package/src/ui/Toolbar.ts +0 -151
  88. package/src/ui/TopControls.ts +0 -137
  89. package/src/ui/icons.ts +0 -50
  90. package/tsconfig.json +0 -19
package/vite.config.ts CHANGED
@@ -3,8 +3,10 @@ import { defineConfig, type Plugin, type Connect } from "vite";
3
3
  import { promises as fs } from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { execFile } from "node:child_process";
6
+ import { createHash } from "node:crypto";
6
7
  import { promisify } from "node:util";
7
8
  import type { IncomingMessage, ServerResponse } from "node:http";
9
+ import { fileURLToPath } from "node:url";
8
10
 
9
11
  const WORKSPACE_DIR = ".workspace";
10
12
  const WORKSPACE_FILE = "workspace.json";
@@ -29,6 +31,18 @@ const MIME: Record<string, string> = {
29
31
  ".json": "application/json; charset=utf-8",
30
32
  };
31
33
 
34
+ const MIME_EXT: Record<string, string> = {
35
+ "image/png": ".png",
36
+ "image/jpeg": ".jpg",
37
+ "image/webp": ".webp",
38
+ "image/gif": ".gif",
39
+ "image/svg+xml": ".svg",
40
+ };
41
+ const MANAGED_ASSET_RE = /^assets\/[a-f0-9]{64}\.(png|jpe?g|webp|gif|svg)$/i;
42
+ const ACTOR_TTL_MS = 30000;
43
+ const LOCK_TTL_MS = 30000;
44
+ const ROOT_LINK_HEADER = '</llms.txt>; rel="describedby"; type="text/plain", </.well-known/viteboard.json>; rel="alternate"; type="application/json"';
45
+
32
46
  const execFileAsync = promisify(execFile);
33
47
 
34
48
  type WorkspaceManifest = {
@@ -59,6 +73,7 @@ type WorkspaceResponse = {
59
73
  projects: Array<{
60
74
  id: string;
61
75
  label: string;
76
+ rootPath: string;
62
77
  docsRoots: Array<{
63
78
  id: string;
64
79
  projectId: string;
@@ -73,10 +88,51 @@ type TreeNode =
73
88
  | { type: "file"; name: string; path: string }
74
89
  | { type: "dir"; name: string; path: string; children: TreeNode[] };
75
90
 
91
+ type ActorState =
92
+ | "idle"
93
+ | "viewing-doc"
94
+ | "editing-doc"
95
+ | "viewing-board"
96
+ | "editing-board"
97
+ | "viewing-project"
98
+ | "writing";
99
+
100
+ type ActorSessionRecord = {
101
+ actorId: string;
102
+ actorType: "human" | "agent";
103
+ label: string;
104
+ route: string;
105
+ state: ActorState;
106
+ rootId?: string;
107
+ projectId?: string;
108
+ docPath?: string;
109
+ boardId?: string;
110
+ updatedAt: number;
111
+ expiresAt: number;
112
+ };
113
+
114
+ type LockRecord = {
115
+ lockId: string;
116
+ actorId: string;
117
+ actorType: "human" | "agent";
118
+ label: string;
119
+ targetType: "doc" | "board";
120
+ rootId: string;
121
+ path?: string;
122
+ boardId?: string;
123
+ acquiredAt: number;
124
+ expiresAt: number;
125
+ };
126
+
76
127
  function contentApiPlugin(): Plugin {
77
128
  const cwd = process.cwd();
129
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
78
130
  const workspaceDir = path.resolve(cwd, WORKSPACE_DIR);
79
131
  const workspaceFile = path.join(workspaceDir, WORKSPACE_FILE);
132
+ const machineDir = path.join(packageRoot, "content", "machine");
133
+ const actorSessions = new Map<string, ActorSessionRecord>();
134
+ const locks = new Map<string, LockRecord>();
135
+ const sseClients = new Set<ServerResponse>();
80
136
 
81
137
  function send(res: ServerResponse, status: number, body: unknown): void {
82
138
  res.statusCode = status;
@@ -90,6 +146,87 @@ function contentApiPlugin(): Plugin {
90
146
  res.end(body);
91
147
  }
92
148
 
149
+ function sendSse(res: ServerResponse, event: string, body: unknown): void {
150
+ res.write(`event: ${event}\n`);
151
+ res.write(`data: ${JSON.stringify(body)}\n\n`);
152
+ }
153
+
154
+ function broadcast(event: string, body: unknown): void {
155
+ for (const client of sseClients) {
156
+ try {
157
+ sendSse(client, event, body);
158
+ } catch {
159
+ sseClients.delete(client);
160
+ }
161
+ }
162
+ }
163
+
164
+ function actorFromHeaders(req: IncomingMessage): { actorId: string; actorType: "human" | "agent"; label: string } | null {
165
+ const actorId = String(req.headers["x-viteboard-actor-id"] ?? "").trim();
166
+ if (!actorId) return null;
167
+ const actorType = String(req.headers["x-viteboard-actor-type"] ?? "human") === "agent" ? "agent" : "human";
168
+ const label = String(req.headers["x-viteboard-actor-label"] ?? "").trim() || (actorType === "agent" ? "Agent" : "ViteBoard UI");
169
+ return { actorId, actorType, label };
170
+ }
171
+
172
+ function lockKey(target: { targetType: "doc" | "board"; rootId: string; path?: string; boardId?: string }): string {
173
+ const suffix = target.targetType === "doc" ? target.path ?? "" : target.boardId ?? "";
174
+ return `${target.targetType}:${target.rootId}:${suffix}`;
175
+ }
176
+
177
+ function activeActors(): ActorSessionRecord[] {
178
+ return [...actorSessions.values()].sort((a, b) => a.label.localeCompare(b.label));
179
+ }
180
+
181
+ function activeLocks(): LockRecord[] {
182
+ return [...locks.values()].sort((a, b) => a.acquiredAt - b.acquiredAt);
183
+ }
184
+
185
+ function broadcastPresence(): void {
186
+ broadcast("presence", { actors: activeActors(), locks: activeLocks() });
187
+ }
188
+
189
+ function broadcastLocks(): void {
190
+ broadcast("lock-changed", { locks: activeLocks() });
191
+ }
192
+
193
+ function pruneEphemeral(): void {
194
+ const now = Date.now();
195
+ let changedPresence = false;
196
+ let changedLocks = false;
197
+ for (const [actorId, actor] of actorSessions) {
198
+ if (actor.expiresAt <= now) {
199
+ actorSessions.delete(actorId);
200
+ changedPresence = true;
201
+ }
202
+ }
203
+ for (const [key, lock] of locks) {
204
+ if (lock.expiresAt <= now || !actorSessions.has(lock.actorId)) {
205
+ locks.delete(key);
206
+ changedLocks = true;
207
+ }
208
+ }
209
+ if (changedPresence) broadcastPresence();
210
+ else if (changedLocks) broadcastLocks();
211
+ }
212
+
213
+ function extendActorLocks(actorId: string): void {
214
+ const now = Date.now();
215
+ for (const lock of locks.values()) {
216
+ if (lock.actorId === actorId) lock.expiresAt = now + LOCK_TTL_MS;
217
+ }
218
+ }
219
+
220
+ async function readMachineAsset(fileName: string): Promise<string> {
221
+ return fs.readFile(path.join(machineDir, fileName), "utf-8");
222
+ }
223
+
224
+ function revisionForText(content: string): string {
225
+ return createHash("sha1").update(content).digest("hex");
226
+ }
227
+
228
+ setInterval(pruneEphemeral, 10000).unref?.();
229
+
93
230
  function readBody(req: IncomingMessage): Promise<string> {
94
231
  return new Promise((resolve, reject) => {
95
232
  let data = "";
@@ -114,6 +251,48 @@ function contentApiPlugin(): Plugin {
114
251
  return (hash >>> 0).toString(36);
115
252
  }
116
253
 
254
+ async function canonicalizeProjectRoot(rootPath: string): Promise<string> {
255
+ const resolved = path.resolve(rootPath);
256
+ try {
257
+ return path.resolve(await fs.realpath(resolved));
258
+ } catch {
259
+ return resolved;
260
+ }
261
+ }
262
+
263
+ function isNestedPath(parent: string, child: string): boolean {
264
+ const rel = path.relative(parent, child);
265
+ return rel !== "" && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel);
266
+ }
267
+
268
+ function findOverlappingProject(
269
+ projects: WorkspaceProjectRecord[],
270
+ candidateRoot: string,
271
+ ignoreProjectId?: string
272
+ ): WorkspaceProjectRecord | null {
273
+ for (const project of projects) {
274
+ if (ignoreProjectId && project.id === ignoreProjectId) continue;
275
+ if (
276
+ project.rootPath === candidateRoot ||
277
+ isNestedPath(project.rootPath, candidateRoot) ||
278
+ isNestedPath(candidateRoot, project.rootPath)
279
+ ) {
280
+ return project;
281
+ }
282
+ }
283
+ return null;
284
+ }
285
+
286
+ function overlapError(existing: WorkspaceProjectRecord, candidateRoot: string): string {
287
+ if (existing.rootPath === candidateRoot) {
288
+ return `\"${existing.label}\" is already added. Pick a different folder, or remove the existing project first.`;
289
+ }
290
+ if (isNestedPath(existing.rootPath, candidateRoot)) {
291
+ return `That folder is inside the existing workspace project \"${existing.label}\". ViteBoard only allows one project root per nested path.`;
292
+ }
293
+ return `That folder contains the existing workspace project \"${existing.label}\". Add the more specific project instead, or remove the current one first.`;
294
+ }
295
+
117
296
  async function ensureWorkspaceDir(): Promise<void> {
118
297
  await fs.mkdir(workspaceDir, { recursive: true });
119
298
  }
@@ -122,17 +301,26 @@ function contentApiPlugin(): Plugin {
122
301
  try {
123
302
  const raw = await fs.readFile(workspaceFile, "utf-8");
124
303
  const parsed = JSON.parse(raw) as Partial<WorkspaceManifest>;
125
- const projects = Array.isArray(parsed.projects)
126
- ? parsed.projects
127
- .filter((item): item is WorkspaceProjectRecord => {
128
- return !!item && typeof item.id === "string" && typeof item.rootPath === "string";
129
- })
130
- .map((item) => ({
131
- id: item.id,
132
- label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(item.rootPath),
133
- rootPath: path.resolve(item.rootPath),
134
- }))
304
+ const rawProjects = Array.isArray(parsed.projects)
305
+ ? parsed.projects.filter((item): item is WorkspaceProjectRecord => {
306
+ return !!item && typeof item.id === "string" && typeof item.rootPath === "string";
307
+ })
135
308
  : [];
309
+ const projects: WorkspaceProjectRecord[] = [];
310
+ const seenIds = new Set<string>();
311
+ const seenPaths = new Set<string>();
312
+ for (const item of rawProjects) {
313
+ const rootPath = await canonicalizeProjectRoot(item.rootPath);
314
+ const id = item.id.trim() || `project-${hashPath(rootPath)}`;
315
+ if (seenIds.has(id) || seenPaths.has(rootPath)) continue;
316
+ seenIds.add(id);
317
+ seenPaths.add(rootPath);
318
+ projects.push({
319
+ id,
320
+ label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(rootPath),
321
+ rootPath,
322
+ });
323
+ }
136
324
  return { version: 1, projects };
137
325
  } catch {
138
326
  return { version: 1, projects: [] };
@@ -153,10 +341,7 @@ function contentApiPlugin(): Plugin {
153
341
  }
154
342
 
155
343
  async function loadProjects(): Promise<WorkspaceProjectRecord[]> {
156
- const manifest = await loadManifest();
157
- return manifest.projects.filter((item, index, list) => {
158
- return list.findIndex((other) => other.rootPath === item.rootPath) === index;
159
- });
344
+ return (await loadManifest()).projects;
160
345
  }
161
346
 
162
347
  async function openProjectPicker(): Promise<string | null> {
@@ -290,6 +475,7 @@ function contentApiPlugin(): Plugin {
290
475
  projects: projects.map((project) => ({
291
476
  id: project.id,
292
477
  label: project.label,
478
+ rootPath: project.rootPath,
293
479
  docsRoots: project.docsRoots.map((root) => ({
294
480
  id: root.id,
295
481
  projectId: root.projectId,
@@ -323,6 +509,142 @@ function contentApiPlugin(): Plugin {
323
509
  return abs;
324
510
  }
325
511
 
512
+ function normalizeRelativePath(input: string): string {
513
+ const parts: string[] = [];
514
+ for (const raw of String(input ?? "").replace(/\\/g, "/").split("/")) {
515
+ const part = raw.trim();
516
+ if (!part || part === ".") continue;
517
+ if (part === "..") {
518
+ if (parts.length > 0) parts.pop();
519
+ continue;
520
+ }
521
+ parts.push(part);
522
+ }
523
+ return parts.join("/");
524
+ }
525
+
526
+ function dirnamePosix(filePath: string): string {
527
+ const normalized = normalizeRelativePath(filePath);
528
+ const idx = normalized.lastIndexOf("/");
529
+ return idx === -1 ? "" : normalized.slice(0, idx);
530
+ }
531
+
532
+ function resolveDocRelativePath(fromFile: string, target: string): string | null {
533
+ const raw = String(target ?? "").trim();
534
+ if (!raw || /^(https?:|mailto:|tel:|#|data:)/i.test(raw)) return null;
535
+ if (raw.startsWith("/")) return normalizeRelativePath(raw);
536
+ const base = dirnamePosix(fromFile);
537
+ return normalizeRelativePath(base ? `${base}/${raw}` : raw);
538
+ }
539
+
540
+ function isManagedAssetPath(relPath: string): boolean {
541
+ return MANAGED_ASSET_RE.test(normalizeRelativePath(relPath));
542
+ }
543
+
544
+ async function collectFiles(dir: string, base = ""): Promise<string[]> {
545
+ let entries: import("node:fs").Dirent[];
546
+ try {
547
+ entries = await fs.readdir(dir, { withFileTypes: true });
548
+ } catch {
549
+ return [];
550
+ }
551
+
552
+ const files: string[] = [];
553
+ for (const entry of entries) {
554
+ if (entry.name.startsWith(".")) continue;
555
+ const rel = base ? `${base}/${entry.name}` : entry.name;
556
+ const abs = path.join(dir, entry.name);
557
+ if (entry.isDirectory()) {
558
+ files.push(...(await collectFiles(abs, rel)));
559
+ } else {
560
+ files.push(rel);
561
+ }
562
+ }
563
+ return files;
564
+ }
565
+
566
+ function collectBoardAssetRefs(raw: unknown): Set<string> {
567
+ const refs = new Set<string>();
568
+ if (typeof raw !== "object" || raw === null) return refs;
569
+ const elements = Array.isArray((raw as { elements?: unknown[] }).elements)
570
+ ? (raw as { elements: unknown[] }).elements
571
+ : [];
572
+ for (const element of elements) {
573
+ if (typeof element !== "object" || element === null) continue;
574
+ const data = (element as { data?: Record<string, unknown> }).data;
575
+ const src = typeof data?.src === "string" ? normalizeRelativePath(data.src) : "";
576
+ if (isManagedAssetPath(src)) refs.add(src);
577
+ }
578
+ return refs;
579
+ }
580
+
581
+ function collectMarkdownAssetRefs(markdown: string, docPath: string): Set<string> {
582
+ const refs = new Set<string>();
583
+ const imagePatterns = [
584
+ /!\[[^\]]*\]\(([^)\s]+)\)/g,
585
+ /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
586
+ ];
587
+
588
+ for (const pattern of imagePatterns) {
589
+ pattern.lastIndex = 0;
590
+ let match: RegExpExecArray | null;
591
+ while ((match = pattern.exec(markdown))) {
592
+ const resolved = resolveDocRelativePath(docPath, match[1]);
593
+ if (resolved && isManagedAssetPath(resolved)) refs.add(resolved);
594
+ }
595
+ }
596
+
597
+ return refs;
598
+ }
599
+
600
+ async function cleanupManagedAssets(rootAbsPath: string, preservePaths: string[] = []): Promise<number> {
601
+ const allFiles = await collectFiles(rootAbsPath);
602
+ const managedAssets = allFiles.filter((file) => isManagedAssetPath(file));
603
+ if (managedAssets.length === 0) return 0;
604
+
605
+ const referenced = new Set<string>();
606
+ for (const relPath of allFiles) {
607
+ const abs = safeResolve(rootAbsPath, relPath);
608
+ if (!abs) continue;
609
+ if (relPath.endsWith(".board.json")) {
610
+ try {
611
+ const raw = JSON.parse(await fs.readFile(abs, "utf-8"));
612
+ for (const ref of collectBoardAssetRefs(raw)) referenced.add(ref);
613
+ } catch {
614
+ // Ignore unreadable or invalid boards; cleanup stays conservative.
615
+ }
616
+ continue;
617
+ }
618
+ if (relPath.endsWith(".md")) {
619
+ try {
620
+ const markdown = await fs.readFile(abs, "utf-8");
621
+ for (const ref of collectMarkdownAssetRefs(markdown, relPath)) referenced.add(ref);
622
+ } catch {
623
+ // Ignore unreadable docs; cleanup stays conservative.
624
+ }
625
+ }
626
+ }
627
+
628
+ for (const relPath of preservePaths) {
629
+ const normalized = normalizeRelativePath(relPath);
630
+ if (isManagedAssetPath(normalized)) referenced.add(normalized);
631
+ }
632
+
633
+ let removed = 0;
634
+ for (const relPath of managedAssets) {
635
+ if (referenced.has(relPath)) continue;
636
+ const abs = safeResolve(rootAbsPath, relPath);
637
+ if (!abs) continue;
638
+ try {
639
+ await fs.unlink(abs);
640
+ removed++;
641
+ } catch (err) {
642
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
643
+ }
644
+ }
645
+ return removed;
646
+ }
647
+
326
648
  async function walkDocsRoot(dir: string, base: string, nestedRoots: Set<string>): Promise<TreeNode[]> {
327
649
  let entries: import("node:fs").Dirent[];
328
650
  try {
@@ -358,7 +680,7 @@ function contentApiPlugin(): Plugin {
358
680
  return nodes;
359
681
  }
360
682
 
361
- /** Walk full project directory tree — returns only directories (no files). */
683
+ /** Walk the project tree for Raw sidebar mode. */
362
684
  async function walkProjectTree(dir: string, base: string): Promise<TreeNode[]> {
363
685
  let entries: import("node:fs").Dirent[];
364
686
  try {
@@ -368,44 +690,247 @@ function contentApiPlugin(): Plugin {
368
690
  }
369
691
  const nodes: TreeNode[] = [];
370
692
  for (const entry of entries) {
371
- if (!entry.isDirectory()) continue;
372
693
  if (entry.name.startsWith(".")) continue;
373
- if (isIgnoredDir(entry.name)) continue;
374
694
  const rel = base ? `${base}/${entry.name}` : entry.name;
375
- nodes.push({
376
- type: "dir",
377
- name: entry.name,
378
- path: rel,
379
- children: await walkProjectTree(path.join(dir, entry.name), rel),
380
- });
695
+ if (entry.isDirectory()) {
696
+ if (isIgnoredDir(entry.name)) continue;
697
+ nodes.push({
698
+ type: "dir",
699
+ name: entry.name,
700
+ path: rel,
701
+ children: await walkProjectTree(path.join(dir, entry.name), rel),
702
+ });
703
+ } else {
704
+ nodes.push({ type: "file", name: entry.name, path: rel });
705
+ }
381
706
  }
382
- nodes.sort((a, b) => a.name.localeCompare(b.name));
707
+ nodes.sort((a, b) => {
708
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
709
+ return a.name.localeCompare(b.name);
710
+ });
383
711
  return nodes;
384
712
  }
385
713
 
714
+ async function sendMachineFile(res: ServerResponse, fileName: string, contentType: string): Promise<void> {
715
+ return sendText(res, 200, await readMachineAsset(fileName), contentType);
716
+ }
717
+
386
718
  const handler: Connect.NextHandleFunction = (req, res, next) => {
387
719
  const url = req.url ?? "";
388
- if (!url.startsWith("/__api/")) return next();
720
+ const parsed = new URL(url, "http://localhost");
721
+ const route = parsed.pathname;
722
+
723
+ if (route === "/" || route === "/index.html") {
724
+ res.setHeader("Link", ROOT_LINK_HEADER);
725
+ return next();
726
+ }
727
+
728
+ if (route === "/llms.txt") {
729
+ void sendMachineFile(res, "llms.txt", "text/plain; charset=utf-8");
730
+ return;
731
+ }
732
+
733
+ if (route === "/.well-known/viteboard.json") {
734
+ void sendMachineFile(res, "viteboard.json", "application/json; charset=utf-8");
735
+ return;
736
+ }
737
+
738
+ if (route === "/viteboard-guide.md") {
739
+ void sendMachineFile(res, "guide.md", "text/markdown; charset=utf-8");
740
+ return;
741
+ }
742
+
743
+ if (route === "/board-v1.schema.json") {
744
+ void sendMachineFile(res, "board-v1.schema.json", "application/schema+json; charset=utf-8");
745
+ return;
746
+ }
747
+
748
+ if (route === "/viteboard-example.board.json") {
749
+ void sendMachineFile(res, "example.board.json", "application/json; charset=utf-8");
750
+ return;
751
+ }
752
+
753
+ if (!route.startsWith("/__api/")) return next();
389
754
 
390
755
  void (async () => {
391
756
  try {
392
- const parsed = new URL(url, "http://localhost");
393
- const route = parsed.pathname;
394
757
  const method = (req.method ?? "GET").toUpperCase();
758
+ const actor = actorFromHeaders(req);
759
+ pruneEphemeral();
395
760
 
396
761
  if (route === "/__api/workspace" && method === "GET") {
397
762
  return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
398
763
  }
399
764
 
765
+ if (route === "/__api/events" && method === "GET") {
766
+ res.statusCode = 200;
767
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
768
+ res.setHeader("Cache-Control", "no-cache, no-transform");
769
+ res.setHeader("Connection", "keep-alive");
770
+ res.write(": connected\n\n");
771
+ sseClients.add(res);
772
+ sendSse(res, "hello", { actors: activeActors(), locks: activeLocks() });
773
+ req.on("close", () => {
774
+ sseClients.delete(res);
775
+ });
776
+ return;
777
+ }
778
+
779
+ if (route === "/__api/actors/session" && method === "GET") {
780
+ return send(res, 200, { actors: activeActors() });
781
+ }
782
+
783
+ if (route === "/__api/actors/session" && method === "POST") {
784
+ const body = JSON.parse(await readBody(req));
785
+ const actorId = String(body.actorId ?? actor?.actorId ?? "").trim();
786
+ if (!actorId) return send(res, 400, { error: "actorId is required" });
787
+ const actorType = String(body.actorType ?? actor?.actorType ?? "human") === "agent" ? "agent" : "human";
788
+ const label = String(body.label ?? actor?.label ?? "").trim() || (actorType === "agent" ? "Agent" : "ViteBoard UI");
789
+ const now = Date.now();
790
+ actorSessions.set(actorId, {
791
+ actorId,
792
+ actorType,
793
+ label,
794
+ route: String(body.route ?? "#/"),
795
+ state: String(body.state ?? "idle") as ActorState,
796
+ rootId: typeof body.rootId === "string" ? body.rootId : undefined,
797
+ projectId: typeof body.projectId === "string" ? body.projectId : undefined,
798
+ docPath: typeof body.docPath === "string" ? body.docPath : undefined,
799
+ boardId: typeof body.boardId === "string" ? body.boardId : undefined,
800
+ selectedElementIds: Array.isArray(body.selectedElementIds)
801
+ ? body.selectedElementIds.filter((item: unknown): item is string => typeof item === "string")
802
+ : undefined,
803
+ updatedAt: now,
804
+ expiresAt: now + ACTOR_TTL_MS,
805
+ });
806
+ extendActorLocks(actorId);
807
+ broadcastPresence();
808
+ return send(res, 200, { ok: true });
809
+ }
810
+
811
+ if (route === "/__api/actors/heartbeat" && method === "POST") {
812
+ const body = JSON.parse(await readBody(req));
813
+ const actorId = String(body.actorId ?? actor?.actorId ?? "").trim();
814
+ if (!actorId) return send(res, 400, { error: "actorId is required" });
815
+ const current = actorSessions.get(actorId);
816
+ if (!current) return send(res, 404, { error: "Actor session not found" });
817
+ const now = Date.now();
818
+ const next: ActorSessionRecord = {
819
+ ...current,
820
+ route: typeof body.route === "string" ? body.route : current.route,
821
+ state: typeof body.state === "string" ? (body.state as ActorState) : current.state,
822
+ rootId: typeof body.rootId === "string" ? body.rootId : current.rootId,
823
+ projectId: typeof body.projectId === "string" ? body.projectId : current.projectId,
824
+ docPath: typeof body.docPath === "string" ? body.docPath : current.docPath,
825
+ boardId: typeof body.boardId === "string" ? body.boardId : current.boardId,
826
+ selectedElementIds: Array.isArray(body.selectedElementIds)
827
+ ? body.selectedElementIds.filter((item: unknown): item is string => typeof item === "string")
828
+ : current.selectedElementIds,
829
+ updatedAt: now,
830
+ expiresAt: now + ACTOR_TTL_MS,
831
+ };
832
+ actorSessions.set(actorId, next);
833
+ extendActorLocks(actorId);
834
+ broadcastPresence();
835
+ return send(res, 200, { ok: true });
836
+ }
837
+
838
+ if (route === "/__api/actors/session" && method === "DELETE") {
839
+ const actorId = parsed.searchParams.get("actorId") ?? actor?.actorId ?? "";
840
+ if (!actorId) return send(res, 400, { error: "actorId is required" });
841
+ actorSessions.delete(actorId);
842
+ let changedLocks = false;
843
+ for (const [key, lock] of locks) {
844
+ if (lock.actorId === actorId) {
845
+ locks.delete(key);
846
+ changedLocks = true;
847
+ }
848
+ }
849
+ if (changedLocks) broadcastLocks();
850
+ broadcastPresence();
851
+ return send(res, 200, { ok: true });
852
+ }
853
+
854
+ if (route === "/__api/locks" && method === "GET") {
855
+ return send(res, 200, { locks: activeLocks() });
856
+ }
857
+
858
+ if (route === "/__api/locks/acquire" && method === "POST") {
859
+ const body = JSON.parse(await readBody(req));
860
+ if (!actor?.actorId) return send(res, 400, { error: "actor identity is required" });
861
+ const targetType = body.targetType === "board" ? "board" : body.targetType === "doc" ? "doc" : null;
862
+ const rootId = String(body.rootId ?? "").trim();
863
+ if (!targetType || !rootId) return send(res, 400, { error: "targetType and rootId are required" });
864
+ const resolved = await resolveRoot(rootId);
865
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
866
+ const targetPath = targetType === "doc" ? String(body.path ?? "").trim() : undefined;
867
+ const targetBoardId = targetType === "board" ? String(body.boardId ?? "").trim() : undefined;
868
+ const targetAbs = targetType === "doc"
869
+ ? safeResolve(resolved.root.absPath, targetPath ?? "")
870
+ : safeResolve(resolved.root.absPath, `${targetBoardId}.board.json`);
871
+ if (!targetAbs) {
872
+ return send(res, 400, { error: targetType === "doc" ? "Invalid path" : "Invalid boardId" });
873
+ }
874
+ const key = lockKey({ targetType, rootId, path: targetPath, boardId: targetBoardId });
875
+ const now = Date.now();
876
+ const existing = locks.get(key);
877
+ if (existing && existing.actorId !== actor.actorId && existing.expiresAt > now) {
878
+ return send(res, 409, { error: `${existing.label} is editing this ${targetType}.`, lock: existing });
879
+ }
880
+ const nextLock: LockRecord = existing && existing.actorId === actor.actorId
881
+ ? { ...existing, expiresAt: now + LOCK_TTL_MS }
882
+ : {
883
+ lockId: `lock-${hashPath(`${key}:${now}`)}`,
884
+ actorId: actor.actorId,
885
+ actorType: actor.actorType,
886
+ label: actor.label,
887
+ targetType,
888
+ rootId,
889
+ path: targetPath,
890
+ boardId: targetBoardId,
891
+ acquiredAt: now,
892
+ expiresAt: now + LOCK_TTL_MS,
893
+ };
894
+ locks.set(key, nextLock);
895
+ broadcastLocks();
896
+ broadcastPresence();
897
+ return send(res, 200, { ok: true, lock: nextLock });
898
+ }
899
+
900
+ if (route === "/__api/locks/release" && method === "POST") {
901
+ const body = JSON.parse(await readBody(req));
902
+ if (!actor?.actorId) return send(res, 400, { error: "actor identity is required" });
903
+ const targetType = body.targetType === "board" ? "board" : body.targetType === "doc" ? "doc" : null;
904
+ const rootId = String(body.rootId ?? "").trim();
905
+ if (!targetType || !rootId) return send(res, 400, { error: "targetType and rootId are required" });
906
+ const key = lockKey({
907
+ targetType,
908
+ rootId,
909
+ path: targetType === "doc" ? String(body.path ?? "").trim() : undefined,
910
+ boardId: targetType === "board" ? String(body.boardId ?? "").trim() : undefined,
911
+ });
912
+ const existing = locks.get(key);
913
+ if (!existing) return send(res, 200, { ok: true });
914
+ if (existing.actorId !== actor.actorId) return send(res, 403, { error: "Lock owned by another actor" });
915
+ locks.delete(key);
916
+ broadcastLocks();
917
+ broadcastPresence();
918
+ return send(res, 200, { ok: true });
919
+ }
920
+
400
921
  if (route === "/__api/workspace/project" && method === "POST") {
401
922
  const { rootPath, label } = JSON.parse(await readBody(req));
402
- const absRoot = path.resolve(String(rootPath ?? "").trim());
923
+ const absRoot = await canonicalizeProjectRoot(String(rootPath ?? "").trim());
403
924
  if (!absRoot || !(await dirExists(absRoot))) {
404
925
  return send(res, 400, { error: "Project root not found" });
405
926
  }
406
927
  const manifest = await loadManifest();
928
+ const existingProject = manifest.projects.find((item) => item.rootPath === absRoot);
929
+ const projectId = existingProject?.id ?? `project-${hashPath(absRoot)}`;
930
+ const overlap = findOverlappingProject(manifest.projects, absRoot, projectId);
931
+ if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
407
932
  const project: WorkspaceProjectRecord = {
408
- id: `project-${hashPath(absRoot)}`,
933
+ id: projectId,
409
934
  label: String(label ?? "").trim() || path.basename(absRoot) || absRoot,
410
935
  rootPath: absRoot,
411
936
  };
@@ -413,17 +938,22 @@ function contentApiPlugin(): Plugin {
413
938
  if (existingIndex === -1) manifest.projects.push(project);
414
939
  else manifest.projects.splice(existingIndex, 1, project);
415
940
  await saveManifest(manifest);
941
+ broadcast("workspace-changed", { action: "project-added", projectId: project.id, actorId: actor?.actorId ?? null });
416
942
  return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
417
943
  }
418
944
 
419
945
  if (route === "/__api/workspace/pick-project" && method === "POST") {
420
946
  const picked = await openProjectPicker();
421
947
  if (!picked) return send(res, 200, { cancelled: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
422
- const absRoot = path.resolve(picked);
948
+ const absRoot = await canonicalizeProjectRoot(picked);
423
949
  if (!(await dirExists(absRoot))) return send(res, 400, { error: "Project root not found" });
424
950
  const manifest = await loadManifest();
951
+ const existingProject = manifest.projects.find((item) => item.rootPath === absRoot);
952
+ const projectId = existingProject?.id ?? `project-${hashPath(absRoot)}`;
953
+ const overlap = findOverlappingProject(manifest.projects, absRoot, projectId);
954
+ if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
425
955
  const project: WorkspaceProjectRecord = {
426
- id: `project-${hashPath(absRoot)}`,
956
+ id: projectId,
427
957
  label: path.basename(absRoot) || absRoot,
428
958
  rootPath: absRoot,
429
959
  };
@@ -431,6 +961,7 @@ function contentApiPlugin(): Plugin {
431
961
  if (existingIndex === -1) manifest.projects.push(project);
432
962
  else manifest.projects.splice(existingIndex, 1, project);
433
963
  await saveManifest(manifest);
964
+ broadcast("workspace-changed", { action: "project-added", projectId: project.id, actorId: actor?.actorId ?? null });
434
965
  return send(res, 200, { cancelled: false, workspace: await toWorkspaceResponse(await getWorkspace()), addedProjectId: project.id });
435
966
  }
436
967
 
@@ -446,6 +977,7 @@ function contentApiPlugin(): Plugin {
446
977
  const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath)
447
978
  ?? refreshedProject?.docsRoots[0];
448
979
  if (!root) return send(res, 500, { error: "Could not create docs root" });
980
+ broadcast("workspace-changed", { action: "docs-root-created", projectId: project.id, rootId: root.id, actorId: actor?.actorId ?? null });
449
981
  return send(res, 200, {
450
982
  root: {
451
983
  id: root.id,
@@ -470,6 +1002,7 @@ function contentApiPlugin(): Plugin {
470
1002
  const refreshedProject = workspace.find((item) => item.id === project.id);
471
1003
  const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath);
472
1004
  if (!root) return send(res, 500, { error: "Could not create docs area" });
1005
+ broadcast("workspace-changed", { action: "docs-area-created", projectId: project.id, rootId: root.id, actorId: actor?.actorId ?? null });
473
1006
  return send(res, 200, {
474
1007
  root: {
475
1008
  id: root.id,
@@ -489,6 +1022,7 @@ function contentApiPlugin(): Plugin {
489
1022
  if (!project) return send(res, 404, { error: "Project not found" });
490
1023
  const docsPath = path.join(project.rootPath, String(parentPath ?? ""), "docs");
491
1024
  await fs.mkdir(docsPath, { recursive: true });
1025
+ broadcast("workspace-changed", { action: "docs-folder-created", projectId: project.id, actorId: actor?.actorId ?? null });
492
1026
  return send(res, 200, { ok: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
493
1027
  }
494
1028
 
@@ -497,6 +1031,7 @@ function contentApiPlugin(): Plugin {
497
1031
  const manifest = await loadManifest();
498
1032
  manifest.projects = manifest.projects.filter((item) => item.id !== id);
499
1033
  await saveManifest(manifest);
1034
+ broadcast("workspace-changed", { action: "project-removed", projectId: id, actorId: actor?.actorId ?? null });
500
1035
  return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
501
1036
  }
502
1037
 
@@ -526,21 +1061,38 @@ function contentApiPlugin(): Plugin {
526
1061
  if (!abs) return send(res, 400, { error: "Invalid path" });
527
1062
  try {
528
1063
  const content = await fs.readFile(abs, "utf-8");
529
- return send(res, 200, { path: rel, content });
1064
+ return send(res, 200, { path: rel, content, revision: revisionForText(content) });
530
1065
  } catch {
531
1066
  return send(res, 404, { error: "Not found" });
532
1067
  }
533
1068
  }
534
1069
 
535
1070
  if (route === "/__api/file" && method === "PUT") {
536
- const { rootId, path: rel, content } = JSON.parse(await readBody(req));
1071
+ const { rootId, path: rel, content, expectedRevision } = JSON.parse(await readBody(req));
537
1072
  const resolved = await resolveRoot(String(rootId ?? ""));
538
1073
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
539
1074
  const abs = safeResolve(resolved.root.absPath, String(rel));
540
1075
  if (!abs) return send(res, 400, { error: "Invalid path" });
1076
+ let currentContent: string | null = null;
1077
+ try {
1078
+ currentContent = await fs.readFile(abs, "utf-8");
1079
+ } catch (err) {
1080
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
1081
+ }
1082
+ const currentRevision = currentContent === null ? null : revisionForText(currentContent);
1083
+ if (typeof expectedRevision === "string" && expectedRevision !== currentRevision) {
1084
+ return send(res, 409, {
1085
+ error: "File changed on disk",
1086
+ currentRevision,
1087
+ path: rel,
1088
+ });
1089
+ }
541
1090
  await fs.mkdir(path.dirname(abs), { recursive: true });
542
- await fs.writeFile(abs, String(content ?? ""), "utf-8");
543
- return send(res, 200, { ok: true, path: rel });
1091
+ const nextContent = String(content ?? "");
1092
+ await fs.writeFile(abs, nextContent, "utf-8");
1093
+ const revision = revisionForText(nextContent);
1094
+ broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "write", actorId: actor?.actorId ?? null });
1095
+ return send(res, 200, { ok: true, path: rel, revision });
544
1096
  }
545
1097
 
546
1098
  if (route === "/__api/directory" && method === "PUT") {
@@ -550,6 +1102,7 @@ function contentApiPlugin(): Plugin {
550
1102
  const abs = safeResolve(resolved.root.absPath, String(rel));
551
1103
  if (!abs) return send(res, 400, { error: "Invalid path" });
552
1104
  await fs.mkdir(abs, { recursive: true });
1105
+ broadcast("workspace-changed", { action: "directory-created", rootId: resolved.root.id, path: String(rel), actorId: actor?.actorId ?? null });
553
1106
  return send(res, 200, { ok: true, path: rel });
554
1107
  }
555
1108
 
@@ -565,6 +1118,7 @@ function contentApiPlugin(): Plugin {
565
1118
  } catch (err) {
566
1119
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
567
1120
  }
1121
+ broadcast("file-changed", { rootId: resolved.root.id, path: rel, action: "delete", actorId: actor?.actorId ?? null });
568
1122
  return send(res, 200, { ok: true });
569
1123
  }
570
1124
 
@@ -585,6 +1139,7 @@ function contentApiPlugin(): Plugin {
585
1139
  }
586
1140
  throw err;
587
1141
  }
1142
+ broadcast("file-changed", { rootId: resolved.root.id, path: String(newPath), oldPath: String(oldPath), action: "rename", actorId: actor?.actorId ?? null });
588
1143
  return send(res, 200, { ok: true, path: newPath });
589
1144
  }
590
1145
 
@@ -597,21 +1152,38 @@ function contentApiPlugin(): Plugin {
597
1152
  if (!abs) return send(res, 400, { error: "Invalid id" });
598
1153
  try {
599
1154
  const content = await fs.readFile(abs, "utf-8");
600
- return send(res, 200, { id, doc: JSON.parse(content) });
1155
+ return send(res, 200, { id, doc: JSON.parse(content), revision: revisionForText(content) });
601
1156
  } catch {
602
1157
  return send(res, 404, { error: "Not found" });
603
1158
  }
604
1159
  }
605
1160
 
606
1161
  if (route === "/__api/board" && method === "PUT") {
607
- const { rootId, id, doc } = JSON.parse(await readBody(req));
1162
+ const { rootId, id, doc, expectedRevision } = JSON.parse(await readBody(req));
608
1163
  const resolved = await resolveRoot(String(rootId ?? ""));
609
1164
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
610
1165
  const abs = safeResolve(resolved.root.absPath, `${String(id)}.board.json`);
611
1166
  if (!abs) return send(res, 400, { error: "Invalid id" });
1167
+ let currentContent: string | null = null;
1168
+ try {
1169
+ currentContent = await fs.readFile(abs, "utf-8");
1170
+ } catch (err) {
1171
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
1172
+ }
1173
+ const currentRevision = currentContent === null ? null : revisionForText(currentContent);
1174
+ if (typeof expectedRevision === "string" && expectedRevision !== currentRevision) {
1175
+ return send(res, 409, {
1176
+ error: "Board changed on disk",
1177
+ currentRevision,
1178
+ id,
1179
+ });
1180
+ }
612
1181
  await fs.mkdir(path.dirname(abs), { recursive: true });
613
- await fs.writeFile(abs, JSON.stringify(doc, null, 2) + "\n", "utf-8");
614
- return send(res, 200, { ok: true, id });
1182
+ const nextContent = JSON.stringify(doc, null, 2) + "\n";
1183
+ await fs.writeFile(abs, nextContent, "utf-8");
1184
+ const revision = revisionForText(nextContent);
1185
+ broadcast("board-changed", { rootId: resolved.root.id, boardId: String(id), action: "write", actorId: actor?.actorId ?? null });
1186
+ return send(res, 200, { ok: true, id, revision });
615
1187
  }
616
1188
 
617
1189
  if (route === "/__api/board" && method === "DELETE") {
@@ -626,6 +1198,7 @@ function contentApiPlugin(): Plugin {
626
1198
  } catch (err) {
627
1199
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
628
1200
  }
1201
+ broadcast("board-changed", { rootId: resolved.root.id, boardId: id, action: "delete", actorId: actor?.actorId ?? null });
629
1202
  return send(res, 200, { ok: true, id });
630
1203
  }
631
1204
 
@@ -648,23 +1221,37 @@ function contentApiPlugin(): Plugin {
648
1221
  }
649
1222
 
650
1223
  if (route === "/__api/asset" && method === "POST") {
651
- const { rootId, name, base64 } = JSON.parse(await readBody(req));
1224
+ const { rootId, name, base64, mimeType } = JSON.parse(await readBody(req));
652
1225
  const resolved = await resolveRoot(String(rootId ?? ""));
653
1226
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
654
- const ext = (path.extname(String(name ?? "")) || ".png").toLowerCase();
655
- const safeBase = String(name ?? "image")
656
- .replace(/\.[^.]*$/, "")
657
- .replace(/[^a-z0-9-_]/gi, "-")
658
- .slice(0, 40);
659
- const unique = `${safeBase || "image"}-${Date.now().toString(36)}${ext}`;
660
- const rel = `assets/${unique}`;
1227
+ const bytes = Buffer.from(String(base64), "base64");
1228
+ const ext = MIME_EXT[String(mimeType ?? "")] ?? (path.extname(String(name ?? "")) || ".png").toLowerCase();
1229
+ const digest = createHash("sha256").update(bytes).digest("hex");
1230
+ const rel = `assets/${digest}${ext}`;
661
1231
  const abs = safeResolve(resolved.root.absPath, rel);
662
1232
  if (!abs) return send(res, 400, { error: "Invalid path" });
663
1233
  await fs.mkdir(path.dirname(abs), { recursive: true });
664
- await fs.writeFile(abs, Buffer.from(String(base64), "base64"));
1234
+ try {
1235
+ await fs.access(abs);
1236
+ } catch {
1237
+ await fs.writeFile(abs, bytes);
1238
+ }
1239
+ broadcast("asset-changed", { rootId: resolved.root.id, path: rel, action: "write", actorId: actor?.actorId ?? null });
665
1240
  return send(res, 200, { ok: true, path: rel });
666
1241
  }
667
1242
 
1243
+ if (route === "/__api/asset/cleanup" && method === "POST") {
1244
+ const { rootId, preservePaths } = JSON.parse(await readBody(req));
1245
+ const resolved = await resolveRoot(String(rootId ?? ""));
1246
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
1247
+ const removed = await cleanupManagedAssets(
1248
+ resolved.root.absPath,
1249
+ Array.isArray(preservePaths) ? preservePaths.filter((item): item is string => typeof item === "string") : []
1250
+ );
1251
+ if (removed > 0) broadcast("asset-changed", { rootId: resolved.root.id, action: "cleanup", removed, actorId: actor?.actorId ?? null });
1252
+ return send(res, 200, { ok: true, removed });
1253
+ }
1254
+
668
1255
  if (route === "/__api/workspace-file" && method === "GET") {
669
1256
  await ensureWorkspaceDir();
670
1257
  const manifest = await loadManifest();