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 @@ -162,6 +162,7 @@ happens while nobody is at the keyboard.
- Run on a fresh GitHub Actions runner
- Run on a Claude Code cloud session
- 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
- Browser-bridge token setting
- Web runs trust the project for Claude Code automatically β€” no manual trust step
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 @@ -38,7 +38,7 @@ See `## User story`, first and second items.

#### Business logic

On every claude.ai session page, the content script watches the DOM and extracts the choice the session rendered per the await protocol β€” a JSON block with a title, its options and their detail text, an optional recommended label, whether several may be picked at once, which start ticked, and which end the session β€” keyed by the cloud session id parsed from the page URL, which is what the daemon joins back to the agent's record. The service worker posts it to the daemon (`POST /_bridge/question`), deduplicating repeats. The dashboard shows the question as the gate it is, in the same panel a local agent's question gets, so answering it takes the single click a local gate takes. The daemon then composes the text that will be typed β€” the wording that continues the session with the picked options, or a hand-over line when a picked option ends it β€” and queues it as the answer. The worker polls `GET /_bridge/answer` on a fast beat, hands a queued answer to the content script in that session's tab, and the content script types that text into the composer and submits it; the worker reports the outcome (`POST /_bridge/answered`), and only a delivery the extension confirmed makes the daemon treat the question as resolved. Alongside questions, the content script mirrors the session's transcript to the daemon (`POST /_bridge/events`) and sends a self-report of what the injected script is and sees (`POST /_bridge/hello`), so the dashboard can show what the session did and diagnosis never needs a screenshot. A daemon with the bridge switched off answers no bridge route at all β€” turning it on is an explicit choice, since it is the one daemon surface meant to be reached from another origin.
On every claude.ai session page, the content script watches the DOM and extracts the choice the session rendered per the await protocol β€” a JSON block with a title, its options and their detail text, an optional recommended label, whether several may be picked at once, which start ticked, and which end the session β€” keyed by the cloud session id parsed from the page URL, which is what the daemon joins back to the agent's record. The service worker posts it to the daemon (`POST /_bridge/question`), deduplicating repeats. The dashboard shows the question as the gate it is, in the same panel a local agent's question gets, so answering it takes the single click a local gate takes. The daemon then composes the text that will be typed β€” the wording that continues the session with the picked options, or a hand-over line when a picked option ends it β€” and queues it as the answer. The worker polls `GET /_bridge/answer` on a fast beat, hands a queued answer to the content script in that session's tab, and the content script types that text into the composer and submits it; the worker reports the outcome (`POST /_bridge/answered`), and only a delivery the extension confirmed makes the daemon treat the question as resolved. Alongside questions, the content script mirrors the session's transcript to the daemon turn by turn, as it is written (`POST /_bridge/events`) and sends a self-report of what the injected script is and sees (`POST /_bridge/hello`), so the dashboard can show what the session did and diagnosis never needs a screenshot. A daemon with the bridge switched off answers no bridge route at all β€” turning it on is an explicit choice, since it is the one daemon surface meant to be reached from another origin.

### Only what the session offered

Expand Down
8 changes: 7 additions & 1 deletion packages/chrome-extension/check.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ Finding the parked question, across every shape the block has been seen in:
- A page with no question block reports no question.
- The await protocol's own spec block, which renders on the page as part of the agent's prompt, never counts as a question β€” on its own, and when a real question follows it, in which case the real question wins.
- The protocol's two literal worked examples β€” the browser-handoff pair and the "Ship this?" approval pair β€” never count as questions.
- When the page marks its messages, everything inside the opening message is the rendered prompt: decoys there are ignored while a real question in a later message still wins, and a question-shaped block that exists only inside the opening message is never reported.
- Everything inside the opening turn is the rendered prompt: decoys there are ignored while a real question in a later turn still wins, and a question-shaped block that exists only inside the opening turn is never reported.
- Every one of those cases also checks that the composer was located and that the panel shows the question's actual title.

What is reported to the daemon:

- The question reaches the daemon in the shape the session asked it: whether several answers may be picked at once, which options start ticked, and which option ends the session, alongside the labels and their detail text β€” while keys the daemon does not know are dropped rather than forwarded.

Mirroring the transcript:

- The mirror is one entry per conversation turn, under the position the page gives the turn: the user's turns as the user's, the session's as the session's, markers such as "Initialized session" left out, interface glyphs and blank lines removed, and the opening turn β€” the run's prompt β€” cut to its first 8000 characters.
- When only the recent part of the transcript is rendered, positions still come from the page, not from counting what is on screen.
- A page that marks no turns mirrors nothing, and the panel says that no transcript rows were found.

Typing the dashboard's answer back into the session:

- The composer is filled and the page's send button is clicked.
Expand Down
74 changes: 67 additions & 7 deletions packages/chrome-extension/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ const highlighted = wideBlock
// Escaped, because these fixtures go in through innerHTML and `<the question>` would otherwise
// be parsed as an HTML tag and vanish from textContent. The real page escapes it too.
const esc = t => t.replace(/</g, '&lt;').replace(/>/g, '&gt;')

// One conversation turn as claude.ai renders it (#1225): a `transcript-row` naming its position
// and its kind. The page has no <article> elements; this is the shape the mirror reads.
const row = (kind, index, html) => `<div data-testid="transcript-row" data-index="${index}" data-perf-row="${kind}"><div role="article" aria-label="Message ${index + 1}">${html}</div></div>`
const feed = (...rows) => `<div role="feed" aria-label="Chat messages">${rows.join('')}</div>`
const SPEC = esc(JSON.stringify({
title: '<the question>',
options: [{ label: '<option>', detail: '<optional one-liner>' }],
Expand Down Expand Up @@ -84,15 +89,15 @@ const cases = [
['browser-handoff example only', `<pre><code>${HANDOFF_EXAMPLE}</code></pre><div contenteditable="true"></div>`, false],
// #1568: the approval example is literal end to end and can only be matched verbatim.
['approval example only', `<pre><code>${APPROVAL_EXAMPLE}</code></pre><div contenteditable="true"></div>`, false],
// #1568: when the page marks messages, everything in the opening one is the rendered prompt β€”
// documentation, not the session asking β€” and the real question in a later message still wins.
// #1568: everything in the opening turn is the rendered prompt β€” documentation, not the
// session asking β€” and the real question in a later turn still wins.
[
'decoys in the opening message, real question after',
`<article><pre><code>${SPEC}</code><code>${HANDOFF_EXAMPLE}</code><code>${APPROVAL_EXAMPLE}</code></pre></article><article><code>${block}</code></article><div contenteditable="true"></div>`,
'decoys in the opening turn, real question after',
`${feed(row('human', 0, `<pre><code>${SPEC}</code><code>${HANDOFF_EXAMPLE}</code><code>${APPROVAL_EXAMPLE}</code></pre>`), row('assistant', 1, `<code>${block}</code>`))}<div contenteditable="true"></div>`,
true,
],
// #1568: a question-shaped block that only exists inside the opening message is never asked.
['question-shaped block only in the opening message', `<article><code>${block}</code></article><div contenteditable="true"></div>`, false],
// #1568: a question-shaped block that only exists inside the opening turn is never asked.
['question-shaped block only in the opening turn', `${feed(row('human', 0, `<code>${block}</code>`))}<div contenteditable="true"></div>`, false],
]

const script = readFileSync(join(here, 'content.js'), 'utf8')
Expand Down Expand Up @@ -138,7 +143,7 @@ for (const [name, body, expectFound] of cases) {
{ label: 'Abandon the plan', stop: true },
],
})
const dom = new JSDOM(`<!doctype html><html><body><main><article>intro</article><article><code>${esc(shaped)}</code></article><div contenteditable="true"></div></main></body></html>`, {
const dom = new JSDOM(`<!doctype html><html><body><main>${feed(row('human', 0, 'intro'), row('assistant', 1, `<code>${esc(shaped)}</code>`))}<div contenteditable="true"></div></main></body></html>`, {
url: 'https://claude.ai/code/session_01TEST',
runScripts: 'outside-only',
})
Expand All @@ -156,6 +161,61 @@ for (const [name, body, expectFound] of cases) {
dom.window.close()
}

// ---------------------------------------------------------------------------
// The mirror (#1225): one entry per conversation turn, keyed by the page's own position, with the
// kind mapped to a role and markers left out. The opening turn is the run's prompt, and the only
// turn long enough to be cut β€” from its end, never the conversation's.

function mirrorOf(body) {
const dom = new JSDOM(`<!doctype html><html><body><main>${body}</main></body></html>`, {
url: 'https://claude.ai/code/session_01TEST',
runScripts: 'outside-only',
})
dom.window.eval(script)
const panel = [...dom.window.document.documentElement.children].filter(el => el.tagName === 'DIV').map(el => el.textContent).join(' ')
const got = dom.window.__tfBridgeTranscript
dom.window.close()
return { got, panel }
}

{
const prompt = 'prompt '.repeat(2000) // 14000 characters: only the opening turn is ever this long
const { got } = mirrorOf(
feed(
row('human', 0, `<p>${prompt}</p>`),
row('marker', 1, 'Initialized session'),
row('assistant', 2, '<p>Looking at the repo.</p>\n<p> \uE001 Copy </p>'),
row('human', 3, 'do the next one'),
row('assistant', 4, 'On it'),
) + '<div contenteditable="true"></div>',
)
const want = [
{ seq: 0, role: 'user', text: prompt.trim().slice(0, 8000) },
{ seq: 2, role: 'agent', text: 'Looking at the repo.\nCopy' },
{ seq: 3, role: 'user', text: 'do the next one' },
{ seq: 4, role: 'agent', text: 'On it' },
]
const ok = JSON.stringify(got) === JSON.stringify(want)
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} mirror is one entry per turn, roles mapped, markers skipped, prompt head-capped (turns=${got?.length}, seqs=${got?.map(e => e.seq).join(',')}, roles=${got?.map(e => e.role).join(',')})`)
}

{
// A virtual list keeps only the tail rendered: positions come from the page, not from DOM order.
const { got } = mirrorOf(feed(row('human', 7, 'later question'), row('assistant', 8, 'later answer')) + '<div contenteditable="true"></div>')
const ok = JSON.stringify(got?.map(e => [e.seq, e.role])) === JSON.stringify([[7, 'user'], [8, 'agent']])
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} mirror keeps the page's positions when only the tail is rendered (got=${JSON.stringify(got?.map(e => e.seq))})`)
}

{
// A layout the mirror does not know is named, never mirrored as whatever text is on screen.
const { got, panel } = mirrorOf('<div>Home Code Artifacts</div><p>some conversation text</p><div contenteditable="true"></div>')
const ok = Array.isArray(got) && got.length === 0 && /no transcript rows found/.test(panel)
if (!ok) failed++
console.log(`${ok ? 'PASS' : 'FAIL'} no turn rows means nothing mirrored and the panel says so (entries=${got?.length}, named=${/no transcript rows found/.test(panel)})`)
}

// ---------------------------------------------------------------------------
// The write half (#1237): the dashboard's pick being typed into the composer and submitted.
// jsdom has no execCommand, so these exercise the fallback fill; what they prove is the flow
Expand Down
Loading
Loading