Route extension relay connections by their per-profile install id before falling back to Google account identity. This keeps two Chrome profiles signed into the same account from replacing each other in the relay.
Keep Playwright-created tabs in the intended Chrome window by remembering the window where the extension was activated and using that window for Target.createTarget and initial tabs.
Handle Chrome debugger cancellation as a global detach event. When Chrome reports canceled_by_user, clear all tracked debugger tabs and emit detach events so Playwright stops sending commands to tabs Chrome already detached from. Also avoid resurrecting stale error tab state if the detach happens while attachTab is still unwinding.
Validation:
- pnpm test --run src/relay-state.test.ts
- pnpm typecheck
- pnpm build in extension/
- Manual two-profile Hacker News navigation and comment extraction
- Mythos review found no blocking issues
Session: ses_120923e0fffe5l4GT77WwyXEGP
Use full user-agent client hints when the browser exposes them so the extension can report more specific Chromium-family names before falling back to low-entropy brands and the user agent string.
This keeps detection inside the browser instead of guessing from local filesystem installs. If a browser only exposes the generic Google Chrome brand, Playwriter keeps showing Chrome.
Bump the extension manifest to 0.0.95 and add a playwriter patch changeset for the browser list improvement.
Session: ses_143f600f9ffe0ftfzwvYK56gQV
The old toast just said "Copied pin #N" which gave no hint about what to do next. Now it says "Copied playwriter element reference, use it in your agent prompt" so users know to paste it into their AI agent.
Bump extension to 0.0.94.
Session: ses_1d03be05affeS58tMdQDs6GMbj
Update vite in root and extension packages from ^8.0.3 to ^8.0.12.
Lockfile reflects all transitive dependency updates from this session:
goke 6.12.1, @holocron.so/vite 0.8.0, dompurify 3.4.3, katex 0.16.46,
and various @csstools patches.
bippy.normalizeFileName strips "/app-pages-browser/" but not the
parenthesized form "/(app-pages-browser)/" that Next.js webpack
actually uses in source maps. Added cleanFileName/cleanName that
runs after normalizeFileName and strips any /(layer-name)/ prefix
using regex, plus trailing "./".
Covers all Next.js webpack layer names: (app-pages-browser), (ssr),
(rsc), (action-browser), (pages-dir-browser), (pages-dir-edge),
(pages-dir-node).
Applied in both:
- Extension context menu handler (background.ts)
- MCP react-source.ts (getReactSource + getReactComponentInfo)
Also applied oracle review fixes:
- userGesture: true on clipboard Runtime.evaluate calls to prevent
NotAllowedError when page loses focus after context menu
- Defensive fileName null check before building clipboard text
Session: ses_208943d84ffenqdbjxK0cO0iak
New context menu item that copies the React component source file path
to clipboard when right-clicking an element on a React page.
Uses bippy (React fiber introspection library) to walk the fiber tree
from the right-clicked DOM element up to the nearest composite component
with source location info. Tries getSource() first (direct __source prop
from JSX transform), then falls back to getOwnerStack() for builds with
source maps.
Copies "path/to/file.tsx:42" format to clipboard on success (green flash),
shows red flash if the element is not part of a React app or no source
maps are available.
Also refactored all inline JS strings in the context menu handlers to use
string-dedent with descriptive variable names for syntax highlighting.
extension@0.0.93
Session: ses_208943d84ffenqdbjxK0cO0iak
Remove the natural-language wrapper around pinned element clipboard text so toolbar and context-menu pins copy a shell-ready playwriter command. This keeps the copied text compact while still delegating DOM and React inspection to inspectPinnedElement.
Session: ses_221fdd7c5ffexkyCmOtfmgwjoI
Expose getReactComponentInfo so agents can inspect the React component name, parent hierarchy, sanitized props, and source metadata for a locator or element handle. Non-React elements return null so the helper is safe to call from generic DOM inspection flows.
Update toolbar and context-menu pinned element copy to use a short inspectPinnedElement(url, expression) call. The wrapper resolves the pinned element, logs outerHTML plus React info internally, and keeps copied prompts small.
Session: ses_221fdd7c5ffexkyCmOtfmgwjoI
Removed the verbose describeElement() summary (tag, id, classes, role, aria,
href, type, text, rect, visible) — all redundant since the agent runs the code
and sees outerHTML directly. Also fixed the evaluate call to use a static
property access (globalThis.playwriterPinnedElem1) instead of passing the
pin number as a runtime argument.
Bumps extension to 0.0.90.
Session: ses_25fb67764ffeSaDgE3xne8F5sB
The cursor DOM is now outer (translate, move timing) + inner (scale+opacity,
press timing). Previously setPressed() overrode transitionDuration to 140ms
which retargeted in-flight 1500ms move transitions, causing clicks to
'sprint' the cursor to the target.
Also:
- Fix toast animation jump when positioned above anchor (toolbar.ts):
keyframes now use --toast-transform CSS variable so translateY(-100%)
base transform is included during animation, not just after.
- Remove stale navigation-rehydration entry from CHANGELOG (was cut in
the simplification pass but the changelog entry remained).
The Chrome extension now injects the ghost-cursor-client.js bundle into
every attached tab via CDP Page.addScriptToEvaluateOnNewDocument +
Runtime.evaluate. The cursor auto-enables on load (top frame only, skips
iframes) and stays on the last spot Playwright clicked or moved to.
Key changes:
- Extension injects cursor bundle via ?raw vite import, inlined into
background.js at build time. Torn down on detachTab.
- RecordingGhostCursorController renamed to GhostCursorController with
attachToPage/detachFromPage instead of enableForRecording/disableForRecording.
Single instance on PlaywrightExecutor, wired in setupPageListeners.
- Recording no longer toggles the cursor on/off.
- 5s idle debounce: cursor fades out (600ms) after no activity, wakes on
next action by teleporting to the new target and fading back in (140ms).
- Animation polish (Emil Kowalski guidelines): easeInOutCubic for moves,
dedicated 140ms press duration with strong ease-out, subtler 0.95 press
scale, transform-origin anchored to cursor hotspot.
- Slower cinematic defaults: 220ms min, 1500ms max, 1.2 px/ms.
- Test: verify cursor element exists on attached tab without explicit enable.
Clipboard now copies a prompt-style message instead of a bare JS snippet:
see the element I pinned in the playwriter tab `playwriter -e '<code>'`
The user pastes this into a chat with their agent. The agent reads it as
"user pinned an element, here is the code to run" and executes it with
their own session (via `-s <id>` or `PLAYWRITER_SESSION`). Makes the
intent obvious when the clipboard lands in a chat window — the agent
sees the goal plus the exact code without needing instructions.
Also fixes a latent bash-safety bug in `buildInspectionCode`. The code
is wrapped in bash `'...'` so single quotes are the only character we
cannot have inside it. `JSON.stringify` does NOT escape literal `'`, so
element text containing an apostrophe (e.g. "Don't save", aria labels
like "I'm loading", URLs with `?q=what's+new`) leaked a literal `'` into
the code and broke the outer bash wrapper.
Fix: replace every `'` in the JSON-stringified URL and summary with
`\u0027`. This is a valid JSON escape that the JS engine parses back to
`'`, so the string value is unchanged at runtime, but the generated
code is now guaranteed to contain zero literal single quotes regardless
of page content.
Single vs double bash quotes comparison:
'...' literal everything except ' ← 1 problem char
"..." $foo expands, `cmd` runs, ← 4 problem chars
\ escapes, " terminates
Our code is full of " (JSON strings) and can contain $ from element
text, so double quotes are a non-starter. Single quotes with the
\u0027 substitution is the simplest robust solution.
Verified with hostile input:
- url with "what's+new" in query → 0 literal ' in code
- summary with "Don't save 'now'" → 0 literal ' in code
- JS eval of the escaped code reproduces the original strings
Bumps extension to 0.0.85. Bundle 72.79 kB → 72.90 kB (+110 bytes for
the new prose prefix and the two .replace calls).
The previous implementation fetched the CLI session list from the relay via
a new GET /extension/sessions endpoint, plumbed it into the MAIN-world
toolbar via chrome.scripting.executeScript args, cached it in a closure,
and baked `playwriter -s <id> -e '<code>'` into the clipboard. The
picked session id was fragile: sessions[0] is the oldest by insertion
order, which is almost never the session the agent is actually using.
The agent also has its own active session already — guessing one is
the wrong job for a browser extension.
Drop all of it. The toolbar now copies only the JS eval code. The agent
wraps it in their own `playwriter -s <session> -e '<paste>'` using
whichever session they already have (or they create a new one). The
code is JSON.stringify'd so it contains no single quotes and slots
into any shell quoting strategy the agent prefers.
Removed:
- `GET /extension/sessions` on the relay (playwriter/src/cdp-relay.ts)
- `fetchSessionSummary` helper in background.ts
- `ToolbarConfig` interface + import + args plumbing
- Async wrapping around `chrome.scripting.executeScript` in both
injection sites (attachTab + webNavigation.onDOMContentLoaded)
- `currentSessions` / `nextSuggested` closure vars in toolbar.ts
- `window.__playwriterUpdateSessions` setter
- `pickSessionId()` helper
- `shellSingleQuote()` helper (agent does their own shell quoting)
- Re-init update routing through `window.__playwriterUpdateSessions`
`onClick` now just:
copyText(buildInspectionCode(n, url, summary))
showToast(`Copied inspection code (pin #${n})`)
The eval code shape is unchanged:
state.page=context.pages().find(x=>x.url()===URL)||context.pages()[0];
console.log(SUMMARY + "\n\nouterHTML:\n" +
await state.page.evaluate(n => globalThis["playwriterPinnedElem"+n]?.outerHTML, N))
Bundle drops from 74.35 kB to 72.79 kB (-1.56 kB). Bumps extension to
0.0.84 and playwriter to 0.0.108 with CHANGELOG entries in both packages.
The in-page toolbar's pin button used to copy a plain
`see playwriter reference: globalThis.playwriterPinnedElem1` string that
still required the agent to wrap it in their own `-e` call. It now copies
a complete, ready-to-run shell command:
playwriter -s 1 -e 'state.page=context.pages().find(x=>x.url()===URL)||context.pages()[0]; console.log(SUMMARY+"\n\nouterHTML:\n"+await state.page.evaluate(n=>globalThis["playwriterPinnedElem"+n]?.outerHTML,N))'
Two `;`-separated statements: pick the right page by URL with first-page
fallback, assign state.page for follow-up calls, log a pre-baked element
summary plus the current outerHTML of `globalThis.playwriterPinnedElemN`
fetched via one `page.evaluate`. No `getCleanHTML`, no
`getLocatorStringForElement`, no ElementHandle dance, no locator round-
trips — the generated JS is runnable against any playwriter version.
Everything the eval code doesn't strictly need a Playwright sandbox for is
pre-computed at pin time in MAIN world (synchronous DOM access) and baked
into the command via `JSON.stringify`:
- page URL from `location.href`
- element summary: tag, id, classes, role, aria-label, name, href, type,
truncated text, bounding rect, visibility
- pin index
Session-id plumbing:
- New public `GET /extension/sessions` endpoint on the relay returns
`{ sessions: [{ id, stateKeys }], nextSuggested }`. It sits outside the
`/cli/*` privileged middleware, which rejects the cross-site
`Sec-Fetch-Site` that chrome-extension fetches to 127.0.0.1 send. It
exposes the same data as `playwriter session list`, so no new attack
surface.
- `background.ts` `fetchSessionSummary()` calls the endpoint with a
1500ms timeout and falls back to `{ sessions: [], nextSuggested: '1' }`
on any failure (relay down, old relay without the endpoint, timeout,
malformed JSON). The extension stays backward-compatible with older
relays.
- Both toolbar injection sites (`attachTab` + `webNavigation.onDOMContentLoaded`
re-inject) now fetch the summary and pass it as
`chrome.scripting.executeScript({ args: [summary] })`.
- The guarded re-init path in `initPlaywriterToolbar` now calls
`window.__playwriterUpdateSessions?.(config)` so re-injects refresh the
closure cache instead of becoming no-ops.
- `pickSessionId()` prefers the first existing session id, falling back
to `nextSuggested` (typically "1") so the agent stays in their current
state when one exists.
Toolbar runtime behavior does not depend on the relay: sessions are
cached in closure at inject time, clipboard writes stay offline, and
all failure modes surface as visible `undefined` in the command's
output rather than exceptions or timeouts.
Bumps extension to 0.0.83 and playwriter to 0.0.107 with CHANGELOG
entries in both packages.
- Deactivate pin mode after each click (toggle off after pin)
- Green flash outline: 3px → 1px to match overlay edge thickness
- Clipboard text prefixed: 'see playwriter reference: globalThis.playwriterPinnedElemN'
so both user and AI agent understand the string's purpose at a glance
- Replace clipboard icon with Playwriter logo-square SVG (inlined)
- Replace (window as Record<string, Element>)[name] with a typed template literal
index signature on the Window interface: [key: playwriterPinnedElem${number}]: Element
- Return type of allocatePinName narrowed to playwriterPinnedElem${number} so
the assignment window[name] = target type-checks without any casts
- Fix stray pw reference missed during declare global refactor
Declare PlaywriterWindow inside the function body (interfaces are TypeScript-only
and stripped at compile time, so safe inside the serialized func injection).
Cast window once to pw = window as PlaywriterWindow and use typed property access
throughout. Dynamic element assignment uses Record<string, Element>.
Injects a floating toolbar into the top-right corner of every Playwriter-connected
tab via chrome.scripting.executeScript({ world: 'MAIN', func: initPlaywriterToolbar }).
- Toolbar uses a closed Shadow DOM for full style isolation from page CSS
- Matches mesurer.ibelick.com visual language: white pill background, mesurer's
exact shadow, 32x32 buttons with rounded-[8px], #0d99ff active state
- Selection overlay uses four 1px edge divs + color-mix fill (same technique as
mesurer's measurement-box) instead of a CSS outline — thinner and cleaner
Pin element mode (clipboard icon button):
- Toggle activates crosshair cursor + hover overlay tracking the element under cursor
- Click assigns the element to globalThis.playwriterPinnedElemN and copies
'globalThis.playwriterPinnedElemN' to clipboard — identical format to right-click menu
- Esc exits pin mode; × button hides toolbar for the session
- Uses window.__playwriterPinCount as a shared MAIN-world counter so toolbar and
right-click context menu never produce conflicting element indices
background.ts changes:
- Inject toolbar in attachTab() after debugger attaches
- Destroy toolbar in detachTab() (fire-and-forget scripting call)
- Re-inject via chrome.webNavigation.onDOMContentLoaded after hard navigations
- Right-click handler now reads window.__playwriterPinCount via Runtime.evaluate
instead of the Zustand store's pinnedCount field
Removed pinnedCount from TabInfo — counter now lives entirely in MAIN world.
No new manifest permissions needed (scripting + webNavigation already present).
Persist a per-install fallback identity for the extension so Chromium-family browsers without chrome.identity profile data no longer collapse to browser-only relay keys and replace each other.
Also stop replaced workers from reclaiming the relay slot while the replacement connection is merely idle, add a regression test that launches a second Chromium context against the same relay, and bump the shipped package versions/changelogs for the extension and playwriter packages.
Chrome popup windows (window.open with features, OAuth login popups)
could not be controlled by Playwriter because chrome.debugger does not
attach to separate popup windows. Agents had to work around this with
cmd+click (`{ modifiers: ['Meta'] }`) to coerce new tabs instead.
The extension now listens for chrome.windows.onCreated with
type === 'popup' and relocates the tab(s) into the source tab's window,
then auto-attaches Playwriter so the new tab appears in context.pages().
Gating: the popup is only relocated when the source tab is already
Playwriter-connected. Otherwise the popup is left alone so unrelated
sites keep their normal Chrome popup behavior. Source tab is discovered
via chrome.webNavigation.onCreatedNavigationTarget because
chrome.tabs.Tab.openerTabId is unreliable for window.open popups on
Chromium 145 (leaves it null).
Focus is not stolen — the relocated tab is appended at the end of the
source tab's strip (index: -1) but the user's active tab stays active.
Executor changes
- Replaced setupPopupDetection with setupNewPageLogging. Warning
message changed from "Popup window detected ... cannot be controlled"
to "New page opened from current page (index N, initial url: ...)"
pointing the agent at the controllable tab.
- Added MIN_EXTENSION_PLAYWRITER_VERSION check so agents running with
old extensions see a targeted warning to update via chrome://extensions.
- enqueueWarning is now called from warnIfExtensionOutdated so the LLM
sees version-skew messages in its warning stream, not just stdout.
Extension changes
- New `webNavigation` permission for the source-tab correlation map.
- connectTab now verifies `chrome.tabs.get(tabId)` before writing an
`error` state, preventing dead tabs from leaking into store.tabs /
badge counts / group sync when a tab closes mid-attach.
- popupSourceTabMap entries are cleaned up both on a 10s timeout and
when chrome.tabs.onRemoved fires, capping memory for plain-new-tab
cases that never trigger windows.onCreated.
Tests
- popup-relocation.test.ts verifies both paths: when a Playwriter tab
is connected the popup is relocated into the main window and appears
in context.pages(); when no tab is connected the popup-type window
remains (extension does not interfere).
Skill docs
- Removed section #10's cmd+click workaround; added a note about
auto-relocation under "working with pages".
Versions
- extension 0.0.79 -> 0.0.80
- playwriter 0.0.103 -> 0.0.104
Chrome's chrome.debugger.attach API refuses to attach to tabs containing
chrome-extension:// iframes from other extensions (LastPass, SurfingKeys,
password managers, etc). This causes the extension to immediately disconnect
after clicking the icon.
Two-layer fix:
1. Pre-attachment (removeRestrictedIframes): when chrome.debugger.attach fails
with the chrome-extension:// error, use chrome.scripting.executeScript to
remove restricted iframes from the DOM, then retry attachment. Requires
new 'scripting' permission.
2. Runtime filtering (onDebuggerEvent): when Target.attachedToTarget arrives
for a restricted child target (injected after debugger attached), filter it
out immediately — don't track, don't forward to relay, detach the child
target. Defense-in-depth against runtime re-injection by other extensions'
content scripts.
Test: loads a fixture extension alongside playwriter, creates a page with a
chrome-extension:// iframe, enables the debugger, and verifies it stays
connected and can execute JS.
Closes#18
Bundle the extension into the CLI so fresh AVPS and local source checkouts can start a Playwriter-ready Chrome for Testing or Chromium without separate installation steps. Also disable the packaged welcome tab, reuse Playwright\s managed Chromium fallback, surface searched browser paths on discovery failures, and document that managed browser launches already enable the tab-capture flags needed for recording on normal web pages.
Respect PLAYWRITER_EXTENSION_DIST in the Prism download step so release builds include welcome page Prism scripts in dist-release/src instead of polluting dist/src.
Prevent service worker startup crashes in environments where chrome.contextMenus is undefined. Use optional chaining for create, update, and onClicked registration so missing context menu APIs degrade gracefully instead of throwing uncaught TypeError.
The alarm added in 0.0.73 was a no-op — the maintainLoop while(true) + sleep(1000)
and setInterval(checkMemory, 5000) already keep the service worker alive.
Removing it drops the unnecessary alarms permission from the manifest.
Bumps extension to 0.0.75.
osascript 'tell application Chrome to open location' blocks indefinitely
waiting for Chrome to finish handling the chrome:// URL. The open command
returns immediately.
When Playwright sends Target.detachFromTarget via the root browser session
(no top-level sessionId), the extension couldn't find the target tab because
it only checked msg.params.sessionId for routing. This caused 'No tab found'
errors that cascaded into disconnects and instability.
- Add getTabForCommand() helper with params.sessionId fallback so any command
referencing a session in its params can be routed when the top-level
sessionId is absent
- No-op Target.detachFromTarget for stale/unknown sessions instead of throwing
- Always re-apply tab group color on every sync to prevent Chrome resetting
it to white
- Replace silent .catch() with error log in aria-snapshot OOPIF detach
- Add regression test using raw WebSocket to verify routing without sessionId
Extension bumped to 0.0.74.
This commit batches the pending workspace updates that were coupled in practice: extension reliability fixes, ghost cursor persistence across MPA navigations, timeout tuning, and aligned docs/versioning updates so behavior and guidance stay in sync.
Key updates:\n- extension: add MV3 keepalive via chrome.alarms, add alarms permission, and bump extension changelog/version to 0.0.73 to reduce silent relay disconnects while idle\n- playwriter runtime: raise default action timeout to 60s while keeping navigation timeout separate; increase relay /version fetch timeout to 2000ms\n- recording + cursor: add persistent cursor init scripts with Page.addScriptToEvaluateOnNewDocument, remove init script on teardown, and ensure cursor enablement before applying mouse actions\n- demo video defaults: use 0.5s side buffer (1s total) and default idle speed 6x for createDemoVideo/computeIdleSections\n- tests/docs: keep click-error tests deterministic with explicit 5000ms per-click timeout, remove outdated click-timeout section from skill mistakes, and update package/changelog entries
syncTabGroup was including 'connecting' tabs in the group unconditionally,
so when the relay WebSocket closed and all tabs transitioned to 'connecting',
the group stayed green. Now 'connecting' tabs are only included when the
relay is actually connected (connectionState === 'connected'). When the
relay is dead, 'connecting' tabs are excluded and the group gets cleaned up.
The onUpdated handler at line ~1601 already guards against the
ungroup→disconnect loop for 'connecting' tabs, so this is safe.
The function is now exposed as `snapshot()` in the executor sandbox scope.
`accessibilitySnapshot` remains as a backward-compatible alias pointing to
the same function, so existing agents and saved commands continue to work.
Updated all code examples across skill.md, README, PLAYWRITER_AGENTS,
docs, tests, and the extension welcome page to use the shorter name.
The extension now reports which playwriter version it was built with via a
`?v=` query param on its WebSocket connection to the relay. The relay stores
this and exposes it in `/extension/status` and `/extensions/status` endpoints.
CLI and MCP compare the extension's bundled version against their own VERSION.
If the extension was built with a newer playwriter, a warning is shown:
Playwriter 0.0.60 is outdated (extension requires 0.0.62).
Run `npm install -g playwriter@latest` or update the playwriter package.
This is fully backward compatible:
- Old extensions without `?v=` get `playwriterVersion: null`, no warning
- No WS protocol changes (version is in URL query params)
- Warning fires once per executor/session lifetime
Flow:
vite build reads playwriter/package.json → injects __PLAYWRITER_VERSION__
→ extension sends ?v=X.Y.Z on WS connect → relay stores in ExtensionInfo
→ /extension/status returns playwriterVersion → CLI/executor checks & warns
Route Runtime.enable/disable through the incoming child sessionId instead of always using the tab root session so OOPIF iframe targets receive execution contexts reliably. Add a focused relay regression test for empty-src cross-origin iframe attach flow and update extension version/changelog for release tracking.
When a user drags a tab into the playwriter group, connectTab() sets
its state to 'connecting'. syncTabGroup() previously only kept
'connected' tabs in the group, so it would immediately ungroup the
'connecting' tab. This ungroup fired chrome.tabs.onUpdated, which
called disconnectTab(). When the original connectTab() resolved, it
re-added the tab, triggering onUpdated again — creating an infinite
add/remove/add/remove loop.
Fix: include 'connecting' tabs in syncTabGroup's group membership
filter. This way syncTabGroup won't fight the user's intent by
removing tabs that are still being set up. Error-state tabs are
still correctly excluded from the group.
Tabs created via Ghost Browser's openTab API now automatically
attach to Playwriter's debugger, making them immediately available
in context.pages() without requiring manual extension icon click.
- Extension connections are now tracked per-connection with isolated state
- Each connection has its own connectedTargets, pendingRequests, messageId
- Sessions are bound to specific extensionId via SessionMetadata
- New /extensions/status endpoint for listing all connected browsers
- CLI session new shows browser selection when multiple connected
- Browser detection via navigator.userAgentData (Ghost, Brave, Edge, etc)
- Added identity permission for Chrome profile email detection
- Constants, types, proxy factory, and handler in one file
- Works in both Node.js (executor) and Chrome (extension) environments
- executor.ts imports createGhostBrowserChrome factory
- background.ts imports handleGhostBrowserCommand handler