agent-canvas 0.1.4 → 0.1.7

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/bin/agent-canvas.ts +26 -353
  4. package/bin/lib/commands/daemon.ts +51 -0
  5. package/bin/lib/commands/fetch.ts +20 -0
  6. package/bin/lib/commands/install.ts +48 -0
  7. package/bin/lib/commands/push.ts +66 -0
  8. package/bin/lib/commands/watch.ts +23 -0
  9. package/bin/lib/config.ts +10 -0
  10. package/bin/lib/daemon-lifecycle.ts +74 -0
  11. package/bin/lib/helpers.ts +78 -0
  12. package/daemon/build.ts +27 -61
  13. package/daemon/client/AnnotationProvider.tsx +2 -2
  14. package/daemon/client/AnnotationSidebar.tsx +199 -31
  15. package/daemon/client/App.tsx +4 -3
  16. package/daemon/client/FileBrowser.tsx +8 -7
  17. package/daemon/client/FileViewer.tsx +153 -16
  18. package/daemon/client/PlanRenderer.tsx +6 -3
  19. package/daemon/client/ResponsePreview.tsx +5 -116
  20. package/daemon/client/SessionSwitcher.tsx +21 -10
  21. package/daemon/client/compat/preact-all.ts +4 -0
  22. package/daemon/client/components/Markdown.tsx +44 -0
  23. package/daemon/client/components/ResponseNote.tsx +3 -8
  24. package/daemon/client/components/UserInput.tsx +2 -4
  25. package/daemon/client/components/index.ts +1 -0
  26. package/daemon/client/domDiff.ts +82 -2
  27. package/daemon/client/generateMarkdown.ts +46 -9
  28. package/daemon/client/highlightRange.ts +8 -6
  29. package/daemon/client/runtime.ts +2 -0
  30. package/daemon/client/styles.css +126 -0
  31. package/daemon/client/unifiedDiff.ts +155 -2
  32. package/daemon/client/utils.ts +10 -0
  33. package/daemon/dist/client.css +1 -1
  34. package/daemon/dist/client.js +83 -36
  35. package/daemon/dist/components.js +63 -5
  36. package/daemon/dist/index.html +5 -12
  37. package/daemon/dist/jsx-runtime.js +3 -0
  38. package/daemon/dist/preact-compat.js +1 -0
  39. package/daemon/src/compiler.ts +28 -2
  40. package/daemon/src/handlers/api.ts +150 -0
  41. package/daemon/src/handlers/files.ts +114 -0
  42. package/daemon/src/handlers/static.ts +61 -0
  43. package/daemon/src/handlers/utils.ts +14 -0
  44. package/daemon/src/router.ts +16 -0
  45. package/daemon/src/server.ts +28 -403
  46. package/daemon/src/session.ts +41 -23
  47. package/daemon/src/websocket.ts +83 -0
  48. package/package.json +3 -3
  49. package/skills/canvas/SKILL.md +32 -22
  50. package/skills/canvas/components.md +31 -0
  51. package/skills/canvas/flows.md +2 -2
  52. package/daemon/dist/jsx-dev-runtime-shim.js +0 -17
  53. package/daemon/dist/jsx-runtime-shim.js +0 -17
  54. package/daemon/dist/react-dom-client-shim.js +0 -4
  55. package/daemon/dist/react-dom-shim.js +0 -4
  56. package/daemon/dist/react-shim.js +0 -4
@@ -0,0 +1,150 @@
1
+ import { readFileSync } from "fs";
2
+ import type { SessionManager } from "../session";
3
+ import { compilePlan } from "../compiler";
4
+ import { watchSession } from "../watcher";
5
+ import { jsonResponse } from "./utils";
6
+ import type { Route } from "../router";
7
+
8
+ export interface ApiContext {
9
+ sessionManager: SessionManager;
10
+ broadcastPlanUpdate: (id: string) => void;
11
+ port: number;
12
+ }
13
+
14
+ function readRevisionJsx(sessionManager: SessionManager, sessionId: string, rev: number): string | null {
15
+ try {
16
+ const path = sessionManager.getRevisionJsxPath(sessionId, rev);
17
+ return readFileSync(path, "utf-8");
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function createApiHandlers(ctx: ApiContext): Route[] {
24
+ const { sessionManager, broadcastPlanUpdate, port } = ctx;
25
+
26
+ async function handlePlanPost(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
27
+ const sessionId = match.pathname.groups.id!;
28
+ try {
29
+ const body = await req.json();
30
+ const { jsx, projectRoot, label, sourceFile } = body;
31
+ if (!jsx) return jsonResponse({ error: "Missing jsx" }, 400);
32
+
33
+ const unconsumed = sessionManager.getLatestUnconsumedFeedback(sessionId);
34
+ if (unconsumed) {
35
+ sessionManager.consumeFeedback(sessionId, unconsumed.revision);
36
+ return jsonResponse({
37
+ ok: false,
38
+ error: `Unconsumed feedback from revision ${unconsumed.revision}. Address the feedback before pushing a new canvas.`,
39
+ unconsumedFeedback: unconsumed.feedback,
40
+ unconsumedRevision: unconsumed.revision,
41
+ }, 409);
42
+ }
43
+
44
+ const isNew = !sessionManager.get(sessionId);
45
+ const session = sessionManager.upsert(sessionId, jsx, projectRoot || process.cwd(), label, sourceFile);
46
+
47
+ const result = await compilePlan(jsx, session.projectRoot);
48
+ if (result.ok) {
49
+ sessionManager.saveCompiled(sessionId, result.js, session.currentRevision);
50
+ broadcastPlanUpdate(sessionId);
51
+ }
52
+
53
+ watchSession(sessionId, sessionManager, broadcastPlanUpdate);
54
+
55
+ const browserUrl = `http://localhost:${port}/s/${sessionId}`;
56
+ return jsonResponse({
57
+ ok: result.ok,
58
+ browserUrl,
59
+ isNew,
60
+ revision: session.currentRevision,
61
+ error: result.ok ? undefined : result.error,
62
+ });
63
+ } catch (e: any) {
64
+ return jsonResponse({ error: e.message }, 500);
65
+ }
66
+ }
67
+
68
+ async function handlePlanJs(_req: Request, url: URL, match: URLPatternResult): Promise<Response> {
69
+ const sessionId = match.pathname.groups.id!;
70
+ const revParam = url.searchParams.get("rev");
71
+ const rev = revParam ? parseInt(revParam, 10) : undefined;
72
+
73
+ let compiled = sessionManager.getCompiled(sessionId, rev);
74
+
75
+ if (!compiled && rev) {
76
+ const session = sessionManager.get(sessionId);
77
+ if (session) {
78
+ const jsx = readRevisionJsx(sessionManager, sessionId, rev);
79
+ if (jsx) {
80
+ const result = await compilePlan(jsx, session.projectRoot);
81
+ if (result.ok) {
82
+ sessionManager.saveCompiled(sessionId, result.js, rev);
83
+ compiled = result.js;
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ if (!compiled) return jsonResponse({ error: "No compiled plan" }, 404);
90
+ return new Response(compiled, {
91
+ headers: {
92
+ "Content-Type": "application/javascript",
93
+ "Cache-Control": "no-cache",
94
+ },
95
+ });
96
+ }
97
+
98
+ function handleHealth(): Response {
99
+ const sessions = sessionManager.list().map((s) => s.id);
100
+ return jsonResponse({ ok: true, sessions });
101
+ }
102
+
103
+ function handleMeta(_req: Request, _url: URL, match: URLPatternResult): Response {
104
+ const sessionId = match.pathname.groups.id!;
105
+ const session = sessionManager.get(sessionId);
106
+ if (!session) return jsonResponse({ error: "Session not found" }, 404);
107
+ return jsonResponse({
108
+ projectRoot: session.projectRoot,
109
+ currentRevision: session.currentRevision,
110
+ revisions: session.revisions,
111
+ createdAt: session.createdAt,
112
+ updatedAt: session.updatedAt,
113
+ });
114
+ }
115
+
116
+ function handleSessions(): Response {
117
+ return jsonResponse(sessionManager.list().map((s) => ({
118
+ id: s.id,
119
+ projectRoot: s.projectRoot,
120
+ currentRevision: s.currentRevision,
121
+ updatedAt: s.updatedAt,
122
+ })));
123
+ }
124
+
125
+ function handleFeedbackGet(_req: Request, _url: URL, match: URLPatternResult): Response {
126
+ const sessionId = match.pathname.groups.id!;
127
+ const rev = parseInt(match.pathname.groups.rev!, 10);
128
+ const feedback = sessionManager.getFeedback(sessionId, rev);
129
+ if (feedback === null) return jsonResponse({ error: "No feedback" }, 404);
130
+ return jsonResponse({ feedback });
131
+ }
132
+
133
+ function handleFeedbackConsume(_req: Request, _url: URL, match: URLPatternResult): Response {
134
+ const sessionId = match.pathname.groups.id!;
135
+ const result = sessionManager.getLatestUnconsumedFeedback(sessionId);
136
+ if (!result) return jsonResponse({ found: false });
137
+ sessionManager.consumeFeedback(sessionId, result.revision);
138
+ return jsonResponse({ found: true, revision: result.revision, feedback: result.feedback });
139
+ }
140
+
141
+ return [
142
+ { method: "GET", pattern: new URLPattern({ pathname: "/health" }), handler: handleHealth },
143
+ { method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/plan" }), handler: handlePlanPost },
144
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/plan.js" }), handler: handlePlanJs },
145
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/meta" }), handler: handleMeta },
146
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/feedback" }), handler: handleFeedbackGet },
147
+ { method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/feedback/consume" }), handler: handleFeedbackConsume },
148
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/sessions" }), handler: handleSessions },
149
+ ];
150
+ }
@@ -0,0 +1,114 @@
1
+ import { join, resolve } from "path";
2
+ import { readdir } from "fs/promises";
3
+ import type { SessionManager } from "../session";
4
+ import { LANG_MAP } from "../../langMap";
5
+ import { jsonResponse } from "./utils";
6
+ import type { Route } from "../router";
7
+
8
+ const IMAGE_MIME: Record<string, string> = {
9
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
10
+ gif: "image/gif", svg: "image/svg+xml", webp: "image/webp",
11
+ ico: "image/x-icon", bmp: "image/bmp", avif: "image/avif",
12
+ };
13
+
14
+ function resolveSessionPath(
15
+ sessionManager: SessionManager,
16
+ sessionId: string,
17
+ relPath: string,
18
+ ): { fullPath: string; session: ReturnType<SessionManager["get"]> & {} } | Response {
19
+ const session = sessionManager.get(sessionId);
20
+ if (!session) return jsonResponse({ error: "Session not found" }, 404);
21
+
22
+ const fullPath = resolve(join(session.projectRoot, relPath));
23
+ if (!fullPath.startsWith(resolve(session.projectRoot))) {
24
+ return jsonResponse({ error: "Path traversal rejected" }, 403);
25
+ }
26
+
27
+ return { fullPath, session };
28
+ }
29
+
30
+ export function createFileHandlers(sessionManager: SessionManager): Route[] {
31
+ async function handleFileServe(_req: Request, url: URL): Promise<Response> {
32
+ const sessionId = url.searchParams.get("session");
33
+ const relPath = url.searchParams.get("path");
34
+ if (!sessionId || !relPath) return jsonResponse({ error: "Missing session or path" }, 400);
35
+
36
+ const resolved = resolveSessionPath(sessionManager, sessionId, relPath);
37
+ if (resolved instanceof Response) return resolved;
38
+
39
+ try {
40
+ const file = Bun.file(resolved.fullPath);
41
+ const stat = await file.exists();
42
+ if (!stat) return jsonResponse({ error: "File not found" }, 404);
43
+ if (file.size > 1024 * 1024) return jsonResponse({ error: "File too large" }, 413);
44
+
45
+ const content = await file.text();
46
+ const ext = relPath.split(".").pop() || "";
47
+ return jsonResponse({ content, language: LANG_MAP[ext] || "text" });
48
+ } catch {
49
+ return jsonResponse({ error: "Failed to read file" }, 500);
50
+ }
51
+ }
52
+
53
+ async function handleImageServe(_req: Request, url: URL): Promise<Response> {
54
+ const sessionId = url.searchParams.get("session");
55
+ const relPath = url.searchParams.get("path");
56
+ if (!sessionId || !relPath) return jsonResponse({ error: "Missing session or path" }, 400);
57
+
58
+ const resolved = resolveSessionPath(sessionManager, sessionId, relPath);
59
+ if (resolved instanceof Response) return resolved;
60
+
61
+ try {
62
+ const file = Bun.file(resolved.fullPath);
63
+ if (!(await file.exists())) return jsonResponse({ error: "File not found" }, 404);
64
+ if (file.size > 10 * 1024 * 1024) return jsonResponse({ error: "File too large" }, 413);
65
+
66
+ const ext = relPath.split(".").pop()?.toLowerCase() || "";
67
+ const mime = IMAGE_MIME[ext];
68
+ if (!mime) return jsonResponse({ error: "Unsupported image format" }, 415);
69
+
70
+ return new Response(file, {
71
+ headers: {
72
+ "Content-Type": mime,
73
+ "Cache-Control": "public, max-age=300",
74
+ },
75
+ });
76
+ } catch {
77
+ return jsonResponse({ error: "Failed to read file" }, 500);
78
+ }
79
+ }
80
+
81
+ async function handleTreeServe(_req: Request, url: URL): Promise<Response> {
82
+ const sessionId = url.searchParams.get("session");
83
+ const relPath = url.searchParams.get("path") || "";
84
+ if (!sessionId) return jsonResponse({ error: "Missing session" }, 400);
85
+
86
+ const resolved = resolveSessionPath(sessionManager, sessionId, relPath);
87
+ if (resolved instanceof Response) return resolved;
88
+
89
+ const ignoreDirs = new Set([".git", "node_modules", ".next", "__pycache__", "dist", ".cache"]);
90
+
91
+ try {
92
+ const dirents = await readdir(resolved.fullPath, { withFileTypes: true });
93
+ const entries = dirents
94
+ .filter((d) => !ignoreDirs.has(d.name) && !d.name.startsWith("."))
95
+ .map((d) => ({
96
+ name: d.name,
97
+ type: d.isDirectory() ? "dir" : "file",
98
+ }))
99
+ .sort((a, b) => {
100
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
101
+ return a.name.localeCompare(b.name);
102
+ });
103
+ return jsonResponse({ entries });
104
+ } catch {
105
+ return jsonResponse({ error: "Failed to read directory" }, 500);
106
+ }
107
+ }
108
+
109
+ return [
110
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/file" }), handler: handleFileServe },
111
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/image" }), handler: handleImageServe },
112
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/tree" }), handler: handleTreeServe },
113
+ ];
114
+ }
@@ -0,0 +1,61 @@
1
+ import { join, dirname } from "path";
2
+ import { DATA_DIR } from "../paths";
3
+ import type { Route } from "../router";
4
+
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
+ };
12
+
13
+ let cachedDistDir: string | null = null;
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;
24
+ }
25
+
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 });
33
+ }
34
+
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 });
41
+
42
+ const ext = path.split(".").pop() || "";
43
+ return new Response(file, {
44
+ headers: { "Content-Type": MIME_TYPES[ext] || "application/octet-stream" },
45
+ });
46
+ }
47
+
48
+ 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
+ ];
61
+ }
@@ -0,0 +1,14 @@
1
+ export function jsonResponse(data: unknown, status = 200): Response {
2
+ return new Response(JSON.stringify(data), {
3
+ status,
4
+ headers: { "Content-Type": "application/json" },
5
+ });
6
+ }
7
+
8
+ export function corsHeaders(port: number): Record<string, string> {
9
+ return {
10
+ "Access-Control-Allow-Origin": "http://localhost:" + port,
11
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
12
+ "Access-Control-Allow-Headers": "Content-Type",
13
+ };
14
+ }
@@ -0,0 +1,16 @@
1
+ export type RouteHandler = (req: Request, url: URL, match: URLPatternResult) => Response | Promise<Response>;
2
+
3
+ export interface Route {
4
+ method: string;
5
+ pattern: URLPattern;
6
+ handler: RouteHandler;
7
+ }
8
+
9
+ export function dispatch(routes: Route[], req: Request, url: URL): Response | Promise<Response> | null {
10
+ for (const route of routes) {
11
+ if (route.method !== req.method) continue;
12
+ const match = route.pattern.exec(url);
13
+ if (match) return route.handler(req, url, match);
14
+ }
15
+ return null;
16
+ }