fix(the-framework): end a Claude web run at the hand-off (#1225) - #1231
Merged
Conversation
A cloud session's replies stay in the cloud, so every phase after the first prompt was reading the driver's own "handed off to <url>" note as though the agent had written it. The production-grade checklist found no fenced verdict in it and reported the app un-reviewable, and the backlog gate then asked "Start the next backlog item?" about work that was no longer on this machine. That is why the same question turned up on every cloud run, before the session had even replied, and stayed there after a refresh. A driver now declares whether it hands the task somewhere this machine cannot follow. For one that does, the run is scope -> build and nothing after it: Bootstrap already skips its whole loop when given no checklist step, and the backlog loop and the stay-open composer are skipped too. The composer is the one that is a judgement call rather than a bug fix: the CLI can start a cloud session and pull one back, but it cannot send a second message to one, so staying open offered a box whose every message answered itself. The questions the session really does ask are asked over there and cannot be carried here, so the run notice says so rather than leaving it to be found. Closes #1225
suleimansh
marked this pull request as ready for review
July 26, 2026 14:24
This was referenced Jul 26, 2026
Closed
suleimansh
added a commit
that referenced
this pull request
Jul 26, 2026
…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.
suleimansh
added a commit
that referenced
this pull request
Jul 26, 2026
… (#1238) * spike: read-only Claude web bridge extension 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. * spike: report why extraction failed, and prove the parser offline 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. * spike: look at <code> without a <pre>, and cross shadow roots and frames 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. * spike: brace-match the choice block instead of guessing its indentation 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. * spike: prefer the agent's real question over our own protocol spec 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. * feat(the-framework): accept a cloud session's question over a guarded 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. * feat(the-framework): put the browser bridge behind an opt-in preference 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. * feat(dashboard): show the question a cloud session is parked on 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. * feat: post the parked question from the extension to the daemon 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. * fix: say on the page whether the bridge report actually landed 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. * feat(the-framework): publish which cloud sessions the bridge should watch 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. * fix: have the options test prove the read path, not just auth "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. * fix: say why no tab opened, and offer to try right now 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. * fix: check the host grants before blaming the token 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. * fix: one closed tab no longer blacklists every session, and show the 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. * feat: mirror what a Claude web session is saying into the run view 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. * fix: report the transcript half on the panel too 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. * fix: show the running version on the panel, and stop tabs piling up 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. * fix: mirror the page when there are no article blocks to split on 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. * feat: let the injected page script report itself to the daemon 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. * fix: mirror the conversation, not the whole application 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. * feat: answer a parked Claude web question from the dashboard (#1237) 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. * fix: wait for the composer before failing a delivery (#1237) 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
added a commit
that referenced
this pull request
Aug 17, 2026
…ies real questions (#1562) Adds tickets/2026-07-26_choices-not-working-cc-web.plan.md and lifts the plan lock. The invented-choices mechanism is gone (#1234 hands-off protocol, #1231 end-at-hand-off, fixed cloud-driver turn text) and the #1237 bridge (extension v0.8.0) carries real questions home; what remains is the live re-test protocol documented in the plan, then closing #1225. Claude-Session: https://claude.ai/code/session_01C6jbmY5pMD62um8ysEnSFV Co-authored-by: Claude <noreply@anthropic.com>
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 #1225
What you saw
Two different things, and neither came from the cloud session:
todo-loop.ts), raised on this machine. That is why it was the same choice all three times: it is the first open entry in the backlog, not anything the session said. It appeared before the session replied because it never depended on a reply.{ blockers }verdict was missing." The production-grade checklist prompted, got back the driver's ownHanded off to Claude Code on the web. View the session: ...note, found no fenced verdict in it, and failed closed. Correctly, for a reply the agent never wrote.The real choice, the one in your second screenshot, was asked inside the cloud session. Nothing carries it here and nothing could carry an answer back: a cloud session exposes no read-back API, and the CLI cannot send a second message to one.
The cause
The run loop was never told the run had left.
CloudSession.prompt()answers every later prompt with the same hand-off note (#1213 stopped it spending a session per prompt, but the loop kept coming back), so review, improve, backlog and live chat all ran against a canned string.The fix
DrivergainshandsOff, andCloudDriversets it. For a hand-off driver the run is scope -> build and nothing after:checkliststep, so this is a step omitted rather than a branch added.passes: 0, no blockers,stoppedEarly: false.todoLoop: trueis explicit.Handed off:log say why it stopped, so a finished hand-off does not read as a run that gave up one phase in.CloudRunNoticenow says the session asks its questions and opens its PR over there, not here. That is the only honest answer to the title of this issue: the choices cannot be relayed, so the UI should stop implying they might be.Verification
the-framework1381 pass / 0 fail / 1 skipped,framework-dashboard517 pass, typecheck clean.false, exactly the two newrun.test.tstests fail, and the messages one hangs its full 2s budget, which is the stay-open composer being real rather than assumed.Not in this PR
A
--run-on webrun with handoff armed still opens a PR for the branch it is sitting on, off a local worktree the cloud session never touched. Separate from the choices, so it is not folded in here.