@levistudio/redline 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  }
@@ -168,11 +192,19 @@ export function initSSE(): void {
168
192
  });
169
193
  on("finished", () => {
170
194
  document.body.innerHTML =
171
- '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:system-ui;flex-direction:column;gap:16px;color:#374151"><div style="font-size:48px">\u2713</div><div style="font-size:20px;font-weight:600">Review complete</div><div style="color:#6b7280">You can close this tab and continue in Claude Code.</div></div>';
195
+ '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:system-ui;flex-direction:column;gap:16px;color:#374151"><div style="font-size:48px">\u2713</div><div style="font-size:20px;font-weight:600">Review complete</div><div style="color:#6b7280">You can close this tab and continue in your agent environment.</div></div>';
172
196
  });
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/pickModel.ts CHANGED
@@ -1,12 +1,14 @@
1
- // Heuristic for picking which Claude model to use based on the human's message.
2
- // Short / simple Haiku. Long, question, or "involved" keyword → Sonnet.
1
+ // Heuristic for picking how much model capacity to use based on the human's
2
+ // message. Providers map these tiers to concrete model ids.
3
3
  //
4
4
  // Used in two places:
5
5
  // - agent.ts picks per-reply, looking at the last human message in the thread
6
6
  // - resolve.ts picks per-revision, scanning every human message across settled comments
7
7
 
8
- export const FAST_MODEL = "claude-haiku-4-5-20251001";
9
- export const SMART_MODEL = "claude-sonnet-4-6";
8
+ export type ModelTier = "fast" | "smart";
9
+
10
+ export const FAST_MODEL: ModelTier = "fast";
11
+ export const SMART_MODEL: ModelTier = "smart";
10
12
 
11
13
  export const REPLY_INVOLVED_PATTERNS =
12
14
  /\b(what|why|how|which|who|suggest|suggestion|alternative|option|idea|propose|explain|think|consider|recommend|help|could|would|should)\b/i;
@@ -1,5 +1,5 @@
1
1
  // Per-prompt envelope around user-controlled strings before they reach
2
- // `claude -p`. Same delimiter-over-JSON principle the agent reply path
2
+ // the selected local agent provider. Same delimiter-over-JSON principle the agent reply path
3
3
  // already uses (see retro entry 2026-05-07 — "delimiter envelope for agent
4
4
  // replies"), applied to the *input* side: comment text, document body,
5
5
  // thread messages.
package/src/resolve.ts CHANGED
@@ -1,15 +1,17 @@
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";
8
+ import { getAgentProvider, resolveProviderId, type AgentProviderId } from "./agentProvider";
8
9
 
9
10
  const serverBase = () => `http://localhost:${process.env.REDLINE_PORT ?? "3000"}`;
10
11
  const csrfHeader = (): Record<string, string> => ({ "X-Redline-Token": process.env.REDLINE_TOKEN ?? "" });
11
12
 
12
- export async function resolve(filePath: string, options: { model?: string } = {}) {
13
+ export async function resolve(filePath: string, options: { model?: string; agentProvider?: AgentProviderId } = {}) {
14
+ const provider = getAgentProvider(options.agentProvider ?? resolveProviderId());
13
15
  const model = options.model ?? null;
14
16
  const sidecar = await loadSidecar(filePath);
15
17
 
@@ -20,7 +22,7 @@ export async function resolve(filePath: string, options: { model?: string } = {}
20
22
  }
21
23
  const round: Round = resolvedRounds[resolvedRounds.length - 1];
22
24
  const settled = round.comments.filter((c) => c.resolved);
23
- const chosenModel = model ?? pickRevisionModel(settled);
25
+ const chosenModel = model ?? provider.modelForTier(pickRevisionModel(settled));
24
26
 
25
27
  const docText = await readFile(filePath, "utf-8");
26
28
 
@@ -89,42 +91,6 @@ export async function resolve(filePath: string, options: { model?: string } = {}
89
91
  contextBlock(sidecar.context, env) +
90
92
  `<comments-to-apply>\n${commentsBlock}\n</comments-to-apply>${priorChangesBlock}\n\n<document>\n${env.wrap("document", docText)}\n</document>`;
91
93
 
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
- 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
-
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
94
  const broadcastChunk = (text: string, kind: "thinking" | "text") => {
129
95
  fetch(`${serverBase()}/api/revision-chunk`, {
130
96
  method: "POST",
@@ -132,40 +98,17 @@ export async function resolve(filePath: string, options: { model?: string } = {}
132
98
  body: JSON.stringify({ text, kind }),
133
99
  }).catch(() => {});
134
100
  };
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
101
 
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");
102
+ // Per-attempt state, hoisted so fail() can report whatever the latest
103
+ // attempt produced.
104
+ let revised = "";
105
+ let exitCode = 0;
106
+ let stderrText = "";
164
107
 
165
108
  const fail = async (reason: string) => {
166
109
  await logRevisionFailure(filePath, {
167
110
  reason,
168
- model: chosenModel,
111
+ model: `${provider.id}/${chosenModel}`,
169
112
  exitCode,
170
113
  stderr: stderrText.trim(),
171
114
  stdoutSample: revised.slice(0, 2000),
@@ -174,37 +117,52 @@ export async function resolve(filePath: string, options: { model?: string } = {}
174
117
  throw new Error(reason);
175
118
  };
176
119
 
177
- if (exitCode !== 0) {
178
- await fail(`claude CLI exited with code ${exitCode}${stderrText.trim() ? ` ${stderrText.trim().split("\n").slice(-3).join(" | ")}` : ""}`);
179
- }
120
+ // A mangled revision is usually a one-off model stumble — Haiku dropping an
121
+ // uncommented section, streaming a preamble, etc. Retry once before giving
122
+ // up. A non-zero CLI exit is NOT retried: that's an environment/auth failure
123
+ // a re-run won't fix.
124
+ const MAX_ATTEMPTS = 2;
125
+ let trimmed = "";
180
126
 
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");
127
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
128
+ console.log(
129
+ attempt === 1
130
+ ? `Revising with ${provider.id}/${chosenModel}...\n`
131
+ : `\nRetrying revision with ${provider.id}/${chosenModel} (attempt ${attempt}/${MAX_ATTEMPTS})...\n`
132
+ );
133
+ console.log("".repeat(60));
134
+ const run = await provider.runRevision({
135
+ systemPrompt,
136
+ userMessage,
137
+ model: chosenModel,
138
+ cwd: process.cwd(),
139
+ onChunk: broadcastChunk,
140
+ });
141
+ revised = run.revised;
142
+ stderrText = run.stderr;
143
+ exitCode = run.exitCode;
144
+ console.log("\n" + "─".repeat(60));
145
+ console.log(`Model: ${provider.id}/${chosenModel} · Duration: ${(run.durationMs / 1000).toFixed(1)}s · Exit: ${exitCode}`);
146
+ console.log("─".repeat(60) + "\n");
147
+
148
+ // A CLI crash is not retryable — fail immediately.
149
+ if (exitCode !== 0) {
150
+ await fail(`${provider.id} CLI exited with code ${exitCode}${stderrText.trim() ? ` — ${stderrText.trim().split("\n").slice(-3).join(" | ")}` : ""}`);
151
+ }
152
+
153
+ const result = validateRevision(revised, docText, settled);
154
+ if (result.ok) {
155
+ trimmed = result.doc;
156
+ break;
157
+ }
158
+
159
+ // Validation failed — the model returned mangled output. Retry once; on
160
+ // the last attempt, log and throw so the session surfaces the error.
161
+ if (attempt < MAX_ATTEMPTS) {
162
+ console.log(`Revision output rejected: ${result.reason}`);
163
+ continue;
207
164
  }
165
+ await fail(result.reason);
208
166
  }
209
167
 
210
168
  // If the model made no changes, skip the write and signal the browser
@@ -230,6 +188,85 @@ export async function resolve(filePath: string, options: { model?: string } = {}
230
188
  } catch { /* server may not be running — non-fatal */ }
231
189
  }
232
190
 
191
+ const HEADING_RE = /^#{1,6} .+$/gm;
192
+
193
+ // Headings present in `inputDoc` but missing from `outputDoc` whose section the
194
+ // reviewer never commented on. A comment quoting text inside a section means
195
+ // the reviewer was working there, so dropping/reworking it is authorized; a
196
+ // section vanishing with no comment near it means the model mangled the doc.
197
+ function droppedSections(inputDoc: string, outputDoc: string, settled: Comment[]): string[] {
198
+ const outHeadings = new Set(outputDoc.match(HEADING_RE) ?? []);
199
+ const inMatches = [...inputDoc.matchAll(HEADING_RE)];
200
+ const quotes = settled.map((c) => c.quote.trim()).filter((q) => q.length > 0);
201
+
202
+ const unauthorized: string[] = [];
203
+ for (let i = 0; i < inMatches.length; i++) {
204
+ const heading = inMatches[i]![0];
205
+ if (outHeadings.has(heading)) continue;
206
+ // This heading's section runs from the heading to the next one (or EOF).
207
+ const start = inMatches[i]!.index!;
208
+ const end = i + 1 < inMatches.length ? inMatches[i + 1]!.index! : inputDoc.length;
209
+ const section = inputDoc.slice(start, end);
210
+ const commented = quotes.some((q) => section.includes(q));
211
+ if (!commented) unauthorized.push(heading);
212
+ }
213
+ return unauthorized;
214
+ }
215
+
216
+ // Validate (and lightly normalize) a revision pass's raw output. Pure — the
217
+ // retry loop and tests both drive it. Returns the cleaned document on success,
218
+ // or a human-readable reason on failure.
219
+ export function validateRevision(
220
+ revised: string,
221
+ inputDoc: string,
222
+ settled: Comment[]
223
+ ): { ok: true; doc: string } | { ok: false; reason: string } {
224
+ // Strip a wrapping code fence and any <document> wrapper tags the model
225
+ // sometimes includes despite the system prompt.
226
+ let trimmed = revised.trim()
227
+ .replace(/^```(?:markdown)?\n([\s\S]*)\n```$/, "$1")
228
+ .replace(/^<document>\s*/i, "")
229
+ .replace(/\s*<\/document>\s*$/i, "")
230
+ .trim();
231
+
232
+ // Strip a trailing meta-section the model sometimes appends (Settled
233
+ // comments, Changelog, …), plus a horizontal rule that often precedes it.
234
+ const metaHeading = trimmed.match(/\n#{2,3} (Settled comments|Previously agreed changes|Changelog|Revision notes)\b/i);
235
+ if (metaHeading) {
236
+ trimmed = trimmed.slice(0, metaHeading.index).trimEnd().replace(/\n+---\s*$/, "").trimEnd();
237
+ }
238
+
239
+ if (!trimmed) {
240
+ return { ok: false, reason: "Revision produced empty output — no text was streamed back" };
241
+ }
242
+
243
+ // If the input had headings, the output should too. Strip a preamble before
244
+ // the first heading; if there's no heading at all, the model returned prose
245
+ // (an apology, a summary) instead of the document. A genuinely heading-less
246
+ // input is left alone — headings can't be the structural anchor there.
247
+ if (/^#{1,6} /m.test(inputDoc) && !/^#{1,6} /.test(trimmed)) {
248
+ const firstHeadingIdx = trimmed.search(/^#{1,6} /m);
249
+ if (firstHeadingIdx > 0) {
250
+ trimmed = trimmed.slice(firstHeadingIdx).trim();
251
+ } else {
252
+ return { ok: false, reason: "Revision output has no Markdown headings — the model returned non-document content, not a revised document" };
253
+ }
254
+ }
255
+
256
+ // Structural integrity: the revision must not silently drop a section the
257
+ // reviewer never commented on.
258
+ const dropped = droppedSections(inputDoc, trimmed, settled);
259
+ if (dropped.length > 0) {
260
+ const list = dropped.map((h) => `"${h}"`).join(", ");
261
+ return {
262
+ ok: false,
263
+ reason: `Revision dropped section${dropped.length > 1 ? "s" : ""} the reviewer never commented on: ${list} — the model mangled the document instead of editing it`,
264
+ };
265
+ }
266
+
267
+ return { ok: true, doc: trimmed };
268
+ }
269
+
233
270
  async function logRevisionFailure(
234
271
  filePath: string,
235
272
  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
+ }
@@ -17,7 +17,8 @@ function pageTemplate(
17
17
  context?: string,
18
18
  readOnly = false,
19
19
  csrfToken = "",
20
- noAgent = false
20
+ noAgent = false,
21
+ agentName = "selected local"
21
22
  ): string {
22
23
  const commentsJson = JSON.stringify(comments);
23
24
 
@@ -51,7 +52,7 @@ function pageTemplate(
51
52
  </span>
52
53
  <div class="header-actions">
53
54
  <span id="agent-status" class="agent-status" hidden></span>
54
- ${noAgent ? `<span class="manual-mode-pill" title="Started with --no-agent. No Claude replies, no revision pass.">Manual mode</span>` : ''}
55
+ ${noAgent ? `<span class="manual-mode-pill" title="Started with --no-agent. No ${escapeHtml(agentName)} replies, no revision pass.">Manual mode</span>` : ''}
55
56
  ${!readOnly && totalRounds > 1 ? `<button class="btn-toggle-diff" id="btn-toggle-diff" type="button" aria-pressed="false">Show changes</button>` : ''}
56
57
  ${readOnly
57
58
  ? `<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>`
@@ -65,7 +66,7 @@ function pageTemplate(
65
66
  </div>` : ''}
66
67
  ${!readOnly ? `<div class="first-run-banner" id="first-run-banner" hidden>
67
68
  <span class="first-run-icon" aria-hidden="true">⚠</span>
68
- <span class="first-run-text">Redline sends document and comment text to your local Claude Code agent. Use trusted docs.</span>
69
+ <span class="first-run-text">Redline sends document and comment text to your ${escapeHtml(agentName)} agent. Use trusted docs.</span>
69
70
  <button class="first-run-dismiss" id="first-run-dismiss" aria-label="Dismiss">Got it</button>
70
71
  </div>` : ''}
71
72
  <article class="prose" id="prose">
@@ -97,6 +98,7 @@ function pageTemplate(
97
98
  </div>
98
99
  </div>
99
100
  <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>
101
+ <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 your agent environment.</div>
100
102
 
101
103
  <script>
102
104
  window.__REDLINE__ = {