@levistudio/redline 0.4.1 → 0.5.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/AGENTS.md CHANGED
@@ -35,6 +35,9 @@ redline <file> --context "..." # opens with a reviewer-supplied focus statement
35
35
  redline <file> --agent codex # use Codex instead of the auto-detected/default provider
36
36
  redline <file> --no-agent # manual annotation mode — no agent spawn, no provider CLI required on PATH
37
37
  redline resolve <file> # one-shot: read the sidecar, run the revision pass, write back
38
+ redline author-needed <file> # list comments where the inline agent requested author input
39
+ redline author-reply <file> <comment-id> --message "..." # post an author-marked reply into the thread
40
+ redline author-wait <file> # block until author input is needed or the review result is written
38
41
  ```
39
42
 
40
43
  The bare-arg path also spawns a dedicated agent subprocess alongside the server — see "The dedicated agent process" below.
@@ -196,12 +199,13 @@ JSON was tried first and abandoned: agent replies frequently contain quotes, cod
196
199
 
197
200
  The verdict is **agent-owned**. The human cannot flip it directly. Disagreement flows through a follow-up reply, which gives the agent a chance to re-classify rather than be silently overridden.
198
201
 
199
- ## Escalation to the launching agent
202
+ ## Author handoff
200
203
 
201
- The inline review agent and the agent that *launched* `redline` share no live channel — the sidecar is the only persisted artifact, and the launching agent only regains control when the session exits. So two mechanisms carry feedback back to it:
204
+ The inline review agent and the agent that *authored/launched* `redline` are separate processes. The sidecar is the persisted artifact, and `.startup.json` gives the authoring agent a local API bridge while the session is live. Two mechanisms carry feedback back to it:
202
205
 
203
- - **`ESCALATE` verdict.** A second, orthogonal flag in the reply envelope. The agent sets `ESCALATE: true` when a comment can't be acted on from inside the review it needs the launching agent's project context, tools, or authority (an external style guide, a spec to check against, a wider-project decision). It's stored as `escalate?: boolean` on the agent's `ThreadEntry` and rendered as an "↑ Escalated" badge on the comment and an "↑ Routed to the launching agent" note in the thread. Escalation is independent of `requires_revision` — the agent judges each on its own.
204
- - **Closeout transcript.** On `finished`, the CLI loads the sidecar and prints every comment thread verbatim via [src/reviewSummary.ts](src/reviewSummary.ts) (`formatReviewSummary`), with a dedicated callout listing escalated comments (`collectEscalations`). The escalation count is also written to `.review/<file>.result` and appended to the `REDLINE_RESULT:` line. This is the launching agent's read point — it sees the transcript when control hands back.
206
+ - **`ESCALATE` verdict.** The storage/envelope name is still `ESCALATE` for compatibility, but product language is **author reply needed**. The inline agent sets `ESCALATE: true` when a comment needs author-level input: information, tools, authority, or project context it cannot access from the document and comment thread (an external style guide, a spec to check against, a wider-project decision). It does **not** set it for ordinary requested edits, reframes, emphasis changes, rewrites, or approvals; those are handled by `requires_revision`. It's stored as `escalate?: boolean` on the agent's `ThreadEntry` and rendered as an "↑ Author reply needed" badge on the comment. The author handoff signal is independent of `requires_revision` — the agent judges each on its own.
207
+ - **Live author replies.** The authoring agent can run `redline author-wait <file>` while a session is open; it returns JSON when either a pending author-needed comment appears or the final `.result` is written. For pending comments, it can run `redline author-reply <file> <comment-id> --message "..."` to post back into the same thread, then wait again. When the server is live, the reply command uses `.review/<file>.startup.json` and the local API so the browser soft-refreshes; if the server is gone, it falls back to a locked sidecar write.
208
+ - **Closeout transcript.** On `finished`, the CLI loads the sidecar and prints every comment thread verbatim via [src/reviewSummary.ts](src/reviewSummary.ts) (`formatReviewSummary`), with a dedicated callout listing comments that need author input (`collectEscalations`). The count is also written to `.review/<file>.result` and appended to the `REDLINE_RESULT:` line. This remains the fallback read point for abandoned or unfinished sessions.
205
209
 
206
210
  ## Security & resilience as built
207
211
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to Redline are documented here. The format follows [Keep a C
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.0] - 2026-05-15
8
+
9
+ ### Added
10
+ - Added author-handoff CLI commands: `redline author-needed <file>` lists pending author-needed comments, `redline author-reply <file> <comment-id> --message "..."` posts an author-marked reply back into the thread, and `redline author-wait <file>` blocks until either author input is needed or the review finishes.
11
+
12
+ ### Changed
13
+ - Reframed M9 author handoff language from "escalated to the launching agent" to "author reply needed" while preserving the existing `ESCALATE`/`escalations` compatibility fields.
14
+
7
15
  ## [0.4.1] - 2026-05-15
8
16
 
9
17
  ### Fixed
@@ -65,7 +73,8 @@ Initial public release on npm as `@levistudio/redline`.
65
73
  - Auto-installs missing dependencies on first CLI run.
66
74
  - Initial test suite: server, sidecar, parsing, model-picking, rendering, diff, SSE, integration, happy-dom client.
67
75
 
68
- [Unreleased]: https://github.com/alevi/redline/compare/v0.4.1...HEAD
76
+ [Unreleased]: https://github.com/alevi/redline/compare/v0.5.0...HEAD
77
+ [0.5.0]: https://github.com/alevi/redline/compare/v0.4.1...v0.5.0
69
78
  [0.4.1]: https://github.com/alevi/redline/compare/v0.4.0...v0.4.1
70
79
  [0.4.0]: https://github.com/alevi/redline/compare/v0.3.0...v0.4.0
71
80
  [0.3.0]: https://github.com/alevi/redline/releases/tag/v0.3.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levistudio/redline",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Inline comments on Markdown files, for human-in-the-loop AI doc review.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -35,11 +35,10 @@ echo "REDLINE_URL: $URL"
35
35
  echo "REDLINE_PID: $PID"
36
36
 
37
37
  # Step 2: tell the human the browser opened (you do this after the first shell call returns
38
- # — see the next section), then wait for the redline process to exit. Watching
39
- # the PID (essentially free) instead of polling for the result file means you
40
- # wake up within ~0.5s of the human clicking Done, not up to 30s later.
41
- while kill -0 "$PID" 2>/dev/null; do sleep 0.5; done
42
- cat "$RESULT"
38
+ # — see the next section), then wait until either author input is needed or the
39
+ # review exits. If author input is needed, answer it with author-reply and run
40
+ # author-wait again.
41
+ __REDLINE_BIN__ author-wait "$FILE"
43
42
  ```
44
43
 
45
44
  The startup file at `.review/<basename>.startup.json` is written synchronously when the server begins listening; it contains `url`, `port`, `file`, `result_file`, `started_at`, `pid`. The result file at `.review/<basename>.result` is written when the session ends (approved, abandoned, or error).
@@ -47,7 +46,7 @@ The startup file at `.review/<basename>.startup.json` is written synchronously w
47
46
  In practice, run the script above as **two separate shell calls** so you can tell the human the URL between steps:
48
47
  1. First call: everything through `echo "REDLINE_PID: $PID"`. Returns in ~1s with the URL and PID on stdout.
49
48
  2. Tell the human Redline opened in their browser, and include the URL only as a fallback (see "Surfacing the URL" below).
50
- 3. Second call: just the `while kill -0` loop waiting for the PID, then `cat "$RESULT"`. Long timeout (`timeout: 1800000` = 30 min, or longer).
49
+ 3. Second call: `__REDLINE_BIN__ author-wait "$FILE"`. It returns either `{ "kind": "author-needed", ... }` or `{ "kind": "result", ... }`. Long timeout (`timeout: 1800000` = 30 min, or longer). If it returns author-needed JSON, answer with `author-reply`, then run `author-wait` again.
51
50
 
52
51
  If invocation fails (binary missing, startup file never appears, etc.), surface the error verbatim and stop — do not try to recover. The human will re-run `redline install-skill`.
53
52
 
@@ -87,11 +86,11 @@ The full loop, when you are the outer agent producing the doc:
87
86
  2. Tell the human in one sentence what's about to happen.
88
87
  3. First shell call: launch `__REDLINE_BIN__ <abs-path> --context "<one-liner>" --open` in the background and poll for `.startup.json`. Returns in ~1s with the URL.
89
88
  4. Tell the human Redline opened in their browser and include the URL only as a fallback.
90
- 5. Second shell call: wait on the redline PID (`while kill -0 "$PID" 2>/dev/null; do sleep 0.5; done`) then `cat "$RESULT"`, with a long timeout (30+ min). While the session runs, you are idle — do not start unrelated work, do not run other tools.
89
+ 5. Second shell call: run `__REDLINE_BIN__ author-wait "$FILE"`. If it returns `kind: "author-needed"`, answer with `__REDLINE_BIN__ author-reply "$FILE" <comment-id> --message "..."`, then run `author-wait` again. Do not start unrelated work while the session runs.
91
90
  6. On `approved`: re-read the file from disk (it may have been revised) and continue with whatever required sign-off.
92
91
  7. On `abandoned` or `error`: stop and ask the human how to proceed; do not retry automatically.
93
92
 
94
- You do not need to reply to comments — Redline spawns its own agent subprocess for that. You do not need to invoke `redline resolve` separately — revisions happen inside the session when the human accepts.
93
+ You usually do not need to reply to comments — Redline spawns its own inline agent subprocess for that. The exception is an author-needed handoff: if `author-wait` returns `kind: "author-needed"`, you are the authoring agent and should answer only when you have the project context, tools, or authority the inline agent lacked. You do not need to invoke `redline resolve` separately — revisions happen inside the session when the human accepts.
95
94
 
96
95
  ## When *not* to use this
97
96
 
package/src/agent.ts CHANGED
@@ -91,7 +91,7 @@ const REPLY_SYSTEM_PROMPT_BODY =
91
91
  "Good: message: \"Got it.\" reason: \"Add a third line to the hard line breaks example\"\n" +
92
92
  "When requires_revision is false, leave reason empty (or a very short note about why no edit). The reply IS the answer.\n" +
93
93
  "\n" +
94
- "Separately, decide whether this comment needs the agent that LAUNCHED this review rather than you. ESCALATE is rare: set ESCALATE: true only when the reviewer explicitly asks for information, tools, authority, or project context you cannot access from the document and comment thread — for example an external style guide, a hidden spec, repo-wide code context, or a decision the launching agent must make. Do NOT escalate ordinary requested edits, reframes, emphasis changes, priority changes, rewrites, or approvals; those are handled by requires_revision. If the comment can be satisfied by editing this document, set ESCALATE: false. When you do escalate, briefly tell the reviewer in your message that you've routed it to the launching agent. Escalating does not change requires_revision — judge that on its own.\n" +
94
+ "Separately, decide whether this comment needs an AUTHOR reply from the agent that launched/wrote this document rather than you. ESCALATE is rare for now: set ESCALATE: true only when the reviewer explicitly asks for information, tools, authority, or project context you cannot access from the document and comment thread — for example an external style guide, a hidden spec, repo-wide code context, or a decision the authoring agent must make. Do NOT route ordinary requested edits, reframes, emphasis changes, priority changes, rewrites, or approvals; those are handled by requires_revision. If the comment can be satisfied by editing this document, set ESCALATE: false. When you do route to the author, briefly tell the reviewer that an author reply is needed. Author handoff does not change requires_revision — judge that on its own.\n" +
95
95
  "\n" +
96
96
  "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" +
97
97
  "REQUIRES_REVISION: <true|false>\n" +
@@ -0,0 +1,191 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { readFile } from "fs/promises";
3
+ import path from "path";
4
+ import { loadSidecar, withSidecar, type Comment, type Sidecar, type ThreadEntry } from "./sidecar";
5
+
6
+ export interface AuthorNeededItem {
7
+ round: number;
8
+ commentId: string;
9
+ quote: string;
10
+ request: string;
11
+ note: string;
12
+ resolved: boolean;
13
+ }
14
+
15
+ function flatten(s: string, n: number): string {
16
+ const flat = s.replace(/\s+/g, " ").trim();
17
+ return flat.length > n ? flat.slice(0, n - 1).trimEnd() + "…" : flat;
18
+ }
19
+
20
+ function latestIndex(thread: ThreadEntry[], predicate: (entry: ThreadEntry) => boolean): number {
21
+ for (let i = thread.length - 1; i >= 0; i--) {
22
+ if (predicate(thread[i]!)) return i;
23
+ }
24
+ return -1;
25
+ }
26
+
27
+ export function collectAuthorNeeded(sidecar: Sidecar): AuthorNeededItem[] {
28
+ const items: AuthorNeededItem[] = [];
29
+ for (const round of sidecar.rounds) {
30
+ for (const comment of round.comments) {
31
+ const escIdx = latestIndex(comment.thread, (entry) => entry.role === "agent" && entry.escalate === true);
32
+ if (escIdx === -1) continue;
33
+ const authorIdx = latestIndex(comment.thread, (entry) => entry.role === "agent" && entry.author === true);
34
+ if (authorIdx > escIdx) continue;
35
+
36
+ const agentEntry = comment.thread[escIdx]!;
37
+ let request = "";
38
+ for (let i = escIdx - 1; i >= 0; i--) {
39
+ if (comment.thread[i]!.role === "human") {
40
+ request = comment.thread[i]!.message;
41
+ break;
42
+ }
43
+ }
44
+
45
+ items.push({
46
+ round: round.round,
47
+ commentId: comment.id,
48
+ quote: flatten(comment.quote, 120),
49
+ request: flatten(request, 500),
50
+ note: flatten(agentEntry.revision_reason || agentEntry.message, 500),
51
+ resolved: comment.resolved,
52
+ });
53
+ }
54
+ }
55
+ return items;
56
+ }
57
+
58
+ export async function listAuthorNeeded(filePath: string): Promise<AuthorNeededItem[]> {
59
+ return collectAuthorNeeded(await loadSidecar(filePath));
60
+ }
61
+
62
+ export interface AuthorReplyResult {
63
+ via: "server" | "sidecar";
64
+ commentId: string;
65
+ }
66
+
67
+ export type AuthorWaitResult =
68
+ | { kind: "author-needed"; file: string; author_needed: AuthorNeededItem[] }
69
+ | { kind: "result"; file: string; result: Record<string, unknown> };
70
+
71
+ function startupPath(filePath: string): string {
72
+ return path.join(path.dirname(filePath), ".review", path.basename(filePath) + ".startup.json");
73
+ }
74
+
75
+ function resultPath(filePath: string): string {
76
+ return path.join(path.dirname(filePath), ".review", path.basename(filePath) + ".result");
77
+ }
78
+
79
+ function readStartup(filePath: string): { url?: string; csrf_token?: string } | null {
80
+ const sp = startupPath(filePath);
81
+ if (!existsSync(sp)) return null;
82
+ try {
83
+ return JSON.parse(readFileSync(sp, "utf-8")) as { url?: string; csrf_token?: string };
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ async function postToLiveServer(
90
+ filePath: string,
91
+ commentId: string,
92
+ message: string,
93
+ name: string,
94
+ ): Promise<boolean> {
95
+ const startup = readStartup(filePath);
96
+ if (!startup?.url || !startup.csrf_token) return false;
97
+ try {
98
+ const res = await fetch(`${startup.url}/api/comment/${encodeURIComponent(commentId)}/reply`, {
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/json",
102
+ "X-Redline-Token": startup.csrf_token,
103
+ },
104
+ body: JSON.stringify({ role: "agent", name, message, author: true }),
105
+ });
106
+ return res.ok;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function findComment(sidecar: Sidecar, commentId: string): Comment | null {
113
+ for (const round of sidecar.rounds) {
114
+ const found = round.comments.find((comment) => comment.id === commentId);
115
+ if (found) return found;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ export async function postAuthorReply(
121
+ filePath: string,
122
+ commentId: string,
123
+ message: string,
124
+ options: { name?: string } = {},
125
+ ): Promise<AuthorReplyResult> {
126
+ const trimmed = message.trim();
127
+ if (!trimmed) throw new Error("message is required");
128
+ const name = options.name?.trim() || "Author";
129
+
130
+ if (await postToLiveServer(filePath, commentId, trimmed, name)) {
131
+ return { via: "server", commentId };
132
+ }
133
+
134
+ const result = await withSidecar(filePath, (sidecar) => {
135
+ const comment = findComment(sidecar, commentId);
136
+ if (!comment) return { ok: false as const };
137
+ comment.thread.push({
138
+ role: "agent",
139
+ name,
140
+ message: trimmed,
141
+ at: new Date().toISOString(),
142
+ author: true,
143
+ });
144
+ return { ok: true as const };
145
+ });
146
+ if (!result.ok) throw new Error(`Comment not found: ${commentId}`);
147
+ return { via: "sidecar", commentId };
148
+ }
149
+
150
+ export function formatAuthorNeeded(items: AuthorNeededItem[]): string {
151
+ if (items.length === 0) return "No author replies needed.";
152
+ const lines = [
153
+ `${items.length} comment${items.length === 1 ? " needs an author reply" : "s need author replies"}:`,
154
+ ];
155
+ for (const item of items) {
156
+ lines.push(`- ${item.commentId} (round ${item.round}${item.resolved ? ", resolved" : ", open"}): "${item.quote}"`);
157
+ if (item.request) lines.push(` Reviewer: ${item.request}`);
158
+ if (item.note) lines.push(` Inline agent: ${item.note}`);
159
+ }
160
+ return lines.join("\n");
161
+ }
162
+
163
+ function sleep(ms: number): Promise<void> {
164
+ return new Promise((resolve) => setTimeout(resolve, ms));
165
+ }
166
+
167
+ export async function waitForAuthorEvent(
168
+ filePath: string,
169
+ options: { intervalMs?: number; timeoutMs?: number } = {},
170
+ ): Promise<AuthorWaitResult> {
171
+ const intervalMs = Math.max(50, options.intervalMs ?? 500);
172
+ const deadline = options.timeoutMs != null ? Date.now() + options.timeoutMs : null;
173
+ const rp = resultPath(filePath);
174
+
175
+ while (true) {
176
+ const authorNeeded = await listAuthorNeeded(filePath);
177
+ if (authorNeeded.length > 0) {
178
+ return { kind: "author-needed", file: filePath, author_needed: authorNeeded };
179
+ }
180
+
181
+ if (existsSync(rp)) {
182
+ const raw = await readFile(rp, "utf-8");
183
+ return { kind: "result", file: filePath, result: JSON.parse(raw) as Record<string, unknown> };
184
+ }
185
+
186
+ if (deadline != null && Date.now() >= deadline) {
187
+ throw new Error("Timed out waiting for author-needed comments or review result");
188
+ }
189
+ await sleep(intervalMs);
190
+ }
191
+ }
package/src/cli.ts CHANGED
@@ -45,6 +45,7 @@ preflightDependencies();
45
45
  // Dynamic imports so preflight runs before module resolution pulls in third-party deps.
46
46
  const { createServer } = await import("./server");
47
47
  const { resolve } = await import("./resolve");
48
+ const { formatAuthorNeeded, listAuthorNeeded, postAuthorReply, waitForAuthorEvent } = await import("./authorHandoff");
48
49
  const {
49
50
  getAgentProvider,
50
51
  invalidProviderMessage,
@@ -123,6 +124,81 @@ if (args[0] === "install-skill") {
123
124
  process.exit(result.status ?? 1);
124
125
  }
125
126
 
127
+ // redline author-needed <file> [--json]
128
+ if (args[0] === "author-needed") {
129
+ const filePath = args[1];
130
+ if (!filePath) {
131
+ console.error("Usage: redline author-needed <file.md> [--json]");
132
+ process.exit(1);
133
+ }
134
+ const resolved = path.resolve(filePath);
135
+ if (!existsSync(resolved)) {
136
+ console.error(`File not found: ${resolved}`);
137
+ process.exit(1);
138
+ }
139
+ const items = await listAuthorNeeded(resolved);
140
+ if (args.includes("--json")) {
141
+ console.log(JSON.stringify({ file: resolved, author_needed: items }, null, 2));
142
+ } else {
143
+ console.log(formatAuthorNeeded(items));
144
+ }
145
+ process.exit(0);
146
+ }
147
+
148
+ // redline author-reply <file> <comment-id> --message "..."
149
+ if (args[0] === "author-reply") {
150
+ const filePath = args[1];
151
+ const commentId = args[2];
152
+ const message = argValue(args, "--message");
153
+ if (!filePath || !commentId || !message) {
154
+ console.error("Usage: redline author-reply <file.md> <comment-id> --message \"...\" [--name \"Author\"]");
155
+ process.exit(1);
156
+ }
157
+ const resolved = path.resolve(filePath);
158
+ if (!existsSync(resolved)) {
159
+ console.error(`File not found: ${resolved}`);
160
+ process.exit(1);
161
+ }
162
+ try {
163
+ const result = await postAuthorReply(resolved, commentId, message, { name: argValue(args, "--name") });
164
+ console.log(`Posted author reply to ${result.commentId} via ${result.via}.`);
165
+ } catch (e) {
166
+ console.error(e instanceof Error ? e.message : String(e));
167
+ process.exit(1);
168
+ }
169
+ process.exit(0);
170
+ }
171
+
172
+ // redline author-wait <file> [--timeout-ms <ms>] [--interval-ms <ms>]
173
+ if (args[0] === "author-wait") {
174
+ const filePath = args[1];
175
+ if (!filePath) {
176
+ console.error("Usage: redline author-wait <file.md> [--timeout-ms <ms>] [--interval-ms <ms>]");
177
+ process.exit(1);
178
+ }
179
+ const resolved = path.resolve(filePath);
180
+ if (!existsSync(resolved)) {
181
+ console.error(`File not found: ${resolved}`);
182
+ process.exit(1);
183
+ }
184
+ const timeoutRaw = argValue(args, "--timeout-ms");
185
+ const intervalRaw = argValue(args, "--interval-ms");
186
+ const timeoutMs = timeoutRaw != null ? Number(timeoutRaw) : undefined;
187
+ const intervalMs = intervalRaw != null ? Number(intervalRaw) : undefined;
188
+ if ((timeoutRaw != null && !Number.isFinite(timeoutMs)) || (intervalRaw != null && !Number.isFinite(intervalMs))) {
189
+ console.error("--timeout-ms and --interval-ms must be numbers");
190
+ process.exit(1);
191
+ }
192
+ try {
193
+ const result = await waitForAuthorEvent(resolved, { timeoutMs, intervalMs });
194
+ console.log(JSON.stringify(result, null, 2));
195
+ } catch (e) {
196
+ console.error(e instanceof Error ? e.message : String(e));
197
+ process.exit(1);
198
+ }
199
+ process.exit(0);
200
+ }
201
+
126
202
  // redline resolve <file> [--model <id>]
127
203
  if (args[0] === "resolve") {
128
204
  const filePath = args[1];
@@ -148,7 +224,7 @@ if (args[0] === "resolve") {
148
224
  // redline <file> — open review reader
149
225
  const filePath = args[0];
150
226
  if (!filePath) {
151
- console.error("Usage: redline <file.md>\n redline resolve <file.md> [--model <model-id>] [--agent claude|codex]\n redline install-skill [--agent claude|codex|both]");
227
+ console.error("Usage: redline <file.md>\n redline resolve <file.md> [--model <model-id>] [--agent claude|codex]\n redline author-needed <file.md> [--json]\n redline author-reply <file.md> <comment-id> --message \"...\" [--name \"Author\"]\n redline author-wait <file.md> [--timeout-ms <ms>] [--interval-ms <ms>]\n redline install-skill [--agent claude|codex|both]");
152
228
  process.exit(1);
153
229
  }
154
230
  const resolved = path.resolve(filePath);
@@ -315,7 +391,7 @@ if (args[0] === "resolve") {
315
391
  const status = lastRevisionError ? "error" : "abandoned";
316
392
  const payload: Record<string, unknown> = { status, file: resolved };
317
393
  if (lastRevisionError) payload.reason = lastRevisionError;
318
- // Carry escalations through the error/abandon path too — on an incomplete
394
+ // Carry author handoffs through the error/abandon path too — on an incomplete
319
395
  // session they matter more, not less. Read the sidecar synchronously:
320
396
  // abandon runs in signal context where async I/O may not complete.
321
397
  let escalations: import("./reviewSummary").EscalationItem[] = [];
@@ -334,7 +410,7 @@ if (args[0] === "resolve") {
334
410
  } catch { /* best effort */ }
335
411
  if (escalations.length) {
336
412
  console.log(
337
- `\n⚠ ${escalations.length} comment${escalations.length !== 1 ? "s" : ""} escalated to the launching agent — see ${path.basename(resultFile)}`
413
+ `\n⚠ ${escalations.length} comment${escalations.length !== 1 ? "s need" : " needs"} an author reply — see ${path.basename(resultFile)}`
338
414
  );
339
415
  }
340
416
  const escSuffix = escalations.length ? ` escalations=${escalations.length}` : "";
@@ -355,9 +431,9 @@ if (args[0] === "resolve") {
355
431
  console.log(` Revised document: ${resolved}`);
356
432
  console.log(`${line}`);
357
433
 
358
- // Print the full comment threads so the launching agent — which has no
434
+ // Print the full comment threads so the authoring agent — which has no
359
435
  // live channel to the inline review agent — sees everything the reviewer
360
- // said, including escalated feedback meant for it.
436
+ // said, including author-level feedback meant for it.
361
437
  let escalations: import("./reviewSummary").EscalationItem[] = [];
362
438
  try {
363
439
  const { loadSidecar } = await import("./sidecar");
@@ -44,7 +44,7 @@ export function buildCommentCard(comment: ClientComment): HTMLDivElement {
44
44
  if (isEscalated(comment)) {
45
45
  const ebadge = document.createElement("span");
46
46
  ebadge.className = "escalate-badge";
47
- ebadge.textContent = "↑ Escalated";
47
+ ebadge.textContent = "↑ Author reply needed";
48
48
  quote.appendChild(ebadge);
49
49
  }
50
50
  quote.appendChild(document.createTextNode('"' + comment.quote + '"'));
@@ -164,16 +164,16 @@ export function buildCommentCard(comment: ClientComment): HTMLDivElement {
164
164
 
165
165
  function buildThreadEntry(entry: ThreadEntry, isLatestVerdict: boolean): HTMLDivElement {
166
166
  const role = entry.role ?? "agent";
167
- const label = entry.name ?? (role === "agent" ? "Agent" : "Human");
167
+ const label = entry.name ?? (entry.author ? "Author" : role === "agent" ? "Agent" : "Human");
168
168
  const div = document.createElement("div");
169
- div.className = "thread-entry";
169
+ div.className = "thread-entry" + (entry.author ? " author-entry" : "");
170
170
  let verdictHtml = "";
171
171
  if (role === "agent" && entry.requires_revision === true && isLatestVerdict) {
172
172
  const reason = entry.revision_reason ? escapeHtml(entry.revision_reason) : "edit queued";
173
173
  verdictHtml = `<div class="verdict revise"><span class="verdict-icon">\u270E</span><span>${reason}</span></div>`;
174
174
  }
175
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>`;
176
+ verdictHtml += `<div class="verdict escalate"><span class="verdict-icon">\u2191</span><span>Author reply needed</span></div>`;
177
177
  }
178
178
  const messageHtml = entry.messageHtml ?? escapeHtml(entry.message);
179
179
  div.innerHTML = `
package/src/client/lib.ts CHANGED
@@ -23,6 +23,7 @@ export type ThreadEntry = {
23
23
  requires_revision?: boolean;
24
24
  revision_reason?: string;
25
25
  escalate?: boolean;
26
+ author?: boolean;
26
27
  };
27
28
 
28
29
  export function escapeHtml(s: string): string {
@@ -45,9 +46,18 @@ export function latestVerdict(comment: ClientComment): "revise" | "accept" | nul
45
46
  return null;
46
47
  }
47
48
 
48
- // True when an agent reply flagged this comment for the launching agent.
49
+ // True when an agent reply flagged this comment for authoring-agent input.
49
50
  export function isEscalated(comment: ClientComment): boolean {
50
- return (comment.thread || []).some((e) => e.role === "agent" && e.escalate === true);
51
+ const thread = comment.thread || [];
52
+ let escIdx = -1;
53
+ let authorIdx = -1;
54
+ for (let i = thread.length - 1; i >= 0; i--) {
55
+ const entry = thread[i]!;
56
+ if (escIdx === -1 && entry.role === "agent" && entry.escalate === true) escIdx = i;
57
+ if (authorIdx === -1 && entry.role === "agent" && entry.author === true) authorIdx = i;
58
+ if (escIdx !== -1 && authorIdx !== -1) break;
59
+ }
60
+ return escIdx !== -1 && authorIdx < escIdx;
51
61
  }
52
62
 
53
63
  export function nearestCell(node: Node): HTMLElement | null {
@@ -346,15 +346,15 @@ export function applyRoundState(): void {
346
346
  bannerText = `${reviseCount} of ${total} comments imply edits.`;
347
347
  }
348
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
349
+ // Author handoff is orthogonal to the revise/accept verdict — a comment
350
+ // can be answered in-thread yet still need authoring-agent input. Call
351
351
  // it out on its own line so it survives all three banner branches.
352
352
  const escCount = state.comments.filter(isEscalated).length;
353
353
  if (escCount > 0) {
354
354
  const escLine = document.createElement("div");
355
355
  escLine.className = "banner-escalation";
356
356
  escLine.textContent =
357
- `↑ ${escCount} comment${escCount !== 1 ? "s" : ""} escalated to the launching agent.`;
357
+ `↑ ${escCount} comment${escCount !== 1 ? "s need" : " needs"} an author reply.`;
358
358
  banner.appendChild(escLine);
359
359
  }
360
360
  banner.style.display = "block";
@@ -496,6 +496,13 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
496
496
  }
497
497
  .thread-role.human { color: var(--accent); }
498
498
  .thread-role.agent { color: #3b5bdb; }
499
+ .thread-entry.author-entry {
500
+ border-left: 3px solid #7c3aed;
501
+ padding-left: 10px;
502
+ }
503
+ .thread-entry.author-entry .thread-role {
504
+ color: #5b21b6;
505
+ }
499
506
 
500
507
  .thread-message {
501
508
  font-size: 13.5px;
@@ -597,7 +604,7 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
597
604
  .verdict-badge.revise { background: #fef3c7; color: #92400e; }
598
605
  .verdict-badge.accept { background: #e5e7eb; color: var(--text-muted); }
599
606
 
600
- /* Escalation badge on the quote line — comment routed to the launching agent */
607
+ /* Author-reply-needed badge on the quote line */
601
608
  .escalate-badge {
602
609
  display: inline-flex;
603
610
  align-items: center;
@@ -803,8 +810,8 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
803
810
  align-items: center;
804
811
  gap: 8px;
805
812
  }
806
- /* Escalation sub-line under the round banner — purple to match the
807
- Escalated comment badges, distinct from the green verdict copy. */
813
+ /* Author-handoff sub-line under the round banner — purple to match the
814
+ Author reply needed badges, distinct from the green verdict copy. */
808
815
  .banner-escalation {
809
816
  margin-top: 6px;
810
817
  padding-top: 6px;
@@ -1,10 +1,10 @@
1
1
  // Closeout summary of a finished review.
2
2
  //
3
- // The inline review agent (src/agent.ts) and the agent that *launched* redline
3
+ // The inline review agent (src/agent.ts) and the agent that *authored/launched* redline
4
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
5
+ // authoring agent only regains control when the session exits. So at closeout
6
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.
7
+ // including feedback meant for the authoring agent — lands in front of it.
8
8
  //
9
9
  // Comments the inline agent explicitly flagged (`escalate: true`) get a
10
10
  // dedicated section so they aren't lost in the full transcript.
@@ -15,7 +15,7 @@ export interface EscalationItem {
15
15
  round: number;
16
16
  quote: string;
17
17
  request: string; // the reviewer message the inline agent couldn't act on
18
- note: string; // the agent's escalation note
18
+ note: string; // the agent's author-handoff note
19
19
  }
20
20
 
21
21
  function flatten(s: string, n: number): string {
@@ -24,19 +24,24 @@ function flatten(s: string, n: number): string {
24
24
  }
25
25
 
26
26
  function isEscalated(c: Comment): boolean {
27
- return c.thread.some((e) => e.role === "agent" && e.escalate === true);
27
+ const escIdx = latestIndex(c.thread, (e) => e.role === "agent" && e.escalate === true);
28
+ if (escIdx === -1) return false;
29
+ const authorIdx = latestIndex(c.thread, (e) => e.role === "agent" && e.author === true);
30
+ return authorIdx < escIdx;
28
31
  }
29
32
 
30
- // Pull out every comment the inline agent routed to the launching agent.
33
+ // Pull out every comment the inline agent routed to the authoring agent.
31
34
  export function collectEscalations(sidecar: Sidecar): EscalationItem[] {
32
35
  const items: EscalationItem[] = [];
33
36
  for (const round of sidecar.rounds) {
34
37
  for (const c of round.comments) {
35
- const escIdx = c.thread.findIndex((e) => e.role === "agent" && e.escalate === true);
38
+ const escIdx = latestIndex(c.thread, (e) => e.role === "agent" && e.escalate === true);
36
39
  if (escIdx === -1) continue;
40
+ const authorIdx = latestIndex(c.thread, (e) => e.role === "agent" && e.author === true);
41
+ if (authorIdx > escIdx) continue;
37
42
  const agentEntry = c.thread[escIdx]!;
38
- // The reviewer message immediately before the escalation is the request
39
- // the inline agent couldn't fulfill.
43
+ // The reviewer message immediately before the author handoff is the
44
+ // request the inline agent couldn't fulfill.
40
45
  let request = "";
41
46
  for (let i = escIdx - 1; i >= 0; i--) {
42
47
  if (c.thread[i]!.role === "human") { request = c.thread[i]!.message; break; }
@@ -52,8 +57,15 @@ export function collectEscalations(sidecar: Sidecar): EscalationItem[] {
52
57
  return items;
53
58
  }
54
59
 
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.
60
+ function latestIndex(thread: Comment["thread"], predicate: (entry: Comment["thread"][number]) => boolean): number {
61
+ for (let i = thread.length - 1; i >= 0; i--) {
62
+ if (predicate(thread[i]!)) return i;
63
+ }
64
+ return -1;
65
+ }
66
+
67
+ // A readable transcript of every comment thread, plus an author-handoff callout.
68
+ // Printed to stdout on session close so the authoring agent can read it.
57
69
  export function formatReviewSummary(sidecar: Sidecar): string {
58
70
  const lines: string[] = [`Review threads — ${sidecar.file}`];
59
71
 
@@ -62,7 +74,7 @@ export function formatReviewSummary(sidecar: Sidecar): string {
62
74
  lines.push("", `Round ${round.round}`);
63
75
  round.comments.forEach((c, i) => {
64
76
  const tags = [c.resolved ? "resolved" : "open"];
65
- if (isEscalated(c)) tags.push("escalated");
77
+ if (isEscalated(c)) tags.push("author reply needed");
66
78
  lines.push(` ${i + 1}. "${flatten(c.quote, 80)}" — ${tags.join(" · ")}`);
67
79
  for (const e of c.thread) {
68
80
  const who = e.role === "human" ? "Reviewer" : (e.name || "Agent");
@@ -75,7 +87,7 @@ export function formatReviewSummary(sidecar: Sidecar): string {
75
87
  if (esc.length > 0) {
76
88
  lines.push(
77
89
  "",
78
- `⚠ ${esc.length} comment${esc.length !== 1 ? "s" : ""} escalated to you (the launching agent):`,
90
+ `⚠ ${esc.length} comment${esc.length !== 1 ? "s need" : " needs"} an author reply from you:`,
79
91
  );
80
92
  for (const e of esc) {
81
93
  lines.push(` • "${e.quote}" (round ${e.round})`);
@@ -84,7 +96,7 @@ export function formatReviewSummary(sidecar: Sidecar): string {
84
96
  }
85
97
  lines.push(
86
98
  "",
87
- "The inline review agent couldn't act on these. Address them in the",
99
+ "The inline review agent marked these for author-level input. Address them in the",
88
100
  "document or with the user before considering the review closed out.",
89
101
  );
90
102
  }
package/src/server.ts CHANGED
@@ -472,6 +472,7 @@ export function createServer(
472
472
  requires_revision?: boolean;
473
473
  revision_reason?: string;
474
474
  escalate?: boolean;
475
+ author?: boolean;
475
476
  }>();
476
477
  if (!body.message?.trim()) return c.json({ ok: false, error: "message is required" }, 400);
477
478
  const role = (body.role === "human" ? "human" : "agent") as "human" | "agent";
@@ -491,12 +492,13 @@ export function createServer(
491
492
  if (body.revision_reason?.trim()) entry.revision_reason = body.revision_reason.trim();
492
493
  }
493
494
  if (body.escalate === true) entry.escalate = true;
495
+ if (body.author === true) entry.author = true;
494
496
  }
495
497
  comment.thread.push(entry);
496
498
  return { skip: false as const, roundNumber: round.round, comment };
497
499
  });
498
500
  if (out.skip) return c.json({ ok: false, error: out.error }, out.status as 400 | 404);
499
- if (role === "human") {
501
+ if (role === "human" || (role === "agent" && body.author === true)) {
500
502
  broadcast("comment-reply", { round: out.roundNumber, commentId: id });
501
503
  }
502
504
  return c.json({ ok: true, comment: out.comment });
package/src/sidecar.ts CHANGED
@@ -14,10 +14,14 @@ export interface ThreadEntry {
14
14
  requires_revision?: boolean;
15
15
  revision_reason?: string;
16
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.
17
+ // this review — it needs author-level context, tools, or authority the inline
18
+ // agent lacks. Surfaced in the closeout summary so the authoring agent picks
19
+ // it up. Only set on agent entries.
20
20
  escalate?: boolean;
21
+ // Set true on an agent entry posted by the authoring/launching agent rather
22
+ // than the inline review agent. This lets the UI distinguish author replies
23
+ // without changing the legacy role shape.
24
+ author?: boolean;
21
25
  }
22
26
 
23
27
  // Latest agent verdict on a comment thread: