@fullstackhouse/cerber 0.23.0 → 0.24.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
@@ -41,9 +41,9 @@ npx @fullstackhouse/cerber serve # → http://127.0.0.1:4820
41
41
 
42
42
  `serve` does the whole loop by default: discover → draft → wait for you.
43
43
  Sending stays a human click. Tame it with `--no-auto-review` (list awaiting
44
- PRs, review on click) or `--no-poll` (no GitHub polling at all) — or flip the
45
- same two switches in the cockpit's Settings, which persist in
46
- `~/.cerber/config.json` and apply on the next poll. The queue only lists what
44
+ PRs, review on click) or `--no-poll` (no GitHub polling at all) — or flip those
45
+ switches in the cockpit's Settings, which persist in `~/.cerber/config.json`
46
+ and apply on the next poll. The queue only lists what
47
47
  still wants you: anything you have settled moves to the filed tabs right of
48
48
  the thin rule in the filter row — reviews you sent, ones you marked reviewed
49
49
  or skipped, merged and closed PRs — each tab appearing only while it holds
@@ -88,15 +88,28 @@ who did is the one mistake worth designing against.
88
88
 
89
89
  ### It taps you on the shoulder
90
90
 
91
- The cockpit is a background tab most of the day, so it tells you when the queue
92
- grows: a desktop notification naming the PR that arrived — click it to open the
93
- review — folded into one popup when several land at once. It stays quiet while
94
- you're looking straight at cerber, and no browser announces the same PR twice.
95
- Only the browser can grant this, and only off a click, so the bell in the top
96
- bar is one: click it to be asked, click it again to stop. Both the switch and
97
- the record of what has been announced are per-browser, kept in that browser
98
- rather than in `~/.cerber/config.json` — the permission they gate is the
99
- browser's, so a second browser starts its own record.
91
+ When a PR lands in the queue you get a desktop notification naming it, folded
92
+ into one popup when several arrive at once. The tap comes from the poll that
93
+ found the PR, so it reaches you with the cockpit closed, in another Space, or
94
+ after a browser restart — `cerber serve` is the only thing that has to be
95
+ running. It goes to whatever this machine already has (Notification Centre on
96
+ macOS, `notify-send` on Linux) and stays quiet where there is neither. Off with
97
+ `daemon.notify` in `~/.cerber/config.json`, or the checkbox in Settings.
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
102
+ exactly what you don't have on the afternoons this feature is for. So it stands
103
+ down while the machine's own tap is on, rather than making one PR two popups,
104
+ and the bell in the top bar says so. Untick the machine one and this browser
105
+ takes over: click the bell, grant the permission, and click it again to stop.
106
+ The switch and the record of what has been announced are per-browser — the
107
+ permission they gate is the browser's, so a second browser starts its own
108
+ record — and it stays quiet while you're looking straight at cerber.
109
+
110
+ A cockpit served from somewhere else (`cerber serve -H 0.0.0.0 --token` on a
111
+ VPS) keeps its bell either way: the machine's tap would land on the VPS, where
112
+ nobody is looking.
100
113
 
101
114
  The tab itself says it too, without asking anyone: while the inbox holds
102
115
  anything, the favicon wears a red dot, and it goes away when the last review is
package/dist/cli/index.js CHANGED
@@ -313,6 +313,7 @@ program
313
313
  .option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
314
314
  .option("--no-poll", "don't poll GitHub for PRs awaiting your review (also disables auto-review)")
315
315
  .option("--no-auto-review", "list awaiting PRs but don't review them until you click review")
316
+ .option("--no-notify", "don't notify this machine when a PR lands in the queue")
316
317
  .option("-d, --daemon", "(deprecated no-op — polling and auto-review are now the default)")
317
318
  .option("-i, --interval <minutes>", "poll interval (default: config daemon.intervalMinutes, 5)")
318
319
  .option("-R, --repo <owner/repo>", "repo(s) to watch (repeatable; default: config daemon.repos, all)", collect, [])
@@ -361,6 +362,7 @@ program
361
362
  withSource: opts.source,
362
363
  trust: opts.trust ? undefined : false,
363
364
  autoReview: opts.autoReview,
365
+ notify: opts.notify,
364
366
  autoSend: opts.autoSend ? "on" : "shadow",
365
367
  autoSendThreshold: threshold,
366
368
  })
@@ -22,6 +22,12 @@ export const DaemonConfigSchema = z.object({
22
22
  poll: z.boolean().default(true),
23
23
  /** Draft an AI review for whatever lands in the queue. Never sends. */
24
24
  autoReview: z.boolean().default(true),
25
+ /**
26
+ * Tap this machine's notification centre when a PR lands in the queue. The
27
+ * cockpit's bell needs a tab open and permitted; this one only needs `serve`
28
+ * running, which is the point of it.
29
+ */
30
+ notify: z.boolean().default(true),
25
31
  intervalMinutes: z.number().int().positive().default(5),
26
32
  parallel: z.number().int().positive().default(3),
27
33
  /** Repos to watch (owner/repo). Empty = everything your gh account can see. */
@@ -0,0 +1,105 @@
1
+ // The machine's own shoulder-tap, for the hours the cockpit isn't open.
2
+ //
3
+ // The cockpit has a bell of its own (`web/src/notify.ts`), and it is the better
4
+ // one when it can ring: it names the PR and opens that review when you click
5
+ // it. But it is a page notification, so it needs a tab that is open, alive and
6
+ // permitted — which is exactly what you don't have while you're in an editor
7
+ // all afternoon. This half rides the poll that discovers the PR instead, so the
8
+ // tap survives a closed cockpit, a denied permission and a browser restart.
9
+ //
10
+ // Everything here shells out to whatever the OS already has. No dependency, no
11
+ // daemon of its own, and a machine with no notifier is a machine cerber stays
12
+ // quiet on rather than one it fails on.
13
+ import { execFile } from "node:child_process";
14
+ import { promisify } from "node:util";
15
+ const execFileAsync = promisify(execFile);
16
+ /**
17
+ * How long a notifier gets before the poll stops waiting for it.
18
+ *
19
+ * A notification is instant or it is broken, and the thing on the other end of
20
+ * this is somebody else's daemon: a wedged Notification Centre or a DBus that
21
+ * never answers would otherwise hang `execFile` forever. The poll awaits this
22
+ * call, and the daemon refuses to start a poll while one is running — so an
23
+ * unbounded wait here doesn't cost one notification, it stops cerber
24
+ * discovering PRs at all. Timing out is caught like any other failure.
25
+ */
26
+ export const NOTIFY_TIMEOUT_MS = 5_000;
27
+ const slug = (a) => `${a.repo}#${a.number}`;
28
+ /**
29
+ * One popup for one poll's arrivals — a batch is one interruption, not five.
30
+ * Deliberately the same shape and wording the cockpit's bell uses, so the two
31
+ * channels never read as two different pieces of news about one PR.
32
+ */
33
+ export function notice(arrived) {
34
+ if (arrived.length === 0)
35
+ return null;
36
+ if (arrived.length === 1) {
37
+ const a = arrived[0];
38
+ return { title: `${slug(a)} awaits your review`, body: `${a.title} — ${a.author}` };
39
+ }
40
+ const named = arrived.slice(0, 3).map(slug);
41
+ const rest = arrived.length - named.length;
42
+ return {
43
+ title: `${arrived.length} PRs await your review`,
44
+ body: rest > 0 ? `${named.join(", ")} and ${rest} more` : named.join(", "),
45
+ };
46
+ }
47
+ /**
48
+ * A PR title, as an AppleScript string literal.
49
+ *
50
+ * osascript takes a script, not an argument list, so the title has to go into
51
+ * the source — and a PR title is text somebody else wrote. Escaping the two
52
+ * characters a literal cannot hold, a backslash and a quote, is what stops a
53
+ * title from closing the string and being read as script. Control characters
54
+ * can't appear in a literal at all, so they collapse to a space.
55
+ */
56
+ export function appleScriptLiteral(value) {
57
+ const flat = value.replace(/[\u0000-\u001f\u007f]+/g, " ");
58
+ return `"${flat.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
59
+ }
60
+ /**
61
+ * The command that taps this machine's notification centre, or null where there
62
+ * is nothing to tap. macOS and the freedesktop notifiers cover what cerber runs
63
+ * on; anywhere else it stays quiet rather than pretending.
64
+ */
65
+ export function notifyCommand(n, platform = process.platform) {
66
+ if (platform === "darwin") {
67
+ return {
68
+ file: "osascript",
69
+ args: [
70
+ "-e",
71
+ `display notification ${appleScriptLiteral(n.body)} with title ${appleScriptLiteral(n.title)}`,
72
+ ],
73
+ };
74
+ }
75
+ if (platform === "linux") {
76
+ // `--` first, because notify-send parses options before positionals. The
77
+ // PR's own title reaches this as the notice *body* (`notice()` builds the
78
+ // summary itself), so the body is the argument carrying somebody else's
79
+ // text — a PR titled "--help me" would be read as a flag and cost the
80
+ // notification. The marker ends option parsing ahead of both arguments, so
81
+ // neither the summary nor the body can be read as anything but text.
82
+ return { file: "notify-send", args: ["--app-name=cerber", "--", n.title, n.body] };
83
+ }
84
+ return null;
85
+ }
86
+ /**
87
+ * Show it, and say whether it was shown. False is an ordinary answer here —
88
+ * an unsupported platform, no `notify-send` installed, a headless box, a
89
+ * notifier that took too long — and the caller reports it once rather than
90
+ * every poll. Never throws and never hangs: a notification is the least
91
+ * important thing a poll does, so it is also the last thing allowed to stop
92
+ * one.
93
+ */
94
+ export async function notify(n) {
95
+ const cmd = notifyCommand(n);
96
+ if (!cmd)
97
+ return false;
98
+ try {
99
+ await execFileAsync(cmd.file, cmd.args, { timeout: NOTIFY_TIMEOUT_MS });
100
+ return true;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
@@ -2,6 +2,7 @@ import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
2
2
  import { evaluateAutoSend } from "../core/autosend.js";
3
3
  import { loadConfig } from "../core/config.js";
4
4
  import { classifyReply, currentLogin, fetchConversation, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
5
+ import { notice, notify } from "../core/notify.js";
5
6
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
6
7
  import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
7
8
  import { ReviewInProgressError } from "../runner/inflight.js";
@@ -140,6 +141,9 @@ export function startDaemon(opts) {
140
141
  repos: opts.repos,
141
142
  intervalMs: opts.intervalMs,
142
143
  pollEnabled: true,
144
+ // Claims nothing until a poll has read the config, so the cockpit's bell
145
+ // keeps its own counsel for the few seconds that takes.
146
+ notify: false,
143
147
  autoReview: opts.autoReview,
144
148
  trustedRuns: false,
145
149
  polls: 0,
@@ -178,7 +182,7 @@ export function startDaemon(opts) {
178
182
  * search results lag GitHub by minutes and never override them.
179
183
  */
180
184
  async function syncQueue(refs) {
181
- let discovered = 0;
185
+ const discovered = [];
182
186
  for (const ref of refs) {
183
187
  const existing = await loadArtifact(artifactId(ref));
184
188
  if (existing) {
@@ -195,7 +199,7 @@ export function startDaemon(opts) {
195
199
  continue;
196
200
  }
197
201
  await saveArtifact(stubArtifact(ref));
198
- discovered++;
202
+ discovered.push(ref);
199
203
  }
200
204
  // Asked once per pass, and only if a row actually reaches the check that
201
205
  // needs it. Undefined is "not asked yet"; null is "gh could not say", which
@@ -333,6 +337,41 @@ export function startDaemon(opts) {
333
337
  }
334
338
  return { discovered };
335
339
  }
340
+ /** Said once, not every poll: a machine with no notifier will never grow one. */
341
+ let notifierMissing = false;
342
+ /**
343
+ * What the last attempt to tap this machine actually did, or null before one
344
+ * has been made.
345
+ *
346
+ * `status.notify` is a promise to the cockpit — it stands its own bell down
347
+ * on the strength of it — so it has to mean "this machine gets told", not
348
+ * "this machine was asked to tell". A box with no `notify-send` would
349
+ * otherwise go on claiming the job while the browser politely declined it,
350
+ * and one PR would be announced by nobody at all. Untried counts as working:
351
+ * the alternative is both bells ringing everywhere until the first PR lands.
352
+ */
353
+ let notifierWorks = null;
354
+ /**
355
+ * Tap this machine when a PR lands in the queue.
356
+ *
357
+ * An artifact is the ledger this rides on: a PR is announced on the poll that
358
+ * first writes a stub for it, so a restart re-announces nothing, and a PR
359
+ * that arrived while `serve` was down is still news the next time it runs.
360
+ * The cockpit's bell announces the same arrival, off the same stub — which is
361
+ * why it stands down when this is on and both are on one machine.
362
+ */
363
+ async function announce(arrivals) {
364
+ const n = notice(arrivals);
365
+ if (!n)
366
+ return;
367
+ notifierWorks = await notify(n);
368
+ if (notifierWorks)
369
+ return;
370
+ if (notifierMissing)
371
+ return;
372
+ notifierMissing = true;
373
+ log("no desktop notifier here — arrivals will only show in the cockpit");
374
+ }
336
375
  /**
337
376
  * Whose move it is on each awaiting PR, one conversation read apiece.
338
377
  *
@@ -410,6 +449,14 @@ export function startDaemon(opts) {
410
449
  const knobs = config.daemon;
411
450
  const autoReview = opts.autoReview && knobs.autoReview;
412
451
  status.pollEnabled = knobs.poll;
452
+ // What the cockpit's bell defers to: a loop that isn't polling announces
453
+ // nothing, whatever the notify toggle says.
454
+ const notifyOn = opts.notify && knobs.notify;
455
+ // Only true while all three hold, and the third is only knowable by
456
+ // having tried: a notifier that isn't there is not a bell the cockpit
457
+ // should be standing down for.
458
+ const announcing = () => knobs.poll && notifyOn && notifierWorks !== false;
459
+ status.notify = announcing();
413
460
  status.autoReview = autoReview;
414
461
  // Recomputed too, so a trust rule added mid-run shows up in the strip.
415
462
  status.trustedRuns = autoReview && opts.trust !== false && config.trust.length > 0;
@@ -428,6 +475,16 @@ export function startDaemon(opts) {
428
475
  // A failed poll leaves the last good list up; lastPollError says so.
429
476
  status.awaiting = await classifyAll(refs);
430
477
  const { discovered } = await syncQueue(refs);
478
+ // Before the reviews, not after: the arrival is the news, and drafting it
479
+ // takes minutes. This is the same moment the cockpit's bell would ring.
480
+ if (notifyOn && discovered.length > 0) {
481
+ await announce(discovered);
482
+ // Recomputed on the spot rather than left to the next poll: that is
483
+ // minutes away, and the cockpit would spend them deferring to a tap
484
+ // that never came. Recomputed rather than only cleared, so a machine
485
+ // whose notifier comes back takes the job back with it.
486
+ status.notify = announcing();
487
+ }
431
488
  if (autoReview) {
432
489
  const { reviewed, skipped, failed } = await reviewAll(refs);
433
490
  status.lastSummary =
@@ -435,7 +492,7 @@ export function startDaemon(opts) {
435
492
  `${failed > 0 ? `, ${failed} failed` : ""}`;
436
493
  }
437
494
  else {
438
- status.lastSummary = `${refs.length} awaiting${discovered > 0 ? `, ${discovered} new` : ""} (auto-review off)`;
495
+ status.lastSummary = `${refs.length} awaiting${discovered.length > 0 ? `, ${discovered.length} new` : ""} (auto-review off)`;
439
496
  }
440
497
  log(status.lastSummary);
441
498
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.23.0",
3
+ "version": "0.24.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
6
  "author": "Full Stack House (https://fullstack.house)",