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