Skip to content

fix: close the agent-readiness gaps the Kitesurf sweep surfaced - #354

Merged
oddharsh merged 3 commits into
mainfrom
agent-readiness-fixes
Aug 14, 2026
Merged

oddharsh merged 3 commits into
mainfrom
agent-readiness-fixes

Conversation

@oddharsh

Copy link
Copy Markdown
Owner

Stacked on #353, which found these. This fixes them.

1. The photo grid was twelve blank squares

Every tile returned HTTP 200 and none decoded, because the tiles are AVIF and Kitesurf cannot read it. A request-level view calls that healthy, which is why nothing here caught it.

lib/photo-grid.js names one URL per tile deliberately, and that decision is untouched. This adds a recovery path instead, and the design turns on a fact measured first: Kitesurf does fire error on the tile it cannot decode. One delegated listener parses the stem back out of the failing URL and retries through /images/<stem>.jpg, which 301s to the hashed JPEG. No bytes are added to any tile.

Verified against the live grid with the listener as shipped:

KITESURF before  12 tiles, 12 broken,  0 repaired
KITESURF after   12 tiles,  0 broken, 12 repaired
CHROME   after   12 tiles,  0 broken,  0 repaired

The last row is the point: a browser that decodes AVIF never fires the event, so the fast path costs nothing.

The <noscript> twin gets <picture> instead, and the asymmetry is deliberate. Inert markup cannot instantiate a fallback, so the churn that took <picture> off the live tiles is impossible there, while a no-JS reader has no script running to repair anything.

Worth knowing for future decisions: <picture> works correctly in Kitesurf. /photos still ships it, and Kitesurf selected the JPEG for all 158 tiles and broke none, because a well-behaved engine declines a source type it cannot decode rather than claiming it. Gotcha 7's decode-failure warning does not apply to this engine.

2. Four surfaces advertised a twin and served none

/writing, /lens, /coffee and /around all carry flags.agents: true and had no Markdown twin. All four render from the Worker, so htmlFileFor() returns null and there is nothing for the generator to read.

/writing is generated from www/writing/posts.json, the same committed registry the page renders. A hand twin would go stale on the next post; this is the ordinary twin argument reaching one step further back than the HTML. Each row links to the post's .txt, so an agent gets prose with no Notepad chrome.

The other three are hand twins beside bot.md and terminal.md, each stating plainly that it describes rather than mirrors, and pointing at the JSON that carries the changing part. Every fact was read off the live endpoints rather than recalled.

Three carry a warning an agent needs that the HTML only implies: booking is a request, so a successful POST means "asked" and never "booked"; availability fails closed, so a 503 is the safe answer; a crawl skip is a legitimate outcome.

All four are swept by the route oracle now (134 routes), each with a load-bearing sentence as its marker rather than a title.

3. WebMCP, investigated rather than fixed

Not actionable here, but it resolves the open unknown from #353.

  • Kitesurf gets no bridge injection at all. No <script src="/.webmcp/bridge.js">, no request for it. The browser tool catalog is simply absent on the agent browser.
  • Kitesurf ships a stub document.modelContext carrying _listeners and _ontoolchange and no methods.

That second one is the same shape as the navigation stub that broke nav.js, which makes it a bug class rather than a one-off: this engine ships stub objects for APIs it does not implement, so if (window.X) is unsafe by default and if (typeof X.method === "function") is the only reliable form.

I swept the client scripts for other instances. tooltip.js touches Temporal behind a bare typeof check but wraps both call sites in try/catch with a Date fallback, so a partial implementation degrades correctly. Everything else is either our own modules or already checks the method. navigation was the only unguarded one and #353 fixes it.

249/249 contract tests, typecheck clean, route oracle 134/134, page contracts clean.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
aadhar-sh e4d1de8 Aug 14 2026, 02:50 PM

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Wire-size diff vs merge base

d0dabd5 → aeaab7f

Brotli q11, the quality the edge serves. Every number here is deterministic:
an unchanged file produces no row. Advisory — this check fails on nothing.

Client assets

No change. 19 files, 68.08 KiB Brotli total.

Pages

Total Brotli: 494.22 KiB → 494.35 KiB (+0.13 KiB, +0.0%) across 48 files, 1 changed.

file base head Δ
index.html 7.78 KiB 7.90 KiB +0.13 KiB (+1.6%)

Worker bundle

278.56 KiB → 278.57 KiB gzip (+0.01 KiB, +0.0%)

Largest module deltas (raw bytes in bundle):

file base head Δ
.build/www/_worker.js/lib/twins.js 0.92 KiB 0.97 KiB +0.04 KiB (+4.5%)

Dictionary deltas (pd/)

144 → 144 deltas, 449.54 KiB → 449.97 KiB (+0.42 KiB).

@oddharsh
oddharsh force-pushed the agent-readiness-fixes branch from 30e5807 to f41b482 Compare August 12, 2026 20:49
@oddharsh
oddharsh force-pushed the agent-readiness-fixes branch from bddcc9c to d6e7c42 Compare August 12, 2026 21:15
@oddharsh
oddharsh force-pushed the agent-readiness-fixes branch from d6e7c42 to e1ccba3 Compare August 12, 2026 21:48
@oddharsh
oddharsh force-pushed the agent-readiness-fixes branch from e1ccba3 to d81489b Compare August 12, 2026 22:53
Base automatically changed from kitesurf-agent-check to main August 13, 2026 11:38
@oddharsh
oddharsh force-pushed the agent-readiness-fixes branch from d81489b to 735c424 Compare August 13, 2026 11:38
…code

The homepage grid was twelve blank squares in Cloudflare's Kitesurf. Every
tile returned HTTP 200 and none of them decoded, because the tiles are AVIF
and that engine cannot read it. A request-level view calls this healthy,
which is why nothing here caught it.

lib/photo-grid.js names ONE url per tile on purpose: a <picture> there had
its JPEG fallback instantiated over and over as the cursor crossed the grid
(13 loads for one tile, measured 2026-08-11). That decision is untouched.
This adds a RECOVERY path instead, and the whole design turns on a fact
measured 2026-08-12: Kitesurf does fire `error` on the tile it cannot decode.

So one delegated listener parses the stem back out of the failing URL and
retries through /images/<stem>.jpg, which 301s to the hashed JPEG. It adds no
bytes to any tile, because nothing is shipped that a working browser would
not use. Capture phase, since `error` on an <img> does not bubble.

Verified against the live grid, same listener as shipped:

    KITESURF before  12 tiles, 12 broken,  0 repaired
    KITESURF after   12 tiles,  0 broken, 12 repaired
    CHROME   after   12 tiles,  0 broken,  0 repaired

The last row is the point. A browser that decodes AVIF never fires the event,
so the fast path costs exactly nothing.

The <noscript> twin gets <picture> instead, and that asymmetry is deliberate.
Inert markup cannot instantiate a fallback, so the churn that took <picture>
off the live tiles is impossible there, while a no-JS reader that cannot
decode AVIF has no script running to repair anything. /photos already proves
the mechanism: it still ships <picture>, and Kitesurf selected the JPEG for
all 158 tiles and broke none, because a well-behaved engine DECLINES a source
type it cannot decode rather than claiming it and failing.
…own twin

/writing, /lens, /coffee and /around all carry flags.agents: true in
site-manifest.json and all four served no twin. Found 2026-08-12 by probing
the twin PER ROUTE rather than trusting the site-wide count, which is 44 and
was never wrong.

They were missed because every one of them renders from the Worker, so
htmlFileFor() returns null and there is no HTML for the generator to read.
CLAUDE.md already names the fix for that case: hand-author into www/md/, or
render live where the data exists in another representation, or drop
flags.agents rather than advertise a surface an agent cannot read.

/writing takes a third shape and it is the better one for a LIST. A hand twin
would go stale the next time a post is added, so the twin is generated from
www/writing/posts.json, which is the same committed registry the page itself
renders. That is the ordinary twin argument reaching one step further back
than the HTML: still a pure function of committed bytes, still impossible to
drift. It links each post to its .txt, so an agent following a row gets the
prose with no Notepad chrome to strip.

The other three are hand twins beside bot.md, security.md and terminal.md.
Each states plainly that it describes rather than mirrors, and points at the
JSON that already carries the changing part: /around/json and
/around/changes.json for the crawl, /coffee/availability.json for the slots.
Every fact in them was read off the live endpoints or the source rather than
recalled.

Three of them carry a warning an agent needs and the HTML only implies.
Booking is a REQUEST and a successful POST means "asked", never "booked".
Availability fails closed, so a 503 is the safe answer rather than a broken
one. A crawl skip is a legitimate outcome rather than an error.

All four are swept by the route oracle now, each with a load-bearing sentence
as its marker rather than a title, so a twin that degrades into a stub fails.
Markers are single-line on purpose: the first one spanned a wrapped line and
never matched.
The <noscript> tile briefly carried <picture>. It is SAFE there (inert markup
instantiates nothing, and a no-JS AVIF browser would still select the AVIF
source), but safe is not the bar. It costs 195 bytes a tile against 130, and
it buys a client that has to be no-JS AND no-AVIF simultaneously, when the
engine this whole repair exists for runs JavaScript and takes the onerror
path in index.html without ever reading that block.

One url per tile, everywhere, which is what this file said in the first place.

The live path is unchanged and was never the concern. Measured at the network
layer on the real homepage, counting image requests rather than repairs:

    CHROME  (decodes avif)   12 avif,  0 jpg,  0 broken tiles
    KITESURF(no avif)        12 avif, 24 jpg,  0 broken tiles

Zero JPEG bytes reach a browser that can decode the AVIF it is already being
sent. Kitesurf's 24 is 12 redirect hops plus 12 images, so three requests a
tile on the engine that is already failing. The alternative is a data-jpg
attribute on every tile, which would ship ~35 bytes of never-used markup to
everyone to save a hop for almost nobody. The hop is the cheaper half.
@oddharsh
oddharsh force-pushed the agent-readiness-fixes branch from 735c424 to e4d1de8 Compare August 14, 2026 14:48
@oddharsh
oddharsh merged commit 8660dd1 into main Aug 14, 2026
6 of 7 checks passed
@oddharsh
oddharsh deleted the agent-readiness-fixes branch August 14, 2026 19:26
oddharsh added a commit that referenced this pull request Aug 24, 2026
…26-08-14

`src/content/md/` holds seven hand-authored twins (/around, /bot, /coffee,
/lens, /security, /terminal, /whoareyou). CLAUDE.md's key-files row, its layout
table, `.assetsignore`'s comment and the generator's own header all said three,
and all four still called the directory `public/md/`.

The count could rot because the set is not a list anywhere in the code.
`buildTwins` looks for `<path>.md` per SURFACE, so a page joins by someone
dropping a file in: /terminal joined in #227 and /around, /coffee and /lens in
#354, and nothing announced either. What the row now records is the mechanism
rather than the membership, plus the fact that `checkTwinFacts()` pins only
three of the seven, so the other four can drift from their page silently.

Two neighbouring rows named prose that moved in the same #456 split:

- `public/writing/` is `src/content/writing/`, and its render code is in
  `src/worker/` rather than `_worker.js`.
- `public/index.md` is `src/content/index.md`. That path confusion has already
  cost something: gen-md-twins looked in `public/` after the move, silently
  stopped finding the hand twin, and overwrote it with a generated one 472
  bytes shorter. The row records that now.

`.assetsignore` also opened on "Files in `www/`" and called the deltas
`www/ad/`. Its two paragraphs about `www/scripts` and the old dictionary
location stay, since those are the record of why there is no entry at either
path.
oddharsh added a commit that referenced this pull request Aug 24, 2026
…26-08-14

`src/content/md/` holds seven hand-authored twins (/around, /bot, /coffee,
/lens, /security, /terminal, /whoareyou). CLAUDE.md's key-files row, its layout
table, `.assetsignore`'s comment and the generator's own header all said three,
and all four still called the directory `public/md/`.

The count could rot because the set is not a list anywhere in the code.
`buildTwins` looks for `<path>.md` per SURFACE, so a page joins by someone
dropping a file in: /terminal joined in #227 and /around, /coffee and /lens in
#354, and nothing announced either. What the row now records is the mechanism
rather than the membership, plus the fact that `checkTwinFacts()` pins only
three of the seven, so the other four can drift from their page silently.

Two neighbouring rows named prose that moved in the same #456 split:

- `public/writing/` is `src/content/writing/`, and its render code is in
  `src/worker/` rather than `_worker.js`.
- `public/index.md` is `src/content/index.md`. That path confusion has already
  cost something: gen-md-twins looked in `public/` after the move, silently
  stopped finding the hand twin, and overwrote it with a generated one 472
  bytes shorter. The row records that now.

`.assetsignore` also opened on "Files in `www/`" and called the deltas
`www/ad/`. Its two paragraphs about `www/scripts` and the old dictionary
location stay, since those are the record of why there is no entry at either
path.
oddharsh added a commit that referenced this pull request Aug 24, 2026
…#563)

* docs: the runbooks still describe the pnpm tree and the www directory

`pnpm exec` refuses on a bun tree outright (it reads package.json's
`packageManager`), and there is no pnpm workspace left for `--filter` to read,
so every pnpm command in these files fails rather than degrading. Fixed in the
clone-and-verify recipe, cal/README.md, `checkpoints:check`, `bun why esbuild`,
and three live instructions in CLAUDE.md (`deploy:direct`, `infra:apply`, the
cal secrets block).

Four claims contradicted a value something else checks:

- MAINTENANCE.md's rollout recipe told you to type `pnpm exec wrangler versions
  upload` into the Workers Builds dashboard, where infra.json declares `bash
  .github/deploy-wrangler.sh versions upload` and check-infra.mjs exact-matches
  it. Following the recipe reddens `validate`. CLAUDE.md's gotcha 29 carried the
  same stale string, and infra.json's own prose said "both moved to `pnpm exec`"
  fifteen lines above the value that is the wrapper.
- MAINTENANCE.md said the wrapper picks its invocation from the lockfile. It
  stopped doing that on 2026-08-20 and runs the pinned entry file under node,
  because wrangler does not support bun and the refusal is per-command.
- CLAUDE.md said the ramp calls `pnpm exec wrangler`. Spawns go through
  `wranglerCommand()` in tools/lib/wrangler-bin.ts, which names no package
  manager. This one already cost a release: five tools were still spawning
  `pnpm exec` the day the tree became bun, and two of them swallowed the
  refusal and reported a build that measured nothing.
- CLAUDE.md's root-files list named `pnpm-workspace.yaml` and `pnpm-lock.yaml`.
  The lockfile is `bun.lock` and package.json carries the four workspaces.

`www/` survived in two places. design/DESIGN.md cited `www/index.html` and
`www/garage/*` for the two canonical Luna surfaces, and src/content/README.md
was a whole fossil of the old directory, titled `# www/`, describing
`_worker.js` and `scripts/` and linking a MAINTENANCE.md that resolves to
nothing from where it now sits. That file BUILDS to .build/public/README.md, so
it has been serving at aadhar.sh/README.md the whole time.

Gotchas 29 and 30 keep their pnpm, since they are the record of that era, and
so does the note that one dashboard string has to suit a pnpm branch and a bun
one. Where a fix landed inside those blocks the old state is kept and the newer
move appended rather than overwritten.

* docs(twins): the hand-twin set says three and has been seven since 2026-08-14

`src/content/md/` holds seven hand-authored twins (/around, /bot, /coffee,
/lens, /security, /terminal, /whoareyou). CLAUDE.md's key-files row, its layout
table, `.assetsignore`'s comment and the generator's own header all said three,
and all four still called the directory `public/md/`.

The count could rot because the set is not a list anywhere in the code.
`buildTwins` looks for `<path>.md` per SURFACE, so a page joins by someone
dropping a file in: /terminal joined in #227 and /around, /coffee and /lens in
#354, and nothing announced either. What the row now records is the mechanism
rather than the membership, plus the fact that `checkTwinFacts()` pins only
three of the seven, so the other four can drift from their page silently.

Two neighbouring rows named prose that moved in the same #456 split:

- `public/writing/` is `src/content/writing/`, and its render code is in
  `src/worker/` rather than `_worker.js`.
- `public/index.md` is `src/content/index.md`. That path confusion has already
  cost something: gen-md-twins looked in `public/` after the move, silently
  stopped finding the hand twin, and overwrote it with a generated one 472
  bytes shorter. The row records that now.

`.assetsignore` also opened on "Files in `www/`" and called the deltas
`www/ad/`. Its two paragraphs about `www/scripts` and the old dictionary
location stay, since those are the record of why there is no entry at either
path.

---------

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant