Skip to content

fix(the-framework): end a Claude web run at the hand-off (#1225) - #1231

Merged
suleimansh merged 1 commit into
mainfrom
fix/1225-cc-web-handoff
Jul 26, 2026
Merged

fix(the-framework): end a Claude web run at the hand-off (#1225)#1231
suleimansh merged 1 commit into
mainfrom
fix/1225-cc-web-handoff

Conversation

@suleimansh

Copy link
Copy Markdown
Contributor

Closes #1225

What you saw

Two different things, and neither came from the cloud session:

  1. The choice. "Start the next backlog item? (1 open)" is not the agent's. It is the framework's own backlog gate (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.
  2. "ended early — the { blockers } verdict was missing." The production-grade checklist prompted, got back the driver's own Handed 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

Driver gains handsOff, and CloudDriver sets it. For a hand-off driver the run is scope -> build and nothing after:

  • No checklist, no improve. Bootstrap already skips its entire loop when given no checklist step, so this is a step omitted rather than a branch added. passes: 0, no blockers, stoppedEarly: false.
  • No backlog loop. This machine has no standing to ask which item to start next when the work is somewhere it cannot see. Skipped even when todoLoop: true is explicit.
  • No stay-open composer. This is the judgement call, flag it if you disagree: leaving it open is not harmless, since every message would be answered by "this run was already handed off". The run ends instead, with the link and the teleport command.
  • The CLI's closing line and a new Handed off: log say why it stopped, so a finished hand-off does not read as a run that gave up one phase in.
  • CloudRunNotice now 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-framework 1381 pass / 0 fail / 1 skipped, framework-dashboard 517 pass, typecheck clean.
  • Proof-by-revert: with the guard forced to false, exactly the two new run.test.ts tests 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 web run 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.

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 suleimansh added the bug Something isn't working label Jul 26, 2026
@suleimansh suleimansh self-assigned this Jul 26, 2026
@suleimansh
suleimansh marked this pull request as ready for review July 26, 2026 14:24
@suleimansh
suleimansh merged commit 309a0d7 into main Jul 26, 2026
1 of 2 checks passed
@suleimansh
suleimansh deleted the fix/1225-cc-web-handoff branch July 26, 2026 14:27
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Choices no working with CC web

1 participant