Skip to content

Commit abc513e

Browse files
suleimanshclaude
andauthored
Web runs: the Chrome extension creates the cloud session through claude.ai's repo picker (#1328) (#1693)
* Web runs: the Chrome extension creates the cloud session through claude.ai's repo picker (#1328) A session created through the page's repository picker is repo-bound and can push and open its pull request; `claude --cloud` has, on some accounts, produced a bundle upload that never could (#1320). So a web run now asks its daemon for an extension-created session first. Daemon: the session start-queue (`bridge-starts.ts`, re-landed from #1330) behind two faces on the one token — the extension's `GET /_bridge/start` (claim-on-read) + `POST /_bridge/started`, and the run's `POST /_web-start` + `GET /_web-start/<id>` (`web-start-endpoints.ts`), which answers 409 at once when no extension has called within three minutes. The daemon hands every spawned run its own URL in `TF_DAEMON_URL`. Driver: `CloudDriver` takes `extension: { daemonUrl, token }` (wired by the CLI from the env and the registry token); after the anchor push and trust check it posts the request — repo from the GitHub remote, the pushed ref, the whole hand-off prompt — and polls until created, then reports exactly as the CLI path does. 409/404, no GitHub remote or an unpushed ref fall back to the CLI's cloud mode with a notice; an extension that tried and failed fails the turn with its note. Extension 0.11.0 (daemon lockstep): the worker claims requests on the answer beat, one at a time, opens claude.ai/code pinned and inactive, and the content script drives the repo chip's searchable list, verifies the branch chip reads the pushed ref before sending, types the prompt, sends, and reads the session id off the address. A created session's tab is a watched tab; a failed attempt's tab is closed and the note says what the page lacked. Harness cases cover the drive, the wrong-branch refusal and the missing-picker report. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extension: wait for the repo chip or picker to render, and treat a remembered repo's chip as the picker (#1328 dogfood 1) First live attempt: the page had remembered the last repo picked, so its chips (the-framework / main, as comboboxes) were already there — but they render after the composer, and createSession read the page before they did, then looked for a "Select repo" trigger that no longer exists once a repo is remembered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extension: drive the new-session page as it actually is — combobox chips in order, visible options only, React-safe search (#1328 dogfood 2) Run 2 clicked the repo chip itself as if it were a list entry (its text is the bare repo name) and then found no chip "afterwards". Read from the live page: the chips are combobox buttons in the order repo, branch, add; a picker is a dialog with a role=combobox search input and a listbox of options; a closed picker's options stay in the DOM, so only visible options are entries, and the trigger is never one. The branch list does offer a ref pushed seconds earlier (checked live), so verifying the chip before sending is enough. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extension: wait for the picker's list to load before filtering it, fall back to the whole list, and say what the list showed on failure (#1328 dogfood 3) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Harness: a list that already holds the entry is not filtered first Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extension: read chip and entry labels through the page's icon-font glyphs (#1328 dogfood 4) In the content script's world the labels carry private-use glyphs — the chips read "\ue048the-framework" and "\ue078main", the chosen entry "framework/the-framework\ue03b" — so no exact match ever fired. The mirror already strips those; the control labels now do too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 61ad1eb commit abc513e

36 files changed

Lines changed: 1530 additions & 18 deletions

FEATURES-SPEC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ happens while nobody is at the keyboard.
165165
- Saved remote devices: run an agent on another machine's daemon — dashboard here, agents there
166166
- Run on a fresh GitHub Actions runner
167167
- Run on a Claude Code cloud session
168+
- A web run's cloud session is created by the Chrome extension in your own browser, through claude.ai's repository picker — repo-bound, so it can push and open its pull request; the CLI's cloud mode only when no extension is around
168169
- Chrome extension bridging claude.ai questions back to the dashboard
169170
- A cloud session's conversation mirrored into the run view, turn by turn, as it is written
170171
- Answer a cloud agent's question from the dashboard (typed back into claude.ai) — the same gate panel a local agent gets, multi-select and stop options included, listed with every other open question

packages/chrome-extension/SPEC.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Four parts: the content script (the page half — reads claude.ai, types answers
2323
- **A stranded question's round trip** - the content script extracts the parked question from the page, the service worker reports it to the daemon, the dashboard shows it as an ordinary gate; the answer is queued, collected, typed into the composer, submitted, and the outcome acknowledged.
2424
- **The question crosses whole, so it is answered like a local one** - the block's own shape travels with it, which is what lets the dashboard offer the recommended option, several answers at once, and a pick that hands the session back.
2525
- **Only what the session offered** - the daemon composes what is typed out of labels of the parked question's own options and nothing else, an answer stays withdrawable until the extension collects it, and the extension otherwise only observes.
26+
- **It creates the sessions the daemon asks for** - a web run's cloud session is created here, on claude.ai's own new-session page with the repository and branch the run named, so it is bound to the repository and can push and open its pull request; the branch is verified before anything is sent.
2627
- **Tabs nobody has to think about** - the daemon publishes which cloud sessions to watch; the extension keeps one pinned, inactive tab per session (opt-in), closes its own stale tabs, and never reopens one the user closed.
2728
- **The trust boundary** - the bridge token and all daemon traffic live in the service worker; the content script, which shares its tab with claude.ai, holds no secret and calls no daemon.
2829
- **Version lockstep** - every daemon call states the extension's version, and a daemon expecting another refuses it outright, naming both versions; the two halves must ship the same number.
@@ -50,6 +51,20 @@ See `## User story`, second and third items.
5051

5152
Three properties bound the write path. The daemon refuses to queue an answer unless every label picked is one of the parked question's own options — exactly one of them unless the question allows several — and it composes the text to be typed itself, so the only thing the bridge can ever put in a composer is built from what the session offered, never free text from the browser. An answer stays withdrawable until the extension collects it, and that window is the only time withdrawing means anything. And the extension acts only on delivery: everything else it does is read-only, and its one manual write control — a "Fill composer (does not send)" button on its in-page panel — fills without submitting, proving the write path exists without the extension ever speaking for the user.
5253

54+
### It creates the sessions the daemon asks for
55+
56+
#### User story
57+
58+
A web run wants a cloud session that can push its work and open a pull request. Such a session is created through claude.ai's repository picker, in the user's own signed-in browser — which is exactly where this extension runs.
59+
60+
#### Business logic
61+
62+
The service worker asks the daemon for the next session to create, at the same beat it looks for answers. Given one, it opens claude.ai's new-session page in a pinned, inactive tab and hands the request — repository, branch, prompt — to the content script there, which chooses the repository, makes sure the branch chip reads the requested branch, types the prompt and sends. The session id is read from the page's address once it becomes a session, and reported back to the daemon along with a note of what was clicked; a failure reports what the page lacked instead. One creation runs at a time. A created session's tab is kept as a watched tab like any other; a failed attempt's tab is closed.
63+
64+
#### Rationale
65+
66+
The session is created through the same controls a person would use, in their own browser, on their own account. Every selector is a guess about a page that is not ours, so the page half reports what it saw rather than insisting — the first failure is meant to be diagnosable from the run's log.
67+
5368
### Tabs nobody has to think about
5469

5570
#### User story

packages/chrome-extension/background.SPEC.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ A `web`-target agent hands its task to a cloud session and ends; nothing streams
2121
- **Tabs open themselves for watched sessions** - one pinned, inactive tab per cloud session the daemon lists, so the bridge does not depend on the user happening to be on claude.ai.
2222
- **A tab the user closed is not reopened** - closing the tab for a session dismisses that one session, and only that one.
2323
- **Tabs the extension opened are also closed by it** - once the daemon stops watching a session, its tab goes, so the browser does not accumulate pinned tabs forever.
24+
- **Sessions are created one at a time** - the worker claims the daemon's next session request on the answer beat, opens the new-session page in its own pinned tab, has the content script drive it, and reports the session it became; a created session's tab becomes a watched tab, a failed attempt's tab is closed, and a second request waits for the first to finish.
2425
- **Every attempt records why it did nothing** - the outcome of the last tab sweep is kept so the options page can state the reason instead of leaving the user guessing.
2526

2627
## Business logic
@@ -149,6 +150,20 @@ A user who leaves the browser running for days should not end up with a pinned t
149150

150151
At the end of every sweep, tabs this extension opened for sessions the daemon no longer lists are closed. Only tabs the extension opened: a claude.ai session the user opened themselves is theirs to keep.
151152

153+
### Creating the session the daemon asked for
154+
155+
#### User story
156+
157+
A web run is waiting on the daemon for a cloud session that can push its work; the only thing that can create one is this extension, in the user's browser.
158+
159+
#### Business logic
160+
161+
On the answer beat, and once at start, the worker asks the daemon for the next session request. Given one, and none already in progress, it opens claude.ai's new-session page in a pinned, inactive tab, waits for the page to load, and hands the request to the content script there — retrying for a while, since the script may still be being injected. The content script's outcome is reported to the daemon under the request's id: success with the session id, or failure with the note of what the page lacked. A created session's tab is recorded as one the extension opened for that session, so it is watched and eventually closed like any other; a failed attempt's tab is closed at once. The outcome is kept where the options page can show it.
162+
163+
#### Rationale
164+
165+
Creation navigates a page, so two at once would race each other's controls; serial creation makes a fan-out of several runs a matter of throughput, not correctness. A report that fails to reach the daemon is not retried here: the daemon's claim expires on its own and the request is offered again.
166+
152167
### Waking up on a schedule rather than on a timer
153168

154169
#### User story

packages/chrome-extension/background.js

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,100 @@ async function pollAnswers() {
182182
await deliverAnswers(sessions.map(s => s?.id).filter(Boolean))
183183
}
184184

185+
// ---------------------------------------------------------------------------
186+
// Creating sessions (#1328): the daemon queues a repo, a branch and a prompt; this claims the
187+
// next one, opens the new-session page in a pinned tab, has the content script drive it, and
188+
// reports the session it became. One at a time: creation navigates a page, so two at once would
189+
// race each other's chips.
190+
191+
/** A creation in flight, so a poll landing mid-way does not start a second tab. */
192+
let creating = false
193+
194+
/** How long to give the new-session page to load and its content script to answer. */
195+
const NEW_SESSION_URL = 'https://claude.ai/code'
196+
const TAB_LOAD_MS = 30_000
197+
const SCRIPT_RETRIES = 10
198+
199+
/** Resolve once the tab reports `complete`, or after {@link TAB_LOAD_MS}. */
200+
function tabLoaded(tabId) {
201+
return new Promise(resolve => {
202+
const done = () => {
203+
chrome.tabs.onUpdated.removeListener(listener)
204+
resolve()
205+
}
206+
const listener = (id, info) => {
207+
if (id === tabId && info.status === 'complete') done()
208+
}
209+
chrome.tabs.onUpdated.addListener(listener)
210+
setTimeout(done, TAB_LOAD_MS)
211+
})
212+
}
213+
214+
/** Hand the request to the content script, retrying while it is still being injected. */
215+
async function askPage(tabId, start) {
216+
let lastErr
217+
for (let i = 0; i < SCRIPT_RETRIES; i++) {
218+
try {
219+
const outcome = await chrome.tabs.sendMessage(tabId, { type: 'tf-create-session', start })
220+
if (outcome) return outcome
221+
} catch (err) {
222+
lastErr = err
223+
}
224+
await new Promise(resolve => setTimeout(resolve, 2000))
225+
}
226+
return { ok: false, note: `the new-session page never answered: ${String(lastErr?.message ?? lastErr ?? 'no reply')}` }
227+
}
228+
229+
async function pollStarts() {
230+
if (creating) return
231+
const { daemonUrl, token } = await chrome.storage.local.get(['daemonUrl', 'token'])
232+
if (!token) return
233+
const base = (daemonUrl || DEFAULT_DAEMON).replace(/\/+$/, '')
234+
let start
235+
try {
236+
const res = await fetch(`${base}/_bridge/start`, { headers: { authorization: `Bearer ${token}`, ...VERSION_HEADER } })
237+
if (!res.ok) return
238+
start = (await res.json())?.start
239+
} catch {
240+
return
241+
}
242+
if (!start?.id || typeof start.repo !== 'string' || typeof start.branch !== 'string' || typeof start.prompt !== 'string') return
243+
244+
creating = true
245+
let tab
246+
let outcome
247+
try {
248+
tab = await chrome.tabs.create({ url: NEW_SESSION_URL, active: false, pinned: true })
249+
await tabLoaded(tab.id)
250+
outcome = await askPage(tab.id, { repo: start.repo, branch: start.branch, prompt: start.prompt })
251+
} catch (err) {
252+
outcome = { ok: false, note: `could not drive a new-session tab: ${String(err?.message ?? err)}` }
253+
}
254+
const ok = Boolean(outcome?.ok && outcome?.sessionId)
255+
if (tab?.id != null) {
256+
// A created session's tab is now a watched tab like any other; a failed attempt's tab goes,
257+
// its note carries what the page looked like.
258+
if (ok) await chrome.storage.local.set({ openedTabs: { ...(await openedTabs()), [tab.id]: outcome.sessionId } })
259+
else await chrome.tabs.remove(tab.id).catch(() => {})
260+
}
261+
try {
262+
await fetch(`${base}/_bridge/started`, {
263+
method: 'POST',
264+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}`, ...VERSION_HEADER },
265+
body: JSON.stringify({
266+
id: start.id,
267+
ok,
268+
...(ok ? { sessionId: outcome.sessionId } : {}),
269+
...(outcome?.note ? { note: String(outcome.note).slice(0, 1500) } : {}),
270+
}),
271+
})
272+
} catch {
273+
// The claim expires on the daemon, and the request is offered again.
274+
}
275+
await note({ ok, reason: ok ? `created ${outcome.sessionId}` : `session creation failed: ${outcome?.note ?? 'unknown'}` })
276+
creating = false
277+
}
278+
185279
/** One authenticated POST to the daemon. */
186280
async function post(path, body) {
187281
const { daemonUrl, token } = await chrome.storage.local.get(['daemonUrl', 'token'])
@@ -342,7 +436,12 @@ chrome.alarms.create('tf-sessions', { periodInMinutes: SESSION_POLL_MINUTES })
342436
chrome.alarms.create('tf-answers', { periodInMinutes: ANSWER_POLL_MINUTES })
343437
chrome.alarms.onAlarm.addListener(alarm => {
344438
if (alarm.name === 'tf-sessions') void openWatchedTabs()
345-
if (alarm.name === 'tf-answers') void pollAnswers()
439+
if (alarm.name === 'tf-answers') {
440+
void pollAnswers()
441+
// Session requests ride the same fast beat: a run is waiting on the other end of one.
442+
void pollStarts()
443+
}
346444
})
347445
void openWatchedTabs()
348446
void pollAnswers()
447+
void pollStarts()

packages/chrome-extension/check.SPEC.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ Typing the dashboard's answer back into the session:
2727
- With no send button on the page, the answer is submitted with an Enter keypress instead.
2828
- A page with no composer is refused with that as the stated reason, rather than the text being typed somewhere else.
2929

30+
Creating a session:
31+
32+
- On a synthetic new-session page built like the live one was observed to be — combobox chips for the repository and the branch, each opening a searchable list of options, a composer and a send button that turns the page into a session address — the branch is chosen, the prompt typed, send clicked, and the new session's id reported, in each of the three states the page opens in: the requested repository already remembered, another repository remembered (re-picked through its chip), and none remembered (picked through the select-repository control).
33+
- When the branch list does not offer the requested branch, nothing is sent and the outcome names the branch.
34+
- A page with no repository picker is refused naming that control, and the probe describes the page's controls without touching them.
35+
3036
The panel: it folds down to a compact "TF" tab, dropping its rows and its full title, and unfolds with the question's details intact.
3137

3238
## Rationale

0 commit comments

Comments
 (0)