agent-canvas 0.1.5 → 0.1.8
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.
- package/.github/workflows/ci.yml +23 -0
- package/.github/workflows/release.yml +36 -0
- package/CLAUDE.md +86 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/bin/agent-canvas.ts +26 -353
- package/bin/lib/commands/daemon.ts +51 -0
- package/bin/lib/commands/fetch.ts +20 -0
- package/bin/lib/commands/install.ts +48 -0
- package/bin/lib/commands/push.ts +67 -0
- package/bin/lib/commands/watch.ts +23 -0
- package/bin/lib/config.ts +10 -0
- package/bin/lib/daemon-lifecycle.ts +74 -0
- package/bin/lib/helpers.ts +78 -0
- package/bun.lock +176 -0
- package/daemon/build.ts +27 -61
- package/daemon/bunfig.toml +2 -0
- package/daemon/client/AnnotationProvider.tsx +2 -2
- package/daemon/client/AnnotationSidebar.tsx +199 -31
- package/daemon/client/App.tsx +39 -3
- package/daemon/client/FileBrowser.tsx +8 -7
- package/daemon/client/FileViewer.tsx +153 -16
- package/daemon/client/PlanRenderer.tsx +6 -3
- package/daemon/client/ResponsePreview.tsx +5 -116
- package/daemon/client/SessionSwitcher.tsx +21 -10
- package/daemon/client/compat/preact-all.ts +4 -0
- package/daemon/client/components/Markdown.tsx +44 -0
- package/daemon/client/components/ResponseNote.tsx +3 -8
- package/daemon/client/components/UserInput.tsx +2 -4
- package/daemon/client/components/index.ts +1 -0
- package/daemon/client/domDiff.ts +82 -2
- package/daemon/client/generateMarkdown.ts +46 -9
- package/daemon/client/highlightRange.ts +8 -6
- package/daemon/client/runtime.ts +2 -0
- package/daemon/client/styles.css +126 -0
- package/daemon/client/unifiedDiff.ts +155 -2
- package/daemon/client/utils.ts +10 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +83 -36
- package/daemon/dist/components.js +63 -5
- package/daemon/dist/index.html +5 -12
- package/daemon/dist/jsx-runtime.js +3 -0
- package/daemon/dist/preact-compat.js +1 -0
- package/daemon/langMap.ts +8 -0
- package/daemon/src/compiler.ts +28 -2
- package/daemon/src/handlers/api.ts +150 -0
- package/daemon/src/handlers/files.ts +114 -0
- package/daemon/src/handlers/static.ts +61 -0
- package/daemon/src/handlers/utils.ts +14 -0
- package/daemon/src/router.ts +16 -0
- package/daemon/src/server.ts +28 -403
- package/daemon/src/session.ts +43 -24
- package/daemon/src/websocket.ts +83 -0
- package/daemon/tsconfig.json +20 -0
- package/package.json +4 -14
- package/skills/canvas/SKILL.md +47 -22
- package/skills/canvas/components.md +31 -0
- package/skills/canvas/flows.md +2 -2
- package/tsconfig.json +14 -0
- package/daemon/dist/jsx-dev-runtime-shim.js +0 -17
- package/daemon/dist/jsx-runtime-shim.js +0 -17
- package/daemon/dist/react-dom-client-shim.js +0 -4
- package/daemon/dist/react-dom-shim.js +0 -4
- package/daemon/dist/react-shim.js +0 -4
package/daemon/src/server.ts
CHANGED
|
@@ -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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
40
|
+
const wsBrowserMatch = url.pathname.match(/^\/ws\/session\/([^/]+)$/);
|
|
128
41
|
if (wsBrowserMatch) {
|
|
129
|
-
const
|
|
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 =
|
|
48
|
+
const wsWaitMatch = url.pathname.match(/^\/ws\/wait\/([^/]+)$/);
|
|
137
49
|
if (wsWaitMatch) {
|
|
138
|
-
const
|
|
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
|
-
//
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
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
|
-
//
|
|
156
|
-
if (
|
|
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
|
-
|
|
218
|
-
|
|
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}`);
|
package/daemon/src/session.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface RevisionInfo {
|
|
|
8
8
|
sourceFile?: string;
|
|
9
9
|
createdAt: string;
|
|
10
10
|
hasFeedback: boolean;
|
|
11
|
+
feedbackConsumed: boolean;
|
|
12
|
+
response?: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export interface SessionData {
|
|
@@ -119,7 +121,7 @@ export class SessionManager {
|
|
|
119
121
|
const revDir = this.revisionDir(id, num);
|
|
120
122
|
mkdirSync(revDir, { recursive: true });
|
|
121
123
|
renameSync(join(historyDir, file), join(revDir, "plan.jsx"));
|
|
122
|
-
revisions.push({ revision: num, createdAt: legacy.createdAt, hasFeedback: false });
|
|
124
|
+
revisions.push({ revision: num, createdAt: legacy.createdAt, hasFeedback: false, feedbackConsumed: false });
|
|
123
125
|
}
|
|
124
126
|
rmSync(historyDir, { recursive: true, force: true });
|
|
125
127
|
}
|
|
@@ -134,7 +136,7 @@ export class SessionManager {
|
|
|
134
136
|
if (existsSync(flatCompiled)) {
|
|
135
137
|
renameSync(flatCompiled, join(revDir, "plan.compiled.js"));
|
|
136
138
|
}
|
|
137
|
-
revisions.push({ revision: currentRev, createdAt: legacy.updatedAt, hasFeedback: false });
|
|
139
|
+
revisions.push({ revision: currentRev, createdAt: legacy.updatedAt, hasFeedback: false, feedbackConsumed: false });
|
|
138
140
|
|
|
139
141
|
// Write new meta
|
|
140
142
|
const meta: SessionMeta = {
|
|
@@ -155,12 +157,12 @@ export class SessionManager {
|
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
upsert(id: string, jsx: string, projectRoot: string, label?: string, sourceFile?: string): SessionData {
|
|
160
|
+
upsert(id: string, jsx: string, projectRoot: string, label?: string, sourceFile?: string, response?: string): SessionData {
|
|
159
161
|
const existing = this.sessions.get(id);
|
|
160
162
|
const now = new Date().toISOString();
|
|
161
163
|
const revision = existing ? existing.currentRevision + 1 : 1;
|
|
162
164
|
|
|
163
|
-
const revInfo: RevisionInfo = { revision, createdAt: now, hasFeedback: false, ...(label ? { label } : {}), ...(sourceFile ? { sourceFile } : {}) };
|
|
165
|
+
const revInfo: RevisionInfo = { revision, createdAt: now, hasFeedback: false, feedbackConsumed: false, ...(label ? { label } : {}), ...(sourceFile ? { sourceFile } : {}), ...(response ? { response } : {}) };
|
|
164
166
|
const revisions = existing ? [...existing.revisions, revInfo] : [revInfo];
|
|
165
167
|
|
|
166
168
|
const session: SessionData = {
|
|
@@ -178,15 +180,7 @@ export class SessionManager {
|
|
|
178
180
|
return session;
|
|
179
181
|
}
|
|
180
182
|
|
|
181
|
-
private
|
|
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
|
|
183
|
+
private persistMeta(session: SessionData) {
|
|
190
184
|
const meta: SessionMeta = {
|
|
191
185
|
projectRoot: session.projectRoot,
|
|
192
186
|
createdAt: session.createdAt,
|
|
@@ -194,7 +188,14 @@ export class SessionManager {
|
|
|
194
188
|
currentRevision: session.currentRevision,
|
|
195
189
|
revisions: session.revisions,
|
|
196
190
|
};
|
|
197
|
-
writeFileSync(join(
|
|
191
|
+
writeFileSync(join(this.sessionDir(session.id), "meta.json"), JSON.stringify(meta, null, 2));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private persistToDisk(session: SessionData) {
|
|
195
|
+
const revDir = this.revisionDir(session.id, session.currentRevision);
|
|
196
|
+
mkdirSync(revDir, { recursive: true });
|
|
197
|
+
writeFileSync(join(revDir, "plan.jsx"), session.jsx);
|
|
198
|
+
this.persistMeta(session);
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
get(id: string): SessionData | undefined {
|
|
@@ -235,19 +236,14 @@ export class SessionManager {
|
|
|
235
236
|
mkdirSync(revDir, { recursive: true });
|
|
236
237
|
writeFileSync(join(revDir, "feedback.md"), markdown);
|
|
237
238
|
|
|
238
|
-
// Update in-memory and on-disk meta
|
|
239
239
|
const session = this.sessions.get(id);
|
|
240
240
|
if (session) {
|
|
241
241
|
const ri = session.revisions.find((r) => r.revision === rev);
|
|
242
|
-
if (ri)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
currentRevision: session.currentRevision,
|
|
248
|
-
revisions: session.revisions,
|
|
249
|
-
};
|
|
250
|
-
writeFileSync(join(this.sessionDir(id), "meta.json"), JSON.stringify(meta, null, 2));
|
|
242
|
+
if (ri) {
|
|
243
|
+
ri.hasFeedback = true;
|
|
244
|
+
ri.feedbackConsumed = false;
|
|
245
|
+
}
|
|
246
|
+
this.persistMeta(session);
|
|
251
247
|
}
|
|
252
248
|
}
|
|
253
249
|
|
|
@@ -259,6 +255,29 @@ export class SessionManager {
|
|
|
259
255
|
}
|
|
260
256
|
}
|
|
261
257
|
|
|
258
|
+
consumeFeedback(id: string, rev: number) {
|
|
259
|
+
const session = this.sessions.get(id);
|
|
260
|
+
if (!session) return;
|
|
261
|
+
const ri = session.revisions.find((r) => r.revision === rev);
|
|
262
|
+
if (!ri || !ri.hasFeedback || ri.feedbackConsumed) return;
|
|
263
|
+
ri.feedbackConsumed = true;
|
|
264
|
+
this.persistMeta(session);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
getLatestUnconsumedFeedback(id: string): { revision: number; feedback: string } | null {
|
|
268
|
+
const session = this.sessions.get(id);
|
|
269
|
+
if (!session) return null;
|
|
270
|
+
// Search from newest to oldest
|
|
271
|
+
for (let i = session.revisions.length - 1; i >= 0; i--) {
|
|
272
|
+
const ri = session.revisions[i];
|
|
273
|
+
if (ri.hasFeedback && !ri.feedbackConsumed) {
|
|
274
|
+
const feedback = this.getFeedback(id, ri.revision);
|
|
275
|
+
if (feedback) return { revision: ri.revision, feedback };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
262
281
|
getRevisionJsxPath(id: string, rev: number): string {
|
|
263
282
|
return join(this.revisionDir(id, rev), "plan.jsx");
|
|
264
283
|
}
|
|
@@ -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
|
+
}
|