better changelog capture

This commit is contained in:
2026-10-05 11:05:24 -04:00
parent 53f1413966
commit 92af52dfc9
4 changed files with 233 additions and 48 deletions

View File

@@ -86,7 +86,7 @@
This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration.
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy` → `/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.)
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy` → `/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.) **Every subdomain surface requires both files:** the `src/routes/<prefix>/<name>.tsx` content component AND a matching top-level dispatch file (`src/routes/<name>.tsx` or `src/routes/<name>/index.tsx`) that imports and dispatches to it — without the dispatch file, the page is unreachable on the subdomain. The `src/routes/subdomain-dispatch.test.ts` suite enforces this automatically on every `bun test` run.
- **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host.
- **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters).
- **Auth:** Host-scoped only — no cookie domain broadening.

View File

@@ -29,12 +29,23 @@ const AGENTS: AgentEntry[] = [
name: "Claude Code",
wiring: "Hook entries in settings.json pointing at the shared hook binary",
events: [
"UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "StopFailure",
"SubagentStart", "SubagentStop", "Notification", "PreToolUse",
"PermissionRequest", "PostToolUse", "PostToolUseFailure",
"PermissionDenied", "PreCompact"
"UserPromptSubmit",
"SessionStart",
"SessionEnd",
"Stop",
"StopFailure",
"SubagentStart",
"SubagentStop",
"Notification",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PostToolUseFailure",
"PermissionDenied",
"PreCompact"
],
gating: "Full — tool calls and permission prompts can be allowed or denied from a card"
gating:
"Full — tool calls and permission prompts can be allowed or denied from a card"
},
{
name: "Codex",
@@ -44,89 +55,167 @@ const AGENTS: AgentEntry[] = [
},
{
name: "OpenCode",
wiring: "In-process plugin (~/.config/opencode/plugins/nook.js) on the server bus",
wiring:
"In-process plugin (~/.config/opencode/plugins/nook.js) on the server bus",
events: [
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
"PostToolUse", "Stop", "MessageUpdate", "PermissionRequest", "QuestionAsked"
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"MessageUpdate",
"PermissionRequest",
"QuestionAsked"
],
gating: "Full — the plugin answers OpenCode's own permission API when you tap Allow or Deny"
gating:
"Full — the plugin answers OpenCode's own permission API when you tap Allow or Deny"
},
{
name: "Pi",
wiring: "In-process extension (~/.pi/agent/extensions/nook.ts)",
events: [
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
"PostToolUse", "Stop", "MessageUpdate", "QuestionAsked", "QuestionResolved"
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"MessageUpdate",
"QuestionAsked",
"QuestionResolved"
],
gating: "Full when you force verification — Pi alone never asks natively, so the gate engages on the approval tool"
gating:
"Full when you force verification — Pi alone never asks natively, so the gate engages on the approval tool"
},
{
name: "Oh My Pi",
wiring: "In-process extension package (~/.omp/agent/extensions/nook/)",
events: [
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
"PostToolUse", "Stop", "MessageUpdate", "QuestionAsked", "QuestionResolved"
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"MessageUpdate",
"QuestionAsked",
"QuestionResolved"
],
gating: "Full — honors the agent's own approval tiers, per-tool policies included"
gating:
"Full — honors the agent's own approval tiers, per-tool policies included"
},
{
name: "Hermes",
wiring: "Shell-hook entries in the agent's config, consent-gated by allowlist",
wiring:
"Shell-hook entries in the agent's config, consent-gated by allowlist",
events: [
"SessionStart", "SessionEnd", "UserPromptSubmit", "Stop", "PreToolUse",
"PostToolUse", "SubagentStart", "SubagentStop"
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"Stop",
"PreToolUse",
"PostToolUse",
"SubagentStart",
"SubagentStop"
],
gating: "Observation — every event appears as a card; the agent keeps its own prompting"
gating:
"Observation — every event appears as a card; the agent keeps its own prompting"
},
{
name: "OpenClaw",
wiring: "Hook handler package (~/.openclaw/hooks/nook/)",
events: ["SessionStart", "UserPromptSubmit", "AgentResponse", "PreCompact", "SessionEnd"],
gating: "Observation — strict fire-and-forget; the agent surface has no tool or permission events"
events: [
"SessionStart",
"UserPromptSubmit",
"AgentResponse",
"PreCompact",
"SessionEnd"
],
gating:
"Observation — strict fire-and-forget; the agent surface has no tool or permission events"
},
{
name: "Z.ai (ZCode)",
wiring: "Hook entries in ~/.zcode/cli/config.json",
events: [
"SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest",
"PostToolUse", "PostToolUseFailure", "Stop"
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PostToolUseFailure",
"Stop"
],
gating: "Full — tool calls and permission prompts can be allowed or denied from a card"
gating:
"Full — tool calls and permission prompts can be allowed or denied from a card"
},
{
name: "Gemini CLI",
wiring: "Hook entries in settings.json",
events: ["SessionStart", "SessionEnd", "AgentStart", "Stop", "Notification"],
gating: "Observation — every event appears as a card; the agent keeps its own prompting"
events: [
"SessionStart",
"SessionEnd",
"AgentStart",
"Stop",
"Notification"
],
gating:
"Observation — every event appears as a card; the agent keeps its own prompting"
},
{
name: "Cline",
wiring: "One hook file per event in ~/Documents/Cline/Hooks",
events: [
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
"PostToolUse", "Stop", "PreCompact", "Notification"
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"PreCompact",
"Notification"
],
gating: "Observation — every event appears as a card; the agent keeps its own prompting"
gating:
"Observation — every event appears as a card; the agent keeps its own prompting"
},
{
name: "Qwen Code",
wiring: "Hook entries in settings.json (same surface as the Claude-style schema)",
wiring:
"Hook entries in settings.json (same surface as the Claude-style schema)",
events: [
"UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "StopFailure",
"SubagentStart", "SubagentStop", "Notification", "PreToolUse",
"PermissionRequest", "PostToolUse", "PostToolUseFailure",
"PermissionDenied", "PreCompact"
"UserPromptSubmit",
"SessionStart",
"SessionEnd",
"Stop",
"StopFailure",
"SubagentStart",
"SubagentStop",
"Notification",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PostToolUseFailure",
"PermissionDenied",
"PreCompact"
],
gating: "Full — tool calls and permission prompts can be allowed or denied from a card"
gating:
"Full — tool calls and permission prompts can be allowed or denied from a card"
},
{
name: "DeepSeek",
wiring: "Standalone hooks.json at ~/.dsh",
events: [
"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop",
"SubagentStart", "SubagentStop"
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStart",
"SubagentStop"
],
gating: "Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
gating:
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
}
];
@@ -175,9 +264,7 @@ export default function NookAgentsPage() {
<div class="grid gap-4 lg:grid-cols-2">
<For each={AGENTS}>
{(agent) => (
<article
class="border-overlay0 bg-surface0/40 rounded-2xl border p-5"
>
<article class="border-overlay0 bg-surface0/40 rounded-2xl border p-5">
<div class="mb-3 flex items-center justify-between gap-3">
<h2 class="text-text text-lg font-semibold">{agent.name}</h2>
<span
@@ -207,8 +294,7 @@ export default function NookAgentsPage() {
<p class="text-subtext1 mt-10 max-w-2xl text-sm">
Running something else? A custom agent can wire itself in with the
same one-line contract the built-in integrations use — see the
plugin spec in the app's docs.
same one-line contract the built-in integrations use.
</p>
</main>
</>

View File

@@ -29,7 +29,7 @@ const FAQ: FaqEntry[] = [
{
question: "How much battery does it cost?",
answer:
"The Nook is a small native UI, not a background agent — it watches the agents you're already running; it doesn't run anything itself. The heaviest thing it does is read and write your Mac's fan and charging controllers, which is a lightweight sensor read. If you're running enough coding agents to need it, those agents dwarf the app's own draw — which is rather the point: the fan control exists because the agents are what heat the machine."
"The Nook is a small native UI, not a background agent — it watches the agents you're already running; it doesn't run anything itself. The heaviest thing it does is read and write your Mac's fan and charging controllers, which is a lightweight sensor read. If you're running enough coding agents to need it, those agents dwarf the app's own draw."
},
{
question: "What permissions does it need?",
@@ -39,7 +39,7 @@ const FAQ: FaqEntry[] = [
{
question: "Why macOS 14 or later?",
answer:
"The interface is built on Apple's current observation framework, which ships with macOS 14 (Sonoma). Supporting older versions would mean rewriting the whole UI layer on a deprecated foundation — time that goes into the app instead. macOS 14 runs on hardware from 2018 onward, which covers the machines that run coding agents comfortably."
"The interface is built on Apple's current observation framework, which ships with macOS 14 (Sonoma)."
},
{
question: "Does it work offline?",
@@ -60,7 +60,7 @@ export default function NookFaqPage() {
ogTitle="The Nook — FAQ"
/>
<SubdomainHeader />
<main class="min-h-screen px-[8vw] py-[8vh]">
<main class="flex min-h-screen flex-col items-center px-[8vw] py-[8vh]">
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
Questions people actually ask
</h1>
@@ -76,7 +76,7 @@ export default function NookFaqPage() {
.
</p>
<div class="max-w-3xl space-y-3">
<div class="w-full max-w-3xl space-y-3">
<For each={FAQ}>
{(entry, i) => (
<div class="border-overlay0 bg-surface0/40 rounded-2xl border">

View File

@@ -0,0 +1,99 @@
/**
* Subdomain routing invariant test.
*
* Verifies that every subdomain route (src/routes/<prefix>/<name>) has a
* corresponding top-level dispatch route (src/routes/<name>.tsx or
* src/routes/<name>/index.tsx). This is required because production Vercel
* deployments do not apply vercel.json host rewrites — the Nitro Build Output
* API config.json replaces them entirely, so host-aware root dispatch is the
* only mechanism that serves subdomain pages.
*
* See docs/subdomain-setup.md and AGENTS.md "Subdomain Routing" for context.
*/
import { describe, it, expect } from "bun:test";
import { readdirSync, existsSync, statSync } from "fs";
function isRouteFile(name: string): boolean {
return name.endsWith(".tsx") || name.endsWith(".ts");
}
function listRouteFiles(dir: string): string[] {
try {
return readdirSync(dir).filter(isRouteFile);
} catch {
return [];
}
}
function hasDispatchRoute(routeName: string, routesDir: string): boolean {
// Check for src/routes/<name>.tsx
if (existsSync(`${routesDir}/${routeName}.tsx`)) {
return true;
}
// Check for src/routes/<name>/index.tsx
if (existsSync(`${routesDir}/${routeName}/index.tsx`)) {
return true;
}
return false;
}
describe("subdomain routing dispatch invariant", () => {
const routesDir = "./src/routes";
it("every subdomain route has a top-level dispatch route", () => {
const prefixDirs = listRouteFiles(routesDir)
.filter((name) => {
try {
return statSync(`${routesDir}/${name}`).isDirectory();
} catch {
return false;
}
})
// Exclude non-product subdomains
.filter((name) =>
["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(name)
);
const missing = [];
for (const prefix of prefixDirs) {
const prefixFiles = listRouteFiles(`${routesDir}/${prefix}`);
for (const file of prefixFiles) {
// Skip test files and non-route files
if (file.includes("test") || file.includes("spec")) {
continue;
}
if (!isRouteFile(file)) {
continue;
}
// Determine route name (without extension)
const routeName = file.replace(/\.(tsx|ts)$/, "");
// index.tsx maps to the parent directory name
const effectiveRoute = routeName === "index" ? prefix : routeName;
// Skip routes that are intentionally not dispatched to root
// (these are subdomain-specific, not shared surfaces)
if (
effectiveRoute === "content" ||
effectiveRoute === "meta" ||
effectiveRoute.endsWith("-content") ||
effectiveRoute === "password-reset" ||
effectiveRoute === "request-password-reset" ||
effectiveRoute === "checkout" ||
effectiveRoute === "success" ||
effectiveRoute === "notifications"
) {
continue;
}
if (!hasDispatchRoute(effectiveRoute, routesDir)) {
missing.push(`${prefix}/${file} → missing dispatch for /${effectiveRoute}`);
}
}
}
expect(missing).toEqual([]);
});
});