@fullstackhouse/cerber 0.9.1 → 0.10.1

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.
@@ -39,7 +39,12 @@ export const DEFAULT_TIMEOUT_MS = 30 * 60_000;
39
39
  /** How long a killed run gets to exit on its own before SIGKILL. */
40
40
  const SIGKILL_GRACE_MS = 5_000;
41
41
  export function buildClaudeArgs(opts) {
42
- const args = ["-p", "--output-format", "json"];
42
+ // stream-json, not json: the same final result arrives in the closing event,
43
+ // and everything before it is the run saying what it is doing. `--verbose` is
44
+ // not optional — the CLI refuses the combination without it.
45
+ const args = ["-p", "--output-format", "stream-json", "--verbose"];
46
+ if (opts.resumeSessionId)
47
+ args.push("--resume", opts.resumeSessionId);
43
48
  if (opts.model)
44
49
  args.push("--model", opts.model);
45
50
  if (opts.allowedTools?.length)
@@ -51,9 +56,39 @@ export function buildClaudeArgs(opts) {
51
56
  }
52
57
  return args;
53
58
  }
59
+ /**
60
+ * Split a stream-json stdout chunk into whole events, keeping whatever trailing
61
+ * fragment has not been terminated yet. A tool result can be megabytes, so a
62
+ * single event routinely arrives across several chunks.
63
+ */
64
+ export function readEvents(buffer) {
65
+ const lines = buffer.split("\n");
66
+ // The last piece has no newline after it — it may be half an event.
67
+ const rest = lines.pop() ?? "";
68
+ const events = [];
69
+ for (const line of lines) {
70
+ if (!line.trim())
71
+ continue;
72
+ try {
73
+ const parsed = JSON.parse(line);
74
+ if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
75
+ events.push(parsed);
76
+ }
77
+ }
78
+ catch {
79
+ // A line we can't parse is a line we don't need — the run's own result
80
+ // comes from the closing event, and dying here would throw away a
81
+ // finished review over a stray byte.
82
+ }
83
+ }
84
+ return { events, rest };
85
+ }
54
86
  /**
55
87
  * Run `claude -p` headless with the prompt on stdin, return the result text.
56
88
  * Rides the user's existing Claude Code login — no API key handling here.
89
+ *
90
+ * Reads the run as a stream so {@link ClaudeOptions.onEvent} can report what it
91
+ * is doing while it does it; the result itself is the closing event.
57
92
  */
58
93
  export function runClaude(prompt, opts = {}) {
59
94
  const args = buildClaudeArgs(opts);
@@ -63,7 +98,10 @@ export function runClaude(prompt, opts = {}) {
63
98
  cwd: opts.cwd,
64
99
  env: opts.env,
65
100
  });
66
- let stdout = "";
101
+ let unread = "";
102
+ let result = null;
103
+ /** Kept only to explain a failure — the events carry everything we use. */
104
+ let tail = "";
67
105
  let stderr = "";
68
106
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
69
107
  let settled = false;
@@ -87,7 +125,26 @@ export function runClaude(prompt, opts = {}) {
87
125
  child.stdin.destroy();
88
126
  settle(() => reject(new Error(`claude did not finish within ${Math.round(timeoutMs / 60_000)} minutes — run killed.`)));
89
127
  }, timeoutMs);
90
- child.stdout.on("data", (d) => (stdout += d));
128
+ const ingest = (chunk) => {
129
+ const read = readEvents(unread + chunk);
130
+ unread = read.rest;
131
+ for (const event of read.events) {
132
+ if (event.type === "result")
133
+ result = event;
134
+ // A throwing listener must not take the run down with it: the answer is
135
+ // the point, the narration is not.
136
+ try {
137
+ opts.onEvent?.(event);
138
+ }
139
+ catch {
140
+ // ignore
141
+ }
142
+ }
143
+ };
144
+ child.stdout.on("data", (d) => {
145
+ tail = (tail + d).slice(-2_000);
146
+ ingest(String(d));
147
+ });
91
148
  child.stderr.on("data", (d) => (stderr += d));
92
149
  child.on("error", (err) => {
93
150
  settle(() => reject(new Error(`Failed to start claude CLI: ${err.message}. Is Claude Code installed and on PATH?`)));
@@ -98,20 +155,24 @@ export function runClaude(prompt, opts = {}) {
98
155
  clearTimeout(timer);
99
156
  settled = true;
100
157
  if (code !== 0) {
101
- reject(new Error(`claude exited with code ${code}: ${stderr.trim() || stdout.slice(0, 500)}`));
158
+ reject(new Error(`claude exited with code ${code}: ${stderr.trim() || tail.slice(-500)}`));
102
159
  return;
103
160
  }
104
- try {
105
- const wrapper = JSON.parse(stdout);
106
- resolve({
107
- text: wrapper.result ?? "",
108
- costUsd: typeof wrapper.total_cost_usd === "number" ? wrapper.total_cost_usd : null,
109
- model: wrapper.modelUsage ? Object.keys(wrapper.modelUsage)[0] ?? null : null,
110
- });
111
- }
112
- catch {
113
- reject(new Error(`Could not parse claude output as JSON wrapper: ${stdout.slice(0, 500)}`));
161
+ // The last line need not be newline-terminated, and it is the one that
162
+ // matters: dropping it would throw away a finished review for the want
163
+ // of a trailing byte.
164
+ ingest("\n");
165
+ const wrapper = result;
166
+ if (!wrapper) {
167
+ reject(new Error(`claude produced no result event: ${tail.slice(-500)}`));
168
+ return;
114
169
  }
170
+ resolve({
171
+ text: typeof wrapper.result === "string" ? wrapper.result : "",
172
+ costUsd: typeof wrapper.total_cost_usd === "number" ? wrapper.total_cost_usd : null,
173
+ model: wrapper.modelUsage ? Object.keys(wrapper.modelUsage)[0] ?? null : null,
174
+ sessionId: typeof wrapper.session_id === "string" ? wrapper.session_id : null,
175
+ });
115
176
  });
116
177
  child.stdin.write(prompt);
117
178
  child.stdin.end();
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Turning a run's own events into a line a human can read while they wait.
3
+ *
4
+ * A review or a chat turn takes minutes, and until now the only honest thing
5
+ * cerber could say about that time was "thinking…". The run already narrates
6
+ * itself — what it is about to read, what it concluded, what it was refused —
7
+ * so this says that instead, in the same plain words the review itself uses:
8
+ * what happened, not the wire format it happened in.
9
+ *
10
+ * Ignoring is the default. An event we don't recognise, and anything that would
11
+ * leak the machinery (the JSON answer, tool results, session bookkeeping),
12
+ * returns null and never reaches the user.
13
+ *
14
+ * Pure: no I/O, no clock.
15
+ */
16
+ /** How much of any one line survives. Long enough to be useful in a sidebar. */
17
+ const MAX = 180;
18
+ export function describeEvent(event) {
19
+ if (event.type === "assistant") {
20
+ return (event.message?.content ?? []).flatMap((block) => {
21
+ if (block.type === "thinking")
22
+ return ["thinking…"];
23
+ if (block.type === "text") {
24
+ const text = (block.text ?? "").trim();
25
+ // The turn's answer is a JSON object; showing it raw would put the
26
+ // machinery on screen a second before the rendered version lands.
27
+ if (!text || looksLikeJson(text))
28
+ return [];
29
+ return [clip(collapse(text))];
30
+ }
31
+ if (block.type === "tool_use")
32
+ return [describeTool(block.name ?? "", block.input ?? {})];
33
+ return [];
34
+ });
35
+ }
36
+ // Cerber denies most tools on purpose. When the run reaches for one anyway,
37
+ // saying so beats a silent gap in the story.
38
+ if (event.type === "system" && event.subtype === "permission_denied") {
39
+ const tool = typeof event.tool_name === "string" ? event.tool_name : "a tool";
40
+ return [`not allowed to use ${tool}`];
41
+ }
42
+ return [];
43
+ }
44
+ function describeTool(name, input) {
45
+ const str = (key) => (typeof input[key] === "string" ? input[key] : "");
46
+ switch (name) {
47
+ case "Read": {
48
+ const file = short(str("file_path"));
49
+ return file ? `reading ${file}` : "reading a file";
50
+ }
51
+ case "Grep": {
52
+ const where = short(str("path"));
53
+ return clip(`searching for ${str("pattern")}${where ? ` in ${where}` : ""}`);
54
+ }
55
+ case "Glob":
56
+ return clip(`looking for files matching ${str("pattern")}`);
57
+ case "Bash": {
58
+ // The description is the model's own words for what it is about to run;
59
+ // the command is what it will actually do. The command is the honest one.
60
+ const command = collapse(str("command"));
61
+ return command ? clip(`running ${command}`) : "running a command";
62
+ }
63
+ case "WebSearch":
64
+ return clip(`searching the web for ${str("query")}`);
65
+ case "WebFetch":
66
+ return clip(`fetching ${str("url")}`);
67
+ case "TodoWrite":
68
+ return "planning the rest of the turn";
69
+ default:
70
+ return name ? `using ${name}` : "using a tool";
71
+ }
72
+ }
73
+ /** Paths in a checkout are absolute and long; the repo-relative tail is the useful part. */
74
+ function short(file) {
75
+ if (!file)
76
+ return "";
77
+ const parts = file.split("/").filter(Boolean);
78
+ return parts.length > 3 ? parts.slice(-3).join("/") : file;
79
+ }
80
+ function looksLikeJson(text) {
81
+ return text.startsWith("{") || text.startsWith("```");
82
+ }
83
+ function collapse(text) {
84
+ return text.replace(/\s+/g, " ").trim();
85
+ }
86
+ function clip(text) {
87
+ return text.length > MAX ? `${text.slice(0, MAX - 1)}…` : text;
88
+ }
@@ -93,6 +93,138 @@ ${diffBody}
93
93
  `;
94
94
  return { prompt, truncated };
95
95
  }
96
+ /** Human names for what the user pointed at, so the model can find it. */
97
+ function describeRefs(artifact, refs) {
98
+ if (refs.length === 0)
99
+ return "";
100
+ const lines = refs.map((ref) => {
101
+ if (ref.target === "summary")
102
+ return "- the summary";
103
+ if (ref.target === "verdict")
104
+ return "- the verdict";
105
+ if (ref.target === "chapter") {
106
+ const ch = artifact.chapters.find((c) => c.id === ref.id);
107
+ return `- the chapter "${ch?.title ?? ref.id}" (chapterId: ${ref.id})`;
108
+ }
109
+ const c = artifact.comments.find((x) => x.id === ref.id);
110
+ return c
111
+ ? `- the comment on ${c.path}:${c.line ?? "file"} (commentId: ${c.id})`
112
+ : `- comment ${ref.id}`;
113
+ });
114
+ return `\n## What the user is pointing at\n\n${lines.join("\n")}\n`;
115
+ }
116
+ /** The review as it stands right now, with the ids a revision has to name. */
117
+ function renderReview(artifact) {
118
+ const chapters = artifact.chapters
119
+ .map((ch) => `- ${ch.id} — ${ch.title}\n ${ch.explanation}`)
120
+ .join("\n");
121
+ const comments = artifact.comments
122
+ .map((c) => {
123
+ const mine = c.origin === "user" || c.editedByUser ? " [THE USER'S OWN WRITING]" : "";
124
+ const dropped = c.status === "dropped" ? " [dropped]" : "";
125
+ return `- ${c.id} — ${c.path}:${c.line ?? "file-level"}${mine}${dropped}\n ${c.body}`;
126
+ })
127
+ .join("\n");
128
+ const verdict = artifact.verdict
129
+ ? `${artifact.verdict.recommendation} (confidence ${artifact.verdict.confidence})\n${artifact.verdict.reasoning}`
130
+ : "(none)";
131
+ return `## The review as it stands
132
+
133
+ ### Summary
134
+
135
+ ${artifact.summary || "(empty)"}
136
+
137
+ ### Chapters
138
+
139
+ ${chapters || "(none)"}
140
+
141
+ ### Comments
142
+
143
+ ${comments || "(none)"}
144
+
145
+ ### Verdict
146
+
147
+ ${verdict}
148
+ `;
149
+ }
150
+ function renderTranscript(chat) {
151
+ if (chat.length === 0)
152
+ return "";
153
+ const turns = chat
154
+ .map((t) => `${t.role === "user" ? "User" : "You"}: ${t.body}`)
155
+ .join("\n\n");
156
+ return `\n## The conversation so far\n\n${turns}\n`;
157
+ }
158
+ /**
159
+ * The prompt for one turn of a conversation about a drafted review.
160
+ *
161
+ * The point of this over a one-shot "redo it" is that the turn resumes the
162
+ * review's own session, so the model can answer *why* it said something rather
163
+ * than re-deriving an opinion. When resume is unavailable the transcript and
164
+ * the review come along anyway, and the model is told it is arriving cold —
165
+ * confidently citing a file it cannot open is the failure mode to avoid.
166
+ */
167
+ export function buildChatPrompt(artifact, message, opts = {}) {
168
+ const needsDiff = !opts.resumed;
169
+ const truncated = needsDiff && artifact.diff.length > MAX_DIFF_CHARS;
170
+ const diffSection = needsDiff
171
+ ? `\n## Diff\n\n\`\`\`diff\n${truncated ? artifact.diff.slice(0, MAX_DIFF_CHARS) + "\n[... diff truncated ...]" : artifact.diff}\n\`\`\`\n`
172
+ : "";
173
+ const note = opts.sourceNote ? `\n${opts.sourceNote}\n` : "";
174
+ const sourceSection = opts.source
175
+ ? (opts.trusted ? TRUSTED_SECTION : SOURCE_SECTION) + note + "\n"
176
+ : `## No source checkout
177
+
178
+ The repository is NOT available to you. Every tool is off. Answer from the diff, the review, and this conversation — and when the honest answer is "I would have to look at the file to be sure", say that rather than describing code you cannot open.
179
+ `;
180
+ const cold = opts.resumed
181
+ ? ""
182
+ : `\nYou are picking this review up cold: it was written in an earlier session you no longer hold. Everything you know about it is below. Do not claim to remember reading something — if the review asserts a thing you cannot now confirm, say so.\n`;
183
+ const prompt = `You are the code reviewer who wrote the review below, talking to the user who is about to send it.
184
+
185
+ They are not asking you to re-review the PR. They are questioning the draft you produced. Answer them honestly — defend what you actually checked, concede what you did not, and revise the review where they are right.
186
+
187
+ Respond with ONLY a JSON object (no markdown fences, no prose before or after) matching exactly this shape:
188
+
189
+ {
190
+ "reply": "markdown — what you say back to the user. Plain, direct, 1-3 short paragraphs.",
191
+ "revisions": [
192
+ { "kind": "summary", "body": "the rewritten summary" },
193
+ { "kind": "verdict", "verdict": { "recommendation": "approve" | "comment" | "request_changes", "confidence": 0-100, "reasoning": "..." } },
194
+ { "kind": "chapter", "chapterId": "...", "title": "optional new title", "explanation": "optional new explanation" },
195
+ { "kind": "comment-edit", "commentId": "...", "body": "the rewritten comment" },
196
+ { "kind": "comment-drop", "commentId": "..." },
197
+ { "kind": "comment-add", "path": "path/to/file.ts", "line": 42, "body": "...", "chapterId": "..." }
198
+ ]
199
+ }
200
+
201
+ Rules:
202
+ - Revise when the user is right, and only then. "revisions" is usually empty or has one entry; an empty array with a reply that explains your reasoning is a perfectly good turn.
203
+ - Do NOT revise merely to seem agreeable. If you checked something and the user is wrong about it, say so and change nothing. A reviewer that folds under every push-back is worthless.
204
+ - Never revise something the user did not raise. Fix what they pointed at, not everything you would now write differently.
205
+ - Comments marked [THE USER'S OWN WRITING] are theirs. Do not edit or drop them. If one of them is what needs changing, say so in your reply and let them do it.
206
+ - Every "commentId" and "chapterId" must be one listed below. Never invent one.
207
+ - "line" is a line in the NEW version of the file and must appear in the diff. Use null for a file-level comment.
208
+ - Your reply is prose to a person, not a changelog. The cockpit already shows what you changed — do not list your own edits back to them.
209
+
210
+ Writing style:
211
+ - Plain words, short sentences, no jargon. Someone who has NOT read the diff should follow you.
212
+ - Lead with the point. State the claim first, support it after.
213
+ - Do NOT imitate the PR description's writing style, and do not restate the author's claims as your own findings. If the description asserts something, it is a claim to check, not a fact to repeat.
214
+ ${cold}
215
+ ${sourceSection}## Pull request
216
+
217
+ Repo: ${artifact.pr.owner}/${artifact.pr.repo}
218
+ PR #${artifact.pr.number}: ${artifact.pr.title}
219
+ Author: ${artifact.pr.author}
220
+
221
+ ${renderReview(artifact)}${describeRefs(artifact, opts.refs ?? [])}${renderTranscript(artifact.chat)}${diffSection}
222
+ ## The user's message
223
+
224
+ ${message}
225
+ `;
226
+ return { prompt, truncated };
227
+ }
96
228
  export function buildRetryPrompt(originalPrompt, badOutput, error) {
97
229
  return `${originalPrompt}
98
230
 
@@ -7,6 +7,7 @@ import { loadArtifact, saveArtifact } from "../core/state.js";
7
7
  import { loadConfig } from "../core/config.js";
8
8
  import { decideTrust, membershipQueries, parseTrustRules } from "../core/trust.js";
9
9
  import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
10
+ import { describeEvent } from "./progress.js";
10
11
  import { beginReview, endReview, isReviewRunning } from "./inflight.js";
11
12
  import { buildReviewPrompt, buildRetryPrompt } from "./prompt.js";
12
13
  /** All an untrusted reviewer needs from a checkout — everything else stays off. */
@@ -121,10 +122,18 @@ async function runReview(ref, opts) {
121
122
  error: null,
122
123
  withSource: source !== null,
123
124
  trusted: trusted && source !== null,
125
+ sessionId: null,
124
126
  },
125
127
  sent: null,
126
128
  refresh: null,
127
129
  calibration: null,
130
+ // The conversation is the user's writing, so a re-review keeps it — the
131
+ // chat prompt replays the transcript, which is what makes it survive the
132
+ // session change. The snapshot does NOT carry over: it describes the draft
133
+ // this run just replaced, and resetting to it would restore a dead review.
134
+ chat: existing?.chat ?? [],
135
+ preChat: null,
136
+ pendingChat: null,
128
137
  };
129
138
  await saveArtifact(artifact);
130
139
  const { prompt, truncated } = buildReviewPrompt(pr, diff, { source: source !== null, trusted });
@@ -170,6 +179,9 @@ async function runReview(ref, opts) {
170
179
  error: null,
171
180
  withSource: source !== null,
172
181
  trusted: trusted && source !== null,
182
+ // Only worth keeping when there is a checkout to resume into: a session
183
+ // whose working directory was empty has nothing a chat turn could read.
184
+ sessionId: source ? review.sessionId : null,
173
185
  },
174
186
  };
175
187
  // A re-review regenerates the AI's comments, but the human's own comments
@@ -221,6 +233,9 @@ async function runAiReview(prompt, opts, source, trusted) {
221
233
  const empty = await createRunDir();
222
234
  const claudeOpts = {
223
235
  model: opts.model,
236
+ // Say what the review is doing while it does it, rather than going quiet
237
+ // for minutes. `cerber review` and the daemon log print these.
238
+ onEvent: (event) => opts.onProgress && describeEvent(event).forEach(opts.onProgress),
224
239
  cwd: source ?? empty,
225
240
  env: unauthenticatedEnv(process.env, empty),
226
241
  allowedTools: source ? (trusted ? TRUSTED_TOOLS : READ_TOOLS) : undefined,
@@ -236,17 +251,25 @@ async function runAiReview(prompt, opts, source, trusted) {
236
251
  try {
237
252
  const first = await runClaude(prompt, claudeOpts);
238
253
  try {
239
- return { ai: AiReviewSchema.parse(extractJson(first.text)), costUsd: first.costUsd, model: first.model };
254
+ return {
255
+ ai: AiReviewSchema.parse(extractJson(first.text)),
256
+ costUsd: first.costUsd,
257
+ model: first.model,
258
+ sessionId: first.sessionId,
259
+ };
240
260
  }
241
261
  catch (err) {
242
262
  const message = err instanceof Error ? err.message : String(err);
243
263
  opts.onProgress?.("Model output failed validation — retrying once…");
264
+ // A fresh run, not a resume: the retry prompt already carries the whole
265
+ // original prompt, so resuming would send the diff twice.
244
266
  const second = await runClaude(buildRetryPrompt(prompt, first.text, message), claudeOpts);
245
267
  const cost = (first.costUsd ?? 0) + (second.costUsd ?? 0);
246
268
  return {
247
269
  ai: AiReviewSchema.parse(extractJson(second.text)),
248
270
  costUsd: cost > 0 ? cost : null,
249
271
  model: second.model,
272
+ sessionId: second.sessionId ?? first.sessionId,
250
273
  };
251
274
  }
252
275
  }
@@ -43,6 +43,9 @@ export function stubArtifact(ref) {
43
43
  sent: null,
44
44
  refresh: null,
45
45
  calibration: null,
46
+ chat: [],
47
+ preChat: null,
48
+ pendingChat: null,
46
49
  };
47
50
  }
48
51
  /** An artifact nothing has touched yet — safe to delete when its PR leaves the queue. */
@@ -17,6 +17,10 @@ import { buildReviewPayload, computeCalibration } from "../core/send.js";
17
17
  import { listArtifacts, loadArtifactByKey, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
18
18
  import { isReviewRunning } from "../runner/inflight.js";
19
19
  import { reviewPr } from "../runner/review.js";
20
+ import { runChatTurn } from "../runner/chat.js";
21
+ import { mergeConcurrentEdits, restoreReview } from "../core/revise.js";
22
+ import { progressWriter } from "./progress.js";
23
+ import { ChatTurnSchema } from "../core/artifact.js";
20
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
25
  export async function buildApp(opts) {
22
26
  const app = new Hono();
@@ -294,6 +298,7 @@ export async function buildApp(opts) {
294
298
  error: null,
295
299
  withSource: withSource !== false,
296
300
  trusted: false,
301
+ sessionId: null,
297
302
  },
298
303
  }));
299
304
  // Minutes-long: run it detached and let the cockpit poll the artifact.
@@ -315,6 +320,121 @@ export async function buildApp(opts) {
315
320
  });
316
321
  return c.json(running, 202);
317
322
  });
323
+ // ---- Chat: argue with the review before sending it ----
324
+ // Local only. The transcript never reaches GitHub — Send still builds its
325
+ // payload from the summary, comments and verdict alone.
326
+ const ChatRequestSchema = z.object({
327
+ message: z.string().min(1),
328
+ refs: ChatTurnSchema.shape.refs.optional(),
329
+ /** The user said, in so many words, that the run may touch their comments. */
330
+ allowUserComments: z.boolean().optional(),
331
+ });
332
+ const turnInFlight = (a) => a.pendingChat != null && a.pendingChat.error == null;
333
+ app.post("/api/reviews/:key/chat", async (c) => {
334
+ const key = c.req.param("key");
335
+ const artifact = await loadArtifactByKey(key);
336
+ if (!artifact)
337
+ return c.json({ error: "not found" }, 404);
338
+ // A sent review is a record of what was posted — arguing with it now would
339
+ // rewrite history that GitHub already has.
340
+ if (artifact.sent)
341
+ return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
342
+ if (isReviewRunning(artifact.id) || turnInFlight(artifact)) {
343
+ return c.json({ error: "a run for this PR is already in flight" }, 409);
344
+ }
345
+ let request;
346
+ try {
347
+ request = ChatRequestSchema.parse(await c.req.json());
348
+ }
349
+ catch (err) {
350
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
351
+ }
352
+ // Record the question before responding, so the cockpit's next poll — and a
353
+ // reload mid-turn — finds a turn being answered rather than a message that
354
+ // went nowhere.
355
+ const pending = await updateArtifactByKey(key, (a) => ({
356
+ ...a,
357
+ pendingChat: {
358
+ message: request.message,
359
+ refs: request.refs ?? [],
360
+ startedAt: new Date().toISOString(),
361
+ progress: [],
362
+ error: null,
363
+ },
364
+ }));
365
+ // The turn narrates itself as it reads the code; those lines go onto the
366
+ // artifact so the cockpit's poll can show them instead of a spinner.
367
+ const progress = progressWriter(key);
368
+ // Minutes-long, like a re-review: run it detached and let the cockpit poll.
369
+ // Holding the request open is fine on localhost and fails behind a reverse
370
+ // proxy's read timeout, which tells the user it broke while the turn
371
+ // quietly succeeds — a worse failure than an honest error.
372
+ void runChatTurn(artifact, request.message, {
373
+ refs: request.refs,
374
+ allowUserComments: request.allowUserComments,
375
+ onProgress: (m) => {
376
+ console.log(`[chat ${artifact.id}] ${m}`);
377
+ progress.push(m);
378
+ },
379
+ })
380
+ .then(async (result) => {
381
+ // Let the narration finish writing first: an append that read the
382
+ // artifact before this point would otherwise land on top of the answer.
383
+ await progress.stop();
384
+ // The cockpit stays live throughout, so fold the result onto whatever
385
+ // is on disk now rather than overwriting it — an edit the user made
386
+ // while waiting must not vanish when the answer lands.
387
+ await updateArtifactByKey(key, (current) => ({
388
+ ...mergeConcurrentEdits(artifact, result.artifact, current),
389
+ pendingChat: null,
390
+ }));
391
+ })
392
+ .catch(async (err) => {
393
+ const message = err instanceof Error ? err.message : String(err);
394
+ console.error(`[chat ${artifact.id}] failed: ${message}`);
395
+ await progress.stop();
396
+ // Nobody is holding a response to hand this to any more, so the failure
397
+ // lives on the artifact against the question that caused it — under
398
+ // whatever the turn had managed to do before it died.
399
+ await updateArtifactByKey(key, (a) => ({
400
+ ...a,
401
+ pendingChat: a.pendingChat ? { ...a.pendingChat, error: message } : null,
402
+ })).catch(() => { });
403
+ });
404
+ return c.json(pending, 202);
405
+ });
406
+ // Clear a turn that failed. Only that: a turn still being answered would come
407
+ // back on the next write and the cockpit would have lied about dropping it.
408
+ app.delete("/api/reviews/:key/chat/pending", async (c) => {
409
+ const key = c.req.param("key");
410
+ const artifact = await loadArtifactByKey(key);
411
+ if (!artifact)
412
+ return c.json({ error: "not found" }, 404);
413
+ if (turnInFlight(artifact)) {
414
+ return c.json({ error: "that turn is still being answered" }, 409);
415
+ }
416
+ const updated = await updateArtifactByKey(key, (a) => ({ ...a, pendingChat: null }));
417
+ return c.json(updated);
418
+ });
419
+ // Put the review back the way it was before the conversation started. The
420
+ // conversation itself stays — you lose the edits, not the reasoning.
421
+ app.post("/api/reviews/:key/chat/reset", async (c) => {
422
+ const key = c.req.param("key");
423
+ const artifact = await loadArtifactByKey(key);
424
+ if (!artifact)
425
+ return c.json({ error: "not found" }, 404);
426
+ if (artifact.sent)
427
+ return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
428
+ // A turn in flight will fold its own result on top of whatever is on disk
429
+ // when it lands, so resetting now would be undone a minute later.
430
+ if (turnInFlight(artifact)) {
431
+ return c.json({ error: "a turn is still being answered — wait for it to land" }, 409);
432
+ }
433
+ if (!artifact.preChat)
434
+ return c.json({ error: "this review has not been chatted about" }, 409);
435
+ const updated = await updateArtifactByKey(key, (a) => a.preChat ? restoreReview(a, a.preChat) : a);
436
+ return c.json(updated);
437
+ });
318
438
  // ---- Export (local file download) ----
319
439
  app.get("/api/reviews/:key/export", async (c) => {
320
440
  const artifact = await loadArtifactByKey(c.req.param("key"));
@@ -381,7 +501,7 @@ export async function buildApp(opts) {
381
501
  export async function startServer(opts) {
382
502
  const cleared = await reconcileRunning();
383
503
  if (cleared > 0) {
384
- console.log(`Cleared ${cleared} review(s) left mid-run by a previous process — re-review to retry.`);
504
+ console.log(`Cleared ${cleared} AI run(s) left in flight by a previous process — start them again from the cockpit.`);
385
505
  }
386
506
  const app = await buildApp(opts);
387
507
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
@@ -0,0 +1,72 @@
1
+ import { updateArtifactByKey } from "../core/state.js";
2
+ /**
3
+ * Putting a running turn's narration where the cockpit can see it.
4
+ *
5
+ * The turn is detached, so there is no connection to stream down: the artifact
6
+ * is the channel, exactly as it is for the pending question itself. That keeps
7
+ * the story on a reload and behind a proxy, and costs no new transport.
8
+ *
9
+ * Two things it must not do. It must not rewrite the whole artifact — diff and
10
+ * all — once per line, so lines are coalesced into one write every couple of
11
+ * seconds, which is as often as the cockpit polls anyway. And it must not still
12
+ * be writing when the turn lands: an append that read the artifact before the
13
+ * answer was folded in would write back the "still thinking" version on top of
14
+ * it. `stop()` is awaited before the result is saved, and refuses every append
15
+ * after that.
16
+ */
17
+ /** Enough to see what it has been up to, not so much that the panel scrolls away. */
18
+ const KEEP = 30;
19
+ export function progressWriter(key, opts = {}) {
20
+ const everyMs = opts.everyMs ?? 2_000;
21
+ const keep = opts.keep ?? KEEP;
22
+ const update = opts.update ?? updateArtifactByKey;
23
+ let queued = [];
24
+ let last = null;
25
+ let timer = null;
26
+ let stopped = false;
27
+ // Writes chain onto each other so two flushes can never interleave their
28
+ // read-modify-write, and so stop() has something to await.
29
+ let writes = Promise.resolve();
30
+ const flush = () => {
31
+ timer = null;
32
+ const lines = queued;
33
+ queued = [];
34
+ if (!lines.length)
35
+ return;
36
+ writes = writes.then(() => update(key, (a) =>
37
+ // No pending turn means it already landed (or was dismissed) — there is
38
+ // nothing left for this narration to describe.
39
+ a.pendingChat
40
+ ? {
41
+ ...a,
42
+ pendingChat: {
43
+ ...a.pendingChat,
44
+ progress: [...a.pendingChat.progress, ...lines].slice(-keep),
45
+ },
46
+ }
47
+ : a).catch(() => {
48
+ // A line that never reaches disk is a line the user doesn't see. The
49
+ // turn itself is unaffected, so it is not worth failing over.
50
+ }));
51
+ };
52
+ return {
53
+ push(line) {
54
+ if (stopped || !line || line === last)
55
+ return;
56
+ last = line;
57
+ queued.push(line);
58
+ if (!timer)
59
+ timer = setTimeout(flush, everyMs);
60
+ },
61
+ async stop() {
62
+ stopped = true;
63
+ if (timer)
64
+ clearTimeout(timer);
65
+ // Flush what's left rather than dropping it: on the failure path the
66
+ // pending turn survives, and the last thing it did before dying is
67
+ // exactly what the user wants to see.
68
+ flush();
69
+ await writes;
70
+ },
71
+ };
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
5
5
  "license": "MIT",
6
6
  "repository": "github:fullstackhouse/cerber",