diff --git a/.claude/settings.json b/.claude/settings.json
index 430f950e..e0769e89 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -25,7 +25,11 @@
"Bash(ps -eo pid,cmd)",
"Bash(ps -o pid,lstart,cmd -p 746640)",
"Bash(kill 746636)",
- "Bash(ps -eo pid,lstart,cmd)"
+ "Bash(ps -eo pid,lstart,cmd)",
+ "Bash(grep -E \"^d|\\\\.py$\")",
+ "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
+ "Bash(xargs cat:*)",
+ "Bash(find /home/timothy/aden/hive -path \"*/.venv\" -prune -o -name \"*.py\" -type f -exec grep -l \"frontend\\\\|UI\\\\|terminal\\\\|interactive\\\\|TUI\" {} \\\\;)"
],
"additionalDirectories": [
"/home/timothy/.hive/skills/writing-hive-skills",
diff --git a/core/framework/skills/_default_skills/browser-automation/SKILL.md b/core/framework/skills/_default_skills/browser-automation/SKILL.md
index 6b460ac6..61d51369 100644
--- a/core/framework/skills/_default_skills/browser-automation/SKILL.md
+++ b/core/framework/skills/_default_skills/browser-automation/SKILL.md
@@ -1,43 +1,290 @@
---
-name: hive.browser-automation
-description: Best practices for browser automation via gcu-tools MCP server (reading pages, navigation, scrolling, tab management, shadow DOM, coordinates).
+name: browser-automation
+description: Drive a real Chrome browser via the GCU Beeline extension + Chrome DevTools Protocol. Navigation, clicks, typing, screenshots, shadow-DOM sites (LinkedIn / Reddit / X), keyboard shortcuts, CSP gotchas, rich-text editors. Verified against real production sites 2026-04-11.
metadata:
author: hive
type: default-skill
+ version: "2.0"
+ verified: 2026-04-11
---
-## Operational Protocol: Browser Automation
+# GCU Browser Automation
-Follow these rules for reliable, efficient browser interaction.
+All GCU browser tools drive a real Chrome instance through the Beeline extension and Chrome DevTools Protocol (CDP). That means clicks, keystrokes, and screenshots are processed by the actual browser's native hit testing, focus, and layout engines — **not** a synthetic event layer. Understanding this unlocks strategies that make hard sites easy.
-### Reading Pages
-- ALWAYS prefer `browser_snapshot` over `browser_get_text("body")` -- it returns a compact ~1-5 KB accessibility tree vs 100+ KB of raw HTML.
-- Interaction tools (`browser_click`, `browser_type`, `browser_fill`, `browser_scroll`, etc.) return a page snapshot automatically in their result. Use it to decide your next action -- do NOT call `browser_snapshot` separately after every action. Only call `browser_snapshot` when you need a fresh view without performing an action, or after setting `auto_snapshot=false`.
-- Many complex pages (LinkedIn, Twitter/X, SPAs with virtual scrolling) have DOMs that don't match what's visually rendered — snapshot refs may be stale, missing, or misaligned with visible layout. On these pages, `browser_screenshot` is the only reliable way to orient yourself.
-- When using screenshots for interaction, you MUST convert image pixel positions via `browser_coords(x, y)` before clicking. NEVER pass raw screenshot pixel positions directly to `browser_click_coordinate` — the image is downscaled and the coordinates will be wrong. Always: screenshot → read position → `browser_coords` → use `physical_x/y` to click.
+## Coordinates: always CSS pixels
+
+**Chrome DevTools Protocol `Input.dispatchMouseEvent` operates in CSS pixels, not physical pixels.**
+
+When you call `browser_coords(image_x, image_y)` after a screenshot, the returned dict has both `css_x/y` and `physical_x/y`. **Always use `css_x/y` for clicks, hovers, and key presses.**
+
+```
+browser_screenshot() → image (downscaled to 800/900 px wide)
+browser_coords(img_x, img_y) → {css_x, css_y, physical_x, physical_y}
+browser_click_coordinate(css_x, css_y) ← USE css_x/y
+browser_hover_coordinate(css_x, css_y) ← USE css_x/y
+browser_press_at(css_x, css_y, key) ← USE css_x/y
+```
+
+Feeding `physical_x/y` on a HiDPI display overshoots by DPR× — on a DPR=1.6 laptop, clicks land 60% too far right and down. The ratio between `physicalScale` and `cssScale` tells you the effective DPR.
+
+`getBoundingClientRect()` already returns CSS pixels — feed those values straight through to click/hover tools without any DPR multiplication.
+
+**Exception for zoomed elements:** pages that use `zoom` or `transform: scale()` on a container (LinkedIn's `#interop-outlet`, some embedded iframes) render in a scaled local coordinate space. `getBoundingClientRect` there may not match CDP's hit space. Use `browser_shadow_query` which handles the math, or fall back to visually picking coordinates from a screenshot.
+
+## Screenshot + coordinates is shadow-agnostic — prefer it on shadow-heavy sites
+
+On sites that use Shadow DOM heavily (Reddit's faceplate Web Components, LinkedIn's `#interop-outlet` messaging overlay, some X custom elements), **coordinate-based operations reach elements that selector-based tools can't see.**
+
+Why:
+
+- **CDP hit testing walks shadow roots natively.** `browser_click_coordinate(css_x, css_y)` routes through Chrome's native hit tester, which traverses open shadow roots automatically. You don't need to know the shadow structure.
+- **Keyboard dispatch follows focus** into shadow roots. After a click focuses an input (even one three shadow levels deep), `browser_press(...)` with no selector dispatches keys to `document.activeElement`'s computed focus target.
+- **Screenshots render the real layout** regardless of DOM implementation.
+
+Whereas `wait_for_selector`, `browser_click(selector=...)`, `browser_type(selector=...)` all use `document.querySelector` under the hood, which **stops at shadow boundaries**. They cannot see elements inside shadow roots.
+
+### Recommended workflow on shadow-heavy sites
+
+1. `browser_screenshot()` → visual image
+2. Identify the target visually → image pixel `(x, y)` (eyeball from the screenshot)
+3. `browser_coords(x, y)` → convert to CSS px
+4. `browser_click_coordinate(css_x, css_y)` → lands on the element via native hit testing; inputs get focused
+5. For typing:
+ - If the element was reachable via a selector → `browser_type(selector, text)`
+ - Otherwise → `browser_press(key)` per character (dispatches to focused element, no selector needed)
+6. Verify by reading element state via a targeted `browser_evaluate` that walks the shadow tree
+
+### Empirically verified (2026-04-11)
+
+Tested against `https://www.reddit.com/r/programming/` whose search input lives at:
+```
+document > reddit-search-large [shadow]
+ > faceplate-search-input#search-input [shadow]
+ > input[name="q"]
+```
+
+- `document.querySelector('input')` → **0 visible inputs** on the page (all in shadow)
+- `browser_type('faceplate-search-input input', 'python')` → "Element not found"
+- `browser_click_coordinate(617, 28)` → focus trail: `REDDIT-SEARCH-LARGE > FACEPLATE-SEARCH-INPUT > INPUT` ✓
+- Char-by-char key dispatch after the click → `input.value === 'python'` ✓
+
+Coordinate pipeline: works perfectly. Selector pipeline: unusable without shadow-piercing syntax.
+
+### Shadow-piercing selectors
+
+When you DO want a selector-based approach and know the shadow structure, `browser_shadow_query` and `browser_get_rect` support `>>>` shadow-piercing syntax:
+
+```
+browser_shadow_query("reddit-search-large >>> #search-input")
+browser_get_rect("#interop-outlet >>> #ember37 >>> p")
+```
+
+Returns the element's rect in **CSS pixels** (feed directly to click tools). Remember: `browser_type` and `wait_for_selector` do **not** support `>>>` — only shadow_query and get_rect do.
+
+## Navigation and waiting
+
+### The basics
+
+```
+browser_navigate(url, wait_until="load") # "load" | "domcontentloaded" | "networkidle"
+browser_wait_for_selector("h1", timeout_ms=5000)
+browser_wait_for_text("Some text", timeout_ms=5000)
+browser_go_back()
+browser_go_forward()
+browser_reload()
+```
+
+All return real URLs and titles. On a fast page `navigate(wait_until="load")` returns in sub-second. `wait_for_selector` and `wait_for_text` typically resolve in single-digit milliseconds on elements already in the DOM.
+
+### Timing expectations (measured against real sites)
+
+| Site | Navigate load time |
+|---|---|
+| example.com | 100–400 ms |
+| wikipedia.org | 200–500 ms |
+| reddit.com | 1.5–2 s |
+| x.com/twitter | 1.2–1.6 s |
+| linkedin.com (logged in) | 4–5 s |
+
+Use `timeout_ms=20000` for LinkedIn and other heavy SPAs to give them margin.
+
+### After navigate, always let SPA hydrate
+
+Even after `wait_until="load"`, React/Vue SPAs often render their real chrome in a second pass. Add `await sleep(2)` to `await sleep(3)` before querying for site-specific elements. Otherwise `wait_for_selector` will fail on elements that do exist moments later.
+
+### Reading pages efficiently
+
+- **Prefer `browser_snapshot` over `browser_get_text("body")`** — returns a compact ~1–5 KB accessibility tree vs 100+ KB of raw HTML.
+- Interaction tools (`browser_click`, `browser_type`, `browser_fill`, `browser_scroll`, etc.) return a page snapshot automatically in their result. Use it to decide your next action — do NOT call `browser_snapshot` separately after every action. Only call `browser_snapshot` when you need a fresh view without performing an action, or after setting `auto_snapshot=false`.
+- Complex pages (LinkedIn, Twitter/X, SPAs with virtual scrolling) have DOMs that don't match what's visually rendered — snapshot refs may be stale, missing, or misaligned with visible layout. On these pages, `browser_screenshot` is the only reliable way to orient yourself.
- Only fall back to `browser_get_text` for extracting specific small elements by CSS selector.
-### Navigation & Waiting
-- `browser_navigate` and `browser_open` already wait for the page to load. Do NOT call `browser_wait` with no arguments after navigation -- it wastes time. Only use `browser_wait` when you need a *specific element* or *text* to appear (pass `selector` or `text`).
-- NEVER re-navigate to the same URL after scrolling -- this resets your scroll position and loses loaded content.
+## Typing and keyboard input
-### Scrolling
-- Use large scroll amounts ~2000 when loading more content -- sites like twitter and linkedin have lazy loading for paging.
-- The scroll result includes a snapshot automatically -- no need to call `browser_snapshot` separately.
+### ALWAYS click before typing into rich-text editors
-### Batching Actions
-- You can call multiple tools in a single turn -- they execute in parallel. ALWAYS batch independent actions together. Examples: fill multiple form fields in one turn, navigate + snapshot in one turn, click + scroll if targeting different elements.
+**The single most common "looks like it worked but send button stays disabled" failure.** If you're typing into a modern editor (X/Twitter's Draft.js compose, LinkedIn's post composer, Reddit's comment box, Gmail compose, Slack, Discord, Notion, Monaco, any `contenteditable`), **click the input area first with `browser_click_coordinate` or `browser_click(selector)` before you type**.
+
+Why this is necessary:
+
+- **React / Vue controlled components** don't trust JS-sourced `.focus()`. React uses event delegation and watches for *native* pointer/focus events — a `click` dispatched via CDP fires the real `pointerdown`/`pointerup`/`click`/`focus` sequence that React listens to, and updates its internal state. A JS-only `.focus()` sets `document.activeElement` but the framework's controlled state doesn't see it.
+- **Draft.js** (X/Twitter compose) and **Lexical** (Gmail, LinkedIn DMs) use contenteditable divs with immutable editor state. They only enter "edit mode" after a real click on the editor surface. Typing at them without clicking routes keys to `document.body` or gets silently discarded.
+- **Send/submit buttons are bound to framework state**, not DOM state. They're typically `disabled={!hasRealContent}` where `hasRealContent` is computed from React/Vue/Svelte state. The input field can have characters in the DOM but the button stays disabled because the framework never saw a real input event.
+
+The symptom is always the same: **you type, the characters appear visually, and the send button doesn't enable**. The agent then clicks send anyway, nothing happens, and it thinks the post failed.
+
+### Safe "click-then-type-then-verify" pattern
+
+```
+# 1. Focus the real element via a real click (not JS .focus()).
+rect = browser_get_rect(selector) # or browser_shadow_query for shadow sites
+browser_click_coordinate(rect.cx, rect.cy)
+sleep(0.5) # let the editor open / focus settle
+
+# 2. Type. browser_type now uses CDP Input.insertText by default, which is
+# the most reliable way to insert text into rich editors (Lexical,
+# Draft.js, ProseMirror, any React-controlled contenteditable).
+browser_type(selector, text)
+sleep(1.0) # let framework state commit
+
+# 3. BEFORE clicking send, verify the submit button is actually enabled.
+# Don't trust that typing worked — check state.
+state = browser_evaluate("""
+ (function(){
+ const btn = document.querySelector('[data-testid="tweetButton"]');
+ if (!btn) return {exists: false};
+ return {
+ exists: true,
+ disabled: btn.disabled || btn.getAttribute('aria-disabled') === 'true',
+ text: btn.textContent.trim(),
+ };
+ })()
+""")
+
+# 4. Only click send if the button is enabled.
+if not state['disabled']:
+ browser_click(submit_selector)
+else:
+ # Recovery: sometimes a click-again + one extra keystroke nudges
+ # React into recomputing hasRealContent.
+ browser_click_coordinate(rect.cx, rect.cy)
+ browser_press("End")
+ browser_press(" ")
+ browser_press("Backspace")
+ # re-check state
+```
+
+### Why `browser_type` uses `Input.insertText` by default
+
+CDP has a dedicated method — `Input.insertText` — for committing text into the focused element as if IME just committed it. It **bypasses the keyboard event pipeline entirely** and works cleanly on every rich-text editor tested to date: Lexical (LinkedIn DMs, Gmail), Draft.js (X compose), ProseMirror (Reddit), Monaco, and plain `contenteditable`. Playwright uses this under the hood for `keyboard.type()` on rich editors.
+
+Per-character `Input.dispatchKeyEvent` looks equivalent on paper, but some rich editors listen for `beforeinput` events with a specific shape and route insertion through their own state machine — the raw keys arrive but never get turned into text. That was the exact failure mode that left LinkedIn's message composer empty (and its Send button disabled) during the 2026-04-11 empirical run.
+
+If you need per-keystroke dispatch (autocomplete testing, code editors, animated typing with `delay_ms`), pass `use_insert_text=False` to fall back to the old `keyDown/keyUp` path.
+
+### Neutralizing `beforeunload` draft dialogs
+
+When a composer has unsent text and you try to navigate away or close the tab, sites like LinkedIn pop a native "You have an unsent message, leave?" confirm dialog via `window.onbeforeunload`. Your automation hangs waiting on the dialog — `browser_close_tab` and `browser_navigate` both time out.
+
+**Strip the handler via `browser_evaluate` before navigating:**
+
+```
+browser_evaluate("""
+ (function(){
+ window.onbeforeunload = null;
+ window.addEventListener('beforeunload', function(e){
+ e.stopImmediatePropagation();
+ }, true);
+ return true;
+ })()
+""")
+# Now browser_navigate / close_tab work without hitting a confirm
+```
+
+Always include an equivalent cleanup block in any script that types into a compose UI — without it, a script crash mid-type leaves the tab in an unusable state with the draft modal blocking every subsequent automation call.
+
+### Verified site-specific quirks
+
+| Site | Editor | Workaround |
+|---|---|---|
+| **X / Twitter** compose | Draft.js | Click `[data-testid='tweetTextarea_0']` first, then type with `delay_ms=20`. First 1-2 chars may be eaten — accept truncation or prepend a throwaway char. Verify `[data-testid='tweetButton']` has `disabled: false` before clicking. |
+| **LinkedIn** messaging | contenteditable (inside `#interop-outlet` shadow root) | Use `browser_shadow_query` to find the rect, click-coordinate to focus, then type via focus-based key dispatch (selector-based type can't reach shadow). Send button is `.msg-form__send-button`. |
+| **LinkedIn** feed post composer | Quill/LinkedIn custom | Click the "Start a post" trigger first, wait 1s for modal, click the textarea, type. |
+| **Reddit** comment/post box | ProseMirror | Click the textarea, wait 0.5s for the toolbar to mount, then type. Submit is `button[slot="submit-button"]` inside a shreddit-composer. |
+| **Gmail** compose | Lexical | Click the body first. Gmail has a visible `div[contenteditable=true][aria-label*='Message Body']` after opening a compose window. |
+| **Slack** message box | contenteditable | Click first, then type. Send is a paper-plane button with `data-qa='texty_send_button'`. |
+| **Discord** | Slate | Click first. Discord's send is implicit on Enter (no button), so just press Enter after typing. |
+| **Monaco** editors (GitHub code review, CodeSandbox) | Monaco | Click first, type with `delay_ms=10`. Monaco listens for `textarea` input events on a hidden textarea — requires focus to be on that textarea. |
+
+### Plain text into a real input
+
+For plain `` and `