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: 1 addition & 1 deletion FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ happens while nobody is at the keyboard.
- 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
- 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 only the sessions awaiting input, unread, or 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
- 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
- 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
2 changes: 1 addition & 1 deletion packages/chrome-extension/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ See `## User story`, first item — and it has to hold for fifty sessions as it

#### Business logic

The extension only sees pages it is injected into, so it cannot know an agent started. The daemon publishes which cloud sessions are its — every web run's session of the last twelve hours, each flagged with whether an answer is queued — and the extension keeps one pinned, inactive background tab, the Driver, open on claude.ai: content scripts run in background tabs, so the bridge works while Chrome merely runs. Twice a minute the Driver reads the session list — each of the daemon's sessions with the status claude.ai shows beside it — reports those statuses to the daemon, and visits only the sessions the planner picks — a handful a cycle, answers first: the parked ones (awaiting input or unread) whose status changed or that have not been looked at for a while, and any holding a queued answer. A visit is an in-app navigation, clicking the session's row and later the list's "New" link back, never a page load; the list itself is refreshed by one page load a minute, since claude.ai's list refreshes only on a load. A visited session is read and mirrored as any page is, and typed into when an answer travelled with the visit. Running the Driver is opt-in from the options page. Closing the Driver tab pauses the bridge until the options page reopens it or the browser restarts; the user's own claude.ai tabs are never navigated or typed into — with one exception the extension cannot avoid: after a browser restart, a lone pinned claude.ai sessions tab is taken to be the Driver Chrome restored, since a restored tab carries no other mark.
The extension only sees pages it is injected into, so it cannot know an agent started. The daemon publishes which cloud sessions are its — every web run's session of the last twelve hours, each flagged with whether an answer is queued — and the extension keeps one pinned, inactive background tab, the Driver, open on claude.ai: content scripts run in background tabs, so the bridge works while Chrome merely runs. Twice a minute the Driver reads the session list — each of the daemon's sessions with the status claude.ai shows beside it — reports those statuses to the daemon, and visits only the sessions the planner picks — a handful a cycle, answers first: the ones whose status changed to a stopped one (awaiting input, unread or idle), the awaiting ones not looked at for five minutes, and any holding a queued answer. A visit is an in-app navigation, clicking the session's row and later the list's "New" link back, never a page load; the list itself is refreshed by one page load a minute, since claude.ai's list refreshes only on a load. A visited session is read and mirrored as any page is, and typed into when an answer travelled with the visit. Running the Driver is opt-in from the options page. Closing the Driver tab pauses the bridge until the options page reopens it or the browser restarts; the user's own claude.ai tabs are never navigated or typed into — with one exception the extension cannot avoid: after a browser restart, a lone pinned claude.ai sessions tab is taken to be the Driver Chrome restored, since a restored tab carries no other mark.

The Driver tab shows a full-page overlay in place of claude.ai's interface: a heading naming it "The Framework Driver", a line saying what the tab is for and that closing it pauses the bridge, and a collapsed "Show debug logs" holding the cycle log — what was read, visited, typed and created, and what the daemon answered.

Expand Down
2 changes: 1 addition & 1 deletion packages/chrome-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ async function runCycle() {

const now = Date.now()
const planned = planVisits(statuses, answers, seen, now)
// Answers first, then a bounded handful of parked sessions; what is cut waits for the next
// Answers first, then a bounded handful of the due sessions; what is cut waits for the next
// beat, with the change that made it due kept pending below.
const visits = [...planned.filter(v => v.answer), ...planned.filter(v => !v.answer)].slice(0, MAX_VISITS)
for (const visit of visits) if (visit.answer) deliveredAnswers.add(visit.answer.id)
Expand Down
4 changes: 2 additions & 2 deletions packages/chrome-extension/check.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ The Driver, on a synthetic app built like the live one was observed to be — a
- A session missing from the list is not visited and its answer is not claimed.
- The session the worker asked for is created first, from the list page, before the visits, and the cycle still ends on the list.
- A second instruction while a drive runs is refused as busy, and the drive completes.
- A status word on a row beats its pull-request label.
- A status word on a row beats its pull-request label; a row showing only a pull request is idle when the pull request is open or a draft and landed when it is merged or closed.

The visit planner: a parked session is visited when never seen, when its status changed, and again after five minutes; a queued answer forces a visit whatever the status; idle, running, landed and missing sessions are never visited on their own.
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.

## Rationale

Expand Down
87 changes: 63 additions & 24 deletions packages/chrome-extension/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,31 @@ function appPage({ sessions = SESSIONS, firstPage = 6, sendAppendsRow = true } =
dom.window.close()
}

{
// A row carrying only a pull-request label: the work landed when the pull request is merged or
// closed; any other state — open, or draft as the live list spelled it on 2026-08-26 — is a
// session that went quiet, so it is idle — visited when its row changes, never for its age (#1707).
const sessions = [
{ id: 'session_01PROPEN', label: '#1700 · Open', title: 'Pull request open' },
{ id: 'session_01PRDRAFT', label: '#1706 · Draft', title: 'Pull request draft' },
{ id: 'session_01PRMERGED', label: '#1701 · Merged', title: 'Pull request merged' },
{ id: 'session_01PRCLOSED', label: '#1702 · Closed', title: 'Pull request closed' },
]
const { dom, w } = appPage({ sessions })
const got = await w.__tfBridgeReadSessionList(sessions.map(s => s.id))
const statuses = got.statuses.map(s => [s.sessionId, s.status])
const want = [
['session_01PROPEN', 'idle'],
['session_01PRDRAFT', 'idle'],
['session_01PRMERGED', 'landed'],
['session_01PRCLOSED', 'landed'],
]
const ok = JSON.stringify(statuses) === JSON.stringify(want)
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} an open or draft pull request alone reads as idle, a merged or closed one as landed (${JSON.stringify(statuses)})`)
dom.window.close()
}

// ---------------------------------------------------------------------------
// The script's own drawing is not a page change (#1707). The corner panel is redrawn on every
// survey and the Driver overlay on every log line, both under the observed root — so unless the
Expand Down Expand Up @@ -720,8 +745,9 @@ function appPage({ sessions = SESSIONS, firstPage = 6, sendAppendsRow = true } =

// ---------------------------------------------------------------------------
// Which sessions a cycle visits (driver-plan.js): the statuses are sticky — an in-app visit clears
// neither "Awaiting input" nor "Unread response" — so a parked session is visited on a change,
// after a while, and always when an answer is queued; the rest are never visited.
// neither "Awaiting input" nor "Unread response" — so a session is visited when its status changed
// to awaiting, unread or idle, only awaiting is visited again on age, and an answer always earns a
// visit; running, landed and missing sessions are never visited on their own (#1707).

{
const plan = readFileSync(join(here, 'driver-plan.js'), 'utf8')
Expand All @@ -738,30 +764,43 @@ function appPage({ sessions = SESSIONS, firstPage = 6, sendAppendsRow = true } =
{ sessionId: 's_missing', status: 'missing' },
]
const ids = visits => visits.map(v => v.id)
// Never seen: every parked session is due, nothing else is.
const first = planVisits(statuses, new Map(), new Map(), NOW)
// Seen a moment ago with the same status: nothing is due.
const recent = new Map(statuses.map(s => [s.sessionId, { status: s.status, visitedAt: NOW - 1000 }]))
const second = planVisits(statuses, new Map(), recent, NOW)
// The same, six minutes later: the parked ones are due again.
const later = planVisits(statuses, new Map(), recent, NOW + 6 * 60_000)
// A status change makes a parked session due at once.
const changed = new Map(recent)
changed.set('s_unread', { status: 'idle', visitedAt: NOW - 1000 })
const onChange = planVisits(statuses, new Map(), changed, NOW)
// An answer forces a visit whatever the status, and travels with it.
const answers = new Map([['s_idle', { id: 'a1', text: 't' }]])
const forced = planVisits(statuses, answers, recent, NOW)
const ok =
JSON.stringify(ids(first)) === JSON.stringify(['s_await', 's_unread']) &&
second.length === 0 &&
JSON.stringify(ids(later)) === JSON.stringify(['s_await', 's_unread']) &&
JSON.stringify(ids(onChange)) === JSON.stringify(['s_unread']) &&
JSON.stringify(forced) === JSON.stringify([{ id: 's_idle', status: 'idle', answer: { id: 'a1', text: 't' } }])
if (!ok) failed++
console.log(
`${ok ? 'PASS' : 'FAIL'} visits are planned on change, on age, and on a queued answer; idle, running, landed and missing sessions are left alone (first=${ids(first)}, second=${second.length}, later=${ids(later)}, change=${ids(onChange)}, forced=${JSON.stringify(forced)})`,
)
{
// Never seen: the awaiting, unread and idle sessions are due, nothing else is.
const first = planVisits(statuses, new Map(), new Map(), NOW)
const second = planVisits(statuses, new Map(), recent, NOW)
// A status change makes each of those due at once — the unread one from idle, the idle one
// from running — and leaves a running session alone.
const changed = new Map(recent)
changed.set('s_unread', { status: 'idle', visitedAt: NOW - 1000 })
changed.set('s_idle', { status: 'running', visitedAt: NOW - 1000 })
changed.set('s_run', { status: 'idle', visitedAt: NOW - 1000 })
const onChange = planVisits(statuses, new Map(), changed, NOW)
// An answer forces a visit whatever the status, and travels with it.
const answers = new Map([['s_landed', { id: 'a1', text: 't' }]])
const forced = planVisits(statuses, answers, recent, NOW)
const ok =
JSON.stringify(ids(first)) === JSON.stringify(['s_await', 's_unread', 's_idle']) &&
second.length === 0 &&
JSON.stringify(ids(onChange)) === JSON.stringify(['s_unread', 's_idle']) &&
JSON.stringify(forced) === JSON.stringify([{ id: 's_landed', status: 'landed', answer: { id: 'a1', text: 't' } }])
if (!ok) failed++
console.log(
`${ok ? 'PASS' : 'FAIL'} awaiting, unread and idle sessions are visited when never seen or on a status change, an answer forces a visit; running, landed and missing sessions are left alone (first=${ids(first)}, second=${second.length}, change=${ids(onChange)}, forced=${JSON.stringify(forced)})`,
)
}
{
// The same list six minutes later, nothing changed: only the awaiting session is due again.
// The unread one was visited once when it turned unread; re-reading it every five minutes
// for the whole window would mirror nothing new (#1707).
const later = planVisits(statuses, new Map(), recent, NOW + 6 * 60_000)
// And one minute later nothing is: the age revisit is five minutes, not every beat.
const soon = planVisits(statuses, new Map(), recent, NOW + 60_000)
const ok = JSON.stringify(ids(later)) === JSON.stringify(['s_await']) && soon.length === 0
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} after five minutes unchanged, only an awaiting session is visited again; unread and idle are not (later=${ids(later)}, soon=${soon.length})`)
}
dom.window.close()
}

Expand Down
Loading
Loading