@levistudio/redline 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/src/server.ts ADDED
@@ -0,0 +1,634 @@
1
+ import { Hono } from "hono";
2
+ import { readFile, realpath } from "fs/promises";
3
+ import path from "path";
4
+ import { renderMarkdown } from "./render";
5
+ import { renderDocDiff } from "./diff";
6
+ import { pageTemplate } from "./server-page";
7
+ import {
8
+ loadSidecar,
9
+ saveSidecar,
10
+ withSidecar,
11
+ getOrCreateActiveRound,
12
+ activeRound,
13
+ type Comment,
14
+ } from "./sidecar";
15
+
16
+ // Bundle the client JS once per server lifetime. The build is ~50-100ms — felt
17
+ // only at server startup, not on page loads (the bundle is cached in memory)
18
+ // and not when the source file is re-read. See M10 for the on-disk cache idea.
19
+ let clientBundlePromise: Promise<string> | null = null;
20
+ function getClientBundle(): Promise<string> {
21
+ if (!clientBundlePromise) {
22
+ clientBundlePromise = (async () => {
23
+ const entrypoint = path.resolve(import.meta.dir, "client/main.ts");
24
+ const result = await Bun.build({ entrypoints: [entrypoint], target: "browser", minify: false });
25
+ if (!result.success) {
26
+ const errs = result.logs.map((l) => l.message).join("\n");
27
+ throw new Error("client bundle failed to build:\n" + errs);
28
+ }
29
+ return await result.outputs[0]!.text();
30
+ })();
31
+ }
32
+ return clientBundlePromise;
33
+ }
34
+
35
+ export function createServer(
36
+ filePath: string,
37
+ opts: { context?: string; csrfToken?: string; noAgent?: boolean } = {}
38
+ ) {
39
+ const app = new Hono();
40
+ const fileName = path.basename(filePath);
41
+
42
+ // CSRF token. Issued at server start, embedded in the rendered page, passed
43
+ // to the agent subprocess via env, required as `X-Redline-Token` on every
44
+ // mutating /api request. Defends against a malicious page in another tab
45
+ // firing no-cors POSTs at the loopback server — a custom request header
46
+ // forces a CORS preflight that the server doesn't honor, so the actual POST
47
+ // never lands. The browser's same-origin policy already blocks reads cross-
48
+ // origin; the token closes the write side.
49
+ const csrfToken = opts.csrfToken ?? crypto.randomUUID();
50
+
51
+ app.use("/api/*", async (c, next) => {
52
+ const m = c.req.method;
53
+ if (m === "GET" || m === "HEAD" || m === "OPTIONS") return next();
54
+ const got = c.req.header("X-Redline-Token");
55
+ if (got !== csrfToken) {
56
+ return new Response("forbidden: missing or wrong X-Redline-Token", { status: 403 });
57
+ }
58
+ return next();
59
+ });
60
+
61
+ // Kick off the bundle build immediately so it's ready by the time the
62
+ // browser hits /. In practice the build finishes long before the browser
63
+ // requests /client.js, but await it on the route just in case.
64
+ getClientBundle().catch((err) => console.error("client bundle build error:", err));
65
+
66
+ app.get("/client.js", async (c) => {
67
+ const js = await getClientBundle();
68
+ return new Response(js, {
69
+ headers: { "Content-Type": "application/javascript; charset=utf-8" },
70
+ });
71
+ });
72
+
73
+ // Serve extracted CSS — read once and cache in memory like the JS bundle.
74
+ let cssCache: string | null = null;
75
+ app.get("/styles.css", async (c) => {
76
+ if (!cssCache) {
77
+ cssCache = await readFile(path.resolve(import.meta.dir, "client/styles.css"), "utf-8");
78
+ }
79
+ return new Response(cssCache, {
80
+ headers: { "Content-Type": "text/css; charset=utf-8" },
81
+ });
82
+ });
83
+
84
+ // On startup, ensure there is always an open round to receive comments
85
+ (async () => {
86
+ await withSidecar(filePath, (sidecar) => {
87
+ let changed = false;
88
+ const hasOpen = sidecar.rounds.some((r: any) => r.resolved_at === null);
89
+ if (!hasOpen) {
90
+ sidecar.rounds.push({
91
+ round: sidecar.rounds.length + 1,
92
+ started_at: new Date().toISOString(),
93
+ submitted_at: null,
94
+ agent_replied_at: null,
95
+ resolved_at: null,
96
+ comments: [],
97
+ });
98
+ changed = true;
99
+ }
100
+ if (opts.context && !sidecar.context) {
101
+ sidecar.context = opts.context;
102
+ changed = true;
103
+ }
104
+ // Skip the save if there's nothing to write.
105
+ if (!changed) return false as const;
106
+ });
107
+ })();
108
+
109
+ // ── SSE broadcast ────────────────────────────────────────────────────
110
+ const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
111
+ const browserClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
112
+ const enc = new TextEncoder();
113
+
114
+ // Abandonment detection: if no browser is connected for ABANDON_GRACE_MS after
115
+ // the first one ever connected, fire onAbandonCallback so the CLI can exit.
116
+ // Default 10min — DevTools-offline debugging, brief network blips, and tab
117
+ // sleeps all reconnect well within that. The previous 2min default tripped on
118
+ // routine offline-mode testing. Override with REDLINE_ABANDON_MS for tests.
119
+ const ABANDON_GRACE_MS = process.env.REDLINE_ABANDON_MS
120
+ ? parseInt(process.env.REDLINE_ABANDON_MS, 10)
121
+ : 10 * 60 * 1000;
122
+ let hadBrowser = false;
123
+ let abandonTimer: ReturnType<typeof setTimeout> | null = null;
124
+ let onAbandonCallback: (() => void) | undefined;
125
+ let onFinishedCallback: ((payload: { totalRounds: number; totalComments: number }) => void) | undefined;
126
+ let onRevisionErrorCallback: ((message: string) => void) | undefined;
127
+ let onRevisionRecoveredCallback: (() => void) | undefined;
128
+
129
+ // Revision watchdog: when /api/accept fires we start a timer. If no terminal
130
+ // event (/api/reload, /api/revision-no-changes, /api/revision-error) arrives
131
+ // within REVISION_TIMEOUT_MS, we assume the resolve flow is wedged and
132
+ // surface a `revision-stalled` event so the user can recover. The round is
133
+ // also un-resolved so clicking Revise again is meaningful.
134
+ const REVISION_TIMEOUT_MS = process.env.REDLINE_REVISION_TIMEOUT_MS
135
+ ? parseInt(process.env.REDLINE_REVISION_TIMEOUT_MS, 10)
136
+ : 3 * 60 * 1000;
137
+ let revisionWatchdog: ReturnType<typeof setTimeout> | null = null;
138
+ // Token bumped on every clear/start. The async setTimeout callback compares
139
+ // against this token before mutating the sidecar and broadcasting — a
140
+ // /api/reload that arrives between "timer fires" and "callback finishes its
141
+ // await" must NOT have its un-resolve / revision-stalled side effects land,
142
+ // because the round is already legitimately resolved by the new revision.
143
+ // Without the token, the watchdog and reload race: clearTimeout on an
144
+ // already-fired timer is a no-op, so the in-flight callback completes its
145
+ // sidecar mutation and broadcasts a spurious revision-stalled event.
146
+ let revisionWatchdogId = 0;
147
+ function clearRevisionWatchdog() {
148
+ if (revisionWatchdog) { clearTimeout(revisionWatchdog); revisionWatchdog = null; }
149
+ revisionWatchdogId += 1;
150
+ }
151
+ function startRevisionWatchdog() {
152
+ clearRevisionWatchdog();
153
+ const myId = revisionWatchdogId;
154
+ revisionWatchdog = setTimeout(async () => {
155
+ revisionWatchdog = null;
156
+ const reason = `revision did not complete within ${REVISION_TIMEOUT_MS / 1000}s`;
157
+ console.error(`[redline] ${reason} — un-resolving round and notifying browser.`);
158
+ try {
159
+ await withSidecar(filePath, (sidecar) => {
160
+ // Re-check inside the lock: another endpoint may have bumped
161
+ // revisionWatchdogId after this callback was queued (the race the
162
+ // token is here to close).
163
+ if (myId !== revisionWatchdogId) return false as const;
164
+ const lastResolved = [...sidecar.rounds].reverse().find((r) => r.resolved_at !== null);
165
+ if (!lastResolved) return false as const;
166
+ lastResolved.resolved_at = null;
167
+ });
168
+ } catch (e) {
169
+ console.error("[redline] watchdog: failed to un-resolve round:", e);
170
+ }
171
+ // Same race-check before broadcasting + telling the calling agent: if
172
+ // /api/reload landed first, this watchdog is stale and silent.
173
+ if (myId !== revisionWatchdogId) return;
174
+ broadcast("revision-stalled", { message: reason });
175
+ // Same recovery semantics as a revision crash — calling agent should see
176
+ // the session as errored if it abandons in this state.
177
+ onRevisionErrorCallback?.(reason);
178
+ }, REVISION_TIMEOUT_MS);
179
+ }
180
+
181
+ function checkBrowserPresence() {
182
+ if (browserClients.size > 0) {
183
+ hadBrowser = true;
184
+ if (abandonTimer) { clearTimeout(abandonTimer); abandonTimer = null; }
185
+ } else if (hadBrowser && !abandonTimer) {
186
+ abandonTimer = setTimeout(() => {
187
+ console.log(`\n[redline] No browser connected for ${ABANDON_GRACE_MS / 1000}s — assuming abandoned.`);
188
+ onAbandonCallback?.();
189
+ }, ABANDON_GRACE_MS);
190
+ }
191
+ }
192
+
193
+ function broadcast(event: string, data: Record<string, unknown> = {}) {
194
+ const msg = enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
195
+ for (const ctrl of sseClients) {
196
+ try { ctrl.enqueue(msg); } catch { sseClients.delete(ctrl); browserClients.delete(ctrl); }
197
+ }
198
+ }
199
+
200
+ app.get("/api/events", (c) => {
201
+ const isBrowser = new URL(c.req.url).searchParams.get("client") === "browser";
202
+ let ctrl: ReadableStreamDefaultController<Uint8Array>;
203
+ let keepaliveTimer: ReturnType<typeof setInterval>;
204
+ const stream = new ReadableStream<Uint8Array>({
205
+ start(controller) {
206
+ ctrl = controller;
207
+ sseClients.add(controller);
208
+ if (isBrowser) { browserClients.add(controller); checkBrowserPresence(); }
209
+ controller.enqueue(enc.encode(": connected\n\n"));
210
+ keepaliveTimer = setInterval(() => {
211
+ try { controller.enqueue(enc.encode(": ping\n\n")); }
212
+ catch { clearInterval(keepaliveTimer); sseClients.delete(controller); browserClients.delete(controller); checkBrowserPresence(); }
213
+ }, 8000);
214
+ },
215
+ cancel() {
216
+ clearInterval(keepaliveTimer);
217
+ sseClients.delete(ctrl);
218
+ browserClients.delete(ctrl);
219
+ checkBrowserPresence();
220
+ },
221
+ });
222
+ return new Response(stream, {
223
+ headers: {
224
+ "Content-Type": "text/event-stream",
225
+ "Cache-Control": "no-cache",
226
+ "Connection": "keep-alive",
227
+ },
228
+ });
229
+ });
230
+
231
+ app.get("/", async (c) => {
232
+ const content = await readFile(filePath, "utf-8");
233
+ const html = renderMarkdown(content);
234
+ const sidecar = await loadSidecar(filePath);
235
+ const round = activeRound(sidecar);
236
+ const latestRound = sidecar.rounds[sidecar.rounds.length - 1] ?? null;
237
+ const comments = latestRound?.comments ?? [];
238
+ const roundResolved = latestRound?.resolved_at != null;
239
+ const agentRepliedAt = latestRound?.agent_replied_at ?? null;
240
+ const roundNumber = latestRound?.round ?? 1;
241
+ const totalRounds = sidecar.rounds.length;
242
+ return c.html(pageTemplate(fileName, html, comments, roundResolved, agentRepliedAt, roundNumber, totalRounds, sidecar.context, false, csrfToken, opts.noAgent ?? false));
243
+ });
244
+
245
+ // Add a comment to the active round
246
+ app.post("/api/comment", async (c) => {
247
+ const body = await c.req.json<{
248
+ quote: string;
249
+ context_before: string;
250
+ context_after: string;
251
+ message: string;
252
+ }>();
253
+
254
+ const { comment, roundNumber } = await withSidecar(filePath, (sidecar) => {
255
+ const round = getOrCreateActiveRound(sidecar);
256
+ const comment: Comment = {
257
+ // crypto.randomUUID() — collision-free without depending on
258
+ // single-process timestamp resolution (the prior `c<Date.now()>
259
+ // <random4>` shape relied on the 4-digit suffix to dodge per-ms
260
+ // collisions, which gets statistically thin under any concurrency).
261
+ id: `c${crypto.randomUUID()}`,
262
+ quote: body.quote,
263
+ context_before: body.context_before,
264
+ context_after: body.context_after,
265
+ thread: [
266
+ { role: "human", message: body.message, at: new Date().toISOString() },
267
+ ],
268
+ resolved: false,
269
+ };
270
+ round.comments.push(comment);
271
+ return { comment, roundNumber: round.round };
272
+ });
273
+ broadcast("comment-added", { round: roundNumber, commentId: comment.id });
274
+ return c.json({ ok: true, comment });
275
+ });
276
+
277
+ // Mark a comment resolved
278
+ app.post("/api/comment/:id/resolve", async (c) => {
279
+ const id = c.req.param("id");
280
+ const out = await withSidecar(filePath, (sidecar) => {
281
+ const round = activeRound(sidecar);
282
+ if (!round) return { skip: true as const, status: 400, error: "No active round" };
283
+ const comment = round.comments.find((cm) => cm.id === id);
284
+ if (!comment) return { skip: true as const, status: 404, error: "Comment not found" };
285
+ comment.resolved = true;
286
+ const allResolved = round.comments.length > 0 && round.comments.every((cm) => cm.resolved);
287
+ return { skip: false as const, roundNumber: round.round, allResolved };
288
+ });
289
+ if (out.skip) return c.json({ ok: false, error: out.error }, out.status as 400 | 404);
290
+ broadcast("comment-resolved", { round: out.roundNumber, commentId: id, allResolved: out.allResolved });
291
+ return c.json({ ok: true, allResolved: out.allResolved });
292
+ });
293
+
294
+ // Reopen a resolved comment
295
+ app.post("/api/comment/:id/reopen", async (c) => {
296
+ const id = c.req.param("id");
297
+ const out = await withSidecar(filePath, (sidecar) => {
298
+ const latestRound = sidecar.rounds[sidecar.rounds.length - 1] ?? null;
299
+ if (!latestRound) return { skip: true as const, status: 400, error: "No round found" };
300
+ const comment = latestRound.comments.find((cm) => cm.id === id);
301
+ if (!comment) return { skip: true as const, status: 404, error: "Comment not found" };
302
+ comment.resolved = false;
303
+ const allResolved = latestRound.comments.length > 0 && latestRound.comments.every((cm) => cm.resolved);
304
+ return { skip: false as const, roundNumber: latestRound.round, allResolved, comment };
305
+ });
306
+ if (out.skip) return c.json({ ok: false, error: out.error }, out.status as 400 | 404);
307
+ broadcast("comment-resolved", { round: out.roundNumber, commentId: id, allResolved: out.allResolved });
308
+ return c.json({ ok: true, comment: out.comment });
309
+ });
310
+
311
+ // Submit for agent review — signals the agent to respond to comments
312
+ app.post("/api/submit", async (c) => {
313
+ const out = await withSidecar(filePath, (sidecar) => {
314
+ const round = activeRound(sidecar);
315
+ if (!round) return { skip: true as const, status: 400, error: "No active round" };
316
+ if (round.comments.length === 0) return { skip: true as const, status: 400, error: "No comments to submit" };
317
+ round.submitted_at = new Date().toISOString();
318
+ round.agent_replied_at = null; // clear so agent knows to respond again
319
+ return { skip: false as const, roundNumber: round.round, count: round.comments.length };
320
+ });
321
+ if (out.skip) return c.json({ ok: false, error: out.error }, out.status as 400);
322
+ broadcast("submitted", { round: out.roundNumber, comments: out.count });
323
+ return c.json({ ok: true });
324
+ });
325
+
326
+ // Accept & revise — human is done discussing; agent should now revise the document
327
+ app.post("/api/accept", async (c) => {
328
+ const out = await withSidecar(filePath, (sidecar) => {
329
+ const round = activeRound(sidecar);
330
+ if (!round) return { skip: true as const };
331
+ round.resolved_at = new Date().toISOString();
332
+ return { skip: false as const, roundNumber: round.round };
333
+ });
334
+ if (out.skip) return c.json({ ok: false, error: "No active round" }, 400);
335
+ broadcast("accepted", { round: out.roundNumber });
336
+ onRevisionRecoveredCallback?.();
337
+ startRevisionWatchdog();
338
+ return c.json({ ok: true });
339
+ });
340
+
341
+ // Finish a round with no comments — no revision needed, just close out
342
+ app.post("/api/finish", async (c) => {
343
+ const out = await withSidecar(filePath, (sidecar) => {
344
+ const round = activeRound(sidecar);
345
+ if (!round) return { skip: true as const };
346
+ round.resolved_at = new Date().toISOString();
347
+ const totalRounds = sidecar.rounds.filter((r: any) => r.resolved_at).length;
348
+ const totalComments = sidecar.rounds.reduce((n: number, r: any) => n + (r.comments?.length ?? 0), 0);
349
+ return { skip: false as const, roundNumber: round.round, totalRounds, totalComments };
350
+ });
351
+ if (out.skip) return c.json({ ok: false, error: "No active round" }, 400);
352
+ broadcast("finished", { round: out.roundNumber });
353
+ // Let the CLI handle the summary printout, result-file writing, and process exit.
354
+ setTimeout(() => onFinishedCallback?.({ totalRounds: out.totalRounds, totalComments: out.totalComments }), 500);
355
+ return c.json({ ok: true });
356
+ });
357
+
358
+ // Called by redline resolve after writing the revised document
359
+ app.post("/api/reload", (c) => {
360
+ clearRevisionWatchdog();
361
+ broadcast("reload", {});
362
+ onRevisionRecoveredCallback?.();
363
+ return c.json({ ok: true });
364
+ });
365
+
366
+ // Called by redline resolve when the model returned no changes
367
+ app.post("/api/revision-no-changes", (c) => {
368
+ clearRevisionWatchdog();
369
+ broadcast("revision-no-changes", {});
370
+ onRevisionRecoveredCallback?.();
371
+ return c.json({ ok: true });
372
+ });
373
+
374
+ // Called by redline resolve for each stdout chunk (streaming progress to browser)
375
+ app.post("/api/revision-chunk", async (c) => {
376
+ const { text, kind } = await c.req.json();
377
+ broadcast("revision-chunk", { text, kind });
378
+ return c.json({ ok: true });
379
+ });
380
+
381
+ // Called by the agent when the revision flow throws — un-resolves the latest
382
+ // round so the human can retry by clicking "Revise document" again
383
+ app.post("/api/revision-error", async (c) => {
384
+ clearRevisionWatchdog();
385
+ const { message } = await c.req.json();
386
+ await withSidecar(filePath, (sidecar) => {
387
+ const lastResolved = [...sidecar.rounds].reverse().find((r) => r.resolved_at !== null);
388
+ if (!lastResolved) return false as const;
389
+ lastResolved.resolved_at = null;
390
+ });
391
+ broadcast("revision-error", { message });
392
+ onRevisionErrorCallback?.(message);
393
+ return c.json({ ok: true });
394
+ });
395
+
396
+ // CLI signals the agent subprocess is gone for good (restart cap exhausted,
397
+ // missing claude CLI, etc). Surfaces a small persistent indicator in the
398
+ // header so the user knows replies aren't coming and can restart redline.
399
+ // No paired "agent-available" event — recovery requires a restart, so the
400
+ // indicator stays until the page reloads.
401
+ app.post("/api/agent-unavailable", async (c) => {
402
+ let reason = "Agent process unavailable.";
403
+ try {
404
+ const body = await c.req.json<{ reason?: string }>();
405
+ if (body.reason?.trim()) reason = body.reason.trim();
406
+ } catch { /* empty body is fine */ }
407
+ broadcast("agent-unavailable", { reason });
408
+ return c.json({ ok: true });
409
+ });
410
+
411
+ // Agent signals it is composing a reply (shows typing indicator in thread)
412
+ app.post("/api/comment/:id/thinking", async (c) => {
413
+ const id = c.req.param("id");
414
+ broadcast("comment-thinking", { commentId: id });
415
+ return c.json({ ok: true });
416
+ });
417
+
418
+ // Post a reply to a comment thread (human or agent)
419
+ app.post("/api/comment/:id/reply", async (c) => {
420
+ const id = c.req.param("id");
421
+ const body = await c.req.json<{
422
+ message: string;
423
+ role?: string;
424
+ name?: string;
425
+ requires_revision?: boolean;
426
+ revision_reason?: string;
427
+ }>();
428
+ if (!body.message?.trim()) return c.json({ ok: false, error: "message is required" }, 400);
429
+ const role = (body.role === "human" ? "human" : "agent") as "human" | "agent";
430
+ const name = body.name?.trim() || undefined;
431
+
432
+ const out = await withSidecar(filePath, (sidecar) => {
433
+ const round = activeRound(sidecar);
434
+ if (!round) return { skip: true as const, status: 400, error: "No active round" };
435
+ const comment = round.comments.find((c) => c.id === id);
436
+ if (!comment) return { skip: true as const, status: 404, error: "Comment not found" };
437
+ const entry: import("./sidecar").ThreadEntry = { role, message: body.message.trim(), at: new Date().toISOString() };
438
+ if (name) entry.name = name;
439
+ // Verdict only meaningful on agent replies; ignore on human entries.
440
+ if (role === "agent" && typeof body.requires_revision === "boolean") {
441
+ entry.requires_revision = body.requires_revision;
442
+ if (body.revision_reason?.trim()) entry.revision_reason = body.revision_reason.trim();
443
+ }
444
+ comment.thread.push(entry);
445
+ return { skip: false as const, roundNumber: round.round, comment };
446
+ });
447
+ if (out.skip) return c.json({ ok: false, error: out.error }, out.status as 400 | 404);
448
+ if (role === "human") {
449
+ broadcast("comment-reply", { round: out.roundNumber, commentId: id });
450
+ }
451
+ return c.json({ ok: true, comment: out.comment });
452
+ });
453
+
454
+ // Agent signals it has finished replying to all comments
455
+ app.post("/api/agent-replied", async (c) => {
456
+ const out = await withSidecar(filePath, (sidecar) => {
457
+ const round = activeRound(sidecar);
458
+ if (!round) return { skip: true as const };
459
+ round.agent_replied_at = new Date().toISOString();
460
+ return { skip: false as const, roundNumber: round.round };
461
+ });
462
+ if (out.skip) return c.json({ ok: false, error: "No active round" }, 400);
463
+ broadcast("agent-replied", { round: out.roundNumber });
464
+ return c.json({ ok: true });
465
+ });
466
+
467
+ // Keep /api/resolve as an alias for backward compat
468
+ app.post("/api/resolve", async (c) => {
469
+ const out = await withSidecar(filePath, (sidecar) => {
470
+ const round = activeRound(sidecar);
471
+ if (!round) return { skip: true as const, status: 400, error: "No active round" };
472
+ if (round.comments.length === 0) return { skip: true as const, status: 400, error: "No comments to submit" };
473
+ round.submitted_at = new Date().toISOString();
474
+ return { skip: false as const };
475
+ });
476
+ if (out.skip) return c.json({ ok: false, error: out.error }, out.status as 400);
477
+ return c.json({ ok: true });
478
+ });
479
+
480
+ // Live comments for the active round (used by client soft-refresh)
481
+ app.get("/api/comments", async (c) => {
482
+ const sidecar = await loadSidecar(filePath);
483
+ const latestRound = sidecar.rounds[sidecar.rounds.length - 1] ?? null;
484
+ return c.json({
485
+ comments: latestRound?.comments ?? [],
486
+ roundResolved: latestRound?.resolved_at != null,
487
+ totalRounds: sidecar.rounds.length,
488
+ });
489
+ });
490
+
491
+ // Sidecar read (for agent polling)
492
+ app.get("/api/sidecar", async (c) => {
493
+ const sidecar = await loadSidecar(filePath);
494
+ return c.json(sidecar);
495
+ });
496
+
497
+ // Read-only view of a past round
498
+ app.get("/round/:n", async (c) => {
499
+ const n = parseInt(c.req.param("n"));
500
+ const sidecar = await loadSidecar(filePath);
501
+ const roundData = sidecar.rounds.find((r) => r.round === n);
502
+ if (!roundData) return c.text("Round not found", 404);
503
+
504
+ // Find the history snapshot taken just before this round's revision
505
+ // (snapshot saved before round n+1 starts = document state during round n)
506
+ const historyDir = path.join(path.dirname(filePath), ".review", "history");
507
+ const base = path.basename(filePath);
508
+ let snapshots: string[] = [];
509
+ try {
510
+ const { readdir } = await import("fs/promises");
511
+ const files = await readdir(historyDir);
512
+ snapshots = files.filter((f) => f.startsWith(base + ".")).sort();
513
+ } catch { /* no history */ }
514
+
515
+ // Snapshot[n-1] (0-indexed, ascending sort) = document state during round n.
516
+ // The first snapshot was saved just before round 2's revision overwrote round 1's file, etc.
517
+ const snap = snapshots[n - 1];
518
+
519
+ let docContent: string;
520
+ if (snap) {
521
+ const { readFile } = await import("fs/promises");
522
+ docContent = await readFile(path.join(historyDir, snap), "utf-8");
523
+ } else {
524
+ // No snapshot — fall back to current file (round 1 with no prior history)
525
+ docContent = await readFile(filePath, "utf-8");
526
+ }
527
+
528
+ const html = renderMarkdown(docContent);
529
+ return c.html(pageTemplate(
530
+ fileName,
531
+ html,
532
+ roundData.comments,
533
+ true, // treat as resolved (read-only)
534
+ roundData.agent_replied_at ?? null,
535
+ n,
536
+ sidecar.rounds.length,
537
+ sidecar.context,
538
+ true, // readOnly
539
+ csrfToken,
540
+ opts.noAgent ?? false
541
+ ));
542
+ });
543
+
544
+ // Line diff between most recent history snapshot and current file
545
+ app.get("/api/diff", async (c) => {
546
+ const historyDir = path.join(path.dirname(filePath), ".review", "history");
547
+ const base = path.basename(filePath);
548
+ let snapshots: string[] = [];
549
+ try {
550
+ const { readdir } = await import("fs/promises");
551
+ const files = await readdir(historyDir);
552
+ snapshots = files
553
+ .filter((f) => f.startsWith(base + "."))
554
+ .sort()
555
+ .reverse();
556
+ } catch { /* no history dir yet */ }
557
+
558
+ if (snapshots.length === 0) return c.json({ ok: false, error: "No history snapshot found" });
559
+
560
+ const { readFile } = await import("fs/promises");
561
+ const oldText = await readFile(path.join(historyDir, snapshots[0]), "utf-8");
562
+ const newText = await readFile(filePath, "utf-8");
563
+ const html = renderDocDiff(oldText, newText);
564
+ return c.json({ ok: true, html });
565
+ });
566
+
567
+ // Static asset fallback: serve sibling files (images, etc.) from the doc's
568
+ // directory so relative `![alt](./diagram.png)` works. Path-traversal guard:
569
+ // resolved path must stay under the doc directory; the .review subdir is
570
+ // off limits. Symlink guard: a `path.resolve` prefix check is sound against
571
+ // `../foo` but not against a symlink IN the doc directory pointing outside
572
+ // — the resolved path looks safe, but readFile follows the link. We re-
573
+ // check after `realpath` so the *real* destination has to be inside docDir.
574
+ app.get("*", async (c) => {
575
+ const docDir = path.resolve(path.dirname(filePath));
576
+ let urlPath: string;
577
+ try {
578
+ urlPath = decodeURIComponent(new URL(c.req.url).pathname);
579
+ } catch {
580
+ return c.notFound();
581
+ }
582
+ const requested = path.resolve(docDir, "." + urlPath);
583
+ if (!requested.startsWith(docDir + path.sep) && requested !== docDir) return c.notFound();
584
+ if (requested.startsWith(path.join(docDir, ".review") + path.sep)) return c.notFound();
585
+ if (requested === path.resolve(filePath)) return c.notFound(); // the markdown itself is served at "/"
586
+
587
+ // Resolve symlinks before reading. If `requested` is a symlink (or any
588
+ // ancestor is) that points outside docDir, realpath returns the true
589
+ // location and we reject. Also defends against the docDir itself being
590
+ // symlinked, since we realpath docDir for the comparison.
591
+ let realDocDir: string;
592
+ let realRequested: string;
593
+ try {
594
+ realDocDir = await realpath(docDir);
595
+ realRequested = await realpath(requested);
596
+ } catch {
597
+ // realpath errors when the path doesn't exist (404 territory) or when a
598
+ // symlink target is missing (also 404). Either way: 404.
599
+ return c.notFound();
600
+ }
601
+ if (!realRequested.startsWith(realDocDir + path.sep) && realRequested !== realDocDir) {
602
+ return c.notFound();
603
+ }
604
+
605
+ try {
606
+ const data = await readFile(realRequested);
607
+ const ext = path.extname(realRequested).toLowerCase();
608
+ const ct =
609
+ ext === ".png" ? "image/png" :
610
+ ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
611
+ ext === ".gif" ? "image/gif" :
612
+ ext === ".svg" ? "image/svg+xml" :
613
+ ext === ".webp" ? "image/webp" :
614
+ ext === ".pdf" ? "application/pdf" :
615
+ "application/octet-stream";
616
+ return new Response(data, { headers: { "Content-Type": ct, "Cache-Control": "no-cache" } });
617
+ } catch {
618
+ return c.notFound();
619
+ }
620
+ });
621
+
622
+ return {
623
+ fetch: app.fetch.bind(app),
624
+ csrfToken,
625
+ onAbandon(cb: () => void) { onAbandonCallback = cb; },
626
+ onFinished(cb: (payload: { totalRounds: number; totalComments: number }) => void) {
627
+ onFinishedCallback = cb;
628
+ },
629
+ onRevisionError(cb: (message: string) => void) { onRevisionErrorCallback = cb; },
630
+ onRevisionRecovered(cb: () => void) { onRevisionRecoveredCallback = cb; },
631
+ };
632
+ }
633
+
634
+