@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/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/ROADMAP.md +39 -0
- package/SECURITY.md +33 -0
- package/bin/redline.cjs +61 -0
- package/package.json +61 -0
- package/scripts/install-skill.sh +78 -0
- package/skills/redline-review/SKILL.md +102 -0
- package/src/agent.ts +283 -0
- package/src/cli.ts +332 -0
- package/src/client/cards.ts +385 -0
- package/src/client/diff.ts +100 -0
- package/src/client/firstRunBanner.ts +26 -0
- package/src/client/lib.ts +299 -0
- package/src/client/main.ts +119 -0
- package/src/client/render.ts +413 -0
- package/src/client/selection.ts +253 -0
- package/src/client/sse.ts +179 -0
- package/src/client/state.ts +56 -0
- package/src/client/styles.css +994 -0
- package/src/contextBlock.ts +16 -0
- package/src/diff.ts +166 -0
- package/src/parseReply.ts +115 -0
- package/src/pickModel.ts +38 -0
- package/src/promptEnvelope.ts +58 -0
- package/src/render.ts +83 -0
- package/src/resolve.ts +290 -0
- package/src/server-page.ts +119 -0
- package/src/server.ts +634 -0
- package/src/sidecar.ts +190 -0
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 `` 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
|
+
|