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
1 change: 1 addition & 0 deletions FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ happens while nobody is at the keyboard.
- 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
- 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 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
- 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
Expand Down
12 changes: 8 additions & 4 deletions packages/chrome-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ the Driver: reading the session list by label and paging it, a cycle that visits
the question, types and waits for the send, then returns to the list under the overlay, an answer
the page did not take, a session missing from the list, and the visit planner.

After editing any file here, reload the extension on `chrome://extensions` AND reload the open
claude.ai tabs: reloading the extension does not re-inject content scripts, and an orphaned
script cannot hear the new worker. The panel shows the manifest version, which is how you tell
a stale script from a current one.
After editing any file here, nothing to click: the worker fingerprints the extension's files
every beat (30 s) and reloads the extension itself when any changed, never mid-cycle
(#1711). The Driver tab gets the new content script on the next cycle's page load; your own
claude.ai tabs still need a reload, since reloading the extension does not re-inject content
scripts and an orphaned script cannot hear the new worker. The panel shows the manifest
version, which is how you tell a stale script from a current one. Leave developer mode on in
`chrome://extensions`: with it off, Chrome 137+ disables an unpacked extension on reload instead
of reloading it, and only a click there brings it back.
17 changes: 16 additions & 1 deletion packages/chrome-extension/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ It exists because an agent with run target `web` is hands-off: the daemon hands

A cloud session asks the same way a local agent does — its gates are never framed away. The bridge is what carries the question home; with the bridge off, the question simply waits in the session on claude.ai until the user finds it there.

Five parts: the content script (the page half — reads claude.ai's session list and session pages, types answers, draws the Driver overlay), the service worker (the daemon half — holds the token, makes every daemon call, runs the Driver cycle), the visit planner the worker and the harness share, the options page (setup and connection proof), and an offline check harness that proves the reading, driving and typing against synthetic pages without a browser.
Six parts: the content script (the page half — reads claude.ai's session list and session pages, types answers, draws the Driver overlay), the service worker (the daemon half — holds the token, makes every daemon call, runs the Driver cycle, reloads the extension when its files change), the visit planner and the file fingerprint the worker and the harness share, the options page (setup and connection proof), and an offline check harness that proves the reading, driving, typing and the fingerprint against synthetic pages without a browser.

## User story

Expand All @@ -29,6 +29,7 @@ Five parts: the content script (the page half — reads claude.ai's session list
- **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.
- **It reloads itself when its files change** - the extension is unpacked and edited in place; the service worker fingerprints its own files every beat and reloads the extension when any changed on disk, never mid-cycle, so an edit in the checkout is running within half a minute without a visit to chrome://extensions.
- **Where it runs and why each permission exists** - a content script on every claude.ai page and frame; host access to the localhost origins for the worker's CORS-free daemon calls; storage, tabs and alarms for the token, the Driver tab's bookkeeping, and a cycle that survives the worker's idle termination.

## Business logic
Expand Down Expand Up @@ -103,6 +104,20 @@ See `## User story`, first item — a bridge that half-works is worse than one t

Every daemon call states the extension's version in the `x-tf-extension-version` header. A daemon expecting a different version refuses the call outright with an error naming both versions and the way out, because a version-skewed extension does not fail loudly — it half-works, which reads as dashboard bugs. The options page shows that refusal verbatim. The extension and the daemon must therefore ship the same version number.

### It reloads itself when its files change

#### User story

The extension is dogfood-only and unpacked: it runs straight from the checkout. A developer who edits it wants the change running without the click on chrome://extensions that Chrome otherwise requires before it re-reads an unpacked extension's files.

#### Business logic

The service worker takes a fingerprint of the extension's own files when it starts — every file Chrome loads for the extension — and again every beat; when any of them changed on disk, it reloads the extension instead of running that beat's cycle, and never while a cycle is running. The reload is recorded as the last cycle's outcome, naming the changed files. Content scripts already injected — the Driver tab's included — are orphaned by the reload, as by a manual one; the Driver's is replaced by the next cycle's page load, and the user's own claude.ai tabs get the new script on their next load. The fingerprint's rules are in `fingerprint.SPEC.md`, the worker's in `background.SPEC.md`.

#### Rationale

Chrome offers no way for anything but a click to reload an unpacked extension — chrome:// pages are off-limits to extensions and the daemon has no handle on the browser — but an extension may reload itself, and its worker can read its own files as they are on disk. The trigger the click used to supply is the worker noticing the change.

### Where it runs and why each permission exists

#### User story
Expand Down
19 changes: 18 additions & 1 deletion packages/chrome-extension/background.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ A `web`-target agent hands its task to a cloud session and ends; nothing streams
- **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.
- **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.

## Business logic

Expand Down Expand Up @@ -148,6 +149,22 @@ Each cycle claims the daemon's next session request, if any, and hands it to the

Creation navigates the page, and so do visits; running both inside one cycle, one after the other, keeps them from racing each other's controls. 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.

### Reloading itself when its files change

#### User story

A developer edits the extension in its checkout — the content script, the worker, the manifest — and wants the running extension to pick the change up on its own. Chrome re-reads an unpacked extension's files only on a reload, which used to be a click on chrome://extensions after every edit.

#### Business logic

When the worker starts it takes the fingerprint of the extension's files — see `fingerprint.SPEC.md` for which files and what counts as a change. Every beat, before running the cycle, it takes the fingerprint again; when any file changed, it records "reloading the extension" naming the changed files as the last cycle's outcome and reloads the extension instead of running the cycle. The reload is never done while a cycle is running: a beat that lands on a running cycle does nothing at all, and the next beat checks again. While the files cannot be read, nothing is reloaded. The new worker takes a fresh fingerprint at its start, so one edit is one reload.

After the reload the Driver tab's content script is an orphan, as after a manual reload, and is replaced by the next cycle's page load (see `### A Driver that cannot hear the worker`).

#### Rationale

The worker can read its own files as they are on disk right now — measured on 2026-08-26: a fetch of the extension's own URL from the worker returns the current file, not a cached copy — so no other process has to watch the checkout. A reload mid-cycle would end a drive with answers handed over and unaccounted for; waiting a beat costs half a minute. With developer mode switched off on chrome://extensions, Chrome (137 and later) disables an unpacked extension on reload rather than reloading it; the mode is on for anyone who loaded the extension unpacked, and the extension's README says to leave it on.

### Waking up on a schedule rather than on a timer

#### User story
Expand All @@ -156,7 +173,7 @@ None directly — this is what makes every scheduled behavior above actually hap

#### Business logic

The cycle runs off one browser alarm, twice a minute, and once when the service worker starts.
The beat — the file check and then the cycle runs off one browser alarm, twice a minute; the cycle also runs once when the service worker starts.

#### Rationale

Expand Down
52 changes: 50 additions & 2 deletions packages/chrome-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// It also keeps the token out of the page. A content script shares a tab with claude.ai, and
// nothing on that page should ever be able to read the secret that talks to a daemon.

importScripts('driver-plan.js')
importScripts('driver-plan.js', 'fingerprint.js')

const DEFAULT_DAEMON = 'http://localhost:4200'

Expand Down Expand Up @@ -475,6 +475,54 @@ chrome.tabs.onRemoved.addListener(async (tabId, info) => {
// dashboard watching an answer's spinner, and a run may be waiting for its session.
chrome.alarms.create('tf-cycle', { periodInMinutes: CYCLE_MINUTES })
chrome.alarms.onAlarm.addListener(alarm => {
if (alarm.name === 'tf-cycle') void cycle()
if (alarm.name === 'tf-cycle') void beat()
})

// ---------------------------------------------------------------------------
// Reloading itself when its files change (#1711). The extension is unpacked and edited in place,
// and Chrome re-reads its files only on a reload; that used to be a click on chrome://extensions
// after every change. The worker can read its own files as they are on disk, so it takes their
// fingerprint at start and compares each beat. Reloading orphans the content script in the
// Driver tab, which the next cycle's page load replaces, as after a manual reload.
//
// One thing to know: with developer mode switched off on chrome://extensions, Chrome (137 and
// later) disables an unpacked extension on reload instead of reloading it, and only a click on
// chrome://extensions brings it back. The mode is on for anyone who loaded the extension
// unpacked; leave it on.

/** The files' hashes when this worker started, or undefined until the first read succeeds. */
let filesAtStart

/** The extension's own file as it is on disk now. */
const readOwnFile = file => fetch(chrome.runtime.getURL(file)).then(res => res.text())

/** The watched files that changed since this worker started; none while a read fails. */
async function filesChanged() {
try {
const now = await fingerprint(readOwnFile)
if (!filesAtStart) {
filesAtStart = now
return []
}
return changedFiles(filesAtStart, now)
} catch {
return []
}
}

/**
* One beat: reload the extension if its files changed, else run a cycle. Never mid-cycle — a
* reload would kill a drive with answers handed over and unaccounted for — so a beat that lands
* on a running cycle merely does nothing, and the next one checks again. The reload is recorded
* as the last cycle's outcome, so the options page can say why the worker restarted.
*/
async function beat() {
if (cycling) return
const changed = await filesChanged()
if (changed.length === 0 || cycling) return cycle()
await note({ ok: true, reason: `reloading the extension: ${changed.join(', ')} changed on disk` })
chrome.runtime.reload()
}

void filesChanged()
void cycle()
4 changes: 3 additions & 1 deletion packages/chrome-extension/check.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Runs the Claude web bridge's page half and its visit planner against synthetic claude.ai pages, so the reading, driving and writing of the page can be checked without a browser, an installed extension, or a live cloud session.
Runs the Claude web bridge's page half, its visit planner and its file fingerprint against synthetic claude.ai pages and files, so the reading, driving and writing of the page and the rule for reloading the extension can be checked without a browser, an installed extension, or a live cloud session.

## What the tests cover

Expand Down Expand Up @@ -50,6 +50,8 @@ The Driver, on a synthetic app built like the live one was observed to be — a

The visit planner: an awaiting, unread or idle session is visited when never seen and when its status changed; after five minutes unchanged only an awaiting session is visited again, an unread or idle one is not; a queued answer forces a visit whatever the status; running, landed and missing sessions are never visited on their own.

The file fingerprint, which decides when the extension reloads itself: the watched files are exactly the ones Chrome loads for the extension — those the manifest names, those the service worker imports, and the options page's script — with nothing missing and nothing extra; unchanged files change nothing; one edited file is named; and a file that cannot be read fails the fingerprint rather than counting as a change.

## Rationale

What this deliberately does not cover is the one thing left: whether claude.ai's real page puts the question block somewhere these strategies reach. Only loading the extension against a live session answers that.
Expand Down
51 changes: 51 additions & 0 deletions packages/chrome-extension/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -804,5 +804,56 @@ function appPage({ sessions = SESSIONS, firstPage = 6, sendAppendsRow = true } =
dom.window.close()
}

// ---------------------------------------------------------------------------
// The extension reloads itself when its files change (fingerprint.js, #1711): the worker
// fingerprints its own files at start and again every beat, and a difference in any of them is
// what triggers the reload. Every file Chrome loads for the extension is watched, and a file
// that cannot be read is never taken for a change: a reload on a transient read failure would
// loop.

{
const src = readFileSync(join(here, 'fingerprint.js'), 'utf8')
const dom = new JSDOM('<!doctype html><html><body></body></html>', { runScripts: 'outside-only' })
dom.window.eval(src)
const { WATCHED_FILES, fingerprint, changedFiles } = dom.window.__tfFingerprint
{
// The manifest's files, what the worker imports, and the options page's script.
const manifest = JSON.parse(readFileSync(join(here, 'manifest.json'), 'utf8'))
const worker = readFileSync(join(here, 'background.js'), 'utf8')
const options = readFileSync(join(here, manifest.options_page), 'utf8')
const imported = [...worker.matchAll(/importScripts\(([^)]*)\)/g)].flatMap(m => [...m[1].matchAll(/'([^']+)'/g)].map(x => x[1]))
const loaded = new Set([
'manifest.json',
manifest.background.service_worker,
...imported,
...manifest.content_scripts.flatMap(c => c.js),
manifest.options_page,
...[...options.matchAll(/<script src="([^"]+)"/g)].map(m => m[1]),
])
const missing = [...loaded].filter(f => !WATCHED_FILES.includes(f))
const extra = WATCHED_FILES.filter(f => !loaded.has(f))
const ok = missing.length === 0 && extra.length === 0
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} every file Chrome loads for the extension is watched, and nothing else (watched=${WATCHED_FILES.join(',')}, unwatched=${missing.join(',') || 'none'}, not loaded=${extra.join(',') || 'none'})`)
}
{
const files = new Map(WATCHED_FILES.map(f => [f, `the text of ${f}`]))
const read = async f => {
if (!files.has(f)) throw new Error(`cannot read ${f}`)
return files.get(f)
}
const before = await fingerprint(read)
const same = changedFiles(before, await fingerprint(read))
files.set('content.js', 'the text of content.js // edited')
const edited = changedFiles(before, await fingerprint(read))
files.delete('driver-plan.js')
const unreadable = await fingerprint(read).then(() => 'taken', err => `refused: ${err.message}`)
const ok = same.length === 0 && JSON.stringify(edited) === JSON.stringify(['content.js']) && unreadable.startsWith('refused')
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} unchanged files change nothing, one edited file is named, and a file that cannot be read refuses the fingerprint rather than counting as a change (same=${same.length}, edited=${edited}, unreadable=${unreadable})`)
}
dom.window.close()
}

console.log(failed ? `\n${failed} case(s) failed` : '\nall cases passed')
process.exit(failed ? 1 : 0)
Loading
Loading