diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md
index 5c904d10..6bbb12cf 100644
--- a/FEATURES-SPEC.md
+++ b/FEATURES-SPEC.md
@@ -165,6 +165,7 @@ happens while nobody is at the keyboard.
- Saved remote devices: run an agent on another machine's daemon — dashboard here, agents there
- Run on a fresh GitHub Actions runner
- Run on a Claude Code cloud session
+- 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
- Chrome extension bridging claude.ai questions back to the dashboard
- A cloud session's conversation mirrored into the run view, turn by turn, as it is written
- 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
diff --git a/packages/chrome-extension/SPEC.md b/packages/chrome-extension/SPEC.md
index 3640cb64..4c3cef23 100644
--- a/packages/chrome-extension/SPEC.md
+++ b/packages/chrome-extension/SPEC.md
@@ -23,6 +23,7 @@ Four parts: the content script (the page half — reads claude.ai, types answers
- **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.
- **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.
- **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.
+- **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.
- **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.
- **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.
- **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.
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.
+### It creates the sessions the daemon asks for
+
+#### User story
+
+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.
+
+#### Business logic
+
+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.
+
+#### Rationale
+
+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.
+
### Tabs nobody has to think about
#### User story
diff --git a/packages/chrome-extension/background.SPEC.md b/packages/chrome-extension/background.SPEC.md
index b69b691c..56d5d9da 100644
--- a/packages/chrome-extension/background.SPEC.md
+++ b/packages/chrome-extension/background.SPEC.md
@@ -21,6 +21,7 @@ A `web`-target agent hands its task to a cloud session and ends; nothing streams
- **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.
- **A tab the user closed is not reopened** - closing the tab for a session dismisses that one session, and only that one.
- **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.
+- **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.
- **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.
## Business logic
@@ -149,6 +150,20 @@ A user who leaves the browser running for days should not end up with a pinned t
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.
+### Creating the session the daemon asked for
+
+#### User story
+
+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.
+
+#### Business logic
+
+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.
+
+#### Rationale
+
+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.
+
### Waking up on a schedule rather than on a timer
#### User story
diff --git a/packages/chrome-extension/background.js b/packages/chrome-extension/background.js
index dd1c3c24..decad652 100644
--- a/packages/chrome-extension/background.js
+++ b/packages/chrome-extension/background.js
@@ -182,6 +182,100 @@ async function pollAnswers() {
await deliverAnswers(sessions.map(s => s?.id).filter(Boolean))
}
+// ---------------------------------------------------------------------------
+// Creating sessions (#1328): the daemon queues a repo, a branch and a prompt; this claims the
+// next one, opens the new-session page in a pinned tab, has the content script drive it, and
+// reports the session it became. One at a time: creation navigates a page, so two at once would
+// race each other's chips.
+
+/** A creation in flight, so a poll landing mid-way does not start a second tab. */
+let creating = false
+
+/** How long to give the new-session page to load and its content script to answer. */
+const NEW_SESSION_URL = 'https://claude.ai/code'
+const TAB_LOAD_MS = 30_000
+const SCRIPT_RETRIES = 10
+
+/** Resolve once the tab reports `complete`, or after {@link TAB_LOAD_MS}. */
+function tabLoaded(tabId) {
+ return new Promise(resolve => {
+ const done = () => {
+ chrome.tabs.onUpdated.removeListener(listener)
+ resolve()
+ }
+ const listener = (id, info) => {
+ if (id === tabId && info.status === 'complete') done()
+ }
+ chrome.tabs.onUpdated.addListener(listener)
+ setTimeout(done, TAB_LOAD_MS)
+ })
+}
+
+/** Hand the request to the content script, retrying while it is still being injected. */
+async function askPage(tabId, start) {
+ let lastErr
+ for (let i = 0; i < SCRIPT_RETRIES; i++) {
+ try {
+ const outcome = await chrome.tabs.sendMessage(tabId, { type: 'tf-create-session', start })
+ if (outcome) return outcome
+ } catch (err) {
+ lastErr = err
+ }
+ await new Promise(resolve => setTimeout(resolve, 2000))
+ }
+ return { ok: false, note: `the new-session page never answered: ${String(lastErr?.message ?? lastErr ?? 'no reply')}` }
+}
+
+async function pollStarts() {
+ if (creating) return
+ const { daemonUrl, token } = await chrome.storage.local.get(['daemonUrl', 'token'])
+ if (!token) return
+ const base = (daemonUrl || DEFAULT_DAEMON).replace(/\/+$/, '')
+ let start
+ try {
+ const res = await fetch(`${base}/_bridge/start`, { headers: { authorization: `Bearer ${token}`, ...VERSION_HEADER } })
+ if (!res.ok) return
+ start = (await res.json())?.start
+ } catch {
+ return
+ }
+ if (!start?.id || typeof start.repo !== 'string' || typeof start.branch !== 'string' || typeof start.prompt !== 'string') return
+
+ creating = true
+ let tab
+ let outcome
+ try {
+ tab = await chrome.tabs.create({ url: NEW_SESSION_URL, active: false, pinned: true })
+ await tabLoaded(tab.id)
+ outcome = await askPage(tab.id, { repo: start.repo, branch: start.branch, prompt: start.prompt })
+ } catch (err) {
+ outcome = { ok: false, note: `could not drive a new-session tab: ${String(err?.message ?? err)}` }
+ }
+ const ok = Boolean(outcome?.ok && outcome?.sessionId)
+ if (tab?.id != null) {
+ // A created session's tab is now a watched tab like any other; a failed attempt's tab goes,
+ // its note carries what the page looked like.
+ if (ok) await chrome.storage.local.set({ openedTabs: { ...(await openedTabs()), [tab.id]: outcome.sessionId } })
+ else await chrome.tabs.remove(tab.id).catch(() => {})
+ }
+ try {
+ await fetch(`${base}/_bridge/started`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}`, ...VERSION_HEADER },
+ body: JSON.stringify({
+ id: start.id,
+ ok,
+ ...(ok ? { sessionId: outcome.sessionId } : {}),
+ ...(outcome?.note ? { note: String(outcome.note).slice(0, 1500) } : {}),
+ }),
+ })
+ } catch {
+ // The claim expires on the daemon, and the request is offered again.
+ }
+ await note({ ok, reason: ok ? `created ${outcome.sessionId}` : `session creation failed: ${outcome?.note ?? 'unknown'}` })
+ creating = false
+}
+
/** One authenticated POST to the daemon. */
async function post(path, body) {
const { daemonUrl, token } = await chrome.storage.local.get(['daemonUrl', 'token'])
@@ -342,7 +436,12 @@ chrome.alarms.create('tf-sessions', { periodInMinutes: SESSION_POLL_MINUTES })
chrome.alarms.create('tf-answers', { periodInMinutes: ANSWER_POLL_MINUTES })
chrome.alarms.onAlarm.addListener(alarm => {
if (alarm.name === 'tf-sessions') void openWatchedTabs()
- if (alarm.name === 'tf-answers') void pollAnswers()
+ if (alarm.name === 'tf-answers') {
+ void pollAnswers()
+ // Session requests ride the same fast beat: a run is waiting on the other end of one.
+ void pollStarts()
+ }
})
void openWatchedTabs()
void pollAnswers()
+void pollStarts()
diff --git a/packages/chrome-extension/check.SPEC.md b/packages/chrome-extension/check.SPEC.md
index 3214da29..6a82b60b 100644
--- a/packages/chrome-extension/check.SPEC.md
+++ b/packages/chrome-extension/check.SPEC.md
@@ -27,6 +27,12 @@ Typing the dashboard's answer back into the session:
- With no send button on the page, the answer is submitted with an Enter keypress instead.
- A page with no composer is refused with that as the stated reason, rather than the text being typed somewhere else.
+Creating a session:
+
+- 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).
+- When the branch list does not offer the requested branch, nothing is sent and the outcome names the branch.
+- A page with no repository picker is refused naming that control, and the probe describes the page's controls without touching them.
+
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.
## Rationale
diff --git a/packages/chrome-extension/check.mjs b/packages/chrome-extension/check.mjs
index b8a2c3a2..5f516d7d 100644
--- a/packages/chrome-extension/check.mjs
+++ b/packages/chrome-extension/check.mjs
@@ -308,5 +308,142 @@ async function deliver(body, prepare) {
dom.window.close()
}
+// ---------------------------------------------------------------------------
+// Creating a session (#1328): the new-session page's repo chip opens a searchable list, a branch
+// chip appears beside the chosen repo, the prompt goes into the composer, send turns the page
+// into a session URL. The synthetic page behaves the way the live one was observed to, and what
+// these prove is the flow around it: the right chip is opened, the branch is verified before
+// anything is sent, and a page that cannot be driven says which control it lacked.
+
+function newSessionPage({ branches = ['main', 'cloud-1-abcd'], remembered = 'the-framework', repoPicker = true, glyphs = false } = {}) {
+ // Mirrors the live page as observed 2026-08-24: chips are combobox buttons in the order repo,
+ // branch, add; a picker is a dialog holding a search input (role combobox) and a listbox of
+ // options; a closed picker's options stay in the DOM.
+ const dom = new JSDOM(
+ `
`
+ dialog.querySelector('input').addEventListener('input', e => seen.searched.push(e.target.value))
+ for (const opt of dialog.querySelectorAll('[role="option"]')) opt.addEventListener('click', () => { dialog.remove(); onPick(opt.textContent.replace(/[\uE000-\uF8FF]/g, '')) })
+ d.body.append(dialog)
+ }
+ const ensureBranchChip = () => {
+ let chip = d.getElementById('branch')
+ if (!chip) {
+ chip = d.createElement('button')
+ chip.setAttribute('role', 'combobox')
+ chip.id = 'branch'
+ chip.textContent = 'main'
+ d.getElementById('repo').after(chip)
+ }
+ chip.onclick = () => openList('Search branches…', branches, b => { chip.textContent = b })
+ }
+ const wireRepo = chip => {
+ chip.onclick = () => openList('Search repos…', ['brillout/docpress', 'framework/the-framework'], picked => {
+ chip.textContent = picked.split('/').pop()
+ chip.setAttribute('role', 'combobox')
+ chip.id = 'repo'
+ ensureBranchChip()
+ })
+ }
+ if (d.getElementById('repo')) { wireRepo(d.getElementById('repo')); ensureBranchChip() }
+ if (d.getElementById('select')) wireRepo(d.getElementById('select'))
+ d.getElementById('send').addEventListener('click', () => {
+ seen.sent = true
+ w.history.pushState({}, '', '/code/session_01NEW')
+ })
+ w.__tfComposerWaitMs = 1000
+ w.__tfMenuSettleMs = 10
+ w.__tfSessionWaitMs = 2000
+ w.eval(script)
+ return { dom, w, d, seen }
+}
+
+const START = { repo: 'framework/the-framework', branch: 'cloud-1-abcd', prompt: 'Add the thing' }
+
+{
+ // The page remembered our repo: nothing to pick but the branch.
+ const { dom, w, d, seen } = newSessionPage()
+ const result = await w.__tfBridgeCreateSession(START)
+ const branch = d.getElementById('branch')?.textContent
+ const text = d.querySelector('[contenteditable="true"]').textContent
+ const ok = result.ok && result.sessionId === 'session_01NEW' && /repo already the-framework/.test(result.note) && branch === 'cloud-1-abcd' && text === 'Add the thing' && seen.sent
+ if (!ok) failed++
+ console.log(`${ok ? 'PASS' : 'FAIL'} create with the repo remembered picks the branch, types the prompt and sends (branch=${branch}, searched=${JSON.stringify(seen.searched)}, result=${JSON.stringify(result)})`)
+ dom.window.close()
+}
+
+{
+ // As the live page renders in the content script's world: icon-font glyphs beside every label
+ // and a check glyph on the chosen entry (dogfood 4).
+ const { dom, w, d, seen } = newSessionPage({ glyphs: true, remembered: 'docpress' })
+ const result = await w.__tfBridgeCreateSession(START)
+ const repo = d.getElementById('repo')?.textContent.replace(/[\uE000-\uF8FF]/g, '')
+ const branch = d.getElementById('branch')?.textContent.replace(/[\uE000-\uF8FF]/g, '')
+ const ok = result.ok && repo === 'the-framework' && branch === 'cloud-1-abcd' && seen.sent
+ if (!ok) failed++
+ console.log(`${ok ? 'PASS' : 'FAIL'} create reads labels through the page's icon glyphs (repo=${repo}, branch=${branch}, result=${JSON.stringify(result)})`)
+ dom.window.close()
+}
+
+{
+ // The page remembered another repo: its chip is the picker.
+ const { dom, w, d, seen } = newSessionPage({ remembered: 'docpress' })
+ const result = await w.__tfBridgeCreateSession(START)
+ const repo = d.getElementById('repo')?.textContent
+ const branch = d.getElementById('branch')?.textContent
+ const ok = result.ok && repo === 'the-framework' && branch === 'cloud-1-abcd' && seen.sent && /repo: clicked "framework\/the-framework"/.test(result.note)
+ if (!ok) failed++
+ console.log(`${ok ? 'PASS' : 'FAIL'} create with another repo remembered re-picks it through its chip (repo=${repo}, branch=${branch}, result=${JSON.stringify(result)})`)
+ dom.window.close()
+}
+
+{
+ // Nothing remembered: the bare "Select repo" trigger.
+ const { dom, w, d, seen } = newSessionPage({ remembered: '' })
+ const result = await w.__tfBridgeCreateSession(START)
+ const repo = d.getElementById('repo')?.textContent
+ const ok = result.ok && repo === 'the-framework' && seen.sent
+ if (!ok) failed++
+ console.log(`${ok ? 'PASS' : 'FAIL'} create with no repo remembered uses the select-repo trigger (repo=${repo}, result=${JSON.stringify(result)})`)
+ dom.window.close()
+}
+
+{
+ // The branch list does not offer the pushed ref: nothing is sent, and the note says so.
+ const { dom, w, seen } = newSessionPage({ branches: ['main', 'develop'] })
+ const result = await w.__tfBridgeCreateSession(START)
+ const ok = !result.ok && /branch/.test(result.note) && !seen.sent
+ if (!ok) failed++
+ console.log(`${ok ? 'PASS' : 'FAIL'} create refuses to send on the wrong branch (sent=${seen.sent}, note=${JSON.stringify(result.note)})`)
+ dom.window.close()
+}
+
+{
+ const { dom, w, seen } = newSessionPage({ remembered: '', repoPicker: false })
+ const result = await w.__tfBridgeCreateSession(START)
+ const ok = !result.ok && /no repo picker/.test(result.note) && !seen.sent
+ if (!ok) failed++
+ console.log(`${ok ? 'PASS' : 'FAIL'} create names the missing control (note=${result.note.slice(0, 80)}…)`)
+ const probe = w.__tfBridgeProbeNewSession()
+ const probeOk = probe.composer === 'contenteditable' && probe.sendButton === true && probe.triggers.some(t => t.text === 'Default')
+ if (!probeOk) failed++
+ console.log(`${probeOk ? 'PASS' : 'FAIL'} probe describes the page without touching it (${JSON.stringify(probe.triggers)})`)
+ dom.window.close()
+}
+
console.log(failed ? `\n${failed} case(s) failed` : '\nall cases passed')
process.exit(failed ? 1 : 0)
diff --git a/packages/chrome-extension/content.SPEC.md b/packages/chrome-extension/content.SPEC.md
index aef66bb6..4b3ab630 100644
--- a/packages/chrome-extension/content.SPEC.md
+++ b/packages/chrome-extension/content.SPEC.md
@@ -22,6 +22,7 @@ A `web`-target agent hands its task to a cloud session and ends, so when that se
- **An answer is typed only into a composer that exists** - the composer is waited for, filled, given a beat to settle, then submitted by the page's own send button, or by Enter when there is none.
- **Only the top frame delivers an answer** - a child frame answering too would submit the same text twice.
- **The panel says which step failed** - what was found, where, and what the daemon said, with structure-only counters when nothing was found.
+- **A session is created through the page's own controls** - the composer's chips (repository, then branch), each opening a searchable list; the page remembers the last repository picked, so the chips are waited for and read rather than assumed; the branch chip must read the requested branch before anything is sent, the session id is read from the address the page becomes, and a failure names the control that was missing.
- **It watches the page rather than polling it** - the session's own changes trigger a re-read immediately; a slow heartbeat is only a backstop.
## Business logic
@@ -124,6 +125,20 @@ Only the page's top frame accepts an answer.
This is the one place the extension acts on the user's behalf instead of observing, so it says exactly what it did. The text it can type is bounded twice over: it comes only from the daemon, and the daemon composes it out of the options belonging to the question the session is currently parked on — this half neither writes nor edits any of it. The composer wait exists because the first live delivery landed right after a tab was reloaded, and the page takes well over a few seconds to render — "no composer on the page" almost always means "not yet". Only the top frame answers because a child frame answering as well would submit the same text a second time.
+### Creating a session
+
+#### User story
+
+The daemon wants a cloud session opened on a given repository and branch with a given prompt, and it must be one that can push: the kind the new-session page's repository picker creates.
+
+#### Business logic
+
+On the new-session page, the composer is waited for, then the chips beside it, which render a beat later: the page remembers the last repository picked, so it may open already showing the requested repository (nothing to pick), another one (that chip is the picker), or none (a bare select-repository control is). When picking, the picker is opened, the full `owner/name` is typed into its search box, and the entry whose text is exactly the repository is clicked — only entries the open list actually shows, since a closed list's entries linger on the page, and never the chip itself — after which the repository chip must read the repository. The branch chip beside it is then read: if it does not already read the requested branch, it is opened and the branch chosen the same way, and the chip is read again; when it still does not read the requested branch, nothing is sent and the outcome says so. The prompt is then typed into the composer and sent by the page's send button, or by Enter when there is none. The outcome is a success only once the page's address names a session, and it carries the session id and a note of what was clicked; every failure names the control that could not be found, and a probe describes the page's controls without touching them so a failed first run is diagnosable.
+
+#### Rationale
+
+A session opened on the wrong branch would push its work somewhere the run never looks, which is why the branch is verified rather than assumed. The session id comes from the address because it is the one thing the page is guaranteed to expose, and it is exactly what the daemon joins runs on.
+
### The bridge panel
#### User story
diff --git a/packages/chrome-extension/content.js b/packages/chrome-extension/content.js
index b8e7793e..10fb5500 100644
--- a/packages/chrome-extension/content.js
+++ b/packages/chrome-extension/content.js
@@ -469,10 +469,242 @@ async function deliverAnswer(text) {
return { ok: true, note: `filled ${composer.via}, no send button, sent Enter` }
}
-// The worker hands answers to the top frame only: the composer lives there, and a child frame
-// answering too would submit twice.
+// ---------------------------------------------------------------------------
+// Creating a session (#1328). A session created through this page's repo picker is repo-bound,
+// and those are the ones that can push and open a pull request; `claude --cloud` on some accounts
+// produces a bundle upload that cannot (#1320). So the daemon queues a repo, a branch and a prompt,
+// and this drives the same controls a person would on the new-session page: the repo chip and
+// its searchable list, the branch chip that appears beside it, the composer, send.
+//
+// Every selector here is a guess about someone else's UI, so this half reports rather than
+// insists: `probeNewSession` describes what the page offers without touching it, and every
+// failure below names the control it could not find.
+
+/**
+ * Visible text of a control, for matching chips and entries: the icon-font glyphs the page puts
+ * beside a label (a repo icon, a branch icon, the check on the chosen entry) are dropped, since
+ * "framework/the-framework" followed by a check glyph is the entry, not another entry.
+ */
+function controlText(el) {
+ return (el.textContent ?? '').replace(ICON_GLYPHS, '').replace(/\s+/g, ' ').trim()
+}
+
+/**
+ * Whether a control is actually usable: rendered, enabled, not aria-hidden. The page keeps a
+ * closed picker's entries in the DOM, so an entry that is not visible is not on offer — a browser
+ * answers that exactly; the offline harness has no layout and answers nothing, which counts as yes.
+ */
+function usable(el) {
+ if (!el || el.disabled) return false
+ if (el.getAttribute?.('aria-hidden') === 'true') return false
+ if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false
+ return true
+}
+
+/** Every button-like control on the page, most explicit picker shapes first. */
+function menuTriggers() {
+ const out = []
+ const add = (el, via) => {
+ if (usable(el) && !out.some(entry => entry.el === el)) out.push({ el, via, text: controlText(el) })
+ }
+ for (const el of deepQueryAll('button[role="combobox"]')) add(el, 'combobox')
+ for (const el of deepQueryAll('button[aria-haspopup="listbox"], button[aria-haspopup="menu"], button[aria-haspopup="true"]')) add(el, 'haspopup')
+ for (const el of deepQueryAll('button[aria-expanded]')) add(el, 'expandable')
+ for (const el of deepQueryAll('button')) add(el, 'button')
+ return out
+}
+
+/**
+ * The composer's chips, in the order the page lays them out: the repository, then the branch,
+ * then "add another repository". Each is a combobox button; the branch chip exists only once a
+ * repository is chosen.
+ */
+function chips() {
+ return deepQueryAll('button[role="combobox"]').filter(usable).map(el => ({ el, via: 'combobox', text: controlText(el) }))
+}
+
+/** The entries an open picker currently offers — visible options only, the closed ones stay in the DOM. */
+function menuEntries() {
+ return deepQueryAll('[role="option"]')
+ .filter(usable)
+ .map(el => ({ el, text: controlText(el) }))
+}
+
+/** The open picker's search box, if it has one. */
+function pickerSearch() {
+ return deepQueryAll('input[role="combobox"], input[type="search"], input[placeholder*="search" i]').filter(usable).at(-1)
+}
+
+/** Type into a React-controlled input so the page sees the change: the native setter, then an input event. */
+function typeInto(input, text) {
+ input.focus()
+ const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value')?.set ?? Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (setter) setter.call(input, text)
+ else input.value = text
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+}
+
+/** Wait for `read` to return something truthy, or give up. */
+async function waitFor(read, timeoutMs, stepMs = 150) {
+ const deadline = Date.now() + timeoutMs
+ for (;;) {
+ const value = read()
+ if (value) return value
+ if (Date.now() >= deadline) return undefined
+ await new Promise(resolve => setTimeout(resolve, stepMs))
+ }
+}
+
+/** How long to wait for a picker to render its entries, and for the chips to settle. */
+const MENU_WAIT_MS = 6000
+
+/**
+ * Open `trigger` and choose the entry whose text is one of `wanted` (lower-cased; the first is
+ * the full name, later ones aliases). Types the full name into the picker's search box when it
+ * has one, since a long list is filtered rather than scrolled. Never clicks the trigger itself
+ * or anything inside it as an entry. Returns a note either way.
+ */
+async function chooseFrom(trigger, wanted, hint) {
+ trigger.el.click()
+ const outside = e => e.el !== trigger.el && !trigger.el.contains(e.el)
+ const entries = () => menuEntries().filter(outside)
+ const find = () => {
+ const now = entries()
+ for (const w of wanted) {
+ const exact = now.find(e => e.text.toLowerCase() === w)
+ if (exact) return exact
+ }
+ return undefined
+ }
+ // The list is fetched after the picker opens: wait for it to hold something before filtering
+ // it, and filter only when it has a search box. A filter that finds nothing is cleared and the
+ // whole list scanned, in case the page's search matches differently than expected.
+ const search = await waitFor(pickerSearch, 1500, 100)
+ const loaded = await waitFor(() => entries().length > 0, MENU_WAIT_MS)
+ let hit = loaded ? find() : undefined
+ if (!hit && search) {
+ typeInto(search, wanted[0])
+ hit = await waitFor(find, 3000)
+ if (!hit) {
+ typeInto(search, '')
+ hit = await waitFor(find, 3000)
+ }
+ }
+ if (!hit) {
+ const seen = entries()
+ const diag = `search ${search ? `"${search.placeholder}"` : 'none'}, ${seen.length} visible of ${deepQueryAll('[role="option"]').length} options${seen.length ? `: ${seen.slice(0, 5).map(e => JSON.stringify(e.text.slice(0, 40))).join(', ')}` : ''}; chips ${JSON.stringify(chips().map(c => c.text))}`
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ return { ok: false, note: `${hint}: the list offered no "${wanted[0]}" (${diag})` }
+ }
+ hit.el.click()
+ return { ok: true, note: `${hint}: clicked "${hit.text}" via ${trigger.via}` }
+}
+
+/**
+ * Describe the controls on this page without touching any of them. Text only, capped, so the
+ * report is safe to paste into an issue — the point is that a failed first run says what the
+ * page offers, so the selectors above can be aimed at the real markup.
+ */
+function probeNewSession() {
+ return {
+ url: location.href,
+ sessionId: sessionIdFromUrl() ?? null,
+ composer: findComposer()?.via ?? null,
+ sendButton: Boolean(findSendButton()),
+ chips: chips().map(c => c.text.slice(0, 80)),
+ triggers: menuTriggers()
+ .slice(0, 40)
+ .map(trigger => ({ via: trigger.via, text: trigger.text.slice(0, 80) }))
+ .filter(trigger => trigger.text),
+ }
+}
+
+/**
+ * Drive the new-session flow: repo, branch, prompt, send, and report the session it became.
+ *
+ * The page remembers the last repository picked, so it may open already showing ours, another
+ * one, or none — the chips are waited for and read rather than assumed. The branch is checked,
+ * not assumed: the branch chip must read the requested ref before anything is sent, because a
+ * session opened on the wrong branch would push its work somewhere the run never looks. The
+ * session id is read from the URL, the one thing the page is guaranteed to tell us and exactly
+ * what the daemon joins runs on; a send that never becomes a session URL is a failure with a
+ * reason, never a silent success.
+ */
+async function createSession({ repo, branch, prompt }) {
+ const composer = await waitFor(findComposer, window.__tfComposerWaitMs ?? 20000)
+ if (!composer) return { ok: false, note: 'no composer on the new-session page' }
+
+ const bare = String(repo).split('/').pop() ?? repo
+ const repoWanted = [String(repo).toLowerCase(), bare.toLowerCase()]
+ const selectRepo = () => menuTriggers().find(t => /select repo|add repo|choose repo/i.test(t.text) || /select repo|choose repo/i.test(t.el.getAttribute('aria-label') ?? ''))
+ // The chips render a beat after the composer: wait for either a chip or the bare picker.
+ await waitFor(() => chips().some(c => c.text) || selectRepo(), MENU_WAIT_MS)
+ await new Promise(resolve => setTimeout(resolve, window.__tfMenuSettleMs ?? 800))
+
+ let repoNote
+ if (repoWanted.includes(chips()[0]?.text.toLowerCase())) {
+ repoNote = `repo already ${chips()[0].text}`
+ } else {
+ // A remembered other repository's chip is the picker; with nothing remembered, the page offers one.
+ const trigger = chips()[0]?.text ? chips()[0] : selectRepo()
+ if (!trigger) return { ok: false, note: `no repo picker on the page (${JSON.stringify(probeNewSession())})` }
+ let pick = await chooseFrom(trigger, repoWanted, 'repo')
+ if (!pick.ok) {
+ // The page may have finished loading its remembered repository under us: read again once.
+ await new Promise(resolve => setTimeout(resolve, 1500))
+ if (repoWanted.includes(chips()[0]?.text.toLowerCase())) pick = { ok: true, note: `repo already ${chips()[0].text} (after a late render)` }
+ else return pick
+ }
+ repoNote = pick.note
+ const chip = await waitFor(() => (repoWanted.includes(chips()[0]?.text.toLowerCase()) ? chips()[0] : undefined), MENU_WAIT_MS)
+ if (!chip) return { ok: false, note: `${repoNote}, but the repo chip does not read "${repo}" afterwards (chips: ${JSON.stringify(chips().map(c => c.text))})` }
+ }
+
+ // The branch chip appears beside the repo chip once a repository is chosen, reading the default branch.
+ const branchWanted = [String(branch).toLowerCase()]
+ const readsBranch = () => (chips()[1]?.text.toLowerCase() === branchWanted[0] ? chips()[1] : undefined)
+ let branchChip = await waitFor(() => (chips()[1]?.text ? chips()[1] : undefined), MENU_WAIT_MS)
+ if (!branchChip) return { ok: false, note: `${repoNote}; no branch chip appeared beside the repo (chips: ${JSON.stringify(chips().map(c => c.text))})` }
+ let branchNote
+ if (readsBranch()) {
+ branchNote = `branch already ${branchChip.text}`
+ } else {
+ const pick = await chooseFrom(branchChip, branchWanted, 'branch')
+ if (!pick.ok) return { ok: false, note: `${repoNote}; ${pick.note}` }
+ branchNote = pick.note
+ branchChip = await waitFor(readsBranch, MENU_WAIT_MS)
+ if (!branchChip) return { ok: false, note: `${repoNote}; ${branchNote}, but the branch chip does not read "${branch}" — not sending (chips: ${JSON.stringify(chips().map(c => c.text))})` }
+ }
+
+ fillComposer(composer, prompt)
+ await new Promise(resolve => setTimeout(resolve, 400))
+ const button = findSendButton()
+ if (button) button.click()
+ else {
+ for (const type of ['keydown', 'keyup']) {
+ composer.el.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }))
+ }
+ }
+
+ const sessionId = await waitFor(sessionIdFromUrl, window.__tfSessionWaitMs ?? 60000, 500)
+ if (!sessionId) return { ok: false, note: `sent, but the page never became a session URL (${repoNote}; ${branchNote})` }
+ return { ok: true, sessionId, note: `${repoNote}; ${branchNote}; sent via ${button ? 'button' : 'enter'}` }
+}
+
+// The worker hands answers and session requests to the top frame only: the composer lives
+// there, and a child frame acting too would submit twice.
if (IS_TOP && typeof chrome !== 'undefined' && chrome.runtime?.onMessage) {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ if (message?.type === 'tf-probe-new-session') {
+ sendResponse(probeNewSession())
+ return true
+ }
+ if (message?.type === 'tf-create-session' && message.start) {
+ void createSession(message.start)
+ .then(sendResponse)
+ .catch(err => sendResponse({ ok: false, note: String(err?.message ?? err) }))
+ return true
+ }
if (message?.type !== 'tf-deliver-answer' || typeof message.text !== 'string') return false
void deliverAnswer(message.text)
.then(sendResponse)
@@ -486,6 +718,8 @@ if (IS_TOP && typeof chrome !== 'undefined' && chrome.runtime?.onMessage) {
// added to the window claude.ai can see.
if (typeof chrome === 'undefined') {
window.__tfBridgeDeliverAnswer = deliverAnswer
+ window.__tfBridgeCreateSession = createSession
+ window.__tfBridgeProbeNewSession = probeNewSession
}
/**
diff --git a/packages/chrome-extension/manifest.json b/packages/chrome-extension/manifest.json
index 7a8f3b3f..76301418 100644
--- a/packages/chrome-extension/manifest.json
+++ b/packages/chrome-extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "The Framework: Claude web bridge",
- "version": "0.10.0",
+ "version": "0.11.0",
"description": "Shows the question a Claude Code cloud session is parked on in your local The Framework dashboard, and types the answer you pick there back into the session.",
"permissions": [
"storage",
diff --git a/packages/framework/src/cli.SPEC.md b/packages/framework/src/cli.SPEC.md
index 03b9bcdc..d381b0d4 100644
--- a/packages/framework/src/cli.SPEC.md
+++ b/packages/framework/src/cli.SPEC.md
@@ -164,7 +164,7 @@ The user picks a run target in the dashboard: this device, a GitHub Actions runn
For the `actions` target, the repository's owner and name come from its GitHub origin remote and the credential from the environment or, failing that, from the `gh` CLI — never from the committed `the-framework.yml`, since a repo file is public and this must be a user credential. A missing remote or a missing credential aborts the agent before any driver exists, with a message naming both ways to supply the token and pointing out that it must belong to a user rather than an app.
-For the `web` target nothing is resolved: the wrapped CLI holds the account the cloud session runs under. The agent announces the hand-off and, when the repository has no GitHub remote, that a bundle of it will be uploaded instead. It also states plainly that the cloud session clones the remote at its current branch, so local commits that were never pushed are not in it.
+For the `web` target nothing is resolved: the wrapped CLI holds the account the cloud session runs under. The agent announces the hand-off and, when the repository has no GitHub remote, that a bundle of it will be uploaded instead. It also states plainly that the cloud session clones the remote at its current branch, so local commits that were never pushed are not in it. A `web` agent that a daemon spawned — its environment names the daemon's address, and the registry holds the daemon token — asks that daemon for a session created by the browser extension before falling back to the CLI's own cloud mode; an agent with no daemon address, or no token, hands off through the CLI alone.
The framework launches Chrome itself for a browser-enabled agent rather than letting the driver's browser tooling launch its own, because that is what lets the preview attach to the same page. It does so only for a local agent: the browser tools are wired on this machine, so a remote agent could never reach them, and launching Chrome would leak a headless browser per agent. A browser asked for on a remote target, or on a machine with no Chrome, is reported as having no effect rather than failing the agent. Whether the system prompt may claim the agent has a browser is narrower still than the setting: it must be a local, real, Claude Code agent.
diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts
index 4c832719..6e93935b 100644
--- a/packages/framework/src/cli.ts
+++ b/packages/framework/src/cli.ts
@@ -7,8 +7,10 @@ import { fileURLToPath } from 'node:url'
import { type ClaudeCodeDriverOptions, type Driver, type DriverSession, type PermissionMode } from './driver/index.js'
import { DRIVERS, DRIVER_SPECS, isDriverName, type DriverName } from './driver-cli.js'
import { createAgentDriver } from './agent-driver.js'
+import type { CloudDriverOptions } from './driver/cloud.js'
import { checkLayout } from './layout.js'
import { githubSlugFor } from './dashboard/github.js'
+import { DAEMON_URL_ENV } from './dashboard/web-start-endpoints.js'
import { githubToken } from './dashboard/gh.js'
import type { ActionsDriverOptions } from './driver/index.js'
import { launchSharedBrowser, withBrowser, type SharedBrowser } from './browser.js'
@@ -92,6 +94,18 @@ export function chooseSessionLink(opts: Pick, fake: bool
return fake || opts.driver !== 'claude' ? undefined : CLAUDE_CODE_SESSION_LIST
}
+/**
+ * The extension-backed session start a web run can use (#1328): only when a daemon spawned this
+ * run (its URL is in {@link DAEMON_URL_ENV}) and the registry holds the daemon token that the
+ * daemon's start-queue asks for. Anything less and the run hands off through the CLI alone.
+ */
+export async function extensionStartConfig(env: NodeJS.ProcessEnv): Promise {
+ const daemonUrl = env[DAEMON_URL_ENV]
+ if (!daemonUrl) return undefined
+ const token = await readDaemonToken(undefined, env).catch(() => undefined)
+ return token ? { extension: { daemonUrl, token } } : undefined
+}
+
/** Where the CLI writes. Injectable so tests capture output. */
export interface CliIO {
out: (line: string) => void
@@ -1165,6 +1179,9 @@ async function driveAgent(opts: AgentOptions, io: CliIO): Promise {
}
}
+ // A daemon-spawned web run asks its daemon for an extension-created session first (#1328): the
+ // daemon's URL is in the environment it was spawned with, the token is the registry's.
+ const cloudConfig = opts.target === 'web' && !fake ? await extensionStartConfig(process.env) : undefined
const driver: Driver = fake
? fakeDriver()
: createAgentDriver({
@@ -1172,6 +1189,7 @@ async function driveAgent(opts: AgentOptions, io: CliIO): Promise {
claudeOpts: withBrowser(claudeOpts, opts.browser, sharedBrowser?.browserUrl),
...(opts.target ? { target: opts.target } : {}),
...(actionsConfig ? { actionsConfig } : {}),
+ ...(cloudConfig ? { cloudConfig } : {}),
})
// Whether the agent actually ends up with browser tools, which is narrower than the flag: they
diff --git a/packages/framework/src/daemon-runtime.SPEC.md b/packages/framework/src/daemon-runtime.SPEC.md
index 57a30440..8cec5bab 100644
--- a/packages/framework/src/daemon-runtime.SPEC.md
+++ b/packages/framework/src/daemon-runtime.SPEC.md
@@ -38,6 +38,8 @@ Whatever the framework's own CLI entry is, the agent is spawned from it. The dae
The spawned agent's error output is written to a file inside its checkout instead of being discarded, so an agent that dies at boot leaves a trace to read.
+The spawned agent's environment is the daemon's own plus the daemon's address, when the daemon has one: a web run uses it to ask this daemon for a cloud session created by the browser extension. A run nobody's daemon spawned has no such address and hands off by itself.
+
### Every agent gets its own checkout
#### User story
diff --git a/packages/framework/src/daemon-runtime.ts b/packages/framework/src/daemon-runtime.ts
index 9a44d3f4..312ba38e 100644
--- a/packages/framework/src/daemon-runtime.ts
+++ b/packages/framework/src/daemon-runtime.ts
@@ -52,6 +52,7 @@ import { withAgentLock } from './agent-locks.js'
import { errorMessage } from './error-message.js'
import { preflight, preflightProblems, type PreflightResult } from './preflight.js'
import { isDriverName, type DriverName } from './driver-names.js'
+import { DAEMON_URL_ENV } from './dashboard/web-start-endpoints.js'
/**
* How long a passing agent preflight (#1326) is trusted before it is probed again. Short enough
@@ -104,8 +105,12 @@ async function appendAgentLog(cwd: string, message: string): Promise {
await appendFile(join(cwd, FRAMEWORK_DIR, EVENTS_FILE), JSON.stringify(event) + '\n').catch(() => {})
}
-/** Spawn a detached, unref'd framework child (`node --agent `) that outlives us. */
-export function spawnDetached(binPath: string, specPath: string, stderrFile?: string): ChildProcess {
+/**
+ * Spawn a detached, unref'd framework child (`node --agent `) that outlives us.
+ * `env` is the child's whole environment; the daemon adds its own URL to it (#1328) so a web run
+ * can ask this daemon for a cloud session.
+ */
+export function spawnDetached(binPath: string, specPath: string, stderrFile?: string, env: NodeJS.ProcessEnv = process.env): ChildProcess {
// stderr goes to a file, never a pipe: a detached child must not block on a dead parent's pipe
// buffer, and the file is what makes a silent boot death diagnosable (#1261). Best-effort — a
// run must still start when the log cannot be opened.
@@ -119,12 +124,18 @@ export function spawnDetached(binPath: string, specPath: string, stderrFile?: st
const child = spawn(process.execPath, [binPath, '--agent', specPath], {
detached: true,
stdio: ['ignore', 'ignore', fd ?? 'ignore'],
+ env,
})
if (fd !== undefined) closeSync(fd)
child.unref()
return child
}
+/** A spawned run's environment: ours, plus the daemon's URL when it has one (#1328). */
+export function childEnv(daemonUrl: string | undefined, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
+ return daemonUrl ? { ...base, [DAEMON_URL_ENV]: daemonUrl } : base
+}
+
/** Where a spawned agent's stderr lands (#1261), so a child that dies at boot leaves a trace. */
export function agentStderrPath(cwd: string): string {
return join(cwd, FRAMEWORK_DIR, 'stderr.log')
@@ -363,6 +374,12 @@ export interface ProjectRuntimeOptions {
retryDelayMs?: number | undefined
/** How a start checks the agent can run (#1326); undefined runs the real {@link preflight}. A test seam. */
driverPreflight?: ((driver: DriverName) => Promise) | undefined
+ /**
+ * The URL this daemon serves at, once it listens (#1328). Handed to every spawned run as
+ * {@link DAEMON_URL_ENV}, so a web run can ask the daemon for an extension-created session.
+ * A getter because the runtime exists before the daemon has a port.
+ */
+ daemonUrl?: (() => string | undefined) | undefined
}
/** The per-project agent + preview surface the dashboard drives, plus its teardown. */
@@ -415,7 +432,7 @@ export interface ProjectRuntime {
* with no project id (or the home id) resolves to it without a registry lookup. Split out of
* {@link runDaemon} so the daemon body reads as lifecycle and this reads as business logic.
*/
-export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, driverPreflight }: ProjectRuntimeOptions): ProjectRuntime {
+export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, driverPreflight, daemonUrl }: ProjectRuntimeOptions): ProjectRuntime {
const homeId = projectId(resolve(cwd))
// Live run pids, keyed per agent rather than per project (#736) — see onStart for the key.
const activeAgents = new Map()
@@ -796,7 +813,7 @@ export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, driverPr
}
return { ok: false, error: 'the daemon is shutting down' }
}
- const child = spawnDetached(realBin, specPath, ...(workspace.agentId ? [agentStderrPath(workspace.cwd)] : []))
+ const child = spawnDetached(realBin, specPath, workspace.agentId ? agentStderrPath(workspace.cwd) : undefined, childEnv(daemonUrl?.()))
// The agent narrates itself through its own `.the-framework/events.jsonl`, which the
// dashboard streams over `GET /_rpc/events`; the daemon just tracks liveness.
const settle = (detail: string): void => {
diff --git a/packages/framework/src/daemon.SPEC.md b/packages/framework/src/daemon.SPEC.md
index 52cb855b..739c4423 100644
--- a/packages/framework/src/daemon.SPEC.md
+++ b/packages/framework/src/daemon.SPEC.md
@@ -10,6 +10,7 @@ The user runs one command in a repository and gets a working dashboard in their
- **Files are the seam** - the dashboard is a projection of each agent's event log, and steering goes back through the control channel; there is no agent-to-daemon messaging.
- **Where the dashboard binds** - localhost by default; binding anywhere reachable off the machine requires a shared token on every route.
- **The browser bridge is opt-in** - off unless the user turned it on, and it shares the daemon's one secret rather than minting a second.
+- **Spawned agents know their daemon's address** - every agent the daemon starts is told where the daemon listens, so a web run can ask it for a cloud session created by the browser extension.
- **The boot sequence** - create the framework directory, register the home workspace, then reconcile agents a dead daemon left marked running.
- **Nothing is resumed at boot** - stopping the last daemon was a deliberate act, and the stopped agents keep their branches so the user can continue them when they choose.
- **One quota meter for the whole daemon** - the usage panel and the unattended work read the same long-lived reading.
@@ -64,6 +65,8 @@ The browser bridge is off unless the user turned it on, because it opens the dae
The cloud sessions the bridge should have a tab open for are gathered across every registered project, because a cloud agent is not tied to the home workspace, and per project best-effort so one unreadable repo cannot empty the list.
+The daemon's own address is known only once it listens, so the agent runtime is handed a way to read it rather than the address itself, and reads it each time it spawns an agent.
+
### The boot sequence
#### User story
diff --git a/packages/framework/src/daemon.ts b/packages/framework/src/daemon.ts
index 527a98aa..a17fb713 100644
--- a/packages/framework/src/daemon.ts
+++ b/packages/framework/src/daemon.ts
@@ -157,11 +157,14 @@ export async function runDaemon(cwd: string, opts: RunDaemonOptions = {}): Promi
// Everything the dashboard drives per project — run spawning, project install, and app
// previews — lives in the runtime, so this body stays about the daemon's own lifecycle.
+ // Known only once the dashboard listens; the runtime reads it per spawn (#1328).
+ let daemonUrl: string | undefined
const runtime = createProjectRuntime({
cwd,
env,
...(opts.binPath !== undefined ? { binPath: opts.binPath } : {}),
...(opts.driverPreflight !== undefined ? { driverPreflight: opts.driverPreflight } : {}),
+ daemonUrl: () => daemonUrl,
})
// The daemon serves the built dashboard bundle (#405/#426): the SPA reads each project's
@@ -214,6 +217,7 @@ export async function runDaemon(cwd: string, opts: RunDaemonOptions = {}): Promi
...(clientBundleDir ? { clientBundleDir } : {}),
})
+ daemonUrl = dashboard.url
try {
const actualPort = Number(new URL(dashboard.url).port) || port
opts.onListening?.({ pid: process.pid, port: actualPort, host, url: dashboard.url, startedAt: new Date().toISOString() })
diff --git a/packages/framework/src/dashboard/bridge-endpoints.SPEC.md b/packages/framework/src/dashboard/bridge-endpoints.SPEC.md
index 6008ee66..1f73b4db 100644
--- a/packages/framework/src/dashboard/bridge-endpoints.SPEC.md
+++ b/packages/framework/src/dashboard/bridge-endpoints.SPEC.md
@@ -10,13 +10,14 @@ The daemon half of the Claude web bridge: the `/_bridge` routes the Chrome exten
- **Every bridge call presents the daemon token** - this is the one route family meant to be reached from another origin, so it authenticates itself instead of relying on the protections the dashboard's other routes have.
- **Deliberately no cross-origin headers** - the extension's background worker can call without them, and adding them would let any page the user visits reach their daemon.
-- **A tiny, fully validated surface** - no path, command, prompt or free text is accepted anywhere, so a stolen token buys at most a bogus question card.
+- **A tiny, fully validated surface** - no path, command, prompt or free text is accepted anywhere — the one free text on the bridge, a session request's prompt, travels out to the extension and never in — so a stolen token buys at most a bogus question card.
- **The extension version must match exactly** - a mismatched extension is refused outright rather than allowed to half-work.
- **The parked question becomes an open question** - a validated question from a cloud session, in the shape the session asked it, is handed to the dashboard.
- **The transcript arrives as numbered entries** - each message carries its position in the transcript, so re-reading the page overwrites rather than duplicates.
- **The answer is polled, and its delivery reported back** - the extension asks for the exact text to type, which the daemon composed, and then says whether typing it worked.
- **The daemon says which cloud sessions to watch** - the extension only sees tabs the user is already on, so the daemon lists the sessions a tab should be opened for.
- **Every contact is recorded, including refusals** - so the dashboard can show what the bridge is doing.
+- **The session start-queue is served claim-on-read** - the extension asks for the next cloud session to create and is handed it in the same step that takes it off the queue, then reports the session it became.
- **The bridge can be off** - a daemon with the feature disabled answers every bridge route as not found.
## Business logic
@@ -123,6 +124,20 @@ The extension reports what it injected into the page: its version, which cloud s
Separately, every request that reaches the bridge is recorded with the route it asked for and the status it got, refusals included.
+### The session start-queue
+
+#### User story
+
+A web run wants its cloud session created through claude.ai's repository picker, in the user's browser, because such a session can push and open a pull request. The extension is the only thing that can do that, so it must be told what to create and must say what it created.
+
+#### Business logic
+
+The extension asks for the next session to create and always gets an answer: the request — its id, the repository as `owner/name`, the branch, and the prompt — or nothing, including on a daemon that wired no queue. Handing out the request is what takes it off the queue: the claim happens in that same step, never in a second call, so two tabs polling cannot both be handed it. The extension then reports the request's id, whether it succeeded, the session id when it did, and a short note; the report is validated field by field before it reaches the queue, and a daemon with no queue accepts and drops it.
+
+#### Rationale
+
+A read that mutates is unusual, and it is the point: the only duplicate that matters here is a second cloud session on the user's account, and the only way to rule it out is to never serve the same request twice.
+
### The bridge can be off
#### Business logic
diff --git a/packages/framework/src/dashboard/bridge-endpoints.test.SPEC.md b/packages/framework/src/dashboard/bridge-endpoints.test.SPEC.md
index a5bf243d..011d260f 100644
--- a/packages/framework/src/dashboard/bridge-endpoints.test.SPEC.md
+++ b/packages/framework/src/dashboard/bridge-endpoints.test.SPEC.md
@@ -11,6 +11,7 @@ What the tests cover: the `/_bridge` routes the Claude web bridge's Chrome exten
- **The answer round trip** - the extension is served the answer queued for a cloud session — its identity and the exact text to type — gets an explicit "nothing to deliver" for a session with none, is refused for a malformed session id, and its report of whether delivery worked is accepted and passed on (with a malformed report refused). A daemon that queues no answers degrades to "nothing to deliver" rather than failing.
- **The extension version gate** - an extension claiming the wrong version, or too old to claim one at all, is refused on every route including the health check, with a message naming both versions and how to update; the matching version passes; every claim is reported to the dashboard, which is what clears a blocked banner once the extension is updated. The gate sits behind the token, so an unauthenticated caller learns nothing about which version is expected.
- **Lockstep with the extension** - the version this daemon expects and the version in the Chrome extension's manifest must be equal, so bumping one without the other fails here instead of in the user's browser.
+- **The session start-queue** - the next request is served to the extension with its repository, branch and prompt and is gone on the following poll; the extension's report reaches the queue with its session id; a daemon with no queue answers nothing and accepts a report without failing; the report is validated field by field; the routes demand the token and reject the wrong method.
## Before modifying/creating SPEC.md files
diff --git a/packages/framework/src/dashboard/bridge-endpoints.test.ts b/packages/framework/src/dashboard/bridge-endpoints.test.ts
index 43eb917e..fc64d32c 100644
--- a/packages/framework/src/dashboard/bridge-endpoints.test.ts
+++ b/packages/framework/src/dashboard/bridge-endpoints.test.ts
@@ -330,3 +330,84 @@ test('the expected version and the extension manifest move in lockstep (#1519)',
}
assert.equal(EXPECTED_EXTENSION_VERSION, manifest.version)
})
+
+test('the start-queue is served to the extension and its report travels back (#1328)', async () => {
+ const reports: { id: string; ok: boolean; sessionId?: string; note?: string }[] = []
+ let pending: { id: string; repo: string; branch: string; prompt: string } | undefined = {
+ id: 'req-1',
+ repo: 'framework/the-framework',
+ branch: 'cloud-1-abcd1234',
+ prompt: 'Spike and plan the queue ticket',
+ }
+ const s = await serve({
+ token: TOKEN,
+ record: () => {},
+ // Claim-on-read, as the daemon wires it: a second poll must not be handed the same request.
+ start: () => {
+ const next = pending
+ pending = undefined
+ return next
+ },
+ started: (id, ok, sessionId, note) => void reports.push({ id, ok, ...(sessionId ? { sessionId } : {}), ...(note ? { note } : {}) }),
+ })
+ try {
+ const first = await fetch(`${s.url}${BRIDGE_PREFIX}/start`, { headers: { authorization: `Bearer ${TOKEN}` } })
+ assert.equal(first.status, 200)
+ assert.deepEqual(await first.json(), {
+ start: { id: 'req-1', repo: 'framework/the-framework', branch: 'cloud-1-abcd1234', prompt: 'Spike and plan the queue ticket' },
+ })
+
+ const second = await fetch(`${s.url}${BRIDGE_PREFIX}/start`, { headers: { authorization: `Bearer ${TOKEN}` } })
+ assert.deepEqual(await second.json(), { start: null }, 'claimed, so the next poll gets nothing')
+
+ const ack = await fetch(`${s.url}${BRIDGE_PREFIX}/started`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${TOKEN}` },
+ body: JSON.stringify({ id: 'req-1', ok: true, sessionId: 'session_018KtaRYq8N1T9mjrJBTvrNS' }),
+ })
+ assert.equal(ack.status, 204)
+ assert.deepEqual(reports, [{ id: 'req-1', ok: true, sessionId: 'session_018KtaRYq8N1T9mjrJBTvrNS' }])
+ } finally {
+ await s.close()
+ }
+})
+
+test('a daemon with no start-queue degrades to null rather than failing (#1328)', async () => {
+ const s = await serve({ token: TOKEN, record: () => {} })
+ try {
+ const res = await fetch(`${s.url}${BRIDGE_PREFIX}/start`, { headers: { authorization: `Bearer ${TOKEN}` } })
+ assert.equal(res.status, 200)
+ assert.deepEqual(await res.json(), { start: null })
+ const ack = await fetch(`${s.url}${BRIDGE_PREFIX}/started`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${TOKEN}` },
+ body: JSON.stringify({ id: 'req-1', ok: true, sessionId: 'session_018KtaRYq8N1T9mjrJBTvrNS' }),
+ })
+ assert.equal(ack.status, 204, 'accepted and dropped')
+ } finally {
+ await s.close()
+ }
+})
+
+test('the start report is validated field by field, and the routes demand the token (#1328)', async () => {
+ const s = await serve({ token: TOKEN, record: () => {}, start: () => undefined, started: () => {} })
+ const send = (body: unknown): Promise =>
+ fetch(`${s.url}${BRIDGE_PREFIX}/started`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${TOKEN}` },
+ body: JSON.stringify(body),
+ })
+ try {
+ assert.equal((await send({ ok: true })).status, 400, 'no id')
+ assert.equal((await send({ id: 'x'.repeat(65), ok: true })).status, 400, 'absurd id')
+ assert.equal((await send({ id: 'r', ok: 'yes' })).status, 400, 'ok must be a boolean')
+ assert.equal((await send({ id: 'r', ok: true, sessionId: 'not-a-session' })).status, 400, 'bad session id')
+ assert.equal((await send({ id: 'r', ok: false, note: 42 })).status, 400, 'note must be a string')
+ assert.equal((await send({ id: 'r', ok: false })).status, 204, 'a plain failure is fine')
+ assert.equal((await fetch(`${s.url}${BRIDGE_PREFIX}/start`)).status, 401)
+ assert.equal((await fetch(`${s.url}${BRIDGE_PREFIX}/started`, { method: 'POST' })).status, 401)
+ assert.equal((await fetch(`${s.url}${BRIDGE_PREFIX}/start`, { method: 'POST', headers: { authorization: `Bearer ${TOKEN}` } })).status, 405)
+ } finally {
+ await s.close()
+ }
+})
diff --git a/packages/framework/src/dashboard/bridge-endpoints.ts b/packages/framework/src/dashboard/bridge-endpoints.ts
index d5b45f34..42a5f694 100644
--- a/packages/framework/src/dashboard/bridge-endpoints.ts
+++ b/packages/framework/src/dashboard/bridge-endpoints.ts
@@ -33,7 +33,7 @@ export const BRIDGE_PREFIX = '/_bridge'
* which reads as a framework bug and burns a debugging session. The extension's manifest must
* carry the same number; a test keeps the two in lockstep.
*/
-export const EXPECTED_EXTENSION_VERSION = '0.10.0'
+export const EXPECTED_EXTENSION_VERSION = '0.11.0'
/** The header the extension states its version in. Lowercase, as node presents all headers. */
export const EXTENSION_VERSION_HEADER = 'x-tf-extension-version'
@@ -67,6 +67,21 @@ export interface BridgeSession {
url: string
}
+/**
+ * A session the daemon wants the extension to create on claude.ai (#1328).
+ *
+ * The one shape that travels *out* of the bridge carrying free text, and that direction is what
+ * keeps the "deliberately tiny input" rule intact: the daemon writes it, the extension reads it,
+ * and what comes back is only an id, a boolean and a session id.
+ */
+export interface BridgeStart {
+ id: string
+ /** `owner/name`, as the repo picker lists it. */
+ repo: string
+ branch: string
+ prompt: string
+}
+
/** What the daemon wires behind the bridge. Absent when the feature is off, which 404s it. */
export interface BridgeHandlers {
/** The shared secret every bridge call must present. */
@@ -100,6 +115,14 @@ export interface BridgeHandlers {
answer?: (sessionId: string) => { id: string; text: string } | undefined
/** The extension's word on what a delivery attempt did. */
answered?: (sessionId: string, id: string, ok: boolean, note?: string) => void
+ /**
+ * The next session the daemon wants created, claimed for whoever is asking (#1328). Claiming is
+ * the callee's job: two tabs polling must not both be handed the same request, because a
+ * duplicate here is a duplicate cloud session on the user's account.
+ */
+ start?: () => BridgeStart | undefined
+ /** The extension's word on what a creation attempt did (#1328). */
+ started?: (id: string, ok: boolean, sessionId?: string, note?: string) => void
now?: () => Date
}
@@ -123,7 +146,7 @@ export async function handleBridgeRequest(
): Promise {
if (!handlers) return end(res, 404, 'bridge not enabled')
seen(handlers, pathname, res)
- if (!authorized(req, handlers.token)) return end(res, 401, 'unauthorized')
+ if (!bearerAuthorized(req, handlers.token)) return end(res, 401, 'unauthorized')
// The version gate (#1519), behind the token so an unauthenticated caller learns nothing.
if (handlers.expectedExtensionVersion !== undefined) {
const raw = req.headers[EXTENSION_VERSION_HEADER]
@@ -148,6 +171,8 @@ export async function handleBridgeRequest(
if (pathname === `${BRIDGE_PREFIX}/hello`) return handleHello(req, res, handlers)
if (pathname === `${BRIDGE_PREFIX}/answer`) return handleAnswer(req, res, handlers)
if (pathname === `${BRIDGE_PREFIX}/answered`) return handleAnswered(req, res, handlers)
+ if (pathname === `${BRIDGE_PREFIX}/start`) return handleStart(req, res, handlers)
+ if (pathname === `${BRIDGE_PREFIX}/started`) return handleStarted(req, res, handlers)
end(res, 404, 'not found')
}
@@ -155,7 +180,7 @@ export async function handleBridgeRequest(
* `Authorization: Bearer `, compared in constant time. Rejects before the body is read,
* so an unauthenticated caller cannot make the daemon buffer anything.
*/
-function authorized(req: IncomingMessage, token: string): boolean {
+export function bearerAuthorized(req: IncomingMessage, token: string): boolean {
const header = req.headers.authorization
if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false
const given = Buffer.from(header.slice('Bearer '.length))
@@ -328,6 +353,43 @@ async function handleAnswered(req: IncomingMessage, res: ServerResponse, handler
end(res, 204, '')
}
+/**
+ * `GET /_bridge/start`: the next session the daemon wants created, claimed by this call (#1328).
+ *
+ * Always 200 with `{start: ...}`, null when there is nothing to do, so the extension polls it
+ * blindly alongside `/answer`; null too on a daemon that wired no queue. A GET that mutates, and
+ * the mutation is the point: handing the same request to two polling tabs would create two cloud
+ * sessions, so taking it off the queue has to happen in the same step as reading it.
+ */
+async function handleStart(req: IncomingMessage, res: ServerResponse, handlers: BridgeHandlers): Promise {
+ if (req.method !== 'GET') return end(res, 405, 'method not allowed', { allow: 'GET' })
+ const start = handlers.start?.()
+ res.writeHead(200, { 'content-type': 'application/json' })
+ res.end(JSON.stringify({ start: start ?? null }))
+}
+
+/** `POST /_bridge/started`: what the extension's creation attempt did (#1328). */
+async function handleStarted(req: IncomingMessage, res: ServerResponse, handlers: BridgeHandlers): Promise {
+ if (req.method !== 'POST') return end(res, 405, 'method not allowed', { allow: 'POST' })
+ let body: unknown
+ try {
+ body = await readJsonBody(req, MAX_BODY)
+ } catch (err) {
+ return end(res, 400, (err as Error).message)
+ }
+ if (typeof body !== 'object' || body === null) return end(res, 400, 'body must be an object')
+ const { id, ok, sessionId, note } = body as Record
+ if (typeof id !== 'string' || !id || id.length > 64) return end(res, 400, 'id must be the start request id')
+ if (typeof ok !== 'boolean') return end(res, 400, 'ok must be a boolean')
+ if (sessionId !== undefined && (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId))) {
+ return end(res, 400, 'sessionId must look like session_')
+ }
+ if (note !== undefined && typeof note !== 'string') return end(res, 400, 'note must be a string')
+ // A failure's note is the only diagnosis the run gets, so it may carry the page's controls.
+ handlers.started?.(id, ok, typeof sessionId === 'string' ? sessionId : undefined, typeof note === 'string' ? note.slice(0, 1500) : undefined)
+ end(res, 204, '')
+}
+
/**
* `GET /_bridge/sessions`: the cloud sessions the extension should have a tab for.
*
@@ -342,7 +404,7 @@ async function handleAgents(req: IncomingMessage, res: ServerResponse, handlers:
}
/** Read a JSON body, refusing anything past the cap rather than buffering it. */
-function readJsonBody(req: IncomingMessage, maxBytes: number): Promise {
+export function readJsonBody(req: IncomingMessage, maxBytes: number): Promise {
return new Promise((resolve, reject) => {
let size = 0
const chunks: Buffer[] = []
diff --git a/packages/framework/src/dashboard/bridge-starts.SPEC.md b/packages/framework/src/dashboard/bridge-starts.SPEC.md
new file mode 100644
index 00000000..31e41a3a
--- /dev/null
+++ b/packages/framework/src/dashboard/bridge-starts.SPEC.md
@@ -0,0 +1,44 @@
+The session start-queue of the Claude web bridge: the cloud sessions a web run wants created by the browser extension, waiting for the extension to claim them and report the session each became.
+
+## User story
+
+A `web`-target agent hands its task to a cloud session on claude.ai. A session created through claude.ai's own repository picker is bound to that repository, so it can push its work and open a pull request; one created through the CLI's cloud flag may not be. So the run asks the daemon for an extension-created session, the extension in the user's own browser creates it, and the run learns where its work went.
+
+## Business logic — TL;DR
+
+- **A request names the repository, the branch and the prompt** - the repository as `owner/name` the way the picker lists it, the branch the run pushed its starting point to, and the whole hand-off prompt; a malformed repository or branch, an empty prompt, or an absurdly long one is refused.
+- **A request is claimed in the same step that serves it** - two polling tabs must never be handed the same request, because a duplicate is a second cloud session on the user's account.
+- **A claim nobody reports on expires** - after ninety seconds the request is offered again, so a browser that quit mid-creation retries instead of stranding the run.
+- **Success without a session is a failure** - a run pointing nowhere is not a usable outcome.
+- **A report on a request nobody holds is ignored** - a tab that died after its claim expired cannot overwrite the retry that replaced it.
+- **Nothing survives a daemon restart** - the queue is held in memory; the run's own timeout is the backstop.
+
+## Business logic
+
+### The request
+
+#### User story
+
+See `## User story`.
+
+#### Business logic
+
+A request carries three things: the repository as `owner/name`, the branch, and the prompt. The repository must be exactly two path segments of ordinary characters, neither of which is only dots; the branch must be a plausible git branch name; the prompt must be non-empty and under the cap. The cap is generous, because the hand-off prompt carries the whole framing The Framework injects — the system prompt, the file formats, the protocols — and not only the user's task. A queued request is stamped with when it was queued and starts as queued.
+
+### Claiming and reporting
+
+#### User story
+
+The extension's background half polls the daemon; there may be more than one browser, and a browser may quit half-way through creating a session.
+
+#### Business logic
+
+Claiming hands out the oldest request that nobody holds and marks it claimed in that same step. A claimed request whose claim is older than ninety seconds counts as unheld and is handed out again. The extension's report on a claimed request settles it: created, with the session's id and the session URL derived from it, or failed, with the extension's note of what it could not find. A report of success that names no session is recorded as a failure. A report on a request that is not currently claimed is ignored.
+
+#### Rationale
+
+The whole design is about never creating two cloud sessions for one run. Taking the request off the queue as it is served, expiring a silent claim rather than a reported one, and ignoring reports from expired claims are the three halves of that one rule.
+
+## Before modifying/creating SPEC.md files
+
+You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md
diff --git a/packages/framework/src/dashboard/bridge-starts.test.SPEC.md b/packages/framework/src/dashboard/bridge-starts.test.SPEC.md
new file mode 100644
index 00000000..ff74e217
--- /dev/null
+++ b/packages/framework/src/dashboard/bridge-starts.test.SPEC.md
@@ -0,0 +1,14 @@
+What the tests cover: the session start-queue that web runs fill and the browser extension drains.
+
+- A queued request carries its repository, branch and prompt and starts as queued.
+- A repository that is not `owner/name`, that contains extra path segments, dot-only segments or shell syntax, is refused; a leading dot in a real repository name passes.
+- A branch containing spaces or shell syntax, an empty branch, or an absurdly long one is refused; a run's own hand-off ref passes.
+- An empty prompt or one past the cap is refused, while a prompt as long as a whole hand-off framing passes.
+- Claiming hands out the oldest request first and each request only once; a claim nobody reported on is honoured while fresh and offered again once expired.
+- A success records the session id and its URL; a success naming no session is recorded as a failure; a failure keeps the extension's note.
+- Only a request that is currently claimed can be settled — a report on a queued or unknown request changes nothing.
+- The queue is one per daemon, and can be reset for tests.
+
+## Before modifying/creating SPEC.md files
+
+You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md
diff --git a/packages/framework/src/dashboard/bridge-starts.test.ts b/packages/framework/src/dashboard/bridge-starts.test.ts
new file mode 100644
index 00000000..4abad44c
--- /dev/null
+++ b/packages/framework/src/dashboard/bridge-starts.test.ts
@@ -0,0 +1,114 @@
+import { strict as assert } from 'node:assert'
+import { test } from 'node:test'
+import { BridgeStarts, MAX_START_PROMPT, START_CLAIM_TTL_MS, bridgeStarts, resetBridgeStarts } from './bridge-starts.js'
+
+const INPUT = { repo: 'framework/the-framework', branch: 'main', prompt: 'Do the thing' }
+
+test('a queued start carries the repo, branch and prompt (#1328)', () => {
+ const starts = new BridgeStarts()
+ const queued = starts.request(INPUT, new Date('2026-08-24T10:00:00Z'))
+ assert.ok(typeof queued !== 'string', 'accepted')
+ assert.equal(queued.repo, 'framework/the-framework')
+ assert.equal(queued.branch, 'main')
+ assert.equal(queued.prompt, 'Do the thing')
+ assert.equal(queued.state, 'queued')
+})
+
+test('a repo that is not owner/name is refused (#1328)', () => {
+ const starts = new BridgeStarts()
+ for (const repo of ['the-framework', 'a/b/c', '../etc', 'owner/..', './x', 'owner/name; rm -rf /', '']) {
+ assert.equal(typeof starts.request({ ...INPUT, repo }), 'string', `${JSON.stringify(repo)} refused`)
+ }
+ // A leading dot is legal in a real repo name, so only the all-dots segments are the traversal.
+ assert.ok(typeof starts.request({ ...INPUT, repo: 'framework/.github' }) !== 'string', 'owner/.github passes')
+})
+
+test('a branch that could act as syntax is refused (#1328)', () => {
+ const starts = new BridgeStarts()
+ for (const branch of ['a branch', 'main;whoami', '$(id)', '', 'x'.repeat(300)]) {
+ assert.equal(typeof starts.request({ ...INPUT, branch }), 'string', `${JSON.stringify(branch)} refused`)
+ }
+ assert.ok(typeof starts.request({ ...INPUT, branch: 'cloud-1-abcd1234' }) !== 'string', 'a hand-off ref passes')
+})
+
+test('an empty or absurd prompt is refused, a whole hand-off prompt is not (#1328)', () => {
+ const starts = new BridgeStarts()
+ assert.equal(typeof starts.request({ ...INPUT, prompt: ' ' }), 'string')
+ assert.equal(typeof starts.request({ ...INPUT, prompt: 'x'.repeat(MAX_START_PROMPT + 1) }), 'string')
+ assert.ok(typeof starts.request({ ...INPUT, prompt: 'x'.repeat(50_000) }) !== 'string', 'the framing makes prompts long')
+})
+
+test('claiming hands out the oldest request, once (#1328)', () => {
+ // The duplicate this prevents is a second cloud session on the user's account, not a stray row.
+ const starts = new BridgeStarts()
+ starts.request({ ...INPUT, prompt: 'first' }, new Date('2026-08-24T10:00:00Z'))
+ starts.request({ ...INPUT, prompt: 'second' }, new Date('2026-08-24T10:01:00Z'))
+ const now = new Date('2026-08-24T10:02:00Z')
+ assert.equal(starts.claimNext(now)?.prompt, 'first')
+ assert.equal(starts.claimNext(now)?.prompt, 'second')
+ assert.equal(starts.claimNext(now), undefined, 'nothing left to hand out')
+})
+
+test('a claim nobody resolved goes back on the queue (#1328)', () => {
+ // A tab closed mid-creation would otherwise brick its request for the life of the daemon.
+ const starts = new BridgeStarts()
+ starts.request(INPUT, new Date('2026-08-24T10:00:00Z'))
+ const claimedAt = new Date('2026-08-24T10:00:01Z')
+ assert.ok(starts.claimNext(claimedAt), 'claimed')
+ const stillFresh = new Date(claimedAt.getTime() + START_CLAIM_TTL_MS - 1000)
+ assert.equal(starts.claimNext(stillFresh), undefined, 'a live claim is honoured')
+ const expired = new Date(claimedAt.getTime() + START_CLAIM_TTL_MS + 1000)
+ assert.ok(starts.claimNext(expired), 'an expired claim is offered again')
+})
+
+test('a success records the session and its url (#1328)', () => {
+ const starts = new BridgeStarts()
+ const queued = starts.request(INPUT)
+ assert.ok(typeof queued !== 'string')
+ starts.claimNext()
+ starts.resolve(queued.id, true, 'session_01ABC')
+ const done = starts.get(queued.id)
+ assert.equal(done?.state, 'created')
+ assert.equal(done?.sessionId, 'session_01ABC')
+ assert.equal(done?.url, 'https://claude.ai/code/session_01ABC')
+})
+
+test('success without a session id is recorded as a failure (#1328)', () => {
+ // The run would have nowhere to point, so "created, location unknown" is not a usable outcome.
+ const starts = new BridgeStarts()
+ const queued = starts.request(INPUT)
+ assert.ok(typeof queued !== 'string')
+ starts.claimNext()
+ starts.resolve(queued.id, true)
+ assert.equal(starts.get(queued.id)?.state, 'failed')
+})
+
+test('only a claimed request can be resolved (#1328)', () => {
+ // A tab that died after its claim expired must not overwrite the retry that replaced it.
+ const starts = new BridgeStarts()
+ const queued = starts.request(INPUT)
+ assert.ok(typeof queued !== 'string')
+ starts.resolve(queued.id, true, 'session_01ABC')
+ assert.equal(starts.get(queued.id)?.state, 'queued', 'never claimed, so the report is ignored')
+ starts.resolve('no-such-id', true, 'session_01ABC')
+})
+
+test('a failure keeps the extension note (#1328)', () => {
+ const starts = new BridgeStarts()
+ const queued = starts.request(INPUT)
+ assert.ok(typeof queued !== 'string')
+ starts.claimNext()
+ starts.resolve(queued.id, false, undefined, 'no repo picker on the page')
+ const failed = starts.get(queued.id)
+ assert.equal(failed?.state, 'failed')
+ assert.equal(failed?.note, 'no repo picker on the page')
+})
+
+test('the store is a singleton, and resettable for tests (#1328)', () => {
+ resetBridgeStarts()
+ assert.equal(bridgeStarts(), bridgeStarts())
+ bridgeStarts().request(INPUT)
+ assert.equal(bridgeStarts().list().length, 1)
+ resetBridgeStarts()
+ assert.equal(bridgeStarts().list().length, 0)
+})
diff --git a/packages/framework/src/dashboard/bridge-starts.ts b/packages/framework/src/dashboard/bridge-starts.ts
new file mode 100644
index 00000000..3368fd9f
--- /dev/null
+++ b/packages/framework/src/dashboard/bridge-starts.ts
@@ -0,0 +1,121 @@
+import { randomUUID } from 'node:crypto'
+
+/**
+ * The session start-queue (#1328): cloud sessions the daemon wants the browser extension to
+ * create on claude.ai, because a session created through the page's repo picker is repo-bound
+ * and can push and open pull requests, which is what a hand-off is for.
+ *
+ * A run asks for a session and polls until it exists; the extension claims the oldest request,
+ * drives the page, and reports the session it became. The claim is the one subtle part: two
+ * polling tabs handed the same request would create two cloud sessions on the user's account,
+ * so a request leaves the queue in the same step that serves it, and comes back only if nobody
+ * reports on it within {@link START_CLAIM_TTL_MS} — a browser that quit mid-creation must retry,
+ * not brick the run.
+ */
+
+export type BridgeStartState = 'queued' | 'claimed' | 'created' | 'failed'
+
+export interface BridgeStartRequest {
+ id: string
+ /** `owner/name`, as the repo picker lists it. */
+ repo: string
+ /** The branch the session opens on — the run's pushed hand-off ref. */
+ branch: string
+ prompt: string
+ queuedAt: string
+ state: BridgeStartState
+ claimedAt?: string
+ sessionId?: string
+ url?: string
+ note?: string
+}
+
+export interface BridgeStartInput {
+ repo: string
+ branch: string
+ prompt: string
+}
+
+/** How long a claim stands before the request is offered again. */
+export const START_CLAIM_TTL_MS = 90_000
+
+const REPO = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/
+const DOTS_ONLY = /^\.+$/
+const BRANCH = /^[A-Za-z0-9._\-/]{1,255}$/
+/** The hand-off prompt carries the whole framing (system prompt, formats, protocols), so it is long. */
+export const MAX_START_PROMPT = 200_000
+
+export class BridgeStarts {
+ private readonly byId = new Map()
+
+ /** Queue a request, or say what is wrong with it. */
+ request(input: BridgeStartInput, now = new Date()): BridgeStartRequest | string {
+ const repo = input.repo.trim()
+ if (!REPO.test(repo) || repo.split('/').some(segment => DOTS_ONLY.test(segment))) return 'repo must look like owner/name'
+ const branch = input.branch.trim()
+ if (!BRANCH.test(branch)) return 'branch must be a git branch name'
+ const prompt = input.prompt.trim()
+ if (!prompt) return 'prompt must not be empty'
+ if (prompt.length > MAX_START_PROMPT) return `prompt must be at most ${MAX_START_PROMPT} characters`
+ const start: BridgeStartRequest = { id: randomUUID(), repo, branch, prompt, queuedAt: now.toISOString(), state: 'queued' }
+ this.byId.set(start.id, start)
+ return start
+ }
+
+ /** The oldest request nobody holds, claimed for the caller; an expired claim counts as unheld. */
+ claimNext(now = new Date()): BridgeStartRequest | undefined {
+ const stale = now.getTime() - START_CLAIM_TTL_MS
+ const waiting = [...this.byId.values()]
+ .filter(start => start.state === 'queued' || (start.state === 'claimed' && Date.parse(start.claimedAt ?? '') <= stale))
+ .sort((a, b) => a.queuedAt.localeCompare(b.queuedAt))
+ const next = waiting[0]
+ if (!next) return undefined
+ const claimed: BridgeStartRequest = { ...next, state: 'claimed', claimedAt: now.toISOString() }
+ this.byId.set(claimed.id, claimed)
+ return claimed
+ }
+
+ /**
+ * The extension's word on a claimed request. Success without a session id is a failure: a run
+ * pointing nowhere is not a usable outcome. A report on a request nobody holds is ignored — a
+ * tab that died after its claim expired must not overwrite the retry that replaced it.
+ */
+ resolve(id: string, ok: boolean, sessionId?: string, note?: string): void {
+ const start = this.byId.get(id)
+ if (!start || start.state !== 'claimed') return
+ if (ok && !sessionId) {
+ this.byId.set(id, { ...start, state: 'failed', note: note ?? 'reported success without a session id' })
+ return
+ }
+ this.byId.set(id, {
+ ...start,
+ state: ok ? 'created' : 'failed',
+ ...(sessionId ? { sessionId, url: `https://claude.ai/code/${sessionId}` } : {}),
+ ...(note ? { note } : {}),
+ })
+ }
+
+ get(id: string): BridgeStartRequest | undefined {
+ return this.byId.get(id)
+ }
+
+ list(): BridgeStartRequest[] {
+ return [...this.byId.values()].sort((a, b) => b.queuedAt.localeCompare(a.queuedAt))
+ }
+
+ clear(id: string): void {
+ this.byId.delete(id)
+ }
+}
+
+let instance: BridgeStarts | undefined
+
+/** The daemon's one queue: the run's request and the extension's claim must meet in the same store. */
+export function bridgeStarts(): BridgeStarts {
+ if (!instance) instance = new BridgeStarts()
+ return instance
+}
+
+export function resetBridgeStarts(): void {
+ instance = undefined
+}
diff --git a/packages/framework/src/dashboard/bridge-store.SPEC.md b/packages/framework/src/dashboard/bridge-store.SPEC.md
index be1a9aa9..5e689456 100644
--- a/packages/framework/src/dashboard/bridge-store.SPEC.md
+++ b/packages/framework/src/dashboard/bridge-store.SPEC.md
@@ -18,6 +18,7 @@ Holds everything the Claude web bridge carries: the question each cloud session
- **A delivery report is matched by answer identity** - a stale report from a tab that died mid-delivery cannot resolve a newer answer.
- **The transcript is kept by position** - repeat reports of the same message overwrite rather than accumulate, and a bounded number of entries is kept per cloud session.
- **Nothing survives a daemon restart** - held in memory on purpose.
+- **An extension counts as present while it keeps calling** - something let through the bridge within the last few minutes means there is an extension to hand a session request to; a refusal does not count.
- **Contacts are recorded even when refused** - a refused request at least proves something is trying.
## Glossary
diff --git a/packages/framework/src/dashboard/bridge-store.ts b/packages/framework/src/dashboard/bridge-store.ts
index f694a36a..ac4f79a9 100644
--- a/packages/framework/src/dashboard/bridge-store.ts
+++ b/packages/framework/src/dashboard/bridge-store.ts
@@ -55,6 +55,9 @@ export interface BridgeVersion {
at: string
}
+/** How recently the extension must have spoken to count as present. */
+export const EXTENSION_ALIVE_WINDOW_MS = 3 * 60_000
+
export class BridgeQuestions {
private readonly bySession = new Map()
private contact: BridgeContact | undefined
@@ -104,6 +107,17 @@ export class BridgeQuestions {
return this.contact
}
+ /**
+ * Whether an extension is around (#1328): something reached the bridge and was let in within
+ * the window. The worker polls every half minute while it lives, so a longer silence means
+ * there is nobody to drain a request.
+ */
+ extensionAlive(now = new Date(), windowMs = EXTENSION_ALIVE_WINDOW_MS): boolean {
+ const contact = this.contact
+ if (!contact || contact.status >= 400) return false
+ return now.getTime() - Date.parse(contact.at) <= windowMs
+ }
+
private readonly eventsBySession = new Map>()
/**
diff --git a/packages/framework/src/dashboard/index.ts b/packages/framework/src/dashboard/index.ts
index 123a9542..fcd2bb9a 100644
--- a/packages/framework/src/dashboard/index.ts
+++ b/packages/framework/src/dashboard/index.ts
@@ -45,6 +45,8 @@ export { buildOpenQuestions, openChoiceRequest, type OpenQuestion, type OpenQues
export { bridgeChoiceRequest, type BridgeOption, type BridgeQuestion } from './bridge-question.js'
export { buildActivity, activityKey, activityLine, postActivityDiscord, type Activity, type ActivityDeps } from './activity.js'
export { startKeyedWatcher, SeenTracker, type KeyedWatcher, type KeyedWatcherOptions } from './keyed-watcher.js'
-export { BRIDGE_PREFIX, handleBridgeRequest, type BridgeHandlers, type BridgeSession, type BridgeEvent, type BridgeHello } from './bridge-endpoints.js'
+export { BRIDGE_PREFIX, handleBridgeRequest, type BridgeHandlers, type BridgeSession, type BridgeEvent, type BridgeHello, type BridgeStart } from './bridge-endpoints.js'
+export { bridgeStarts, resetBridgeStarts, BridgeStarts, START_CLAIM_TTL_MS, MAX_START_PROMPT, type BridgeStartRequest, type BridgeStartState, type BridgeStartInput } from './bridge-starts.js'
+export { WEB_START_PREFIX, DAEMON_URL_ENV, handleWebStartRequest, type WebStartHandlers } from './web-start-endpoints.js'
export { bridgeSessionsFrom, BRIDGE_SESSION_WINDOW_MS, BRIDGE_SESSION_LIMIT } from './bridge-sessions.js'
export { bridgeQuestions, resetBridgeQuestions, BridgeQuestions, type BridgeContact, type BridgeAnswer } from './bridge-store.js'
diff --git a/packages/framework/src/dashboard/server.SPEC.md b/packages/framework/src/dashboard/server.SPEC.md
index ce991ef5..a6fa1565 100644
--- a/packages/framework/src/dashboard/server.SPEC.md
+++ b/packages/framework/src/dashboard/server.SPEC.md
@@ -10,7 +10,7 @@ The user runs the daemon and opens http://127.0.0.1:4200. Everything the product
- **A broken install says so** - with no built app to serve, every request answers that the dashboard bundle is not installed, rather than half-standing-up.
- **Token guard for a non-loopback bind** - when a token is set, every route needs it; the first visit may carry it in the address, after which it rides a cookie and disappears from the address bar.
- **Browser-borne routes are guarded even on loopback** - the relay and the browser preview apply the same cross-origin and rebound-name checks the calls surface applies, because both change state and the token guard does nothing on a loopback bind.
-- **The bridge authenticates itself** - the bridge routes are checked before the token guard and carry their own token; without one configured they do not exist at all.
+- **The bridge authenticates itself** - the bridge routes, and the routes a web run uses to ask for an extension-created session, are checked before the token guard and carry their own token; without one configured they do not exist at all.
- **Nothing takes the daemon down** - a failure inside the browser preview tears down that one request instead of crashing the process.
- **Closing means closing** - shutting the server down also stops the quota polling and force-closes streaming connections, so it actually finishes.
@@ -76,7 +76,7 @@ The Chrome extension on claude.ai posts what a cloud session is parked on into t
#### Business logic
-The bridge routes are checked before the shared token guard and present their own token instead. When no bridge token is configured the bridge routes do not exist, which is the default. The bridge also gates on the extension's version, refusing a stale extension outright rather than letting it half-work.
+The bridge routes are checked before the shared token guard and present their own token instead. When no bridge token is configured the bridge routes do not exist, which is the default. The bridge also gates on the extension's version, refusing a stale extension outright rather than letting it half-work. The session start-queue is wired behind both of its faces on the same token: the extension's, which claims requests and reports sessions, and the web run's, which queues a request and follows it, and which is told at once when no extension has called recently.
#### Rationale
diff --git a/packages/framework/src/dashboard/server.ts b/packages/framework/src/dashboard/server.ts
index cb6cd5f8..646030cf 100644
--- a/packages/framework/src/dashboard/server.ts
+++ b/packages/framework/src/dashboard/server.ts
@@ -17,6 +17,8 @@ import type { EventsSource, RemoteAgents } from './rpc-serve.js'
import { handleRelayRequest, RELAY_PREFIX, type RelayHandlers } from './relay-endpoints.js'
import { BRIDGE_PREFIX, EXPECTED_EXTENSION_VERSION, handleBridgeRequest, type BridgeHandlers } from './bridge-endpoints.js'
import { bridgeQuestions } from './bridge-store.js'
+import { bridgeStarts } from './bridge-starts.js'
+import { WEB_START_PREFIX, handleWebStartRequest, type WebStartHandlers } from './web-start-endpoints.js'
/** Options for {@link startDashboard}. */
export interface DashboardOptions {
@@ -189,10 +191,27 @@ export function startDashboard(opts: DashboardOptions): Promise {
return pending ? { id: pending.id, text: pending.text } : undefined
},
answered: (sessionId, id, ok, note) => bridgeQuestions().resolveAnswer(sessionId, id, ok, note),
+ // The session start-queue (#1328). The claim happens inside claimNext, not in the route:
+ // two polling tabs handed the same request would create two cloud sessions.
+ start: () => {
+ const next = bridgeStarts().claimNext()
+ return next ? { id: next.id, repo: next.repo, branch: next.branch, prompt: next.prompt } : undefined
+ },
+ started: (id, ok, sessionId, note) => bridgeStarts().resolve(id, ok, sessionId, note),
...(opts.bridgeSessions ? { sessions: opts.bridgeSessions } : {}),
}
: undefined
+ // The run-facing side of the same queue (#1328): a spawned web run asks here, on the same token.
+ const webStartHandlers: WebStartHandlers | undefined = opts.bridgeToken
+ ? {
+ token: opts.bridgeToken,
+ extensionAlive: () => bridgeQuestions().extensionAlive(),
+ request: input => bridgeStarts().request(input),
+ get: id => bridgeStarts().get(id),
+ }
+ : undefined
+
const token = opts.token
const server = createServer((req, res) => {
const pathname = requestPathname(req)
@@ -208,6 +227,10 @@ export function startDashboard(opts: DashboardOptions): Promise {
void handleBridgeRequest(req, res, pathname, bridgeHandlers)
return
}
+ if (pathname === WEB_START_PREFIX || pathname.startsWith(`${WEB_START_PREFIX}/`)) {
+ void handleWebStartRequest(req, res, pathname, webStartHandlers)
+ return
+ }
// #1051: one guard fronting every route on a non-loopback bind; a no-op when no token is set.
if (token !== undefined && !authorizeDaemonRequest(req, res, token)) return
// The device relay (#1067): another daemon posts an agent here and streams its events back.
diff --git a/packages/framework/src/dashboard/web-start-endpoints.SPEC.md b/packages/framework/src/dashboard/web-start-endpoints.SPEC.md
new file mode 100644
index 00000000..1b44c926
--- /dev/null
+++ b/packages/framework/src/dashboard/web-start-endpoints.SPEC.md
@@ -0,0 +1,43 @@
+The run-facing side of the Claude web bridge's session start-queue: how a web run asks its daemon for a cloud session created by the browser extension, and learns what it became.
+
+## User story
+
+A `web`-target agent is a process the daemon spawned, not a part of the daemon, so it cannot reach the daemon's queue directly. It reaches these routes at the address the daemon put in its environment when it spawned it, presenting the daemon token it reads from the registry — and gets an answer it can act on at once: queued, or nobody here to create it.
+
+## Business logic — TL;DR
+
+- **The run presents the daemon token** - the same secret the extension holds; a missing or wrong token is refused before anything is read.
+- **No extension around means no waiting** - when nothing has spoken to the bridge recently, the request is refused on the spot with that reason, so the run hands off another way instead of timing out.
+- **A request is validated as the queue validates it** - repository, branch and prompt must be strings, and the queue's own rules decide the rest.
+- **The run polls the request by its id** - and reads queued, claimed, created with the session and its URL, or failed with the extension's note.
+- **Off with the bridge** - when the browser bridge is off, these routes do not exist either.
+
+## Business logic
+
+### Asking for a session
+
+#### User story
+
+See `## User story`.
+
+#### Business logic
+
+A run posts the repository, the branch and the prompt. With the bridge off the route is not found; without the daemon token it is refused; when no extension has been let through the bridge within the presence window it is refused as a conflict naming that reason; a malformed request is refused with the queue's reason. Otherwise the request is queued and the run is handed its id.
+
+#### Rationale
+
+Refusing at once when no extension is around is what keeps the CLI's cloud flag a working hand-off on a machine without the extension: the run learns in one round trip that it must hand off by itself, rather than after a two-minute wait.
+
+### Following the request
+
+#### User story
+
+The run has nothing to do but wait for the session; it must know the moment the session exists, and must fail with a reason when the extension gave up.
+
+#### Business logic
+
+A run reads its request by id and gets its state, plus the session id and URL once created, or the extension's note once failed. An unknown id, or one that does not look like an id at all, is not found.
+
+## Before modifying/creating SPEC.md files
+
+You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md
diff --git a/packages/framework/src/dashboard/web-start-endpoints.test.SPEC.md b/packages/framework/src/dashboard/web-start-endpoints.test.SPEC.md
new file mode 100644
index 00000000..42c36b60
--- /dev/null
+++ b/packages/framework/src/dashboard/web-start-endpoints.test.SPEC.md
@@ -0,0 +1,10 @@
+What the tests cover: the routes a web run uses to ask its daemon for an extension-created cloud session.
+
+- A run queues a request, reads it back as queued, and — once the extension's side has claimed and reported it — reads the created session's id and URL.
+- A failed creation reads back as failed with the extension's note.
+- With no extension around, the request is refused at once as a conflict naming that reason.
+- The routes demand the daemon token (missing and wrong both refused), refuse a malformed request and a body that is not an object, reject the wrong method, treat an unknown or malformed id as not found, and do not exist when the bridge is off.
+
+## Before modifying/creating SPEC.md files
+
+You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md
diff --git a/packages/framework/src/dashboard/web-start-endpoints.test.ts b/packages/framework/src/dashboard/web-start-endpoints.test.ts
new file mode 100644
index 00000000..9ab7e932
--- /dev/null
+++ b/packages/framework/src/dashboard/web-start-endpoints.test.ts
@@ -0,0 +1,111 @@
+import { strict as assert } from 'node:assert'
+import { test } from 'node:test'
+import { createServer, type Server } from 'node:http'
+import { AddressInfo } from 'node:net'
+import { BridgeStarts } from './bridge-starts.js'
+import { WEB_START_PREFIX, handleWebStartRequest, type WebStartHandlers } from './web-start-endpoints.js'
+
+const TOKEN = 'b'.repeat(43)
+
+async function serve(handlers: WebStartHandlers | undefined): Promise<{ url: string; close: () => Promise }> {
+ const server: Server = createServer((req, res) => {
+ const pathname = new URL(req.url ?? '/', 'http://localhost').pathname
+ void handleWebStartRequest(req, res, pathname, handlers)
+ })
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+ const { port } = server.address() as AddressInfo
+ return {
+ url: `http://127.0.0.1:${port}`,
+ close: () =>
+ new Promise(resolve => {
+ server.closeAllConnections()
+ server.close(() => resolve())
+ }),
+ }
+}
+
+function wired(starts: BridgeStarts, alive = true): WebStartHandlers {
+ return { token: TOKEN, extensionAlive: () => alive, request: input => starts.request(input), get: id => starts.get(id) }
+}
+
+const INPUT = { repo: 'framework/the-framework', branch: 'cloud-1-abcd1234', prompt: 'Add the thing' }
+
+function post(url: string, body: unknown, token: string | null = TOKEN): Promise {
+ return fetch(`${url}${WEB_START_PREFIX}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
+ body: JSON.stringify(body),
+ })
+}
+
+test('a run queues a request and polls it to the session the extension created (#1328)', async () => {
+ const starts = new BridgeStarts()
+ const s = await serve(wired(starts))
+ try {
+ const queued = await post(s.url, INPUT)
+ assert.equal(queued.status, 202)
+ const { id } = (await queued.json()) as { id: string }
+ assert.ok(id)
+
+ const pending = await fetch(`${s.url}${WEB_START_PREFIX}/${id}`, { headers: { authorization: `Bearer ${TOKEN}` } })
+ assert.deepEqual(await pending.json(), { state: 'queued' })
+
+ // The extension's side: claim it and report the session.
+ const claimed = starts.claimNext()
+ assert.equal(claimed?.id, id)
+ starts.resolve(id, true, 'session_01ABCdefGHIjklMNO')
+
+ const done = await fetch(`${s.url}${WEB_START_PREFIX}/${id}`, { headers: { authorization: `Bearer ${TOKEN}` } })
+ assert.deepEqual(await done.json(), {
+ state: 'created',
+ sessionId: 'session_01ABCdefGHIjklMNO',
+ url: 'https://claude.ai/code/session_01ABCdefGHIjklMNO',
+ })
+ } finally {
+ await s.close()
+ }
+})
+
+test('a failure travels back with the extension note (#1328)', async () => {
+ const starts = new BridgeStarts()
+ const s = await serve(wired(starts))
+ try {
+ const { id } = (await (await post(s.url, INPUT)).json()) as { id: string }
+ starts.claimNext()
+ starts.resolve(id, false, undefined, 'no repo picker on the page')
+ const res = await fetch(`${s.url}${WEB_START_PREFIX}/${id}`, { headers: { authorization: `Bearer ${TOKEN}` } })
+ assert.deepEqual(await res.json(), { state: 'failed', note: 'no repo picker on the page' })
+ } finally {
+ await s.close()
+ }
+})
+
+test('no extension around answers 409 at once, not after a timeout (#1328)', async () => {
+ const s = await serve(wired(new BridgeStarts(), false))
+ try {
+ const res = await post(s.url, INPUT)
+ assert.equal(res.status, 409)
+ assert.match(await res.text(), /no browser extension/)
+ } finally {
+ await s.close()
+ }
+})
+
+test('the routes demand the daemon token, validate the request, and 404 when the bridge is off (#1328)', async () => {
+ const starts = new BridgeStarts()
+ const s = await serve(wired(starts))
+ const off = await serve(undefined)
+ try {
+ assert.equal((await post(s.url, INPUT, null)).status, 401)
+ assert.equal((await post(s.url, INPUT, 'wrong')).status, 401)
+ assert.equal((await post(s.url, { ...INPUT, repo: 'not-a-slug' })).status, 400)
+ assert.equal((await post(s.url, 'not an object')).status, 400)
+ assert.equal((await fetch(`${s.url}${WEB_START_PREFIX}`, { headers: { authorization: `Bearer ${TOKEN}` } })).status, 405)
+ assert.equal((await fetch(`${s.url}${WEB_START_PREFIX}/nope`, { headers: { authorization: `Bearer ${TOKEN}` } })).status, 404)
+ assert.equal((await fetch(`${s.url}${WEB_START_PREFIX}/../x`, { headers: { authorization: `Bearer ${TOKEN}` } })).status, 404)
+ assert.equal((await post(off.url, INPUT)).status, 404)
+ } finally {
+ await s.close()
+ await off.close()
+ }
+})
diff --git a/packages/framework/src/dashboard/web-start-endpoints.ts b/packages/framework/src/dashboard/web-start-endpoints.ts
new file mode 100644
index 00000000..e8f71c45
--- /dev/null
+++ b/packages/framework/src/dashboard/web-start-endpoints.ts
@@ -0,0 +1,93 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { bearerAuthorized, readJsonBody } from './bridge-endpoints.js'
+import type { BridgeStartInput, BridgeStartRequest } from './bridge-starts.js'
+
+/**
+ * The run-facing side of the session start-queue (#1328): how a web run asks the daemon for a
+ * cloud session created by the browser extension, and learns what it became.
+ *
+ * A run is a separate process the daemon spawned, so it cannot touch the daemon's queue directly;
+ * it reaches these routes at the URL the daemon put in its environment ({@link DAEMON_URL_ENV}),
+ * presenting the daemon token it reads from the registry. The extension's own side of the same
+ * queue lives on `/_bridge/start` and `/_bridge/started`.
+ */
+
+export const WEB_START_PREFIX = '/_web-start'
+
+/** The environment variable a daemon-spawned run finds its daemon's URL in. */
+export const DAEMON_URL_ENV = 'TF_DAEMON_URL'
+
+/** What the daemon wires behind the routes. Absent when the bridge is off, which 404s them. */
+export interface WebStartHandlers {
+ /** The daemon token every call must present — the same one the extension holds. */
+ token: string
+ /** Whether an extension has spoken to this daemon recently enough to be trusted with a request. */
+ extensionAlive: () => boolean
+ /** Queue a request, or say what is wrong with it. */
+ request: (input: BridgeStartInput) => BridgeStartRequest | string
+ get: (id: string) => BridgeStartRequest | undefined
+}
+
+const MAX_BODY = 512 * 1024
+const ID = /^[A-Za-z0-9-]{1,64}$/
+
+/** Route a `/_web-start*` request. */
+export async function handleWebStartRequest(
+ req: IncomingMessage,
+ res: ServerResponse,
+ pathname: string,
+ handlers: WebStartHandlers | undefined,
+): Promise {
+ if (!handlers) return end(res, 404, 'bridge not enabled')
+ if (!bearerAuthorized(req, handlers.token)) return end(res, 401, 'unauthorized')
+ if (pathname === WEB_START_PREFIX) return handleRequest(req, res, handlers)
+ const id = pathname.slice(WEB_START_PREFIX.length + 1)
+ if (pathname.startsWith(`${WEB_START_PREFIX}/`) && ID.test(id)) return handleState(req, res, id, handlers)
+ end(res, 404, 'not found')
+}
+
+/**
+ * `POST /_web-start`: queue a session request. 409 when no extension is around to drain the
+ * queue — the caller then has its answer at once rather than after a timeout, and can hand off
+ * another way.
+ */
+async function handleRequest(req: IncomingMessage, res: ServerResponse, handlers: WebStartHandlers): Promise {
+ if (req.method !== 'POST') return end(res, 405, 'method not allowed', { allow: 'POST' })
+ if (!handlers.extensionAlive()) return end(res, 409, 'no browser extension has spoken to this daemon recently')
+ let body: unknown
+ try {
+ body = await readJsonBody(req, MAX_BODY)
+ } catch (err) {
+ return end(res, 400, (err as Error).message)
+ }
+ if (typeof body !== 'object' || body === null) return end(res, 400, 'body must be an object')
+ const { repo, branch, prompt } = body as Record
+ if (typeof repo !== 'string' || typeof branch !== 'string' || typeof prompt !== 'string') {
+ return end(res, 400, 'repo, branch and prompt must be strings')
+ }
+ const queued = handlers.request({ repo, branch, prompt })
+ if (typeof queued === 'string') return end(res, 400, queued)
+ res.writeHead(202, { 'content-type': 'application/json' })
+ res.end(JSON.stringify({ id: queued.id }))
+}
+
+/** `GET /_web-start/`: where the request stands. */
+async function handleState(req: IncomingMessage, res: ServerResponse, id: string, handlers: WebStartHandlers): Promise {
+ if (req.method !== 'GET') return end(res, 405, 'method not allowed', { allow: 'GET' })
+ const start = handlers.get(id)
+ if (!start) return end(res, 404, 'no such start request')
+ res.writeHead(200, { 'content-type': 'application/json' })
+ res.end(
+ JSON.stringify({
+ state: start.state,
+ ...(start.sessionId ? { sessionId: start.sessionId } : {}),
+ ...(start.url ? { url: start.url } : {}),
+ ...(start.note ? { note: start.note } : {}),
+ }),
+ )
+}
+
+function end(res: ServerResponse, status: number, message: string, headers: Record = {}): void {
+ res.writeHead(status, { 'content-type': 'text/plain', ...headers })
+ res.end(message)
+}
diff --git a/packages/framework/src/driver/cloud.SPEC.md b/packages/framework/src/driver/cloud.SPEC.md
index f44cb6b3..7db8e3a1 100644
--- a/packages/framework/src/driver/cloud.SPEC.md
+++ b/packages/framework/src/driver/cloud.SPEC.md
@@ -11,6 +11,7 @@ The user wants a task done without it costing anything on their own machine —
## Business logic — TL;DR
- **The user's own account does the work** - the hand-off goes through the Claude Code CLI's own cloud mode, so the account, the sign-in, and the quota are the user's, exactly as for a local agent. Nothing here drives the claude.ai website: no browser, no extension, no scraping.
+- **The browser extension creates the session when it can** - a run its daemon spawned asks the daemon for a session created through claude.ai's own repository picker, which is bound to the repository and can push and open the pull request; only when no extension is around, the bridge is off, the repository has no GitHub remote or the starting point could not be pushed does the CLI's own cloud mode hand off instead.
- **One agent, exactly one cloud session** - the first prompt hands off; every later prompt says the work is already over there and spends nothing.
- **The hand-off prompt is written for a human to read** - the task comes first, and everything The Framework injects follows behind a labelled rule.
- **The project is trusted on the user's behalf** - starting a `web` agent is itself the user's decision to trust the project, so Claude Code's one-time trust question is answered ahead of time instead of blocking the hand-off.
@@ -20,6 +21,20 @@ The user wants a task done without it costing anything on their own machine —
## Business logic
+### The browser extension creates the session when it can
+
+#### User story
+
+The user runs the browser extension. Their web runs should land as sessions that can push and open pull requests, without them doing anything on claude.ai — and on a machine without the extension, web runs should still work.
+
+#### Business logic
+
+After the starting point is pushed and the project trusted, a run that knows its daemon's address asks the daemon to queue a session request naming the repository as `owner/name` (read from the checkout's GitHub remote), the pushed starting-point ref, and the whole hand-off prompt. It then follows the request until the extension reports the session, and hands off exactly as the CLI path does — the same link, the same summary, the same single hand-off per agent. Four things make the run hand off through the CLI's cloud mode instead, each announced in the agent's log: no GitHub remote to name in the repository picker; a starting point that could not be pushed, since the session must open on that ref; a daemon that answers no extension is around; or a daemon with the bridge off. An extension that tried and could not create the session fails the turn with the extension's own note of what it could not find — never a silent second attempt through the CLI. The wait shares the hand-off's overall timeout.
+
+#### Rationale
+
+A session created through the page's repository picker is repo-bound; the CLI's cloud mode has, on some accounts, produced a bundle upload that could never push (#1320). The extension path is the one that ends in a pull request, so it goes first, and the CLI path stays only for the machines and cases where it cannot run.
+
### One agent, exactly one cloud session
#### User story
diff --git a/packages/framework/src/driver/cloud.test.SPEC.md b/packages/framework/src/driver/cloud.test.SPEC.md
index f2dffa7c..afef24e6 100644
--- a/packages/framework/src/driver/cloud.test.SPEC.md
+++ b/packages/framework/src/driver/cloud.test.SPEC.md
@@ -8,6 +8,7 @@ What the tests cover: handing an agent's task to a cloud session on claude.ai an
- If Claude Code's trust question appears anyway, the agent fails immediately naming the one-time manual fix, and both the failure and the notice name the project root rather than the throwaway worktree the user could never act on — including when the agent runs from a worktree, whose root is derived without guessing at unrelated paths.
- An empty hand-off anchor commit is created on top of the checkout without moving any branch, pushed to the remote under the agent's own slash-free identifier, and the cloud session is told to clone that ref; the anchor is recorded on the agent's result so its branch can be recognized later. A push that fails falls back to no ref, says so, names how to recover the work by hand, and still hands off — recording no anchor, since nothing reached the remote.
- Nothing the user typed reaches a shell as syntax: the prompt and model travel through the environment, the task sits directly after the cloud flag with nothing allowed in between, a plain model name is passed through, and an unsafe one is refused before anything is started at all.
+- With an extension around, the session comes from the daemon's start-queue: the request names the repository from the GitHub remote, the pushed starting-point ref and the whole prompt, the request is followed until created, the CLI's cloud mode never runs, and the agent's record carries the session link as on the CLI path. When the daemon answers that no extension is around, the CLI's cloud mode hands off instead and the log says why; a checkout with no GitHub remote never asks. An extension that tried and failed fails the turn carrying the extension's note, with no CLI retry.
- The CLI's non-essential traffic is switched off, which is what keeps the cloud session bound to the repository.
- A hand-off that created no cloud session fails carrying what the CLI actually said.
- Two agents never share an identifier; a disposed agent refuses further prompts; an agent already stopped fails before starting anything.
diff --git a/packages/framework/src/driver/cloud.test.ts b/packages/framework/src/driver/cloud.test.ts
index 7164c5db..52728c86 100644
--- a/packages/framework/src/driver/cloud.test.ts
+++ b/packages/framework/src/driver/cloud.test.ts
@@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { isHandsOff } from '../agent-location.js'
-import { CLOUD_COMMAND, CLOUD_ENV, CLOUD_PROMPT_SEPARATOR, CloudDriver, cloudHandOffPrompt, trustRootOf, type AgentPtyOptions } from './cloud.js'
+import { CLOUD_COMMAND, CLOUD_ENV, CLOUD_PROMPT_SEPARATOR, CloudDriver, cloudHandOffPrompt, trustRootOf, type AgentPtyOptions, type ExtensionStart } from './cloud.js'
import type { DriverEvent } from './types.js'
/**
@@ -390,3 +390,88 @@ test('the ref rides the fixed command as its own guarded flag, after the model (
test('the invocation disables nonessential traffic, which is what keeps the session repo-bound (#1320)', () => {
assert.equal(CLOUD_ENV['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'], '1')
})
+
+// ---------------------------------------------------------------------------
+// The extension-created session (#1328): the run asks its daemon, the extension does the page.
+
+/** A git runner whose origin is on GitHub, so the repo picker has a slug to look for. */
+function githubGit(calls: string[][] = []) {
+ return {
+ calls,
+ run: async (args: string[], _cwd: string): Promise => {
+ calls.push([...args])
+ if (args[0] === 'commit-tree') return `${ANCHOR}\n`
+ if (args[0] === 'remote') return 'git@github.com:framework/the-framework.git\n'
+ return ''
+ },
+ }
+}
+
+/**
+ * A daemon whose start-queue answers as scripted: the POST gets `queue`, then each poll pops the
+ * next `states` entry (the last one repeats). Records every request the run made.
+ */
+function fakeDaemon(queue: { status: number; body?: unknown }, states: unknown[]) {
+ const requests: { method: string; path: string; body?: unknown }[] = []
+ const doFetch = (async (input: string | URL | Request, init?: RequestInit) => {
+ const path = new globalThis.URL(String(input)).pathname
+ const method = init?.method ?? 'GET'
+ requests.push({ method, path, ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}) })
+ if (method === 'POST') {
+ return new Response(queue.body === undefined ? 'nope' : JSON.stringify(queue.body), { status: queue.status })
+ }
+ const state = states.length > 1 ? states.shift() : states[0]
+ return new Response(JSON.stringify(state), { status: 200 })
+ }) as typeof fetch
+ return { requests, fetch: doFetch }
+}
+
+function extensionDriver(daemon: ReturnType, calls: AgentPtyOptions[] = [], git = githubGit()) {
+ const pty = fakePty(CREATED, calls)
+ const extension: ExtensionStart = { daemonUrl: 'http://127.0.0.1:4200/', token: 'tok', fetch: daemon.fetch, pollMs: 1 }
+ return new CloudDriver({ extension, runPty: pty.run, git: git.run, agentTag: () => 'tag', timeoutMs: 1000, claudeConfig: tmpClaudeConfig('/repo') })
+}
+
+test('with an extension around, the session comes from the start-queue and the CLI never runs (#1328)', async () => {
+ const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [{ state: 'queued' }, { state: 'claimed' }, { state: 'created', sessionId: SESSION, url: `https://claude.ai/code/${SESSION}` }])
+ const calls: AgentPtyOptions[] = []
+ const events: DriverEvent[] = []
+ const session = await extensionDriver(daemon, calls).start({ cwd: '/repo', onEvent: e => events.push(e) })
+ const turn = await session.prompt('Add the --verbose flag')
+ assert.equal(turn.sessionId, SESSION)
+ assert.equal(calls.length, 0, 'no --cloud invocation')
+ // The request names the repo the picker lists, the pushed hand-off ref, and the whole prompt.
+ const post = daemon.requests.find(r => r.method === 'POST')
+ assert.deepEqual(post?.body, { repo: 'framework/the-framework', branch: session.id, prompt: 'Add the --verbose flag' })
+ assert.equal(daemon.requests.filter(r => r.method === 'GET').length, 3, 'polled until created')
+ assert.ok(events.some(e => e.type === 'result' && e.sessionLink === `https://claude.ai/code/${SESSION}`))
+ assert.ok(events.some(e => e.type === 'notice' && /asked the browser extension/.test(e.message)))
+})
+
+test('no extension around (409) hands off through the CLI instead (#1328)', async () => {
+ const daemon = fakeDaemon({ status: 409 }, [])
+ const calls: AgentPtyOptions[] = []
+ const events: DriverEvent[] = []
+ const session = await extensionDriver(daemon, calls).start({ cwd: '/repo', onEvent: e => events.push(e) })
+ const turn = await session.prompt('go')
+ assert.equal(turn.sessionId, SESSION, 'the CLI path created it')
+ assert.equal(calls.length, 1)
+ assert.ok(events.some(e => e.type === 'notice' && /no browser extension is around/.test(e.message)))
+})
+
+test('a checkout with no GitHub remote never asks the extension (#1328)', async () => {
+ const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [])
+ const calls: AgentPtyOptions[] = []
+ const session = await extensionDriver(daemon, calls, fakeGit()).start({ cwd: '/repo' })
+ await session.prompt('go')
+ assert.equal(daemon.requests.length, 0, 'nothing to name in the repo picker')
+ assert.equal(calls.length, 1, 'the CLI path ran')
+})
+
+test('an extension that tried and failed fails the turn with its note, not a silent CLI retry (#1328)', async () => {
+ const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [{ state: 'failed', note: 'no repo picker on the page' }])
+ const calls: AgentPtyOptions[] = []
+ const session = await extensionDriver(daemon, calls).start({ cwd: '/repo' })
+ await assert.rejects(session.prompt('go'), /could not create the session — no repo picker on the page/)
+ assert.equal(calls.length, 0)
+})
diff --git a/packages/framework/src/driver/cloud.ts b/packages/framework/src/driver/cloud.ts
index 9672f588..0747b8d1 100644
--- a/packages/framework/src/driver/cloud.ts
+++ b/packages/framework/src/driver/cloud.ts
@@ -8,6 +8,8 @@ import { makeEmit } from './session-support.js'
import { nodeGitRunner, type GitRunner } from '../project.js'
import { readClaudeTrust, writeClaudeTrust } from '../claude-trust.js'
import { errorMessage } from '../error-message.js'
+import { githubSlugFor } from '../dashboard/github.js'
+import { WEB_START_PREFIX } from '../dashboard/web-start-endpoints.js'
import type { Driver, DriverEvent, DriverPromptOptions, DriverSession, DriverStartOptions, DriverTurn } from './types.js'
/**
@@ -51,8 +53,26 @@ export class CloudDriver implements Driver {
// already reports, and there is nothing extra to ask a session we cannot query.
}
+/**
+ * How a run reaches its daemon's session start-queue (#1328): the daemon's URL, put in the run's
+ * environment when it was spawned, and the daemon token from the registry.
+ */
+export interface ExtensionStart {
+ daemonUrl: string
+ token: string
+ /** Injected in tests; defaults to the global `fetch`. */
+ fetch?: typeof fetch
+ /** How often to ask where the request stands, in ms. Default 2000. */
+ pollMs?: number
+}
+
/** Options for {@link CloudDriver}. */
export interface CloudDriverOptions {
+ /**
+ * Ask the daemon for a session created by the browser extension (#1328) before falling back
+ * to the CLI's `--cloud`. Absent on a run no daemon spawned, which has no daemon to ask.
+ */
+ extension?: ExtensionStart
/** Claude Code binary. Default `"claude"`. */
bin?: string
/** Give up on session creation after this long, in ms. Default 120000. */
@@ -281,6 +301,23 @@ export class CloudSession implements DriverSession {
let output = ''
let trusting = false
let found: { url: string; sessionId: string } | undefined
+ // The extension path first (#1328): a session created through the page's repo picker is
+ // repo-bound, which is what lets it push and open the pull request. Undefined means the
+ // daemon had nobody to hand the request to, and the CLI's own `--cloud` does the hand-off.
+ if (this.config.extension) {
+ try {
+ found = await this.createViaExtension(this.config.extension, full, ref, git, controller.signal)
+ } finally {
+ if (found) {
+ clearTimeout(timer)
+ this.controllers.delete(controller)
+ }
+ }
+ }
+ if (found) {
+ this.handedOff = found
+ return this.report(found, 'first')
+ }
try {
await (this.config.runPty ?? runPtyWithScript)({
bin: this.config.bin ?? 'claude',
@@ -332,6 +369,65 @@ export class CloudSession implements DriverSession {
return this.report(found, 'first')
}
+ /**
+ * Hand the session request to the daemon's start-queue and wait for the extension's word
+ * (#1328). Resolves undefined — the CLI path then runs — when the daemon has no extension to
+ * ask (409), no queue at all (404), when this checkout has no GitHub remote for the repo picker
+ * to name, or when the hand-off ref was never pushed. Throws when the extension tried and
+ * failed, naming what it could not find, or when the wait was aborted.
+ */
+ private async createViaExtension(
+ ext: ExtensionStart,
+ prompt: string,
+ ref: string | undefined,
+ git: GitRunner,
+ signal: AbortSignal,
+ ): Promise<{ url: string; sessionId: string } | undefined> {
+ const slug = await githubSlugFor(this.cwd, git)
+ if (!slug || !ref) {
+ this.emit({
+ type: 'notice',
+ message: `[framework] claude-web: ${slug ? 'the hand-off ref was not pushed' : 'no GitHub remote here'}, so the browser extension cannot create the session — handing off through the CLI instead.`,
+ })
+ return undefined
+ }
+ const doFetch = ext.fetch ?? fetch
+ const base = ext.daemonUrl.replace(/\/+$/, '')
+ const headers = { authorization: `Bearer ${ext.token}` }
+ const queued = await doFetch(`${base}${WEB_START_PREFIX}`, {
+ method: 'POST',
+ headers: { ...headers, 'content-type': 'application/json' },
+ body: JSON.stringify({ repo: `${slug.owner}/${slug.repo}`, branch: ref, prompt }),
+ signal,
+ })
+ if (queued.status === 409 || queued.status === 404) {
+ this.emit({
+ type: 'notice',
+ message: `[framework] claude-web: ${queued.status === 409 ? 'no browser extension is around' : 'the daemon has the browser bridge off'}, so the CLI hands off instead.`,
+ })
+ return undefined
+ }
+ if (!queued.ok) throw new Error(`[framework] claude-web: the daemon refused the session request (${queued.status}): ${(await queued.text()).slice(0, 300)}`)
+ const { id } = (await queued.json()) as { id: string }
+ this.emit({ type: 'notice', message: `[framework] claude-web: asked the browser extension to create the cloud session on ${slug.owner}/${slug.repo} at ${ref} (request ${id}).` })
+
+ const pollMs = ext.pollMs ?? 2000
+ for (;;) {
+ if (signal.aborted) throw new Error('[framework] claude-web prompt aborted')
+ const res = await doFetch(`${base}${WEB_START_PREFIX}/${id}`, { headers, signal })
+ if (!res.ok) throw new Error(`[framework] claude-web: lost the session request ${id} (${res.status})`)
+ const state = (await res.json()) as { state: string; sessionId?: string; url?: string; note?: string }
+ if (state.state === 'created' && state.sessionId && state.url) return { url: state.url, sessionId: state.sessionId }
+ if (state.state === 'failed') {
+ throw new Error(`[framework] claude-web: the browser extension could not create the session${state.note ? ` — ${state.note}` : ''}.`)
+ }
+ await new Promise(resolve => {
+ const t = setTimeout(resolve, pollMs)
+ signal.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
+ })
+ }
+ }
+
/**
* Report where this agent went. The `first` hand-off emits the `cloud ` action the agent
* view links through to — mirroring the Actions driver's `run ` — and a later pass