Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,10 @@ happens while nobody is at the keyboard.
- 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; without the extension (or with the browser bridge off) the run stops and says which is missing
- A web run runs on the model chosen for it — the launcher's or Settings' model is picked in claude.ai's model menu before the session is sent, and a model the menu does not offer stops the run saying so
- Chrome extension bridging claude.ai questions back to the dashboard
- One pinned "The Framework Driver" tab serves every recent cloud session: it reads claude.ai's own session list (the status beside each session), visits a session when the list's word for it changed to awaiting input, unread or idle, an awaiting one again every five minutes, and any session holding a queued answer — navigating inside the app, one page load a minute — and shows a full-page overlay naming what it is, with collapsible debug logs; closing the tab pauses the bridge until the extension's options page reopens it or the browser restarts
- The extension's options page shows what its last cycle did, or why it did nothing — including that it reloaded itself because its files changed on disk — and keeps the line current while the page is open
- The extension reloads itself when its files change on disk: an edit in the checkout is running within half a minute, never mid-cycle, with no trip to chrome://extensions
- A cloud run's row says "waiting" when claude.ai's session list shows its session awaiting input, even when the question was asked in prose rather than as a choice block
- A cloud session's conversation mirrored into the run view, turn by turn, as it is written
Expand Down
4 changes: 2 additions & 2 deletions packages/chrome-extension/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Six parts: the content script (the page half — reads claude.ai's session list
- **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, in the Driver tab 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.
- **It creates the sessions the daemon asks for** - a web run's cloud session is created here, in the Driver tab 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, and on the model the run named when it named one; the branch and the model are verified before anything is sent.
- **One Driver tab for every session** - the daemon publishes which cloud sessions are its; the extension keeps one pinned, inactive tab (opt-in) that reads claude.ai's session list, visits only the sessions waiting on their user or holding a queued answer — navigating inside the app, with one page load a minute to refresh the list — reports each session's list status to the daemon, and covers itself with a full-page overlay saying what it is, with collapsible debug logs. Closing the tab pauses the bridge until the options page reopens it or the browser restarts.
- **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.
Expand Down Expand Up @@ -62,7 +62,7 @@ A web run wants a cloud session that can push its work and open a pull request.

#### Business logic

Each cycle the service worker claims the daemon's next session request, if any. The Driver returns to claude.ai's new-session page at the end of the cycle's visits and is handed the request — repository, branch, prompt — and its content script 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 per cycle. The created session is one of the daemon's from the next cycle on.
Each cycle the service worker claims the daemon's next session request, if any. The Driver returns to claude.ai's new-session page at the end of the cycle's visits and is handed the request — repository, branch, prompt, and the model when the run named one — and its content script chooses the repository, makes sure the branch chip reads the requested branch, picks the model in the page's model menu when one was named (a menu that does not offer it fails the creation rather than sending on the page's default), 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 per cycle. The created session is one of the daemon's from the next cycle on.

#### Rationale

Expand Down
2 changes: 1 addition & 1 deletion packages/chrome-extension/background.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ A `web`-target agent hands its task to a cloud session and ends; nothing streams
- **A Driver that cannot hear the worker is reloaded** - the tab is the extension's own, so an orphaned script is revived by a reload rather than reported; a tab that is gone, or a page torn down mid-drive, fails the cycle instead, with every answer handed over accounted for.
- **An acknowledgement is retried until it lands** - a lost acknowledgement would leave an answer sitting as queued in the dashboard after it was already typed.
- **Closing the Driver tab pauses the bridge** - until the options page reopens it or the browser restarts; closing its window does not. A tab someone moved off claude.ai is forgotten, never brought back, and after a restart a lone pinned claude.ai tab is taken to be the Driver Chrome restored rather than doubled.
- **Sessions are created in the Driver tab** - the worker claims the daemon's next session request each cycle and the Driver creates it first thing, before the visits, from claude.ai's new-session page the cycle starts on; the outcome is reported under the request's id, and a Driver that is off or paused reports the request failed at once rather than leaving it queued.
- **Sessions are created in the Driver tab** - the worker claims the daemon's next session request each cycle — repository, branch, prompt, and the model when the run named one — and the Driver creates it first thing, before the visits, from claude.ai's new-session page the cycle starts on; the outcome is reported under the request's id, and a Driver that is off or paused reports the request failed at once rather than leaving it queued.
- **Every cycle records what it did** - the outcome of the last cycle is kept so the options page can state it, including every reason a cycle did nothing.
- **It reloads itself when its files change** - the worker fingerprints the extension's own files when it starts and again every beat; when any of them changed on disk it reloads the extension, never in the middle of a cycle, and records the reload as the last cycle's outcome.

Expand Down
6 changes: 5 additions & 1 deletion packages/chrome-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,11 @@ async function runCycle() {
for (const visit of visits) if (visit.answer) deliveredAnswers.add(visit.answer.id)
const driven =
visits.length || start
? await askDriver(tab.id, { type: 'tf-drive', visits, ...(start ? { start: { repo: start.repo, branch: start.branch, prompt: start.prompt } } : {}) })
? await askDriver(tab.id, {
type: 'tf-drive',
visits,
...(start ? { start: { repo: start.repo, branch: start.branch, prompt: start.prompt, ...(typeof start.model === 'string' ? { model: start.model } : {}) } } : {}),
})
: { ok: true, visited: [], delivered: [] }
if (driven.busy) {
// The page is still on an earlier cycle's drive — a worker that ended mid-cycle leaves the
Expand Down
109 changes: 102 additions & 7 deletions packages/chrome-extension/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -334,25 +334,67 @@ async function deliver(body, prepare) {
// 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 } = {}) {
// anything is sent, the model is picked when the request names one (#1697), 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,
models = ['Fable 5', 'Opus 5', 'Sonnet 5', 'Haiku 4.5'],
moreModels = ['Opus 4.8', 'Sonnet 4.6'],
currentModel = 'Opus 5',
modelPicker = true,
} = {}) {
// 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.
// options; a closed picker's options stay in the DOM. The model picker as observed 2026-08-26:
// a menu button reading the current model beside an effort button reading "High"; its menu is
// radio items whose label sits in a span beside a hidden shortcut digit and, on the current one,
// a hidden check glyph; a "More models" item opens a submenu of older versions on click.
const dom = new JSDOM(
`<!doctype html><html><body><main>
<button id="env">Default</button>
${remembered ? `<button role="combobox" id="repo">${glyphs ? '\ue048' : ''}${remembered}</button><button role="combobox" id="branch">${glyphs ? '\ue078' : ''}main</button>` : repoPicker ? '<button id="select">+ Select repo…</button>' : ''}
<button role="combobox" aria-label="Add repository"></button>
<div contenteditable="true"></div>
<button aria-label="Send message" id="send"></button>
${modelPicker ? `<button aria-haspopup="menu" aria-expanded="false" id="model">${currentModel}</button>` : ''}
<button aria-haspopup="menu" aria-expanded="false" id="effort">High</button>
</main></body></html>`,
{ url: 'https://claude.ai/code', runScripts: 'outside-only' },
)
const w = dom.window
const d = w.document
const seen = { sent: false, searched: [] }
const seen = { sent: false, searched: [], modelOpened: 0 }
const modelItem = (label, i, withShortcut) => {
const current = label === d.getElementById('model').textContent
return `<div role="menuitemradio" aria-checked="${current}"><span>${label}</span><span><span aria-hidden="true">${current ? '\ue03b' : ''}</span>${withShortcut ? `<span aria-hidden="true"><kbd>${i + 1}</kbd></span>` : ''}</span></div>`
}
const wireModelItems = menu => {
for (const item of menu.querySelectorAll('[role="menuitemradio"]')) {
item.addEventListener('click', () => {
d.getElementById('model').textContent = item.querySelector('span').textContent
for (const open of d.querySelectorAll('[role="menu"]')) open.remove()
})
}
}
d.getElementById('model')?.addEventListener('click', () => {
seen.modelOpened++
const menu = d.createElement('div')
menu.setAttribute('role', 'menu')
menu.innerHTML = `<div>Models</div>${models.map((m, i) => modelItem(m, i, true)).join('')}<div role="menuitem" aria-haspopup="menu"><span>More models</span><span aria-hidden="true">\ue0a4</span></div>`
wireModelItems(menu)
menu.querySelector('[role="menuitem"]').addEventListener('click', () => {
const sub = d.createElement('div')
sub.setAttribute('role', 'menu')
sub.innerHTML = moreModels.map((m, i) => modelItem(m, i, false)).join('')
wireModelItems(sub)
d.body.append(sub)
})
d.body.append(menu)
})
const openList = (placeholder, entries, onPick) => {
const dialog = d.createElement('div')
dialog.setAttribute('role', 'dialog')
Expand Down Expand Up @@ -401,9 +443,62 @@ const START = { repo: 'framework/the-framework', branch: 'cloud-1-abcd', prompt:
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
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 && seen.modelOpened === 0
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} create with the repo remembered picks the branch, types the prompt and sends, and leaves the model picker alone (branch=${branch}, searched=${JSON.stringify(seen.searched)}, modelOpened=${seen.modelOpened}, result=${JSON.stringify(result)})`)
dom.window.close()
}

{
// A request naming a model (#1697): the picker is opened and the entry for that model clicked
// before anything is sent; the note reports what was clicked, and the trigger reads it.
const { dom, w, d, seen } = newSessionPage()
const result = await w.__tfBridgeCreateSession({ ...START, model: 'sonnet' })
const model = d.getElementById('model')?.textContent
const ok = result.ok && model === 'Sonnet 5' && /model: clicked "Sonnet 5"/.test(result.note) && seen.sent && seen.modelOpened === 1
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} create picks the requested model in the model menu before sending (model=${model}, result=${JSON.stringify(result)})`)
dom.window.close()
}

{
// The picker already reads the requested model: nothing is opened, and the note says so.
const { dom, w, d, seen } = newSessionPage({ currentModel: 'Opus 5' })
const result = await w.__tfBridgeCreateSession({ ...START, model: 'opus' })
const ok = result.ok && d.getElementById('model')?.textContent === 'Opus 5' && /model already Opus 5/.test(result.note) && seen.sent && seen.modelOpened === 0
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} create leaves a model picker that already reads the requested model alone (result=${JSON.stringify(result)})`)
dom.window.close()
}

{
// An older version lives behind "More models": the submenu is opened and searched too.
const { dom, w, d, seen } = newSessionPage()
const result = await w.__tfBridgeCreateSession({ ...START, model: 'Opus 4.8' })
const model = d.getElementById('model')?.textContent
const ok = result.ok && model === 'Opus 4.8' && /model: clicked "Opus 4.8" via More models/.test(result.note) && seen.sent
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} create finds an older model behind "More models" (model=${model}, result=${JSON.stringify(result)})`)
dom.window.close()
}

{
// The menu does not offer the model: nothing is sent, and the note names the model and what was offered.
const { dom, w, d, seen } = newSessionPage()
const result = await w.__tfBridgeCreateSession({ ...START, model: 'gpt-9' })
const ok = !result.ok && /model: the menu offered no "gpt-9"/.test(result.note) && /Fable 5/.test(result.note) && !seen.sent && d.getElementById('model')?.textContent === 'Opus 5'
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} create refuses to send on a model the menu does not offer (sent=${seen.sent}, note=${JSON.stringify(result.note)})`)
dom.window.close()
}

{
// No model picker on the page at all: a request naming a model is not sent on whatever the page defaults to.
const { dom, w, seen } = newSessionPage({ modelPicker: false })
const result = await w.__tfBridgeCreateSession({ ...START, model: 'sonnet' })
const ok = !result.ok && /no model picker on the page/.test(result.note) && !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)})`)
console.log(`${ok ? 'PASS' : 'FAIL'} create names a missing model picker rather than sending on the default (sent=${seen.sent}, note=${result.note.slice(0, 80)}…)`)
dom.window.close()
}

Expand Down
Loading
Loading