@fullstackhouse/cerber 0.10.0 → 0.11.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/README.md CHANGED
@@ -8,9 +8,7 @@ verdict with a confidence score. You go through it in a local web cockpit.
8
8
  write is the Send button (plus opt-in daemon auto-send you turn on yourself) —
9
9
  reviewing is 100% local and read-only.
10
10
 
11
- Named after Cerberus, the gatekeeper — and a sibling of
12
- [cezar](https://github.com/open-mercato/cezar): cezar runs coding agents,
13
- cerber guards what gets merged.
11
+ Named after Cerberus, the gatekeeper: cerber guards what gets merged.
14
12
 
15
13
  ## Quick start
16
14
 
@@ -37,9 +35,11 @@ npx @fullstackhouse/cerber serve # → http://127.0.0.1:4820
37
35
  Sending stays a human click. Tame it with `--no-auto-review` (list awaiting
38
36
  PRs, review on click) or `--no-poll` (no GitHub polling at all) — or flip the
39
37
  same two switches in the cockpit's Settings, which persist in
40
- `~/.cerber/config.json` and apply on the next poll. Merged and closed PRs are
41
- archived out of the queue automatically, and PRs in archived repos are never
42
- picked up at all — the repo is read-only, so a review could never be sent.
38
+ `~/.cerber/config.json` and apply on the next poll. The queue only lists what
39
+ still wants you: anything you have settled drops into a drawer under it — one
40
+ for reviews you sent, one for the ones you marked reviewed or skipped, one for
41
+ merged and closed PRs — and PRs in archived repos are never picked up at all,
42
+ since the repo is read-only and a review could never be sent.
43
43
 
44
44
  ## What a review looks like
45
45
 
@@ -59,6 +59,13 @@ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
59
59
  The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
60
60
  diffs. Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
61
61
 
62
+ The queue is meant to be walked, not clicked through: `j`/`k` move the cursor
63
+ and the strip under the table explains whatever it lands on — the verdict's own
64
+ reasoning, what the run read, how it sits against the auto-send bar — so most
65
+ rows can be judged without opening them. `↵` opens one, `r` drafts (or
66
+ re-drafts) it. Inside a review, `[` and `]` walk to the previous/next PR still
67
+ awaiting you, `n` steps through the chapters, and `s` sends.
68
+
62
69
  ### It reviews the code, not just the diff
63
70
 
64
71
  Cerber checks the PR's head out locally and lets the review read it. That
@@ -222,6 +229,17 @@ you already asked for the change. What it changed shows up in the transcript
222
229
  blind rather than left to describe files from memory.
223
230
  - **It can say no.** The prompt tells it not to fold under push-back. If you
224
231
  are wrong about something it checked, it says so and changes nothing.
232
+ - **The wait is not a held request.** Re-reading the code takes minutes, so a
233
+ turn runs detached the way a re-review does: your question appears in the
234
+ transcript straight away and the cockpit polls for the answer. Reload, close
235
+ the tab, or sit behind a proxy that times connections out at 60s — the answer
236
+ still lands on the review, and a turn that fails says so against the question
237
+ that caused it.
238
+ - **You can watch it work.** Those minutes aren't a spinner: the run narrates
239
+ itself under your question — *thinking…*, *reading src/core/refresh.ts*,
240
+ *searching for carryOverComments in src*, *not allowed to use Bash* — so you
241
+ can tell a turn that is checking your claim from one that is about to answer
242
+ from memory. `cerber review` and the daemon log print the same lines.
225
243
  - **Your words are yours.** Comments you wrote or edited are off limits: it
226
244
  will tell you one of them needs changing rather than rewriting it. Say so
227
245
  explicitly and it will.
@@ -290,4 +308,4 @@ Web cockpit dev with hot reload: `pnpm dev serve` in one terminal,
290
308
 
291
309
  ## License
292
310
 
293
- MIT © Full Stack House
311
+ MIT © [Full Stack House](https://fullstack.house)
@@ -144,6 +144,29 @@ export const ChatTurnSchema = z.object({
144
144
  refused: z.array(RefusalSchema).default([]),
145
145
  costUsd: z.number().nullable().default(null),
146
146
  });
147
+ /**
148
+ * The chat turn being answered right now — or the one that failed.
149
+ *
150
+ * A turn takes minutes: the reviewer re-reads the code before it answers. So
151
+ * the turn runs detached and this is what the cockpit polls. The question is on
152
+ * the artifact from the moment it is asked, which is what makes a reload
153
+ * mid-turn show it still being answered rather than a conversation that lost
154
+ * it, and what carries a failure back to a request that already returned.
155
+ */
156
+ export const PendingChatSchema = z.object({
157
+ /** The message being answered — shown in the transcript while we wait. */
158
+ message: z.string(),
159
+ refs: ChatTurnSchema.shape.refs,
160
+ startedAt: z.string(),
161
+ /**
162
+ * What the turn has been doing, in its own words — "reading src/core/diff.ts",
163
+ * "searching for carryOverComments". The answer takes minutes and this is the
164
+ * only honest account of them; it is thrown away when the answer lands.
165
+ */
166
+ progress: z.array(z.string()).default([]),
167
+ /** Why the turn failed. Null while it is still running. */
168
+ error: z.string().nullable().default(null),
169
+ });
147
170
  /** The parts of a review a chat turn may rewrite — the reset target. */
148
171
  export const ReviewSnapshotSchema = z.object({
149
172
  at: z.string(),
@@ -175,6 +198,8 @@ export const ArtifactSchema = z.object({
175
198
  calibration: CalibrationSchema.nullable().default(null),
176
199
  /** The conversation about this review. Never sent to GitHub. */
177
200
  chat: z.array(ChatTurnSchema).default([]),
201
+ /** A turn in flight, or the one that failed. Null when nothing is pending. */
202
+ pendingChat: PendingChatSchema.nullable().default(null),
178
203
  /** The review as it stood before the first chat turn — "reset" restores this. */
179
204
  preChat: ReviewSnapshotSchema.nullable().default(null),
180
205
  });
@@ -92,25 +92,31 @@ export async function updateArtifactByKey(key, mutate) {
92
92
  return updated;
93
93
  }
94
94
  /**
95
- * Clear "running" statuses left behind by a process that died mid-review.
96
- * Nothing in a freshly started process is running, so anything still marked
97
- * running is a leftover — without this it stays wedged forever, with no way to
98
- * retry it from the cockpit. A `cerber review` running in another terminal is
99
- * the one false positive; it overwrites the artifact when it finishes anyway.
95
+ * Clear AI work left in flight by a process that died mid-run — a review marked
96
+ * "running", or a chat turn still waiting for its answer. Nothing in a freshly
97
+ * started process is running, so anything still marked so is a leftover; without
98
+ * this it stays wedged forever, with no way to retry it from the cockpit. A
99
+ * `cerber review` running in another terminal is the one false positive; it
100
+ * overwrites the artifact when it finishes anyway.
100
101
  */
101
102
  export async function reconcileRunning() {
102
103
  const artifacts = await listArtifacts();
103
104
  let cleared = 0;
104
105
  for (const artifact of artifacts) {
105
- if (artifact.status !== "running")
106
+ const stuckRun = artifact.status === "running";
107
+ const stuckChat = artifact.pendingChat && artifact.pendingChat.error == null ? artifact.pendingChat : null;
108
+ if (!stuckRun && !stuckChat)
106
109
  continue;
107
110
  await saveArtifact({
108
111
  ...artifact,
109
- status: "failed",
112
+ status: stuckRun ? "failed" : artifact.status,
110
113
  updatedAt: new Date().toISOString(),
111
- run: artifact.run
114
+ run: stuckRun && artifact.run
112
115
  ? { ...artifact.run, error: "interrupted — cerber restarted while this review was running" }
113
- : null,
116
+ : artifact.run,
117
+ pendingChat: stuckChat
118
+ ? { ...stuckChat, error: "interrupted — cerber restarted while this turn was running" }
119
+ : artifact.pendingChat,
114
120
  });
115
121
  cleared++;
116
122
  }
@@ -5,6 +5,7 @@ import { createRunDir, prepareCheckout, removeRunDir, sourceDir } from "../core/
5
5
  import { applyRevisions, snapshotReview } from "../core/revise.js";
6
6
  import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
7
7
  import { beginReview, endReview } from "./inflight.js";
8
+ import { describeEvent } from "./progress.js";
8
9
  import { buildChatPrompt } from "./prompt.js";
9
10
  /**
10
11
  * One turn of a conversation about a drafted review.
@@ -126,6 +127,10 @@ async function turn(artifact, message, opts) {
126
127
  const empty = await createRunDir();
127
128
  const claudeOpts = {
128
129
  model: opts.model,
130
+ // The turn narrates itself while it works. Nothing waits on this — it is
131
+ // the difference between a minute of spinner and a minute of "reading
132
+ // src/core/refresh.ts".
133
+ onEvent: (event) => describeEvent(event).forEach(log),
129
134
  cwd: source ?? empty,
130
135
  env: unauthenticatedEnv(process.env, empty),
131
136
  allowedTools: source ? (trusted ? TRUSTED_TOOLS : READ_TOOLS) : undefined,
@@ -39,7 +39,10 @@ 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"];
43
46
  if (opts.resumeSessionId)
44
47
  args.push("--resume", opts.resumeSessionId);
45
48
  if (opts.model)
@@ -53,9 +56,39 @@ export function buildClaudeArgs(opts) {
53
56
  }
54
57
  return args;
55
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
+ }
56
86
  /**
57
87
  * Run `claude -p` headless with the prompt on stdin, return the result text.
58
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.
59
92
  */
60
93
  export function runClaude(prompt, opts = {}) {
61
94
  const args = buildClaudeArgs(opts);
@@ -65,7 +98,10 @@ export function runClaude(prompt, opts = {}) {
65
98
  cwd: opts.cwd,
66
99
  env: opts.env,
67
100
  });
68
- 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 = "";
69
105
  let stderr = "";
70
106
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
71
107
  let settled = false;
@@ -89,7 +125,26 @@ export function runClaude(prompt, opts = {}) {
89
125
  child.stdin.destroy();
90
126
  settle(() => reject(new Error(`claude did not finish within ${Math.round(timeoutMs / 60_000)} minutes — run killed.`)));
91
127
  }, timeoutMs);
92
- 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
+ });
93
148
  child.stderr.on("data", (d) => (stderr += d));
94
149
  child.on("error", (err) => {
95
150
  settle(() => reject(new Error(`Failed to start claude CLI: ${err.message}. Is Claude Code installed and on PATH?`)));
@@ -100,21 +155,24 @@ export function runClaude(prompt, opts = {}) {
100
155
  clearTimeout(timer);
101
156
  settled = true;
102
157
  if (code !== 0) {
103
- 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)}`));
104
159
  return;
105
160
  }
106
- try {
107
- const wrapper = JSON.parse(stdout);
108
- resolve({
109
- text: wrapper.result ?? "",
110
- costUsd: typeof wrapper.total_cost_usd === "number" ? wrapper.total_cost_usd : null,
111
- model: wrapper.modelUsage ? Object.keys(wrapper.modelUsage)[0] ?? null : null,
112
- sessionId: typeof wrapper.session_id === "string" ? wrapper.session_id : null,
113
- });
114
- }
115
- catch {
116
- 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;
117
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
+ });
118
176
  });
119
177
  child.stdin.write(prompt);
120
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
+ }
@@ -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. */
@@ -132,6 +133,7 @@ async function runReview(ref, opts) {
132
133
  // this run just replaced, and resetting to it would restore a dead review.
133
134
  chat: existing?.chat ?? [],
134
135
  preChat: null,
136
+ pendingChat: null,
135
137
  };
136
138
  await saveArtifact(artifact);
137
139
  const { prompt, truncated } = buildReviewPrompt(pr, diff, { source: source !== null, trusted });
@@ -231,6 +233,9 @@ async function runAiReview(prompt, opts, source, trusted) {
231
233
  const empty = await createRunDir();
232
234
  const claudeOpts = {
233
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),
234
239
  cwd: source ?? empty,
235
240
  env: unauthenticatedEnv(process.env, empty),
236
241
  allowedTools: source ? (trusted ? TRUSTED_TOOLS : READ_TOOLS) : undefined,
@@ -45,6 +45,7 @@ export function stubArtifact(ref) {
45
45
  calibration: null,
46
46
  chat: [],
47
47
  preChat: null,
48
+ pendingChat: null,
48
49
  };
49
50
  }
50
51
  /** An artifact nothing has touched yet — safe to delete when its PR leaves the queue. */
@@ -19,6 +19,7 @@ import { isReviewRunning } from "../runner/inflight.js";
19
19
  import { reviewPr } from "../runner/review.js";
20
20
  import { runChatTurn } from "../runner/chat.js";
21
21
  import { mergeConcurrentEdits, restoreReview } from "../core/revise.js";
22
+ import { progressWriter } from "./progress.js";
22
23
  import { ChatTurnSchema } from "../core/artifact.js";
23
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
25
  export async function buildApp(opts) {
@@ -143,10 +144,19 @@ export async function buildApp(opts) {
143
144
  additions: a.pr.additions,
144
145
  deletions: a.pr.deletions,
145
146
  changedFiles: a.pr.changedFiles,
147
+ // The queue's cursor row explains itself without opening the review,
148
+ // so the list carries what that explanation is made of.
149
+ baseRefName: a.pr.baseRefName,
150
+ headRefName: a.pr.headRefName,
146
151
  },
147
152
  verdict: a.verdict,
148
153
  commentCount: a.comments.filter((cm) => cm.status !== "dropped").length,
154
+ driftedCount: a.comments.filter((cm) => cm.status !== "dropped" && cm.drifted).length,
149
155
  costUsd: a.run?.costUsd ?? null,
156
+ withSource: a.run?.withSource ?? null,
157
+ trusted: a.run?.trusted ?? null,
158
+ runError: a.run?.error ?? null,
159
+ sent: a.sent ? { at: a.sent.at, event: a.sent.event, url: a.sent.url, auto: a.sent.auto ?? false } : null,
150
160
  })));
151
161
  });
152
162
  app.get("/api/reviews/:key", async (c) => {
@@ -328,6 +338,7 @@ export async function buildApp(opts) {
328
338
  /** The user said, in so many words, that the run may touch their comments. */
329
339
  allowUserComments: z.boolean().optional(),
330
340
  });
341
+ const turnInFlight = (a) => a.pendingChat != null && a.pendingChat.error == null;
331
342
  app.post("/api/reviews/:key/chat", async (c) => {
332
343
  const key = c.req.param("key");
333
344
  const artifact = await loadArtifactByKey(key);
@@ -337,7 +348,7 @@ export async function buildApp(opts) {
337
348
  // rewrite history that GitHub already has.
338
349
  if (artifact.sent)
339
350
  return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
340
- if (isReviewRunning(artifact.id)) {
351
+ if (isReviewRunning(artifact.id) || turnInFlight(artifact)) {
341
352
  return c.json({ error: "a run for this PR is already in flight" }, 409);
342
353
  }
343
354
  let request;
@@ -347,21 +358,72 @@ export async function buildApp(opts) {
347
358
  catch (err) {
348
359
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
349
360
  }
350
- try {
351
- const result = await runChatTurn(artifact, request.message, {
352
- refs: request.refs,
353
- allowUserComments: request.allowUserComments,
354
- onProgress: (m) => console.log(`[chat ${artifact.id}] ${m}`),
355
- });
356
- // A turn takes minutes and the cockpit stays live throughout, so fold the
357
- // result onto whatever is on disk now rather than overwriting it — an
358
- // edit the user made while waiting must not vanish when the answer lands.
359
- const saved = await updateArtifactByKey(key, (current) => mergeConcurrentEdits(artifact, result.artifact, current));
360
- return c.json({ artifact: saved, applied: result.applied, refused: result.refused });
361
- }
362
- catch (err) {
363
- return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
361
+ // Record the question before responding, so the cockpit's next poll — and a
362
+ // reload mid-turn — finds a turn being answered rather than a message that
363
+ // went nowhere.
364
+ const pending = await updateArtifactByKey(key, (a) => ({
365
+ ...a,
366
+ pendingChat: {
367
+ message: request.message,
368
+ refs: request.refs ?? [],
369
+ startedAt: new Date().toISOString(),
370
+ progress: [],
371
+ error: null,
372
+ },
373
+ }));
374
+ // The turn narrates itself as it reads the code; those lines go onto the
375
+ // artifact so the cockpit's poll can show them instead of a spinner.
376
+ const progress = progressWriter(key);
377
+ // Minutes-long, like a re-review: run it detached and let the cockpit poll.
378
+ // Holding the request open is fine on localhost and fails behind a reverse
379
+ // proxy's read timeout, which tells the user it broke while the turn
380
+ // quietly succeeds — a worse failure than an honest error.
381
+ void runChatTurn(artifact, request.message, {
382
+ refs: request.refs,
383
+ allowUserComments: request.allowUserComments,
384
+ onProgress: (m) => {
385
+ console.log(`[chat ${artifact.id}] ${m}`);
386
+ progress.push(m);
387
+ },
388
+ })
389
+ .then(async (result) => {
390
+ // Let the narration finish writing first: an append that read the
391
+ // artifact before this point would otherwise land on top of the answer.
392
+ await progress.stop();
393
+ // The cockpit stays live throughout, so fold the result onto whatever
394
+ // is on disk now rather than overwriting it — an edit the user made
395
+ // while waiting must not vanish when the answer lands.
396
+ await updateArtifactByKey(key, (current) => ({
397
+ ...mergeConcurrentEdits(artifact, result.artifact, current),
398
+ pendingChat: null,
399
+ }));
400
+ })
401
+ .catch(async (err) => {
402
+ const message = err instanceof Error ? err.message : String(err);
403
+ console.error(`[chat ${artifact.id}] failed: ${message}`);
404
+ await progress.stop();
405
+ // Nobody is holding a response to hand this to any more, so the failure
406
+ // lives on the artifact against the question that caused it — under
407
+ // whatever the turn had managed to do before it died.
408
+ await updateArtifactByKey(key, (a) => ({
409
+ ...a,
410
+ pendingChat: a.pendingChat ? { ...a.pendingChat, error: message } : null,
411
+ })).catch(() => { });
412
+ });
413
+ return c.json(pending, 202);
414
+ });
415
+ // Clear a turn that failed. Only that: a turn still being answered would come
416
+ // back on the next write and the cockpit would have lied about dropping it.
417
+ app.delete("/api/reviews/:key/chat/pending", async (c) => {
418
+ const key = c.req.param("key");
419
+ const artifact = await loadArtifactByKey(key);
420
+ if (!artifact)
421
+ return c.json({ error: "not found" }, 404);
422
+ if (turnInFlight(artifact)) {
423
+ return c.json({ error: "that turn is still being answered" }, 409);
364
424
  }
425
+ const updated = await updateArtifactByKey(key, (a) => ({ ...a, pendingChat: null }));
426
+ return c.json(updated);
365
427
  });
366
428
  // Put the review back the way it was before the conversation started. The
367
429
  // conversation itself stays — you lose the edits, not the reasoning.
@@ -372,6 +434,11 @@ export async function buildApp(opts) {
372
434
  return c.json({ error: "not found" }, 404);
373
435
  if (artifact.sent)
374
436
  return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
437
+ // A turn in flight will fold its own result on top of whatever is on disk
438
+ // when it lands, so resetting now would be undone a minute later.
439
+ if (turnInFlight(artifact)) {
440
+ return c.json({ error: "a turn is still being answered — wait for it to land" }, 409);
441
+ }
375
442
  if (!artifact.preChat)
376
443
  return c.json({ error: "this review has not been chatted about" }, 409);
377
444
  const updated = await updateArtifactByKey(key, (a) => a.preChat ? restoreReview(a, a.preChat) : a);
@@ -443,7 +510,7 @@ export async function buildApp(opts) {
443
510
  export async function startServer(opts) {
444
511
  const cleared = await reconcileRunning();
445
512
  if (cleared > 0) {
446
- console.log(`Cleared ${cleared} review(s) left mid-run by a previous process — re-review to retry.`);
513
+ console.log(`Cleared ${cleared} AI run(s) left in flight by a previous process — start them again from the cockpit.`);
447
514
  }
448
515
  const app = await buildApp(opts);
449
516
  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,8 +1,9 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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
+ "author": "Full Stack House (https://fullstack.house)",
6
7
  "repository": "github:fullstackhouse/cerber",
7
8
  "type": "module",
8
9
  "packageManager": "pnpm@10.33.2",