@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 +12 -1
- package/package.json +1 -1
- package/src/agent.ts +8 -4
- package/src/cli.ts +41 -4
- package/src/client/cards.ts +10 -1
- package/src/client/lib.ts +143 -78
- package/src/client/render.ts +17 -5
- package/src/client/selection.ts +1 -1
- package/src/client/sse.ts +33 -1
- package/src/client/state.ts +22 -0
- package/src/client/styles.css +27 -0
- package/src/parseReply.ts +9 -1
- package/src/resolve.ts +174 -91
- package/src/reviewSummary.ts +93 -0
- package/src/server-page.ts +1 -0
- package/src/server.ts +45 -10
- package/src/sidecar.ts +5 -0
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.
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/client/cards.ts
CHANGED
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
type FlatSegment = {
|
|
96
|
+
node: Text | HTMLImageElement;
|
|
97
|
+
start: number;
|
|
98
|
+
len: number;
|
|
99
|
+
isImg: boolean;
|
|
100
|
+
};
|
|
94
101
|
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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:
|
|
138
|
-
context_before: flat.slice(Math.max(0,
|
|
139
|
-
context_after: flat.slice(
|
|
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
|
-
//
|
|
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
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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;
|
package/src/client/render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/client/selection.ts
CHANGED
|
@@ -197,7 +197,7 @@ export function initSelectionHandlers(): void {
|
|
|
197
197
|
|
|
198
198
|
const captured = captureSelection(sel, text);
|
|
199
199
|
if (!captured) {
|
|
200
|
-
showError("
|
|
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
|
})();
|
package/src/client/state.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/styles.css
CHANGED
|
@@ -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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
103
|
+
// Per-attempt state, hoisted so fail() can report whatever the latest
|
|
104
|
+
// attempt produced.
|
|
105
|
+
let revised = "";
|
|
106
|
+
let exitCode = 0;
|
|
107
|
+
let stderrText = "";
|
|
164
108
|
|
|
165
109
|
const fail = async (reason: string) => {
|
|
166
110
|
await logRevisionFailure(filePath, {
|
|
@@ -174,37 +118,97 @@ export async function resolve(filePath: string, options: { model?: string } = {}
|
|
|
174
118
|
throw new Error(reason);
|
|
175
119
|
};
|
|
176
120
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
121
|
+
// A mangled revision is usually a one-off model stumble — Haiku dropping an
|
|
122
|
+
// uncommented section, streaming a preamble, etc. Retry once before giving
|
|
123
|
+
// up. A non-zero CLI exit is NOT retried: that's an environment/auth failure
|
|
124
|
+
// a re-run won't fix.
|
|
125
|
+
const MAX_ATTEMPTS = 2;
|
|
126
|
+
let trimmed = "";
|
|
180
127
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
.
|
|
188
|
-
.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
128
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
129
|
+
console.log(
|
|
130
|
+
attempt === 1
|
|
131
|
+
? `Revising with ${chosenModel}...\n`
|
|
132
|
+
: `\nRetrying revision with ${chosenModel} (attempt ${attempt}/${MAX_ATTEMPTS})...\n`
|
|
133
|
+
);
|
|
134
|
+
console.log("─".repeat(60));
|
|
135
|
+
const revisionStartedAt = Date.now();
|
|
136
|
+
|
|
137
|
+
const proc = Bun.spawn(
|
|
138
|
+
[cliBin, "-p", "--system-prompt", systemPrompt, "--model", chosenModel,
|
|
139
|
+
"--output-format", "stream-json", "--include-partial-messages", "--verbose"],
|
|
140
|
+
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" }
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
proc.stdin.write(userMessage);
|
|
144
|
+
proc.stdin.end();
|
|
145
|
+
|
|
146
|
+
// Drain stderr concurrently so we can include it in any error report.
|
|
147
|
+
stderrText = "";
|
|
148
|
+
const stderrDone = (async () => {
|
|
149
|
+
const r = proc.stderr.getReader();
|
|
150
|
+
const dec = new TextDecoder();
|
|
151
|
+
while (true) {
|
|
152
|
+
const { done, value } = await r.read();
|
|
153
|
+
if (done) break;
|
|
154
|
+
const chunk = dec.decode(value);
|
|
155
|
+
stderrText += chunk;
|
|
156
|
+
process.stderr.write(chunk);
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
|
|
160
|
+
revised = "";
|
|
161
|
+
let buffer = "";
|
|
162
|
+
const reader = proc.stdout.getReader();
|
|
163
|
+
while (true) {
|
|
164
|
+
const { done, value } = await reader.read();
|
|
165
|
+
if (done) break;
|
|
166
|
+
buffer += new TextDecoder().decode(value);
|
|
167
|
+
const lines = buffer.split("\n");
|
|
168
|
+
buffer = lines.pop() ?? "";
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
if (!line.trim()) continue;
|
|
171
|
+
try {
|
|
172
|
+
const obj = JSON.parse(line);
|
|
173
|
+
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
|
|
174
|
+
const delta = obj.event.delta;
|
|
175
|
+
if (delta?.type === "text_delta" && delta.text) {
|
|
176
|
+
revised += delta.text;
|
|
177
|
+
process.stdout.write(delta.text);
|
|
178
|
+
broadcastChunk(delta.text, "text");
|
|
179
|
+
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
180
|
+
broadcastChunk(delta.thinking, "thinking");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch { /* malformed JSON line, skip */ }
|
|
184
|
+
}
|
|
207
185
|
}
|
|
186
|
+
|
|
187
|
+
exitCode = await proc.exited;
|
|
188
|
+
await stderrDone;
|
|
189
|
+
const revisionDurationMs = Date.now() - revisionStartedAt;
|
|
190
|
+
console.log("\n" + "─".repeat(60));
|
|
191
|
+
console.log(`Model: ${chosenModel} · Duration: ${(revisionDurationMs / 1000).toFixed(1)}s · Exit: ${exitCode}`);
|
|
192
|
+
console.log("─".repeat(60) + "\n");
|
|
193
|
+
|
|
194
|
+
// A CLI crash is not retryable — fail immediately.
|
|
195
|
+
if (exitCode !== 0) {
|
|
196
|
+
await fail(`claude CLI exited with code ${exitCode}${stderrText.trim() ? ` — ${stderrText.trim().split("\n").slice(-3).join(" | ")}` : ""}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const result = validateRevision(revised, docText, settled);
|
|
200
|
+
if (result.ok) {
|
|
201
|
+
trimmed = result.doc;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Validation failed — the model returned mangled output. Retry once; on
|
|
206
|
+
// the last attempt, log and throw so the session surfaces the error.
|
|
207
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
208
|
+
console.log(`Revision output rejected: ${result.reason}`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
await fail(result.reason);
|
|
208
212
|
}
|
|
209
213
|
|
|
210
214
|
// If the model made no changes, skip the write and signal the browser
|
|
@@ -230,6 +234,85 @@ export async function resolve(filePath: string, options: { model?: string } = {}
|
|
|
230
234
|
} catch { /* server may not be running — non-fatal */ }
|
|
231
235
|
}
|
|
232
236
|
|
|
237
|
+
const HEADING_RE = /^#{1,6} .+$/gm;
|
|
238
|
+
|
|
239
|
+
// Headings present in `inputDoc` but missing from `outputDoc` whose section the
|
|
240
|
+
// reviewer never commented on. A comment quoting text inside a section means
|
|
241
|
+
// the reviewer was working there, so dropping/reworking it is authorized; a
|
|
242
|
+
// section vanishing with no comment near it means the model mangled the doc.
|
|
243
|
+
function droppedSections(inputDoc: string, outputDoc: string, settled: Comment[]): string[] {
|
|
244
|
+
const outHeadings = new Set(outputDoc.match(HEADING_RE) ?? []);
|
|
245
|
+
const inMatches = [...inputDoc.matchAll(HEADING_RE)];
|
|
246
|
+
const quotes = settled.map((c) => c.quote.trim()).filter((q) => q.length > 0);
|
|
247
|
+
|
|
248
|
+
const unauthorized: string[] = [];
|
|
249
|
+
for (let i = 0; i < inMatches.length; i++) {
|
|
250
|
+
const heading = inMatches[i]![0];
|
|
251
|
+
if (outHeadings.has(heading)) continue;
|
|
252
|
+
// This heading's section runs from the heading to the next one (or EOF).
|
|
253
|
+
const start = inMatches[i]!.index!;
|
|
254
|
+
const end = i + 1 < inMatches.length ? inMatches[i + 1]!.index! : inputDoc.length;
|
|
255
|
+
const section = inputDoc.slice(start, end);
|
|
256
|
+
const commented = quotes.some((q) => section.includes(q));
|
|
257
|
+
if (!commented) unauthorized.push(heading);
|
|
258
|
+
}
|
|
259
|
+
return unauthorized;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Validate (and lightly normalize) a revision pass's raw output. Pure — the
|
|
263
|
+
// retry loop and tests both drive it. Returns the cleaned document on success,
|
|
264
|
+
// or a human-readable reason on failure.
|
|
265
|
+
export function validateRevision(
|
|
266
|
+
revised: string,
|
|
267
|
+
inputDoc: string,
|
|
268
|
+
settled: Comment[]
|
|
269
|
+
): { ok: true; doc: string } | { ok: false; reason: string } {
|
|
270
|
+
// Strip a wrapping code fence and any <document> wrapper tags the model
|
|
271
|
+
// sometimes includes despite the system prompt.
|
|
272
|
+
let trimmed = revised.trim()
|
|
273
|
+
.replace(/^```(?:markdown)?\n([\s\S]*)\n```$/, "$1")
|
|
274
|
+
.replace(/^<document>\s*/i, "")
|
|
275
|
+
.replace(/\s*<\/document>\s*$/i, "")
|
|
276
|
+
.trim();
|
|
277
|
+
|
|
278
|
+
// Strip a trailing meta-section the model sometimes appends (Settled
|
|
279
|
+
// comments, Changelog, …), plus a horizontal rule that often precedes it.
|
|
280
|
+
const metaHeading = trimmed.match(/\n#{2,3} (Settled comments|Previously agreed changes|Changelog|Revision notes)\b/i);
|
|
281
|
+
if (metaHeading) {
|
|
282
|
+
trimmed = trimmed.slice(0, metaHeading.index).trimEnd().replace(/\n+---\s*$/, "").trimEnd();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!trimmed) {
|
|
286
|
+
return { ok: false, reason: "Revision produced empty output — no text was streamed back" };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// If the input had headings, the output should too. Strip a preamble before
|
|
290
|
+
// the first heading; if there's no heading at all, the model returned prose
|
|
291
|
+
// (an apology, a summary) instead of the document. A genuinely heading-less
|
|
292
|
+
// input is left alone — headings can't be the structural anchor there.
|
|
293
|
+
if (/^#{1,6} /m.test(inputDoc) && !/^#{1,6} /.test(trimmed)) {
|
|
294
|
+
const firstHeadingIdx = trimmed.search(/^#{1,6} /m);
|
|
295
|
+
if (firstHeadingIdx > 0) {
|
|
296
|
+
trimmed = trimmed.slice(firstHeadingIdx).trim();
|
|
297
|
+
} else {
|
|
298
|
+
return { ok: false, reason: "Revision output has no Markdown headings — the model returned non-document content, not a revised document" };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Structural integrity: the revision must not silently drop a section the
|
|
303
|
+
// reviewer never commented on.
|
|
304
|
+
const dropped = droppedSections(inputDoc, trimmed, settled);
|
|
305
|
+
if (dropped.length > 0) {
|
|
306
|
+
const list = dropped.map((h) => `"${h}"`).join(", ");
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
reason: `Revision dropped section${dropped.length > 1 ? "s" : ""} the reviewer never commented on: ${list} — the model mangled the document instead of editing it`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { ok: true, doc: trimmed };
|
|
314
|
+
}
|
|
315
|
+
|
|
233
316
|
async function logRevisionFailure(
|
|
234
317
|
filePath: string,
|
|
235
318
|
details: { reason: string; model: string; exitCode: number; stderr: string; stdoutSample: string; stdoutLength: number }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Closeout summary of a finished review.
|
|
2
|
+
//
|
|
3
|
+
// The inline review agent (src/agent.ts) and the agent that *launched* redline
|
|
4
|
+
// share no live channel — the sidecar is the only persisted artifact, and the
|
|
5
|
+
// launching agent only regains control when the session exits. So at closeout
|
|
6
|
+
// the CLI prints every comment thread verbatim. Anything the reviewer said —
|
|
7
|
+
// including feedback meant for the launching agent — lands in front of it.
|
|
8
|
+
//
|
|
9
|
+
// Comments the inline agent explicitly flagged (`escalate: true`) get a
|
|
10
|
+
// dedicated section so they aren't lost in the full transcript.
|
|
11
|
+
|
|
12
|
+
import type { Sidecar, Comment } from "./sidecar";
|
|
13
|
+
|
|
14
|
+
export interface EscalationItem {
|
|
15
|
+
round: number;
|
|
16
|
+
quote: string;
|
|
17
|
+
request: string; // the reviewer message the inline agent couldn't act on
|
|
18
|
+
note: string; // the agent's escalation note
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function flatten(s: string, n: number): string {
|
|
22
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
23
|
+
return flat.length > n ? flat.slice(0, n - 1).trimEnd() + "…" : flat;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isEscalated(c: Comment): boolean {
|
|
27
|
+
return c.thread.some((e) => e.role === "agent" && e.escalate === true);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Pull out every comment the inline agent routed to the launching agent.
|
|
31
|
+
export function collectEscalations(sidecar: Sidecar): EscalationItem[] {
|
|
32
|
+
const items: EscalationItem[] = [];
|
|
33
|
+
for (const round of sidecar.rounds) {
|
|
34
|
+
for (const c of round.comments) {
|
|
35
|
+
const escIdx = c.thread.findIndex((e) => e.role === "agent" && e.escalate === true);
|
|
36
|
+
if (escIdx === -1) continue;
|
|
37
|
+
const agentEntry = c.thread[escIdx]!;
|
|
38
|
+
// The reviewer message immediately before the escalation is the request
|
|
39
|
+
// the inline agent couldn't fulfill.
|
|
40
|
+
let request = "";
|
|
41
|
+
for (let i = escIdx - 1; i >= 0; i--) {
|
|
42
|
+
if (c.thread[i]!.role === "human") { request = c.thread[i]!.message; break; }
|
|
43
|
+
}
|
|
44
|
+
items.push({
|
|
45
|
+
round: round.round,
|
|
46
|
+
quote: flatten(c.quote, 80),
|
|
47
|
+
request: flatten(request, 300),
|
|
48
|
+
note: flatten(agentEntry.revision_reason || agentEntry.message, 300),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return items;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A readable transcript of every comment thread, plus an escalation callout.
|
|
56
|
+
// Printed to stdout on session close so the launching agent can read it.
|
|
57
|
+
export function formatReviewSummary(sidecar: Sidecar): string {
|
|
58
|
+
const lines: string[] = [`Review threads — ${sidecar.file}`];
|
|
59
|
+
|
|
60
|
+
for (const round of sidecar.rounds) {
|
|
61
|
+
if (round.comments.length === 0) continue;
|
|
62
|
+
lines.push("", `Round ${round.round}`);
|
|
63
|
+
round.comments.forEach((c, i) => {
|
|
64
|
+
const tags = [c.resolved ? "resolved" : "open"];
|
|
65
|
+
if (isEscalated(c)) tags.push("escalated");
|
|
66
|
+
lines.push(` ${i + 1}. "${flatten(c.quote, 80)}" — ${tags.join(" · ")}`);
|
|
67
|
+
for (const e of c.thread) {
|
|
68
|
+
const who = e.role === "human" ? "Reviewer" : (e.name || "Agent");
|
|
69
|
+
lines.push(` ${who}: ${flatten(e.message, 280)}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const esc = collectEscalations(sidecar);
|
|
75
|
+
if (esc.length > 0) {
|
|
76
|
+
lines.push(
|
|
77
|
+
"",
|
|
78
|
+
`⚠ ${esc.length} comment${esc.length !== 1 ? "s" : ""} escalated to you (the launching agent):`,
|
|
79
|
+
);
|
|
80
|
+
for (const e of esc) {
|
|
81
|
+
lines.push(` • "${e.quote}" (round ${e.round})`);
|
|
82
|
+
if (e.request) lines.push(` Reviewer asked: ${e.request}`);
|
|
83
|
+
if (e.note) lines.push(` Agent note: ${e.note}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push(
|
|
86
|
+
"",
|
|
87
|
+
"The inline review agent couldn't act on these. Address them in the",
|
|
88
|
+
"document or with the user before considering the review closed out.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
package/src/server-page.ts
CHANGED
|
@@ -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 —
|
|
133
|
-
//
|
|
134
|
-
//
|
|
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
|
-
|
|
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"
|
|
457
|
-
|
|
458
|
-
|
|
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:
|