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
@@ -1,12 +1,13 @@
1
- import { join, resolve, dirname } from "path";
2
- import { readdir } from "fs/promises";
3
1
  import { writeFileSync, mkdirSync } from "fs";
2
+ import { join } from "path";
4
3
  import { tmpdir } from "os";
5
- import { DATA_DIR } from "./paths";
6
4
  import { SessionManager } from "./session";
7
- import { compilePlan } from "./compiler";
8
- import { watchSession } from "./watcher";
9
- import { LANG_MAP } from "../langMap";
5
+ import { createWebSocketManager, type WSData } from "./websocket";
6
+ import { dispatch } from "./router";
7
+ import { jsonResponse, corsHeaders } from "./handlers/utils";
8
+ import { createApiHandlers } from "./handlers/api";
9
+ import { createFileHandlers } from "./handlers/files";
10
+ import { createStaticHandlers } from "./handlers/static";
10
11
 
11
12
  const PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
12
13
 
@@ -14,146 +15,49 @@ const PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
14
15
  const pidDir = join(tmpdir(), "agent-canvas");
15
16
  mkdirSync(pidDir, { recursive: true });
16
17
  writeFileSync(join(pidDir, "daemon.pid"), String(process.pid));
17
- const sessionManager = new SessionManager();
18
-
19
- function jsonResponse(data: unknown, status = 200): Response {
20
- return new Response(JSON.stringify(data), {
21
- status,
22
- headers: { "Content-Type": "application/json" },
23
- });
24
- }
25
18
 
26
- function corsHeaders(headers: Record<string, string> = {}): Record<string, string> {
27
- return {
28
- "Access-Control-Allow-Origin": "http://localhost:" + PORT,
29
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
30
- "Access-Control-Allow-Headers": "Content-Type",
31
- ...headers,
32
- };
33
- }
34
-
35
- // WebSocket connections
36
- type WS<T = undefined> = import("bun").ServerWebSocket<T>;
37
- const browserSockets = new Map<string, Set<WS<{ type: string; sessionId: string }>>>();
38
- const waitSockets = new Map<string, Set<WS<{ type: string; sessionId: string }>>>();
19
+ const sessionManager = new SessionManager();
20
+ const wsManager = createWebSocketManager(sessionManager);
39
21
 
40
- type WSData = { type: "browser" | "wait"; sessionId: string };
22
+ const routes = [
23
+ ...createApiHandlers({ sessionManager, broadcastPlanUpdate: wsManager.broadcastPlanUpdate, port: PORT }),
24
+ ...createFileHandlers(sessionManager),
25
+ ...createStaticHandlers(),
26
+ ];
41
27
 
42
28
  const server = Bun.serve<WSData>({
43
29
  port: PORT,
44
30
  hostname: "localhost",
45
31
  fetch(req, server) {
46
32
  const url = new URL(req.url);
47
- const path = url.pathname;
48
33
 
49
- // OPTIONS (CORS preflight)
34
+ // CORS preflight
50
35
  if (req.method === "OPTIONS") {
51
- return new Response(null, { status: 204, headers: corsHeaders() });
52
- }
53
-
54
- // Health
55
- if (path === "/health" && req.method === "GET") {
56
- const sessions = sessionManager.list().map((s) => s.id);
57
- return jsonResponse({ ok: true, sessions });
58
- }
59
-
60
- // POST /api/session/:id/plan
61
- const planPostMatch = path.match(/^\/api\/session\/([^/]+)\/plan$/);
62
- if (planPostMatch && req.method === "POST") {
63
- const sessionId = planPostMatch[1];
64
- return handlePlanPost(req, sessionId);
65
- }
66
-
67
- // GET /api/session/:id/plan.js?rev=N
68
- const planJsMatch = path.match(/^\/api\/session\/([^/]+)\/plan\.js$/);
69
- if (planJsMatch && req.method === "GET") {
70
- const sessionId = planJsMatch[1];
71
- const revParam = url.searchParams.get("rev");
72
- const rev = revParam ? parseInt(revParam, 10) : undefined;
73
- return handlePlanJs(sessionId, rev);
74
- }
75
-
76
- // GET /api/session/:id/meta
77
- const metaMatch = path.match(/^\/api\/session\/([^/]+)\/meta$/);
78
- if (metaMatch && req.method === "GET") {
79
- const sessionId = metaMatch[1];
80
- const session = sessionManager.get(sessionId);
81
- if (!session) return jsonResponse({ error: "Session not found" }, 404);
82
- return jsonResponse({
83
- projectRoot: session.projectRoot,
84
- currentRevision: session.currentRevision,
85
- revisions: session.revisions,
86
- createdAt: session.createdAt,
87
- updatedAt: session.updatedAt,
88
- });
89
- }
90
-
91
- // GET /api/session/:id/revision/:rev/feedback
92
- const feedbackMatch = path.match(/^\/api\/session\/([^/]+)\/revision\/(\d+)\/feedback$/);
93
- if (feedbackMatch && req.method === "GET") {
94
- const sessionId = feedbackMatch[1];
95
- const rev = parseInt(feedbackMatch[2], 10);
96
- const feedback = sessionManager.getFeedback(sessionId, rev);
97
- if (feedback === null) return jsonResponse({ error: "No feedback" }, 404);
98
- return jsonResponse({ feedback });
99
- }
100
-
101
- // GET /api/sessions
102
- if (path === "/api/sessions" && req.method === "GET") {
103
- return jsonResponse(sessionManager.list().map((s) => ({
104
- id: s.id,
105
- projectRoot: s.projectRoot,
106
- currentRevision: s.currentRevision,
107
- updatedAt: s.updatedAt,
108
- })));
109
- }
110
-
111
- // GET /api/file
112
- if (path === "/api/file" && req.method === "GET") {
113
- return handleFileServe(url);
114
- }
115
-
116
- // GET /api/image
117
- if (path === "/api/image" && req.method === "GET") {
118
- return handleImageServe(url);
119
- }
120
-
121
- // GET /api/tree
122
- if (path === "/api/tree" && req.method === "GET") {
123
- return handleTreeServe(url);
36
+ return new Response(null, { status: 204, headers: corsHeaders(PORT) });
124
37
  }
125
38
 
126
39
  // WebSocket upgrade: /ws/session/:id
127
- const wsBrowserMatch = path.match(/^\/ws\/session\/([^/]+)$/);
40
+ const wsBrowserMatch = url.pathname.match(/^\/ws\/session\/([^/]+)$/);
128
41
  if (wsBrowserMatch) {
129
- const sessionId = wsBrowserMatch[1];
130
- const upgraded = server.upgrade(req, { data: { type: "browser", sessionId } });
42
+ const upgraded = server.upgrade(req, { data: { type: "browser", sessionId: wsBrowserMatch[1] } });
131
43
  if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
132
44
  return undefined as any;
133
45
  }
134
46
 
135
47
  // WebSocket upgrade: /ws/wait/:id
136
- const wsWaitMatch = path.match(/^\/ws\/wait\/([^/]+)$/);
48
+ const wsWaitMatch = url.pathname.match(/^\/ws\/wait\/([^/]+)$/);
137
49
  if (wsWaitMatch) {
138
- const sessionId = wsWaitMatch[1];
139
- const upgraded = server.upgrade(req, { data: { type: "wait", sessionId } });
50
+ const upgraded = server.upgrade(req, { data: { type: "wait", sessionId: wsWaitMatch[1] } });
140
51
  if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
141
52
  return undefined as any;
142
53
  }
143
54
 
144
- // GET /s/:id — serve session UI
145
- const sessionPageMatch = path.match(/^\/s\/([^/]+)$/);
146
- if (sessionPageMatch && req.method === "GET") {
147
- return serveSessionPage(sessionPageMatch[1]);
148
- }
149
-
150
- // GET /assets/* — static files
151
- if (path.startsWith("/assets/") && req.method === "GET") {
152
- return serveStaticAsset(path);
153
- }
55
+ // Route dispatch
56
+ const response = dispatch(routes, req, url);
57
+ if (response) return response;
154
58
 
155
- // GET / — redirect to last active session or list
156
- if (path === "/" && req.method === "GET") {
59
+ // Root redirect
60
+ if (url.pathname === "/" && req.method === "GET") {
157
61
  const sessions = sessionManager.list();
158
62
  if (sessions.length > 0) {
159
63
  const latest = sessions.sort((a, b) =>
@@ -167,291 +71,12 @@ const server = Bun.serve<WSData>({
167
71
  return jsonResponse({ error: "Not found" }, 404);
168
72
  },
169
73
 
170
- websocket: {
171
- open(ws) {
172
- const { type, sessionId } = ws.data;
173
- const map = type === "browser" ? browserSockets : waitSockets;
174
- if (!map.has(sessionId)) map.set(sessionId, new Set());
175
- map.get(sessionId)!.add(ws);
176
- },
177
- message(ws, message) {
178
- const { type, sessionId } = ws.data;
179
- if (type === "browser") {
180
- // Browser sends submit
181
- try {
182
- const data = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
183
- if (data.type === "submit") {
184
- const feedback = data.feedback as string;
185
-
186
- // Persist feedback to current revision
187
- const session = sessionManager.get(sessionId);
188
- if (session) {
189
- sessionManager.saveFeedback(sessionId, session.currentRevision, feedback);
190
- // Notify browsers that revision metadata changed
191
- broadcastRevisionUpdate(sessionId);
192
- }
193
-
194
- // Forward to all waiting CLI sockets
195
- const waiters = waitSockets.get(sessionId);
196
- if (waiters) {
197
- const payload = JSON.stringify({ type: "submit", feedback });
198
- for (const waiter of waiters) {
199
- waiter.send(payload);
200
- waiter.close();
201
- }
202
- waitSockets.delete(sessionId);
203
- }
204
- }
205
- } catch {}
206
- }
207
- },
208
- close(ws) {
209
- const { type, sessionId } = ws.data;
210
- const map = type === "browser" ? browserSockets : waitSockets;
211
- map.get(sessionId)?.delete(ws);
212
- if (map.get(sessionId)?.size === 0) map.delete(sessionId);
213
- },
214
- },
74
+ websocket: wsManager.handlers,
215
75
  });
216
76
 
217
- async function handlePlanPost(req: Request, sessionId: string): Promise<Response> {
218
- try {
219
- const body = await req.json();
220
- const { jsx, projectRoot, label, sourceFile } = body;
221
- if (!jsx) return jsonResponse({ error: "Missing jsx" }, 400);
222
-
223
- const isNew = !sessionManager.get(sessionId);
224
- const session = sessionManager.upsert(sessionId, jsx, projectRoot || process.cwd(), label, sourceFile);
225
-
226
- const result = await compilePlan(jsx, session.projectRoot);
227
- if (result.ok) {
228
- sessionManager.saveCompiled(sessionId, result.js, session.currentRevision);
229
- broadcastPlanUpdate(sessionId);
230
- }
231
-
232
- // Start watching for file changes
233
- watchSession(sessionId, sessionManager, broadcastPlanUpdate);
234
-
235
- const browserUrl = `http://localhost:${PORT}/s/${sessionId}`;
236
- return jsonResponse({
237
- ok: result.ok,
238
- browserUrl,
239
- isNew,
240
- revision: session.currentRevision,
241
- error: result.ok ? undefined : result.error,
242
- });
243
- } catch (e: any) {
244
- return jsonResponse({ error: e.message }, 500);
245
- }
246
- }
247
-
248
- async function handlePlanJs(sessionId: string, rev?: number): Promise<Response> {
249
- let compiled = sessionManager.getCompiled(sessionId, rev);
250
-
251
- // On-demand compilation for historical revisions without compiled JS
252
- if (!compiled && rev) {
253
- const session = sessionManager.get(sessionId);
254
- if (session) {
255
- const jsx = readRevisionJsx(sessionId, rev);
256
- if (jsx) {
257
- const result = await compilePlan(jsx, session.projectRoot);
258
- if (result.ok) {
259
- sessionManager.saveCompiled(sessionId, result.js, rev);
260
- compiled = result.js;
261
- }
262
- }
263
- }
264
- }
265
-
266
- if (!compiled) return jsonResponse({ error: "No compiled plan" }, 404);
267
- return new Response(compiled, {
268
- headers: {
269
- "Content-Type": "application/javascript",
270
- "Cache-Control": "no-cache",
271
- },
272
- });
273
- }
274
-
275
- function readRevisionJsx(sessionId: string, rev: number): string | null {
276
- try {
277
- const path = sessionManager.getRevisionJsxPath(sessionId, rev);
278
- return require("fs").readFileSync(path, "utf-8");
279
- } catch {
280
- return null;
281
- }
282
- }
283
-
284
- function broadcastPlanUpdate(sessionId: string) {
285
- const sockets = browserSockets.get(sessionId);
286
- if (!sockets) return;
287
- const session = sessionManager.get(sessionId);
288
- if (!session) return;
289
- const payload = JSON.stringify({
290
- type: "plan-updated",
291
- currentRevision: session.currentRevision,
292
- revisions: session.revisions,
293
- });
294
- for (const ws of sockets) {
295
- ws.send(payload);
296
- }
297
- }
298
-
299
- function broadcastRevisionUpdate(sessionId: string) {
300
- const sockets = browserSockets.get(sessionId);
301
- if (!sockets) return;
302
- const session = sessionManager.get(sessionId);
303
- if (!session) return;
304
- const payload = JSON.stringify({
305
- type: "revision-updated",
306
- revisions: session.revisions,
307
- });
308
- for (const ws of sockets) {
309
- ws.send(payload);
310
- }
311
- }
312
-
313
- async function handleFileServe(url: URL): Promise<Response> {
314
- const sessionId = url.searchParams.get("session");
315
- const relPath = url.searchParams.get("path");
316
- if (!sessionId || !relPath) return jsonResponse({ error: "Missing session or path" }, 400);
317
-
318
- const session = sessionManager.get(sessionId);
319
- if (!session) return jsonResponse({ error: "Session not found" }, 404);
320
-
321
- const fullPath = resolve(join(session.projectRoot, relPath));
322
- if (!fullPath.startsWith(resolve(session.projectRoot))) {
323
- return jsonResponse({ error: "Path traversal rejected" }, 403);
324
- }
325
-
326
- try {
327
- const file = Bun.file(fullPath);
328
- const stat = await file.exists();
329
- if (!stat) return jsonResponse({ error: "File not found" }, 404);
330
- if (file.size > 1024 * 1024) return jsonResponse({ error: "File too large" }, 413);
331
-
332
- const content = await file.text();
333
- const ext = relPath.split(".").pop() || "";
334
- return jsonResponse({ content, language: LANG_MAP[ext] || "text" });
335
- } catch {
336
- return jsonResponse({ error: "Failed to read file" }, 500);
337
- }
338
- }
339
-
340
- const IMAGE_MIME: Record<string, string> = {
341
- png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
342
- gif: "image/gif", svg: "image/svg+xml", webp: "image/webp",
343
- ico: "image/x-icon", bmp: "image/bmp", avif: "image/avif",
344
- };
345
-
346
- async function handleImageServe(url: URL): Promise<Response> {
347
- const sessionId = url.searchParams.get("session");
348
- const relPath = url.searchParams.get("path");
349
- if (!sessionId || !relPath) return jsonResponse({ error: "Missing session or path" }, 400);
350
-
351
- const session = sessionManager.get(sessionId);
352
- if (!session) return jsonResponse({ error: "Session not found" }, 404);
353
-
354
- const fullPath = resolve(join(session.projectRoot, relPath));
355
- if (!fullPath.startsWith(resolve(session.projectRoot))) {
356
- return jsonResponse({ error: "Path traversal rejected" }, 403);
357
- }
358
-
359
- try {
360
- const file = Bun.file(fullPath);
361
- if (!(await file.exists())) return jsonResponse({ error: "File not found" }, 404);
362
- if (file.size > 10 * 1024 * 1024) return jsonResponse({ error: "File too large" }, 413);
363
-
364
- const ext = relPath.split(".").pop()?.toLowerCase() || "";
365
- const mime = IMAGE_MIME[ext];
366
- if (!mime) return jsonResponse({ error: "Unsupported image format" }, 415);
367
-
368
- return new Response(file, {
369
- headers: {
370
- "Content-Type": mime,
371
- "Cache-Control": "public, max-age=300",
372
- },
373
- });
374
- } catch {
375
- return jsonResponse({ error: "Failed to read file" }, 500);
376
- }
377
- }
378
-
379
- async function handleTreeServe(url: URL): Promise<Response> {
380
- const sessionId = url.searchParams.get("session");
381
- const relPath = url.searchParams.get("path") || "";
382
- if (!sessionId) return jsonResponse({ error: "Missing session" }, 400);
383
-
384
- const session = sessionManager.get(sessionId);
385
- if (!session) return jsonResponse({ error: "Session not found" }, 404);
386
-
387
- const fullPath = resolve(join(session.projectRoot, relPath));
388
- if (!fullPath.startsWith(resolve(session.projectRoot))) {
389
- return jsonResponse({ error: "Path traversal rejected" }, 403);
390
- }
391
- const ignoreDirs = new Set([".git", "node_modules", ".next", "__pycache__", "dist", ".cache"]);
392
-
393
- try {
394
- const dirents = await readdir(fullPath, { withFileTypes: true });
395
- const entries = dirents
396
- .filter((d) => !ignoreDirs.has(d.name) && !d.name.startsWith("."))
397
- .map((d) => ({
398
- name: d.name,
399
- type: d.isDirectory() ? "dir" : "file",
400
- }))
401
- .sort((a, b) => {
402
- if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
403
- return a.name.localeCompare(b.name);
404
- });
405
- return jsonResponse({ entries });
406
- } catch {
407
- return jsonResponse({ error: "Failed to read directory" }, 500);
408
- }
409
- }
410
-
411
- let cachedDistDir: string | null = null;
412
- async function getDistDir(): Promise<string> {
413
- if (cachedDistDir) return cachedDistDir;
414
- const localDist = join(dirname(import.meta.dir), "dist");
415
- if (await Bun.file(join(localDist, "index.html")).exists()) {
416
- cachedDistDir = localDist;
417
- } else {
418
- cachedDistDir = join(DATA_DIR, "daemon");
419
- }
420
- return cachedDistDir;
421
- }
422
-
423
- async function serveSessionPage(_sessionId: string): Promise<Response> {
424
- const distDir = await getDistDir();
425
- const file = Bun.file(join(distDir, "index.html"));
426
- if (await file.exists()) {
427
- return new Response(file, { headers: { "Content-Type": "text/html" } });
428
- }
429
- return new Response("Build not found. Run: bun run daemon/build.ts", { status: 500 });
430
- }
431
-
432
- async function serveStaticAsset(path: string): Promise<Response> {
433
- const distDir = await getDistDir();
434
- const assetPath = join(distDir, path.replace("/assets/", ""));
435
- const file = Bun.file(assetPath);
436
- if (!(await file.exists())) return new Response("Not found", { status: 404 });
437
-
438
- const ext = path.split(".").pop() || "";
439
- const mimeTypes: Record<string, string> = {
440
- js: "application/javascript",
441
- css: "text/css",
442
- html: "text/html",
443
- svg: "image/svg+xml",
444
- png: "image/png",
445
- };
446
- return new Response(file, {
447
- headers: { "Content-Type": mimeTypes[ext] || "application/octet-stream" },
448
- });
449
- }
450
-
451
- // Export for file watcher to use
452
- export { broadcastPlanUpdate, sessionManager, server };
77
+ export { sessionManager, server };
78
+ export const broadcastPlanUpdate = wsManager.broadcastPlanUpdate;
453
79
 
454
- // Periodically clean up stale sessions (every hour)
455
80
  setInterval(() => sessionManager.cleanupStale(), 60 * 60 * 1000);
456
81
 
457
82
  console.log(`Planner daemon listening on http://localhost:${PORT}`);
@@ -8,6 +8,7 @@ export interface RevisionInfo {
8
8
  sourceFile?: string;
9
9
  createdAt: string;
10
10
  hasFeedback: boolean;
11
+ feedbackConsumed: boolean;
11
12
  }
12
13
 
13
14
  export interface SessionData {
@@ -119,7 +120,7 @@ export class SessionManager {
119
120
  const revDir = this.revisionDir(id, num);
120
121
  mkdirSync(revDir, { recursive: true });
121
122
  renameSync(join(historyDir, file), join(revDir, "plan.jsx"));
122
- revisions.push({ revision: num, createdAt: legacy.createdAt, hasFeedback: false });
123
+ revisions.push({ revision: num, createdAt: legacy.createdAt, hasFeedback: false, feedbackConsumed: false });
123
124
  }
124
125
  rmSync(historyDir, { recursive: true, force: true });
125
126
  }
@@ -134,7 +135,7 @@ export class SessionManager {
134
135
  if (existsSync(flatCompiled)) {
135
136
  renameSync(flatCompiled, join(revDir, "plan.compiled.js"));
136
137
  }
137
- revisions.push({ revision: currentRev, createdAt: legacy.updatedAt, hasFeedback: false });
138
+ revisions.push({ revision: currentRev, createdAt: legacy.updatedAt, hasFeedback: false, feedbackConsumed: false });
138
139
 
139
140
  // Write new meta
140
141
  const meta: SessionMeta = {
@@ -160,7 +161,7 @@ export class SessionManager {
160
161
  const now = new Date().toISOString();
161
162
  const revision = existing ? existing.currentRevision + 1 : 1;
162
163
 
163
- const revInfo: RevisionInfo = { revision, createdAt: now, hasFeedback: false, ...(label ? { label } : {}), ...(sourceFile ? { sourceFile } : {}) };
164
+ const revInfo: RevisionInfo = { revision, createdAt: now, hasFeedback: false, feedbackConsumed: false, ...(label ? { label } : {}), ...(sourceFile ? { sourceFile } : {}) };
164
165
  const revisions = existing ? [...existing.revisions, revInfo] : [revInfo];
165
166
 
166
167
  const session: SessionData = {
@@ -178,15 +179,7 @@ export class SessionManager {
178
179
  return session;
179
180
  }
180
181
 
181
- private persistToDisk(session: SessionData) {
182
- const dir = this.sessionDir(session.id);
183
- const revDir = this.revisionDir(session.id, session.currentRevision);
184
- mkdirSync(revDir, { recursive: true });
185
-
186
- // Write revision JSX
187
- writeFileSync(join(revDir, "plan.jsx"), session.jsx);
188
-
189
- // Write meta
182
+ private persistMeta(session: SessionData) {
190
183
  const meta: SessionMeta = {
191
184
  projectRoot: session.projectRoot,
192
185
  createdAt: session.createdAt,
@@ -194,7 +187,14 @@ export class SessionManager {
194
187
  currentRevision: session.currentRevision,
195
188
  revisions: session.revisions,
196
189
  };
197
- writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
190
+ writeFileSync(join(this.sessionDir(session.id), "meta.json"), JSON.stringify(meta, null, 2));
191
+ }
192
+
193
+ private persistToDisk(session: SessionData) {
194
+ const revDir = this.revisionDir(session.id, session.currentRevision);
195
+ mkdirSync(revDir, { recursive: true });
196
+ writeFileSync(join(revDir, "plan.jsx"), session.jsx);
197
+ this.persistMeta(session);
198
198
  }
199
199
 
200
200
  get(id: string): SessionData | undefined {
@@ -235,19 +235,14 @@ export class SessionManager {
235
235
  mkdirSync(revDir, { recursive: true });
236
236
  writeFileSync(join(revDir, "feedback.md"), markdown);
237
237
 
238
- // Update in-memory and on-disk meta
239
238
  const session = this.sessions.get(id);
240
239
  if (session) {
241
240
  const ri = session.revisions.find((r) => r.revision === rev);
242
- if (ri) ri.hasFeedback = true;
243
- const meta: SessionMeta = {
244
- projectRoot: session.projectRoot,
245
- createdAt: session.createdAt,
246
- updatedAt: session.updatedAt,
247
- currentRevision: session.currentRevision,
248
- revisions: session.revisions,
249
- };
250
- writeFileSync(join(this.sessionDir(id), "meta.json"), JSON.stringify(meta, null, 2));
241
+ if (ri) {
242
+ ri.hasFeedback = true;
243
+ ri.feedbackConsumed = false;
244
+ }
245
+ this.persistMeta(session);
251
246
  }
252
247
  }
253
248
 
@@ -259,6 +254,29 @@ export class SessionManager {
259
254
  }
260
255
  }
261
256
 
257
+ consumeFeedback(id: string, rev: number) {
258
+ const session = this.sessions.get(id);
259
+ if (!session) return;
260
+ const ri = session.revisions.find((r) => r.revision === rev);
261
+ if (!ri || !ri.hasFeedback || ri.feedbackConsumed) return;
262
+ ri.feedbackConsumed = true;
263
+ this.persistMeta(session);
264
+ }
265
+
266
+ getLatestUnconsumedFeedback(id: string): { revision: number; feedback: string } | null {
267
+ const session = this.sessions.get(id);
268
+ if (!session) return null;
269
+ // Search from newest to oldest
270
+ for (let i = session.revisions.length - 1; i >= 0; i--) {
271
+ const ri = session.revisions[i];
272
+ if (ri.hasFeedback && !ri.feedbackConsumed) {
273
+ const feedback = this.getFeedback(id, ri.revision);
274
+ if (feedback) return { revision: ri.revision, feedback };
275
+ }
276
+ }
277
+ return null;
278
+ }
279
+
262
280
  getRevisionJsxPath(id: string, rev: number): string {
263
281
  return join(this.revisionDir(id, rev), "plan.jsx");
264
282
  }
@@ -0,0 +1,83 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import type { SessionManager } from "./session";
3
+
4
+ export type WSData = { type: "browser" | "wait"; sessionId: string };
5
+
6
+ export function createWebSocketManager(sessionManager: SessionManager) {
7
+ const browserSockets = new Map<string, Set<ServerWebSocket<WSData>>>();
8
+ const waitSockets = new Map<string, Set<ServerWebSocket<WSData>>>();
9
+
10
+ function broadcastPlanUpdate(sessionId: string) {
11
+ const sockets = browserSockets.get(sessionId);
12
+ if (!sockets) return;
13
+ const session = sessionManager.get(sessionId);
14
+ if (!session) return;
15
+ const payload = JSON.stringify({
16
+ type: "plan-updated",
17
+ currentRevision: session.currentRevision,
18
+ revisions: session.revisions,
19
+ });
20
+ for (const ws of sockets) {
21
+ ws.send(payload);
22
+ }
23
+ }
24
+
25
+ function broadcastRevisionUpdate(sessionId: string) {
26
+ const sockets = browserSockets.get(sessionId);
27
+ if (!sockets) return;
28
+ const session = sessionManager.get(sessionId);
29
+ if (!session) return;
30
+ const payload = JSON.stringify({
31
+ type: "revision-updated",
32
+ revisions: session.revisions,
33
+ });
34
+ for (const ws of sockets) {
35
+ ws.send(payload);
36
+ }
37
+ }
38
+
39
+ const handlers = {
40
+ open(ws: ServerWebSocket<WSData>) {
41
+ const { type, sessionId } = ws.data;
42
+ const map = type === "browser" ? browserSockets : waitSockets;
43
+ if (!map.has(sessionId)) map.set(sessionId, new Set());
44
+ map.get(sessionId)!.add(ws);
45
+ },
46
+ message(ws: ServerWebSocket<WSData>, message: string | Buffer) {
47
+ const { type, sessionId } = ws.data;
48
+ if (type === "browser") {
49
+ try {
50
+ const data = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
51
+ if (data.type === "submit") {
52
+ const feedback = data.feedback as string;
53
+
54
+ const session = sessionManager.get(sessionId);
55
+ if (session) {
56
+ sessionManager.saveFeedback(sessionId, session.currentRevision, feedback);
57
+ broadcastRevisionUpdate(sessionId);
58
+
59
+ const waiters = waitSockets.get(sessionId);
60
+ if (waiters && waiters.size > 0) {
61
+ sessionManager.consumeFeedback(sessionId, session.currentRevision);
62
+ const payload = JSON.stringify({ type: "submit", feedback });
63
+ for (const waiter of waiters) {
64
+ waiter.send(payload);
65
+ waiter.close();
66
+ }
67
+ waitSockets.delete(sessionId);
68
+ }
69
+ }
70
+ }
71
+ } catch {}
72
+ }
73
+ },
74
+ close(ws: ServerWebSocket<WSData>) {
75
+ const { type, sessionId } = ws.data;
76
+ const map = type === "browser" ? browserSockets : waitSockets;
77
+ map.get(sessionId)?.delete(ws);
78
+ if (map.get(sessionId)?.size === 0) map.delete(sessionId);
79
+ },
80
+ };
81
+
82
+ return { handlers, broadcastPlanUpdate, broadcastRevisionUpdate };
83
+ }