@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.
package/README.md CHANGED
@@ -199,6 +199,53 @@ get its opinion of the new code, hit **Re-review at the new head** in the
199
199
  cockpit (or `cerber review <pr> --force`) — comments you wrote or rewrote are
200
200
  carried into the fresh review.
201
201
 
202
+ ### Arguing with the review before you send it
203
+
204
+ A draft you disagree with used to leave two options: rewrite it by hand, or
205
+ re-review and hope. Neither lets you say *what was wrong*. So every review has
206
+ a conversation attached — **Talk to the reviewer**, at the bottom of the
207
+ walkthrough.
208
+
209
+ Ask it why it said something, and it answers. Tell it the summary is restating
210
+ the author's claims rather than what it verified, and it rewrites the summary.
211
+ The reviewer revises the draft as it answers: there is no accept step, because
212
+ you already asked for the change. What it changed shows up in the transcript
213
+ (*"✎ rewrote the summary"*, *"✎ dropped the comment on line 250"*).
214
+
215
+ - **It is the same reviewer.** A turn resumes the Claude session the review ran
216
+ in, so it still holds everything it read. Asking "did you actually check
217
+ that?" gets an answer from the run that did or didn't — not from a fresh
218
+ agent re-deriving an opinion from the diff.
219
+ - **It reads the code to answer you.** The checkout cache is small, so by the
220
+ time you argue with a review its source is usually evicted; a turn re-clones
221
+ it. If it can't, the turn still runs and the reviewer is told it is working
222
+ blind rather than left to describe files from memory.
223
+ - **It can say no.** The prompt tells it not to fold under push-back. If you
224
+ are wrong about something it checked, it says so and changes nothing.
225
+ - **The wait is not a held request.** Re-reading the code takes minutes, so a
226
+ turn runs detached the way a re-review does: your question appears in the
227
+ transcript straight away and the cockpit polls for the answer. Reload, close
228
+ the tab, or sit behind a proxy that times connections out at 60s — the answer
229
+ still lands on the review, and a turn that fails says so against the question
230
+ that caused it.
231
+ - **You can watch it work.** Those minutes aren't a spinner: the run narrates
232
+ itself under your question — *thinking…*, *reading src/core/refresh.ts*,
233
+ *searching for carryOverComments in src*, *not allowed to use Bash* — so you
234
+ can tell a turn that is checking your claim from one that is about to answer
235
+ from memory. `cerber review` and the daemon log print the same lines.
236
+ - **Your words are yours.** Comments you wrote or edited are off limits: it
237
+ will tell you one of them needs changing rather than rewriting it. Say so
238
+ explicitly and it will.
239
+ - **Point at things.** *discuss* on any comment, chapter, or the summary drops
240
+ a reference into your next message.
241
+ - **Reset** puts the review back the way the AI first wrote it and keeps the
242
+ conversation — you lose the edits, not the reasoning.
243
+
244
+ Nothing in the conversation reaches GitHub. Send still builds its payload from
245
+ the summary, comments and verdict alone, and is still one deliberate click. A
246
+ review that has already been sent can't be argued with: that artifact is the
247
+ record of what GitHub has.
248
+
202
249
  ## Status / roadmap
203
250
 
204
251
  Early, but all six planned phases have shipped.
@@ -66,6 +66,11 @@ export const RunInfoSchema = z.object({
66
66
  withSource: z.boolean().default(false),
67
67
  /** The user vouched for this PR, so the reviewer could also run commands. */
68
68
  trusted: z.boolean().default(false),
69
+ /**
70
+ * The Claude session this review ran in. Chat turns resume it, so the
71
+ * reviewer answering a question still holds everything it read.
72
+ */
73
+ sessionId: z.string().nullable().default(null),
69
74
  });
70
75
  export const SentInfoSchema = z.object({
71
76
  at: z.string(),
@@ -94,6 +99,82 @@ export const CalibrationSchema = z.object({
94
99
  aiCommentsEdited: z.number().int(),
95
100
  userCommentsAdded: z.number().int(),
96
101
  });
102
+ /**
103
+ * One edit a chat turn made to the review it is about.
104
+ *
105
+ * The agent revises the draft directly — there is no accept step — so these are
106
+ * a record of what already happened, rendered in the transcript, not a proposal
107
+ * awaiting a click. The user's escape hatch is the conversation itself, plus
108
+ * the one pre-chat snapshot.
109
+ */
110
+ export const RevisionSchema = z.discriminatedUnion("kind", [
111
+ z.object({ kind: z.literal("summary"), body: z.string() }),
112
+ z.object({ kind: z.literal("verdict"), verdict: VerdictSchema }),
113
+ z.object({ kind: z.literal("chapter"), chapterId: z.string(), title: z.string().optional(), explanation: z.string().optional() }),
114
+ z.object({ kind: z.literal("comment-edit"), commentId: z.string(), body: z.string() }),
115
+ z.object({ kind: z.literal("comment-drop"), commentId: z.string() }),
116
+ z.object({
117
+ kind: z.literal("comment-add"),
118
+ path: z.string(),
119
+ line: z.number().int().positive().nullable(),
120
+ body: z.string(),
121
+ chapterId: z.string().nullable().default(null),
122
+ }),
123
+ ]);
124
+ /** A revision the run declined to make, and why — shown in the transcript. */
125
+ export const RefusalSchema = z.object({
126
+ revision: RevisionSchema,
127
+ reason: z.string(),
128
+ });
129
+ export const ChatTurnSchema = z.object({
130
+ id: z.string(),
131
+ role: z.enum(["user", "assistant"]),
132
+ at: z.string(),
133
+ body: z.string(),
134
+ /** What the user pointed at with "discuss this" — user turns only. */
135
+ refs: z
136
+ .array(z.object({
137
+ target: z.enum(["summary", "verdict", "chapter", "comment"]),
138
+ id: z.string().nullable().default(null),
139
+ }))
140
+ .default([]),
141
+ /** Edits this turn made to the review. Assistant turns only. */
142
+ revisions: z.array(RevisionSchema).default([]),
143
+ /** Edits this turn asked for but the artifact would not accept. */
144
+ refused: z.array(RefusalSchema).default([]),
145
+ costUsd: z.number().nullable().default(null),
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
+ });
170
+ /** The parts of a review a chat turn may rewrite — the reset target. */
171
+ export const ReviewSnapshotSchema = z.object({
172
+ at: z.string(),
173
+ summary: z.string(),
174
+ chapters: z.array(ChapterSchema),
175
+ comments: z.array(CommentSchema),
176
+ verdict: VerdictSchema.nullable(),
177
+ });
97
178
  export const ArtifactSchema = z.object({
98
179
  schemaVersion: z.literal(SCHEMA_VERSION),
99
180
  /** "owner/repo#123" */
@@ -115,6 +196,12 @@ export const ArtifactSchema = z.object({
115
196
  /** Last time this review was pulled forward onto a newer head commit. */
116
197
  refresh: RefreshInfoSchema.nullable().default(null),
117
198
  calibration: CalibrationSchema.nullable().default(null),
199
+ /** The conversation about this review. Never sent to GitHub. */
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),
203
+ /** The review as it stood before the first chat turn — "reset" restores this. */
204
+ preChat: ReviewSnapshotSchema.nullable().default(null),
118
205
  });
119
206
  /** What the AI must return. Subset of the artifact; cerber owns the rest. */
120
207
  export const AiReviewSchema = z.object({
@@ -133,6 +220,11 @@ export const AiReviewSchema = z.object({
133
220
  })),
134
221
  verdict: VerdictSchema,
135
222
  });
223
+ /** What a chat turn must return: something to say, and what it changed. */
224
+ export const AiChatTurnSchema = z.object({
225
+ reply: z.string(),
226
+ revisions: z.array(RevisionSchema).default([]),
227
+ });
136
228
  export function artifactId(pr) {
137
229
  return `${pr.owner}/${pr.repo}#${pr.number}`;
138
230
  }
@@ -0,0 +1,166 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /** The review as it stands, for restoring later. */
3
+ export function snapshotReview(artifact, at) {
4
+ return {
5
+ at,
6
+ summary: artifact.summary,
7
+ chapters: artifact.chapters,
8
+ comments: artifact.comments,
9
+ verdict: artifact.verdict,
10
+ };
11
+ }
12
+ /** Put a review back the way the snapshot found it. Chat history is untouched. */
13
+ export function restoreReview(artifact, snapshot) {
14
+ return {
15
+ ...artifact,
16
+ summary: snapshot.summary,
17
+ chapters: snapshot.chapters,
18
+ comments: snapshot.comments,
19
+ verdict: snapshot.verdict,
20
+ };
21
+ }
22
+ /**
23
+ * Fold a finished chat turn back onto whatever the artifact says now.
24
+ *
25
+ * A turn takes minutes, and the cockpit stays live throughout: the user can
26
+ * edit, drop or add a comment while waiting for the answer. Writing the turn's
27
+ * result wholesale would silently undo that, which is the one thing a run must
28
+ * never do to the user's own work.
29
+ *
30
+ * `before` is the artifact the turn started from, `after` is what it produced,
31
+ * `current` is what is on disk now. Anything the user changed between `before`
32
+ * and `current` wins; everything else comes from the turn.
33
+ */
34
+ export function mergeConcurrentEdits(before, after, current) {
35
+ const wasThere = new Map(before.comments.map((c) => [c.id, c]));
36
+ const fromTurn = new Map(after.comments.map((c) => [c.id, c]));
37
+ const userTouched = (id, now) => {
38
+ const then = wasThere.get(id);
39
+ // Absent from `before` means the user added it while the turn ran.
40
+ if (!then)
41
+ return true;
42
+ return then.body !== now.body || then.status !== now.status;
43
+ };
44
+ const merged = current.comments.map((now) => userTouched(now.id, now) ? now : fromTurn.get(now.id) ?? now);
45
+ // Comments the turn itself added are not in `current` yet. Only those: a
46
+ // comment present in both `before` and `after` but missing from `current` is
47
+ // one the user deleted while the turn ran, and pushing it back would undo
48
+ // the deletion — the very thing this function exists to prevent. (Delete
49
+ // removes the row outright; drop only marks it, so drops survive above.)
50
+ const seen = new Set(merged.map((c) => c.id));
51
+ for (const c of after.comments) {
52
+ if (!wasThere.has(c.id) && !seen.has(c.id))
53
+ merged.push(c);
54
+ }
55
+ return {
56
+ ...after,
57
+ comments: merged,
58
+ // The turn never sets a queue status, so a "mark reviewed" clicked while it
59
+ // ran is the user's and stands. The verdict it can change, so the turn wins
60
+ // there only when it actually revised one.
61
+ status: current.status,
62
+ verdict: after.verdict === before.verdict ? current.verdict : after.verdict,
63
+ };
64
+ }
65
+ export function applyRevisions(artifact, revisions, opts = {}) {
66
+ const newId = opts.newId ?? randomUUID;
67
+ const applied = [];
68
+ const refused = [];
69
+ let next = artifact;
70
+ const refuse = (revision, reason) => refused.push({ revision, reason });
71
+ /** A comment the user authored or rewrote is theirs, not the run's to redo. */
72
+ const isUsers = (id) => {
73
+ const c = next.comments.find((x) => x.id === id);
74
+ return c ? c.origin === "user" || c.editedByUser : false;
75
+ };
76
+ for (const revision of revisions) {
77
+ switch (revision.kind) {
78
+ case "summary": {
79
+ next = { ...next, summary: revision.body };
80
+ applied.push(revision);
81
+ break;
82
+ }
83
+ case "verdict": {
84
+ next = { ...next, verdict: revision.verdict };
85
+ applied.push(revision);
86
+ break;
87
+ }
88
+ case "chapter": {
89
+ const chapter = next.chapters.find((ch) => ch.id === revision.chapterId);
90
+ if (!chapter) {
91
+ refuse(revision, `no chapter "${revision.chapterId}" in this review`);
92
+ break;
93
+ }
94
+ next = {
95
+ ...next,
96
+ chapters: next.chapters.map((ch) => ch.id === revision.chapterId
97
+ ? {
98
+ ...ch,
99
+ title: revision.title ?? ch.title,
100
+ explanation: revision.explanation ?? ch.explanation,
101
+ }
102
+ : ch),
103
+ };
104
+ applied.push(revision);
105
+ break;
106
+ }
107
+ case "comment-edit": {
108
+ if (!next.comments.some((c) => c.id === revision.commentId)) {
109
+ refuse(revision, `no comment ${revision.commentId} in this review`);
110
+ break;
111
+ }
112
+ if (isUsers(revision.commentId) && !opts.allowUserComments) {
113
+ refuse(revision, "that comment is the user's own writing — ask before rewriting it");
114
+ break;
115
+ }
116
+ next = {
117
+ ...next,
118
+ comments: next.comments.map((c) => c.id === revision.commentId ? { ...c, body: revision.body } : c),
119
+ };
120
+ applied.push(revision);
121
+ break;
122
+ }
123
+ case "comment-drop": {
124
+ if (!next.comments.some((c) => c.id === revision.commentId)) {
125
+ refuse(revision, `no comment ${revision.commentId} in this review`);
126
+ break;
127
+ }
128
+ if (isUsers(revision.commentId) && !opts.allowUserComments) {
129
+ refuse(revision, "that comment is the user's own writing — ask before dropping it");
130
+ break;
131
+ }
132
+ // Dropped, not deleted: `status` is what Send already reads, and the
133
+ // cockpit shows a dropped comment struck through rather than vanished.
134
+ next = {
135
+ ...next,
136
+ comments: next.comments.map((c) => c.id === revision.commentId ? { ...c, status: "dropped" } : c),
137
+ };
138
+ applied.push(revision);
139
+ break;
140
+ }
141
+ case "comment-add": {
142
+ next = {
143
+ ...next,
144
+ comments: [
145
+ ...next.comments,
146
+ {
147
+ id: newId(),
148
+ path: revision.path,
149
+ line: revision.line,
150
+ body: revision.body,
151
+ chapterId: revision.chapterId,
152
+ origin: "ai",
153
+ status: "draft",
154
+ editedByUser: false,
155
+ originalLine: null,
156
+ drifted: false,
157
+ },
158
+ ],
159
+ };
160
+ applied.push(revision);
161
+ break;
162
+ }
163
+ }
164
+ }
165
+ return { artifact: next, applied, refused };
166
+ }
@@ -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
  }
@@ -0,0 +1,220 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { AiChatTurnSchema, } from "../core/artifact.js";
4
+ import { createRunDir, prepareCheckout, removeRunDir, sourceDir } from "../core/checkout.js";
5
+ import { applyRevisions, snapshotReview } from "../core/revise.js";
6
+ import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
7
+ import { beginReview, endReview } from "./inflight.js";
8
+ import { describeEvent } from "./progress.js";
9
+ import { buildChatPrompt } from "./prompt.js";
10
+ /**
11
+ * One turn of a conversation about a drafted review.
12
+ *
13
+ * The turn resumes the review's own Claude session where it can, so the model
14
+ * answering "did you actually check that?" is the one that did or didn't. The
15
+ * agent revises the draft directly — no accept step — because the user asked
16
+ * for the change and asking again would be friction wearing safety's coat.
17
+ *
18
+ * Everything a review run is denied, a chat turn is denied identically: it has
19
+ * no GitHub credentials, treats the checkout as untrusted content, and never
20
+ * gets Edit or Write. It is one step from the only path that writes to GitHub,
21
+ * so the isolation is the same isolation.
22
+ */
23
+ /** All an untrusted turn needs from a checkout — everything else stays off. */
24
+ const READ_TOOLS = ["Read", "Grep", "Glob"];
25
+ const OFF_TOOLS = ["Bash", "Edit", "Write", "NotebookEdit", "Task", "WebFetch", "WebSearch"];
26
+ const TRUSTED_TOOLS = [...READ_TOOLS, "Bash", "WebFetch", "WebSearch"];
27
+ const TRUSTED_OFF_TOOLS = ["Edit", "Write", "NotebookEdit"];
28
+ /**
29
+ * Put the reviewed source back under the chat turn, or say plainly that we
30
+ * could not.
31
+ *
32
+ * The checkout cache is small and evicts by least-recent use, so by the time
33
+ * someone argues with a review its source is often already gone. That matters
34
+ * more here than anywhere else: the turn resumes a session whose transcript
35
+ * still describes files it read, and Claude sessions are keyed by working
36
+ * directory. Resuming into a deleted one leaves a model that believes it can
37
+ * open the file and will otherwise describe it from memory.
38
+ *
39
+ * `prepareCheckout` is idempotent and restores the same deterministic path, so
40
+ * re-cloning is enough to make resume whole again. It also touches the
41
+ * directory, which is what keeps a checkout alive through a long conversation:
42
+ * eviction is by mtime, so every turn renews it.
43
+ */
44
+ export async function ensureChatSource(artifact, opts = {}) {
45
+ const log = opts.log ?? (() => { });
46
+ const prepare = opts.prepare ?? prepareCheckout;
47
+ // A review that never had a checkout has nothing to restore, and re-cloning
48
+ // now would give the conversation context the review itself never had.
49
+ if (!artifact.run?.withSource)
50
+ return { dir: null, sha: null, recloned: false };
51
+ const ref = {
52
+ owner: artifact.pr.owner,
53
+ repo: artifact.pr.repo,
54
+ number: artifact.pr.number,
55
+ };
56
+ const existed = await isCheckedOut(sourceDir(ref));
57
+ try {
58
+ // The same trust the review ran under: a checkout prepared as trusted keeps
59
+ // the PR's own CLAUDE.md in place, and re-preparing with the wrong flag
60
+ // would either un-quarantine it or quarantine what the review could read.
61
+ const checkout = await prepare(ref, { log, trusted: artifact.run.trusted });
62
+ if (!existed)
63
+ log("The checkout had been evicted — re-cloned it so this turn can read the code.");
64
+ return { dir: checkout.dir, sha: checkout.sha, recloned: !existed };
65
+ }
66
+ catch (err) {
67
+ log(`Could not restore the source (${err instanceof Error ? err.message : err}) — ` +
68
+ `this turn works from the review, the diff and the conversation alone.`);
69
+ return { dir: null, sha: null, recloned: false };
70
+ }
71
+ }
72
+ async function isCheckedOut(dir) {
73
+ return fs
74
+ .stat(dir)
75
+ .then((s) => s.isDirectory())
76
+ .catch(() => false);
77
+ }
78
+ /**
79
+ * Append the user's message and the reviewer's answer, applying whatever the
80
+ * answer revised. Claims the artifact's inflight slot for the duration: the
81
+ * daemon's timer and the cockpit's re-review button write the same file.
82
+ */
83
+ export async function runChatTurn(artifact, message, opts = {}) {
84
+ beginReview(artifact.id);
85
+ try {
86
+ return await turn(artifact, message, opts);
87
+ }
88
+ finally {
89
+ endReview(artifact.id);
90
+ }
91
+ }
92
+ async function turn(artifact, message, opts) {
93
+ const now = opts.now ?? (() => new Date().toISOString());
94
+ const newId = opts.newId ?? randomUUID;
95
+ const run = opts.run ?? runClaude;
96
+ const log = opts.onProgress ?? (() => { });
97
+ // Restore the checkout unless the caller already resolved one (or asked for
98
+ // none). Doing it here, rather than making the cockpit remember, is what
99
+ // stops "the cache evicted it" from silently degrading every later turn.
100
+ const resolved = opts.source === undefined
101
+ ? await ensureChatSource(artifact, { log })
102
+ : { dir: opts.source, sha: opts.sourceSha ?? null, recloned: false };
103
+ const source = resolved.dir;
104
+ const trusted = (opts.trusted ?? artifact.run?.trusted ?? false) && source !== null;
105
+ // A re-cloned checkout follows the PR, not the review: if the author pushed
106
+ // since, the files under the model are newer than the draft it is defending.
107
+ const moved = source !== null && resolved.sha !== null && artifact.pr.headSha !== "" && resolved.sha !== artifact.pr.headSha
108
+ ? `Note: the review was written against ${artifact.pr.headSha.slice(0, 7)}, but the checkout is now at ${resolved.sha.slice(0, 7)} — the author has pushed since. Code you read may not be the code the review is about; say so when it matters.`
109
+ : undefined;
110
+ if (moved)
111
+ log(moved);
112
+ // The session is only worth resuming into the directory it was created in.
113
+ // Without the checkout the transcript survives but Read and Grep do not, and
114
+ // a model that thinks it can still open the file will describe code from
115
+ // memory — worse than one that knows it is working blind.
116
+ const resumeSessionId = source && artifact.run?.sessionId ? artifact.run.sessionId : undefined;
117
+ if (artifact.run?.sessionId && !source) {
118
+ log("The checkout is gone — this turn works from the review, the diff and the conversation.");
119
+ }
120
+ const { prompt } = buildChatPrompt(artifact, message, {
121
+ refs: opts.refs,
122
+ source: source !== null,
123
+ trusted,
124
+ resumed: Boolean(resumeSessionId),
125
+ sourceNote: moved,
126
+ });
127
+ const empty = await createRunDir();
128
+ const claudeOpts = {
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),
134
+ cwd: source ?? empty,
135
+ env: unauthenticatedEnv(process.env, empty),
136
+ allowedTools: source ? (trusted ? TRUSTED_TOOLS : READ_TOOLS) : undefined,
137
+ disallowedTools: source
138
+ ? trusted
139
+ ? TRUSTED_OFF_TOOLS
140
+ : OFF_TOOLS
141
+ : [...OFF_TOOLS, ...READ_TOOLS],
142
+ isolateWorkspace: !(source && trusted),
143
+ resumeSessionId,
144
+ };
145
+ let reply;
146
+ let costUsd;
147
+ let sessionId;
148
+ try {
149
+ const first = await run(prompt, claudeOpts);
150
+ try {
151
+ reply = AiChatTurnSchema.parse(extractJson(first.text));
152
+ costUsd = first.costUsd;
153
+ sessionId = first.sessionId;
154
+ }
155
+ catch (err) {
156
+ const detail = err instanceof Error ? err.message : String(err);
157
+ log("Model output failed validation — retrying once…");
158
+ // Resume the turn we just had rather than the review: the retry is about
159
+ // the JSON shape, and re-sending the whole prompt would say it all twice.
160
+ const second = await run(retryPrompt(first.text, detail), {
161
+ ...claudeOpts,
162
+ resumeSessionId: first.sessionId ?? resumeSessionId,
163
+ });
164
+ reply = AiChatTurnSchema.parse(extractJson(second.text));
165
+ const total = (first.costUsd ?? 0) + (second.costUsd ?? 0);
166
+ costUsd = total > 0 ? total : null;
167
+ sessionId = second.sessionId ?? first.sessionId;
168
+ }
169
+ }
170
+ finally {
171
+ await removeRunDir(empty);
172
+ }
173
+ // The first turn snapshots the review, so "reset" always has somewhere to go
174
+ // back to. Later turns leave that snapshot alone: it is the pre-chat draft,
175
+ // not the previous turn's.
176
+ const at = now();
177
+ const preChat = artifact.preChat ?? snapshotReview(artifact, at);
178
+ const userTurn = {
179
+ id: newId(),
180
+ role: "user",
181
+ at,
182
+ body: message,
183
+ refs: opts.refs ?? [],
184
+ revisions: [],
185
+ refused: [],
186
+ costUsd: null,
187
+ };
188
+ const revised = applyRevisions(artifact, reply.revisions, {
189
+ newId,
190
+ allowUserComments: opts.allowUserComments,
191
+ });
192
+ const assistantTurn = {
193
+ id: newId(),
194
+ role: "assistant",
195
+ at: now(),
196
+ body: reply.reply,
197
+ refs: [],
198
+ revisions: revised.applied,
199
+ refused: revised.refused,
200
+ costUsd,
201
+ };
202
+ return {
203
+ artifact: {
204
+ ...revised.artifact,
205
+ updatedAt: assistantTurn.at,
206
+ preChat,
207
+ chat: [...artifact.chat, userTurn, assistantTurn],
208
+ run: artifact.run ? { ...artifact.run, sessionId: sessionId ?? artifact.run.sessionId } : null,
209
+ },
210
+ applied: revised.applied,
211
+ refused: revised.refused,
212
+ };
213
+ }
214
+ function retryPrompt(badOutput, error) {
215
+ return `Your previous response could not be parsed as the required JSON.
216
+ Error: ${error}
217
+ Previous response (truncated): ${badOutput.slice(0, 2_000)}
218
+
219
+ Respond again with ONLY the valid JSON object described earlier: an object with "reply" and "revisions".`;
220
+ }