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 packages/chrome-extension/check.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Creating a session:

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.

One page change costs one re-read: the panel's own redraw is not taken for a page change, so a re-read never triggers the next.

The Driver, on a synthetic app built like the live one was observed to be — a session list of links carrying the session id with a text status label beside each, a "Show more" button at the list's end and a decoy one deep in the middle panel, a "New" link, and in-app navigation that swaps the main area without a page load:

- The list is read by label — awaiting, unread, idle, running, landed, and an unknown label carried verbatim — paged through the list's own button and never the decoy, and a session absent from the list is reported missing.
Expand Down
27 changes: 27 additions & 0 deletions packages/chrome-extension/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,33 @@ function appPage({ sessions = SESSIONS, firstPage = 6, sendAppendsRow = true } =
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
// observers are paused around those writes, each survey's redraw is the next survey's trigger:
// a full survey about four times a second, forever, on a page where nothing changed.

{
const dom = new JSDOM(`<!doctype html><html><body><main>${feed(row('human', 0, 'intro'), row('assistant', 1, 'Hello.'))}<div contenteditable="true"></div></main></body></html>`, {
url: 'https://claude.ai/code/session_01TEST',
runScripts: 'outside-only',
})
dom.window.eval(script)
const w = dom.window
const settle = ms => new Promise(resolve => setTimeout(resolve, ms))
// Let whatever the load itself queued run out, then make exactly one page change. The re-read
// coalesces over 250 ms; a self-loop fits several more surveys into the wait after it.
await settle(600)
const before = w.__tfBridgeSurveys()
w.document.querySelector('[role=feed]').insertAdjacentHTML('beforeend', row('assistant', 2, 'One more turn.'))
await settle(1500)
const ran = w.__tfBridgeSurveys() - before
const ok = ran === 1
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} one page change costs one survey: the panel's redraw does not trigger the next (surveys after the change=${ran})`)
dom.window.close()
}

// ---------------------------------------------------------------------------
// 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,
Expand Down
4 changes: 2 additions & 2 deletions packages/chrome-extension/content.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,11 @@ The bridge is meant to work in a pinned background tab nobody is looking at.

#### Business logic

Every re-read is triggered by the page's own changes, coalesced so a burst of changes causes one pass, plus a slow heartbeat once a minute as a backstop for a change that was missed. When the extension is reloaded out from under an already-injected script, that script stops watching entirely instead of continuing to fail.
Every re-read is triggered by the page's own changes, coalesced so a burst of changes causes one pass, plus a slow heartbeat once a minute as a backstop for a change that was missed. The bridge's own drawing — the corner panel and the Driver overlay — is not a page change: watching is paused while they are drawn, so one page change causes exactly one re-read. When the extension is reloaded out from under an already-injected script, that script stops watching entirely instead of continuing to fail.

#### Rationale

A frequent timer is the wrong instrument here: the browser slows timers in a tab hidden for more than a few minutes to roughly once a minute, while the session's stream mutates the page the moment anything happens, so watching catches it immediately. And an orphaned script left running after an extension reload throws on every attempt to reach the extension; those throws surface as extension errors and read as product bugs, so a script whose extension is gone shuts itself down.
A frequent timer is the wrong instrument here: the browser slows timers in a tab hidden for more than a few minutes to roughly once a minute, while the session's stream mutates the page the moment anything happens, so watching catches it immediately. The panel and the overlay are drawn into the same page that is watched, so without the pause each re-read's own redraw was the next re-read's trigger — a full pass about four times a second, forever, in a tab where nothing had changed. And an orphaned script left running after an extension reload throws on every attempt to reach the extension; those throws surface as extension errors and read as product bugs, so a script whose extension is gone shuts itself down.

## Before modifying/creating SPEC.md files

Expand Down
40 changes: 34 additions & 6 deletions packages/chrome-extension/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,27 @@
// `check.mjs` covers all four offline, in jsdom, with no browser and no live session.

const POLL_MS = 2000
// How many surveys this page has run; read by the offline harness (check.mjs) to prove one page
// change costs one survey (#1707).
let surveys = 0

// Every observer `watch` runs, so the script's own drawing can pause them (#1707): the corner
// panel and the Driver overlay live under the observed root, and without the pause each survey's
// redraw was the next survey's trigger — a full survey about four times a second, forever, on a
// page where nothing had changed.
const watchers = new Set()
const OBSERVED = { childList: true, subtree: true, characterData: true }
let drawing = 0

/** Run `draw`, which writes the script's own elements, without the observers taking it for a page change. */
function ownWrites(draw) {
if (drawing++ === 0) for (const observer of watchers) observer.disconnect()
try {
draw()
} finally {
if (--drawing === 0) for (const observer of watchers) observer.observe(document.documentElement, OBSERVED)
}
}
/** The on-page panel's element id. */
const PANEL_ID = 'tf-bridge-panel'
const IS_TOP = window.top === window
Expand Down Expand Up @@ -1010,16 +1031,18 @@ function ensureOverlay() {
log.style.cssText = 'max-height:50vh;overflow:auto;background:#1f2430;padding:12px;border-radius:8px;font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;margin:8px 0 0'
details.append(summary, log)
overlay.append(heading, phrase, status, details)
document.documentElement.appendChild(overlay)
ownWrites(() => document.documentElement.appendChild(overlay))
renderOverlay()
}

function renderOverlay() {
const overlay = document.getElementById(OVERLAY_ID)
if (!overlay) return
const version = typeof chrome !== 'undefined' && chrome.runtime?.getManifest ? chrome.runtime.getManifest().version : '?'
overlay.querySelector('.tf-driver-status').textContent = `bridge v${version} · ${location.pathname} · ${turnRows().length} turn rows · question ${bridgeStatus} · transcript ${transcriptStatus}`
overlay.querySelector('.tf-driver-log').textContent = driverLines.join('\n')
ownWrites(() => {
overlay.querySelector('.tf-driver-status').textContent = `bridge v${version} · ${location.pathname} · ${turnRows().length} turn rows · question ${bridgeStatus} · transcript ${transcriptStatus}`
overlay.querySelector('.tf-driver-log').textContent = driverLines.join('\n')
})
}

// The worker drives the top frame only: the list and the composer live there, and a child frame
Expand Down Expand Up @@ -1050,6 +1073,7 @@ if (typeof chrome === 'undefined') {
window.__tfBridgeProbeNewSession = probeNewSession
window.__tfBridgeReadSessionList = ids => whileFree(() => readSessionList(ids))
window.__tfBridgeDrive = args => whileFree(() => drive(args))
window.__tfBridgeSurveys = () => surveys
}

/**
Expand Down Expand Up @@ -1098,6 +1122,7 @@ function diagnostics() {
}

function survey() {
surveys++
const choice = findPendingChoice()
const composer = findComposer()
return {
Expand Down Expand Up @@ -1171,7 +1196,8 @@ if (!IS_TOP) {
return b
}

const render = () => {
// Drawn under `ownWrites`: the redraw must not read as a page change (#1707).
const render = () => ownWrites(() => {
const top = survey()
// A child frame's find wins: it means the content lives there, which is the finding.
latest = { ...top, fromFrame: fromFrame ?? null }
Expand Down Expand Up @@ -1275,7 +1301,7 @@ if (!IS_TOP) {
document.execCommand('insertText', false, text)
}
}))
}
})

render()
watch(render)
Expand All @@ -1302,6 +1328,7 @@ function watch(run) {
// Reading chrome.runtime itself can throw once the context is gone.
}
observer.disconnect()
watchers.delete(observer)
clearInterval(interval)
return false
}
Expand All @@ -1313,7 +1340,8 @@ function watch(run) {
run()
}, 250)
})
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true })
observer.observe(document.documentElement, OBSERVED)
watchers.add(observer)
interval = setInterval(() => {
if (alive()) run()
}, 30 * POLL_MS)
Expand Down
Loading