From 92af52dfc9102c1e19bf1cd22c3ddb86bd578c28 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 5 Oct 2026 11:05:24 -0400 Subject: [PATCH] better changelog capture --- AGENTS.md | 2 +- src/routes/nook/agents.tsx | 172 +++++++++++++++++++------- src/routes/nook/faq.tsx | 8 +- src/routes/subdomain-dispatch.test.ts | 99 +++++++++++++++ 4 files changed, 233 insertions(+), 48 deletions(-) create mode 100644 src/routes/subdomain-dispatch.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7828732..a8db43f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//*` (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//*` (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//.tsx` content component AND a matching top-level dispatch file (`src/routes/.tsx` or `src/routes//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. diff --git a/src/routes/nook/agents.tsx b/src/routes/nook/agents.tsx index 565b245..e594cdd 100644 --- a/src/routes/nook/agents.tsx +++ b/src/routes/nook/agents.tsx @@ -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() {
{(agent) => ( -
+

{agent.name}

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.

diff --git a/src/routes/nook/faq.tsx b/src/routes/nook/faq.tsx index 087ee7c..719a546 100644 --- a/src/routes/nook/faq.tsx +++ b/src/routes/nook/faq.tsx @@ -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" /> -
+

Questions people actually ask

@@ -76,7 +76,7 @@ export default function NookFaqPage() { .

-
+
{(entry, i) => (
diff --git a/src/routes/subdomain-dispatch.test.ts b/src/routes/subdomain-dispatch.test.ts new file mode 100644 index 0000000..a8efd65 --- /dev/null +++ b/src/routes/subdomain-dispatch.test.ts @@ -0,0 +1,99 @@ +/** + * Subdomain routing invariant test. + * + * Verifies that every subdomain route (src/routes//) has a + * corresponding top-level dispatch route (src/routes/.tsx or + * src/routes//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/.tsx + if (existsSync(`${routesDir}/${routeName}.tsx`)) { + return true; + } + // Check for src/routes//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([]); + }); +});