@levistudio/redline 0.1.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 +37 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/ROADMAP.md +39 -0
- package/SECURITY.md +33 -0
- package/bin/redline.cjs +61 -0
- package/package.json +61 -0
- package/scripts/install-skill.sh +78 -0
- package/skills/redline-review/SKILL.md +102 -0
- package/src/agent.ts +283 -0
- package/src/cli.ts +332 -0
- package/src/client/cards.ts +385 -0
- package/src/client/diff.ts +100 -0
- package/src/client/firstRunBanner.ts +26 -0
- package/src/client/lib.ts +299 -0
- package/src/client/main.ts +119 -0
- package/src/client/render.ts +413 -0
- package/src/client/selection.ts +253 -0
- package/src/client/sse.ts +179 -0
- package/src/client/state.ts +56 -0
- package/src/client/styles.css +994 -0
- package/src/contextBlock.ts +16 -0
- package/src/diff.ts +166 -0
- package/src/parseReply.ts +115 -0
- package/src/pickModel.ts +38 -0
- package/src/promptEnvelope.ts +58 -0
- package/src/render.ts +83 -0
- package/src/resolve.ts +290 -0
- package/src/server-page.ts +119 -0
- package/src/server.ts +634 -0
- package/src/sidecar.ts +190 -0
package/src/agent.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { existsSync, unlinkSync } from "fs";
|
|
3
|
+
import { appendFile, mkdir, readFile } from "fs/promises";
|
|
4
|
+
import { resolve } from "./resolve";
|
|
5
|
+
import { pickReplyModel } from "./pickModel";
|
|
6
|
+
import { parseReply } from "./parseReply";
|
|
7
|
+
import { newEnvelope } from "./promptEnvelope";
|
|
8
|
+
import { contextBlock } from "./contextBlock";
|
|
9
|
+
import { loadSidecar } from "./sidecar";
|
|
10
|
+
|
|
11
|
+
const filePath = process.argv[2];
|
|
12
|
+
if (!filePath) {
|
|
13
|
+
console.error("[agent] Usage: agent <file.md>");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Test-only crash hook: shortly after startup, if REDLINE_AGENT_CRASH_FILE
|
|
18
|
+
// points to an existing file, delete it and exit non-zero. The delay is so
|
|
19
|
+
// the agent connects to SSE and logs "[agent] connected" first — the test
|
|
20
|
+
// then waits for a *second* connect to prove the restart fired. Unlink
|
|
21
|
+
// ensures the restart spawn starts cleanly.
|
|
22
|
+
const crashFile = process.env.REDLINE_AGENT_CRASH_FILE;
|
|
23
|
+
if (crashFile) {
|
|
24
|
+
setTimeout(() => {
|
|
25
|
+
if (!existsSync(crashFile)) return;
|
|
26
|
+
console.log(`[agent] crash hook fired — exiting (file=${crashFile})`);
|
|
27
|
+
try { unlinkSync(crashFile); } catch { /* best effort */ }
|
|
28
|
+
process.exit(99);
|
|
29
|
+
}, 500);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Test-only "always crash" hook: every spawn exits non-zero after a short
|
|
33
|
+
// delay, so the cli's restart cap can be exercised end-to-end. Unlike the
|
|
34
|
+
// crash-file hook above, this leaves no trigger to delete — every spawn
|
|
35
|
+
// crashes until the cli gives up.
|
|
36
|
+
if (process.env.REDLINE_AGENT_CRASH_ALWAYS === "1") {
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
console.log("[agent] crash-always hook fired — exiting");
|
|
39
|
+
process.exit(99);
|
|
40
|
+
}, 100);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const BASE_URL = `http://localhost:${process.env.REDLINE_PORT ?? "3000"}`;
|
|
44
|
+
const CSRF_TOKEN = process.env.REDLINE_TOKEN ?? "";
|
|
45
|
+
|
|
46
|
+
// Inject `X-Redline-Token` on every mutating call back to the server. The
|
|
47
|
+
// server rejects unauthenticated POST/DELETE/PUT/PATCH on /api/*; without
|
|
48
|
+
// this header the agent's replies would be silently 403'd.
|
|
49
|
+
const CSRF_HEADER = { "X-Redline-Token": CSRF_TOKEN };
|
|
50
|
+
|
|
51
|
+
async function logReplyFailure(commentId: string, err: unknown) {
|
|
52
|
+
const logDir = path.join(path.dirname(path.resolve(filePath)), ".review");
|
|
53
|
+
await mkdir(logDir, { recursive: true });
|
|
54
|
+
const logPath = path.join(logDir, "errors.log");
|
|
55
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
56
|
+
const stack = err instanceof Error && err.stack ? `\n${err.stack}` : "";
|
|
57
|
+
const entry =
|
|
58
|
+
`\n=== ${new Date().toISOString()} — reply failure — comment ${commentId} ===\n` +
|
|
59
|
+
`file: ${path.resolve(filePath)}\n` +
|
|
60
|
+
`reason: ${msg}${stack}\n` +
|
|
61
|
+
`===\n`;
|
|
62
|
+
try {
|
|
63
|
+
await appendFile(logPath, entry, "utf-8");
|
|
64
|
+
console.error(`[agent] Logged reply failure → .review/errors.log`);
|
|
65
|
+
} catch {
|
|
66
|
+
// best-effort — don't mask the original error
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function replySystemPrompt(envelopeHint: string): string {
|
|
71
|
+
return REPLY_SYSTEM_PROMPT_BODY + "\n\n" + envelopeHint;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const REPLY_SYSTEM_PROMPT_BODY =
|
|
75
|
+
"You are an AI writing assistant responding to inline review comments on a Markdown document. " +
|
|
76
|
+
"The reviewer has selected a passage and left a comment or question. Reply in the cards rail — keep it tight.\n" +
|
|
77
|
+
"- Match reply length to the reviewer's comment. A one-line comment gets a one-line reply. A typo flag gets \"Got it\" or similar.\n" +
|
|
78
|
+
"- Only propose replacement text or alternatives when the reviewer explicitly asks for them. Don't volunteer options they didn't request.\n" +
|
|
79
|
+
"- If the reviewer asks a question, answer it directly. If they approve something, a short confirmation is enough.\n" +
|
|
80
|
+
"- Never recap what they said back to them. No preamble. Start with the substance.\n" +
|
|
81
|
+
"- Hard ceiling: 3 sentences unless the reviewer's comment genuinely requires more.\n" +
|
|
82
|
+
"\n" +
|
|
83
|
+
"After your reply, decide: would fully addressing this comment require editing the document itself, or was it answered within the conversation?\n" +
|
|
84
|
+
"- requires_revision: true → an edit to the doc is implied (typo fix, rewording, restructure, content change, etc.)\n" +
|
|
85
|
+
"- requires_revision: false → the conversation answered it (clarifying question, approval, agent explanation that doesn't imply an edit)\n" +
|
|
86
|
+
"\n" +
|
|
87
|
+
"When requires_revision is true, the UI already shows the reason next to your reply — DO NOT restate the planned edit in your message. The message should engage with the reviewer (acknowledge, confirm, or briefly engage with their point); the reason describes the edit. Don't say the same thing twice.\n" +
|
|
88
|
+
"Bad: message: \"Will add a line 3 to the example.\" reason: \"Add a third line to the hard line breaks example\" ← redundant\n" +
|
|
89
|
+
"Good: message: \"Got it.\" reason: \"Add a third line to the hard line breaks example\"\n" +
|
|
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
|
+
"\n" +
|
|
92
|
+
"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
|
+
"REQUIRES_REVISION: <true|false>\n" +
|
|
94
|
+
"REASON: <one short sentence describing the edit, or empty>\n" +
|
|
95
|
+
"---MESSAGE---\n" +
|
|
96
|
+
"<your reply text — quotes, punctuation, anything>\n" +
|
|
97
|
+
"---END---";
|
|
98
|
+
|
|
99
|
+
const inProgress = new Set<string>();
|
|
100
|
+
|
|
101
|
+
async function fetchComments() {
|
|
102
|
+
const res = await fetch(`${BASE_URL}/api/comments`);
|
|
103
|
+
return res.json() as Promise<{ comments: any[]; roundResolved: boolean }>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function postThinking(commentId: string) {
|
|
107
|
+
await fetch(`${BASE_URL}/api/comment/${commentId}/thinking`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: CSRF_HEADER,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function postReply(
|
|
114
|
+
commentId: string,
|
|
115
|
+
message: string,
|
|
116
|
+
requires_revision: boolean,
|
|
117
|
+
revision_reason: string
|
|
118
|
+
) {
|
|
119
|
+
await fetch(`${BASE_URL}/api/comment/${commentId}/reply`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "Content-Type": "application/json", ...CSRF_HEADER },
|
|
122
|
+
body: JSON.stringify({ role: "agent", name: "Claude", message, requires_revision, revision_reason }),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function postAgentReplied() {
|
|
127
|
+
await fetch(`${BASE_URL}/api/agent-replied`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: CSRF_HEADER,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function handleComment(commentId: string) {
|
|
134
|
+
if (inProgress.has(commentId)) return;
|
|
135
|
+
inProgress.add(commentId);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const data = await fetchComments();
|
|
139
|
+
const comment = data.comments.find((c: any) => c.id === commentId);
|
|
140
|
+
if (!comment || comment.resolved) return;
|
|
141
|
+
|
|
142
|
+
// Only respond when the last message was from the human
|
|
143
|
+
const thread: any[] = comment.thread ?? [];
|
|
144
|
+
if (thread.length === 0 || thread[thread.length - 1].role !== "human") return;
|
|
145
|
+
|
|
146
|
+
await postThinking(commentId);
|
|
147
|
+
|
|
148
|
+
const docText = await readFile(path.resolve(filePath), "utf-8");
|
|
149
|
+
const sidecar = await loadSidecar(path.resolve(filePath));
|
|
150
|
+
const env = newEnvelope();
|
|
151
|
+
const threadText = thread
|
|
152
|
+
.map((e: any) => `${e.role === "human" ? "Reviewer" : "Agent"}: ${e.message}`)
|
|
153
|
+
.join("\n");
|
|
154
|
+
|
|
155
|
+
// Wrap every user-controlled string (document, quote, thread, optional
|
|
156
|
+
// reviewer-supplied context) in a per-prompt UUID-anchored envelope so
|
|
157
|
+
// adversarial content inside any of them can't masquerade as system
|
|
158
|
+
// instructions or as another section. The context block is empty when
|
|
159
|
+
// the user didn't pass --context.
|
|
160
|
+
const userMessage =
|
|
161
|
+
contextBlock(sidecar.context, env) +
|
|
162
|
+
`## Document\n\n${env.wrap("document", docText)}\n\n---\n\n` +
|
|
163
|
+
`## Comment\n\nQuoted passage:\n${env.wrap("quote", comment.quote)}\n\n` +
|
|
164
|
+
`Thread:\n${env.wrap("thread", threadText)}`;
|
|
165
|
+
|
|
166
|
+
const lastMessage = thread[thread.length - 1].message as string;
|
|
167
|
+
const model = pickReplyModel(lastMessage);
|
|
168
|
+
console.log(`[agent] replying to ${commentId} with ${model}`);
|
|
169
|
+
|
|
170
|
+
const cliBin = process.env.CLAUDE_CODE_EXECPATH ?? "claude";
|
|
171
|
+
const proc = Bun.spawn(
|
|
172
|
+
[cliBin, "-p", "--system-prompt", replySystemPrompt(env.systemPromptHint()), "--model", model],
|
|
173
|
+
{ stdin: "pipe", stdout: "pipe", stderr: "inherit" }
|
|
174
|
+
);
|
|
175
|
+
proc.stdin.write(userMessage);
|
|
176
|
+
proc.stdin.end();
|
|
177
|
+
|
|
178
|
+
let reply = "";
|
|
179
|
+
const reader = proc.stdout.getReader();
|
|
180
|
+
while (true) {
|
|
181
|
+
const { done, value } = await reader.read();
|
|
182
|
+
if (done) break;
|
|
183
|
+
reply += new TextDecoder().decode(value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (reply.trim()) {
|
|
187
|
+
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);
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
await logReplyFailure(commentId, err);
|
|
193
|
+
throw err;
|
|
194
|
+
} finally {
|
|
195
|
+
inProgress.delete(commentId);
|
|
196
|
+
if (inProgress.size === 0) {
|
|
197
|
+
postAgentReplied().catch((e) => console.error("[agent] postAgentReplied failed:", e));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function handleAccepted() {
|
|
203
|
+
console.log("[agent] accepted — running revision...");
|
|
204
|
+
try {
|
|
205
|
+
await resolve(path.resolve(filePath));
|
|
206
|
+
} catch (err: any) {
|
|
207
|
+
const msg = err?.message ?? String(err);
|
|
208
|
+
console.error("[agent] revision failed:", msg);
|
|
209
|
+
try {
|
|
210
|
+
await fetch(`${BASE_URL}/api/revision-error`, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { "Content-Type": "application/json", ...CSRF_HEADER },
|
|
213
|
+
body: JSON.stringify({ message: msg }),
|
|
214
|
+
});
|
|
215
|
+
} catch { /* server may be down — non-fatal */ }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function handleEvent(type: string, payload: any) {
|
|
220
|
+
if (type === "comment-added" || type === "comment-reply") {
|
|
221
|
+
handleComment(payload.commentId).catch((e) =>
|
|
222
|
+
console.error("[agent] error handling comment:", e)
|
|
223
|
+
);
|
|
224
|
+
} else if (type === "accepted") {
|
|
225
|
+
handleAccepted().catch((e) =>
|
|
226
|
+
console.error("[agent] error handling accepted:", e)
|
|
227
|
+
);
|
|
228
|
+
} else if (type === "finished") {
|
|
229
|
+
console.log("[agent] review finished — no revision needed");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function connect() {
|
|
234
|
+
let retryDelay = 1000;
|
|
235
|
+
|
|
236
|
+
while (true) {
|
|
237
|
+
try {
|
|
238
|
+
const res = await fetch(`${BASE_URL}/api/events`);
|
|
239
|
+
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
240
|
+
|
|
241
|
+
console.log("[agent] connected — listening for comments");
|
|
242
|
+
retryDelay = 1000;
|
|
243
|
+
|
|
244
|
+
const reader = res.body.getReader();
|
|
245
|
+
const decoder = new TextDecoder();
|
|
246
|
+
let buffer = "";
|
|
247
|
+
let eventType = "";
|
|
248
|
+
|
|
249
|
+
while (true) {
|
|
250
|
+
const { done, value } = await reader.read();
|
|
251
|
+
if (done) break;
|
|
252
|
+
|
|
253
|
+
buffer += decoder.decode(value, { stream: true });
|
|
254
|
+
const lines = buffer.split("\n");
|
|
255
|
+
buffer = lines.pop() ?? "";
|
|
256
|
+
|
|
257
|
+
for (const line of lines) {
|
|
258
|
+
if (line.startsWith("event: ")) {
|
|
259
|
+
eventType = line.slice(7).trim();
|
|
260
|
+
} else if (line.startsWith("data: ")) {
|
|
261
|
+
const data = line.slice(6).trim();
|
|
262
|
+
try {
|
|
263
|
+
const payload = JSON.parse(data);
|
|
264
|
+
handleEvent(eventType, payload).catch((e) =>
|
|
265
|
+
console.error("[agent] unhandled event error:", e)
|
|
266
|
+
);
|
|
267
|
+
} catch {}
|
|
268
|
+
eventType = "";
|
|
269
|
+
} else if (line === "") {
|
|
270
|
+
eventType = "";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// server not up yet or restarting — keep retrying
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await new Promise((r) => setTimeout(r, retryDelay));
|
|
279
|
+
retryDelay = Math.min(retryDelay * 2, 10000);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
connect();
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync, statSync } from "fs";
|
|
3
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import path from "path";
|
|
7
|
+
|
|
8
|
+
// Ensure redline's own dependencies are resolvable before any third-party
|
|
9
|
+
// imports load. `redline` is invoked from arbitrary projects: in a checkout
|
|
10
|
+
// they sit in `<root>/node_modules`, but when installed from npm they're
|
|
11
|
+
// hoisted to the consumer's top-level `node_modules`. Use Node's standard
|
|
12
|
+
// module resolution so both layouts are handled.
|
|
13
|
+
//
|
|
14
|
+
// The auto-install fallback is for the checkout case (`git clone` + run
|
|
15
|
+
// without `bun install`). When already installed from a registry, deps are
|
|
16
|
+
// always resolvable and we never fall through to it.
|
|
17
|
+
function preflightDependencies() {
|
|
18
|
+
const root = path.resolve(import.meta.dir, "..");
|
|
19
|
+
const pkgPath = path.join(root, "package.json");
|
|
20
|
+
// Compiled single-file binaries have no package.json next to the script —
|
|
21
|
+
// skip the check entirely in that case.
|
|
22
|
+
if (!existsSync(pkgPath)) return;
|
|
23
|
+
let pkg: { dependencies?: Record<string, string> };
|
|
24
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, "utf8")); } catch { return; }
|
|
25
|
+
const deps = Object.keys(pkg.dependencies ?? {});
|
|
26
|
+
const require = createRequire(pkgPath);
|
|
27
|
+
const missing = deps.filter((d) => {
|
|
28
|
+
try { require.resolve(d); return false; } catch { return true; }
|
|
29
|
+
});
|
|
30
|
+
if (missing.length === 0) return;
|
|
31
|
+
console.log(`[redline] Dependencies missing from ${root}/node_modules: ${missing.join(", ")}`);
|
|
32
|
+
console.log(`[redline] Running \`bun install\` in the redline checkout…`);
|
|
33
|
+
const r = spawnSync("bun", ["install"], { cwd: root, stdio: "inherit" });
|
|
34
|
+
if (r.status !== 0) {
|
|
35
|
+
console.error(`\n[redline] \`bun install\` failed. Run it manually in ${root} and retry.`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
preflightDependencies();
|
|
40
|
+
|
|
41
|
+
// Dynamic imports so preflight runs before module resolution pulls in third-party deps.
|
|
42
|
+
const { createServer } = await import("./server");
|
|
43
|
+
const { resolve } = await import("./resolve");
|
|
44
|
+
|
|
45
|
+
// Verify the `claude` CLI is reachable before we start, so first-run users
|
|
46
|
+
// without Claude Code installed get a clear message instead of a silent agent
|
|
47
|
+
// crash later (the agent process shells out to `claude -p`; without it, replies
|
|
48
|
+
// fail and errors land in `.review/errors.log` where nobody looks).
|
|
49
|
+
//
|
|
50
|
+
// Resolution mirrors the runtime: prefer CLAUDE_CODE_EXECPATH (used by tests
|
|
51
|
+
// and advanced setups), then look for `claude` on PATH.
|
|
52
|
+
function preflightClaudeCli() {
|
|
53
|
+
const exec = process.env.CLAUDE_CODE_EXECPATH;
|
|
54
|
+
if (exec && existsSync(exec)) return;
|
|
55
|
+
if (Bun.which("claude")) return;
|
|
56
|
+
console.error(
|
|
57
|
+
"\n[redline] Could not find the `claude` CLI on PATH.\n" +
|
|
58
|
+
"Redline shells out to Claude Code for agent replies and revisions.\n" +
|
|
59
|
+
"Install it from https://claude.com/claude-code and re-run.\n"
|
|
60
|
+
);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Walk up from `start` looking for a git root (a `.git` directory or file —
|
|
65
|
+
// worktrees use a file). Returns the directory containing it, or null.
|
|
66
|
+
function findGitRoot(start: string): string | null {
|
|
67
|
+
let dir = start;
|
|
68
|
+
while (true) {
|
|
69
|
+
if (existsSync(path.join(dir, ".git"))) return dir;
|
|
70
|
+
const parent = path.dirname(dir);
|
|
71
|
+
if (parent === dir) return null;
|
|
72
|
+
dir = parent;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// If the file lives inside a git repo and `.review/` isn't already ignored,
|
|
77
|
+
// print a one-line hint. We don't edit .gitignore — just nudge.
|
|
78
|
+
function maybePrintGitignoreHint(filePath: string) {
|
|
79
|
+
const root = findGitRoot(path.dirname(filePath));
|
|
80
|
+
if (!root) return;
|
|
81
|
+
const gitignorePath = path.join(root, ".gitignore");
|
|
82
|
+
let contents = "";
|
|
83
|
+
try {
|
|
84
|
+
if (existsSync(gitignorePath) && statSync(gitignorePath).isFile()) {
|
|
85
|
+
contents = readFileSync(gitignorePath, "utf-8");
|
|
86
|
+
}
|
|
87
|
+
} catch { /* unreadable — fall through and hint */ }
|
|
88
|
+
const lines = contents.split("\n").map((l) => l.trim());
|
|
89
|
+
const ignored = lines.some((l) =>
|
|
90
|
+
l === ".review" || l === ".review/" || l === "**/.review" || l === "**/.review/"
|
|
91
|
+
);
|
|
92
|
+
if (ignored) return;
|
|
93
|
+
console.log(`\n Tip: redline writes to .review/ next to your file. Add this to ${path.relative(process.cwd(), gitignorePath) || ".gitignore"} to keep it out of git:`);
|
|
94
|
+
console.log(` .review/`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const args = process.argv.slice(2);
|
|
98
|
+
|
|
99
|
+
// redline resolve <file> [--model <id>]
|
|
100
|
+
if (args[0] === "resolve") {
|
|
101
|
+
const filePath = args[1];
|
|
102
|
+
if (!filePath) {
|
|
103
|
+
console.error("Usage: redline resolve <file.md> [--model <model-id>]");
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const resolved = path.resolve(filePath);
|
|
107
|
+
if (!existsSync(resolved)) {
|
|
108
|
+
console.error(`File not found: ${resolved}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
const modelFlag = args.indexOf("--model");
|
|
112
|
+
const model = modelFlag !== -1 ? args[modelFlag + 1] : undefined;
|
|
113
|
+
preflightClaudeCli();
|
|
114
|
+
resolve(resolved, { model });
|
|
115
|
+
} else {
|
|
116
|
+
// redline <file> — open review reader
|
|
117
|
+
const filePath = args[0];
|
|
118
|
+
if (!filePath) {
|
|
119
|
+
console.error("Usage: redline <file.md>");
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const resolved = path.resolve(filePath);
|
|
123
|
+
if (!existsSync(resolved)) {
|
|
124
|
+
console.error(`File not found: ${resolved}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
const noAgent = args.includes("--no-agent");
|
|
128
|
+
|
|
129
|
+
// Manual annotation mode skips both the preflight and the agent spawn —
|
|
130
|
+
// the user just wants inline comments without a Claude conversation, so
|
|
131
|
+
// requiring claude on PATH would be a hostile gate.
|
|
132
|
+
if (!noAgent) preflightClaudeCli();
|
|
133
|
+
|
|
134
|
+
const contextFlag = args.indexOf("--context");
|
|
135
|
+
const context = contextFlag !== -1 ? args[contextFlag + 1] : undefined;
|
|
136
|
+
const autoOpen = args.includes("--open");
|
|
137
|
+
|
|
138
|
+
const resultFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".result");
|
|
139
|
+
const startupFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".startup.json");
|
|
140
|
+
|
|
141
|
+
// Clear stale state from a prior run so a polling agent can't be misled
|
|
142
|
+
// by a leftover .result or .startup.json file that predates this process.
|
|
143
|
+
for (const f of [resultFile, startupFile]) {
|
|
144
|
+
try { unlinkSync(f); } catch { /* not present is fine */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function writeResult(payload: Record<string, unknown>) {
|
|
148
|
+
try {
|
|
149
|
+
await mkdir(path.dirname(resultFile), { recursive: true });
|
|
150
|
+
await writeFile(resultFile, JSON.stringify(payload, null, 2), "utf-8");
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error("[redline] Failed to write result file:", e);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// CSRF token threaded through to: createServer (mints from this), the agent
|
|
157
|
+
// subprocess (via REDLINE_TOKEN env), and startup.json (so a calling skill
|
|
158
|
+
// or test runner can read it). `REDLINE_TOKEN` from env wins so an outer
|
|
159
|
+
// caller can pin the token if it needs to.
|
|
160
|
+
const csrfToken = process.env.REDLINE_TOKEN ?? crypto.randomUUID();
|
|
161
|
+
|
|
162
|
+
const app = createServer(resolved, { context, csrfToken, noAgent });
|
|
163
|
+
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: app.fetch, idleTimeout: 0 });
|
|
164
|
+
const url = `http://localhost:${server.port}`;
|
|
165
|
+
|
|
166
|
+
// Surface the URL to a calling agent that can't read this process's stdout.
|
|
167
|
+
// (Bash with timeout buffers stdout until the process exits — for blocking
|
|
168
|
+
// invocations the agent would otherwise never see the URL until the human
|
|
169
|
+
// clicked Done. Polling this file gives a deterministic, race-free signal.)
|
|
170
|
+
try {
|
|
171
|
+
mkdirSync(path.dirname(startupFile), { recursive: true });
|
|
172
|
+
writeFileSync(startupFile, JSON.stringify({
|
|
173
|
+
url,
|
|
174
|
+
port: server.port,
|
|
175
|
+
file: resolved,
|
|
176
|
+
result_file: resultFile,
|
|
177
|
+
started_at: new Date().toISOString(),
|
|
178
|
+
pid: process.pid,
|
|
179
|
+
csrf_token: csrfToken,
|
|
180
|
+
}, null, 2));
|
|
181
|
+
} catch (e) {
|
|
182
|
+
console.error("[redline] Failed to write startup file:", e);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const bar = "─".repeat(60);
|
|
186
|
+
console.log(`\n${bar}`);
|
|
187
|
+
console.log(`Redline review session`);
|
|
188
|
+
console.log(` File: ${resolved}`);
|
|
189
|
+
console.log(` URL: ${url}`);
|
|
190
|
+
console.log(` Result: ${resultFile}`);
|
|
191
|
+
console.log(`${bar}`);
|
|
192
|
+
if (noAgent) console.log(` Mode: manual annotation (--no-agent — no Claude replies, no revision pass)`);
|
|
193
|
+
if (!autoOpen) console.log(`\n → cmd-click the URL when you're ready to review\n`);
|
|
194
|
+
else console.log("");
|
|
195
|
+
|
|
196
|
+
maybePrintGitignoreHint(resolved);
|
|
197
|
+
|
|
198
|
+
// Auto-restart the agent if it dies unexpectedly (harness reaping, OOM,
|
|
199
|
+
// a transient claude-CLI auth blip, etc). Capped to MAX_RESTARTS within
|
|
200
|
+
// RESTART_WINDOW_MS so a permanently-broken environment doesn't loop forever.
|
|
201
|
+
const RESTART_WINDOW_MS = 60_000;
|
|
202
|
+
// Cap is overrideable via env so integration tests can exercise the
|
|
203
|
+
// give-up path without spawning the agent six times.
|
|
204
|
+
const MAX_RESTARTS = Number(process.env.REDLINE_MAX_RESTARTS ?? 5);
|
|
205
|
+
const restartTimes: number[] = [];
|
|
206
|
+
let agentProc: ReturnType<typeof Bun.spawn>;
|
|
207
|
+
let serverExiting = false;
|
|
208
|
+
|
|
209
|
+
function spawnAgent() {
|
|
210
|
+
const proc = Bun.spawn(
|
|
211
|
+
[process.execPath, "run", path.join(import.meta.dir, "agent.ts"), resolved],
|
|
212
|
+
{
|
|
213
|
+
stdout: "inherit", stderr: "inherit", stdin: "ignore",
|
|
214
|
+
env: { ...process.env, REDLINE_PORT: String(server.port), REDLINE_TOKEN: csrfToken },
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
agentProc = proc;
|
|
218
|
+
proc.exited.then((code) => {
|
|
219
|
+
if (serverExiting) return;
|
|
220
|
+
if (code === 0) return;
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
while (restartTimes.length && now - restartTimes[0] > RESTART_WINDOW_MS) restartTimes.shift();
|
|
223
|
+
if (restartTimes.length >= MAX_RESTARTS) {
|
|
224
|
+
const reason = `Agent crashed ${restartTimes.length}× in ${RESTART_WINDOW_MS / 1000}s — replies unavailable. Restart redline to recover.`;
|
|
225
|
+
console.error(
|
|
226
|
+
`\n[redline] ${reason} The review can still be completed manually. Check .review/errors.log.`
|
|
227
|
+
);
|
|
228
|
+
// Surface the dead-agent state to the browser so the user isn't left
|
|
229
|
+
// wondering why replies stopped. Best-effort: server may already be down.
|
|
230
|
+
fetch(`http://localhost:${server.port}/api/agent-unavailable`, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: { "Content-Type": "application/json", "X-Redline-Token": csrfToken },
|
|
233
|
+
body: JSON.stringify({ reason }),
|
|
234
|
+
}).catch(() => { /* server already gone */ });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
restartTimes.push(now);
|
|
238
|
+
console.error(
|
|
239
|
+
`[redline] Agent exited unexpectedly (code ${code}) — restarting (${restartTimes.length}/${MAX_RESTARTS}).`
|
|
240
|
+
);
|
|
241
|
+
spawnAgent();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (!noAgent) spawnAgent();
|
|
245
|
+
|
|
246
|
+
// Graceful shutdown: SIGTERM first so agent.ts can flush in-flight HTTP
|
|
247
|
+
// posts and close its SSE connection cleanly, then SIGKILL after 2s if
|
|
248
|
+
// the agent is still alive (broken handler, stuck syscall, etc.).
|
|
249
|
+
// Async-aware version for the abandon/finish paths; the sync version
|
|
250
|
+
// (`killAgentSync`) is used inside `process.on("exit")` where we can't await.
|
|
251
|
+
const SHUTDOWN_GRACE_MS = 2000;
|
|
252
|
+
async function killAgent() {
|
|
253
|
+
if (!agentProc) return;
|
|
254
|
+
try { agentProc.kill("SIGTERM"); } catch { return; /* already dead */ }
|
|
255
|
+
const deadline = new Promise<void>((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS));
|
|
256
|
+
await Promise.race([agentProc.exited.then(() => undefined), deadline]);
|
|
257
|
+
if (agentProc && agentProc.exitCode === null) {
|
|
258
|
+
try { agentProc.kill("SIGKILL"); } catch { /* already dead */ }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function killAgentSync() {
|
|
262
|
+
try { agentProc?.kill("SIGTERM"); } catch { /* already dead */ }
|
|
263
|
+
}
|
|
264
|
+
// Tracks the last unrecovered revision failure. If the session abandons while
|
|
265
|
+
// this is set, the result file reports "error" instead of "abandoned" so a
|
|
266
|
+
// calling agent can distinguish "user walked away" from "revision broke."
|
|
267
|
+
let lastRevisionError: string | null = null;
|
|
268
|
+
|
|
269
|
+
const abandon = () => {
|
|
270
|
+
if (serverExiting) return;
|
|
271
|
+
serverExiting = true;
|
|
272
|
+
killAgent().catch(() => { /* shutdown already in flight */ });
|
|
273
|
+
try { unlinkSync(startupFile); } catch { /* best effort */ }
|
|
274
|
+
const status = lastRevisionError ? "error" : "abandoned";
|
|
275
|
+
const payload: Record<string, unknown> = { status, file: resolved };
|
|
276
|
+
if (lastRevisionError) payload.reason = lastRevisionError;
|
|
277
|
+
// Synchronous write so the result file lands even if the runtime is
|
|
278
|
+
// terminating due to a signal (async I/O may not complete in that case).
|
|
279
|
+
try {
|
|
280
|
+
mkdirSync(path.dirname(resultFile), { recursive: true });
|
|
281
|
+
writeFileSync(resultFile, JSON.stringify(payload, null, 2));
|
|
282
|
+
} catch { /* best effort */ }
|
|
283
|
+
console.log(`\nREDLINE_RESULT: ${status}${lastRevisionError ? ` reason="${lastRevisionError}"` : ""}`);
|
|
284
|
+
// Exit 3 = revision error; 2 = abandoned. Both still distinguish from 0 (approved).
|
|
285
|
+
process.exit(lastRevisionError ? 3 : 2);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Happy-path finish: human clicked Done.
|
|
289
|
+
app.onFinished(({ totalRounds, totalComments }) => {
|
|
290
|
+
serverExiting = true;
|
|
291
|
+
killAgent().catch(() => { /* shutdown already in flight */ });
|
|
292
|
+
try { unlinkSync(startupFile); } catch { /* best effort */ }
|
|
293
|
+
const line = "─".repeat(60);
|
|
294
|
+
console.log(`\n${line}`);
|
|
295
|
+
console.log(`✓ Review complete — ${path.basename(resolved)}`);
|
|
296
|
+
console.log(` ${totalRounds} round${totalRounds !== 1 ? "s" : ""} · ${totalComments} comment${totalComments !== 1 ? "s" : ""} addressed`);
|
|
297
|
+
console.log(` Revised document: ${resolved}`);
|
|
298
|
+
console.log(`${line}`);
|
|
299
|
+
// Machine-greppable result line for a calling agent. Keep this stable.
|
|
300
|
+
console.log(`REDLINE_RESULT: approved file=${resolved} rounds=${totalRounds} comments=${totalComments}`);
|
|
301
|
+
console.log("");
|
|
302
|
+
writeResult({ status: "approved", file: resolved, rounds: totalRounds, comments: totalComments })
|
|
303
|
+
.finally(() => process.exit(0));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Tab-close abandonment: if no browser reconnects within the abandon grace, exit cleanly.
|
|
307
|
+
app.onAbandon(abandon);
|
|
308
|
+
|
|
309
|
+
// Track revision-error state so a session that abandons after a broken revision
|
|
310
|
+
// exits with status: "error" rather than "abandoned".
|
|
311
|
+
app.onRevisionError((message) => {
|
|
312
|
+
lastRevisionError = message;
|
|
313
|
+
console.error(`[redline] Revision failed: ${message}`);
|
|
314
|
+
});
|
|
315
|
+
app.onRevisionRecovered(() => {
|
|
316
|
+
if (lastRevisionError) console.log("[redline] Revision-error state cleared.");
|
|
317
|
+
lastRevisionError = null;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
process.on("exit", () => { serverExiting = true; killAgentSync(); });
|
|
321
|
+
// SIGINT/SIGTERM = abandoned session. Exit 2 so a calling agent can
|
|
322
|
+
// distinguish "user gave up" from "user clicked Done" (exit 0).
|
|
323
|
+
process.on("SIGINT", abandon);
|
|
324
|
+
process.on("SIGTERM", abandon);
|
|
325
|
+
|
|
326
|
+
if (autoOpen) {
|
|
327
|
+
const open =
|
|
328
|
+
process.platform === "darwin" ? "open" :
|
|
329
|
+
process.platform === "win32" ? "start" : "xdg-open";
|
|
330
|
+
Bun.spawn([open, url]);
|
|
331
|
+
}
|
|
332
|
+
}
|