@fullstackhouse/cerber 0.28.0 → 0.29.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
@@ -96,9 +96,15 @@ running. It goes to whatever this machine already has (Notification Centre on
96
96
  macOS, `notify-send` on Linux) and stays quiet where there is neither. Off with
97
97
  `daemon.notify` in `~/.cerber/config.json`, or the checkbox in Settings.
98
98
 
99
- The cockpit has a bell of its own, and it is the better one where it can ring:
100
- its notification **opens that review when you click it**. But it is a page
101
- notification — it needs a tab that is open, alive and permitted, which is
99
+ **On macOS, clicking it opens that review** in the cockpit. It takes a small app
100
+ cerber builds once under `~/.cerber` — the first tap asks you to allow
101
+ notifications from "Cerber" — because a notification can only open the app that
102
+ posted it, and one posted the usual way belongs to Script Editor, which is
103
+ where the click would otherwise land. A `notify-send` tap on Linux announces
104
+ the PR but has no click to give.
105
+
106
+ The cockpit has a bell of its own, which does the same from the page. But it is
107
+ a page notification — it needs a tab that is open, alive and permitted, which is
102
108
  exactly what you don't have on the afternoons this feature is for. So it stands
103
109
  down while the machine's own tap is on, rather than making one PR two popups,
104
110
  and the bell in the top bar says so. Untick the machine one and this browser
@@ -0,0 +1,21 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
2
+ <!-- The app icon for the notifier bundle cerber builds under ~/.cerber, and
3
+ the source the committed assets/cerber.icns is rendered from (see the
4
+ note in src/core/notify.ts). Same paw as web/public/favicon.svg — an app
5
+ icon is a squircle on the shelf, so the mark is set on the brand tile
6
+ rather than floated on nothing, which at notification size reads as a
7
+ missing icon. -->
8
+ <defs>
9
+ <linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
10
+ <stop offset="0" stop-color="#5f6ef2" />
11
+ <stop offset="1" stop-color="#2f3bbd" />
12
+ </linearGradient>
13
+ </defs>
14
+ <rect x="100" y="100" width="824" height="824" rx="185" fill="url(#tile)" />
15
+ <g fill="#fff" transform="translate(200 188) scale(26)">
16
+ <circle cx="6.1" cy="8.9" r="2.5" />
17
+ <circle cx="12" cy="6.7" r="2.7" />
18
+ <circle cx="17.9" cy="8.9" r="2.5" />
19
+ <ellipse cx="12" cy="16.6" rx="5.4" ry="4.3" />
20
+ </g>
21
+ </svg>
Binary file
package/dist/cli/index.js CHANGED
@@ -13,7 +13,7 @@ import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, reconcileRunn
13
13
  import { pool, reviewPr } from "../runner/review.js";
14
14
  import { isReviewRunning } from "../runner/inflight.js";
15
15
  import { startDaemon } from "../server/daemon.js";
16
- import { startServer } from "../server/index.js";
16
+ import { cockpitUrl, startServer } from "../server/index.js";
17
17
  const program = new Command();
18
18
  program
19
19
  .name("cerber")
@@ -415,6 +415,13 @@ program
415
415
  trust: opts.trust ? undefined : false,
416
416
  autoReview: opts.autoReview,
417
417
  notify: opts.notify,
418
+ // The address as asked for, which the server corrects to the one it
419
+ // bound (`cockpitAt`) a moment later. Both, because neither alone
420
+ // is enough: the daemon's first poll runs while the port is still
421
+ // being bound, and a PR discovered on it would otherwise be
422
+ // announced with nowhere to click — while `--port 0` has no
423
+ // answer here at all, since the OS has not chosen yet.
424
+ cockpitUrl: cockpitUrl({ host: opts.host, port: Number(opts.port), token }) ?? undefined,
418
425
  autoSend: opts.autoSend ? "on" : "shadow",
419
426
  autoSendThreshold: threshold,
420
427
  })
@@ -274,6 +274,19 @@ export const ArtifactSchema = z.object({
274
274
  chapters: z.array(ChapterSchema).default([]),
275
275
  comments: z.array(CommentSchema).default([]),
276
276
  verdict: VerdictSchema.nullable().default(null),
277
+ /**
278
+ * The review body to post, written by hand. Null — the normal case — means
279
+ * the body is composed from the draft at send time (§14.4: summary,
280
+ * walkthrough, the comments that cannot post inline, footer).
281
+ *
282
+ * Everywhere else in cerber, what GitHub gets is derived from what the
283
+ * cockpit shows. This is the one place the user can cut that link, so it is
284
+ * a field rather than a rewrite of the summary: the draft underneath stays
285
+ * exactly as the review wrote it, the body can always be built again, and
286
+ * the send panel says which of the two is about to be posted. A re-review
287
+ * clears it — it described a body for a draft that no longer exists.
288
+ */
289
+ bodyOverride: z.string().nullable().default(null),
277
290
  run: RunInfoSchema.nullable().default(null),
278
291
  /** Set once the review was sent to GitHub (explicitly, or via opt-in auto-send). */
279
292
  sent: SentInfoSchema.nullable().default(null),
@@ -161,6 +161,12 @@ export function describeChange(before, after) {
161
161
  lines.push(`verdict ${wasVerdict ? "changed to" : "set to"} ${verdict.recommendation.replace("_", " ")}` +
162
162
  ` (${verdict.confidence}% sure of the findings)`);
163
163
  }
164
+ // What posts stopped following the draft, or started following it again.
165
+ if ((before?.bodyOverride ?? null) !== (after.bodyOverride ?? null)) {
166
+ lines.push(after.bodyOverride == null
167
+ ? "the body to post follows the review again"
168
+ : "the body to post was written by hand");
169
+ }
164
170
  const comments = describeComments(before?.comments ?? [], after.comments);
165
171
  if (comments)
166
172
  lines.push(comments);
@@ -7,11 +7,26 @@
7
7
  // all afternoon. This half rides the poll that discovers the PR instead, so the
8
8
  // tap survives a closed cockpit, a denied permission and a browser restart.
9
9
  //
10
+ // A click has to land in the same place either way, which on macOS is the whole
11
+ // difference between the two paths below. `osascript -e 'display notification'`
12
+ // posts as Script Editor — that is the app macOS sees making the call — so
13
+ // clicking the tap opens Script Editor, an app the user never asked for and a
14
+ // dead end from a notification about a PR. A notification can only open what
15
+ // posted it, so cerber posts its own: a small app bundle built once under
16
+ // `~/.cerber`, which opens the review in the cockpit when clicked. Where it
17
+ // can't be built, the plain `osascript` tap is still better than silence.
18
+ //
10
19
  // Everything here shells out to whatever the OS already has. No dependency, no
11
20
  // daemon of its own, and a machine with no notifier is a machine cerber stays
12
21
  // quiet on rather than one it fails on.
13
22
  import { execFile } from "node:child_process";
23
+ import { promises as fs } from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
14
27
  import { promisify } from "node:util";
28
+ import { artifactId, artifactKey } from "./artifact.js";
29
+ import { cerberHome } from "./state.js";
15
30
  const execFileAsync = promisify(execFile);
16
31
  /**
17
32
  * How long a notifier gets before the poll stops waiting for it.
@@ -25,23 +40,42 @@ const execFileAsync = promisify(execFile);
25
40
  */
26
41
  export const NOTIFY_TIMEOUT_MS = 5_000;
27
42
  const slug = (a) => `${a.repo}#${a.number}`;
43
+ /**
44
+ * Text a notification body or an AppleScript literal can actually hold. A PR
45
+ * title is somebody else's text and can carry anything; control characters
46
+ * collapse to a space rather than splitting a line the applet reads back by
47
+ * line, or ending a string literal early.
48
+ */
49
+ const flatten = (value) => value.replace(/[\u0000-\u001f\u007f]+/g, " ");
28
50
  /**
29
51
  * One popup for one poll's arrivals — a batch is one interruption, not five.
30
52
  * Deliberately the same shape and wording the cockpit's bell uses, so the two
31
53
  * channels never read as two different pieces of news about one PR.
54
+ *
55
+ * `cockpit` is where clicking it lands: the review itself when the notice is
56
+ * about exactly one PR, the queue when it is about several. It must be an
57
+ * origin with no fragment of its own — the deep link is a fragment, and the
58
+ * cockpit is a hash-routed page.
32
59
  */
33
- export function notice(arrived) {
60
+ export function notice(arrived, cockpit) {
34
61
  if (arrived.length === 0)
35
62
  return null;
63
+ const base = cockpit || null;
36
64
  if (arrived.length === 1) {
37
65
  const a = arrived[0];
38
- return { title: `${slug(a)} awaits your review`, body: `${a.title} — ${a.author}` };
66
+ const key = artifactKey(artifactId(a));
67
+ return {
68
+ title: `${slug(a)} awaits your review`,
69
+ body: `${a.title} — ${a.author}`,
70
+ url: base && `${base}#/r/${encodeURIComponent(key)}`,
71
+ };
39
72
  }
40
73
  const named = arrived.slice(0, 3).map(slug);
41
74
  const rest = arrived.length - named.length;
42
75
  return {
43
76
  title: `${arrived.length} PRs await your review`,
44
77
  body: rest > 0 ? `${named.join(", ")} and ${rest} more` : named.join(", "),
78
+ url: base,
45
79
  };
46
80
  }
47
81
  /**
@@ -54,13 +88,15 @@ export function notice(arrived) {
54
88
  * can't appear in a literal at all, so they collapse to a space.
55
89
  */
56
90
  export function appleScriptLiteral(value) {
57
- const flat = value.replace(/[\u0000-\u001f\u007f]+/g, " ");
58
- return `"${flat.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
91
+ return `"${flatten(value).replace(/[\\"]/g, (c) => `\\${c}`)}"`;
59
92
  }
60
93
  /**
61
94
  * The command that taps this machine's notification centre, or null where there
62
95
  * is nothing to tap. macOS and the freedesktop notifiers cover what cerber runs
63
96
  * on; anywhere else it stays quiet rather than pretending.
97
+ *
98
+ * On macOS this is the fallback rather than the first choice — see
99
+ * `ensureNotifierApp` for the one whose click opens the review.
64
100
  */
65
101
  export function notifyCommand(n, platform = process.platform) {
66
102
  if (platform === "darwin") {
@@ -83,6 +119,275 @@ export function notifyCommand(n, platform = process.platform) {
83
119
  }
84
120
  return null;
85
121
  }
122
+ /**
123
+ * What the notification centre knows cerber by. Permission is granted to this
124
+ * identifier, once, by the user — so it has to survive every rebuild, or each
125
+ * new version of cerber would ask for notifications again as a stranger.
126
+ */
127
+ export const BUNDLE_ID = "house.fullstack.cerber";
128
+ /** Bumped whenever the applet or its plist changes, so an installed app is replaced. */
129
+ const APP_BUILD = "3";
130
+ const ICON = fileURLToPath(new URL("../../assets/cerber.icns", import.meta.url));
131
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
132
+ /**
133
+ * The app's whole job, in the only language a bundle this small can be written
134
+ * in without a compiler on the user's machine.
135
+ *
136
+ * It is launched twice per notification and has to tell the two apart with no
137
+ * arguments, because a click gives it none: macOS just opens the app that
138
+ * posted the notification. The pending file is what distinguishes them, and
139
+ * reading it is destructive — a launch that finds a notice posts it, a launch
140
+ * that finds none is the click on the last one, and opens where that one
141
+ * pointed. Nothing else can put the app in either state, so the two can't be
142
+ * confused.
143
+ *
144
+ * The paths are baked in rather than derived from the home folder, so a cerber
145
+ * running under CERBER_HOME talks to the app it actually built.
146
+ */
147
+ export function appletSource(dir) {
148
+ const pending = appleScriptLiteral(path.join(dir, "pending.txt"));
149
+ const landing = appleScriptLiteral(path.join(dir, "url.txt"));
150
+ // `on run` is a launch from cold, `on reopen` a click while it is still up
151
+ // after posting — same job either way.
152
+ return `on run
153
+ tap()
154
+ end run
155
+
156
+ on reopen
157
+ tap()
158
+ end reopen
159
+
160
+ on tap()
161
+ set pendingFile to ${pending}
162
+ set urlFile to ${landing}
163
+ try
164
+ set raw to do shell script "cat " & quoted form of pendingFile & " 2>/dev/null; rm -f " & quoted form of pendingFile
165
+ on error
166
+ set raw to ""
167
+ end try
168
+ if raw is "" then
169
+ try
170
+ set target to do shell script "cat " & quoted form of urlFile & " 2>/dev/null"
171
+ on error
172
+ set target to ""
173
+ end try
174
+ -- Consumed on the way out, the same way the notice was: what is left
175
+ -- behind is what cerber reads as "an earlier notification is still
176
+ -- sitting there unclicked".
177
+ if target is not "" then
178
+ do shell script "open " & quoted form of target & "; rm -f " & quoted form of urlFile
179
+ else
180
+ do shell script "rm -f " & quoted form of urlFile
181
+ end if
182
+ return
183
+ end if
184
+ -- do shell script hands back the lines separated by return, not linefeed.
185
+ set AppleScript's text item delimiters to return
186
+ set parts to text items of raw
187
+ if (count of parts) < 2 then return
188
+ set target to ""
189
+ if (count of parts) > 2 then set target to item 3 of parts
190
+ -- umask, because the target carries the cockpit's token: nothing here is
191
+ -- another account's business to read.
192
+ do shell script "umask 077; printf %s " & quoted form of target & " > " & quoted form of urlFile
193
+ display notification (item 2 of parts) with title (item 1 of parts)
194
+ end tap
195
+ `;
196
+ }
197
+ /**
198
+ * Where a notice may point, given whether an earlier one is still sitting
199
+ * unclicked in Notification Centre.
200
+ *
201
+ * A click reaches the app with no arguments — macOS just opens whatever posted
202
+ * the notification — so one app can hold exactly one click target, and two
203
+ * outstanding notifications cannot be told apart. Pointing both at the newest
204
+ * review would open the wrong PR from the older notification, silently and
205
+ * convincingly. So the second one drops its fragment and both land on the
206
+ * queue, where every arrival is a row: less specific, never wrong.
207
+ *
208
+ * It heals itself. The target is consumed by the click that follows it, so the
209
+ * next notice after any click deep-links again; only a run of notifications
210
+ * nobody touches stays on the queue.
211
+ */
212
+ export function clickTarget(url, outstanding) {
213
+ if (!url || !outstanding)
214
+ return url;
215
+ const fragment = url.indexOf("#");
216
+ return fragment < 0 ? url : url.slice(0, fragment);
217
+ }
218
+ /**
219
+ * The notice, as the app reads it back: title, body and click target, one line
220
+ * each. Flattened first — a newline in a PR title would otherwise shift every
221
+ * line after it, and the body would arrive as the URL.
222
+ */
223
+ export function pendingPayload(n) {
224
+ return `${flatten(n.title)}\n${flatten(n.body)}\n${flatten(n.url ?? "")}\n`;
225
+ }
226
+ async function run(file, args) {
227
+ await execFileAsync(file, args, { timeout: NOTIFY_TIMEOUT_MS });
228
+ }
229
+ /**
230
+ * Set a key in the applet's Info.plist whether or not it is already there.
231
+ *
232
+ * `plutil -replace` is documented as overwriting an *existing* value — it does
233
+ * create a missing key in practice, but on undocumented behaviour, and every
234
+ * key this sets is one `osacompile` may or may not have written. A failure
235
+ * here fails the build and silently costs the click, so the documented
236
+ * inserting form is the fallback rather than the assumption.
237
+ */
238
+ async function setPlistValue(plist, key, type, value) {
239
+ const flags = [key, type, value, plist];
240
+ await run("plutil", ["-replace", ...flags]).catch(() => run("plutil", ["-insert", ...flags]));
241
+ }
242
+ /**
243
+ * Build the app cerber posts through, into `~/.cerber/Cerber.app`.
244
+ *
245
+ * Three things here are load-bearing, each learned the way macOS teaches them —
246
+ * by dropping the notification and saying nothing:
247
+ *
248
+ * - **A bundle identifier.** `osacompile` leaves the applet without one, and
249
+ * a bundle with no identity gets its notifications denied.
250
+ * - **A signature that matches.** Editing `Info.plist` invalidates the ad-hoc
251
+ * signature `osacompile` writes, and a bundle whose seal is broken is denied
252
+ * just as flatly. So it is re-signed, ad-hoc, after the edits.
253
+ * - **A path LaunchServices will register.** A bundle under `/tmp` is never
254
+ * resolved to an app, so the request for permission is never even asked.
255
+ * `~/.cerber` is fine, and `lsregister` makes sure it is known before the
256
+ * first notification rather than after it.
257
+ */
258
+ async function buildNotifierApp(app, dir) {
259
+ // 0700: the notices left here hold the cockpit's URL, token and all.
260
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
261
+ const source = path.join(dir, "applet.applescript");
262
+ await fs.writeFile(source, appletSource(dir));
263
+ // Staged in the one place LaunchServices refuses to register — the same
264
+ // refusal that makes a bundle under /tmp undeliverable also makes it
265
+ // unlaunchable, which is what a half-built app must be. Staged beside the
266
+ // real one it is a second app with cerber's own bundle identifier, and a
267
+ // launch that reaches it mid-build finds an applet with no script yet and
268
+ // puts up AppleScript's "Press Run to run this script" dialog.
269
+ //
270
+ // The name still ends in .app because osacompile picks what it writes from
271
+ // the extension, and anything else gets a bare script file with no bundle
272
+ // around it — no Info.plist, nothing to sign, nothing to post.
273
+ const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "cerber-notifier-"));
274
+ try {
275
+ await assemble(path.join(stagingDir, "Cerber.app"), app, source);
276
+ }
277
+ finally {
278
+ // Every attempt, not just the ones that worked: a machine with no
279
+ // osacompile fails here once per process, and each failure would otherwise
280
+ // leave its half-built bundle behind for good.
281
+ await fs.rm(stagingDir, { recursive: true, force: true });
282
+ }
283
+ }
284
+ /** Everything between an empty staging directory and an installed, signed app. */
285
+ async function assemble(staged, app, source) {
286
+ await run("osacompile", ["-o", staged, source]);
287
+ const plist = path.join(staged, "Contents", "Info.plist");
288
+ await setPlistValue(plist, "CFBundleIdentifier", "-string", BUNDLE_ID);
289
+ // An agent, not an app: cerber's notifier has no window and no business in
290
+ // the Dock or the app switcher for the second it spends posting.
291
+ await setPlistValue(plist, "LSUIElement", "-bool", "true");
292
+ // The applet ships an asset catalogue holding the generic script icon, and a
293
+ // catalogue wins over anything in Resources/ — so the paw only lands once the
294
+ // catalogue and the name it answers to are both gone.
295
+ const resources = path.join(staged, "Contents", "Resources");
296
+ await fs.rm(path.join(resources, "Assets.car"), { force: true });
297
+ await run("plutil", ["-remove", "CFBundleIconName", plist]).catch(() => { });
298
+ await fs.copyFile(ICON, path.join(resources, "applet.icns")).catch(() => {
299
+ // No icon is a generic icon, not a failure — the tap still works.
300
+ });
301
+ await run("codesign", ["--force", "--sign", "-", staged]);
302
+ // Not atomic, because a directory swap can't be: the window between these two
303
+ // is a few milliseconds in which a notification would fall back to osascript.
304
+ await fs.rm(app, { recursive: true, force: true });
305
+ try {
306
+ await fs.rename(staged, app);
307
+ }
308
+ catch {
309
+ // A CERBER_HOME on another volume: rename can't cross one, and a copy
310
+ // carries the signature with it — it lives in the bundle's own files.
311
+ await fs.cp(staged, app, { recursive: true });
312
+ }
313
+ await run(LSREGISTER, ["-f", app]).catch(() => {
314
+ // Best effort: `open` registers the bundle too, just later than we'd like.
315
+ });
316
+ }
317
+ /**
318
+ * One build attempt per home per process, whatever the answer.
319
+ *
320
+ * The poll awaits `announce`, so retrying a hopeless build every time a PR
321
+ * lands would spend a timeout apiece on a machine that is never going to have
322
+ * `osacompile`. A remembered failure falls back to the plain tap instead.
323
+ */
324
+ const builds = new Map();
325
+ export function ensureNotifierApp() {
326
+ // Resolved, because the paths go *into* the app: CERBER_HOME may be
327
+ // relative, and an app LaunchServices launches on a click inherits none of
328
+ // the daemon's working directory to resolve it against.
329
+ const home = path.resolve(cerberHome());
330
+ let building = builds.get(home);
331
+ if (!building) {
332
+ building = installNotifierApp(home).catch(() => null);
333
+ builds.set(home, building);
334
+ }
335
+ return building;
336
+ }
337
+ async function installNotifierApp(home) {
338
+ const app = path.join(home, "Cerber.app");
339
+ const dir = path.join(home, "notify");
340
+ const stamp = path.join(dir, "build");
341
+ const want = `${APP_BUILD} ${app}\n`;
342
+ const built = await fs
343
+ .stat(path.join(app, "Contents", "Info.plist"))
344
+ .then(() => true)
345
+ .catch(() => false);
346
+ if (built && (await fs.readFile(stamp, "utf8").catch(() => null)) === want)
347
+ return app;
348
+ await buildNotifierApp(app, dir);
349
+ await fs.writeFile(stamp, want);
350
+ return app;
351
+ }
352
+ /**
353
+ * Hand the notice to cerber's own app, so a click on it opens the review.
354
+ * False means the app couldn't take it and the caller should fall back — the
355
+ * tap matters more than which app posts it.
356
+ */
357
+ async function postThroughApp(n) {
358
+ const app = await ensureNotifierApp();
359
+ if (!app)
360
+ return false;
361
+ // Beside the app that was actually built, rather than wherever CERBER_HOME
362
+ // points now — the paths the applet reads were baked into it at build time.
363
+ const dir = path.join(path.dirname(app), "notify");
364
+ const file = path.join(dir, "pending.txt");
365
+ const tmp = path.join(dir, `pending.${process.pid}.tmp`);
366
+ try {
367
+ // A target the app hasn't consumed means the notification it belongs to is
368
+ // still there to be clicked, and two of them cannot be told apart.
369
+ const outstanding = await fs
370
+ .stat(path.join(dir, "url.txt"))
371
+ .then(() => true)
372
+ .catch(() => false);
373
+ // tmp+rename, so the app can never read half a notice: it reads the file
374
+ // the instant `open` wakes it, and a partial one would post a PR title cut
375
+ // in two or a URL that leads nowhere.
376
+ // 0600 for the same reason the app's own write is: this line is the
377
+ // cockpit URL, and the cockpit URL carries the token that gets in.
378
+ await fs.writeFile(tmp, pendingPayload({ ...n, url: clickTarget(n.url, outstanding) }), {
379
+ mode: 0o600,
380
+ });
381
+ await fs.rename(tmp, file);
382
+ // -g, so a PR landing doesn't pull focus out of whatever you're doing.
383
+ await run("open", ["-g", app]);
384
+ return true;
385
+ }
386
+ catch {
387
+ await fs.rm(tmp, { force: true }).catch(() => { });
388
+ return false;
389
+ }
390
+ }
86
391
  /**
87
392
  * Show it, and say whether it was shown. False is an ordinary answer here —
88
393
  * an unsupported platform, no `notify-send` installed, a headless box, a
@@ -91,8 +396,10 @@ export function notifyCommand(n, platform = process.platform) {
91
396
  * important thing a poll does, so it is also the last thing allowed to stop
92
397
  * one.
93
398
  */
94
- export async function notify(n) {
95
- const cmd = notifyCommand(n);
399
+ export async function notify(n, platform = process.platform) {
400
+ if (platform === "darwin" && (await postThroughApp(n)))
401
+ return true;
402
+ const cmd = notifyCommand(n, platform);
96
403
  if (!cmd)
97
404
  return false;
98
405
  try {
@@ -60,6 +60,9 @@ export function mergeConcurrentEdits(before, after, current) {
60
60
  // there only when it actually revised one.
61
61
  status: current.status,
62
62
  verdict: after.verdict === before.verdict ? current.verdict : after.verdict,
63
+ // A turn never writes a send body, so one written while it ran is the
64
+ // user's and stands — as does clearing one.
65
+ bodyOverride: current.bodyOverride,
63
66
  };
64
67
  }
65
68
  export function applyRevisions(artifact, revisions, opts = {}) {
package/dist/core/send.js CHANGED
@@ -49,7 +49,16 @@ export function buildReviewPayload(artifact, event) {
49
49
  parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
50
50
  return {
51
51
  event,
52
- body: parts.join("\n").trim(),
52
+ // A body the user wrote by hand replaces the composed one outright,
53
+ // footer and folded notes included: it is the review's own comment, and
54
+ // half-honouring it — keeping a footer they deleted, re-appending notes
55
+ // they cut — would post something nobody wrote. It is not trimmed either,
56
+ // which is not fussiness: a body opening on an indented line is a markdown
57
+ // code block, and trimming it would silently repaint it as a paragraph.
58
+ // Only the composed body is trimmed — the parts above put a blank line in
59
+ // front of it. The composition still runs regardless, because `folded` is
60
+ // what the cockpit uses to say which comments have no line to land on.
61
+ body: artifact.bodyOverride ?? parts.join("\n").trim(),
53
62
  comments: inline,
54
63
  folded,
55
64
  commitId: artifact.pr.headSha || undefined,
@@ -168,6 +168,9 @@ async function performReview(ref, pr, existing, opts, log) {
168
168
  chapters: [],
169
169
  comments: [],
170
170
  verdict: null,
171
+ // Not carried over. A hand-written send body described the draft this run
172
+ // is replacing — the same reason the pre-chat snapshot goes.
173
+ bodyOverride: null,
171
174
  run: {
172
175
  model: opts.model ?? null,
173
176
  startedAt: now(),
@@ -42,6 +42,7 @@ export function stubArtifact(ref) {
42
42
  chapters: [],
43
43
  comments: [],
44
44
  verdict: null,
45
+ bodyOverride: null,
45
46
  run: null,
46
47
  sent: null,
47
48
  refresh: null,
@@ -537,6 +538,8 @@ export function startDaemon(opts) {
537
538
  }
538
539
  /** Said once, not every poll: a machine with no notifier will never grow one. */
539
540
  let notifierMissing = false;
541
+ /** Null until `serve` is listening and says so — see `cockpitAt`. */
542
+ let cockpitUrl = opts.cockpitUrl ?? null;
540
543
  /**
541
544
  * What the last attempt to tap this machine actually did, or null before one
542
545
  * has been made.
@@ -559,7 +562,7 @@ export function startDaemon(opts) {
559
562
  * why it stands down when this is on and both are on one machine.
560
563
  */
561
564
  async function announce(arrivals) {
562
- const n = notice(arrivals);
565
+ const n = notice(arrivals, cockpitUrl);
563
566
  if (!n)
564
567
  return;
565
568
  notifierWorks = await notify(n);
@@ -755,6 +758,9 @@ export function startDaemon(opts) {
755
758
  void poll();
756
759
  return {
757
760
  status: () => ({ ...status }),
761
+ cockpitAt: (url) => {
762
+ cockpitUrl = url;
763
+ },
758
764
  stop: () => {
759
765
  stopped = true;
760
766
  if (timer)
@@ -261,6 +261,7 @@ export async function buildApp(opts) {
261
261
  chapters: [],
262
262
  comments: [],
263
263
  verdict: null,
264
+ bodyOverride: null,
264
265
  run: {
265
266
  model: null,
266
267
  startedAt: now,
@@ -314,6 +315,13 @@ export async function buildApp(opts) {
314
315
  if (body.status !== undefined && !SETTLEABLE.includes(body.status)) {
315
316
  return c.json({ error: `status must be one of ${SETTLEABLE.join(", ")} — got ${body.status}` }, 400);
316
317
  }
318
+ // Coercing this one would be worse than refusing it: `String({})` is
319
+ // "[object Object]", and this field is posted to GitHub verbatim.
320
+ if (body.bodyOverride !== undefined &&
321
+ body.bodyOverride !== null &&
322
+ typeof body.bodyOverride !== "string") {
323
+ return c.json({ error: "bodyOverride must be a string, or null to compose it" }, 400);
324
+ }
317
325
  const updated = await updateArtifactByKey(c.req.param("key"), (a) => {
318
326
  const next = { ...a };
319
327
  if (body.status !== undefined) {
@@ -329,6 +337,12 @@ export async function buildApp(opts) {
329
337
  // the moment you click. Same rule the poll's reopen follows.
330
338
  next.filed = null;
331
339
  }
340
+ // The review body as the user rewrote it — or `null` to hand the body
341
+ // back to the composition it came from. Only ever set from the send
342
+ // panel, where the text being replaced is on screen.
343
+ if (body.bodyOverride !== undefined) {
344
+ next.bodyOverride = body.bodyOverride;
345
+ }
332
346
  if (body.verdictRecommendation !== undefined && next.verdict) {
333
347
  next.verdict = {
334
348
  ...next.verdict,
@@ -676,6 +690,39 @@ export async function buildApp(opts) {
676
690
  }
677
691
  return app;
678
692
  }
693
+ /**
694
+ * The wildcard binds, and the loopback address each one answers on.
695
+ *
696
+ * Only these two are translated. A bind to `::1`, `localhost` or a particular
697
+ * interface is already an address that answers here, and rewriting it would
698
+ * point the click somewhere nothing is listening — `serve --host ::1` serves
699
+ * IPv6 loopback alone, where `127.0.0.1` does not connect at all. `::` maps to
700
+ * IPv6 loopback rather than IPv4, because a dual-stack socket is the only
701
+ * reason `127.0.0.1` would work on one and it is not guaranteed to be one.
702
+ */
703
+ const WILDCARD = new Map([
704
+ ["0.0.0.0", "127.0.0.1"],
705
+ ["::", "::1"],
706
+ ]);
707
+ /**
708
+ * The cockpit's own address, as reached from the machine `serve` runs on — what
709
+ * a desktop notification opens when you click it.
710
+ *
711
+ * It is the bound host only when that host is a particular interface; a
712
+ * wildcard bind is every interface, and the one that always answers from here
713
+ * is loopback. The token rides along because a click has to land on the review
714
+ * rather than on a 401, and it is the same token `serve` already prints to the
715
+ * console on startup. Null where there is no address to name yet — port 0 is
716
+ * whatever the OS picks, which isn't known until it has picked it.
717
+ */
718
+ export function cockpitUrl(opts) {
719
+ if (!Number.isInteger(opts.port) || opts.port <= 0)
720
+ return null;
721
+ const host = WILDCARD.get(opts.host) ?? opts.host;
722
+ const authority = host.includes(":") ? `[${host}]` : host;
723
+ const query = opts.token ? `?token=${encodeURIComponent(opts.token)}` : "";
724
+ return `http://${authority}:${opts.port}/${query}`;
725
+ }
679
726
  /**
680
727
  * Serve the cockpit. `reconcileRunning` is deliberately *not* called here: the
681
728
  * caller must have done it before starting the daemon, because the daemon polls
@@ -687,6 +734,9 @@ export async function startServer(opts) {
687
734
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
688
735
  const tokenHint = opts.token ? `/?token=${opts.token}` : "";
689
736
  console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
737
+ // The bound port, not the asked-for one: `--port 0` is the OS's to choose,
738
+ // and this callback is the first moment anyone knows what it chose.
739
+ opts.daemon?.cockpitAt(cockpitUrl({ ...opts, port: info.port }));
690
740
  if (opts.daemon) {
691
741
  const s = opts.daemon.status();
692
742
  console.log(`inbox: polling every ${Math.round(s.intervalMs / 60_000)}m` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.28.0",
3
+ "version": "0.29.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
  "author": "Full Stack House (https://fullstack.house)",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "dist",
18
18
  "web/dist",
19
+ "assets",
19
20
  "README.md",
20
21
  "LICENSE"
21
22
  ],