@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/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
+ }
@@ -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, "&amp;")
@@ -11,7 +9,7 @@ function escapeHtml(s: string): string {
11
9
  function pageTemplate(
12
10
  title: string,
13
11
  content: string,
14
- comments: Comment[],
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 — DevTools-offline debugging, brief network blips, and tab
117
- // sleeps all reconnect well within that. The previous 2min default tripped on
118
- // routine offline-mode testing. Override with REDLINE_ABANDON_MS for tests.
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
- abandonTimer = setTimeout(() => {
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" && typeof body.requires_revision === "boolean") {
441
- entry.requires_revision = body.requires_revision;
442
- if (body.revision_reason?.trim()) entry.revision_reason = body.revision_reason.trim();
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: