Skip to content

Perch Hub: the chat surface returns, at /dashboard/perch (PR 1 of 2) - #350

Merged
kh0pper merged 25 commits into
mainfrom
feat/perch-hub
Sep 10, 2026
Merged

kh0pper merged 25 commits into
mainfrom
feat/perch-hub

Conversation

@kh0pper

@kh0pper kh0pper commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Perch Hub comes back as a core, gateway-served page that lists every live bot session and owns the conversation. The bot board goes back to being the project-management board it is.

This is PR 1 of 2. The hub ships alongside the existing session drawer — a deliberate, temporary duplication so it can be used before anything is removed. PR 2 moves Talk/Open/Answer to the hub and deletes the drawer. Per the spec, PR 2 should not land until this one has been exercised against a real session on a phone.

Spec: docs/superpowers/specs/2026-09-09-perch-hub-design.md (also #349). Plan: docs/superpowers/plans/2026-09-09-perch-hub.md.

It adds no API

GET /dashboard/perch-api/roost already aggregates every bot def, one engine.list() and the bot_sessions rows in a single pass — it was built for the roost strip and is exactly the hub's list feed. The /interactive/* family already carries a whole conversation. The hub is a client of a contract with 77 route tests behind it.

Why a full page and not a panel

dashboardAuth is applied via router.use("/dashboard", …), so the hub mounts under that prefix and inherits auth, CSRF and the Funnel rejection; /perch is a 302 to it, and browsers preserve the fragment so deep links work. But it renders its own document rather than the dashboard shell — that shell is a large part of what made the drawer cramped on a phone. renderLogin() is the existing precedent.

Mobile rules are carried as constraints, not suggestions: 100dvh after a 100vh fallback, a sticky composer, no unlabelled control, full-bleed width, and a visualViewport offset so iOS's on-screen keyboard doesn't sit over the composer.

What review caught

This branch was reviewed adversarially at every task plus a whole-branch gate. The findings were overwhelmingly one class: an artifact reporting a property it did not measure. Nine instances, all fixed:

  • A test asserting the /perch mount that stayed green when the mount was deleted — it asserted against a route it registered itself.
  • Comments naming a test that had been deleted, claiming a 404 would prove something it couldn't, and citing line numbers that had drifted.
  • A substring check standing in for a binding check — twice (the innerHTML guard, and visualViewport).
  • send() defined, correct, unit-tested, and never called. #perch-send, #perch-back and #perch-abort had no handlers. The page would have rendered perfectly and done nothing.
  • The file attach told the operator "Image attached to the next message" while send() never attached body.images. The image never reached the model.
  • perchApi had no .catch, so a socket-level failure — a dropped phone signal, the dominant case — silently killed the page with no reconnect, and a failed send cleared the input and said nothing. It recovered only from HTTP 500, which a phone almost never sees.
  • Eight emitted CSS classes had no rule at all: the permission-confirm card rendered as plain body text, and user and bot messages were visually identical.

Nine bindings could be deleted with a green suite — including the hand-rolled X-Crow-Csrf header, the one line the file's own comment says must never be simplified. Each now has a test that was verified to fail when the binding is broken.

The durable win

mountHub() — a vm harness that loads the actual emitted client script and drives it only through real onclick/onchange handlers and real hash navigation, capturing genuine fetch bodies. Its FakeEventSource separates the onerror property from addEventListener('error') listeners, which is the exact dual-dispatch that made the native-error collision a real bug.

Every one of the nine defects above would have been caught by it. Known limitation, documented rather than papered over: timers are recorded but never fire, so nothing here proves the 2s reconnect or 10s re-poll actually elapse.

Tests

57/57 across perch-hub-client, perch-hub-page, perch-hub-render (CDP-live, not skipped) and i18n-global-parity. Unchanged elsewhere: roost-strip-ui 17, board-i18n-literals 10, perch-interactive-routes 77.

Every protective assertion in this branch was mutation-tested — the thing it guards was broken, the test confirmed red, then restored.

Known and deliberate

  • No nav link into the hub on this branch. Reachable via /perch, a typed URL, or a bookmark. The nav entry is PR 2, because it is a hard-coded link in layout.js (nav-registry only routes to registered panels, and the hub is not one).
  • attachToCard was implemented, then removed — zero-reference dead code with no UI trigger. It returns in PR 2 with its trigger.
  • Abort fails silently, matching the existing sendControl idiom.
  • loadHistory now shows "No transcript." on a transport failure where it previously hung forever. An improvement, but the copy is imprecise for that case; flagged for a later pass.

kh0pper added 25 commits September 9, 2026 21:34
Perch Hub returns as a core, gateway-served page at /perch, listing every
live bot session and owning the conversation. The bot board goes back to
being the project-management board it is, optionally launching a bot from
a card; the board's session drawer is deleted in phase 2 so there is one
chat surface rather than two.

The hub adds NO API. GET /roost already aggregates every bot def, one
engine.list() and the bot_sessions rows in a single pass, and the
interactive routes already carry the whole conversation. The hub is a
client of a contract that has 77 route tests behind it.

Records the decisions and the reasoning against the alternatives:
restoring the bundle (its own daemon, port, token file and supervised
child, all of which the retirement commit removed) and keeping both chat
surfaces (every future change lands twice; tonight's four defects were
all in one surface).

Carries the mobile rules learned the hard way today as constraints rather
than suggestions: 100dvh over 100vh, a sticky composer, no unlabelled
controls, full-bleed width, and a CDP assertion that Send is reachable
with the transcript scrolled to the top.

Scope boundaries are explicit. Making bundles/bot-engine core, and
provisioning models during onboarding, are named as siblings B and C and
kept out.
Phase 1 (tasks 1-8) builds the hub at /dashboard/perch alongside the
drawer, so a working chat surface exists at every commit. Phase 2 (tasks
9-10) moves Talk, Open and Answer to the hub and deletes the drawer.

The plan adds no API. GET /roost already aggregates every bot def, one
engine.list() and the bot_sessions rows in one pass, and the interactive
routes already carry a whole conversation.

Every trap found on 2026-09-09 is encoded as a tested behaviour rather
than a comment: plan_state.state is an object and an inactive plan
renders nothing; turnInFlight is false in the pre-post replay as well as
at a turn's end; the stream is attached BEFORE history because it carries
no backlog; and the render task asserts Send is reachable with the
transcript scrolled to the top, which is the measurement that caught the
drawer's unreachable send button when a screenshot did not.
Round 1 returned REVISE and was right to. The load-bearing fixes:

- The CSS extraction was off by one. Line 265 is the template literal's
  closing backtick, so the generated file did not parse, and the
  verification step could not catch it because the module never imported.
- Task 7's ask_user contract was FICTIONAL. The discriminator is method,
  not kind; options are plain strings, not {value,label}; and a confirm
  card answers with confirmed, not value. Both of the old tests passed
  against the invented shape while the feature would have rendered every
  option as the word 'undefined' and SILENTLY DENIED every permission
  prompt. Rewritten against perch-interactive.js:222 and :1904.
- The turnInFlight gate was presented as copied from the drawer. It was
  not: drawer.js:829 mirrors the flag directly. The invented version
  wedges the composer on Steer forever when a turn dies before its first
  in-flight frame. Now mirrors, including the stopped clause.
- The nav-registry step was unimplementable — resolveNavGroups only emits
  entries for registered panels and the defaults are not exported. Now a
  hard-coded link in layout.js, which also works on existing installs.
- Tasks 3-5 merged: a commit with openSession but no openStream parses,
  so the parse test passed while every session open threw.
- Async continuations gained session-identity guards; a fast back button
  left a second live EventSource on an abandoned session.
- session-expired and 404/410 are handled instead of being retried five
  times from a logged-out browser.
- Task 8's deletion list missed css.js's birdDrawerCss import and one of
  html.js's two birdDrawerMarkup call sites; Task 7 missed four of six
  openBirdDrawer sites and two existing assertions it would break.
- i18n parity rejects es === en, not just a missing es.
- No innerHTML anywhere, asserted: crow_csrf is not HttpOnly.

Kevin's three calls: filter unattached bots (divergence recorded), a
hard-coded nav link, and engine state read once on the server render.

Also taken: strict perchlive-[0-9a-f]{8} hash validation, the iOS
visualViewport keyboard offset, CSS assertions for the rules CDP cannot
prove, options:null handling for a hibernating session, and the PR split
stated as Tasks 1-6 then 7-8.
…ntracts

Round 2 returned REVISE: 8/10 round-1 items fixed, 2 partial, and four
NEW defects of the class round 1 called worst — plan code that cannot
work, guarded by plan tests that cannot pass.

THE SEVERE ONE: nothing ran at load. applyHash was registered on
hashchange and never called; loadList never fired. Task 7 navigates with
location.href, a FULL page load, which fires no hashchange — so all six
board hand-offs, talk and dispatch included, landed on a session list
stuck on 'Loading sessions...' forever. parseHash was exhaustively
unit-tested throughout, and green, because it is a pure function. Now
bootstrapped, with a test that asserts the emitted script calls it.

Three contracts were wrong and two failed SILENTLY:
- attach-to-card posted {cardId}; parseCardId reads body.card_id and 400s.
- /control needs permission_mode and plan_mode in snake_case. The route
  drops unknown keys and still returns 200, so {permissionMode} would
  have reported success and changed nothing — invisibly, on a security
  control. Body shape now written out and tested.
- perchHubDocument gained an engine parameter in one step and lost it in
  the next, so the banner never shipped and the step's own test failed.

Two tests could never have passed: the dispatch test anchored on a
translation KEY that tJs resolves at emit time, and planStateText
referenced script-level constants the extractor cannot see.

.join('\n') inside the client template literal breaks the module —
verified by execution; drawer.js:650 escapes it for the same reason.

attention is NOT an SSE event; it goes through the notification pipeline.
The spec is wrong and the plan had copied it. Recorded rather than obeyed.

The nav link needs data-turbo=false or Turbo splices the hub's global CSS
into the dashboard head for the rest of the session, plus class=nav-item
which a11y-baseline pins.

Also: every referenced helper is now defined, loadHistory is written out
(Task 8 deletes the only other record of that mapping), scheduleReconnect
cancels on navigate-away, roost-strip-ui:263 added to the rewrite list,
the file table gained five missing files and a Deleted section, and the
PR split no longer contradicts itself.
Round 3 returned REVISE: 10/12 fixed, but round 2's own fix re-created the
undefined-reference class it was fixing.

THE RECONNECT PATH WAS DEAD TWICE OVER. scheduleReconnect() was called
with no argument but declared (sid), so its timer compared a live sid
against undefined and never reopened the stream — 'Reconnecting...' then
silence, forever. Independently, closeStream() called cancelReconnect()
which reset retries, and onStreamError calls closeStream() before every
schedule, so the cap could never be reached. Cancelling the TIMER and
resetting the COUNTER are now separate; the counter resets only when a
stream actually opens; scheduleReconnect takes no argument at all.

appendNote, appendMessage and NO_TRANSCRIPT were referenced with no
definition — on the first-error and empty-transcript paths, which are
exactly where nobody looks. Written out.

The dispatch test's fixed 900-char window cannot reach the success
branch: measured, the attach-card mode occupies ~1900 chars first. Round
2 had replaced the repo's own working matchBrace technique with a
fragile slice; reverted to matchBrace.

Task 8's 'expect no output at all' grep was unattainable — comments in
perch-interactive.js legitimately survive. Narrowed to import and call
sites, with the surviving comments named and perch-interactive.js marked
do-not-touch.

Also fixed, each a latent bug rather than polish: the engine banner told
a mid-install or breaker-open operator to go install it; the 'session is
gone' note was wiped by the loadList that follows it, on exactly the
dead-deep-link path it exists for; stopped sessions rendered as tappable
rows that dead-end; the dispatch confirmation still said 'reloading'
after the reload was removed.

Every string constant is now written out and pinned by a test, and a test
counts the session-identity guards, which were previously load-bearing
and asserted only in a commit message.

Answered: no botboard.bd* key is in IDENTICAL_OK (75 entries checked), so
Task 8's key deletion needs no matching edit. The live-bot ask-card check
is marked a MANUAL GATE that an executing agent must stop at.
…e prose

Round 4: REVISE, with 'make these two edits and this is ready to run; I
would not require another full review round.' Every external contract it
spot-checked against the repo came back accurate.

Both blockers were test scoping, the same shape twice:

- Task 7 asserted !js.includes('openBirdDrawer(') at a commit where
  client.js:1141 still splices birdDrawerJs in, so drawer.js's own
  definition and internal call keep 2 of 8 occurrences alive. Red at
  Task 7, green only at Task 8. Task 7 now asserts its own five rewired
  sites by name; Task 8 gains the absolute form once the splice is gone.
  Deleting the assertion would have silently lost the six-call-site
  guard round 1 added, which is the likelier 'fix' under pressure.
- The reconnect-cap test's 200-char window straddles resetBackoff, whose
  entire body is retries=0 — so it failed against CORRECT code, the
  worst shape because the obvious fix is to move the working function.
  Replaced with a brace-matching fnSrc() helper, and the same helper now
  replaces Task 5's magic-number slice.

Then closed the last of the invent-it-yourself surface, because all four
rounds found their defects in the same place: prose where code belonged.
Written out with their guards and exact keys — renderList (owns rowIndex,
the hash-set, and the flushPendingNote that keeps a parked note alive),
startSession (which is what showListNote was missing callers for),
sendable/sendPath/send/setTurnInFlight, openStream/closeStream with a
guard on every listener and no attention listener, and attachFile/
attachToCard with the snake_case card_id.

Also: tJs is imported, the stream handlers are shown nested inside
openStream, the four engine strings and nine list strings are written out
with es, and Task 1's tautological 100vh assertion is gone — it
short-circuited true and Task 6 asserts the rule properly.
Harmless (it is a block comment) but it emitted a stray line break into
the generated script. Found by linting every client-code block in the
plan for the escaping trap that recurred across three review rounds; the
other 13 flags were false positives and were checked individually.
Pre-flight scan for the execution run found what four review rounds did
not: Task 2's renderList and startSession referenced nine symbols the
plan defined in Task 3. Task 2 would have shipped a ReferenceError on
first render, invisible to its own tests, which extract only the pure
listRows function.

Moved the seven list-facing constants, pendingNote, showListNote and
flushPendingNote into Task 2. Chat-facing constants stay in Task 3.
A full HTML document at /dashboard/perch rather than a dashboard panel —
the panel shell is a large part of what makes the current session surface
cramped on a phone. It lives under /dashboard so it inherits that mount's
dashboardAuth, CSRF and Funnel rejection; a bare top-level /perch would
inherit none of them, so /perch is a 302 to it.

The stylesheet is ported verbatim from the deleted bundle and keeps
Perch's own palette rather than crow's --crow-* tokens. That palette is
the look this page exists to restore.
… with a real one

The redirect/mount test claimed a sibling integration test existed that
asserted against the REAL dashboard router; it didn't — it defined its
own local /perch stub and asserted against that, so deleting the two
lines added to dashboard/index.js would leave this suite green.

Renamed the stub test to say what it actually checks (perchHubRouter's
own routing in isolation) and added a real integration test that
imports dashboardRouter's default export from dashboard/index.js,
mounts it with a real HTTP server, and asserts against real requests:
GET /perch -> 302 to /dashboard/perch, and GET /dashboard/perch
unauthenticated is bounced by dashboardAuth's network gate (403, never
404 -- a 404 would mean the mount was removed). Verified the new test
actually catches the regression by reverting the two mount lines
locally, confirming the test goes red (404 != 302), then restoring them.
…st with a real source-level guard

Round 1's replacement request-level test looked right but wasn't: it
deleted only the perchHubRouter mount line and the test stayed green,
because dashboardAuth is applied to the whole /dashboard prefix and its
network gate 403s an unauthenticated request before any route matching
happens -- identically whether or not perchHubRouter is mounted
underneath it. No pass-through injection is possible either:
dashboardAuth is a static import in dashboard/index.js, not the
mcpAuthMiddleware parameter dashboardRouter() actually takes.

Replaced it with a source-level test that reads dashboard/index.js and
asserts on its text: that it contains
router.use("/dashboard", perchHubRouter(dashboardAuth)) and that
router.get("/perch", ...) redirects to /dashboard/perch. Verified both
halves are independently load-bearing by deleting each of the two
lines in isolation and confirming the test fails on the matching
assertion each time, then restored the file.
…nger exists

The stub test's comment still named "the integration test below
(dashboard/index.js really mounts...)" as where the mount/redirect
wiring gets proven -- round 2 deleted that test and replaced it with a
source-level guard under a different name, so the comment pointed at a
ghost and repeated the "proven for real, against the real
dashboardRouter" claim round 2 disproved. Updated it to name the
surviving source-level guard by its real test name instead of
restating its reasoning.

Swept the rest of the file for the same defect class (a comment naming
a test/function/behaviour that no longer exists, or claiming more than
its assertions prove): also softened two brittle exact-line-number
references in the source-level guard's own comment ("line 11", "line
~613") to non-line-number phrasing, since those numbers drift as the
file changes and aren't load-bearing to the test's point. No other
stale references found.
GET /roost already aggregates every bot def, one engine.list() and the
bot_sessions rows in a single pass — it was built for the roost strip and
is exactly this list's feed, so the hub adds no API.

One row per live session plus one per attached bot with none, so a
session can be started from here. Bots without a perch gateway record are
omitted rather than shown and then 403'd. Rows blocked on you sort first.
Polling runs only while the list is showing; in the chat view the SSE
stream is already the live signal.
Routing, stream and composer ship together because they are not
independently reviewable: a commit with openSession but no openStream
still parses, so a parse test passes while every session open throws.

The stream is attached BEFORE history: it is live-only with no backlog,
so anything emitted in between is lost. Every async continuation carries
a session-identity guard, or a fast back button leaves a second live
EventSource on an abandoned session.

turnInFlight mirrors the engine exactly, including the stopped clause —
gating the false on having seen true wedges the composer on Steer forever
when a turn dies before its first in-flight frame.

session-expired sends you to the login page instead of five silent
retries from a logged-out browser, and 404/410 end the retry loop and
return to the list rather than reporting a reconnect failure.
…ents

The innerHTML test was a bare substring check, so a comment warning
against innerHTML failed a test meant to catch innerHTML use. Tightened
it to the actual sink shapes (.innerHTML=, .outerHTML=,
insertAdjacentHTML(, document.write() and restored both comments to name
innerHTML explicitly, so a grep for it finds the guard.

Also hardened extract()/fnSrc() in the test file: they brace-match raw
source with no awareness of comments, so a stray brace inside a comment
silently swallows the rest of the file. They now depth-count against a
comment-masked copy (same length, so indices still address the original
source) before slicing.
Every control carries a visible label. Model options show the human name
the payload has always carried and say when a model is on demand or not
running; a working model is undecorated.
Built against the engine's real card shape: the discriminator is method
(select|input|confirm|editor), options are plain strings, and the answer
is a tri-state — cancelled, or confirmed for a confirm card, or value.

A confirm card that answers with value reads at the engine as confirmed:
false, so getting this wrong would have silently DENIED every permission
prompt while appearing to approve it. Every card also gets a cancel, or
one whose options do not fit blocks the turn with no way out.

The card renders above the sticky composer rather than in the scrolling
transcript: an ask_user frame blocks the turn, so it must not be
scrollable past.

Also wires #perch-send, #perch-back and #perch-abort, which a prior task
defined but never connected to the DOM — the chat view rendered and Send
did nothing. Pinned with a reachability test, since every existing test
extracts a pure function and asserts behaviour, never reachability.
Send must be reachable with the transcript scrolled to the TOP, which is
the measurement that caught the drawer's unreachable send button when a
screenshot did not. Skips cleanly with no CDP endpoint.
…erclaims

The keyboard test asserted perchHubJs("en").includes("visualViewport") — a
bare token check that still passes against a dead stub with both
addEventListener calls stripped. Assert the resize and scroll bindings
directly instead. Mutation-verified: stripping both, resize alone, or
scroll alone each turn the test red; the restored source passes.

Also corrects the CSS-rules test's comment, which claimed the phone
reachability render test was close to unfailable against loss of
position:sticky/bottom:0 on the composer — mutation testing shows it
is not: either one alone turns that render test red. Background and
min-height:0 remain uncaught by the render test, as originally described.
…ailure

A rejected fetch() (destroyed socket, dropped tunnel, gateway restart —
the dominant phone failure) left every existing .then() callsite unreached:
onStreamError's reconnect probe never scheduled a reconnect, and send()
never surfaced SEND_FAILED. perchApi now resolves a well-formed
{ok:false,status:0,j:null} on a transport-level rejection, so every
caller's existing .then handles it exactly like an HTTP error, with no
per-callsite .catch needed.
… frame

A native EventSource connection failure delivers a type "error" Event to
every listener registered via addEventListener('error', ...), not only to
the single-slot onerror property onStreamError is bound through — and
unlike a real engine error frame it carries no .data. Before this, every
dropped connection printed a bare "error" note (JSON.parse(undefined)
throwing into the catch-all {} default) before onStreamError ever got a
chance to reconnect. A real server-sent "error" frame always carries a
data: line, so gating on typeof ev.data==='string' distinguishes the two
without touching any other event type (none of which fire natively).
The file-attach flow only ever did half of what its own note claimed
("Image attached to the next message."): attachFile() uploaded to
POST .../files but send() posted {message:text} with no images key, so
the model never received the picture. Ported the drawer's two-part
attach (drawer.js:406/916): attachFile() queues the upload's wire shape
{mime,data_b64} onto pendingImages (images only — a non-image upload has
nothing the model reads), send() attaches it as body.images on the next
POST and clears the queue immediately regardless of outcome, and
resetControls() also clears it so a new session never inherits a stale
queue from the one before it.

Also removes attachToCard (and its ATTACH_FAILED string, in both
client.js and i18n.js) — zero-reference dead code with no UI trigger,
sitting directly next to attachFile in the same block being edited here.
It returns in a later PR with a real trigger.
- M1: startSession's continuation had no identity guard — a spawn
  resolving after the operator opened another session would yank them
  out of it via location.hash. Added the same mySid guard every other
  continuation in this file already carries.
- M2: turnInFlight was never reset on a session transition, so a new
  session could show the PREVIOUS session's Steer/Stop state until its
  first 'state' frame arrived. Reset it in both resetControls() (every
  openSession) and closeSession().
- M3: abortTurn's .then was a guard followed by nothing. Dropped it —
  perchApi never throws now (see the C2 commit), so there is nothing
  left for a .then to react to.
- M5: askFields().needsText was computed and never read — renderAsk
  branches on card.method directly (confirm/select are handled above
  the call site; everything else already falls to the input/editor
  branch). Removed the dead field.
- M6: the bootstrap comment justified itself by citing a later PR's
  work ("Task 7 navigates with location.href"). Restated the actual,
  present-tense reason it's needed: a hashchange event never fires for
  a page's own initial load, so a direct load (short link, bookmark,
  refresh) would otherwise sit on the static placeholder forever.

Also removes the now-unused ATTACH_FAILED constant declaration left
over from the attachToCard removal in the previous commit.
Eight classes the client actually emits (entry, note, who, what, ask-card,
ask-title, ask-message, ask-controls) had no rule in css.js. Worst case:
the ask card — how a permission confirm reaches the operator, the
highest-stakes control on the page — had no background, border or
padding, and the user/bot who labels were identical in colour and weight.
Ported the deleted bundle's own equivalents
(42f3916^:bundles/perch-hub/payload/hub/bots-page.mjs .entry/.entry .who/
.entry .what/.ask-card/.ask-card .title/.ask-card .ask-message), renamed
onto the classes this script actually emits (ask-title/ask-controls in
place of the bundle's nested .title/.ask-opts). Verified in-browser via
CDP: the ask card now measures a real background/border/padding and the
user .who renders in --teal while the bot .who stays --dim.

Also removes 12 ported selectors with no producer in this page:
.perch(+::before), .bird(+svg/.attn/.idle), .machines a.here,
.state.attn/.idle, .row-actions, a.btn, .databar(+.mono-dim), .spawn,
.roost (the bare container class — .roost-row/.roost-main/etc. ARE used
and are kept), #msg, .ok. Everything else in the sheet (.perch-head,
.title, .meta, .state, the button/input/roost-row/field/hub-layout rules)
is still reachable from html.js or client.js and is kept.
Adds a full-script harness (mountHub, in perch-hub-client.test.js) that
runs the WHOLE emitted IIFE in a vm context against a fake DOM/fetch/
EventSource, rather than extracting one function at a time — the only
way to actually exercise bootstrap-time bindings (el('x').onchange=...),
a rejected fetch()'s effect on the real promise chain, and two listeners
racing on the same EventSource instance.

Covers every binding this wave's review found undetected by the
existing suite, each verified by mutation (removing/renaming the guarded
code and confirming the new test goes red, then restoring):
  - the X-Crow-Csrf header actually carries the cookie's value
  - all four onchange bindings (model/thinking/permission/plan-mode) post
    the right /control body
  - the ask_user SSE listener actually renders a card
  - the attach button actually opens the file picker
  - applyVV is bound to BOTH visualViewport events as the same function
    (proved behaviorally: firing either recomputes padding-bottom
    identically, which a `function(){}` rebind on either one would fail)
  - the identity-guard count is now asserted exactly (11), not >=6

Also proves C1 (an attached image rides the next /message body and the
queue empties afterward; a non-image upload is never queued), C2 (a
destroyed socket reaches onStreamError and schedules a reconnect; a
failed send appends a visible note) and I3 (a native connection error
prints nothing while a real error frame still renders its text) by
simulation rather than by reading the source.

Plus the small fixups this wave's client.js changes required: the
identity-guard regex title/count, the abort-handler regex (now matches
current.sid inline, not a removed mySid var), askFields' test no longer
expects the removed needsText field, the attach-to-card test is replaced
with a "this is gone" guard, and the perchHubRouter test title/comment in
perch-hub-page.test.js no longer claims the router itself redirects
/perch (it serves /dashboard/perch; the /perch redirect is a separate
route dashboard/index.js registers, and this test's app.get("/perch",
...) line is its own stand-in for that route).
@kh0pper
kh0pper merged commit 2791d70 into main Sep 10, 2026
3 checks passed
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