wicked-interactive 0.4.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.
@@ -0,0 +1,580 @@
1
+ // server.js — the long-running local service (ADR-0005): serve versions, accept feedback
2
+ // as the single writer, watch for _v{n}.md, regenerate, and push updates over SSE (ADR-0006).
3
+ // wicked-bus is the event spine (ADR-0004); SSE is the user-facing "ready" signal.
4
+
5
+ import express from "express";
6
+ import chokidar from "chokidar";
7
+ import { basename, dirname, resolve, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { existsSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, readdirSync, statSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { busEmit, EVENTS } from "./bus.js";
12
+ import { initWorkspace, writeFeedback, processFeedbackFile, forkVersion, loadManifest, readVersionHtml } from "./workspace.js";
13
+ import { applyStructuralResponse, REQUESTS_DIR } from "./structural.js";
14
+ import { writeGenerationRequest, applyGeneratedDraft, generationPlaceholder, GEN_RESPONSE } from "./generation.js";
15
+ import { demoPlaceholder, writeDemoRequest, recordDemo, exportGif, RECORDINGS_DIR } from "./demo.js";
16
+ import { exportHtml, exportPdf } from "./export.js";
17
+ import { preflight } from "./preflight.js";
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+
21
+ /**
22
+ * @param {object} opts
23
+ * @param {string} opts.dir document workspace directory (already initialised)
24
+ * @param {string} [opts.documentId]
25
+ * @param {Function} [opts.llm] structural-change LLM (increment 4)
26
+ * @param {boolean} [opts.watch] enable chokidar processing (default true)
27
+ */
28
+ export function createServer({ dir, documentId = "doc", watch = true, frontendDir, tap } = {}) {
29
+ const app = express();
30
+ app.use(express.json({ limit: "5mb" }));
31
+ const sseClients = new Set();
32
+
33
+ // `tap` is the cross-server hook used by createMultiServer to fan all per-doc events
34
+ // into a single top-level /api/events/all stream. It's a fire-and-forget callback —
35
+ // exceptions are swallowed so a dead tap can't break the doc's own SSE clients.
36
+ function broadcast(event, data) {
37
+ const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
38
+ for (const res of sseClients) res.write(frame);
39
+ if (tap) { try { tap(event, data); } catch { /* never let the tap break broadcasts */ } }
40
+ }
41
+
42
+ // Pipeline emitter: fan out to the bus and (for html updates) to connected browsers.
43
+ function emit(key, payload) {
44
+ busEmit(EVENTS[key], payload);
45
+ if (key === "HTML_UPDATED") broadcast("html-updated", payload);
46
+ }
47
+
48
+ // FIFO serialization (ADR-0007): process watcher events one at a time so concurrent
49
+ // regenerations never race on the manifest. Exposed on the returned object so callers
50
+ // (and tests) can drive processing through the same queue deterministically.
51
+ let queue = Promise.resolve();
52
+ const enqueue = (task) => { queue = queue.then(task).catch(() => {}); return queue; };
53
+
54
+ // Plugin install-gate (ADR-0016): report which sibling plugins are present so the
55
+ // editor can block on missing ones. Cheap (existsSync only); safe to call on every load.
56
+ app.get("/api/preflight", (_req, res) => res.json(preflight()));
57
+
58
+ app.get("/api/versions", (_req, res) => {
59
+ try { res.json(loadManifest(dir)); } catch (e) { res.status(404).json({ error: e.message }); }
60
+ });
61
+
62
+ function sendVersion(res, v) {
63
+ try { res.type("html").send(readVersionHtml(dir, v)); }
64
+ catch { res.status(404).send(`version ${v} not found`); }
65
+ }
66
+ app.get("/doc", (_req, res) => {
67
+ try { sendVersion(res, loadManifest(dir).head); } catch (e) { res.status(404).send(e.message); }
68
+ });
69
+ app.get("/doc/:version", (req, res) => sendVersion(res, Number(req.params.version)));
70
+
71
+ // Single-writer feedback intake (ADR-0002). chokidar processes the written file.
72
+ app.post("/api/feedback", (req, res) => {
73
+ const { items, author } = req.body || {};
74
+ if (!Array.isArray(items) || items.length === 0) {
75
+ return res.status(400).json({ error: "items[] required" });
76
+ }
77
+ try {
78
+ const { version, file } = writeFeedback(dir, { items, author });
79
+ busEmit(EVENTS.FEEDBACK_RECEIVED, {
80
+ document_id: documentId, version_target: version, feedback_file: file,
81
+ item_count: items.length, ts: new Date().toISOString(),
82
+ });
83
+ res.json({ version, file });
84
+ } catch (e) {
85
+ res.status(400).json({ error: e.message });
86
+ }
87
+ });
88
+
89
+ // Fork / "start again from here" (ADR-0008, AC-21): non-destructive.
90
+ app.post("/api/fork", (req, res) => {
91
+ const from = Number(req.body?.from);
92
+ if (!Number.isInteger(from)) return res.status(400).json({ error: "from (version number) required" });
93
+ try {
94
+ const { version, parent } = forkVersion(dir, from);
95
+ emit("HTML_UPDATED", {
96
+ document_id: documentId, version, html_file: `_v${version}.html`, prev_version: parent, ts: new Date().toISOString(),
97
+ });
98
+ res.json({ version, parent });
99
+ } catch (e) {
100
+ res.status(400).json({ error: e.message });
101
+ }
102
+ });
103
+
104
+ // Export to self-contained HTML or PDF (ADR-0009), triggered from the browser.
105
+ // POST creates the file under <workspace>/exports/; response includes a `download`
106
+ // URL the frontend can hit to actually pull the bytes (the server-side `path` alone
107
+ // never reached the user's machine — see the 2026-05-28 "PDF/HTML might generate,
108
+ // but not downloading" report).
109
+ app.post("/api/export", (req, res) => {
110
+ const version = Number(req.body?.version);
111
+ const format = String(req.body?.format || "html").toLowerCase();
112
+ if (!Number.isInteger(version)) return res.status(400).json({ error: "version (number) required" });
113
+ if (format !== "html" && format !== "pdf") return res.status(400).json({ error: "format must be html or pdf" });
114
+ try {
115
+ const result = format === "pdf" ? exportPdf(dir, version) : exportHtml(dir, version);
116
+ const file = basename(result.path);
117
+ const download = `${req.baseUrl || ""}/api/export/file/${encodeURIComponent(file)}`;
118
+ busEmit(EVENTS.EXPORT_REQUESTED, { document_id: documentId, version, format, ts: new Date().toISOString() });
119
+ res.json({ format, ...result, file, download });
120
+ } catch (e) {
121
+ res.status(400).json({ error: e.message });
122
+ }
123
+ });
124
+
125
+ // Download the actual exported file. Content-Disposition: attachment forces a save
126
+ // dialog regardless of the file's MIME type. Filenames are restricted to the slug
127
+ // characters export.js uses, so this can't path-traverse.
128
+ app.get("/api/export/file/:name", (req, res) => {
129
+ const name = req.params.name;
130
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) return res.status(400).send("invalid name");
131
+ const filePath = join(dir, "exports", name);
132
+ if (!existsSync(filePath)) return res.status(404).send("not found");
133
+ const isPdf = name.toLowerCase().endsWith(".pdf");
134
+ res.setHeader("Content-Type", isPdf ? "application/pdf" : "text/html; charset=utf-8");
135
+ res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
136
+ res.sendFile(filePath);
137
+ });
138
+
139
+ // --- Demo (ADR-0018): the agent authors demo.spec.mjs (the click-path); this model-free
140
+ // service EXECUTES + RECORDS it with Playwright and lands the storyboard as a version.
141
+ // Deterministic replay — the same spec yields the same recording. Enqueued on the FIFO
142
+ // so a record never races a feedback regeneration on the manifest.
143
+ app.post("/api/demo/record", (req, res) => {
144
+ const headless = req.body?.headless !== false;
145
+ enqueue(async () => {
146
+ broadcast("status", { state: "working", message: "Recording the demo with Playwright…", ts: new Date().toISOString() });
147
+ try {
148
+ const result = await recordDemo(dir, {
149
+ emit, documentId, headless,
150
+ onStep: ({ index, total, label }) => broadcast("status", {
151
+ state: "working", message: `Step ${index}${total ? `/${total}` : ""}: ${label}`, ts: new Date().toISOString(),
152
+ }),
153
+ });
154
+ broadcast("status", { state: "complete", message: `Recorded v${result.version} (${result.steps.length} steps).`, version: result.version, ts: new Date().toISOString() });
155
+ } catch (e) {
156
+ broadcast("error", { file: "demo.spec.mjs", error: e.message });
157
+ broadcast("status", { state: "error", message: `Demo recording failed: ${e.message}`, ts: new Date().toISOString() });
158
+ }
159
+ });
160
+ res.json({ ok: true, recording: true });
161
+ });
162
+
163
+ // Convert a recorded version's webm -> animated GIF (embeddable where video isn't, e.g. a
164
+ // GitHub README). Lazy + cached; ffmpeg is an optional system dep, so a missing binary comes
165
+ // back as a 400 with an install hint rather than a crash. Synchronous like /api/export.
166
+ app.post("/api/demo/gif", (req, res) => {
167
+ const version = Number(req.body?.version);
168
+ if (!Number.isInteger(version)) return res.status(400).json({ error: "version (number) required" });
169
+ try {
170
+ const { path, bytes, cached } = exportGif(dir, version);
171
+ const name = basename(path);
172
+ const download = `${req.baseUrl || ""}/api/demo/recording/${encodeURIComponent(name)}`;
173
+ res.json({ ok: true, file: name, bytes, cached, download });
174
+ } catch (e) {
175
+ res.status(400).json({ error: e.message });
176
+ }
177
+ });
178
+
179
+ // Stream a recorded demo video / thumbnail / GIF. Path-locked to the slug charset (same
180
+ // guard as the export download) so it can't path-traverse out of recordings/.
181
+ app.get("/api/demo/recording/:name", (req, res) => {
182
+ const name = req.params.name;
183
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) return res.status(400).send("invalid name");
184
+ const filePath = join(dir, RECORDINGS_DIR, name);
185
+ if (!existsSync(filePath)) return res.status(404).send("not found");
186
+ const lower = name.toLowerCase();
187
+ const type = lower.endsWith(".webm") ? "video/webm"
188
+ : lower.endsWith(".gif") ? "image/gif"
189
+ : lower.endsWith(".png") ? "image/png"
190
+ : lower.endsWith(".jpg") || lower.endsWith(".jpeg") ? "image/jpeg"
191
+ : "application/octet-stream";
192
+ res.setHeader("Content-Type", type);
193
+ res.sendFile(filePath);
194
+ });
195
+
196
+ // Conversation log (ADR-0014): append-only transcript persisted across reloads.
197
+ const convoFile = () => resolve(dir, "conversation.jsonl");
198
+ function logConvo(entry) {
199
+ try { appendFileSync(convoFile(), JSON.stringify({ ...entry, ts: entry.ts || new Date().toISOString() }) + "\n"); }
200
+ catch { /* best-effort */ }
201
+ }
202
+
203
+ // Agent status channel (ADR-0012): the supervising agent posts progress / questions.
204
+ app.post("/api/status", (req, res) => {
205
+ const { state, message, version, requestId, question, options } = req.body || {};
206
+ broadcast("status", { state, message, version, requestId, question, options, ts: new Date().toISOString() });
207
+ if (message || question) logConvo({ role: "agent", text: question || message, state });
208
+ res.json({ ok: true });
209
+ });
210
+
211
+ // User -> agent message (ADR-0014), and agent -> user when the supervising agent
212
+ // replies through this lane. Default to "user"; honor an explicit role so an agent
213
+ // post lands as "agent" instead of masquerading as the user. Whitelisted to keep
214
+ // the value safe as a CSS class suffix (wi-msg--{role}) on the frontend.
215
+ const MSG_ROLES = new Set(["user", "agent", "assistant"]);
216
+ app.post("/api/message", (req, res) => {
217
+ const text = (req.body?.text || "").toString().trim();
218
+ if (!text) return res.status(400).json({ error: "text required" });
219
+ const reqRole = (req.body?.role || "").toString();
220
+ const role = MSG_ROLES.has(reqRole) ? reqRole : "user";
221
+ const entry = { role, text, ts: new Date().toISOString() };
222
+ logConvo(entry);
223
+ broadcast("message", entry);
224
+ res.json({ ok: true });
225
+ });
226
+
227
+ app.get("/api/conversation", (_req, res) => {
228
+ try {
229
+ const lines = existsSync(convoFile()) ? readFileSync(convoFile(), "utf-8").trim() : "";
230
+ res.json(lines ? lines.split("\n").map((l) => JSON.parse(l)) : []);
231
+ } catch (e) { res.status(500).json({ error: e.message }); }
232
+ });
233
+
234
+ // User's answer to an agent question -> written as a file the agent reads, + broadcast.
235
+ app.post("/api/answer", (req, res) => {
236
+ const { requestId, answer } = req.body || {};
237
+ if (!requestId) return res.status(400).json({ error: "requestId required" });
238
+ mkdirSync(resolve(dir, REQUESTS_DIR), { recursive: true });
239
+ const file = `${requestId}.answer.json`;
240
+ writeFileSync(resolve(dir, REQUESTS_DIR, file), JSON.stringify({ requestId, answer, ts: new Date().toISOString() }, null, 2));
241
+ broadcast("answer", { requestId, answer });
242
+ res.json({ ok: true, file });
243
+ });
244
+
245
+ // --- Sources (ADR-0017): reference material the supervising agent indexes into the
246
+ // brain and draws on when generating/updating. No uploads — the service is local, so
247
+ // the agent reads these real absolute paths directly. The browser only picks paths.
248
+ const sourcesFile = () => resolve(dir, REQUESTS_DIR, "sources.json");
249
+ function readSources() {
250
+ try {
251
+ const f = sourcesFile();
252
+ if (!existsSync(f)) return [];
253
+ const parsed = JSON.parse(readFileSync(f, "utf-8"));
254
+ return Array.isArray(parsed?.sources) ? parsed.sources : [];
255
+ } catch { return []; }
256
+ }
257
+ function writeSources(sources) {
258
+ mkdirSync(resolve(dir, REQUESTS_DIR), { recursive: true });
259
+ writeFileSync(sourcesFile(), JSON.stringify({ sources }, null, 2));
260
+ }
261
+
262
+ app.get("/api/sources", (_req, res) => res.json({ sources: readSources() }));
263
+
264
+ // User attaches one or more local paths. Dedupe by absolute path; new paths start
265
+ // status "pending" until the agent indexes them. Broadcast so the agent's tail sees it.
266
+ app.post("/api/sources", (req, res) => {
267
+ const incoming = Array.isArray(req.body?.paths) ? req.body.paths : [];
268
+ const note = (req.body?.note || "").toString().trim();
269
+ const paths = incoming.map((p) => (p || "").toString().trim()).filter(Boolean).map((p) => resolve(p));
270
+ if (paths.length === 0) return res.status(400).json({ error: "paths required" });
271
+ const sources = readSources();
272
+ const known = new Set(sources.map((s) => s.path));
273
+ const added = [];
274
+ for (const p of paths) {
275
+ if (known.has(p)) continue;
276
+ known.add(p);
277
+ const entry = { path: p, note, status: "pending", added_at: new Date().toISOString(), indexed_at: null };
278
+ sources.push(entry);
279
+ added.push(entry);
280
+ }
281
+ writeSources(sources);
282
+ if (added.length) {
283
+ broadcast("sources", { sources, added });
284
+ logConvo({ role: "event", text: `Sources attached: ${added.map((s) => basename(s.path)).join(", ")}${note ? ` — ${note}` : ""}` });
285
+ }
286
+ res.json({ ok: true, sources, added });
287
+ });
288
+
289
+ // Agent marks a source's index status (pending -> indexing -> indexed | error).
290
+ const SOURCE_STATES = new Set(["pending", "indexing", "indexed", "error"]);
291
+ app.post("/api/sources/status", (req, res) => {
292
+ const path = (req.body?.path || "").toString().trim();
293
+ const status = (req.body?.status || "").toString();
294
+ if (!path) return res.status(400).json({ error: "path required" });
295
+ if (!SOURCE_STATES.has(status)) return res.status(400).json({ error: "invalid status" });
296
+ const target = resolve(path);
297
+ const sources = readSources();
298
+ const entry = sources.find((s) => s.path === target);
299
+ if (!entry) return res.status(404).json({ error: "unknown source" });
300
+ entry.status = status;
301
+ if (status === "indexed") entry.indexed_at = new Date().toISOString();
302
+ writeSources(sources);
303
+ broadcast("sources", { sources });
304
+ res.json({ ok: true, sources });
305
+ });
306
+
307
+ // Local filesystem browser for the path picker. The page can't read real disk paths
308
+ // from <input type=file>, so the user navigates the local tree here and we hand back
309
+ // absolute paths. Localhost-only; dotfiles hidden; never exposes file contents.
310
+ function isLocalRequest(req) {
311
+ const ip = (req.ip || req.socket?.remoteAddress || "").replace(/^::ffff:/, "");
312
+ return ip === "127.0.0.1" || ip === "::1" || ip === "localhost" || ip === "";
313
+ }
314
+ app.get("/api/fs", (req, res) => {
315
+ if (!isLocalRequest(req)) return res.status(403).json({ error: "local only" });
316
+ const home = homedir();
317
+ const target = resolve((req.query?.path || "").toString().trim() || home);
318
+ try {
319
+ const st = statSync(target);
320
+ if (!st.isDirectory()) return res.status(400).json({ error: "not a directory" });
321
+ const entries = readdirSync(target, { withFileTypes: true })
322
+ .filter((d) => !d.name.startsWith("."))
323
+ .map((d) => {
324
+ let dir = d.isDirectory();
325
+ if (d.isSymbolicLink()) { try { dir = statSync(join(target, d.name)).isDirectory(); } catch { dir = false; } }
326
+ return { name: d.name, path: join(target, d.name), dir };
327
+ })
328
+ .sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1));
329
+ const parent = dirname(target);
330
+ res.json({ path: target, parent: parent === target ? null : parent, home, entries });
331
+ } catch (e) {
332
+ const code = e.code === "EACCES" ? 403 : e.code === "ENOENT" ? 404 : 500;
333
+ res.status(code).json({ error: e.message });
334
+ }
335
+ });
336
+
337
+ app.get("/events", (req, res) => {
338
+ res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
339
+ res.flushHeaders?.();
340
+ res.write("event: ready\ndata: {}\n\n");
341
+ sseClients.add(res);
342
+ req.on("close", () => sseClients.delete(res));
343
+ });
344
+
345
+ // Serve the built React app at / (production). In dev, Vite serves it and proxies the
346
+ // API/doc/events back here. Mounted after the API routes so they take precedence.
347
+ const staticDir = frontendDir || resolve(HERE, "../../frontend/dist");
348
+ if (existsSync(staticDir)) app.use(express.static(staticDir));
349
+
350
+ let watcher;
351
+ const root = resolve(dir);
352
+ const FEEDBACK_FILE = /^_v\d+\.md$/; // feedback batch, in the workspace root
353
+ const RESPONSE_FILE = /^_v\d+\.response\.json$/; // agent's structural reply, in requests/
354
+ const GEN_RESPONSE_FILE = new RegExp(`^${GEN_RESPONSE.replace(/\./g, "\\.")}$`); // first-draft reply
355
+ function startWatching() {
356
+ mkdirSync(resolve(dir, REQUESTS_DIR), { recursive: true }); // so depth:1 sees responses
357
+ // chokidar v4 dropped globs — watch the tree (depth 1) and route by path here.
358
+ watcher = chokidar.watch(dir, { ignoreInitial: true, depth: 1 });
359
+ watcher.on("add", (p) => enqueue(async () => {
360
+ const name = basename(p);
361
+ try {
362
+ if (FEEDBACK_FILE.test(name) && resolve(dirname(p)) === root) {
363
+ const result = await processFeedbackFile(dir, name, { emit, documentId });
364
+ if (!result.idempotent) {
365
+ broadcast("processed", {
366
+ version: result.version, applied: result.applied,
367
+ rejected: result.rejected, stale: result.stale,
368
+ awaiting_structural: result.awaiting_structural,
369
+ });
370
+ }
371
+ } else if (RESPONSE_FILE.test(name)) {
372
+ const result = await applyStructuralResponse(dir, name, { emit, documentId });
373
+ broadcast("processed", {
374
+ version: result.version, parent: result.parent,
375
+ applied: result.applied, rejected: result.rejected, structural: true,
376
+ });
377
+ } else if (GEN_RESPONSE_FILE.test(name)) {
378
+ const result = await applyGeneratedDraft(dir, name, { emit, documentId });
379
+ broadcast("processed", {
380
+ version: result.version, parent: result.parent, generated: true,
381
+ });
382
+ }
383
+ } catch (e) {
384
+ broadcast("error", { file: name, error: e.message });
385
+ }
386
+ }));
387
+ return new Promise((res) => watcher.on("ready", res));
388
+ }
389
+
390
+ let server;
391
+ async function start(port = 0) {
392
+ if (watch) await startWatching();
393
+ return new Promise((resolve) => {
394
+ server = app.listen(port, () => resolve(server.address().port));
395
+ });
396
+ }
397
+ async function stop() {
398
+ if (watcher) await watcher.close();
399
+ if (server) await new Promise((r) => server.close(r));
400
+ for (const res of sseClients) res.end();
401
+ sseClients.clear();
402
+ }
403
+
404
+ return { app, start, stop, startWatching, enqueue, emit, broadcast, get clients() { return sseClients.size; } };
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Multi-document mode (ADR-0015): one express server hosting many workspaces
409
+ // under a docs root. Each doc gets its own createServer sub-app mounted at
410
+ // /d/:doc/. Top-level adds GET /api/docs + POST /api/docs.
411
+ // ---------------------------------------------------------------------------
412
+
413
+ const DOC_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/; // slug-safe, no path separators
414
+
415
+ function slugify(name) {
416
+ return String(name || "").toLowerCase().trim().replace(/[^a-z0-9-]+/g, "-")
417
+ .replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 64);
418
+ }
419
+
420
+ /** Create a multi-doc server. `root` is the parent dir holding one subdir per doc. */
421
+ export function createMultiServer({ root, frontendDir, llm } = {}) {
422
+ if (!root) throw new Error("createMultiServer: root is required");
423
+ mkdirSync(root, { recursive: true });
424
+ const top = express();
425
+ top.use(express.json({ limit: "5mb" }));
426
+
427
+ const docs = new Map(); // name -> { svc, dir }
428
+ let topServer;
429
+
430
+ // Cross-doc event stream (operator-facing). Per-doc servers call `tap()` on every
431
+ // broadcast; we fan them out to anyone subscribed to /api/events/all, prepending the
432
+ // doc name so the receiver can route. Cheap (in-memory; no extra HTTP hops).
433
+ const topClients = new Set();
434
+ function topBroadcast(doc, event, data) {
435
+ const frame = `event: ${event}\ndata: ${JSON.stringify({ doc, ...data })}\n\n`;
436
+ for (const res of topClients) res.write(frame);
437
+ }
438
+ // SSE heartbeat — a comment frame every 15s so half-open sockets surface as errors
439
+ // promptly and downstream watchdogs (bin/wi-watch.mjs has STALL_MS=180s) stay green
440
+ // even if several pings are lost. SSE comments start with `:` and are ignored by EventSource.
441
+ //
442
+ // We also `setNoDelay(true)` on the socket so the kernel doesn't buffer the (small)
443
+ // comment payloads via Nagle — pre-fix, the 24-byte heartbeats sat in the socket
444
+ // buffer and the watcher's 60s watchdog tripped every 7-16 minutes.
445
+ top.get("/api/events/all", (req, res) => {
446
+ res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
447
+ res.flushHeaders?.();
448
+ res.socket?.setNoDelay(true);
449
+ res.write("event: ready\ndata: {\"watching\":\"*\"}\n\n");
450
+ topClients.add(res);
451
+ const heartbeat = setInterval(() => { try { res.write(`: ping ${Date.now()}\n\n`); } catch { /* socket dead — close handler will clear */ } }, 15_000);
452
+ const cleanup = () => { clearInterval(heartbeat); topClients.delete(res); };
453
+ req.on("close", cleanup);
454
+ res.on("close", cleanup);
455
+ });
456
+
457
+ function docDir(name) { return resolve(root, name); }
458
+ function isExistingDoc(name) {
459
+ return DOC_NAME.test(name) && existsSync(join(docDir(name), "versions.json"));
460
+ }
461
+
462
+ async function mountDoc(name) {
463
+ if (docs.has(name)) return docs.get(name);
464
+ if (!isExistingDoc(name)) throw new Error(`unknown or invalid doc: ${name}`);
465
+ const dir = docDir(name);
466
+ const svc = createServer({
467
+ dir, documentId: name, llm, watch: false, frontendDir: null,
468
+ tap: (event, data) => topBroadcast(name, event, data), // fan into /api/events/all
469
+ });
470
+ await svc.startWatching();
471
+ top.use(`/d/${name}`, svc.app);
472
+ docs.set(name, { svc, dir });
473
+ return docs.get(name);
474
+ }
475
+
476
+ function listDocs() {
477
+ const out = [];
478
+ for (const entry of (existsSync(root) ? readdirSync(root, { withFileTypes: true }) : [])) {
479
+ if (!entry.isDirectory()) continue;
480
+ const name = entry.name;
481
+ if (!DOC_NAME.test(name)) continue;
482
+ const v = join(docDir(name), "versions.json");
483
+ if (!existsSync(v)) continue;
484
+ try {
485
+ const m = JSON.parse(readFileSync(v, "utf-8"));
486
+ const last = m.versions[m.versions.length - 1] || {};
487
+ out.push({ name, kind: m.kind || "doc", head: m.head, versions: m.versions.length, updated_at: last.created_at || null });
488
+ } catch { /* skip malformed */ }
489
+ }
490
+ return out.sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""));
491
+ }
492
+
493
+ // Top-level (cross-doc) endpoints
494
+ top.get("/api/preflight", (_req, res) => res.json(preflight()));
495
+ top.get("/api/docs", (_req, res) => res.json(listDocs()));
496
+
497
+ top.post("/api/docs", async (req, res) => {
498
+ const raw = req.body?.name;
499
+ const kind = String(req.body?.kind || "").toLowerCase();
500
+ const fromSource = kind === "source";
501
+ const isDemo = kind === "demo";
502
+ const sourcePaths = (Array.isArray(req.body?.source_paths) ? req.body.source_paths : [])
503
+ .map((s) => String(s).trim()).filter(Boolean);
504
+ const brief = String(req.body?.brief ?? "").trim();
505
+ const demoUrl = String(req.body?.url ?? "").trim();
506
+ const name = DOC_NAME.test(raw) ? raw : slugify(raw);
507
+ if (!name || !DOC_NAME.test(name)) return res.status(400).json({ error: "valid name required (lowercase letters, digits, hyphens; up to 64 chars)" });
508
+ if (isExistingDoc(name)) return res.status(409).json({ error: "doc already exists", name });
509
+
510
+ // Demo (ADR-0018): point at a live URL; the service seeds a placeholder storyboard v0 and
511
+ // hands a demo request to the supervising agent, which explores the app, authors
512
+ // demo.spec.mjs, then calls POST /d/<doc>/api/demo/record to execute + record it.
513
+ if (isDemo) {
514
+ let u;
515
+ try { u = new URL(demoUrl); } catch { return res.status(400).json({ error: "a valid http(s) url is required for a demo" }); }
516
+ if (u.protocol !== "http:" && u.protocol !== "https:") return res.status(400).json({ error: "demo url must be http or https" });
517
+ try {
518
+ const dir = docDir(name);
519
+ initWorkspace(dir, demoPlaceholder(name, demoUrl, brief), { kind: "demo" });
520
+ const { svc } = await mountDoc(name);
521
+ writeDemoRequest(dir, { url: demoUrl, brief, documentId: name });
522
+ svc.broadcast("demo", { document_id: name, url: demoUrl, brief, base_html: "_v0.html", ts: new Date().toISOString() });
523
+ return res.json({ name, head: 0, kind: "demo", learning: true });
524
+ } catch (e) {
525
+ return res.status(400).json({ error: e.message });
526
+ }
527
+ }
528
+
529
+ // "From my content" (ADR-0010): the service is model-free, so it can't read files or
530
+ // generate. It seeds a placeholder v0, then hands a generation request to the supervising
531
+ // agent, which indexes the source(s) and lands the real first draft as a follow-on version.
532
+ // kind:source needs *something* to build from — either source files or a written brief.
533
+ // (Brief-only is a first-class path: the agent generates from the brief alone, no files.)
534
+ if (fromSource && sourcePaths.length === 0 && !brief) return res.status(400).json({ error: "add at least one source path or a brief" });
535
+ const html = fromSource
536
+ ? (String(req.body?.html ?? "") || generationPlaceholder(name, sourcePaths, brief))
537
+ : String(req.body?.html ?? "");
538
+ if (!fromSource && !html.trim()) return res.status(400).json({ error: "html required" });
539
+
540
+ try {
541
+ const dir = docDir(name);
542
+ initWorkspace(dir, html); // seeds _v0.html + versions.json
543
+ const { svc } = await mountDoc(name);
544
+ if (fromSource) {
545
+ writeGenerationRequest(dir, { sourcePaths, brief, documentId: name });
546
+ // Fan a generation event onto /api/events/all so the assist loop picks it up.
547
+ svc.broadcast("generation", { document_id: name, source_paths: sourcePaths, brief, base_html: "_v0.html", ts: new Date().toISOString() });
548
+ }
549
+ res.json({ name, head: 0, ...(fromSource ? { generating: true } : {}) });
550
+ } catch (e) {
551
+ // initWorkspace / mountDoc can throw on malformed HTML or a filesystem error.
552
+ // Return a 400 instead of leaking a 500 + stack to the browser.
553
+ res.status(400).json({ error: e.message });
554
+ }
555
+ });
556
+
557
+ // Mount any docs already on disk so their routes are live from the first request.
558
+ // (Synchronous-enough — chokidar 'ready' resolves quickly per dir.)
559
+ async function bootstrap() {
560
+ for (const entry of (existsSync(root) ? readdirSync(root, { withFileTypes: true }) : [])) {
561
+ if (entry.isDirectory() && isExistingDoc(entry.name)) await mountDoc(entry.name);
562
+ }
563
+ }
564
+
565
+ // Static frontend (SPA) at /, mounted LAST so /api/* and /d/* take precedence.
566
+ const staticDir = frontendDir || resolve(HERE, "../../frontend/dist");
567
+ if (existsSync(staticDir)) top.use(express.static(staticDir));
568
+
569
+ async function start(port = 0) {
570
+ await bootstrap();
571
+ return new Promise((resolve) => { topServer = top.listen(port, () => resolve(topServer.address().port)); });
572
+ }
573
+ async function stop() {
574
+ for (const { svc } of docs.values()) { try { await svc.stop(); } catch {} }
575
+ if (topServer) await new Promise((r) => topServer.close(r));
576
+ }
577
+
578
+ return { app: top, start, stop, mountDoc, listDocs, get docCount() { return docs.size; } };
579
+ }
580
+