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 @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/chrome-extension/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/chrome-extension/background.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
101 changes: 100 additions & 1 deletion packages/chrome-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down Expand Up @@ -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()
6 changes: 6 additions & 0 deletions packages/chrome-extension/check.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading