diff --git a/src/routes/api/the-nook/_releases.ts b/src/routes/api/the-nook/_releases.ts
index 063521f..e72ea6d 100644
--- a/src/routes/api/the-nook/_releases.ts
+++ b/src/routes/api/the-nook/_releases.ts
@@ -5,9 +5,11 @@
* assembles the list inside the route component. A native client must not
* couple to that markup, and the appcast alone is not the full story: releases
* older than the appcast's retention live only in the checked-in archive. So
- * this module mirrors the page's merge — live appcast items, then archived
- * releases the appcast has aged out — and normalizes it into data a client can
- * render without parsing HTML.
+ * this module owns that merge — live appcast items, their notes filled from the
+ * archive wherever the feed lost them, then archived releases the appcast has
+ * aged out — and normalizes it into data a client can render without parsing
+ * HTML. `withArchivedNotes` is exported so /changelog runs the identical rule
+ * rather than a second copy of it.
*
* Underscore-prefixed file — not a route.
*/
@@ -122,17 +124,45 @@ type ArchivedReleaseShape = {
notesHtml: string;
};
+/**
+ * Notes the appcast lost, filled from the archive.
+ *
+ * An item with no notes renders as a version header over a blank row, in the
+ * app and on /changelog alike. That is not hypothetical: Sparkle rebuilds every
+ * item from its DMG and removes the description of one whose DMG carries no
+ * notes file, so 0.8.0 and 0.7.0 published with notes and reached both
+ * surfaces with none (NOOK-304).
+ *
+ * The archive copy is used only where the live one is missing. Where both
+ * exist the published item wins, because that is the text the updater itself
+ * showed, and it carries the build number the archive does not have.
+ */
+export function withArchivedNotes<
+ T extends { version: string; notesHtml: string }
+>(
+ live: readonly T[],
+ archived: readonly { version: string; notesHtml: string }[]
+): T[] {
+ const notesByVersion = new Map(archived.map((r) => [r.version, r.notesHtml]));
+ return live.map((release) => {
+ if (release.notesHtml.trim()) return release;
+ const fallback = notesByVersion.get(release.version);
+ return fallback ? { ...release, notesHtml: fallback } : release;
+ });
+}
+
/**
* Live releases first, newest first, then any archived release the appcast no
* longer lists. Order is concatenation rather than a sort because both inputs
- * are newest-first and the archive only ever holds older releases — matching
- * how /changelog builds its list.
+ * are newest-first and the archive only ever holds older releases — and because
+ * /changelog renders this same list, the page and this feed cannot disagree
+ * about what a release said.
*/
export function mergeReleases(
live: readonly AppcastRelease[],
archived: readonly ArchivedReleaseShape[]
): AppRelease[] {
- const releases = live.map(toAppRelease);
+ const releases = withArchivedNotes(live, archived).map(toAppRelease);
const seen = new Set(releases.map((r) => r.version));
for (const release of archived) {
if (seen.has(release.version)) continue;
diff --git a/src/routes/api/the-nook/releases.test.ts b/src/routes/api/the-nook/releases.test.ts
index 8700454..c26587d 100644
--- a/src/routes/api/the-nook/releases.test.ts
+++ b/src/routes/api/the-nook/releases.test.ts
@@ -79,6 +79,9 @@ async function body(): Promise<{ releases: Release[] }> {
return (await res.json()) as { releases: Release[] };
}
+// The fixture appcast: two releases it lists live, newest first.
+const LIVE = ["0.6.3", "0.6.2"];
+
beforeEach(() => {
appcastXml = appcast(
item("0.6.3", "Fri, 02 Oct 2026 23:29:42 -0400", NOTES) +
@@ -97,7 +100,7 @@ describe("GET /api/the-nook/releases.json", () => {
expect(res.headers.get("Cache-Control")).toBe("public, max-age=300");
const { releases } = await body();
- expect(releases.map((r) => r.version)).toEqual(["0.6.3", "0.6.2", ...archivedVersions]);
+ expect(releases.map((r) => r.version)).toEqual([...LIVE, ...agedOut(...LIVE)]);
expect(releases[0].build).toBe("10");
});
@@ -125,9 +128,9 @@ describe("GET /api/the-nook/releases.json", () => {
it("appends the archived releases the appcast has aged out", async () => {
const releases = (await body()).releases;
- const archived = releases.filter((r) => !["0.6.3", "0.6.2"].includes(r.version));
+ const archived = releases.filter((r) => !LIVE.includes(r.version));
- expect(archived.map((r) => r.version)).toEqual(archivedVersions);
+ expect(archived.map((r) => r.version)).toEqual(agedOut(...LIVE));
expect(archived[0]!.build).toBeNull();
// Archive dates are already calendar dates and pass through untouched.
expect(archived[0]!.date).toBe(ARCHIVED_RELEASES[0]!.date);
@@ -158,6 +161,82 @@ describe("GET /api/the-nook/releases.json", () => {
});
});
+describe("notes the appcast lost", () => {
+ // Sparkle rebuilds every item from its DMG and removes the description of an
+ // item whose DMG carries no notes file, so a release the appcast still lists
+ // can arrive with nothing to show. 0.8.0 and 0.7.0 did exactly that, in both
+ // this feed and /changelog (NOOK-304): a version header over an empty row.
+ const itemWithoutNotes = (version: string) => `
+ -
+ ${version}
+ Fri, 02 Oct 2026 23:29:42 -0400
+ 9
+ ${version}
+
+
`;
+
+ it("falls back to the archive's copy for a release listed without notes", async () => {
+ appcastXml = appcast(
+ item("0.6.3", "Fri, 02 Oct 2026 23:29:42 -0400", NOTES) +
+ itemWithoutNotes("0.5.0")
+ );
+ invalidateAppcastCache();
+
+ const lost = (await body()).releases.find((r) => r.version === "0.5.0")!;
+
+ expect(lost.sections).toEqual(
+ sectionsFromHtml(
+ ARCHIVED_RELEASES.find((r) => r.version === "0.5.0")!.notesHtml
+ )
+ );
+ expect(lost.sections.length).toBeGreaterThan(0);
+ // Still the live row, not the archived one: it carries the build number.
+ expect(lost.build).toBe("9");
+ });
+
+ it("keeps the published notes when the archive has them too", async () => {
+ appcastXml = appcast(item("0.5.0", "Mon, 07 Sep 2026 16:11:44 -0400", NOTES));
+ invalidateAppcastCache();
+
+ const [release] = (await body()).releases;
+
+ expect(release!.sections).toEqual(sectionsFromHtml(NOTES));
+ });
+});
+
+describe("the checked-in archive", () => {
+ // The durable copy of what every release said. The appcast carries only the
+ // DMGs the release machine still holds, and it loses notes when it rebuilds,
+ // so a release missing here is a release the changelog can blank out later
+ // with nothing left to fall back to.
+ it("holds every release that shipped notes, newest first", () => {
+ expect(ARCHIVED_RELEASES.map((r) => r.version)).toEqual([
+ "0.9.0",
+ "0.8.0",
+ "0.7.0",
+ "0.6.3",
+ "0.6.2",
+ "0.6.0",
+ "0.5.0",
+ "0.4.1",
+ "0.4.0",
+ "0.3.0",
+ "0.2.0"
+ // 0.6.1 and 0.3.1 shipped without written notes and are absent by design.
+ ]);
+ });
+
+ it("holds notes for every release it lists, and a date for each", () => {
+ for (const release of ARCHIVED_RELEASES) {
+ expect(
+ sectionsFromHtml(release.notesHtml).length,
+ `${release.version} has no notes`
+ ).toBeGreaterThan(0);
+ expect(isoDateOrNull(release.date), release.version).not.toBeNull();
+ }
+ });
+});
+
describe("sectionsFromHtml", () => {
it("labels an unlabelled list as Notes", () => {
expect(sectionsFromHtml("
")).toEqual([
@@ -194,5 +273,11 @@ describe("isoDateOrNull", () => {
});
});
-/** The archive in full, in file order (newest first) — the tail of the list. */
-const archivedVersions = ARCHIVED_RELEASES.map((r) => r.version);
+/**
+ * The tail a case should expect: the whole archive in file order (newest
+ * first) minus the versions its fixture appcast lists live. A version is
+ * listed once, from the appcast, and the archive copy behind it only fills
+ * notes the feed lost — so the tail shrinks as the archive grows.
+ */
+const agedOut = (...live: string[]) =>
+ ARCHIVED_RELEASES.map((r) => r.version).filter((v) => !live.includes(v));
diff --git a/src/routes/nook/changelog-archive.ts b/src/routes/nook/changelog-archive.ts
index 38a2f42..17bcc4c 100644
--- a/src/routes/nook/changelog-archive.ts
+++ b/src/routes/nook/changelog-archive.ts
@@ -1,8 +1,20 @@
-// Releases older than the appcast's retention, recovered from the
-// changelog file's git history (The-Nook 1ad8a69 / db165cd) and checked
-// in so /changelog renders the full story. 0.3.1 and 0.6.1 shipped
-// without written notes and are absent by design. Generated with the
-// same section->HTML rules as embed_release_notes.py.
+// Every release of The Nook, newest first, checked in as the durable copy of
+// what each one said.
+//
+// The appcast is the live feed — it carries the build numbers — but it is not
+// a store: it holds only the DMGs the release machine still has, and
+// generate_appcast drops the description of any rebuilt item whose DMG ships
+// no notes file of its own. So a release the appcast lists can arrive with
+// nothing to show, and one it has aged out of vanishes outright. Both are
+// covered from here: /changelog and releases.json fill an item that has no
+// notes from these (NOOK-304).
+//
+// Append the release being folded at each release cut, or the next release
+// ages out into a blank row exactly as 0.6.0-0.8.0 did. The source text is
+// `CHANGELOG.md` in the-nook, which keeps every release for this reason, and
+// these notesHtml bodies are rendered by its own embed_release_notes.py rules.
+//
+// 0.3.1 and 0.6.1 shipped without written notes and are absent by design.
export interface ArchivedRelease {
version: string;
date: string;
@@ -10,6 +22,36 @@ export interface ArchivedRelease {
}
export const ARCHIVED_RELEASES: ArchivedRelease[] = [
+ {
+ "version": "0.9.0",
+ "date": "2026-10-12",
+ "notesHtml": "Added
- Replies from iPhone, watch or island reach agents directly in iTerm2, Terminal or kitty
- kitty needs remote control enabled in kitty.conf; the app says so if it's missing
- Remove a module from its tile's own config popup, named with the panel it sits in
Changed
- Island bottom corners match the physical notch on every display
Fixed
- Hitting a usage or session limit ends with an error, including when the limit resets
- OpenClaw sessions now end turns properly instead of looking unfinished forever
- Right-click anywhere on a tile removes it in the layout editor
- The island opens promptly and smoothly, even while the app is busy with layout
- Island readings shrink to fit instead of being cut off on non-notch displays
"
+ },
+ {
+ "version": "0.8.0",
+ "date": "2026-10-12",
+ "notesHtml": "Added
- Island stays visible on the Lock Screen showing agent working and waiting counts; on by default
- Any of the six Nook chiptunes can be assigned to any agent event in Sounds settings
- Volume mixer tile height is now user-controllable with grips in the layout editor
Changed
- Density picker now reshapes stat rows: compact drops labels, mini flows values inline
- Timer countdown uses a travelling tick crest instead of a plain progress bar
- Question chiptune swapped to call audio; unused incoming-call sound kind removed
- Layout editor uses an \u2715 button in place of the old drop target
Fixed
- Notification sounds no longer overlap; one clip at a time, latest event wins
- Mirrored notifications stay silent where a chiptune already sounds
- Idle sweep completions no longer chirp with nothing surfaced
- Pi agent connection errors now surface as errors instead of reading as idle
- Memory tile fills tall tiles with more process rows instead of stopping at five
- Editor auto-fits agents and mixer tile heights to their content
"
+ },
+ {
+ "version": "0.7.0",
+ "date": "2026-10-11",
+ "notesHtml": "Security
- iPhone relay pairing and messages are end-to-end encrypted.
- Weather lookups round locations and keep Apple credentials off Macs.
- Pairing codes and device tokens are stored hashed on the relay.
- Relay registration prevents anonymous takeover of existing paired installs.
Added
- iPhone companion pairing, approvals, questions, replies, history, and push notifications.
- Relay, Tailscale, and LAN connection paths for the companion.
- Timed and activity-based iPhone notification mutes in Phone settings.
- Phone settings card for requesting iPhone TestFlight beta access.
- Watch replies, multi-part questions, completion details, and notification actions.
- Home, Lock Screen, and watch-face widgets for agent fleet status.
- Weather module with saved locations and relay-backed forecasts.
- Countdown timer module, compact slot item, and floating action.
- Configurable floating action buttons around the expanded island.
- One-click remote host setup from ssh config or Tailscale.
- Remote hosts mirror this Mac\u2019s installed integrations automatically.
- Custom agents can register identity, capabilities, and install wiring.
- Changelog tab under About with live, cached, and bundled notes.
- Onboarding now teaches the island, agents, panels, iPhone pairing, and extras.
- Module tiles declare features, density, overflow, and per-tile configuration.
- Layout editor supports gap removal, seam dragging, and drag auto-scroll.
- Panel modules can include Weather and Timer tiles.
- Floating actions can launch apps, shortcuts, URLs, commands, and power controls.
- Onboarding can send an optional trial reminder email.
Changed
- Agents panel rows resurface on turn completion, questions, and errors.
- Expanded agent rows stay stable except when important events surface.
- Questions now render inside their session rows.
- Approvals surface the island, and Allow can cover one session.
- Agent rows show integration capabilities and limitations.
- Remote session rows name their machine when hooks are current.
- Remote session rows no longer repeat \u201cremote\u201d twice.
- Remote hosts show live step progress without blocking local agent rows.
- Remote host removal deletes Nook entries while preserving user hooks.
- Agent slot squares fill three rows before showing +N.
- Phone settings now guide relay, Tailscale, and LAN pairing.
- iPhone connection status moved into Settings.
- iPhone conversations can optionally group by project.
- First iPhone launch explains the companion before asking for notifications.
- Power tile leads with draw and flow, shedding app lists first.
- Temperature tile pairs each die\u2019s usage trace with its temperature.
- Keep Awake and other tiles shrink to their actual content.
- Only modules affected by density show the density picker.
- Module options open from each tile\u2019s gear.
- Disk headlines and volume chips match Finder-style storage numbers.
- Battery slot now shows watts under the glyph.
- Session detail opens wider and renders Markdown tables, code, and lists.
- Island transitions now share one quicker, bouncier motion style.
- Floating action buttons ride the island\u2019s expansion and collapse.
- Call interception now defaults off because it remains alpha.
- Nook Privileged Helper now covers all root-powered features.
- Chiptune sounds are quieter, and calls no longer ring on Mac.
- Phone and watch widget wording now says what the numbers count.
- Plugin installs now keep one canonical copy and link hosts to it.
- Custom agent installs link declared plugin bodies instead of copying them.
- Remote host setup reports skipped missing agents instead of writing unused configs.
Fixed
- Replies to remote sessions target the local ssh pane, not colliding panes.
- Jumping to remote sessions focuses the local ssh window.
- Remote sessions vanish promptly when their machine disappears.
- Codex sessions report again after updating the installed hook.
- Remote setup writes only agents present on that machine.
- Repeated remote source values now accumulate correctly.
- Re-pointing a host replaces old Mac entries instead of duplicating them.
- Remote probes no longer prefer stale socket files.
- Remote hosts with URL-form addresses report again.
- Remote command checks now time out instead of hanging forever.
- SSH masters and media adapters stop spinning at EOF.
- Remote rows prune after silent killed sessions, including pending approvals.
- Codex native approvals and questions now appear and answer correctly.
- Codex native requests no longer disappear after unrelated tool activity.
- Session-end cleanup dismisses stale cards and resets session Allow grants.
- Released bridge requests no longer answer decisions nobody made.
- Hook timeouts clear abandoned approval cards.
- Liveness pings no longer erase completions, approvals, or prompts.
- Content-less session noise no longer keeps dead rows alive.
- Notification events no longer replace an agent\u2019s actual reply.
- Injected task notifications no longer become user prompts or titles.
- Hermes clarify calls now render as questions.
- Hermes turns complete at post-LLM events, not process exit.
- Opencode 2.x plugins now report sessions again.
- Repaired opencode plugins take effect after the service restarts.
- Terminal-only questions are read-only on phone and watch.
- Phone and watch notifications use consistent titles, subtitles, and bodies.
- Paired iPhones no longer crash on launch.
- Notification taps no longer crash the iPhone app.
- Revoked phones return to pairing instead of staying stuck.
- Valid session updates no longer get rejected as replays.
- Some long iPhone notifications now deliver reliably.
- Foreground phone notifications match push notification wording.
- Widget data refreshes independently while the app is closed.
- Phone widgets show actionable counts and sessions more clearly.
- Hourly Weather starts at the hour in progress.
- Dragging tiles no longer drops about half a band low.
- Moving tiles no longer resizes the tile or its neighbor.
- Dragged tiles reflow neighboring tiles live before release.
- Resizing into a neighbor moves it instead of blocking.
- Module cards clip to their assigned box instead of overlapping neighbors.
- Chrome-less agent lists no longer reserve phantom padding.
- Session detail now hugs short replies instead of growing to maximum height.
- Expanded panels now reserve the real tab strip height.
- Floating buttons close only after the island has collapsed.
- Campfire animation no longer causes compact idle CPU churn.
- Waveform drawing no longer relayouts the whole island repeatedly.
- Audio capture lifecycle no longer freezes the UI thread.
- Connected-to-power Keep Awake now follows plug and unplug events.
- Lid-closed Keep Awake now survives clamshell sleep through the helper.
- Lid-awake toggles now reflect helper replies correctly.
- Power-source teardown no longer leaves crash-prone stale callbacks.
- Battery flow and power ribbon watts now add up correctly.
- Battery charge-limit calibration pauses are identified correctly.
- Draining-to-limit labels were removed to avoid misleading states.
- Battery condition now shows macOS\u2019s real service state.
- Battery capacity and temperature read correctly on newer macOS.
- Charge gates no longer release gates owned by other software.
- Disk activity rates now measure bytes per second correctly.
- Disk graphs no longer start with a fake zero sample.
- Disk sampling stays owned by the active panel, not Settings previews.
- Volume chips show real fill and skip Finder-hidden mounts.
- Power and Disk panes avoid several stale, misleading, or clipped readings.
"
+ },
+ {
+ "version": "0.6.3",
+ "date": "2026-10-02",
+ "notesHtml": "Added
- Session rows show project and branch identity with a live state line.
- A rolling session topic built from substantive prompts.
- Per-turn reply cards in each session row.
- A call banner that appears while a call is ringing.
Changed
- Call banner wording distinguishes an active ring and keeps missed calls out.
- Call action labels stay on one line, stacking when the row runs out of width.
- Grouped session rows drop the redundant title.
- Idle sessions no longer show leading padding.
Fixed
- Call actions press only their own button; "End Call" no longer matches "Send".
- The last tool stays visible after it finishes.
- Fan spin animation display.
"
+ },
+ {
+ "version": "0.6.2",
+ "date": "2026-09-24",
+ "notesHtml": "Added
- Extra brightness in Additional Options spends an XDR panel's HDR headroom past the usual maximum, with an intensity slider.
- Mosaic editor for rearranging your panel layout.
- Waveform slot appears for sources without artwork, like Zen and Firefox.
Changed
- Call detection wakes on accessibility events instead of polling the window list four times a second.
- Mosaic layout improvements.
Fixed
- Monitor speaker controls are verified once and the probe restores the level, so route changes stop stepping a display's own volume.
- Call detection keys on bundle ids and call banners, so localized window names no longer hide or fake a ring.
- Battery charging band shows as soon as wall input exceeds draw.
- Volume mixer lists audio players that are no app, like mpv and podcast CLIs.
- Route pickers no longer list the mixer's own private devices, so an app's output target stops flickering.
- Waveform audio tap follows slot changes during browser playback.
- Charge limits hold exact percents, like 87%, natively on macOS 27.
- Dragging a charge limit under the OS floor asks for the helper; the adapter gate holds the cap otherwise.
"
+ },
+ {
+ "version": "0.6.0",
+ "date": "2026-09-07",
+ "notesHtml": "Added
- Volume mixer: per-app volume, mute, and independent output routing.
- Master output, system sounds, and microphone pickers in one panel.
- Network module shows public IP, ISP, ASN, location, gateway, and DNS \u2014 each copyable.
- Network module lists active interfaces, flags VPN as default route, totals bytes since boot.
- Disk panel monitors live read/write throughput, capacity, and SMART health.
- Speed test run button always available; failures report a reason.
- Keep-awake timed and power-based modes with lid-awake pin.
- Now-playing track and source details in the music module.
Changed
- Network identity and interface monitoring expanded into a dedicated section.
- Keep-awake improvements for power and timer behavior.
Fixed
- Audio route CFString lifetime bug.
"
+ },
{
"version": "0.5.0",
"date": "2026-09-07",
diff --git a/src/routes/nook/changelog.tsx b/src/routes/nook/changelog.tsx
index aeb04ef..54788a8 100644
--- a/src/routes/nook/changelog.tsx
+++ b/src/routes/nook/changelog.tsx
@@ -3,8 +3,12 @@
*
* The appcast already ships HTML release notes to the updater on every
* launch, so this page renders exactly what Sparkle publishes: one source
- * of truth, no second place to write release notes. Doubles as the raw
- * material for the release-announcement job (marketing/README.md).
+ * of truth, no second place to write release notes. The checked-in archive
+ * fills an item the appcast lost its notes to — the feed is rebuilt from the
+ * DMGs on every publish, and Sparkle drops the notes of an item whose DMG
+ * carries none, which is how 0.8.0 and 0.7.0 came to render as blank rows.
+ * Doubles as the raw material for the release-announcement job
+ * (marketing/README.md).
*
* Layout: the version list lives in a fixed-height vertical scroller, so
* expanding a release never shifts the page — history is browsed inside
@@ -17,15 +21,20 @@ import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import { fetchAppcastReleases } from "~/routes/api/the-nook/_appcast";
+import { withArchivedNotes } from "~/routes/api/the-nook/_releases";
import { ARCHIVED_RELEASES } from "~/routes/nook/changelog-archive";
const getReleases = query(async () => {
"use server";
- // Appcast items are authoritative (they carry build numbers); releases
- // older than the appcast's retention come from the checked-in archive,
- // recovered from the changelog file's git history. Both normalize to
- // AppcastRelease so the page renders one shape.
- const live = await fetchAppcastReleases();
+ // Appcast items are authoritative (they carry build numbers), with the
+ // archive filling any item whose notes the rebuilt feed lost. Releases the
+ // appcast no longer lists at all come from the archive. Both normalize to
+ // AppcastRelease so the page renders one shape, and the fill is the same
+ // helper releases.json runs, so the two cannot tell different stories.
+ const live = withArchivedNotes(
+ await fetchAppcastReleases(),
+ ARCHIVED_RELEASES
+ );
const liveVersions = new Set(live.map((r) => r.version));
const archived = ARCHIVED_RELEASES.filter(
(r) => !liveVersions.has(r.version)