@levistudio/redline 0.1.0 → 0.3.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 +27 -7
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent.ts +8 -4
- package/src/cli.ts +41 -4
- package/src/client/cards.ts +12 -2
- package/src/client/diff.ts +58 -23
- package/src/client/diffToggle.ts +32 -0
- package/src/client/lib.ts +147 -72
- package/src/client/main.ts +10 -4
- package/src/client/render.ts +17 -5
- package/src/client/selection.ts +1 -1
- package/src/client/sse.ts +33 -1
- package/src/client/state.ts +22 -0
- package/src/client/styles.css +134 -93
- package/src/parseReply.ts +9 -1
- package/src/render.ts +9 -0
- package/src/resolve.ts +174 -91
- package/src/reviewSummary.ts +93 -0
- package/src/server-page.ts +5 -7
- package/src/server.ts +65 -14
- package/src/sidecar.ts +5 -0
package/src/resolve.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appendFile, copyFile, mkdir, readFile, writeFile } from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { loadSidecar, saveSidecar } from "./sidecar";
|
|
4
|
-
import type { Round } from "./sidecar";
|
|
4
|
+
import type { Round, Comment } from "./sidecar";
|
|
5
5
|
import { pickRevisionModel } from "./pickModel";
|
|
6
6
|
import { newEnvelope } from "./promptEnvelope";
|
|
7
7
|
import { contextBlock } from "./contextBlock";
|
|
@@ -90,41 +90,8 @@ export async function resolve(filePath: string, options: { model?: string } = {}
|
|
|
90
90
|
`<comments-to-apply>\n${commentsBlock}\n</comments-to-apply>${priorChangesBlock}\n\n<document>\n${env.wrap("document", docText)}\n</document>`;
|
|
91
91
|
|
|
92
92
|
// Call the claude CLI (inherits auth from the user's Claude Code session — no API key needed)
|
|
93
|
-
console.log(`Revising with ${chosenModel}...\n`);
|
|
94
|
-
console.log("─".repeat(60));
|
|
95
|
-
const revisionStartedAt = Date.now();
|
|
96
|
-
|
|
97
93
|
const cliBin = process.env.CLAUDE_CODE_EXECPATH ?? "claude";
|
|
98
|
-
const proc = Bun.spawn(
|
|
99
|
-
[cliBin, "-p", "--system-prompt", systemPrompt, "--model", chosenModel,
|
|
100
|
-
"--output-format", "stream-json", "--include-partial-messages", "--verbose"],
|
|
101
|
-
{
|
|
102
|
-
stdin: "pipe",
|
|
103
|
-
stdout: "pipe",
|
|
104
|
-
stderr: "pipe",
|
|
105
|
-
}
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
proc.stdin.write(userMessage);
|
|
109
|
-
proc.stdin.end();
|
|
110
94
|
|
|
111
|
-
// Drain stderr concurrently so we can include it in any error report
|
|
112
|
-
let stderrText = "";
|
|
113
|
-
(async () => {
|
|
114
|
-
const r = proc.stderr.getReader();
|
|
115
|
-
const dec = new TextDecoder();
|
|
116
|
-
while (true) {
|
|
117
|
-
const { done, value } = await r.read();
|
|
118
|
-
if (done) break;
|
|
119
|
-
const chunk = dec.decode(value);
|
|
120
|
-
stderrText += chunk;
|
|
121
|
-
process.stderr.write(chunk);
|
|
122
|
-
}
|
|
123
|
-
})();
|
|
124
|
-
|
|
125
|
-
let revised = "";
|
|
126
|
-
let buffer = "";
|
|
127
|
-
const reader = proc.stdout.getReader();
|
|
128
95
|
const broadcastChunk = (text: string, kind: "thinking" | "text") => {
|
|
129
96
|
fetch(`${serverBase()}/api/revision-chunk`, {
|
|
130
97
|
method: "POST",
|
|
@@ -132,35 +99,12 @@ export async function resolve(filePath: string, options: { model?: string } = {}
|
|
|
132
99
|
body: JSON.stringify({ text, kind }),
|
|
133
100
|
}).catch(() => {});
|
|
134
101
|
};
|
|
135
|
-
while (true) {
|
|
136
|
-
const { done, value } = await reader.read();
|
|
137
|
-
if (done) break;
|
|
138
|
-
buffer += new TextDecoder().decode(value);
|
|
139
|
-
const lines = buffer.split("\n");
|
|
140
|
-
buffer = lines.pop() ?? "";
|
|
141
|
-
for (const line of lines) {
|
|
142
|
-
if (!line.trim()) continue;
|
|
143
|
-
try {
|
|
144
|
-
const obj = JSON.parse(line);
|
|
145
|
-
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
|
|
146
|
-
const delta = obj.event.delta;
|
|
147
|
-
if (delta?.type === "text_delta" && delta.text) {
|
|
148
|
-
revised += delta.text;
|
|
149
|
-
process.stdout.write(delta.text);
|
|
150
|
-
broadcastChunk(delta.text, "text");
|
|
151
|
-
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
152
|
-
broadcastChunk(delta.thinking, "thinking");
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
} catch { /* malformed JSON line, skip */ }
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
102
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
103
|
+
// Per-attempt state, hoisted so fail() can report whatever the latest
|
|
104
|
+
// attempt produced.
|
|
105
|
+
let revised = "";
|
|
106
|
+
let exitCode = 0;
|
|
107
|
+
let stderrText = "";
|
|
164
108
|
|
|
165
109
|
const fail = async (reason: string) => {
|
|
166
110
|
await logRevisionFailure(filePath, {
|
|
@@ -174,37 +118,97 @@ export async function resolve(filePath: string, options: { model?: string } = {}
|
|
|
174
118
|
throw new Error(reason);
|
|
175
119
|
};
|
|
176
120
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
121
|
+
// A mangled revision is usually a one-off model stumble — Haiku dropping an
|
|
122
|
+
// uncommented section, streaming a preamble, etc. Retry once before giving
|
|
123
|
+
// up. A non-zero CLI exit is NOT retried: that's an environment/auth failure
|
|
124
|
+
// a re-run won't fix.
|
|
125
|
+
const MAX_ATTEMPTS = 2;
|
|
126
|
+
let trimmed = "";
|
|
180
127
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
.
|
|
188
|
-
.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
128
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
129
|
+
console.log(
|
|
130
|
+
attempt === 1
|
|
131
|
+
? `Revising with ${chosenModel}...\n`
|
|
132
|
+
: `\nRetrying revision with ${chosenModel} (attempt ${attempt}/${MAX_ATTEMPTS})...\n`
|
|
133
|
+
);
|
|
134
|
+
console.log("─".repeat(60));
|
|
135
|
+
const revisionStartedAt = Date.now();
|
|
136
|
+
|
|
137
|
+
const proc = Bun.spawn(
|
|
138
|
+
[cliBin, "-p", "--system-prompt", systemPrompt, "--model", chosenModel,
|
|
139
|
+
"--output-format", "stream-json", "--include-partial-messages", "--verbose"],
|
|
140
|
+
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" }
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
proc.stdin.write(userMessage);
|
|
144
|
+
proc.stdin.end();
|
|
145
|
+
|
|
146
|
+
// Drain stderr concurrently so we can include it in any error report.
|
|
147
|
+
stderrText = "";
|
|
148
|
+
const stderrDone = (async () => {
|
|
149
|
+
const r = proc.stderr.getReader();
|
|
150
|
+
const dec = new TextDecoder();
|
|
151
|
+
while (true) {
|
|
152
|
+
const { done, value } = await r.read();
|
|
153
|
+
if (done) break;
|
|
154
|
+
const chunk = dec.decode(value);
|
|
155
|
+
stderrText += chunk;
|
|
156
|
+
process.stderr.write(chunk);
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
|
|
160
|
+
revised = "";
|
|
161
|
+
let buffer = "";
|
|
162
|
+
const reader = proc.stdout.getReader();
|
|
163
|
+
while (true) {
|
|
164
|
+
const { done, value } = await reader.read();
|
|
165
|
+
if (done) break;
|
|
166
|
+
buffer += new TextDecoder().decode(value);
|
|
167
|
+
const lines = buffer.split("\n");
|
|
168
|
+
buffer = lines.pop() ?? "";
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
if (!line.trim()) continue;
|
|
171
|
+
try {
|
|
172
|
+
const obj = JSON.parse(line);
|
|
173
|
+
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
|
|
174
|
+
const delta = obj.event.delta;
|
|
175
|
+
if (delta?.type === "text_delta" && delta.text) {
|
|
176
|
+
revised += delta.text;
|
|
177
|
+
process.stdout.write(delta.text);
|
|
178
|
+
broadcastChunk(delta.text, "text");
|
|
179
|
+
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
180
|
+
broadcastChunk(delta.thinking, "thinking");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch { /* malformed JSON line, skip */ }
|
|
184
|
+
}
|
|
207
185
|
}
|
|
186
|
+
|
|
187
|
+
exitCode = await proc.exited;
|
|
188
|
+
await stderrDone;
|
|
189
|
+
const revisionDurationMs = Date.now() - revisionStartedAt;
|
|
190
|
+
console.log("\n" + "─".repeat(60));
|
|
191
|
+
console.log(`Model: ${chosenModel} · Duration: ${(revisionDurationMs / 1000).toFixed(1)}s · Exit: ${exitCode}`);
|
|
192
|
+
console.log("─".repeat(60) + "\n");
|
|
193
|
+
|
|
194
|
+
// A CLI crash is not retryable — fail immediately.
|
|
195
|
+
if (exitCode !== 0) {
|
|
196
|
+
await fail(`claude CLI exited with code ${exitCode}${stderrText.trim() ? ` — ${stderrText.trim().split("\n").slice(-3).join(" | ")}` : ""}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const result = validateRevision(revised, docText, settled);
|
|
200
|
+
if (result.ok) {
|
|
201
|
+
trimmed = result.doc;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Validation failed — the model returned mangled output. Retry once; on
|
|
206
|
+
// the last attempt, log and throw so the session surfaces the error.
|
|
207
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
208
|
+
console.log(`Revision output rejected: ${result.reason}`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
await fail(result.reason);
|
|
208
212
|
}
|
|
209
213
|
|
|
210
214
|
// If the model made no changes, skip the write and signal the browser
|
|
@@ -230,6 +234,85 @@ export async function resolve(filePath: string, options: { model?: string } = {}
|
|
|
230
234
|
} catch { /* server may not be running — non-fatal */ }
|
|
231
235
|
}
|
|
232
236
|
|
|
237
|
+
const HEADING_RE = /^#{1,6} .+$/gm;
|
|
238
|
+
|
|
239
|
+
// Headings present in `inputDoc` but missing from `outputDoc` whose section the
|
|
240
|
+
// reviewer never commented on. A comment quoting text inside a section means
|
|
241
|
+
// the reviewer was working there, so dropping/reworking it is authorized; a
|
|
242
|
+
// section vanishing with no comment near it means the model mangled the doc.
|
|
243
|
+
function droppedSections(inputDoc: string, outputDoc: string, settled: Comment[]): string[] {
|
|
244
|
+
const outHeadings = new Set(outputDoc.match(HEADING_RE) ?? []);
|
|
245
|
+
const inMatches = [...inputDoc.matchAll(HEADING_RE)];
|
|
246
|
+
const quotes = settled.map((c) => c.quote.trim()).filter((q) => q.length > 0);
|
|
247
|
+
|
|
248
|
+
const unauthorized: string[] = [];
|
|
249
|
+
for (let i = 0; i < inMatches.length; i++) {
|
|
250
|
+
const heading = inMatches[i]![0];
|
|
251
|
+
if (outHeadings.has(heading)) continue;
|
|
252
|
+
// This heading's section runs from the heading to the next one (or EOF).
|
|
253
|
+
const start = inMatches[i]!.index!;
|
|
254
|
+
const end = i + 1 < inMatches.length ? inMatches[i + 1]!.index! : inputDoc.length;
|
|
255
|
+
const section = inputDoc.slice(start, end);
|
|
256
|
+
const commented = quotes.some((q) => section.includes(q));
|
|
257
|
+
if (!commented) unauthorized.push(heading);
|
|
258
|
+
}
|
|
259
|
+
return unauthorized;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Validate (and lightly normalize) a revision pass's raw output. Pure — the
|
|
263
|
+
// retry loop and tests both drive it. Returns the cleaned document on success,
|
|
264
|
+
// or a human-readable reason on failure.
|
|
265
|
+
export function validateRevision(
|
|
266
|
+
revised: string,
|
|
267
|
+
inputDoc: string,
|
|
268
|
+
settled: Comment[]
|
|
269
|
+
): { ok: true; doc: string } | { ok: false; reason: string } {
|
|
270
|
+
// Strip a wrapping code fence and any <document> wrapper tags the model
|
|
271
|
+
// sometimes includes despite the system prompt.
|
|
272
|
+
let trimmed = revised.trim()
|
|
273
|
+
.replace(/^```(?:markdown)?\n([\s\S]*)\n```$/, "$1")
|
|
274
|
+
.replace(/^<document>\s*/i, "")
|
|
275
|
+
.replace(/\s*<\/document>\s*$/i, "")
|
|
276
|
+
.trim();
|
|
277
|
+
|
|
278
|
+
// Strip a trailing meta-section the model sometimes appends (Settled
|
|
279
|
+
// comments, Changelog, …), plus a horizontal rule that often precedes it.
|
|
280
|
+
const metaHeading = trimmed.match(/\n#{2,3} (Settled comments|Previously agreed changes|Changelog|Revision notes)\b/i);
|
|
281
|
+
if (metaHeading) {
|
|
282
|
+
trimmed = trimmed.slice(0, metaHeading.index).trimEnd().replace(/\n+---\s*$/, "").trimEnd();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!trimmed) {
|
|
286
|
+
return { ok: false, reason: "Revision produced empty output — no text was streamed back" };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// If the input had headings, the output should too. Strip a preamble before
|
|
290
|
+
// the first heading; if there's no heading at all, the model returned prose
|
|
291
|
+
// (an apology, a summary) instead of the document. A genuinely heading-less
|
|
292
|
+
// input is left alone — headings can't be the structural anchor there.
|
|
293
|
+
if (/^#{1,6} /m.test(inputDoc) && !/^#{1,6} /.test(trimmed)) {
|
|
294
|
+
const firstHeadingIdx = trimmed.search(/^#{1,6} /m);
|
|
295
|
+
if (firstHeadingIdx > 0) {
|
|
296
|
+
trimmed = trimmed.slice(firstHeadingIdx).trim();
|
|
297
|
+
} else {
|
|
298
|
+
return { ok: false, reason: "Revision output has no Markdown headings — the model returned non-document content, not a revised document" };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Structural integrity: the revision must not silently drop a section the
|
|
303
|
+
// reviewer never commented on.
|
|
304
|
+
const dropped = droppedSections(inputDoc, trimmed, settled);
|
|
305
|
+
if (dropped.length > 0) {
|
|
306
|
+
const list = dropped.map((h) => `"${h}"`).join(", ");
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
reason: `Revision dropped section${dropped.length > 1 ? "s" : ""} the reviewer never commented on: ${list} — the model mangled the document instead of editing it`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { ok: true, doc: trimmed };
|
|
314
|
+
}
|
|
315
|
+
|
|
233
316
|
async function logRevisionFailure(
|
|
234
317
|
filePath: string,
|
|
235
318
|
details: { reason: string; model: string; exitCode: number; stderr: string; stdoutSample: string; stdoutLength: number }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Closeout summary of a finished review.
|
|
2
|
+
//
|
|
3
|
+
// The inline review agent (src/agent.ts) and the agent that *launched* redline
|
|
4
|
+
// share no live channel — the sidecar is the only persisted artifact, and the
|
|
5
|
+
// launching agent only regains control when the session exits. So at closeout
|
|
6
|
+
// the CLI prints every comment thread verbatim. Anything the reviewer said —
|
|
7
|
+
// including feedback meant for the launching agent — lands in front of it.
|
|
8
|
+
//
|
|
9
|
+
// Comments the inline agent explicitly flagged (`escalate: true`) get a
|
|
10
|
+
// dedicated section so they aren't lost in the full transcript.
|
|
11
|
+
|
|
12
|
+
import type { Sidecar, Comment } from "./sidecar";
|
|
13
|
+
|
|
14
|
+
export interface EscalationItem {
|
|
15
|
+
round: number;
|
|
16
|
+
quote: string;
|
|
17
|
+
request: string; // the reviewer message the inline agent couldn't act on
|
|
18
|
+
note: string; // the agent's escalation note
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function flatten(s: string, n: number): string {
|
|
22
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
23
|
+
return flat.length > n ? flat.slice(0, n - 1).trimEnd() + "…" : flat;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isEscalated(c: Comment): boolean {
|
|
27
|
+
return c.thread.some((e) => e.role === "agent" && e.escalate === true);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Pull out every comment the inline agent routed to the launching agent.
|
|
31
|
+
export function collectEscalations(sidecar: Sidecar): EscalationItem[] {
|
|
32
|
+
const items: EscalationItem[] = [];
|
|
33
|
+
for (const round of sidecar.rounds) {
|
|
34
|
+
for (const c of round.comments) {
|
|
35
|
+
const escIdx = c.thread.findIndex((e) => e.role === "agent" && e.escalate === true);
|
|
36
|
+
if (escIdx === -1) continue;
|
|
37
|
+
const agentEntry = c.thread[escIdx]!;
|
|
38
|
+
// The reviewer message immediately before the escalation is the request
|
|
39
|
+
// the inline agent couldn't fulfill.
|
|
40
|
+
let request = "";
|
|
41
|
+
for (let i = escIdx - 1; i >= 0; i--) {
|
|
42
|
+
if (c.thread[i]!.role === "human") { request = c.thread[i]!.message; break; }
|
|
43
|
+
}
|
|
44
|
+
items.push({
|
|
45
|
+
round: round.round,
|
|
46
|
+
quote: flatten(c.quote, 80),
|
|
47
|
+
request: flatten(request, 300),
|
|
48
|
+
note: flatten(agentEntry.revision_reason || agentEntry.message, 300),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return items;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A readable transcript of every comment thread, plus an escalation callout.
|
|
56
|
+
// Printed to stdout on session close so the launching agent can read it.
|
|
57
|
+
export function formatReviewSummary(sidecar: Sidecar): string {
|
|
58
|
+
const lines: string[] = [`Review threads — ${sidecar.file}`];
|
|
59
|
+
|
|
60
|
+
for (const round of sidecar.rounds) {
|
|
61
|
+
if (round.comments.length === 0) continue;
|
|
62
|
+
lines.push("", `Round ${round.round}`);
|
|
63
|
+
round.comments.forEach((c, i) => {
|
|
64
|
+
const tags = [c.resolved ? "resolved" : "open"];
|
|
65
|
+
if (isEscalated(c)) tags.push("escalated");
|
|
66
|
+
lines.push(` ${i + 1}. "${flatten(c.quote, 80)}" — ${tags.join(" · ")}`);
|
|
67
|
+
for (const e of c.thread) {
|
|
68
|
+
const who = e.role === "human" ? "Reviewer" : (e.name || "Agent");
|
|
69
|
+
lines.push(` ${who}: ${flatten(e.message, 280)}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const esc = collectEscalations(sidecar);
|
|
75
|
+
if (esc.length > 0) {
|
|
76
|
+
lines.push(
|
|
77
|
+
"",
|
|
78
|
+
`⚠ ${esc.length} comment${esc.length !== 1 ? "s" : ""} escalated to you (the launching agent):`,
|
|
79
|
+
);
|
|
80
|
+
for (const e of esc) {
|
|
81
|
+
lines.push(` • "${e.quote}" (round ${e.round})`);
|
|
82
|
+
if (e.request) lines.push(` Reviewer asked: ${e.request}`);
|
|
83
|
+
if (e.note) lines.push(` Agent note: ${e.note}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push(
|
|
86
|
+
"",
|
|
87
|
+
"The inline review agent couldn't act on these. Address them in the",
|
|
88
|
+
"document or with the user before considering the review closed out.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
package/src/server-page.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { Comment } from "./sidecar";
|
|
2
|
-
|
|
3
1
|
function escapeHtml(s: string): string {
|
|
4
2
|
return s
|
|
5
3
|
.replace(/&/g, "&")
|
|
@@ -11,7 +9,7 @@ function escapeHtml(s: string): string {
|
|
|
11
9
|
function pageTemplate(
|
|
12
10
|
title: string,
|
|
13
11
|
content: string,
|
|
14
|
-
comments:
|
|
12
|
+
comments: unknown[],
|
|
15
13
|
roundResolved: boolean,
|
|
16
14
|
agentRepliedAt: string | null,
|
|
17
15
|
roundNumber: number,
|
|
@@ -48,21 +46,20 @@ function pageTemplate(
|
|
|
48
46
|
const label = n === totalRounds ? 'Round ' + n + ' — current' : 'Round ' + n;
|
|
49
47
|
return `<a class="round-picker-item${isCurrent ? ' current' : ''}" href="${href}">${label}</a>`;
|
|
50
48
|
}).join('')
|
|
51
|
-
}</div>` : ''}
|
|
49
|
+
}${!readOnly ? `<button class="round-picker-item round-picker-action" id="btn-compare" type="button">Compare with previous →</button>` : ''}</div>` : ''}
|
|
52
50
|
</span>
|
|
53
51
|
</span>
|
|
54
52
|
<div class="header-actions">
|
|
55
53
|
<span id="agent-status" class="agent-status" hidden></span>
|
|
56
54
|
${noAgent ? `<span class="manual-mode-pill" title="Started with --no-agent. No Claude replies, no revision pass.">Manual mode</span>` : ''}
|
|
55
|
+
${!readOnly && totalRounds > 1 ? `<button class="btn-toggle-diff" id="btn-toggle-diff" type="button" aria-pressed="false">Show changes</button>` : ''}
|
|
57
56
|
${readOnly
|
|
58
57
|
? `<span style="font-size:13px;color:var(--text-muted);font-style:italic">Read-only — <a href="/" style="color:var(--accent)">back to current</a></span>`
|
|
59
|
-
: `<button class="btn-accept" id="btn-accept" disabled>Revise document</button
|
|
60
|
-
${totalRounds > 1 ? `<button class="btn-diff-compare" id="btn-compare">Compare with previous</button>` : ''}`
|
|
58
|
+
: `<button class="btn-accept" id="btn-accept" disabled>Revise document</button>`
|
|
61
59
|
}
|
|
62
60
|
</div>
|
|
63
61
|
</div>
|
|
64
62
|
${context ? `<div class="context-banner" id="context-banner">
|
|
65
|
-
<span class="context-label">Context</span>
|
|
66
63
|
<span class="context-text">${escapeHtml(context)}</span>
|
|
67
64
|
<button class="context-dismiss" onclick="dismissContextBanner()" aria-label="Dismiss">✕</button>
|
|
68
65
|
</div>` : ''}
|
|
@@ -100,6 +97,7 @@ function pageTemplate(
|
|
|
100
97
|
</div>
|
|
101
98
|
</div>
|
|
102
99
|
<div id="error-banner" style="display:none;position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#b71c1c;color:white;padding:12px 24px;border-radius:6px;font-size:14px;font-weight:500;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:999;white-space:nowrap;"></div>
|
|
100
|
+
<div id="session-ended-banner" style="display:none;position:fixed;top:0;left:0;right:0;background:#92400e;color:white;padding:10px 24px;font-size:14px;font-weight:500;text-align:center;z-index:1000;box-shadow:0 1px 4px rgba(0,0,0,0.15);">Review session ended — the redline server is no longer running. Your changes up to this point are saved; close this tab and continue in Claude Code.</div>
|
|
103
101
|
|
|
104
102
|
<script>
|
|
105
103
|
window.__REDLINE__ = {
|
package/src/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { readFile, realpath } from "fs/promises";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import { renderMarkdown } from "./render";
|
|
4
|
+
import { renderMarkdown, renderMessageMarkdown } from "./render";
|
|
5
5
|
import { renderDocDiff } from "./diff";
|
|
6
6
|
import { pageTemplate } from "./server-page";
|
|
7
7
|
import {
|
|
@@ -13,6 +13,22 @@ import {
|
|
|
13
13
|
type Comment,
|
|
14
14
|
} from "./sidecar";
|
|
15
15
|
|
|
16
|
+
// Attach a sanitized HTML rendering of each thread message so the client
|
|
17
|
+
// can show markdown formatting (bold, lists, inline code) instead of the
|
|
18
|
+
// raw source. Done server-side because renderMarkdown depends on marked +
|
|
19
|
+
// sanitize-html which are Node-targeted. The HTML is added as a `messageHtml`
|
|
20
|
+
// field alongside the original `message` so callers that read the source
|
|
21
|
+
// (eg. agent prompt building) are unaffected.
|
|
22
|
+
export function serializeCommentsForClient(comments: Comment[]): unknown[] {
|
|
23
|
+
return comments.map((c) => ({
|
|
24
|
+
...c,
|
|
25
|
+
thread: c.thread.map((e) => ({
|
|
26
|
+
...e,
|
|
27
|
+
messageHtml: renderMessageMarkdown(e.message),
|
|
28
|
+
})),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
// Bundle the client JS once per server lifetime. The build is ~50-100ms — felt
|
|
17
33
|
// only at server startup, not on page loads (the bundle is cached in memory)
|
|
18
34
|
// and not when the source file is re-read. See M10 for the on-disk cache idea.
|
|
@@ -113,12 +129,23 @@ export function createServer(
|
|
|
113
129
|
|
|
114
130
|
// Abandonment detection: if no browser is connected for ABANDON_GRACE_MS after
|
|
115
131
|
// the first one ever connected, fire onAbandonCallback so the CLI can exit.
|
|
116
|
-
// Default 10min —
|
|
117
|
-
//
|
|
118
|
-
//
|
|
132
|
+
// Default 10min — this is now only the *backstop* for the no-beacon case
|
|
133
|
+
// (browser crash, kill -9, OS-killed tab). A cleanly closed tab fires an
|
|
134
|
+
// explicit /api/tab-closed beacon and takes the much shorter TAB_CLOSE_GRACE_MS
|
|
135
|
+
// path instead. The long backstop must stay generous: a bare SSE drop (laptop
|
|
136
|
+
// sleep, network blip, DevTools offline) is NOT a closed tab, and exiting on
|
|
137
|
+
// it kills a session the user means to continue. Override with
|
|
138
|
+
// REDLINE_ABANDON_MS for tests.
|
|
119
139
|
const ABANDON_GRACE_MS = process.env.REDLINE_ABANDON_MS
|
|
120
140
|
? parseInt(process.env.REDLINE_ABANDON_MS, 10)
|
|
121
141
|
: 10 * 60 * 1000;
|
|
142
|
+
// Grace applied after an explicit tab-close beacon. A reload also fires the
|
|
143
|
+
// beacon, so we can't exit immediately — but a reload reconnects its SSE
|
|
144
|
+
// within ~1s, while a real close never does. A few seconds covers the
|
|
145
|
+
// reconnect. Override with REDLINE_TABCLOSE_MS for tests.
|
|
146
|
+
const TAB_CLOSE_GRACE_MS = process.env.REDLINE_TABCLOSE_MS
|
|
147
|
+
? parseInt(process.env.REDLINE_TABCLOSE_MS, 10)
|
|
148
|
+
: 8000;
|
|
122
149
|
let hadBrowser = false;
|
|
123
150
|
let abandonTimer: ReturnType<typeof setTimeout> | null = null;
|
|
124
151
|
let onAbandonCallback: (() => void) | undefined;
|
|
@@ -178,15 +205,24 @@ export function createServer(
|
|
|
178
205
|
}, REVISION_TIMEOUT_MS);
|
|
179
206
|
}
|
|
180
207
|
|
|
208
|
+
function armAbandonTimer(graceMs: number) {
|
|
209
|
+
if (abandonTimer) clearTimeout(abandonTimer);
|
|
210
|
+
abandonTimer = setTimeout(() => {
|
|
211
|
+
abandonTimer = null;
|
|
212
|
+
// Re-check at fire time: a tab may have (re)connected during the grace
|
|
213
|
+
// — a reload, or a second tab — in which case nothing is abandoned.
|
|
214
|
+
if (browserClients.size > 0) return;
|
|
215
|
+
console.log(`\n[redline] No browser connected for ${graceMs / 1000}s — assuming abandoned.`);
|
|
216
|
+
onAbandonCallback?.();
|
|
217
|
+
}, graceMs);
|
|
218
|
+
}
|
|
219
|
+
|
|
181
220
|
function checkBrowserPresence() {
|
|
182
221
|
if (browserClients.size > 0) {
|
|
183
222
|
hadBrowser = true;
|
|
184
223
|
if (abandonTimer) { clearTimeout(abandonTimer); abandonTimer = null; }
|
|
185
224
|
} else if (hadBrowser && !abandonTimer) {
|
|
186
|
-
|
|
187
|
-
console.log(`\n[redline] No browser connected for ${ABANDON_GRACE_MS / 1000}s — assuming abandoned.`);
|
|
188
|
-
onAbandonCallback?.();
|
|
189
|
-
}, ABANDON_GRACE_MS);
|
|
225
|
+
armAbandonTimer(ABANDON_GRACE_MS);
|
|
190
226
|
}
|
|
191
227
|
}
|
|
192
228
|
|
|
@@ -239,7 +275,7 @@ export function createServer(
|
|
|
239
275
|
const agentRepliedAt = latestRound?.agent_replied_at ?? null;
|
|
240
276
|
const roundNumber = latestRound?.round ?? 1;
|
|
241
277
|
const totalRounds = sidecar.rounds.length;
|
|
242
|
-
return c.html(pageTemplate(fileName, html, comments, roundResolved, agentRepliedAt, roundNumber, totalRounds, sidecar.context, false, csrfToken, opts.noAgent ?? false));
|
|
278
|
+
return c.html(pageTemplate(fileName, html, serializeCommentsForClient(comments), roundResolved, agentRepliedAt, roundNumber, totalRounds, sidecar.context, false, csrfToken, opts.noAgent ?? false));
|
|
243
279
|
});
|
|
244
280
|
|
|
245
281
|
// Add a comment to the active round
|
|
@@ -408,6 +444,17 @@ export function createServer(
|
|
|
408
444
|
return c.json({ ok: true });
|
|
409
445
|
});
|
|
410
446
|
|
|
447
|
+
// A browser tab fired pagehide — it is closing, reloading, or navigating away.
|
|
448
|
+
// This is a hint, not proof of abandonment (a reload fires it too), so we
|
|
449
|
+
// shorten the abandon grace rather than exiting outright. On a real close the
|
|
450
|
+
// SSE never reconnects and the short timer fires; on a reload the new page
|
|
451
|
+
// reconnects within ~1s and checkBrowserPresence clears the timer. Crashes
|
|
452
|
+
// and kill -9 send no beacon and fall back to the long ABANDON_GRACE_MS.
|
|
453
|
+
app.post("/api/tab-closed", (c) => {
|
|
454
|
+
if (hadBrowser) armAbandonTimer(TAB_CLOSE_GRACE_MS);
|
|
455
|
+
return c.json({ ok: true });
|
|
456
|
+
});
|
|
457
|
+
|
|
411
458
|
// Agent signals it is composing a reply (shows typing indicator in thread)
|
|
412
459
|
app.post("/api/comment/:id/thinking", async (c) => {
|
|
413
460
|
const id = c.req.param("id");
|
|
@@ -424,6 +471,7 @@ export function createServer(
|
|
|
424
471
|
name?: string;
|
|
425
472
|
requires_revision?: boolean;
|
|
426
473
|
revision_reason?: string;
|
|
474
|
+
escalate?: boolean;
|
|
427
475
|
}>();
|
|
428
476
|
if (!body.message?.trim()) return c.json({ ok: false, error: "message is required" }, 400);
|
|
429
477
|
const role = (body.role === "human" ? "human" : "agent") as "human" | "agent";
|
|
@@ -437,9 +485,12 @@ export function createServer(
|
|
|
437
485
|
const entry: import("./sidecar").ThreadEntry = { role, message: body.message.trim(), at: new Date().toISOString() };
|
|
438
486
|
if (name) entry.name = name;
|
|
439
487
|
// Verdict only meaningful on agent replies; ignore on human entries.
|
|
440
|
-
if (role === "agent"
|
|
441
|
-
|
|
442
|
-
|
|
488
|
+
if (role === "agent") {
|
|
489
|
+
if (typeof body.requires_revision === "boolean") {
|
|
490
|
+
entry.requires_revision = body.requires_revision;
|
|
491
|
+
if (body.revision_reason?.trim()) entry.revision_reason = body.revision_reason.trim();
|
|
492
|
+
}
|
|
493
|
+
if (body.escalate === true) entry.escalate = true;
|
|
443
494
|
}
|
|
444
495
|
comment.thread.push(entry);
|
|
445
496
|
return { skip: false as const, roundNumber: round.round, comment };
|
|
@@ -482,7 +533,7 @@ export function createServer(
|
|
|
482
533
|
const sidecar = await loadSidecar(filePath);
|
|
483
534
|
const latestRound = sidecar.rounds[sidecar.rounds.length - 1] ?? null;
|
|
484
535
|
return c.json({
|
|
485
|
-
comments: latestRound?.comments ?? [],
|
|
536
|
+
comments: serializeCommentsForClient(latestRound?.comments ?? []),
|
|
486
537
|
roundResolved: latestRound?.resolved_at != null,
|
|
487
538
|
totalRounds: sidecar.rounds.length,
|
|
488
539
|
});
|
|
@@ -529,7 +580,7 @@ export function createServer(
|
|
|
529
580
|
return c.html(pageTemplate(
|
|
530
581
|
fileName,
|
|
531
582
|
html,
|
|
532
|
-
roundData.comments,
|
|
583
|
+
serializeCommentsForClient(roundData.comments),
|
|
533
584
|
true, // treat as resolved (read-only)
|
|
534
585
|
roundData.agent_replied_at ?? null,
|
|
535
586
|
n,
|
package/src/sidecar.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface ThreadEntry {
|
|
|
13
13
|
// action defaults to "Revise" or "Accept as-is". Only set on agent entries.
|
|
14
14
|
requires_revision?: boolean;
|
|
15
15
|
revision_reason?: string;
|
|
16
|
+
// Set true on an agent reply when the comment can't be acted on from inside
|
|
17
|
+
// this review — it needs the agent that *launched* redline (project context,
|
|
18
|
+
// tools, or authority the inline agent lacks). Surfaced in the closeout
|
|
19
|
+
// summary so the launching agent picks it up. Only set on agent entries.
|
|
20
|
+
escalate?: boolean;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
// Latest agent verdict on a comment thread:
|