feat: bridge a Claude web session into the dashboard, both ways (#1237) - #1238
Merged
Conversation
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 thebridgepreference (off = 404), own bearer token compared withtimingSafeEqualbefore 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.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
Extension (spike/cc-web-extension, v0.7.0)
mainonly, 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./_bridge/answeron 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 (
onBridgeStatuscarries 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:
articlefinds 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.