@levistudio/redline 0.2.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 CHANGED
@@ -4,6 +4,16 @@ All notable changes to Redline are documented here. The format follows [Keep a C
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.0] - 2026-05-15
8
+
9
+ ### Added
10
+ - **Escalation handoff** ([#86](https://github.com/alevi/redline/pull/86)). When a comment needs something the inline review agent can't provide — an external style guide, a spec it can't see, a decision that needs the wider project — the agent flags its reply with an escalate verdict. The comment shows an "Escalated" badge, the round banner notes the count, and the session's closeout summary and `.review/<file>.result` carry an `escalations` array so the agent that launched the review picks the feedback up.
11
+
12
+ ### Fixed
13
+ - **Revision integrity check and retry** ([#86](https://github.com/alevi/redline/pull/86)). The revision pass now validates its output on every run and rejects a revision that silently drops document sections no comment touched, instead of writing a mangled document to disk. A failed validation retries once before surfacing an error.
14
+ - **Cross-block and image-spanning selections** ([#85](https://github.com/alevi/redline/pull/85)) now anchor correctly. Selecting across a block boundary, or across an image, no longer fails with an anchoring error.
15
+ - **Sessions are no longer abandoned on a transient SSE drop** ([#84](https://github.com/alevi/redline/pull/84)). A backgrounded tab, laptop sleep, or a brief network blip no longer causes the server to exit; only an explicit tab close ends the session. A server that does go away surfaces a clear "session ended" banner instead of a raw fetch error.
16
+
7
17
  ## [0.2.0] - 2026-05-11
8
18
 
9
19
  ### Added
@@ -41,6 +51,7 @@ Initial public release on npm as `@levistudio/redline`.
41
51
  - Auto-installs missing dependencies on first CLI run.
42
52
  - Initial test suite: server, sidecar, parsing, model-picking, rendering, diff, SSE, integration, happy-dom client.
43
53
 
44
- [Unreleased]: https://github.com/alevi/redline/compare/v0.2.0...HEAD
54
+ [Unreleased]: https://github.com/alevi/redline/compare/v0.3.0...HEAD
55
+ [0.3.0]: https://github.com/alevi/redline/releases/tag/v0.3.0
45
56
  [0.2.0]: https://github.com/alevi/redline/releases/tag/v0.2.0
46
57
  [0.1.0]: https://github.com/alevi/redline/releases/tag/v0.1.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levistudio/redline",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Inline comments on Markdown files, for human-in-the-loop AI doc review.",
5
5
  "keywords": [
6
6
  "markdown",
package/src/agent.ts CHANGED
@@ -89,8 +89,11 @@ const REPLY_SYSTEM_PROMPT_BODY =
89
89
  "Good: message: \"Got it.\" reason: \"Add a third line to the hard line breaks example\"\n" +
90
90
  "When requires_revision is false, leave reason empty (or a very short note about why no edit). The reply IS the answer.\n" +
91
91
  "\n" +
92
+ "Separately, decide whether this comment needs the agent that LAUNCHED this review rather than you. That outer agent has the project context, tools, and authority you don't — e.g. an external style guide or canon you can't see, a spec to check the document against, or a decision that depends on the wider project. When the comment asks for something like that, set ESCALATE: true and briefly tell the reviewer in your message that you've routed it to the launching agent. Otherwise ESCALATE: false. Escalating does not change requires_revision — judge that on its own.\n" +
93
+ "\n" +
92
94
  "Output exactly this format and nothing else (no prose before/after, no code fences). Use these literal markers — do not escape quotes inside the message:\n" +
93
95
  "REQUIRES_REVISION: <true|false>\n" +
96
+ "ESCALATE: <true|false>\n" +
94
97
  "REASON: <one short sentence describing the edit, or empty>\n" +
95
98
  "---MESSAGE---\n" +
96
99
  "<your reply text — quotes, punctuation, anything>\n" +
@@ -114,12 +117,13 @@ async function postReply(
114
117
  commentId: string,
115
118
  message: string,
116
119
  requires_revision: boolean,
117
- revision_reason: string
120
+ revision_reason: string,
121
+ escalate: boolean
118
122
  ) {
119
123
  await fetch(`${BASE_URL}/api/comment/${commentId}/reply`, {
120
124
  method: "POST",
121
125
  headers: { "Content-Type": "application/json", ...CSRF_HEADER },
122
- body: JSON.stringify({ role: "agent", name: "Claude", message, requires_revision, revision_reason }),
126
+ body: JSON.stringify({ role: "agent", name: "Claude", message, requires_revision, revision_reason, escalate }),
123
127
  });
124
128
  }
125
129
 
@@ -185,8 +189,8 @@ async function handleComment(commentId: string) {
185
189
 
186
190
  if (reply.trim()) {
187
191
  const parsed = parseReply(reply);
188
- console.log(`[agent] verdict for ${commentId}: requires_revision=${parsed.requires_revision}`);
189
- await postReply(commentId, parsed.message, parsed.requires_revision, parsed.reason);
192
+ console.log(`[agent] verdict for ${commentId}: requires_revision=${parsed.requires_revision} escalate=${parsed.escalate}`);
193
+ await postReply(commentId, parsed.message, parsed.requires_revision, parsed.reason, parsed.escalate);
190
194
  }
191
195
  } catch (err) {
192
196
  await logReplyFailure(commentId, err);
package/src/cli.ts CHANGED
@@ -4,6 +4,10 @@ import { mkdir, writeFile } from "fs/promises";
4
4
  import { spawnSync } from "child_process";
5
5
  import { createRequire } from "module";
6
6
  import path from "path";
7
+ // reviewSummary only `import type`s from ./sidecar, so it pulls in no
8
+ // third-party deps at runtime — safe to import statically ahead of the
9
+ // preflight. abandon() needs it synchronously (signal context).
10
+ import { collectEscalations, formatReviewSummary } from "./reviewSummary";
7
11
 
8
12
  // Ensure redline's own dependencies are resolvable before any third-party
9
13
  // imports load. `redline` is invoked from arbitrary projects: in a checkout
@@ -137,6 +141,7 @@ if (args[0] === "resolve") {
137
141
 
138
142
  const resultFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".result");
139
143
  const startupFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".startup.json");
144
+ const sidecarFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".json");
140
145
 
141
146
  // Clear stale state from a prior run so a polling agent can't be misled
142
147
  // by a leftover .result or .startup.json file that predates this process.
@@ -274,19 +279,36 @@ if (args[0] === "resolve") {
274
279
  const status = lastRevisionError ? "error" : "abandoned";
275
280
  const payload: Record<string, unknown> = { status, file: resolved };
276
281
  if (lastRevisionError) payload.reason = lastRevisionError;
282
+ // Carry escalations through the error/abandon path too — on an incomplete
283
+ // session they matter more, not less. Read the sidecar synchronously:
284
+ // abandon runs in signal context where async I/O may not complete.
285
+ let escalations: import("./reviewSummary").EscalationItem[] = [];
286
+ try {
287
+ if (existsSync(sidecarFile)) {
288
+ const raw = readFileSync(sidecarFile, "utf-8");
289
+ if (raw.trim()) escalations = collectEscalations(JSON.parse(raw));
290
+ }
291
+ } catch { /* best effort — never block shutdown on the summary */ }
292
+ payload.escalations = escalations;
277
293
  // Synchronous write so the result file lands even if the runtime is
278
294
  // terminating due to a signal (async I/O may not complete in that case).
279
295
  try {
280
296
  mkdirSync(path.dirname(resultFile), { recursive: true });
281
297
  writeFileSync(resultFile, JSON.stringify(payload, null, 2));
282
298
  } catch { /* best effort */ }
283
- console.log(`\nREDLINE_RESULT: ${status}${lastRevisionError ? ` reason="${lastRevisionError}"` : ""}`);
299
+ if (escalations.length) {
300
+ console.log(
301
+ `\n⚠ ${escalations.length} comment${escalations.length !== 1 ? "s" : ""} escalated to the launching agent — see ${path.basename(resultFile)}`
302
+ );
303
+ }
304
+ const escSuffix = escalations.length ? ` escalations=${escalations.length}` : "";
305
+ console.log(`\nREDLINE_RESULT: ${status}${lastRevisionError ? ` reason="${lastRevisionError}"` : ""}${escSuffix}`);
284
306
  // Exit 3 = revision error; 2 = abandoned. Both still distinguish from 0 (approved).
285
307
  process.exit(lastRevisionError ? 3 : 2);
286
308
  };
287
309
 
288
310
  // Happy-path finish: human clicked Done.
289
- app.onFinished(({ totalRounds, totalComments }) => {
311
+ app.onFinished(async ({ totalRounds, totalComments }) => {
290
312
  serverExiting = true;
291
313
  killAgent().catch(() => { /* shutdown already in flight */ });
292
314
  try { unlinkSync(startupFile); } catch { /* best effort */ }
@@ -296,10 +318,25 @@ if (args[0] === "resolve") {
296
318
  console.log(` ${totalRounds} round${totalRounds !== 1 ? "s" : ""} · ${totalComments} comment${totalComments !== 1 ? "s" : ""} addressed`);
297
319
  console.log(` Revised document: ${resolved}`);
298
320
  console.log(`${line}`);
321
+
322
+ // Print the full comment threads so the launching agent — which has no
323
+ // live channel to the inline review agent — sees everything the reviewer
324
+ // said, including escalated feedback meant for it.
325
+ let escalations: import("./reviewSummary").EscalationItem[] = [];
326
+ try {
327
+ const { loadSidecar } = await import("./sidecar");
328
+ const sidecar = await loadSidecar(resolved);
329
+ escalations = collectEscalations(sidecar);
330
+ console.log(`\n${formatReviewSummary(sidecar)}`);
331
+ } catch (e) {
332
+ console.error("[redline] Failed to build review summary:", e);
333
+ }
334
+
299
335
  // Machine-greppable result line for a calling agent. Keep this stable.
300
- console.log(`REDLINE_RESULT: approved file=${resolved} rounds=${totalRounds} comments=${totalComments}`);
336
+ const escSuffix = escalations.length ? ` escalations=${escalations.length}` : "";
337
+ console.log(`\nREDLINE_RESULT: approved file=${resolved} rounds=${totalRounds} comments=${totalComments}${escSuffix}`);
301
338
  console.log("");
302
- writeResult({ status: "approved", file: resolved, rounds: totalRounds, comments: totalComments })
339
+ writeResult({ status: "approved", file: resolved, rounds: totalRounds, comments: totalComments, escalations })
303
340
  .finally(() => process.exit(0));
304
341
  });
305
342
 
@@ -1,4 +1,4 @@
1
- import { escapeHtml, latestVerdict, type ClientComment, type ThreadEntry } from "./lib";
1
+ import { escapeHtml, isEscalated, latestVerdict, type ClientComment, type ThreadEntry } from "./lib";
2
2
  import { state } from "./state";
3
3
 
4
4
  export type CardCallbacks = {
@@ -41,6 +41,12 @@ export function buildCommentCard(comment: ClientComment): HTMLDivElement {
41
41
  quote.appendChild(vbadge);
42
42
  }
43
43
  }
44
+ if (isEscalated(comment)) {
45
+ const ebadge = document.createElement("span");
46
+ ebadge.className = "escalate-badge";
47
+ ebadge.textContent = "↑ Escalated";
48
+ quote.appendChild(ebadge);
49
+ }
44
50
  quote.appendChild(document.createTextNode('"' + comment.quote + '"'));
45
51
  card.appendChild(quote);
46
52
 
@@ -166,6 +172,9 @@ function buildThreadEntry(entry: ThreadEntry, isLatestVerdict: boolean): HTMLDiv
166
172
  const reason = entry.revision_reason ? escapeHtml(entry.revision_reason) : "edit queued";
167
173
  verdictHtml = `<div class="verdict revise"><span class="verdict-icon">\u270E</span><span>${reason}</span></div>`;
168
174
  }
175
+ if (role === "agent" && entry.escalate === true) {
176
+ verdictHtml += `<div class="verdict escalate"><span class="verdict-icon">\u2191</span><span>Routed to the launching agent</span></div>`;
177
+ }
169
178
  const messageHtml = entry.messageHtml ?? escapeHtml(entry.message);
170
179
  div.innerHTML = `
171
180
  <div class="thread-role ${role}">${escapeHtml(label)}</div>
package/src/client/lib.ts CHANGED
@@ -22,6 +22,7 @@ export type ThreadEntry = {
22
22
  messageHtml?: string;
23
23
  requires_revision?: boolean;
24
24
  revision_reason?: string;
25
+ escalate?: boolean;
25
26
  };
26
27
 
27
28
  export function escapeHtml(s: string): string {
@@ -44,6 +45,11 @@ export function latestVerdict(comment: ClientComment): "revise" | "accept" | nul
44
45
  return null;
45
46
  }
46
47
 
48
+ // True when an agent reply flagged this comment for the launching agent.
49
+ export function isEscalated(comment: ClientComment): boolean {
50
+ return (comment.thread || []).some((e) => e.role === "agent" && e.escalate === true);
51
+ }
52
+
47
53
  export function nearestCell(node: Node): HTMLElement | null {
48
54
  const el = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
49
55
  return el && (el as Element).closest ? ((el as Element).closest("td, th") as HTMLElement | null) : null;
@@ -86,63 +92,138 @@ export type Captured = {
86
92
  context_after: string;
87
93
  };
88
94
 
89
- // Walk the prose container's text nodes, locate the selection's start, and
90
- // return the quote with surrounding context. Returns null when the selection
91
- // can't be relocated against flat text (e.g. crossed an <img>).
92
- export function captureSelection(prose: Element, sel: Selection, text: string): Captured | null {
93
- const range = sel.getRangeAt(0);
95
+ type FlatSegment = {
96
+ node: Text | HTMLImageElement;
97
+ start: number;
98
+ len: number;
99
+ isImg: boolean;
100
+ };
94
101
 
95
- const walker = (prose.ownerDocument || document).createTreeWalker(prose, NodeFilter.SHOW_TEXT);
96
- const segments: { node: Text; start: number }[] = [];
102
+ // An <img> contributes no characters of its own, so on its own it can't be
103
+ // anchored against. We give it a presence in the flat text as an
104
+ // `[image: alt]` token — the same shape the image-only comment path uses — so
105
+ // a selection can run text → image → text and still round-trip.
106
+ function imgToken(img: HTMLImageElement): string {
107
+ return "[image: " + (img.alt || "") + "]";
108
+ }
109
+
110
+ // Walk a container into a flat string plus the segments that produced it.
111
+ // Text nodes contribute their value; <img> elements contribute an
112
+ // `[image: alt]` token. Segments are in document order. captureSelection and
113
+ // highlightText both build flat through here so their coordinates agree.
114
+ function buildFlat(container: Element): { flat: string; segments: FlatSegment[] } {
115
+ const doc = container.ownerDocument || document;
116
+ const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
117
+ acceptNode(n: Node) {
118
+ if (n.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT;
119
+ return (n as Element).tagName === "IMG" ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
120
+ },
121
+ });
122
+ const segments: FlatSegment[] = [];
97
123
  let flat = "";
98
124
  let node: Node | null;
99
125
  while ((node = walker.nextNode())) {
100
- const tn = node as Text;
101
- segments.push({ node: tn, start: flat.length });
102
- flat += tn.nodeValue ?? "";
103
- }
104
-
105
- let quoteStart = -1;
106
- for (const seg of segments) {
107
- if (seg.node === range.startContainer) {
108
- quoteStart = seg.start + range.startOffset;
109
- break;
126
+ if (node.nodeType === Node.TEXT_NODE) {
127
+ const v = (node as Text).nodeValue ?? "";
128
+ segments.push({ node: node as Text, start: flat.length, len: v.length, isImg: false });
129
+ flat += v;
130
+ } else {
131
+ const img = node as HTMLImageElement;
132
+ const tok = imgToken(img);
133
+ segments.push({ node: img, start: flat.length, len: tok.length, isImg: true });
134
+ flat += tok;
110
135
  }
111
136
  }
137
+ return { flat, segments };
138
+ }
112
139
 
113
- if (quoteStart === -1) {
114
- return { quote: text, context_before: "", context_after: "" };
115
- }
140
+ // Map both range endpoints onto the flat text and return the quote with
141
+ // surrounding context. Returns null only when the selection can't be resolved
142
+ // against the flat text at all.
143
+ //
144
+ // The quote is sliced straight out of `flat` rather than reconstructed from
145
+ // `sel.toString()`. sel.toString() joins blocks with "\n"/"\n\n" separators
146
+ // that don't exist in the walker's output, and marked emits stray whitespace
147
+ // text nodes between block tags — so the two never line up across a block
148
+ // boundary. Working in flat coordinates sidesteps the mismatch: `flat` is the
149
+ // single source of truth, and highlightText re-finds the quote against the
150
+ // same flat string later. `text` is kept only as a last-resort fallback for
151
+ // the rare case where an endpoint can't be resolved.
152
+ export function captureSelection(prose: Element, sel: Selection, text: string): Captured | null {
153
+ const range = sel.getRangeAt(0);
154
+ const { flat, segments } = buildFlat(prose);
155
+ if (segments.length === 0) return null;
116
156
 
117
- // Block boundaries: sel.toString() inserts "\n\n" between blocks (e.g. a
118
- // heading followed by a paragraph), but the text-node walker concatenates
119
- // with no separator. Strip those runs from `text` so it can match `flat`.
120
- // We keep single \n (which can appear inside a single text node, e.g. a
121
- // code block) untouched.
122
- const normalized = text.replace(/\n{2,}/g, "");
157
+ // Map a range boundary point onto an index into `flat`. Text-node boundaries
158
+ // are the common case; Chrome sometimes anchors a drag to an element node
159
+ // plus a child index, which we resolve to the nearest segment edge.
160
+ const pointToFlat = (container: Node, offset: number, side: "start" | "end"): number => {
161
+ for (const seg of segments) {
162
+ if (seg.node === container) {
163
+ if (seg.isImg) return offset > 0 ? seg.start + seg.len : seg.start;
164
+ return seg.start + offset;
165
+ }
166
+ }
167
+ if (container.nodeType === Node.TEXT_NODE) return -1;
168
+ const kids = container.childNodes;
169
+ const segEnd = (seg: FlatSegment) => seg.start + seg.len;
170
+ if (side === "start") {
171
+ const ref = offset < kids.length ? kids[offset]! : null;
172
+ if (ref) {
173
+ for (const seg of segments) {
174
+ if (ref === seg.node || ref.contains(seg.node)) return seg.start;
175
+ if (ref.compareDocumentPosition(seg.node) & Node.DOCUMENT_POSITION_FOLLOWING)
176
+ return seg.start;
177
+ }
178
+ return flat.length;
179
+ }
180
+ let end = -1;
181
+ for (const seg of segments) if (container.contains(seg.node)) end = segEnd(seg);
182
+ return end === -1 ? flat.length : end;
183
+ }
184
+ const ref = offset > 0 ? kids[offset - 1]! : null;
185
+ if (ref) {
186
+ let end = -1;
187
+ for (const seg of segments) {
188
+ if (
189
+ ref === seg.node ||
190
+ ref.contains(seg.node) ||
191
+ ref.compareDocumentPosition(seg.node) & Node.DOCUMENT_POSITION_PRECEDING
192
+ ) {
193
+ end = segEnd(seg);
194
+ }
195
+ }
196
+ return end === -1 ? 0 : end;
197
+ }
198
+ for (const seg of segments) if (container.contains(seg.node)) return seg.start;
199
+ return 0;
200
+ };
123
201
 
124
- const slice = flat.slice(quoteStart, quoteStart + normalized.length);
125
- if (slice !== normalized) {
126
- // Trimming in the caller can shift the true start a few chars away from
127
- // range.startOffset. Search a small window around quoteStart for the
128
- // normalized text before giving up.
129
- const windowStart = Math.max(0, quoteStart - 64);
130
- const windowEnd = Math.min(flat.length, quoteStart + normalized.length + 64);
131
- const found = flat.indexOf(normalized, windowStart);
132
- if (found === -1 || found >= windowEnd) return null;
133
- quoteStart = found;
202
+ let flatStart = pointToFlat(range.startContainer, range.startOffset, "start");
203
+ let flatEnd = pointToFlat(range.endContainer, range.endOffset, "end");
204
+ if (flatStart === -1 || flatEnd === -1 || flatEnd <= flatStart) {
205
+ return text ? { quote: text, context_before: "", context_after: "" } : null;
134
206
  }
135
207
 
208
+ // Trim whitespace overshoot: a drag that ends a hair past a block — or
209
+ // starts in the gap before one — shouldn't fail or carry stray newlines.
210
+ // This is the "clamp": a small overshoot anchors to what the user meant.
211
+ while (flatStart < flatEnd && /\s/.test(flat[flatStart]!)) flatStart++;
212
+ while (flatEnd > flatStart && /\s/.test(flat[flatEnd - 1]!)) flatEnd--;
213
+ if (flatEnd <= flatStart) return null;
214
+
136
215
  return {
137
- quote: normalized,
138
- context_before: flat.slice(Math.max(0, quoteStart - 32), quoteStart),
139
- context_after: flat.slice(quoteStart + normalized.length, quoteStart + normalized.length + 32),
216
+ quote: flat.slice(flatStart, flatEnd),
217
+ context_before: flat.slice(Math.max(0, flatStart - 32), flatStart),
218
+ context_after: flat.slice(flatEnd, flatEnd + 32),
140
219
  };
141
220
  }
142
221
 
143
222
  // Wrap occurrences of `text` inside `container` with <mark> elements. Uses
144
- // `contextBefore` to disambiguate when a quote appears multiple times.
145
- // Image quotes (`[image: alt]`) wrap the matching <img> instead.
223
+ // `contextBefore` to disambiguate when a quote appears multiple times. The
224
+ // quote is matched against the same flat text captureSelection produced, so
225
+ // `[image: alt]` tokens in the quote wrap the corresponding <img> — whether
226
+ // the quote is image-only or text mixed with an image.
146
227
  // Returns the marks created (caller can attach event listeners).
147
228
  export function highlightText(
148
229
  container: Element,
@@ -154,35 +235,9 @@ export function highlightText(
154
235
  const doc = container.ownerDocument || document;
155
236
  const marks: HTMLElement[] = [];
156
237
 
157
- const imgMatch = text.match(/^\[image:\s*(.*)\]$/);
158
- if (imgMatch) {
159
- const alt = imgMatch[1];
160
- const imgs = container.querySelectorAll("img");
161
- for (const img of imgs) {
162
- if ((img.alt || "") === alt) {
163
- const mark = doc.createElement("mark");
164
- mark.className = "rl-highlight rl-img" + (resolved ? " resolved" : "");
165
- mark.dataset.commentId = id;
166
- img.parentNode!.insertBefore(mark, img);
167
- mark.appendChild(img);
168
- marks.push(mark);
169
- return marks;
170
- }
171
- }
172
- return marks;
173
- }
174
-
175
238
  (container as HTMLElement).normalize();
176
239
 
177
- const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT);
178
- const segments: { node: Text; start: number }[] = [];
179
- let flat = "";
180
- let node: Node | null;
181
- while ((node = walker.nextNode())) {
182
- const tn = node as Text;
183
- segments.push({ node: tn, start: flat.length });
184
- flat += tn.nodeValue ?? "";
185
- }
240
+ const { flat, segments } = buildFlat(container);
186
241
 
187
242
  let quoteStart = -1;
188
243
  if (contextBefore) {
@@ -194,18 +249,28 @@ export function highlightText(
194
249
 
195
250
  const quoteEnd = quoteStart + text.length;
196
251
 
197
- const toWrap: { node: Text; localStart: number; localEnd: number }[] = [];
198
252
  for (const seg of segments) {
199
- const segEnd = seg.start + (seg.node.nodeValue?.length ?? 0);
200
- if (segEnd <= quoteStart || seg.start >= quoteEnd) continue;
201
- toWrap.push({
202
- node: seg.node,
203
- localStart: Math.max(0, quoteStart - seg.start),
204
- localEnd: Math.min(seg.node.nodeValue?.length ?? 0, quoteEnd - seg.start),
205
- });
206
- }
253
+ const segStart = seg.start;
254
+ const segEnd = seg.start + seg.len;
255
+ if (segEnd <= quoteStart || segStart >= quoteEnd) continue;
256
+
257
+ if (seg.isImg) {
258
+ // Image tokens are atomic wrap the <img> only when the quote covers
259
+ // the whole token, never on a partial overlap.
260
+ if (segStart < quoteStart || segEnd > quoteEnd) continue;
261
+ const img = seg.node as HTMLImageElement;
262
+ const mark = doc.createElement("mark");
263
+ mark.className = "rl-highlight rl-img" + (resolved ? " resolved" : "");
264
+ mark.dataset.commentId = id;
265
+ img.parentNode!.insertBefore(mark, img);
266
+ mark.appendChild(img);
267
+ marks.push(mark);
268
+ continue;
269
+ }
207
270
 
208
- for (const { node: tn, localStart, localEnd } of toWrap) {
271
+ const tn = seg.node as Text;
272
+ const localStart = Math.max(0, quoteStart - segStart);
273
+ const localEnd = Math.min(seg.len, quoteEnd - segStart);
209
274
  const mark = doc.createElement("mark");
210
275
  mark.className = "rl-highlight" + (resolved ? " resolved" : "");
211
276
  mark.dataset.commentId = id;
@@ -3,8 +3,9 @@ import {
3
3
  highlightText as _highlightText,
4
4
  preserveScroll as _preserveScroll,
5
5
  latestVerdict,
6
+ isEscalated,
6
7
  } from "./lib";
7
- import { state, apiFetch, showError } from "./state";
8
+ import { state, apiFetch, showError, reportMutationFailure } from "./state";
8
9
  import {
9
10
  buildCommentCard,
10
11
  captureTypingState,
@@ -171,7 +172,7 @@ export async function saveComment(
171
172
  showError(data.error || "Failed to save comment");
172
173
  }
173
174
  } catch (err: unknown) {
174
- showError("Failed to save comment: " + (err as Error).message);
175
+ reportMutationFailure("Failed to save comment", err);
175
176
  }
176
177
  }
177
178
 
@@ -195,7 +196,7 @@ export async function submitReply(id: string, message: string): Promise<void> {
195
196
  showError(data.error || "Failed to save reply");
196
197
  }
197
198
  } catch (err: unknown) {
198
- showError("Failed to save reply: " + (err as Error).message);
199
+ reportMutationFailure("Failed to save reply", err);
199
200
  }
200
201
  }
201
202
 
@@ -220,7 +221,7 @@ export async function resolveComment(id: string): Promise<void> {
220
221
  showError(data.error || "Failed to resolve comment");
221
222
  }
222
223
  } catch (err: unknown) {
223
- showError("Failed to resolve: " + (err as Error).message);
224
+ reportMutationFailure("Failed to resolve", err);
224
225
  }
225
226
  }
226
227
 
@@ -240,7 +241,7 @@ export async function reopenComment(id: string): Promise<void> {
240
241
  showError(data.error || "Failed to reopen comment");
241
242
  }
242
243
  } catch (err: unknown) {
243
- showError("Failed to reopen: " + (err as Error).message);
244
+ reportMutationFailure("Failed to reopen", err);
244
245
  }
245
246
  }
246
247
 
@@ -345,6 +346,17 @@ export function applyRoundState(): void {
345
346
  bannerText = `${reviseCount} of ${total} comments imply edits.`;
346
347
  }
347
348
  banner.textContent = bannerText;
349
+ // Escalations are orthogonal to the revise/accept verdict — a comment
350
+ // can be answered in-thread yet still need the launching agent. Call
351
+ // it out on its own line so it survives all three banner branches.
352
+ const escCount = state.comments.filter(isEscalated).length;
353
+ if (escCount > 0) {
354
+ const escLine = document.createElement("div");
355
+ escLine.className = "banner-escalation";
356
+ escLine.textContent =
357
+ `↑ ${escCount} comment${escCount !== 1 ? "s" : ""} escalated to the launching agent.`;
358
+ banner.appendChild(escLine);
359
+ }
348
360
  banner.style.display = "block";
349
361
  }
350
362
 
@@ -197,7 +197,7 @@ export function initSelectionHandlers(): void {
197
197
 
198
198
  const captured = captureSelection(sel, text);
199
199
  if (!captured) {
200
- showError("Highlight a single passage \u2014 selections that cross images or sections can't be anchored.");
200
+ showError("Couldn't anchor that selection \u2014 try highlighting the passage again.");
201
201
  sel.removeAllRanges();
202
202
  return;
203
203
  }
package/src/client/sse.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { state } from "./state";
1
+ import { state, markSessionEnded } from "./state";
2
2
  import {
3
3
  renderComments,
4
4
  applyHighlights,
@@ -10,6 +10,11 @@ import {
10
10
  let sseHasConnectedOnce = false;
11
11
  let currentEs: EventSource | null = null;
12
12
  let lastEventAt = Date.now();
13
+ // Consecutive failed connection attempts with no successful open in between.
14
+ // A transient blip resolves on the next reconnect (resetting this to 0); a
15
+ // dead server never reconnects, so a sustained run means the server is gone.
16
+ let consecutiveSseErrors = 0;
17
+ const MAX_SSE_ERRORS = 4;
13
18
 
14
19
  export async function softRefresh({ rehighlight = false } = {}): Promise<void> {
15
20
  try {
@@ -53,6 +58,24 @@ export function initSSE(): void {
53
58
  document.addEventListener("visibilitychange", onVisibleOrFocus);
54
59
  window.addEventListener("focus", onVisibleOrFocus);
55
60
 
61
+ // Tell the server explicitly when this tab is going away, so it can
62
+ // distinguish a real close from a bare SSE drop (sleep, network blip) and
63
+ // not abandon a session the user means to keep. `keepalive` lets the POST
64
+ // survive unload; `pagehide` is more reliable than `beforeunload`. Skip the
65
+ // bfcache case (e.persisted) — the page may be restored and reconnect.
66
+ window.addEventListener("pagehide", (e) => {
67
+ if ((e as PageTransitionEvent).persisted) return;
68
+ try {
69
+ fetch("/api/tab-closed", {
70
+ method: "POST",
71
+ keepalive: true,
72
+ headers: { "X-Redline-Token": state.csrfToken },
73
+ });
74
+ } catch {
75
+ /* unload is best-effort */
76
+ }
77
+ });
78
+
56
79
  setInterval(() => {
57
80
  const banner = document.getElementById("sidebar-status-banner");
58
81
  const revising = banner?.classList.contains("revising");
@@ -74,6 +97,7 @@ export function initSSE(): void {
74
97
  });
75
98
  es.onopen = () => {
76
99
  lastEventAt = Date.now();
100
+ consecutiveSseErrors = 0;
77
101
  if (sseHasConnectedOnce) {
78
102
  softRefresh({ rehighlight: true });
79
103
  }
@@ -173,6 +197,14 @@ export function initSSE(): void {
173
197
  es.onerror = () => {
174
198
  es.close();
175
199
  if (currentEs === es) currentEs = null;
200
+ consecutiveSseErrors += 1;
201
+ // A run of failures with no successful open in between means the server
202
+ // is gone for good (it exited, or restarted on a fresh port this tab
203
+ // can't reach). Stop the silent retry loop and tell the user.
204
+ if (consecutiveSseErrors >= MAX_SSE_ERRORS) {
205
+ markSessionEnded();
206
+ return;
207
+ }
176
208
  setTimeout(connectEvents, 3000);
177
209
  };
178
210
  })();
@@ -25,6 +25,7 @@ export const state = {
25
25
  pendingSelection: null as PendingSelection | null,
26
26
  navIdx: 0,
27
27
  deliberateScrollUntil: 0,
28
+ sessionEnded: false,
28
29
  };
29
30
 
30
31
  export type PendingSelection = {
@@ -54,3 +55,24 @@ export function showError(msg: string): void {
54
55
  el.style.display = "block";
55
56
  setTimeout(() => (el.style.display = "none"), 4000);
56
57
  }
58
+
59
+ // The redline server has exited (session ended, or process killed). This tab
60
+ // can no longer do anything useful — show a persistent banner instead of
61
+ // letting actions fail with a cryptic "Failed to fetch". Idempotent.
62
+ export function markSessionEnded(): void {
63
+ if (state.sessionEnded) return;
64
+ state.sessionEnded = true;
65
+ const el = document.getElementById("session-ended-banner");
66
+ if (el) el.style.display = "block";
67
+ }
68
+
69
+ // A fetch network failure (TypeError) on a mutating request means the server
70
+ // is unreachable — treat it as a definitively ended session. A non-network
71
+ // failure (server replied with an error) is shown as a transient toast.
72
+ export function reportMutationFailure(action: string, err: unknown): void {
73
+ if (err instanceof TypeError) {
74
+ markSessionEnded();
75
+ } else {
76
+ showError(action + ": " + (err as Error).message);
77
+ }
78
+ }
@@ -569,6 +569,7 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
569
569
  line-height: 1.5;
570
570
  }
571
571
  .verdict.revise { color: #92400e; }
572
+ .verdict.escalate { color: #5b21b6; }
572
573
 
573
574
  /* Warm-tinted resolve button when the latest verdict implies an edit */
574
575
  .btn-resolve-comment.revise {
@@ -596,6 +597,22 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
596
597
  .verdict-badge.revise { background: #fef3c7; color: #92400e; }
597
598
  .verdict-badge.accept { background: #e5e7eb; color: var(--text-muted); }
598
599
 
600
+ /* Escalation badge on the quote line — comment routed to the launching agent */
601
+ .escalate-badge {
602
+ display: inline-flex;
603
+ align-items: center;
604
+ margin-left: 6px;
605
+ padding: 1px 6px;
606
+ font-size: 10.5px;
607
+ font-weight: 600;
608
+ text-transform: uppercase;
609
+ letter-spacing: 0.04em;
610
+ border-radius: 3px;
611
+ font-style: normal;
612
+ background: #ede9fe;
613
+ color: #5b21b6;
614
+ }
615
+
599
616
  /* Round-level secondary action (under the primary banner button) */
600
617
  .round-secondary {
601
618
  margin-top: 8px;
@@ -786,6 +803,16 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
786
803
  align-items: center;
787
804
  gap: 8px;
788
805
  }
806
+ /* Escalation sub-line under the round banner — purple to match the
807
+ ↑ Escalated comment badges, distinct from the green verdict copy. */
808
+ .banner-escalation {
809
+ margin-top: 6px;
810
+ padding-top: 6px;
811
+ border-top: 1px solid #a5d6a7;
812
+ color: #5b21b6;
813
+ font-size: 12.5px;
814
+ font-weight: 600;
815
+ }
789
816
  #sidebar-status-banner.revising {
790
817
  background: #fff3e0;
791
818
  border-bottom-color: #ffb74d;
package/src/parseReply.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // text into the UI. The delimiter form needs no escaping:
8
8
  //
9
9
  // REQUIRES_REVISION: <true|false>
10
+ // ESCALATE: <true|false> (optional — absent means false)
10
11
  // REASON: <one short sentence, or empty>
11
12
  // ---MESSAGE---
12
13
  // <free-form prose, may contain anything>
@@ -25,6 +26,10 @@ export interface ParsedReply {
25
26
  message: string;
26
27
  requires_revision: boolean;
27
28
  reason: string;
29
+ // True when the agent flagged the comment for the launching ("outer") agent
30
+ // — something it couldn't act on from inside the review. Optional in the
31
+ // envelope; absent means false.
32
+ escalate: boolean;
28
33
  }
29
34
 
30
35
  function tryDelimiterEnvelope(s: string): ParsedReply | null {
@@ -37,11 +42,13 @@ function tryDelimiterEnvelope(s: string): ParsedReply | null {
37
42
 
38
43
  const reqMatch = header.match(/REQUIRES_REVISION\s*:\s*(true|false)\b/i);
39
44
  const reasonMatch = header.match(/REASON\s*:\s*(.*?)\s*(?:\n|$)/i);
45
+ const escMatch = header.match(/ESCALATE\s*:\s*(true|false)\b/i);
40
46
 
41
47
  return {
42
48
  message,
43
49
  requires_revision: reqMatch ? reqMatch[1].toLowerCase() === "true" : true,
44
50
  reason: reasonMatch ? reasonMatch[1].trim() : "",
51
+ escalate: escMatch ? escMatch[1].toLowerCase() === "true" : false,
45
52
  };
46
53
  }
47
54
 
@@ -93,6 +100,7 @@ export function parseReply(raw: string): ParsedReply {
93
100
  message: obj.message.trim(),
94
101
  requires_revision: obj.requires_revision !== false, // default true if missing/non-bool
95
102
  reason: typeof obj.reason === "string" ? obj.reason.trim() : "",
103
+ escalate: obj.escalate === true,
96
104
  };
97
105
  }
98
106
  } catch { /* fall through */ }
@@ -111,5 +119,5 @@ export function parseReply(raw: string): ParsedReply {
111
119
  if (obj) return obj;
112
120
  }
113
121
 
114
- return { message: trimmed, requires_revision: true, reason: "" };
122
+ return { message: trimmed, requires_revision: true, reason: "", escalate: false };
115
123
  }
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
- const exitCode = await proc.exited;
160
- const revisionDurationMs = Date.now() - revisionStartedAt;
161
- console.log("\n" + "".repeat(60));
162
- console.log(`Model: ${chosenModel} · Duration: ${(revisionDurationMs / 1000).toFixed(1)}s · Exit: ${exitCode}`);
163
- console.log("─".repeat(60) + "\n");
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
- if (exitCode !== 0) {
178
- await fail(`claude CLI exited with code ${exitCode}${stderrText.trim() ? ` ${stderrText.trim().split("\n").slice(-3).join(" | ")}` : ""}`);
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
- // Validate output. Strip a wrapping code fence if present, a preamble before the
182
- // first heading, any <document> wrapper tags, and any meta-sections the model
183
- // sometimes appends (Settled comments, Previously agreed changes, Changelog).
184
- let trimmed = revised.trim()
185
- .replace(/^```(?:markdown)?\n([\s\S]*)\n```$/, "$1")
186
- .replace(/^<document>\s*/i, "")
187
- .replace(/\s*<\/document>\s*$/i, "")
188
- .trim();
189
- // Strip trailing meta-sections at any heading level (## or ###).
190
- const metaHeading = trimmed.match(/\n#{2,3} (Settled comments|Previously agreed changes|Changelog|Revision notes)\b/i);
191
- if (metaHeading) {
192
- console.log(`Stripping trailing meta-section: ${metaHeading[1]}`);
193
- trimmed = trimmed.slice(0, metaHeading.index).trimEnd();
194
- // Strip a trailing horizontal rule that often precedes the meta-section.
195
- trimmed = trimmed.replace(/\n+---\s*$/, "").trimEnd();
196
- }
197
- if (!trimmed) {
198
- await fail("Agent returned empty output (no text deltas streamed)");
199
- }
200
- if (!trimmed.startsWith("#")) {
201
- const firstHeadingIdx = trimmed.search(/^# /m);
202
- if (firstHeadingIdx > 0) {
203
- console.log("Stripping preamble before first heading.");
204
- trimmed = trimmed.slice(firstHeadingIdx).trim();
205
- } else {
206
- await fail("Output contains no Markdown heading — model returned non-document content");
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
+ }
@@ -97,6 +97,7 @@ function pageTemplate(
97
97
  </div>
98
98
  </div>
99
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>
100
101
 
101
102
  <script>
102
103
  window.__REDLINE__ = {
package/src/server.ts CHANGED
@@ -129,12 +129,23 @@ export function createServer(
129
129
 
130
130
  // Abandonment detection: if no browser is connected for ABANDON_GRACE_MS after
131
131
  // the first one ever connected, fire onAbandonCallback so the CLI can exit.
132
- // Default 10min — DevTools-offline debugging, brief network blips, and tab
133
- // sleeps all reconnect well within that. The previous 2min default tripped on
134
- // routine offline-mode testing. Override with REDLINE_ABANDON_MS for tests.
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.
135
139
  const ABANDON_GRACE_MS = process.env.REDLINE_ABANDON_MS
136
140
  ? parseInt(process.env.REDLINE_ABANDON_MS, 10)
137
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;
138
149
  let hadBrowser = false;
139
150
  let abandonTimer: ReturnType<typeof setTimeout> | null = null;
140
151
  let onAbandonCallback: (() => void) | undefined;
@@ -194,15 +205,24 @@ export function createServer(
194
205
  }, REVISION_TIMEOUT_MS);
195
206
  }
196
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
+
197
220
  function checkBrowserPresence() {
198
221
  if (browserClients.size > 0) {
199
222
  hadBrowser = true;
200
223
  if (abandonTimer) { clearTimeout(abandonTimer); abandonTimer = null; }
201
224
  } else if (hadBrowser && !abandonTimer) {
202
- abandonTimer = setTimeout(() => {
203
- console.log(`\n[redline] No browser connected for ${ABANDON_GRACE_MS / 1000}s — assuming abandoned.`);
204
- onAbandonCallback?.();
205
- }, ABANDON_GRACE_MS);
225
+ armAbandonTimer(ABANDON_GRACE_MS);
206
226
  }
207
227
  }
208
228
 
@@ -424,6 +444,17 @@ export function createServer(
424
444
  return c.json({ ok: true });
425
445
  });
426
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
+
427
458
  // Agent signals it is composing a reply (shows typing indicator in thread)
428
459
  app.post("/api/comment/:id/thinking", async (c) => {
429
460
  const id = c.req.param("id");
@@ -440,6 +471,7 @@ export function createServer(
440
471
  name?: string;
441
472
  requires_revision?: boolean;
442
473
  revision_reason?: string;
474
+ escalate?: boolean;
443
475
  }>();
444
476
  if (!body.message?.trim()) return c.json({ ok: false, error: "message is required" }, 400);
445
477
  const role = (body.role === "human" ? "human" : "agent") as "human" | "agent";
@@ -453,9 +485,12 @@ export function createServer(
453
485
  const entry: import("./sidecar").ThreadEntry = { role, message: body.message.trim(), at: new Date().toISOString() };
454
486
  if (name) entry.name = name;
455
487
  // Verdict only meaningful on agent replies; ignore on human entries.
456
- if (role === "agent" && typeof body.requires_revision === "boolean") {
457
- entry.requires_revision = body.requires_revision;
458
- if (body.revision_reason?.trim()) entry.revision_reason = body.revision_reason.trim();
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;
459
494
  }
460
495
  comment.thread.push(entry);
461
496
  return { skip: false as const, roundNumber: round.round, comment };
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: