agent-canvas 0.2.0 → 0.6.0

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 (98) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -105
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +9 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/remote-feedback.test.ts +0 -165
  96. package/daemon/src/secrets-api.test.ts +0 -123
  97. package/daemon/src/share-e2e.test.ts +0 -277
  98. package/daemon/src/share.test.ts +0 -289
@@ -1,113 +1,120 @@
1
- import { join, resolve } from "path";
2
- import { readdir } from "fs/promises";
3
- import type { SessionManager } from "@fabrika/canvas-kernel/server";
4
- import { jsonResponse, type Route } from "@fabrika/daemon-kit";
5
- import { LANG_MAP } from "../../langMap";
1
+ import type { SessionManager } from '@fabrika/canvas-kernel/server'
2
+ import { jsonResponse, type Route } from '@fabrika/daemon-kit'
3
+ import { readdir } from 'fs/promises'
4
+ import { isAbsolute, relative, resolve, sep } from 'path'
5
+ import { LANG_MAP } from '../../langMap'
6
6
 
7
7
  const IMAGE_MIME: Record<string, string> = {
8
- png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
9
- gif: "image/gif", svg: "image/svg+xml", webp: "image/webp",
10
- ico: "image/x-icon", bmp: "image/bmp", avif: "image/avif",
11
- };
8
+ png: 'image/png',
9
+ jpg: 'image/jpeg',
10
+ jpeg: 'image/jpeg',
11
+ gif: 'image/gif',
12
+ svg: 'image/svg+xml',
13
+ webp: 'image/webp',
14
+ ico: 'image/x-icon',
15
+ bmp: 'image/bmp',
16
+ avif: 'image/avif',
17
+ }
12
18
 
13
19
  function resolveSessionPath(
14
- sessionManager: SessionManager,
15
- sessionId: string,
16
- relPath: string,
17
- ): { fullPath: string; session: ReturnType<SessionManager["get"]> & {} } | Response {
18
- const session = sessionManager.get(sessionId);
19
- if (!session) return jsonResponse({ error: "Session not found" }, 404);
20
-
21
- const fullPath = resolve(join(session.projectRoot, relPath));
22
- if (!fullPath.startsWith(resolve(session.projectRoot))) {
23
- return jsonResponse({ error: "Path traversal rejected" }, 403);
24
- }
25
-
26
- return { fullPath, session };
20
+ sessionManager: SessionManager,
21
+ sessionId: string,
22
+ relPath: string,
23
+ ): { fullPath: string; session: ReturnType<SessionManager['get']> & {} } | Response {
24
+ const session = sessionManager.get(sessionId)
25
+ if (!session) return jsonResponse({ error: 'Session not found' }, 404)
26
+
27
+ const fullPath = resolve(session.projectRoot, relPath)
28
+ const relativePath = relative(resolve(session.projectRoot), fullPath)
29
+ if (!isAbsolute(relPath) && (relativePath === '..' || relativePath.startsWith(`..${sep}`))) {
30
+ return jsonResponse({ error: 'Path traversal rejected' }, 403)
31
+ }
32
+
33
+ return { fullPath, session }
27
34
  }
28
35
 
29
36
  export function createFileHandlers(sessionManager: SessionManager): Route[] {
30
- async function handleFileServe(_req: Request, url: URL): Promise<Response> {
31
- const sessionId = url.searchParams.get("session");
32
- const relPath = url.searchParams.get("path");
33
- if (!sessionId || !relPath) return jsonResponse({ error: "Missing session or path" }, 400);
34
-
35
- const resolved = resolveSessionPath(sessionManager, sessionId, relPath);
36
- if (resolved instanceof Response) return resolved;
37
-
38
- try {
39
- const file = Bun.file(resolved.fullPath);
40
- const stat = await file.exists();
41
- if (!stat) return jsonResponse({ error: "File not found" }, 404);
42
- if (file.size > 1024 * 1024) return jsonResponse({ error: "File too large" }, 413);
43
-
44
- const content = await file.text();
45
- const ext = relPath.split(".").pop() || "";
46
- return jsonResponse({ content, language: LANG_MAP[ext] || "text" });
47
- } catch {
48
- return jsonResponse({ error: "Failed to read file" }, 500);
49
- }
50
- }
51
-
52
- async function handleImageServe(_req: Request, url: URL): Promise<Response> {
53
- const sessionId = url.searchParams.get("session");
54
- const relPath = url.searchParams.get("path");
55
- if (!sessionId || !relPath) return jsonResponse({ error: "Missing session or path" }, 400);
56
-
57
- const resolved = resolveSessionPath(sessionManager, sessionId, relPath);
58
- if (resolved instanceof Response) return resolved;
59
-
60
- try {
61
- const file = Bun.file(resolved.fullPath);
62
- if (!(await file.exists())) return jsonResponse({ error: "File not found" }, 404);
63
- if (file.size > 10 * 1024 * 1024) return jsonResponse({ error: "File too large" }, 413);
64
-
65
- const ext = relPath.split(".").pop()?.toLowerCase() || "";
66
- const mime = IMAGE_MIME[ext];
67
- if (!mime) return jsonResponse({ error: "Unsupported image format" }, 415);
68
-
69
- return new Response(file, {
70
- headers: {
71
- "Content-Type": mime,
72
- "Cache-Control": "public, max-age=300",
73
- },
74
- });
75
- } catch {
76
- return jsonResponse({ error: "Failed to read file" }, 500);
77
- }
78
- }
79
-
80
- async function handleTreeServe(_req: Request, url: URL): Promise<Response> {
81
- const sessionId = url.searchParams.get("session");
82
- const relPath = url.searchParams.get("path") || "";
83
- if (!sessionId) return jsonResponse({ error: "Missing session" }, 400);
84
-
85
- const resolved = resolveSessionPath(sessionManager, sessionId, relPath);
86
- if (resolved instanceof Response) return resolved;
87
-
88
- const ignoreDirs = new Set([".git", "node_modules", ".next", "__pycache__", "dist", ".cache"]);
89
-
90
- try {
91
- const dirents = await readdir(resolved.fullPath, { withFileTypes: true });
92
- const entries = dirents
93
- .filter((d) => !ignoreDirs.has(d.name) && !d.name.startsWith("."))
94
- .map((d) => ({
95
- name: d.name,
96
- type: d.isDirectory() ? "dir" : "file",
97
- }))
98
- .sort((a, b) => {
99
- if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
100
- return a.name.localeCompare(b.name);
101
- });
102
- return jsonResponse({ entries });
103
- } catch {
104
- return jsonResponse({ error: "Failed to read directory" }, 500);
105
- }
106
- }
107
-
108
- return [
109
- { method: "GET", pattern: new URLPattern({ pathname: "/api/file" }), handler: handleFileServe },
110
- { method: "GET", pattern: new URLPattern({ pathname: "/api/image" }), handler: handleImageServe },
111
- { method: "GET", pattern: new URLPattern({ pathname: "/api/tree" }), handler: handleTreeServe },
112
- ];
37
+ async function handleFileServe(_req: Request, url: URL): Promise<Response> {
38
+ const sessionId = url.searchParams.get('session')
39
+ const relPath = url.searchParams.get('path')
40
+ if (!sessionId || !relPath) return jsonResponse({ error: 'Missing session or path' }, 400)
41
+
42
+ const resolved = resolveSessionPath(sessionManager, sessionId, relPath)
43
+ if (resolved instanceof Response) return resolved
44
+
45
+ try {
46
+ const file = Bun.file(resolved.fullPath)
47
+ const stat = await file.exists()
48
+ if (!stat) return jsonResponse({ error: 'File not found' }, 404)
49
+ if (file.size > 1024 * 1024) return jsonResponse({ error: 'File too large' }, 413)
50
+
51
+ const content = await file.text()
52
+ const ext = relPath.split('.').pop() || ''
53
+ return jsonResponse({ content, language: LANG_MAP[ext] || 'text' })
54
+ } catch {
55
+ return jsonResponse({ error: 'Failed to read file' }, 500)
56
+ }
57
+ }
58
+
59
+ async function handleImageServe(_req: Request, url: URL): Promise<Response> {
60
+ const sessionId = url.searchParams.get('session')
61
+ const relPath = url.searchParams.get('path')
62
+ if (!sessionId || !relPath) return jsonResponse({ error: 'Missing session or path' }, 400)
63
+
64
+ const resolved = resolveSessionPath(sessionManager, sessionId, relPath)
65
+ if (resolved instanceof Response) return resolved
66
+
67
+ try {
68
+ const file = Bun.file(resolved.fullPath)
69
+ if (!(await file.exists())) return jsonResponse({ error: 'File not found' }, 404)
70
+ if (file.size > 10 * 1024 * 1024) return jsonResponse({ error: 'File too large' }, 413)
71
+
72
+ const ext = relPath.split('.').pop()?.toLowerCase() || ''
73
+ const mime = IMAGE_MIME[ext]
74
+ if (!mime) return jsonResponse({ error: 'Unsupported image format' }, 415)
75
+
76
+ return new Response(file, {
77
+ headers: {
78
+ 'Content-Type': mime,
79
+ 'Cache-Control': 'public, max-age=300',
80
+ },
81
+ })
82
+ } catch {
83
+ return jsonResponse({ error: 'Failed to read file' }, 500)
84
+ }
85
+ }
86
+
87
+ async function handleTreeServe(_req: Request, url: URL): Promise<Response> {
88
+ const sessionId = url.searchParams.get('session')
89
+ const relPath = url.searchParams.get('path') || ''
90
+ if (!sessionId) return jsonResponse({ error: 'Missing session' }, 400)
91
+
92
+ const resolved = resolveSessionPath(sessionManager, sessionId, relPath)
93
+ if (resolved instanceof Response) return resolved
94
+
95
+ const ignoreDirs = new Set(['.git', 'node_modules', '.next', '__pycache__', 'dist', '.cache'])
96
+
97
+ try {
98
+ const dirents = await readdir(resolved.fullPath, { withFileTypes: true })
99
+ const entries = dirents
100
+ .filter((d) => !ignoreDirs.has(d.name) && !d.name.startsWith('.'))
101
+ .map((d) => ({
102
+ name: d.name,
103
+ type: d.isDirectory() ? 'dir' : 'file',
104
+ }))
105
+ .sort((a, b) => {
106
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
107
+ return a.name.localeCompare(b.name)
108
+ })
109
+ return jsonResponse({ entries })
110
+ } catch {
111
+ return jsonResponse({ error: 'Failed to read directory' }, 500)
112
+ }
113
+ }
114
+
115
+ return [
116
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/file' }), handler: handleFileServe },
117
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/image' }), handler: handleImageServe },
118
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/tree' }), handler: handleTreeServe },
119
+ ]
113
120
  }
@@ -1,61 +1,62 @@
1
- import { join, dirname } from "path";
2
- import { DATA_DIR } from "../paths";
3
- import type { Route } from "@fabrika/daemon-kit";
1
+ import type { Route } from '@fabrika/daemon-kit'
2
+ import { dirname, join } from 'path'
3
+ import { DATA_DIR } from '../paths'
4
4
 
5
5
  const MIME_TYPES: Record<string, string> = {
6
- js: "application/javascript",
7
- css: "text/css",
8
- html: "text/html",
9
- svg: "image/svg+xml",
10
- png: "image/png",
11
- };
6
+ js: 'application/javascript',
7
+ css: 'text/css',
8
+ html: 'text/html',
9
+ svg: 'image/svg+xml',
10
+ png: 'image/png',
11
+ }
12
12
 
13
- let cachedDistDir: string | null = null;
13
+ let cachedDistDir: string | null = null
14
14
  async function getDistDir(): Promise<string> {
15
- if (cachedDistDir) return cachedDistDir;
16
- // import.meta.dir = daemon/src/handlers/, go up two levels to daemon/
17
- const localDist = join(dirname(import.meta.dir), "..", "dist");
18
- if (await Bun.file(join(localDist, "index.html")).exists()) {
19
- cachedDistDir = localDist;
20
- } else {
21
- cachedDistDir = join(DATA_DIR, "daemon");
22
- }
23
- return cachedDistDir;
15
+ if (cachedDistDir) return cachedDistDir
16
+ // import.meta.dir = daemon/src/handlers/, go up two levels to daemon/
17
+ const localDist = join(dirname(import.meta.dir), '..', 'dist')
18
+ if (await Bun.file(join(localDist, 'index.html')).exists()) {
19
+ cachedDistDir = localDist
20
+ } else {
21
+ cachedDistDir = join(DATA_DIR, 'daemon')
22
+ }
23
+ return cachedDistDir
24
24
  }
25
25
 
26
26
  async function serveSessionPage(): Promise<Response> {
27
- const distDir = await getDistDir();
28
- const file = Bun.file(join(distDir, "index.html"));
29
- if (await file.exists()) {
30
- return new Response(file, { headers: { "Content-Type": "text/html" } });
31
- }
32
- return new Response("Build not found. Run: bun run daemon/build.ts", { status: 500 });
27
+ const distDir = await getDistDir()
28
+ const file = Bun.file(join(distDir, 'index.html'))
29
+ if (await file.exists()) {
30
+ return new Response(file, { headers: { 'Content-Type': 'text/html' } })
31
+ }
32
+ return new Response('Build not found. Run: bun run daemon/build.ts', { status: 500 })
33
33
  }
34
34
 
35
35
  async function serveStaticAsset(_req: Request, url: URL): Promise<Response> {
36
- const path = url.pathname;
37
- const distDir = await getDistDir();
38
- const assetPath = join(distDir, path.replace("/assets/", ""));
39
- const file = Bun.file(assetPath);
40
- if (!(await file.exists())) return new Response("Not found", { status: 404 });
36
+ const path = url.pathname
37
+ const distDir = await getDistDir()
38
+ const assetPath = join(distDir, path.replace('/assets/', ''))
39
+ const file = Bun.file(assetPath)
40
+ if (!(await file.exists())) return new Response('Not found', { status: 404 })
41
41
 
42
- const ext = path.split(".").pop() || "";
43
- return new Response(file, {
44
- headers: { "Content-Type": MIME_TYPES[ext] || "application/octet-stream" },
45
- });
42
+ const ext = path.split('.').pop() || ''
43
+ return new Response(file, {
44
+ // A rebuild keeps the file names, and pages that load the review widget would otherwise keep a stale copy.
45
+ headers: { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' },
46
+ })
46
47
  }
47
48
 
48
49
  export function createStaticHandlers(): Route[] {
49
- return [
50
- {
51
- method: "GET",
52
- pattern: new URLPattern({ pathname: "/s/:id" }),
53
- handler: serveSessionPage,
54
- },
55
- {
56
- method: "GET",
57
- pattern: new URLPattern({ pathname: "/assets/*" }),
58
- handler: serveStaticAsset,
59
- },
60
- ];
50
+ return [
51
+ {
52
+ method: 'GET',
53
+ pattern: new URLPattern({ pathname: '/s/:id' }),
54
+ handler: serveSessionPage,
55
+ },
56
+ {
57
+ method: 'GET',
58
+ pattern: new URLPattern({ pathname: '/assets/*' }),
59
+ handler: serveStaticAsset,
60
+ },
61
+ ]
61
62
  }
@@ -1,61 +1,66 @@
1
- import { writeFileSync } from "fs";
2
- import { join } from "path";
3
- import type { SessionManager } from "@fabrika/canvas-kernel/server";
4
- import { jsonResponse, type Route } from "@fabrika/daemon-kit";
5
- import { UPLOADS_DIR } from "../paths";
1
+ import type { SessionManager } from '@fabrika/canvas-kernel/server'
2
+ import { jsonResponse, type Route } from '@fabrika/daemon-kit'
3
+ import { writeFileSync } from 'fs'
4
+ import { join } from 'path'
5
+ import { UPLOADS_DIR } from '../paths'
6
6
 
7
7
  const IMAGE_MIME: Record<string, string> = {
8
- png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
9
- gif: "image/gif", svg: "image/svg+xml", webp: "image/webp",
10
- bmp: "image/bmp", avif: "image/avif",
11
- };
8
+ png: 'image/png',
9
+ jpg: 'image/jpeg',
10
+ jpeg: 'image/jpeg',
11
+ gif: 'image/gif',
12
+ svg: 'image/svg+xml',
13
+ webp: 'image/webp',
14
+ bmp: 'image/bmp',
15
+ avif: 'image/avif',
16
+ }
12
17
 
13
18
  export function createUploadHandlers(sessionManager: SessionManager): Route[] {
14
- async function handleUpload(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
15
- const sessionId = match.pathname.groups.id!;
16
- if (!sessionManager.get(sessionId)) {
17
- return jsonResponse({ error: "Session not found" }, 404);
18
- }
19
-
20
- const formData = await req.formData();
21
- const file = formData.get("image") as File | null;
22
- if (!file) return jsonResponse({ error: "No image provided" }, 400);
23
- if (!file.type.startsWith("image/")) return jsonResponse({ error: "Not an image" }, 400);
24
- if (file.size > 10 * 1024 * 1024) return jsonResponse({ error: "Image too large (max 10MB)" }, 413);
25
-
26
- const ext = file.name.split(".").pop()?.toLowerCase() || "png";
27
- const filename = `${crypto.randomUUID()}.${ext}`;
28
- const filepath = join(UPLOADS_DIR, filename);
29
-
30
- const buffer = await file.arrayBuffer();
31
- writeFileSync(filepath, Buffer.from(buffer));
32
-
33
- return jsonResponse({ path: filepath, filename });
34
- }
35
-
36
- async function handleUploadServe(_req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
37
- const filename = match.pathname.groups.filename!;
38
- if (filename.includes("/") || filename.includes("..")) {
39
- return jsonResponse({ error: "Invalid filename" }, 400);
40
- }
41
-
42
- const filepath = join(UPLOADS_DIR, filename);
43
- const file = Bun.file(filepath);
44
- if (!(await file.exists())) return jsonResponse({ error: "Not found" }, 404);
45
-
46
- const ext = filename.split(".").pop()?.toLowerCase() || "";
47
- const mime = IMAGE_MIME[ext] || "application/octet-stream";
48
-
49
- return new Response(file, {
50
- headers: {
51
- "Content-Type": mime,
52
- "Cache-Control": "public, max-age=3600",
53
- },
54
- });
55
- }
56
-
57
- return [
58
- { method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/upload" }), handler: handleUpload },
59
- { method: "GET", pattern: new URLPattern({ pathname: "/api/uploads/:filename" }), handler: handleUploadServe },
60
- ];
19
+ async function handleUpload(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
20
+ const sessionId = match.pathname.groups.id!
21
+ if (!sessionManager.get(sessionId)) {
22
+ return jsonResponse({ error: 'Session not found' }, 404)
23
+ }
24
+
25
+ const formData = await req.formData()
26
+ const file = formData.get('image') as File | null
27
+ if (!file) return jsonResponse({ error: 'No image provided' }, 400)
28
+ if (!file.type.startsWith('image/')) return jsonResponse({ error: 'Not an image' }, 400)
29
+ if (file.size > 10 * 1024 * 1024) return jsonResponse({ error: 'Image too large (max 10MB)' }, 413)
30
+
31
+ const ext = file.name.split('.').pop()?.toLowerCase() || 'png'
32
+ const filename = `${crypto.randomUUID()}.${ext}`
33
+ const filepath = join(UPLOADS_DIR, filename)
34
+
35
+ const buffer = await file.arrayBuffer()
36
+ writeFileSync(filepath, Buffer.from(buffer))
37
+
38
+ return jsonResponse({ path: filepath, filename })
39
+ }
40
+
41
+ async function handleUploadServe(_req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
42
+ const filename = match.pathname.groups.filename!
43
+ if (filename.includes('/') || filename.includes('..')) {
44
+ return jsonResponse({ error: 'Invalid filename' }, 400)
45
+ }
46
+
47
+ const filepath = join(UPLOADS_DIR, filename)
48
+ const file = Bun.file(filepath)
49
+ if (!(await file.exists())) return jsonResponse({ error: 'Not found' }, 404)
50
+
51
+ const ext = filename.split('.').pop()?.toLowerCase() || ''
52
+ const mime = IMAGE_MIME[ext] || 'application/octet-stream'
53
+
54
+ return new Response(file, {
55
+ headers: {
56
+ 'Content-Type': mime,
57
+ 'Cache-Control': 'public, max-age=3600',
58
+ },
59
+ })
60
+ }
61
+
62
+ return [
63
+ { method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/upload' }), handler: handleUpload },
64
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/uploads/:filename' }), handler: handleUploadServe },
65
+ ]
61
66
  }
@@ -1,15 +1,18 @@
1
- import { join } from "path";
2
- import { createCanvasPaths } from "@fabrika/canvas-kernel/server";
1
+ import { createCanvasPaths } from '@fabrika/canvas-kernel/server'
2
+ import { join } from 'path'
3
3
 
4
4
  /** agent-canvas's on-disk layout. Kept in sync with bin/lib/config.ts. */
5
- export const paths = createCanvasPaths({ appName: "agent-canvas" });
5
+ export const paths = createCanvasPaths({ appName: 'agent-canvas' })
6
6
 
7
- export const DATA_DIR = paths.dataDir;
8
- export const SESSIONS_DIR = paths.sessionsDir;
9
- export const TEMP_DIR = paths.tempDir;
10
- export const PID_FILE = paths.pidFile;
11
- export const COMPILE_TEMP_DIR = paths.compileTempDir;
12
- export const UPLOADS_DIR = paths.uploadsDir;
7
+ export const DATA_DIR = paths.dataDir
8
+ export const SESSIONS_DIR = paths.sessionsDir
9
+ export const TEMP_DIR = paths.tempDir
10
+ export const PID_FILE = paths.pidFile
11
+ export const COMPILE_TEMP_DIR = paths.compileTempDir
12
+ export const UPLOADS_DIR = paths.uploadsDir
13
13
 
14
14
  /** Capability used only by the CLI to resolve secret values */
15
- export const CLI_AUTH_FILE = join(paths.tempDir, "daemon-auth-token");
15
+ export const CLI_AUTH_FILE = join(paths.tempDir, 'daemon-auth-token')
16
+
17
+ /** Review-mode threads, one JSON file per project */
18
+ export const REVIEWS_DIR = join(paths.dataDir, 'reviews')