Skip to content

feat: bridge a Claude web session into the dashboard, both ways (#1237) - #1238

Merged
suleimansh merged 25 commits into
mainfrom
feat/1237-bridge-question
Jul 26, 2026
Merged

feat: bridge a Claude web session into the dashboard, both ways (#1237)#1238
suleimansh merged 25 commits into
mainfrom
feat/1237-bridge-question

Conversation

@suleimansh

@suleimansh suleimansh commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Closes #1237

A Claude web run hands off and ends (#1231), so when the cloud session later asks something there is no live local run to hang a choice gate on, and nothing streams back. This PR carries the session into the dashboard through a browser extension running in the user's own logged-in claude.ai session: what the session says mirrors onto the run page, the question it parks on renders as real choices, and the pick travels back and is typed into the session's composer and submitted. No claude.ai tab has to be in front of anyone.

Grown here slice by slice; the issue's comment thread is the build log. The end state:

Daemon

  • /_bridge/{ping,question,events,sessions,hello,answer,answered}: opt-in behind the bridge preference (off = 404), own bearer token compared with timingSafeEqual before the body is read, no CORS headers on purpose (the extension's worker holds host permissions and needs no preflight; a wildcard would let any page post to a process that spawns processes). Matched before the Remote-daemon lane: non-loopback bind + shared token (atomic) #1051 guard, whose ?token= redirect an extension cannot follow.
  • Payloads are deliberately tiny and fully validated. The answer path only ever queues a label of the currently parked question, never free text, so the extension can never be handed something the session did not itself offer.
  • The store joins questions to runs by RunMeta.sessionId, dedupes re-reports of an answered question (the block stays in the DOM and the worker forgets on restart), refuses stale acks by answer id, and drops an undelivered pick when the session moves on to a new question.

Dashboard

  • The run view shows the mirrored transcript and renders the parked question as choices. Answering is an explicit two-step pick-then-send, a queued pick can be withdrawn until the extension collects it, and delivery reports back as sent or failed with the extension's reason.
  • Settings has a Claude web section: the toggle, the token, and what the injected script last reported.

Extension (spike/cc-web-extension, v0.7.0)

  • Finds the question (shadow-DOM-aware, brace-matched, rejecting our own protocol spec that renders on the page as a decoy), mirrors the conversation (main only, icon glyphs stripped, tail kept when the cap bites: verified live, the head was 8000 chars of rendered system prompt), auto-opens pinned background tabs for watched sessions, capped and self-cleaning.
  • Delivers answers: polls /_bridge/answer on a 30s alarm, types the label into the composer, prefers the labelled send button with an Enter fallback, acks what it did, revives its own discarded/orphaned tabs before delivering, and stops watching when its context is invalidated so a reloaded extension no longer fills the error page.
  • check.mjs: 13 jsdom cases, no browser needed.

Verified live

Read half proven end to end on a real cloud session: question extracted, decoy rejected, rendered on the right run. Mirror content verified against the daemon (onBridgeStatus carries the injected version and last scrape). Write half proven in jsdom and pending one live round trip (extension reload to v0.7.0).

Suites: the-framework 1440 pass / 0 fail, framework-dashboard 601 pass, check.mjs 13/13.

Decided against, for now

Per-message structure in the mirror: article finds nothing on a live claude.ai page, and any prose-boundary heuristic on another product's DOM would post gibberish that looks like output. The mirror stays one tail block, honestly labelled.

Answers whether an extension in the user's own session can find the question a
cloud run is parked on, and the box an answer would go into. Sends nothing: the
manifest requests no host permissions, so transport stays a separate question.

Lives under spike/ rather than packages/, which the pnpm workspace globs do not
match, so it joins no build.
A failed run reported only "no", which costs another round trip on someone's
browser to learn anything. It now reports the page's shape (block counts, which
container guesses exist, whether the await block appears at all). Structure and
lengths only, so the report stays safe to paste into a public issue.

check.mjs runs the content script against a synthetic page carrying the exact
block our agents emit. Three cases pass, so the parsing half is proven and the
only remaining unknown is whether claude.ai's real DOM puts the block somewhere
these strategies reach.
Round 1 on a real session found nothing while the question was plainly on
screen. Two causes, both in the instrument. The selector required a <pre> and
the page has none (32 <code>, 0 <pre>), so it examined nothing. And "options"
was absent from document.body.innerText despite being visible, which can only
mean the content sits behind a shadow root or in a frame.

So queries now walk open shadow roots, the manifest runs in all frames, and a
child frame reports upward rather than drawing a second panel. The report says
which of the two it was: shadow root count, iframe count and how many are
reachable, and "options" presence in body text versus deep text.
Round 2 reached the text but still could not read it: "options" was present in
deep text and six json-ish blocks were counted, yet nothing parsed. The cause
was the fallback matching on two fixed prefixes, {"title" and {\n  "title",
which are guesses at an indentation nobody promised, and the block also arrives
with prose around it.

It now brace-matches from each opening brace, tracking strings and escapes so a
brace inside a label cannot close the object early. Eight cases pass, including
four-space indent, a highlighter splitting the block across spans, prose on both
sides, and the block behind a shadow root, which is where round 2 found it.

A failing report now also says whether candidates were found and how many failed
to parse, so "never found it" and "found it and could not read it" stop looking
the same.
Round 3 found a question and reported it as "<the question>", which is the
await-choices spec from our own system prompt: the page renders the prompt, so
a block with `options` is on screen before the agent has asked anything.

Two guards. Placeholder-shaped blocks (every label matching <...>) are rejected,
and all matches are collected rather than the first, so the last real question
wins, DOM order tracking transcript order. A session that has not asked yet now
correctly reports none rather than the spec.

Ten cases pass. Two of them are the ones that would have shipped the decoy: spec
followed by the real question, and spec alone.
… bridge

Slice 1 of #1237, the daemon half. POST /_bridge/question takes the question a
Claude web session is parked on, keyed by its cloud session id, which joins back
to a run through RunMeta.sessionId. A web run already carries that id, so the
question lands on the right run even though #1231 ended the run at the hand-off
and there is no live session to hang a gate on.

This route carries its own bearer token rather than riding the #1051 guard. That
guard only exists on a non-loopback bind, and what keeps other origins out of
/_telefunc is the same-origin check; the bridge is the first route meant to be
reached from another origin, so neither protects it.

No CORS headers, deliberately. A wildcard would let any page the user visits
post to their daemon. The extension posts from its background worker, which
holds host permissions and needs no preflight.

What it accepts is one fully validated shape with no path, command or free text
in it, so the worst a stolen token buys is a bogus card in a dashboard. Off
unless a token is wired, which 404s every route.
@suleimansh suleimansh added the enhancement New feature or request label Jul 26, 2026
@suleimansh suleimansh self-assigned this Jul 26, 2026
The bridge stays off unless `bridge` is set, which 404s every route. It opens
the daemon's one route reachable from another origin, so it is a choice rather
than a baseline.

Turning it on mints the token by reusing the #1051 shared secret rather than a
second one: both guard the same daemon, so another secret would be one more
thing to rotate and leak without narrowing anything. On a loopback bind that
secret may not exist yet, so this ensures rather than reads.

registry.test.ts pins the boolean-preference table exhaustively on purpose
(#944, the write-then-vanish shape), so the new key is listed there too.
The run view now renders what the bridge reported, so a parked Claude web run
says what it is waiting for instead of looking finished.

Keyed by cloud session id rather than run id, because that is all the bridge can
see: it reads a claude.ai page, which knows its session and nothing about our
runs. The run view already derives that id from the run's own `cloud <url>`
event, so the join happens on the client and the daemon never indexes runs by
session.

Polled rather than streamed. The bridge writes over HTTP and never touches the
run's event log, so there is no event for the live channel to carry.

Read only, and the copy says so rather than offering buttons that do nothing:
showing the question is the win here, and the pick travelling back is a separate
decision.

The read went on the existing reads surface rather than a new telefunc file,
which avoids a new register key and a new dashboard shim.
The extension now reports what it finds. A service worker holds the token and
does the fetch; the content script only finds the question and hands it over.

Both halves of that split are load-bearing. A content script shares a tab with
claude.ai, so nothing there should hold the secret that talks to a daemon. And a
fetch from a content script carries the page's origin into a CORS check the
daemon refuses on purpose, since a wildcard would let any site the user visits
post to their dashboard; a worker with host permissions is not subject to CORS.

Watching replaced polling. Chrome clamps timers in a tab hidden for more than
about five minutes to roughly once a minute, and this is meant to run in a
background tab, so a MutationObserver catches the session's own DOM changes and
the slow interval is only a backstop. The worker also drops an unchanged repeat,
because a parked question can sit there for an hour.

The options page tests the connection rather than only saving, and tells the
three failure modes apart. That test needs the ping BODY, not its status: the
dashboard serves its SPA for any unrecognised path, so a build with no bridge
route answers 200 with HTML, and checking only `res.ok` would report "connected"
to someone whose bridge does not exist. Verified against the running daemon.
The worker's answer was thrown away, so an extension with no token configured
looked identical to one working correctly: the question was found, nothing was
sent, and nothing said so. Found live, with the dashboard showing no card and no
way to tell which half was at fault.

The panel now carries a `bridge` row reporting what the worker said: sent,
unchanged, no token set, the daemon's status, or the worker being unreachable.
…tion

# Conflicts:
#	packages/framework-dashboard/server/reads.telefunc.ts
#	packages/the-framework/src/dashboard-rpc/index.ts
#	packages/the-framework/src/index.ts
…atch

The extension cannot know a cloud run started: it only sees pages it is injected
into, so without this the bridge works only while somebody happens to have the
session open. `GET /_bridge/sessions` lists what to watch, and the worker opens
one pinned inactive tab each.

Recency is the whole filter, and it has to be. #1231 ends a web run at the
hand-off, so every one of them reads `done` whether its session is parked on a
question or finished an hour ago, and no read-back exists that would tell those
apart. So the honest rule is "recent, and not many" rather than a liveness check
we cannot perform: three tabs, twelve hours, newest first, deduped by session.

Opening tabs for someone is asked for rather than assumed, so it is a checkbox,
and a session whose tab the user closed is remembered and not reopened.

Verified against the running daemon: the endpoint lists this machine's three
recent cloud sessions and 401s without the token.
"Connected" answered whether the token works and left the next question open:
whether the daemon has anything to watch. It now reports how many recent cloud
sessions it can see, and whether tab opening is on, so a working setup with
nothing to do is distinguishable from one that is misconfigured.
Every early return in the sweep was silent, so "tabs are not opening" could not
be answered without reading a service worker console: no token, switched off,
daemon unreachable, nothing recent, already open, closed by the user and a
failing tabs.create all looked identical from outside.

Each now records its reason, and an Open tabs now button runs the sweep on
demand and reports the outcome, rather than leaving someone to wait out a
one-minute alarm wondering.
Site access toggles sit off for an unpacked extension even though the manifest
declares the hosts, and the daemon sends no CORS headers by design, so without
the localhost grant the worker's fetch never leaves the browser. The daemon then
records no contact at all, which is indistinguishable from a wrong token, a
worker that never ran, or an extension that was never installed.

The options page now checks the grants first and names the sites to switch on,
and the setup steps say to do it.
…token in settings

Two things made the bridge unusable, both found by driving it live.

The dismissal logic poisoned itself. On any tab close it asked the daemon what
it was watching and dismissed every session without an open tab, so closing one
claude.ai tab blacklisted all of them permanently and the sweep went quiet for
good. It now records which session each tab it opened is showing, and attributes
a close to that one only. The storage key is versioned so the poisoned lists are
discarded rather than migrated, since every entry in them is suspect.

And the feature could not be enabled from the product at all: turning the bridge
on meant hand-editing ~/.the-framework.json, and finding the token meant copying
a field out of the same file. Settings now has a Claude web section with the
toggle and the token, revealed on request rather than sitting on screen, since
this dashboard gets demoed and recorded.
The bridge carried one thing, the question a session was parked on, which left
a run that was working away completely opaque.

`article` is the anchor for extraction: the page renders one per message, which
is a far better handle than guessing at prose boundaries. A block holding the
composer is not a message, and no fallback heuristic is attempted, because a
wrong split would post gibberish that reads like output.

Keyed by position rather than appended. The page is re-read on every DOM change,
so the same message arrives many times; keying makes a repeat replace its
earlier copy, which is also what a message still streaming in needs. The content
script sends only what changed, so a stable transcript costs nothing.

A batch is rejected whole on a bad entry rather than partially accepted, since
gaps in the sequence are indistinguishable to a reader from a message that has
not arrived yet. Sessions are capped at 300 entries, oldest dropped.

Presented as a mirror rather than a live feed of our own, because that is what
it is: no tool calls, no timings, and nothing at all while the tab is closed.
The bridge row is set by the question path only, so a session that replied in
prose showed "not sent yet" while saying nothing about whether the transcript
was scraped, posted, or never found. The panel now carries the transcript status
and how many article blocks were seen, which is also the fastest way to learn
whether article is the right anchor on a given page.
Three rounds were spent unable to tell an old content script from a current one
that found nothing, because the panel looked identical either way. It now shows
the manifest version, so a screenshot says which build is running.

The sweep also closes tabs it opened for sessions that have fallen out of the
watch list. Without that a browser gains a pinned tab per run and never loses
one. Only tabs this extension opened: a session the user opened is theirs, and
the record is dropped before the close so it does not read as a dismissal.
A live session turned out to have no usable article blocks, so the transcript
never sent anything and the run view stayed empty.

It now falls back to mirroring the page as a single block, replaced as it grows.
That is deliberately not a guess at message boundaries: splitting prose wrongly
would post gibberish that reads like output, whereas one unsplit block is simply
what is on screen. Structure can come later once the real markup is known.

Our own panel is hidden for the read, or the mirror would show the mirror.
Every wrong guess about the extension cost a round trip through a person with a
screenshot, because nothing about the page half was visible from the daemon.

POST /_bridge/hello carries the injected version and what the last scrape found,
so which build is running in the page, and whether it saw any blocks, can be
read from the daemon instead of asked for.
The fallback sent the sidebar: Home, Code, Artifacts, Pinned, every nav label
and a run of icon-font glyphs, because it read document.body.

It now prefers main, which is the conversation on this page, and falls back to
body only if there is none. Private-use codepoints are stripped since they are
icon glyphs carrying no meaning outside their font, and blank lines collapse.
The container used is reported, so a layout change shows up as a container
change rather than as mystery text.
The read half shipped earlier: the extension reports the question a cloud
session is parked on and the run view shows it. This is the pick travelling
back, plus what verifying the mirror content turned up.

- dashboard: the question renders as real choices with an explicit
  pick-then-send, a cancel while queued, and sent/failed states
- daemon: GET /_bridge/answer serves the queued pick, POST /_bridge/answered
  takes the delivery outcome; the store only accepts a label the parked
  question offered, ignores re-reports of an answered question, and drops an
  undelivered pick when the session moves on to a new question
- extension v0.7.0: polls for answers on a 30s alarm, types the label into
  the composer, prefers the labelled send button with an Enter fallback,
  acks with what it did, reloads its own orphaned or discarded pinned tabs
  before delivering, and stops watching when its context is invalidated so
  a reloaded extension no longer fills the error page
- mirror: the single-block transcript now keeps the tail instead of the
  head; verified live, the head was 8000 characters of rendered system
  prompt and the session's actual activity never survived the cap

check.mjs grows from ten to thirteen jsdom cases: fill and click send, the
Enter fallback, and refusing a page with no composer.
@suleimansh suleimansh changed the title feat(the-framework): accept a cloud session's question over a guarded bridge (#1237) feat: bridge a Claude web session into the dashboard, both ways (#1237) Jul 26, 2026
The first live delivery landed right after a tab revive and failed with
'no composer on the page': claude.ai renders well past the naive 5 second
wait. The content script now polls for the composer for up to 20 seconds
before giving up. Extension v0.7.1; the harness shortens the wait so the
no-composer case still fails fast.
@suleimansh
suleimansh marked this pull request as ready for review July 26, 2026 22:26
@suleimansh
suleimansh merged commit 422351c into main Jul 26, 2026
1 check passed
@suleimansh
suleimansh deleted the feat/1237-bridge-question branch July 26, 2026 22:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request priority: medium Worth doing, not urgent the-framework ♻️ UX ✨

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bridge a Claude web session into the dashboard with a browser extension

1 participant