app
pup-bridge-macos
Public Made by Adomby adom
The pup for macOS SKILL PACK: the AI skills that teach a cloud/container AI how to drive the macOS Puppeteer (pup) bridge (pup_* verbs): the everyday `pup` skill plus focused sub-skills for windows/sessions/tabs, screenshots+recording, credentials, lanes, settings, browsers+Chrome install, vendor logins and the Adom wiki. Installs into ~/.claude/skills + ~/.codex/skills. DOCS ONLY: this is NOT the bridge runtime. The bridge itself is one signed macOS binary that Adom Bridge for macOS installs an
← Commit history
Publish 3.0.0
19 files changed
+2842
README.md+83SKILL.md+447dockbar.json+45docs/img/dock-pup.svg+11install.sh+60package.json+181page.json+184screenshots/hero.pngskills/pup-adom-wiki/SKILL.md+105skills/pup-browsers-and-chrome/SKILL.md+128skills/pup-credentials/SKILL.md+210skills/pup-lanes/SKILL.md+116skills/pup-screenshots-recording/SKILL.md+120skills/pup-settings/SKILL.md+105skills/pup-vendor-login/SKILL.md+180skills/pup-why-cft/SKILL.md+114skills/pup-windows-sessions-tabs/SKILL.md+152tools/pup-dashboard+573uninstall.sh+28README.mdadded+83@@ -0,0 +1,83 @@+# Pup for macOS: the AI's own browser++**Pup gives your AI a real browser on your Mac that it drives completely, in the background, signed in as you.** Its windows live in a hidden browser app, so they never cover your work or take focus, and the AI can still open pages, click, type, read, screenshot and record them. Every window shares one profile where your logins accumulate, and pup fills saved passwords for the AI. Ask your AI to "open it in pup" and it does.++Pup for macOS is one small signed program, a universal binary for Apple Silicon and Intel Macs, that [Adom Bridge](https://wiki.adom.inc/adom/adom-bridge) installs and keeps up to date. This page holds its source, its AI skills and its releases. It is open source under the MIT license.++## Install++On your Mac there is nothing to install by hand. Adom Bridge for macOS (bundled with Hydrogen) downloads pup the first time an AI asks for a browser, and updates it on its own from this page. In an AI container, install the skills so the AI knows how to use it:++```bash+adom-wiki pkg install adom/pup-bridge-macos+```++## Quick start++Your AI addresses its windows by its own thread name, so there are no ids to track:++```bash+adom-bridge-cli --ai-thread "my-thread" pup_open_window '{"url":"https://example.com"}' # a real window, hidden behind your work+adom-bridge-cli --ai-thread "my-thread" pup_screenshot '{}' # the image lands in the container+adom-bridge-cli --ai-thread "my-thread" pup_navigate '{"url":"https://wiki.adom.inc"}' # same window, next page+adom-bridge-cli --ai-thread "my-thread" pup_my_windows '{}' # everything this thread has open+```++Or just say it: "open my app in pup", "screenshot this site in pup", "record a clip of this page in pup".++## What it does++| area | verbs |+|---|---|+| Windows and tabs | `pup_open_window`, `pup_open_tab`, `pup_switch_tab`, `pup_close_tab`, `pup_my_windows`, `pup_list_windows`, `pup_close_window` |+| Driving a page | `pup_navigate`, `pup_reload`, `pup_back`, `pup_forward`, `pup_click`, `pup_type`, `pup_fill`, `pup_select`, `pup_press_key`, `pup_scroll`, `pup_input_dispatch`, `pup_eval`, `pup_devtools`, `pup_dialog` |+| Capture | `pup_screenshot`, `pup_screenshot_element`, `pup_screenshot_full_res`, `pup_record_start` / `pup_record_stop`, `pup_annotate` |+| Logins and cards | `pup_login`, `pup_google_signin`, `pup_credentials`, `credential_set`, `pup_vault_sync`, `pup_card_set`, `pup_fill_card` |+| Showing the user | `pup_raise_os_window` (needs a `foregroundReason`), `pup_lower_os_window`, `pup_alert_window` (a notification) |+| Health | `pup_readiness`, `pup_prewarm`, `pup_status`, `pup_describe` |++`pup_describe` returns the full machine-readable catalog.++## How it differs from the Windows line++This is a standalone macOS app, forked from the Windows pup line ([adom/pup-bridge](https://wiki.adom.inc/adom/pup-bridge)) with every Windows code path removed.++- Background means pup's browser app is hidden (the Cmd+H state), not pushed to the bottom of a window stack.+- macOS has no per-window taskbar button, so there are no thread tiles, favicon badges, jump lists, taskbar progress bars or flashes. Those verbs answer honestly instead.+- A nudge to the user (`pup_alert_window`) is a macOS notification that never takes focus.+- Importing the browser's own saved passwords is not available. Logins grow when the user signs in once in a pup window, or through `pup_vault_sync` from another of their desktops.+- Passwords live in the macOS Keychain. Pup's browser runs with an in-memory cookie key, so it never asks for Keychain access.+- Installing Google Chrome on request copies it from Google's `.dmg` into `/Applications`. Recording a window needs ffmpeg (`brew install ffmpeg`).++## Keeping up with the Windows line++Most of pup is platform-neutral, and the Windows line moves fast. `tools/check-drift.sh` compares this fork with the Windows page and reports what changed since the last reviewed baseline. Platform-neutral changes are ported forward; Windows-only changes are reviewed and skipped on purpose.++## Dependencies++| dependency | required? | why |+|---|---|---|+| macOS on Apple Silicon or Intel | yes | Pup for macOS is a native Mac program. |+| Adom Bridge for macOS | yes | It installs, updates and starts pup, and carries every command from your AI to it. Hydrogen includes it. |+| A browser build | automatic | Pup downloads its own Chrome build into `~/.cache/puppeteer` once, and uses an installed Google Chrome until that finishes. |++## Skills++The skills teach any AI how to use pup well. The main `pup` skill is the start; the others load when a task needs them.++| skill | purpose |+|---|---|+| `pup` | Start here: the model, addressing by thread name, the everyday verbs, and the rules. |+| `pup-windows-sessions-tabs` | Windows, tabs, ownership and the zero-id forms. |+| `pup-screenshots-recording` | Every screenshot and recording option, and tricky pages. |+| `pup-credentials` | The vault, sign-in, Google accounts and vault sync. |+| `pup-lanes` | How pup drives a window and the rules that keep your closes final. |+| `pup-settings` | Every setting, behavior by behavior. |+| `pup-adom-wiki` | Viewing wiki pages signed in or as the public. |+| `pup-vendor-login` | Signing into vendor portals. |+| `pup-browsers-and-chrome` | Readiness, the browser build and cold starts (maintainer detail). |+| `pup-why-cft` | Why pup drives its own Chrome build (maintainer detail). |++## Open source++The complete source is in this page's **Files** tab: one Rust workspace, with every macOS API kept in a single crate (`rust/host-mac`). Build it with `tools/build.sh <version>`. Issues and ideas are welcome in the **Issues** tab.
SKILL.mdadded+447@@ -0,0 +1,447 @@+---+name: pup+user-invocable: true+description: "Drive a browser on the user's Mac from the cloud: the macOS Puppeteer 'pup' bridge (pup_* verbs via the adom-bridge-cli CLI). To the user these are PUP WINDOWS, full stop: never name the browser build or how pup drives it unless they ask. pup runs its own browser in a pup-managed DURABLE profile (logins/localStorage persist and are shared across pup windows), driven through pup's own extension channel with no debug port, which speaks the full DevTools protocol (CDP) through that extension, so every pup window has CDP-level access (pup_devtools for any raw command): never give up because 'CDP is off'. Windows live in a HIDDEN browser app so they never cover the user or take focus, yet stay fully drivable and capturable; NOT the user's real signed-in profile (that is the Adom extension's nbrowser_* verbs), NOT headless (a real rendered window, so screenshots/recording are true-to-life). Open/close windows + tabs, navigate, screenshot, eval JS, record, sign in from pup's own vault. This is the START-HERE skill: the mental model + when-to-use + the skill map; deeper topics route to the sub-skills. For 'is it ready' use pup_readiness (not pup_status). Trigger words: pup, puppeteer, browser window, open in pup, open my app in pup, browser screenshot, browser eval, browser reload, visual debug, headful chrome, pup_open_window, pup_screenshot, pup_readiness, pup_prewarm, chrome for testing, open in browser, open my web app, show my app in the browser, preview my app, open the app I just built, view my app, open localhost, show my dev server, pup_login, log into a site in pup, autofill password, saved login, durable profile, isolated window."+---++# Pup: browser control on the user's Mac (start here)++## What to call it when you talk to the user++They are **pup windows**. Say "I opened it in pup" or "your pup window". Lead with what the user+sees (pup windows), not the machinery: the browser build, the "Google Chrome for Testing" window+titles and how pup drives them are details to explain plainly whenever the user asks (pup-bridge#114). The build+detail lives in the maintainer skills (pup-why-cft, pup-browsers-and-chrome) for when a user asks.++Drive a browser on the user's Mac from a cloud container via the `adom-bridge-cli` CLI. Every verb is a+**Bash command** (NOT an MCP tool):++```bash+adom-bridge-cli --ai-thread "<your thread>" pup_open_window '{"url":"https://example.com"}' # your thread's own window, no ids+adom-bridge-cli --ai-thread "<your thread>" pup_screenshot '{}' # zero-id: resolves to YOUR window+```++## pup windows have CDP-level access: never give up because "CDP is off"++Every pup window is driven through pup's own browser extension, which speaks the full DevTools protocol (CDP) to the+page with no debug port. So you have everything CDP gives you on every pup window:+trusted clicks and keys (`pup_click`, `pup_type`, `pup_input_dispatch`), `pup_eval`, element and full-page+screenshots, downloads, fetches, HTTP auth, and `pup_devtools {method, params}` for any other DevTools command+(DOM, Runtime, Input, Page, Network, Emulation, iframes through `Page.getFrameTree`).++The separate "CDP lane" (`lane:"cdp"`, a window with a debug port) is OFF by default, and you do not need it. "CDP is switched off, so pup+can't do this here" is never a true answer, and never ask for `lane:"cdp"` to get a feature: a+`lane:"cdp"` open with that lane off simply opens on the extension lane and says so. If something truly cannot be+done, report the exact verb and error on adom/pup-bridge-macos and keep going with what works.++## Logins: pup manages your credentials (no Chrome picker)++pup runs its OWN password manager, so the AI signs into sites without Chrome's foreground autofill+dropdown. Backed by the macOS Keychain (encrypted at rest); passwords are never logged, never echoed.+pup's browser runs with an in-memory cookie key, so it never raises a keychain prompt of its own.++- **Auto-capture**: the user signs into any site once in a pup window and pup records the credential+ to the vault as they submit. The next verb response carries `_credentialCaptured`. Turn off with+ `pup_configure {captureLogins:"off"}`. This is THE way logins grow on a Mac: when a sign-in page has+ no saved login (`_loginTip`, `no_credential`), ask the user to sign in once in that pup window.+- **Auto-fill on return**: a matching login page is filled in the background (no dropdown, no+ foregrounding). The response carries `_autoLogin`, which **regurgitates the live domain** and asks+ you to confirm it is the intended site (anti-phishing).+- **Submit is reason-gated**: pup NEVER auto-submits a stored password. To sign in, call+ `pup_login {submit:true, submitReason:"<why this domain is the right one>"}`. Bare `submit:true`+ is refused with `submit_reason_required` and the exact live host, so a lookalike domain is caught+ before the secret is sent.+- **Google is its own verb**: `pup_google_signin {account, submitReason}` (never `pup_login`). Users+ usually have several Google accounts and pup never guesses: with no `account` it returns the list and+ you decide from the task or by asking. Full playbook: the `pup-credentials` skill.+- **Importing the browser's own saved passwords is not available on macOS.** `pup_import_scan` finds+ nothing and `pup_import_browser_logins` answers `unsupported_platform`: those passwords sit in the+ login keychain behind a per-app grant pup does not ask for. Do not offer an import to a Mac user.+- **Waiting on the human? TOAST, with the window attached.** `notify_user {title, body, level:"warning",+ focus:{hwnd:<pup window>}}` so clicking the toast opens the exact window that is waiting. pup toasts+ on every no-password answer itself; you still toast whenever YOUR thread is about to sit and wait.+- **Manage**: `pup_credentials {}` lists saved logins (host + username only); `credential_delete+ {host}` removes one; `credential_set {host, username, password}` stores one explicitly when the+ user gives it to you directly. Never ask the user to type a password into the chat.+- **Share the vault**: `pup_vault_sync {peer, reason}` merges the adom-you vault with another of the+ user's desktops in both directions (newest wins, pup-to-pup, no AI sees a password).+- **Reveal one to a LOCAL assistant**: `pup_credential_reveal {host, reason}` hands the saved username+ + password for ONE host to a caller running on the desktop, after the user clicks **Reveal** on a+ sticky toast that names the caller, the host and your reason. No click, no secret. A relayed (cloud+ container) call is refused with `relay_forbidden`: from a cloud thread call `pup_login {submit:true,+ submitReason}` instead. **Waiting on the click is never a timeout**: past 45 s the verb answers+ `errorCode: blocked_on_user, stillRunning: true, consentId` while the toast stays up; call the SAME+ verb with the SAME args to collect the decision. `pup_fill_card` behaves the same.++## Payment cards: pup can finish a checkout++- `pup_card_set {nickname, number, expiry, cvv, name}` stores a card in the Keychain; ask the USER+ for the details, never harvest them from a page. `pup_cards {}` lists nickname, brand, last4 and+ expiry only; `pup_card_delete {nickname}` removes one. **No verb ever returns the number or CVV.**+- `pup_fill_card {nickname, merchant, amount}` fills the card into the checkout WITHOUT the digits+ ever reaching you (it walks payment iframes like Stripe and returns booleans). **Every fill is+ approved by the user in the moment** on a desktop toast naming the merchant, amount and last4.++## Addressing: your thread name IS the handle++You never need a window id or `desktop_find_window` for a pup window. pup keys every window to its+owning `--ai-thread` name and resolves ids for you:++- **Open**: `pup_open_window {"url":"..."}`, omit sessionId. Your thread gets its OWN window+ (first open creates it, later opens become tabs in it), sized to the work area, in the background.+- **Come back to it, from any later turn**: call verbs with NO ids: `pup_screenshot {}`,+ `pup_reload {}`, `pup_navigate {"url":"..."}` all resolve to your thread's window.+- **See everything you own**: `pup_my_windows {}`: windows, tabs, urls, titles, state, plus the+ ready-to-run next steps. Start here whenever you are unsure what you have open.+- **Address a thread by name**: any targeting verb accepts `"ai-thread"` instead of an id.+- **Several windows on one thread?** Label them with `window` (e.g. `{"window":"mytask-web"}`), the+ ONLY time you need an explicit id. `sessionId` is the legacy alias; responses carry both.+- Raising onto the user's screen: `pup_raise_os_window {"foregroundReason":"..."}` (gated). Every+ open/navigate response carries `_next` repeating these; trust the hints over hand-rolled desktop verbs.++## One owned window, explicit tabs for overlapping work++Use one pup window per AI thread, with multiple tabs for different items. Start with `pup_my_windows` and+`pup_list_tabs`; reuse a matching tab and call `pup_open_tab` for a new item. Return with `pup_switch_tab`:+selecting a tab inside the browser keeps the window in the background. It does not authorize+`pup_raise_os_window`, a second window, or covering the user's editor.++For overlapping operations pass the exact `window` and `tabId` wherever supported. Independent reads and+downloads can run on separate tabs. Serialize navigation or mutations of the same tab. Tabs share the+profile's cookies and account state, so concurrent logins are not isolated; a separate profile is+`isolated:true` with its reason (its own extension-lane browser).++Never touch another thread's windows.++## "Open my web app in the browser", the #1 use case, and its one trap++When the user asks to see an app they just built, pup IS the right surface: a real browser window+on their Mac that you can keep driving (reload after fixes, screenshot, eval). The ONE trap: an app+running in a CLOUD container is NOT reachable from the desktop as `localhost:<port>`. Use the+container's public proxy URL instead:++```+https://{container-slug}.adom.cloud/proxy/{port}/+```++Give the window your thread's name and re-use the SAME window on every "check it now" (navigate /+reload, don't spawn new windows). Under Hydrogen (local container) `http://localhost:{port}` IS+correct; the trap is cloud-only. pup lints obvious container-local URLs and warns you.++**Vocabulary + routing:** call this surface **pup** ("I opened it in pup"). When the user wants the+page in THEIR OWN browser (their logins, their tabs, no AI driving), that is **nb** (native browser):+say so and use nb. Displaying inside a Hydrogen panel next to the editor is **wv**. Driving/testing+→ pup, handing to the human → nb, docked preview → wv.++**Platform qualifier:** pup requires Adom Bridge. Under **Hydrogen** it is bundled. On **Hydrogen+Cloud** check `adom-bridge-cli ping` first; with no desktop, show the app in a **wv** panel.++## THE CORE GOAL: the shared adom-you profile++pup exists to be **an AI-driven browser that can do everything it needs to for you**. The engine of+that goal is the shared durable **adom-you** profile:++- **Surf as adom-you by default.** Every pup window on adom-you contributes every login it makes to+ the shared credential store, so over time pup signs in to every site it has seen.+- **Isolation is the exception, and it is REASON-GATED.** `isolated:true` without an+ `isolationReason` (min 10 chars) is refused with `isolation_reason_required`. Legit reasons: testing+ logged-out views, acting as a different user, untrusted pages. An isolated window's logins are+ thrown away. (`wikiView:"public"` renders the anonymous wiki view in the same window.)+- A non-adom-you window shows the ANONYMOUS identity tile in the toolbar, so a not-you window is+ visible at a glance.++### ALWAYS say SAVE (John's rule)++When the browser offers to **save** something during your driving ("Save password?", "Save+address?", "Update password?"), the answer is **always Save**, unless the USER told you not to. Every+save enriches adom-you. If you filled a login via `pup_login`, pass `save:true` so the credential+persists. A save bubble you ignore is dismissed on the next navigation and the data is lost. The one+exception: an `isolated:true` window builds nothing, so skip it there.++## The mental model: what pup actually is (read this once)++pup is one signed macOS program (a universal Rust binary) that Adom Bridge spawns on the user's Mac.+When you open a window it does NOT attach to the user's running browser and does NOT run headless. It:++1. **Launches its own browser, Chrome for Testing by policy** (the dated record is `docs/WHY-CFT.md`).+ CfT is the build Google ships FOR automation, and it still honors `--load-extension`, which pup's+ toolbar needs. pup installs it into `~/.cache/puppeteer` on its own (`pup_prewarm` does it ahead of+ time). An installed Google Chrome (or Edge) in `/Applications` is the spawn-verified fallback while+ CfT is downloading. `pup_readiness` tells you which browser a Mac actually runs. pup uses a browser+ *binary*, never the user's *profile*.+2. **…with a pup-managed profile.** By DEFAULT there is **no** debug port: pup drives the window+ through its own MV3 extension channel, because that port is the loudest automation signal a site can+ read. The profile is one **pup owns and persists**, never the user's own Chrome profile. By default+ every window shares ONE durable profile (`adom-you`), so logins, cookies and localStorage+ **accumulate and are shared across all pup windows**. For a throwaway window pass `{isolated:true}`+ (with its reason), or `{profile:"<name>"}` for a separate durable jar. (The user's REAL signed-in+ profile is the **Adom extension** `nbrowser_*`.)+3. **Renders a REAL window, but keeps it in the BACKGROUND.** Not headless: a real composited window,+ so screenshots and recording are true-to-life. pup's browser app is **hidden** (the Cmd+H state), so+ its windows never cover the user's work and never take focus, while you drive and screenshot them+ fully. `pup_lower_os_window` re-hides; `pup_raise_os_window` (with `foregroundReason`) unhides and+ activates. One browser process serves a profile, so a shared-profile window group hides and shows+ together. Never hide the app while the user has it frontmost.+4. **Spawn-verifies + falls through.** pup launches each candidate in order and, if one won't start,+ falls to the next, so an open never hard-fails on a broken browser.+5. **Is addressed by your `--ai-thread` name**, and **one window holds many tabs**.++## Power & rendering: throttling is DEFAULT and correct; `highFps` is the leased override++**Background pup windows are THROTTLED on purpose, to protect the user's CPU and battery.** Chrome+slows rendering of windows nobody is looking at (issue #14: three idle anti-throttled windows held a+laptop out of deep idle overnight and flattened its battery). A healthy background window can LOOK+frozen (an animation stops, a video pauses). That is throttling, not a hang: on-screen ++CDP-responsive + `readyState:complete` = throttled, not broken.++**When a background window must keep rendering** (recording it, a live view), upgrade that ONE+window: `pup_open_window {highFps:true}`. It is **LEASED** (20 min default, 120 max) and+auto-downgrades when idle; renew with `pup_highfps_extend {sessionId, minutes}`. Do NOT reach for+`highFps` to "fix" a window that merely looks frozen.++## A native dialog FREEZES EVERY pup window, the #1 thing that looks like "pup is dead"++If a page raises `alert()`, `confirm()`, `prompt()`, or the **"Reload site? Changes you made may not+be saved."** prompt, that modal blocks the BROWSER, not just its own window. pup's windows share one+browser, so **one dialog wedges every window you have open**. Verbs on unrelated windows start timing+out with different wordings at once; none of them is the real problem.++```bash+adom-bridge-cli --ai-thread "<your thread>" pup_dialog '{"action":"dismiss"}' # keep the page, cancel the navigation+adom-bridge-cli --ai-thread "<your thread>" pup_dialog '{"action":"accept"}' # go ahead (a reload DISCARDS unsaved changes)+```++`pup_dialog` finds the one pending dialog wherever it is, answers it, and unblocks everything.+**pup never answers it for you, on purpose**: a `beforeunload` is a question about the user's UNSAVED+WORK. If the unsaved work might matter, read it out with `pup_eval` before you accept.++## Under heavy load or a crash: pup self-heals, do NOT panic-kill or declare it dead++- **`_degraded` on `pup_list_windows` / `pup_status`**: a window too busy to answer a live read comes+ back with its LAST-KNOWN url/title. It means busy, NOT crashed; retry shortly.+- **A window vanished and `_cleanupReport` says the user closed it**: that close is FINAL. pup forgets+ the session and never brings it back. Your next verb answers `session_not_found`; reopen with+ `pup_open_window` if you still need the page.+- **A `_cleanupReport` says a window crashed**: a browser that CRASHED (nonzero exit, the only proof+ pup accepts) has its windows relaunched by the health loop (15 s), signed in, in the background, at+ their last URLs. Do NOT kill Chrome, clean the profile, or reopen by hand.+- **One window times out but others work** = a per-window jam, not a dead bridge.++## ⛔ HARD RULE: NEVER send NATIVE OS input without warning the user FIRST (John, 2026-07-29)++**Native OS input (`desktop_click`, `desktop_type`, `desktop_press_key`, `desktop_hover`) does NOT go+to a window. It goes to WHATEVER WINDOW HAS FOCUS AT THAT INSTANT.** If the user has switched away,+your keystrokes land in THEIR document, THEIR terminal, THEIR editor.++**Prefer pup's own input** (`pup_click`, `pup_type`, `pup_press_key`, `pup_input_dispatch`): it is+trusted, needs NO foreground and disturbs nobody. `pup_assisted_click` is not available on macOS+(`unsupported_platform`). If you genuinely must use native input, in this order:++1. **`notify_user` TOAST**: what you are about to do and that you need the window.+2. **`desktop_caption`**: on-screen, so it is visible while they look at the window.+3. **Say explicitly: "please don't type or click until I say done."**+4. **WAIT until your window is frontmost and stays there**; never dispatch while it is not.+5. **Re-verify the foreground IMMEDIATELY before EACH native event.**+6. **Caption each step**, and caption when you are DONE.++Native input returning `ok:true` means "dispatched to the OS", **NOT** "it reached your window".+Verify the effect in the page. If it seems not to land, the first hypothesis is "focus moved".++## Standing rule: TOAST THE USER before foregrounding (John, repeatedly)++NEVER put a window on the user's screen silently. Any time you FOREGROUND a pup window, send a toast+FIRST (pup auto-fires `notify_user` on every granted foreground; still narrate WHY in the+`foregroundReason`). For a background nudge, use `pup_alert_window`: on macOS it posts a notification+the user can click and never takes focus (`{stop:true}` has nothing to clear).++## Vendor sites: they load. The three conditions that make them load++1. **Drive the site through pup, not a hand-rolled Chrome.** What makes vendor sites load is pup's+ launch configuration as a whole, starting with the extension lane's missing debug port: over CDP,+ st.com and mouser.com died with `ERR_HTTP2_PROTOCOL_ERROR` while the extension lane loaded them.+2. **Go at human pace: one tab at a time, ~7 s settle.** Bursting tabs trips velocity rules and is+ the most common self-inflicted block.+3. **Clear that domain's cookies first** (`pup_clear_cookies {domain}`): a prior denial writes a reject+ verdict that keeps denying you long after the cause is fixed.++Mouser, Arrow, Octopart, Newark and analog.com still run bot walls that come and go. Check the page+text for denied/captcha before trusting a load, and fall back to DigiKey, LCSC or TI. Per-site detail+lives in the **electronics-sites-skillpack**. A body of 0 chars WITH a title is a bot wall, not a slow+load; settle ~15 s after open/navigate before you read.++**What the extension lane does.** It is the normal pup window: `pup_click`, `pup_hover`, `pup_type`,+`pup_press_key`, `pup_scroll`, `pup_fill`, `pup_select`, `pup_input_dispatch`, `pup_annotate`,+`pup_screenshot`, `pup_screenshot_element`, `pup_screenshot_full_res`, `pup_record_start/stop`, and+`pup_eval` (retried through DevTools on CSP/Trusted-Types pages). Still CDP-only (answered with+`lane_needs_cdp`): selector-driven `pup_download`, `realKeystrokes` logins, iframe targeting,+`webSecurity:false`. The `pup-lanes` skill is the authority on what each lane does.++### When a page comes back as `botWall`++Every page load is checked for a bot wall (Cloudflare, PerimeterX, Akamai, DataDome, generic). The+reply then carries `botWall: {vendor, host, url}` and a `_hint`:++- **Stop loading pages from that host.** Every retry raises the site's bot score.+- **Parts data (stock, price, datasheet): use `adom-parts-search`,** not product pages.+- **Need the page itself:** ask the user for ONE click on the check in that window+ (`pup_alert_window`, or `pup_raise_os_window` with a reason), or open it in their own browser with+ `nbrowser_*`. Never solve it yourself.++A `_paceWarning` means you loaded a guarded vendor again within 15 s. Slow down.++## When pup vs the Adom extension (`nbrowser_*`)++| The task | Tool |+|---|---|+| Screenshot / scrape / test / demo a page; drive an app you're building; sites pup has a login for | **pup (this)** |+| Needs the user's REAL signed-in Chrome: their SSO, their existing sessions, a hand-off with no AI driving | **Adom browser extension** (`nbrowser_*`) |++## Skill map: this is a pack++| Skill | Read it for |+|---|---|+| **`pup`** (this file) | The mental model, when-to-use, quick start, the essentials |+| **`pup-windows-sessions-tabs`** | Window names, **ownership**, **background vs foreground** rules, many tabs in one window, list/switch/close |+| **`pup-screenshots-recording`** | Screenshots (full-page, full-res), reading the shot, **recording** |+| **`pup-browsers-and-chrome`** | Browser detection, `pup_use` to pin a browser, **installing Chrome / Chrome for Testing**, **readiness**, the cold-start error table |+| **`pup-credentials`** | The vault, sign-in, Google accounts, vault sync |+| **`pup-lanes`** | How pup drives a window and the rules that keep closes final |+| **`pup-settings`** | Every setting, behavior by behavior |+| **`pup-adom-wiki`** | Driving + verifying **`wiki.adom.inc`** pages in pup |+| **`pup-vendor-login`** | Signing into vendor portals |+| **`pup-why-cft`** | WHY the driving browser is Chrome for Testing |++*(Maintaining the bridge itself? The `pup-bridge-dev` + `pup-bridge-publish` skills are source-only in the+repo, not shipped here.)*++## Quick start: the 90% path++```bash+# Open in the BACKGROUND (automatic, never disturbs the user), then drive it invisibly.+adom-bridge-cli --ai-thread "<your thread>" pup_open_window '{"url":"http://localhost:3000"}'+adom-bridge-cli --ai-thread "<your thread>" pup_eval '{"expr":"document.title"}' # verify it really loaded+adom-bridge-cli --ai-thread "<your thread>" pup_screenshot '{}' # true-to-life PNG+```++- **Your `--ai-thread` name IS the owner.** A `pup_open_window` with no ai-thread is **refused** with+ `caller_identity_required`.+- **`ok:true` means navigation *started*, not that content rendered**: check `renderCheck`, `pup_eval`+ on `document.title`, or a screenshot.+- **On a fresh Mac the first open may include the one-time Chrome for Testing fetch** (`pup_prewarm`+ does it ahead of time). The rare cold-start cases are in `pup-browsers-and-chrome`.++## Things the Windows line has that this Mac app does not++macOS has no per-window taskbar button, so there is no taskbar identity, thread tile, favicon badge,+jump list, taskbar progress bar or flash. `pup_set_identity` and `pup_audit_icons` answer+`mode:"plain"`, `platform:"macos"`; `pup_progress` answers `painted:false`; `taskbarActivity` reads+`off`; `pup_configure` reports `taskbarSupported:false`. Do not point a Mac user at an icon: tell+windows apart with `pup_list_windows` / `pup_list_tabs` and the window title (your thread name leads it).++## Key rules (the essentials, details in the sub-skills)++- **Always pass `sessionId`/`window`** on session-scoped verbs when your thread runs more than one+ window. **ONE DANGEROUS EXCEPTION: `pup_close`.** There `'{}'` means **ALL sessions, every window,+ across every AI thread on the machine**, immediately. To close one window use+ `pup_close_window {"sessionId":"…"}`, never `pup_close '{}'`.+- **Background is guaranteed; a gated raise is the only way to show a window**: not sizing, not+ navigating, not screenshotting.+- **Own your window**: always pass `--ai-thread`. Never navigate/close a window you don't own.+- **One window, many tabs**: use `pup_open_tab`, not a second `pup_open_window`.+- **`pup_readiness`** (not `pup_status`) answers "is it ready / which browser?".+- **`pup_eval` accepts statement lists** (`location.assign('...'); 'done'` works); a genuinely bad+ expression FAILS with `eval_failed`.+- **Complex page fighting you?** It's usually shadow DOM or a nested scroll container. See+ `pup-screenshots-recording` → "Driving & verifying tricky pages."+- **Never broadly kill Chrome**: `pup_close_window` for a specific session.+- **Surface a verb's `_hint` verbatim** whenever it reports not-ready or failure.+- **If pup's verbs TIME OUT, the bridge PROCESS is down**, usually right after a restart or a crash.+ Fire **one** `pup_open_window` (a status probe does NOT respawn a down bridge), then poll+ `pup_list_windows` with backoff. Do NOT spam opens while Bridge says `bridge_starting`.+- Full machine-readable catalog: `adom-bridge-cli --ai-thread "<your thread>" pup_describe '{}'`.++## Verify what you rendered: pup checks it FOR you++**`ok:true` means the navigation worked. It says NOTHING about whether the page rendered.** Every+`pup_open_window` / `pup_open_tab` / `pup_navigate` / `pup_reload` response can carry:++- **`renderCheck`**: `{ok, httpStatus, signals[], excerpt, bodyChars, elements}`. Signals include+ `http_404`, `page_says_not_found`, `dev_server_cannot_get`, `server_error_text`,+ `stack_trace_visible`, `effectively_blank`, `no_stylesheets_loaded`, `broken_images_N`.+- **`_verifyRender`**: what to do. When `renderCheck.ok` is false it leads with *"THIS PAGE DID NOT+ RENDER CORRECTLY, pup checked it, so do NOT report success"*.++**If `renderCheck.ok === false`, it is not working yet: never report success.** Screenshot it, READ the image, fix+the cause, `pup_reload`, and re-check. A clean `renderCheck` is not proof the page is *right*; when+you generated or edited the content, screenshot it anyway.++## Every adom-bridge-cli call needs `--ai-thread`++Adom Bridge refuses any command that does not say which AI conversation is asking+(`errorCode: caller_identity_required`). It is a **global flag** that goes before the verb:++```bash+adom-bridge-cli --ai-thread "chip-fetcher tab 3" pup_open_window '{"sessionId":"cf-web","url":"..."}'+```++Pick a name the USER would recognise, never a UUID. pup puts it at the FRONT of the window title so+a user running many AI threads can tell windows apart. It is **attribution, not permission**. **Do+NOT `export ADOM_AI_THREAD`**: threads share one container, so it names the shell rather than you.++## Found a pup bug? Please report it++Responses carry `_reportIssues` on failures. Check for a duplicate with+`adom-wiki issue list adom/pup-bridge-macos`, then+`adom-wiki issue create adom/pup-bridge-macos --title "<symptom>" --category bug-report+--body "<what you ran, expected, got, the VERBATIM response JSON, bridge version, macOS version>"`.+An Adom Bridge core problem (relay, bridge lifecycle, caller identity) goes to `adom/adom-bridge`.++## AUDIT YOURSELF: when the user says "audit your windows"++Run all six sections, in order, and report them as a table:++1. **Sessions (bridge view)**, `pup_list_windows`: count, sessionId, owner, tabCount, profile,+ ageMinutes, `_degraded`. Flag owner:null, duplicate sessions for one window, a wrong tabCount.+2. **OS ground truth**, `pup_window_info` per window (never resolve by title). A parked window's+ browser app is HIDDEN: that is the normal background state, not a lost window.+3. **Health**, `pup_eval` per window: `readyState`, `visibilityState`, a `1+1` drivability check.+ `visibilityState:hidden` is NORMAL for a parked window. Note any crashed tabs.+4. **Log**, `bridge_log_read`: count `[crash] [heal] [health] [unstick] [disconnect] [reclaim]+ [tabs] park-failed Handler error`, and report park-failed and `[reclaim] adopted` lines.+5. **Browser footprint**, processes + total MB, and **pup's share vs the user's own Chrome**.+6. **Cleanup state**: leftover test windows, phantom sessions, stale session files.++**Report rules:** state what is WRONG first, name which findings are your own regressions, and never+present an API-reported success as proof; prove it in the page or a screenshot.++## ⏱ Captions: NEVER longer than 2 seconds (John, standing rule)++A caption is a **glance**, not a dialog. Anything past ~2 s covers what the user is doing. The cap is+enforced in code (2000 ms), so longer values are clamped. If something needs longer attention it is+not a caption: use `notify_user`, or put it in the verb response.++## FOREGROUNDING: always caption the REASON, for 2 seconds (John, standing rule)++Foregrounding takes over the user's screen while they work, which is why pup FORCES a+`foregroundReason` before it grants a raise. **The reason must be SHOWN**: every granted foreground+flashes an on-screen caption naming the window and quoting the reason, for 2 seconds at most, and it+is never skipped because a mode is quiet (it is deliberately NOT gated behind `minimalTouch`). If you+add a new path that foregrounds a window, it must go through the same captioned grant.++## Recording the desktop: guide them to Adom Bridge, pup is the fallback++For the whole desktop, prefer Adom Bridge's native recorder (`desktop_record_start` /+`desktop_record_stop`) when this Mac's Bridge offers it. pup's `pup_screen_record_start` is the+fallback: a Meet-style screen share (getDisplayMedia into WebM). Recording ONE pup window's page+content stays pup's (`pup_record_start`); it needs ffmpeg (`brew install ffmpeg`; pup re-detects it+without a restart). Full detail in `pup-screenshots-recording`.++## Know what you are spending: every open reports the machine's RAM++`pup_open_window`, `pup_open_tab`, `pup_list_windows` and `pup_status` return a **`memory`** block+(`boxTotalMb`, `boxFreeMb`, `boxFreePct`, `browserMb`, `tabsOnThisBrowser`, `avgMbPerTab`, `_hint`),+because pup runs on somebody's actual laptop and you are spending their RAM. `browserMb` is the whole+browser process tree for that profile; `avgMbPerTab` prices your next tab. **Read the hint and act on+it.** Under 25 % free, close what you are done with. Under 10 % free, open nothing more until you have+closed something: that is where macOS starts swapping and the user's whole desktop stutters. Close+tabs with `pup_close_tab` the moment a task is done.
dockbar.jsonadded+45@@ -0,0 +1,45 @@+{+ "dock_manifest": "1.2.0",+ "webview": {+ "title": "Pup",+ "favicon": "docs/img/dock-pup.svg"+ },+ "health": {+ "url": "/status",+ "poll_seconds": 15,+ "timeout_ms": 2000+ },+ "launch": {+ "mode": "container_command",+ "steps": [+ {+ "label": "Installing pup",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg install adom/pup-bridge-macos'",+ "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v pup-dashboard'",+ "timeout_seconds": 180+ },+ {+ "label": "Starting the pup dashboard",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; exec pup-dashboard serve --print-url'",+ "await": "url",+ "timeout_seconds": 60+ }+ ],+ "on_ready": "navigate_placeholder",+ "ai": {+ "prompt": "Run the pup demo and show me what pup can do on my machine.\n\n1. Open a pup window to https://wiki.adom.inc/adom/pup-bridge-macos. Tell me it opened in the BACKGROUND \u2014 pup never steals my screen \u2014 and that I can ask you to open one in the foreground any time I want to watch, or just click its taskbar button myself.\n2. Screenshot it and show me the result, so I can see you can read the page without touching my browser.\n3. Record a short clip of the window. Mention highFps mode, which records demo video at full frame rate while the window stays in the background.\n4. Annotate the page: draw on it and capture it, and show me the gliding cursor with its intent narration so I can see what you are doing and why.\n5. Read the pup dashboard's LED status and tell me each light. If anything is not green, name the exact verb that fixes it and offer to run it.\n\nThen close the demo window so nothing is left on my desktop.",+ "thread": "existing",+ "thread_fallback": "new",+ "submit": "staged"+ },+ "console": "show"+ },+ "say": [+ "open your dashboard in pup",+ "open my wiki page in pup",+ "open my app in pup",+ "screenshot this site in pup",+ "record a clip of this page in pup",+ "show me the logged-out view in pup"+ ]+}
docs/img/dock-pup.svgadded+11@@ -0,0 +1,11 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Pup">+ <!-- adom-ui-design 1.10.1 / icons: line drawing, stroke #e6edf3 HARDCODED (a favicon is an isolated+ document: currentColor renders black and vanishes on the dark tab strip, which is what kicad's+ icon does today), transparent ground, no plate, no vendor mark.+ Pup = the browser window pup drives, with an APERTURE/shutter ring for its screenshot role.+ nb owns window-and-cursor, so this stays adjacent but distinct. Three strokes, and the ring+ survives 16px where a four-dot paw mushed into a blob (verified in the 16px LOOK step). -->+ <rect x="2.75" y="4.5" width="18.5" height="15" rx="2.5"/>+ <path d="M2.75 8.75h18.5"/>+ <circle cx="12" cy="14.2" r="3.1"/>+</svg>
install.shadded+60@@ -0,0 +1,60 @@+#!/usr/bin/env bash+# Install the pup USER skillpack: the main `pup` skill + its focused user sub-skills.+# Per the Bridge SDK a pkg ships ONLY user skills; all skills listed here are user-facing.+# The DEV + PUBLISH maintainer skills are source-only (repo dev-skills/ + publish-skills/,+# NOT in package.json files[], never installed here). The bridge RUNTIME isn't here either+# — it ships in Adom Bridge + as the Releases zip. The AD CLI + core skills arrive via+# this pkg's dependencies:{ "adom/adom-bridge": "^1.9.x" } at `pkg install` time.+set -e+PKG_DIR="$(cd "$(dirname "$0")" && pwd)"++deploy_into() {+ local BASE="$1"+ install_skill() { mkdir -p "$BASE/$1"; cp "$PKG_DIR/$2" "$BASE/$1/SKILL.md"; }+ # main user skill. SKILL.md lives at the repo ROOT (required by the registry), but it+ # installs as `pup` — the skill directory name is the slug the loader surfaces, and it+ # MUST equal the SKILL.md `name:` field. Every other skill on a machine follows that+ # (adom-bridge-fusion, adom-bridge-kicad, ... and all four pup sub-skills); this one+ # installed as the REPO name while declaring `name: pup`, so a `pkg install` surfaced the+ # start-here skill under a slug that contradicted its own frontmatter, its trigger words,+ # and every cross-reference in the sub-skills. Repo name != skill name; don't re-conflate.+ rm -rf "$BASE/adom-bridge-puppeteer-bridge" # drop the pre-1.9.86 mis-slugged copy+ # v2.0.276: ALSO purge "pup-bridge" — a second mis-slugged copy of the start-here skill that a+ # pkg-layer install deposited under the PAGE slug. It was user-invocable, so it sat in the user's+ # slash menu as a duplicate of /pup, and because THIS installer never touched it, it served+ # week-old text (found 2026-08-22: it still described windows as opening MAXIMIZED six days after+ # that was fixed). Repo name != skill name; the skill installs as `pup`, and nothing else.+ rm -rf "$BASE/pup-bridge"+ install_skill pup SKILL.md+ # focused user sub-skills+ install_skill pup-windows-sessions-tabs skills/pup-windows-sessions-tabs/SKILL.md+ install_skill pup-screenshots-recording skills/pup-screenshots-recording/SKILL.md+ install_skill pup-browsers-and-chrome skills/pup-browsers-and-chrome/SKILL.md+ install_skill pup-adom-wiki skills/pup-adom-wiki/SKILL.md+ install_skill pup-vendor-login skills/pup-vendor-login/SKILL.md+ install_skill pup-settings skills/pup-settings/SKILL.md+ install_skill pup-lanes skills/pup-lanes/SKILL.md # 2.0.594: the lane comparison; missing from this list left every container on the pre-parity text (pup-bridge#116)+ install_skill pup-credentials skills/pup-credentials/SKILL.md+ # Windows-line-only skills a Windows pkg may have left behind; they describe features this Mac app lacks.+ rm -rf "$BASE/pup-taskbar-states" "$BASE/pup-progress" "$BASE/pup-taskbar-progress" "$BASE/pup-import-logins"+ install_skill pup-why-cft skills/pup-why-cft/SKILL.md+ echo " -> $BASE (pup + 9 sub-skills)"+}++echo "Installing pup for macOS skills:"+deploy_into "$HOME/.claude/skills" # Claude Code+[ -d "$HOME/.codex" ] && deploy_into "$HOME/.codex/skills" # Codex (if present)++# adom/pup-bridge#66 — the Dock Bar card. dockbar.json's first step is skipped when `pup-dashboard`+# is already on PATH, so this install is what makes the card launchable at all. ~/.local/bin is where+# every wiki CLI lands; the manifest's steps prepend it because it is NOT on the exec shell's PATH.+if [ -f "$PKG_DIR/tools/pup-dashboard" ]; then+ mkdir -p "$HOME/.local/bin"+ # SYMLINK, not copy: the Adom convention (and the publish lint enforces it) is to link install+ # targets back into ~/project/adom_modules/<slug>/ so edits propagate and a reinstall cannot+ # clobber a local change. Same shape adom/wiki-issue-loop uses for its hook script.+ chmod +x "$PKG_DIR/tools/pup-dashboard" 2>/dev/null || true+ ln -sfn "$PKG_DIR/tools/pup-dashboard" "$HOME/.local/bin/pup-dashboard"+ echo " -> pup-dashboard installed to ~/.local/bin (the Dock Bar card's status page)"+fi+echo "Done. The pup skills drive pup through the adom-bridge CLI; if pup is not installed on Adom Bridge yet, it self-installs on first use."
package.jsonadded+181@@ -0,0 +1,181 @@+{+ "slug": "pup-bridge-macos",+ "version": "3.0.0",+ "type": "app",+ "description": "The pup for macOS SKILL PACK: the AI skills that teach a cloud/container AI how to drive the macOS Puppeteer (pup) bridge (pup_* verbs): the everyday `pup` skill plus focused sub-skills for windows/sessions/tabs, screenshots+recording, credentials, lanes, settings, browsers+Chrome install, vendor logins and the Adom wiki. Installs into ~/.claude/skills + ~/.codex/skills. DOCS ONLY: this is NOT the bridge runtime. The bridge itself is one signed macOS binary that Adom Bridge for macOS installs and updates, published on the Releases tab as adom-bridge-puppeteer-macos-v<ver>.zip. The pkg version is kept in lockstep with the Release version.",+ "keywords": [+ "pup",+ "puppeteer",+ "browser",+ "adom-bridge",+ "bridge",+ "skills",+ "macos",+ "pup-bridge-macos"+ ],+ "tags": [+ "skills",+ "pup",+ "puppeteer",+ "browser",+ "adom-bridge",+ "bridge",+ "macos",+ "pup-bridge-macos"+ ],+ "files": [+ "SKILL.md",+ "skills/pup-windows-sessions-tabs/SKILL.md",+ "skills/pup-screenshots-recording/SKILL.md",+ "skills/pup-browsers-and-chrome/SKILL.md",+ "skills/pup-adom-wiki/SKILL.md",+ "skills/pup-vendor-login/SKILL.md",+ "skills/pup-lanes/SKILL.md",+ "skills/pup-settings/SKILL.md",+ "skills/pup-credentials/SKILL.md",+ "install.sh",+ "uninstall.sh",+ "README.md",+ "package.json",+ "tools/pup-dashboard",+ "dockbar.json",+ "docs/img/dock-pup.svg",+ "screenshots/hero.png",+ "skills/pup-why-cft/SKILL.md",+ "screenshots/showcase.png"+ ],+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "agent_permissions": {+ "allow_bash": [+ "pup-bridge"+ ],+ "allow": [+ "pup-bridge read verbs (pup_screenshot, pup_status, pup_readiness, pup_describe, pup_my_windows) read the state of pup's own sandbox browser. They never touch the user's real browser.",+ "pup-bridge drives a browser it launched and owns, in a profile it manages, in the background. Opening, navigating, clicking and evaluating there cannot affect the user's own browser.",+ "pup-bridge pup_credentials lists saved logins as host and username only. It never returns a password."+ ],+ "soft_deny": [+ "pup-bridge pup_login with submit:true sends a stored password to a live site. It is reason-gated so the live domain is named and a lookalike is caught first.",+ "pup-bridge credential_set and credential_delete write to and remove from the user's macOS Keychain.",+ "pup-bridge pup_raise_os_window takes over the user's screen and keyboard focus.",+ "pup-bridge pup_close_window and pup_quit_idle_browsers destroy windows the user may be looking at, which commonly hold state a person is part-way through.",+ "pup-bridge pup_credential_reveal returns one saved password in plaintext to a local caller. The user must click Reveal on a desktop toast first, so it should never be called silently or in a loop.",+ "pup-bridge pup_fill_card types a stored payment card into a checkout page. The user must approve EACH fill on a desktop toast naming merchant, amount and last4; the digits never reach the caller."+ ],+ "environment": [+ "pup-bridge drives Chrome for Testing, installed and owned by pup, in a pup-managed profile, on the user's Mac, in a hidden browser app.",+ "pup-bridge is NOT the user's signed-in browser. That is nb, a different package.",+ "pup-bridge verbs are invoked through the adom-bridge CLI, not through a binary this package installs, so prefix rules cannot reach them."+ ]+ },+ "dependencies": {+ "adom/adom-bridge": "^2.0.0"+ },+ "author": "Kyle Bergstedt <[email protected]>",+ "contributors": [+ "John Lauer <[email protected]> (the Windows line this forks)"+ ],+ "hero": {+ "path": "screenshots/hero.png"+ },+ "discovery_pitch": "Pup gives your AI its own real browser on your Mac, driven in the background, signed in as you. It opens pages, clicks, types, screenshots and records while its windows stay hidden behind your work, and it signs you into sites from its own vault once you have signed in there one time. Say 'open it in pup'.",+ "discovery_triggers": [+ "pup",+ "open in pup",+ "open my app in pup",+ "open it in pup",+ "open this in pup",+ "show me in pup",+ "show it in pup",+ "open a pup window",+ "pup window",+ "screenshot my app",+ "screenshot the page",+ "browser screenshot",+ "pup screenshot",+ "record the window",+ "record a browser window",+ "record a pup",+ "record my app",+ "new pup tab",+ "pup tabs",+ "browser tabs",+ "close the pup window",+ "reload the page in pup",+ "eval js in the page",+ "drive a browser",+ "headful browser automation",+ "visual debug",+ "puppeteer",+ "puppeteer bridge",+ "pup bridge",+ "chrome for testing",+ "chrome automation bridge",+ "pup_open_window",+ "pup_screenshot",+ "pup_record_start",+ "pup_readiness",+ "log me in",+ "sign me in",+ "log in to",+ "sign in to",+ "log me into",+ "sign me into",+ "can't log in",+ "cannot sign in",+ "login failed",+ "keeps asking me to log in",+ "saved passwords",+ "my credentials",+ "auto login",+ "autofill my password",+ "password manager",+ "sign in for me",+ "log in for me",+ "cant log in",+ "cant sign in",+ "can't sign in",+ "login problem",+ "login issue",+ "not logged in",+ "logged out",+ "log in to mouser",+ "log in to digikey",+ "sign in problem",+ "stuck on the login page",+ "pup on mac",+ "pup for macos"+ ],+ "sample_prompts": [+ {+ "label": "Open in pup",+ "prompt": "Open my app in pup (pup_open_window) and screenshot it"+ },+ {+ "label": "Screenshot a page",+ "prompt": "Screenshot https://example.com in pup"+ },+ {+ "label": "Record a window",+ "prompt": "Record a 30-second video of this pup tab (pup_record_start/stop)"+ },+ {+ "label": "Many tabs, one window",+ "prompt": "Open these 5 URLs as tabs in one pup window"+ },+ {+ "label": "Sign in once",+ "prompt": "Open mouser in pup so I can sign in once, then log me in there from now on"+ }+ ],+ "changelog": "First release of the standalone macOS app (3.0.0), forked from the Windows pup line at 2.0.737 with every Windows code path removed: one signed universal binary, background windows in a hidden browser app, the vault in the macOS Keychain, and no keychain prompt from pup's browser.",+ "install": {+ "binary_name": "pup-bridge",+ "install_dir": "",+ "install_hint": "",+ "version_cmd": ""+ }+}
page.jsonadded+184@@ -0,0 +1,184 @@+{+ "slug": "pup-bridge-macos",+ "version": "3.0.0",+ "type": "app",+ "description": "The pup for macOS SKILL PACK: the AI skills that teach a cloud/container AI how to drive the macOS Puppeteer (pup) bridge (pup_* verbs): the everyday `pup` skill plus focused sub-skills for windows/sessions/tabs, screenshots+recording, credentials, lanes, settings, browsers+Chrome install, vendor logins and the Adom wiki. Installs into ~/.claude/skills + ~/.codex/skills. DOCS ONLY: this is NOT the bridge runtime. The bridge itself is one signed macOS binary that Adom Bridge for macOS installs and updates, published on the Releases tab as adom-bridge-puppeteer-macos-v<ver>.zip. The pkg version is kept in lockstep with the Release version.",+ "keywords": [+ "pup",+ "puppeteer",+ "browser",+ "adom-bridge",+ "bridge",+ "skills",+ "macos",+ "pup-bridge-macos"+ ],+ "tags": [+ "skills",+ "pup",+ "puppeteer",+ "browser",+ "adom-bridge",+ "bridge",+ "macos",+ "pup-bridge-macos"+ ],+ "files": [+ "SKILL.md",+ "skills/pup-windows-sessions-tabs/SKILL.md",+ "skills/pup-screenshots-recording/SKILL.md",+ "skills/pup-browsers-and-chrome/SKILL.md",+ "skills/pup-adom-wiki/SKILL.md",+ "skills/pup-vendor-login/SKILL.md",+ "skills/pup-lanes/SKILL.md",+ "skills/pup-settings/SKILL.md",+ "skills/pup-credentials/SKILL.md",+ "install.sh",+ "uninstall.sh",+ "README.md",+ "package.json",+ "tools/pup-dashboard",+ "dockbar.json",+ "docs/img/dock-pup.svg",+ "screenshots/hero.png",+ "skills/pup-why-cft/SKILL.md",+ "screenshots/showcase.png"+ ],+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "agent_permissions": {+ "allow_bash": [+ "pup-bridge"+ ],+ "allow": [+ "pup-bridge read verbs (pup_screenshot, pup_status, pup_readiness, pup_describe, pup_my_windows) read the state of pup's own sandbox browser. They never touch the user's real browser.",+ "pup-bridge drives a browser it launched and owns, in a profile it manages, in the background. Opening, navigating, clicking and evaluating there cannot affect the user's own browser.",+ "pup-bridge pup_credentials lists saved logins as host and username only. It never returns a password."+ ],+ "soft_deny": [+ "pup-bridge pup_login with submit:true sends a stored password to a live site. It is reason-gated so the live domain is named and a lookalike is caught first.",+ "pup-bridge credential_set and credential_delete write to and remove from the user's macOS Keychain.",+ "pup-bridge pup_raise_os_window takes over the user's screen and keyboard focus.",+ "pup-bridge pup_close_window and pup_quit_idle_browsers destroy windows the user may be looking at, which commonly hold state a person is part-way through.",+ "pup-bridge pup_credential_reveal returns one saved password in plaintext to a local caller. The user must click Reveal on a desktop toast first, so it should never be called silently or in a loop.",+ "pup-bridge pup_fill_card types a stored payment card into a checkout page. The user must approve EACH fill on a desktop toast naming merchant, amount and last4; the digits never reach the caller."+ ],+ "environment": [+ "pup-bridge drives Chrome for Testing, installed and owned by pup, in a pup-managed profile, on the user's Mac, in a hidden browser app.",+ "pup-bridge is NOT the user's signed-in browser. That is nb, a different package.",+ "pup-bridge verbs are invoked through the adom-bridge CLI, not through a binary this package installs, so prefix rules cannot reach them."+ ]+ },+ "dependencies": {+ "adom/adom-bridge": "^2.0.0"+ },+ "author": "Kyle Bergstedt <[email protected]>",+ "contributors": [+ "John Lauer <[email protected]> (the Windows line this forks)"+ ],+ "hero": {+ "type": "image",+ "path": "screenshots/hero.png"+ },+ "discovery_pitch": "Pup gives your AI its own real browser on your Mac, driven in the background, signed in as you. It opens pages, clicks, types, screenshots and records while its windows stay hidden behind your work, and it signs you into sites from its own vault once you have signed in there one time. Say 'open it in pup'.",+ "discovery_triggers": [+ "pup",+ "open in pup",+ "open my app in pup",+ "open it in pup",+ "open this in pup",+ "show me in pup",+ "show it in pup",+ "open a pup window",+ "pup window",+ "screenshot my app",+ "screenshot the page",+ "browser screenshot",+ "pup screenshot",+ "record the window",+ "record a browser window",+ "record a pup",+ "record my app",+ "new pup tab",+ "pup tabs",+ "browser tabs",+ "close the pup window",+ "reload the page in pup",+ "eval js in the page",+ "drive a browser",+ "headful browser automation",+ "visual debug",+ "puppeteer",+ "puppeteer bridge",+ "pup bridge",+ "chrome for testing",+ "chrome automation bridge",+ "pup_open_window",+ "pup_screenshot",+ "pup_record_start",+ "pup_readiness",+ "log me in",+ "sign me in",+ "log in to",+ "sign in to",+ "log me into",+ "sign me into",+ "can't log in",+ "cannot sign in",+ "login failed",+ "keeps asking me to log in",+ "saved passwords",+ "my credentials",+ "auto login",+ "autofill my password",+ "password manager",+ "sign in for me",+ "log in for me",+ "cant log in",+ "cant sign in",+ "can't sign in",+ "login problem",+ "login issue",+ "not logged in",+ "logged out",+ "log in to mouser",+ "log in to digikey",+ "sign in problem",+ "stuck on the login page",+ "pup on mac",+ "pup for macos"+ ],+ "sample_prompts": [+ {+ "label": "Open in pup",+ "prompt": "Open my app in pup (pup_open_window) and screenshot it"+ },+ {+ "label": "Screenshot a page",+ "prompt": "Screenshot https://example.com in pup"+ },+ {+ "label": "Record a window",+ "prompt": "Record a 30-second video of this pup tab (pup_record_start/stop)"+ },+ {+ "label": "Many tabs, one window",+ "prompt": "Open these 5 URLs as tabs in one pup window"+ },+ {+ "label": "Sign in once",+ "prompt": "Open mouser in pup so I can sign in once, then log me in there from now on"+ }+ ],+ "changelog": "First release of the standalone macOS app (3.0.0), forked from the Windows pup line at 2.0.737 with every Windows code path removed: one signed universal binary, background windows in a hidden browser app, the vault in the macOS Keychain, and no keychain prompt from pup's browser.",+ "install": {+ "binary_name": "pup-bridge",+ "install_dir": "",+ "install_hint": "",+ "version_cmd": ""+ },+ "visibility": "public",+ "org": "adom"+}
screenshots/hero.pngadded⋯ 1 unchanged line ⋯
skills/pup-adom-wiki/SKILL.mdadded+105@@ -0,0 +1,105 @@+---+name: pup-adom-wiki+user-invocable: true+description: >-+ Driving the Adom wiki (wiki.adom.inc) inside a pup window — the common case of loading, viewing, and VERIFYING wiki pages with the pup_* verbs. Use when the user says show me the wiki / open my wiki page in pup / verify my page rendered / check the hero/version/download card / does my published page look right. Covers: pup shows the RENDERED page (use the adom-wiki CLI for API tasks like publish/search/releases — NOT pup); wiki pages are component-heavy with nested scroll, so use the shadow-DOM-survey + scroll-the-element techniques; and the LOGIN situation — every pup window shares ONE durable profile (v1.9.162), so once the user signs into the wiki once it stays logged in everywhere; wikiView:"public" is the explicit logged-OUT view for publish-visibility checks. Trigger words: adom wiki, wiki.adom.inc, show me the wiki, open the wiki in pup, open my wiki page, verify my wiki page, check my hero, wiki version chip, download card, wiki page rendered, is my page live, wiki login pup, log into the wiki.+---++**Parent skill:** pup-bridge · See also: `pup` (start here), `pup-screenshots-recording` (the tricky-pages techniques), the `adom-wiki` skill (the CLI for API work).++# pup — driving the Adom wiki++Loading `wiki.adom.inc` pages into pup to **look at the rendered result** is one of pup's most common+jobs — confirming a page you just published looks right, a hero renders, a version chip updated, a+download card is correct. This skill is the wiki-specific playbook.++## pup VIEWS the rendered page; the `adom-wiki` CLI DOES the work++- **API / authoring tasks — use the `adom-wiki` CLI, not pup:** publishing pages, pushing files, cutting+ releases, `pkg` work, searching/discovery, discussions, breadcrumbs. These are authenticated Bearer-token+ operations the CLI already does; driving them through a browser form would be slower and fragile.+- **pup is for the rendered VIEW:** open the page, screenshot it, verify the hero/version/sections rendered,+ count cards, click through tabs. After any `adom-wiki` publish, showing the LIVE page in pup (cache-bust+ with `?v=<ver>`) is the standard "prove it shipped" step.++```bash+# Verify a page you just published (own the window, task-prefix the id):+adom-bridge-cli --ai-thread "<your thread>" pup_open_window '{"sessionId":"wiki-mypage","url":"https://wiki.adom.inc/adom/my-slug?v=1.2.3"}'+adom-bridge-cli --ai-thread "<your thread>" pup_eval '{"sessionId":"wiki-mypage","expr":"document.title"}' # confirm it loaded+adom-bridge-cli --ai-thread "<your thread>" pup_screenshot '{"sessionId":"wiki-mypage"}' # read it — did the hero/version render?+```++Reuse **one** window per page (stable `sessionId` like `wiki-<slug>`) so re-publishes refresh the same+window instead of piling up tabs.++## Wiki pages are component-heavy — use the tricky-pages techniques++The wiki renders widgets (download cards, sample-prompt cards, the hero) and often puts content in a+**nested scroll container**. So the usual gotchas apply (full detail in `pup-screenshots-recording` →+"Driving & verifying tricky pages"):+- To find/count something (download cards, skills, files), **count in `pup_eval` by document-Y**+ (`rect.top + window.scrollY`), don't eyeball a screenshot that may have overshot.+- To screenshot a specific widget, **`el.scrollIntoView({block:'center'})`** — `window.scrollTo` does+ nothing on the wiki's inner scroll box.+- If a `querySelectorAll` comes back empty, first **survey for shadow roots** — most wiki widgets are+ plain DOM, so an empty result usually means the selector, not a shadow root.++## Two views: LOGGED-IN (default, shared profile) and PUBLIC (explicit `wikiView:"public"`) — show BOTH to verify visibility++pup can now open a wiki page from **either viewpoint**, and the killer use is showing the user+**both** to verify publish visibility:++- **Logged-in (the DEFAULT now, v1.9.162):** a plain `pup_open_window {url:"https://wiki.adom.inc/..."}`+ opens in the ONE shared `adom-you` profile — the same jar every other pup window uses — so once+ the user has signed into the wiki once, EVERY wiki window (and every other pup window) is already+ logged in. `wikiView:"authed"` is now just the explicit name for this default.+- **Public / anonymous (EXPLICIT):** `pup_open_window {url:"...", wikiView:"public"}` — the+ SERVER renders the page exactly as a signed-out visitor sees it, via the `?view=public` URL+ param (adom/wiki#126: the request's user is nulled for that one render, the response is+ byte-identical to a logged-out visitor's, and private pages 404). The window STAYS on the+ shared profile with the session untouched — no isolated jar, no relaunch. Only for a+ deliberate publish-visibility check. (Pre-2.0.132 this minted a throwaway logged-out+ profile; the param replaced that entirely.)++**Use ONE window normally.** Don't open two windows for the same page — that double-reloads and+annoys. Just pick the view the task needs. The ONLY time to compare both is a deliberate+visibility check after a publish, and even then flip the SAME window between views rather than+opening two. Example John cares about: *"I published an app where the+public should get only the binaries but I keep the source private — did the AI accidentally leak+the source?"* The public view answers that directly (if you can see `src/` there, it leaked). The+response `_hint` tells you which view you opened and nudges you to open the other.++**One-time login, then it accumulates.** If the shared profile has not been signed into the wiki+yet, an authed open comes back `wikiLoginNeeded:true`. Sign in once: `pup_raise_os_window` with+a `foregroundReason` like `"sign into the Adom wiki"`, the user completes the hydrogen.adom.inc SSO,+and the session now lives in the ONE shared `adom-you` jar (v1.9.162) — so it is logged in for every+pup window from then on, not just wiki ones. Tell the user it is a one-time login.+(Container-identity auto-login — zero interactive step — is still tracked in `adom/wiki` #89.)++**Switching views.** To show the user the other view of a wiki page, reopen or navigate the window+with `wikiView:"authed"` or `wikiView:"public"` yourself. (The Windows line has a taskbar right-click+task for this; macOS has no taskbar menu, so on a Mac the AI does it.)++## Historical note — pup WAS logged-out only (this is no longer true; see "Two views" above)++This section is kept as history only, because its old present-tense wording ("don't claim you can+see logged-in features, you can't") sat directly under a section saying logged-in is the DEFAULT,+and a skim could believe either one. The current truth is the "Two views" section: since v1.9.162+the shared adom-you profile carries the wiki session, so an authed view is the default and a+one-time interactive sign-in unlocks it for every window.++What used to be true: pup opened a fresh isolated browser per window, no cookies, so every wiki+view was anonymous, and there was no supported container-identity auto-login (the container's+Bearer token is an API credential, not a browser cookie). The rule that DOES survive from that era:+**never drive the SSO form with the user's password** — the one-time login is the user's own+interactive step via `pup_raise_os_window`. Zero-step container-identity auto-login (a magic link+minted from the container token) remains tracked in `adom/wiki` discussion #89.++## Common wiki-in-pup tasks++- **"Show me my page in pup"** → open `https://wiki.adom.inc/<type>/<slug>?v=<ver>`, screenshot, hand over+ the link. (For the user's own machine, respect the background-by-default rule unless they said "show me.")+- **Verify a publish** → open the page, confirm the version chip + hero + your changed section rendered+ (read the screenshot; a curl 200 is NOT verification).+- **Count/verify a widget** (download cards, skills, files) → DOM-count by document-Y, then scroll the+ element in + screenshot to confirm.
skills/pup-browsers-and-chrome/SKILL.mdadded+128@@ -0,0 +1,128 @@+---+name: pup-browsers-and-chrome+user-invocable: true+description: "MAINTAINER detail, not for a user's ears: how pup picks and launches its browser, and every cold-start / readiness case. A user hears 'pup window'; the build behind it (installed Chrome, Edge, or pup's own automation build) is answered only when asked (pup-bridge#114). Covers: the installed-browser-first architecture (launches a FRESH CDP-driven process of installed Chrome → else Edge → else cached Chrome for Testing, spawn-verified with fallthrough, caches the winner as default); pup_use to pin chrome/edge/cft/auto; INSTALLING real Google Chrome on an Edge-only box (pup_use install:true) including the UAC-approval-notify flow on locked-down machines; installing/prewarming Chrome for Testing; pup_readiness (READ-ONLY — the right 'is it ready / which browser' probe, NOT pup_status); the cold-start error table (node_not_found, bridge_restarting, chrome_for_testing_installing, chrome_install_no_disk, CfT 'skipped' is not a failure); AD-managed Node provisioning (no install needed on AD >=1.9.63); and the shell-approval gate. Read on any browser-pick question, a fresh/first-run PC, an install/prewarm, a not-ready error, or a shell-approval prompt. Trigger words: pup_readiness, pup_use, pup_prewarm, chrome for testing, install chrome, chrome_for_testing_installing, node_not_found, bridge_restarting, chrome_install_no_disk, cold start pup, which browser, edge only box, UAC approve chrome, request_shell_approval, shell command approval, adom runtimes, pup not ready."+---++**Parent skill:** pup-bridge · See also: `pup` (start here), `pup-windows-sessions-tabs`, `pup-screenshots-recording`.++# pup — browsers, Chrome install & cold-start++## How pup picks + launches a browser (the architecture)++**POLICY (2026-08-10, pup 2.0.53+): pup's driving browser is Chrome for Testing.** The dated record+of why is `docs/WHY-CFT.md` on the wiki page — the policy has flipped twice, and the current flip is+final: branded Chrome asserts the "controlled by automated test software" banner during real+automation, and branded Chrome 137+ removed `--load-extension`, which the pup toolbar family needs.+This section previously opened with "pup drives the browser already on the machine"; that was the+mid-2026 policy and is REVERSED. What survives of it is the fallback ladder below, which is why a+missing or still-downloading CfT never blocks an open. On every open pup builds a candidate list+and **spawn-verifies** it:++1. **Pinned/cached default first (CfT, per policy) → installed Google Chrome → Edge → cached CfT.**+ Chrome and Edge count only when they are installed in `/Applications` or `~/Applications`.+2. It **launches a fresh process** of that browser binary with an **pup-managed `--user-data-dir`** driven through pup's own+ extension channel (no debug port by default), never the user's+ signed-in profile, and a **real rendered window** (not headless).+3. If the top pick is present but **won't start** (corrupt/partial install), pup **falls through** to the+ next — so an open never hard-fails on one broken browser.+4. The browser that actually launched is **cached as the default** (persisted across bridge restarts) so it+ isn't re-probed each time.+5. **The CfT fetch (~150 MB) happens once** — at prewarm, at first need, or when pinned via+ `pup_use {browser:"cft"}`. Installed Chrome/Edge are the FALLBACK, not the destination: expect a+ healthy box to report `browserKind: chrome-for-testing` in `pup_readiness`.++## "Is it ready / which browser?" — `pup_readiness` (READ-ONLY)++```bash+adom-bridge-cli pup_readiness '{}'+# → {ready, browserKind:"chrome"|"edge"|"chrome-for-testing", browserSource, browserExecutablePath,+# candidates:[…the full fallthrough list…], defaultBrowser, diskFreeMb, chromeStablePhase, …}+```++- **`pup_readiness`** is the right probe — it never triggers a download and tells you exactly which+ browser will be driven + the candidate list + the default.+- **`pup_status` is NOT a readiness check** — it only lists open sessions.+- AD-core **`bridge_readiness`** is the platform-level "is this bridge up?" probe.++## Pinning a browser — `pup_use`++pup auto-picks, but you can override (persists across restarts; launch still spawn-verifies + falls back):++```bash+adom-bridge-cli pup_use '{"browser":"chrome"}' # pin installed Chrome+adom-bridge-cli pup_use '{"browser":"edge"}' # pin Edge+adom-bridge-cli pup_use '{"browser":"cft"}' # install Chrome for Testing (~150 MB once) + pin it+adom-bridge-cli pup_use '{"browser":"auto"}' # back to auto-pick+```++Every `pup_open_window` response also carries `browser` (what was driven), `availableBrowsers`, and+`defaultBrowser` — surface it if the user asks which browser pup is using.++## Installing real Google Chrome++If the user specifically wants Chrome:++```bash+adom-bridge-cli pup_use '{"browser":"chrome","install":true}'+```++- Downloads Google's official `.dmg`, copies Google Chrome into `/Applications`, then pins it. No+ prompt on a normal admin account.+- **Poll `pup_readiness`**: `chromeStablePhase` goes `installing → ready`; `browserKind` flips to+ `"chrome"`. pup keeps driving its current browser meanwhile, so nothing is blocked.++## Installing / prewarming Chrome for Testing (the policy browser)++```bash+adom-bridge-cli pup_prewarm '{}' # fetch CfT WITHOUT opening a window, then pin it (poll readiness)+adom-bridge-cli pup_use '{"browser":"cft"}' # same, via the pin path+```++Worth running on ANY fresh box so the first real open skips the download (the fallback ladder covers the gap meanwhile). **CfT reported as `skipped` is NOT a failure** — on AD+≥ 1.9.76 the prewarm is conditional, so a box with a system browser skips the ~150 MB download and+`bridge_readiness` shows the asset `{present:false, skipped:true}`. That's the intended+installed-browser-first behavior; don't retry or report an error.++## Cold-start error table — what a `pup_open_window` response can mean++| Response | Means | Do |+|---|---|---|+| `ok:true` / a real session | A browser was found + launched | Proceed — the common case |+| `errorCode:"bridge_restarting"` (`stillRunning:true`) | AD ≥ 1.9.77: verb landed in the brief gap while the bridge respawns after an install/update | Poll `pup_readiness` until it answers, then retry the same verb — not a failure |+| `errorCode:"chrome_for_testing_installing"` (`installing:true`) | RARE — no installed Chrome or Edge, so pup is fetching CfT in the background | Poll `pup_readiness` until `{ready:true}`, then re-issue |+| `errorCode:"chrome_install_no_disk"` | No browser + too little disk for CfT (`readiness.diskFreeMb`) | Tell the user how much is free vs the ~800 MB needed; ask them to free space or install Google Chrome. Do NOT retry the download |+| `errorCode:"chrome_install_failed"` / `chrome_download_network` / `chrome_install_permission` | No browser AND the CfT fallback download failed | Read `readiness.lastError`; ask the user to install Google Chrome |++**Always surface a verb's `_hint` verbatim** when it reports not-ready or failure.++## No runtime to provision++pup on macOS is one signed binary: no Node, no npm, nothing for you or the user to install. Adom+Bridge downloads, verifies and spawns it.++## The shell-approval gate (rarely relevant to pup)++Core AD verbs and pup's own `pup_*` verbs (plus the `desktop_record_*` verbs pup answers for ab-core) run with **NO desktop prompt**. Only+open-ended `shell_execute` / `run_script` pop AD's "Shell command needs approval" dialog — which you+almost never need (prefer typed verbs). If one returns `needs_approval` / `human_only` on **AD ≥ 1.9.86**:+run `adom-bridge-cli --target <box> request_shell_approval` (re-surfaces the dialog), ask the user to click+Approve/Bypass, then **retry your original verb**. Never call `shell_auto_approve` (human-only, refuses).+This is Adom Bridge's own gate.++## Temporary pins and the toolbar (v2.0.378)++- `pup_use {browser:"chrome"|"edge", temporary:true}` pins for a comparison or spike and **expires at+ the next bridge boot** (back to auto), so a forgotten pin can never strip the toolbar from every+ later launch. Pinning off CfT while the toolbar is on also returns a WARNING at pin time.+- Any `pup_open_window` whose window launched on a browser that cannot carry the Adom toolbar+ (branded Chrome ≥137, Edge) returns `_toolbarNotice` saying so; CfT is the only toolbar-capable+ engine and the default policy converges onto it.++## The download cache is pruned (2.0.677, pup-bridge#125)++Every build bump used to leave the previous build (~410 MB) in `~/.cache/puppeteer` forever, plus headless-shell+builds and partial downloads. pup now prunes a+minute after every boot, keeping the pinned build, the newest complete build if the pinned one is missing, and+anything a running browser or the saved default uses. `pup_cache_prune {dryRun:true}` shows what would go.+
skills/pup-credentials/SKILL.mdadded+210@@ -0,0 +1,210 @@+---+name: pup-credentials+description: "How pup signs the user into sites AS THEM on macOS: pup's Keychain-backed credential vault (auto-capture on login + silent autofill on return, matched across a site's whole registrable domain), Google sign-in (pup_google_signin), syncing the vault with the user's other desktops (pup_vault_sync), HTTP Basic auth from the vault, and what to do when a saved password is stale. Importing the browser's own saved passwords is NOT available on macOS (pup_import_browser_logins answers unsupported_platform); logins grow by the user signing in once in a pup window. Trigger words: log me in, sign me in, saved password, pup_login, credential_set, pup_credentials, vault, stale password, google sign in in pup."+---++# pup credentials — sign the user in AS THEM++## ⛔ THE RULE: serve the login from the vault, NEVER ask the human for a password++pup's adom-you vault EXISTS so the AI never has to ask the user for a username or password. If you+find yourself about to ask "what's your Google password?" — STOP. You have verbs for this:++1. **It's probably already saved.** `pup_credentials {}` lists every host+username pup holds. To+ sign a window in, `pup_login {submit:true, submitReason}` — pup fills and submits from the vault.+ To hand the actual secret to a LOCAL desktop assistant, `pup_credential_reveal {host, reason}`+ (one consent click).+2. **Not saved on THIS Mac? Ask the user to sign in ONCE in the pup window.** pup captures the+ login as they submit it, and serves it from then on. (Importing the browser's own saved passwords+ is not available on macOS: `pup_import_browser_logins` answers `unsupported_platform`.)+3. **Saved on ANOTHER of the user's desktops? Sync it.** `pup_vault_sync {peer, reason}` merges this+ box's adom-you logins with a peer desktop's, BOTH directions, so both boxes hold the union+ (newest-wins on a conflict). ONE consent toast on this box; passwords move pup-to-pup over ab's+ cross-desktop egress and **never touch an AI**. `targets` names the peers. This is the answer when+ one box has learned some logins and another box built up others.+4. **Only if a stored password is STALE** do you fall to section 3 below. That is the LAST resort, not the first move.++Asking the human to paste a password in chat is almost always the wrong move: it exposes the secret,+it does not grow the shared adom-you profile, and pup already has a cleaner path. Grow the vault+(capture on login, sync) and serve from it.+++pup is the user's doppelganger, so it needs to log in the way the user does. This skill is the whole+credential story: the vault, syncing it, and what to do when a stored password has gone stale. Verbs live in the `pup` bridge;+call them with `adom-bridge-cli`.++## 0. Google: `pup_google_signin` (never pup_login)++Google asks for the email on one page and the password on the next, so `pup_login` cannot do it and+says so (`no_login_form`). Use the Google verbs (2.0.596):++```+adom-bridge pup_google_accounts {} # who this user is on Google, cached a day+adom-bridge pup_google_signin {"account":"[email protected]","submitReason":"the task is the work calendar"}+```++**Most users have several Google accounts, and pup never guesses which one.** The first time it is+asked, pup searches everything it can reach on the Mac: the user's browser profiles as nb reports them+(with the label the user gave each, like "John Personal"), pup's own vault, and which accounts pup's+profile is already signed into. Each account comes back as `work` or `personal` (from+its domain) and `likelyStale` when it lives only in pup's vault and in no browser (those are usually+years old and Google demands a second step for them).++Call `pup_google_signin` with no `account` and more than one account exists, and the answer is+`google_account_choice_required` with the list. Decide, in order:+1. **The task.** Company mail, calendars, docs, vendor portals: the work account. Family, banking,+ school, personal purchases: the personal one.+2. **Your own skills.** The user's work and personal skills usually name their accounts. Search them.+3. **Ask the user.** Then pass `account` as the email. `"work"` or `"personal"` also works when exactly+ one account of that kind exists (a user with two work accounts gets the list again).++What the verb does, and what each answer means:++| Answer | Meaning | Do |+|---|---|---|+| `signedIn:true, alreadySignedIn:true` | pup's profile was already signed in; no password sent | drive the window |+| `signedIn:true` | email and saved password went through | drive the window |+| `google_password_missing` | not signed in and no saved password for that account | the user signs in once in the pup window (pup captures it) |+| `google_verification_required` + `options` | Google wants a second step | `pup_google_signin {window, account, challenge:"text message"}` |+| `google_verification_required` (code) | Google sent a code | `pup_google_signin {window, account, code:"123456"}` |+| `google_verification_required` (other device) | the user must tap Yes on their phone | tell them; pup already waited 45 s |+| `google_password_rejected` | the saved password is out of date | section 3; never retry the same one |+| `google_signin_unconfirmed` | Gmail opened as a different account | call again; do not assume |++Traps measured on John's laptop (2026-09-16):+- **Landing on Gmail proves nothing.** `mail.google.com/mail/u/?authuser=<email>` for an account that is+ not signed in silently opens the signed-in account's inbox. pup confirms the account by name.+- **Never restart a sign-in stuck on a verification step.** That resends the password and a new code.+ Finish it with `{window, code}` or `{window, challenge}`.+- **A text code can finish hands-free** when your thread has the user's standing permission to read+ their texts (a personal texting skill). Otherwise ask the user for it.+- Google does not block pup's default window. A window opened with `lane:"cdp"` carries a debug port+ and can draw "This browser or app may not be secure".+- **The vault holds one login per site AND username** since 2.0.596. Before that a second Google+ account overwrote the first.++### TOAST THE USER, AND PUT THE WINDOW ON THE TOAST (John, 2026-09-17)++> "i didn't even realize you were waiting on me. why did you not ab toast me?" ... "when i just clicked+> that ab toast, i would have expected its action when i clicked the toast to open your pup window. i+> believe ab lets you attach those actions now." ... "whenever you need the users attention for a+> password, you should do it that way"++The moment a flow parks on the human, TOAST, and make the toast open the thing that is waiting:++```+adom-bridge notify_user {"title":"pup needs you to sign in once",+ "body":"Sign in as [email protected] in the pup window (click here to open it).",+ "level":"warning", "focus":{"hwnd": <the pup window's hwnd>}}+```++`focus` is what makes the click useful: `{hwnd}` raises that exact pup window, `{host:true}` reveals the+app Bridge runs under (use it for a Bridge consent, which is a notification inside Hydrogen, not a+window). A toast with no `focus` is the one the user clicks and nothing happens, which is worse than none.++pup does this itself from 2.0.603 on every no-password answer (`google_password_missing`,+`google_verification_required`, `google_password_rejected`, `no_credential`), so an AI that just relays the answer is already covered. Do it yourself too whenever+you are about to sit and wait: the user cannot see your thread.++### Capture works on BOTH lanes (2.0.602, and why it did not)++Capture is what makes "sign in once" true: type a password into any pup window and pup stores it. It lived+ONLY on the CDP lane until 2.0.602, so every login typed into a normal (extension-lane) window was thrown+away and pup asked again next time. John typed his Google password into a pup window, pup kept nothing,+and the explanation "your browser never saved it" was about the wrong thing. If you are on a build older+than 2.0.602, a typed password is NOT stored on the default lane. Verify a capture instead of assuming it:+`pup_credentials {}` must list the host right after the sign-in.++## 1. The vault (how pup remembers logins)++- **Auto-capture:** when the user (or pup) signs into a site, pup grabs the credential and stores it+ in the **macOS Keychain**. Passwords are NEVER+ echoed back — `pup_credentials` lists host + username only.+- **Autofill:** on a return visit pup fills the saved login in the **background** (no Chrome+ dropdown, no foregrounding). Submitting is reason-gated (`pup_login {submit:true, submitReason}`)+ and confirms the live domain first (anti-phishing).+- **Registrable-domain matching:** a login saved on `ms.example.com` is offered on `example.com`+ and its siblings, like a browser password manager. So the host a credential was saved under does+ not have to equal the host of the page you are filling.+- **Durable:** the vault index lives in `~/.adom` (not the bridge cache), so it survives pup updates.+- Store one explicitly: `credential_set {host, username, password}`. List: `pup_credentials {}`.++## 2b. Sync the vault across the user's desktops (a real union, not a push)++The user runs several desktops; each pup learns different logins over time (one signs into Gusto on+the laptop, another into a vendor portal on the office box). `pup_vault_sync {peer, reason}` makes+both complete:++```+adom-bridge-cli pup_vault_sync {"peer":"arav-rog","reason":"both my desktops should have all my logins"}+```++- **Bidirectional.** It pushes THIS box's logins to the peer AND pulls the peer's back, so afterwards+ both boxes hold the UNION. A one-way copy would leave each box missing what the other learned —+ which is exactly the failure this verb avoids.+- **Newest-wins.** If the same host has different passwords on the two boxes, the more recently+ updated one wins on both. Identical entries are skipped.+- **No AI sees a password.** The bytes go pup→ab→peer-ab→peer-pup over the account's cross-desktop+ egress. `pup_vault_export` / `pup_vault_import` (the internal halves) are refused for cloud/relay+ callers — only same-account desktops sync.+- **One consent.** The initiating box shows one toast ("Sync your saved logins with <peer>?"); the+ user clicks Allow. Like every consent verb, waiting on the click is not a timeout — re-call with the+ same args to re-attach.+- Run it again whenever a box has learned new logins; it is cheap and idempotent.+++## 3. A saved password is STALE++**The tell:** pup fills + submits (`filledUsername/filledPassword/submitted` all true) but the site+says **"Fail to authenticate"** / "incorrect password". The stored secret is out of date, OR the+**username itself is wrong** (the same site can be saved under different usernames).++On a Mac the clean fix is the user: ask them to sign in once in the pup window (toast with the+window attached, see above). pup's capture rail stores the current login, with the right username,+and `pup_login` works from then on. If the user gives you the current details directly, store them+with `credential_set {host, username, password}` and retry. If another of their desktops holds a+fresher copy, `pup_vault_sync {peer, hostFilter}` brings it over. Registrable-domain matching+(section 1) handles a login saved on `ms.<site>` and used on `<site>`.++## Privacy rules (non-negotiable)++- Only ever handle a login for the site the user asked about, on the user's own machine, at their request.+- A password you are given is used once to store it. **Store it and move on — never print it, never+ repeat it in a later message, never log it.**+- Prefer, when the user would rather pup never see the plaintext: have them **log in once manually**+ (pup's capture rail grabs it fresh), or use a **"Sign in with Google/Microsoft"** SSO path+ (`pup-vendor-login`) that types no password at all.++## When NOT to need any of this++Manufacturer-direct sites (TI, ST, ams, …) and datasheets need no login at all — see+`ee-parts-sites.md`. Reach for credential recovery only when a site truly gates what the user wants+behind their own account.++## Read `landed` before you say a word about signing in (pup 2.0.642)++Every `pup_open_window` and `pup_navigate` response carries `landed:{url,title,loginPage,signedIn,_hint}`. The+shared adom-you profile keeps sessions (claude.ai, chatgpt.com, the wiki, vendor portals) for weeks, so most+opens land already signed in. An AI toasted "Sign into claude.ai in the pup window once" on 2026-09-19 while the+window was already on the user's chats. Never do that: read `landed`; `signedIn:true` means say nothing;+`loginPage:true` means try the vault, Google sign-in and the site's recipe in `login-recipes/` first, and toast+the user only when pup itself cannot finish. Recipes: `login-recipes/claude.ai.md`, `login-recipes/chatgpt.com.md`.++## HTTP Basic/Digest auth (the browser's native "Sign in" dialog) is answered from the vault on every lane (2.0.644)++Some downloads (SamacSys / componentsearchengine.com CAD models, vendor file servers) answer with a 401 and+Chrome pops its native "Sign in" dialog, which no page verb can see. pup answers it from the vault on both+lanes now, matched on the registrable domain (a `componentsearchengine.com` entry serves `st.`, `ms.` and+`nxp.componentsearchengine.com`). With no entry, pup CANCELS the challenge so the dialog never blocks the+window, and the next `pup_navigate` / `pup_fetch_url` returns `errorCode: no_credential` naming the host, with+the routes: the user signs in once, `pup_vault_sync {peer, hostFilter}`, or `credential_set+{host:"<domain>", username, password}`. A stored credential the server rejects is tried once, then reported+the same way (stale password: see `login-recipes/recover-a-current-password.md`). `pup_fetch_url` works on+the extension lane too, so a gated file can be fetched as `bodyBase64` without a download dialog.++## Where secrets live++Every password is a macOS Keychain item under the service `adom-bridge-pup` (keyed by host, or+`host|username` for a second account on the same host), indexed by `~/.adom/pup-credentials.index.json`+(host + username only). pup's own browser runs with an in-memory cookie key (`--use-mock-keychain`),+so it never asks the user for Keychain access.
skills/pup-lanes/SKILL.mdadded+116@@ -0,0 +1,116 @@+---+name: pup-lanes+user-invocable: false+description: "Parent skill: pup. The two ways pup drives a pup window (never name the browser build to a user), the extension lane (DEFAULT, every vendor site loads, no debug port) and the CDP lane (opt-in ONLY via lane:'cdp', full DevTools control but detectable and walled by vendor edges). Pros and cons of each, exactly which verbs need CDP, what lane:'cdp' costs (it takes adom-you away from the extension lane and closes its windows), and the rule: the extension lane already has CDP-level access (pup_devtools sends any DevTools command), so never stop or move a task because the CDP lane is off. Read before passing lane, isolated, profile, highFps or webSecurity to pup_open_window, and whenever a verb answers lane_needs_cdp or cdp_lane_required. Trigger words: pup lane, extension lane, cdp lane, lane:cdp, lane_needs_cdp, cdp_lane_required, which lane, remote-debugging-port, debug port detected, site blocks pup, pup_open_window lane, isolated:true refused, highFps refused, three icons vs four icons."+---++# pup lanes: the extension lane is pup. CDP is the exception.++The point of the September 2026 work (John, 2026-09-13 and 14) was one thing: pup windows that load+EVERY website without the site blocking us. That turned out to be the extension lane. So it is the+default, and the CDP lane opens only when an AI writes `lane:"cdp"` because the task needs something+only CDP can do AND the user has turned that lane on for the box (`cdpLane`, off by default since 2.0.652:+`cdp_lane_disabled` otherwise). pup no longer falls back to CDP on its own for any reason.++## What each lane is++| | Extension lane (DEFAULT) | CDP lane (`lane:"cdp"`, opt-in) |+|---|---|---|+| How pup drives the window | pup's own MV3 extension polls the bridge and runs the commands inside Chrome | Chrome DevTools Protocol over `--remote-debugging-port` |+| Debug port | none | yes, and vendor edges detect it (measured: st.com and mouser RST the HTTP/2 stream, arrow denies) |+| Profile | one browser per profile: adom-you is shared by every thread; `isolated:true` and `profile:"<name>"` get their own lane browser, extension and queue (2.0.659) | any profile; on adom-you it TAKES the profile from the extension lane and closes those windows |+| Toolbar | four icons: pup dots, pen, identity tile, nbe | three icons (nbe rides the extension lane only) |+| Taskbar identity, tiles, overlays, jump lists | none on macOS (no per-window taskbar button) | none on macOS |+| Annotate (pen, snip) | yes (2.0.569, Trusted-Types safe since 2.0.570) | yes |+| Vendor sites (st, mouser, digikey, arrow, analog, ti, avnet, nxp, infineon, and the rest of the 20) | load | walled by several of them |++## Pros and cons++**Extension lane**+- Pro: sites load. This is the reason it exists and the reason it is the default.+- Pro: one shared adom-you browser, so logins and cookies accumulate for every thread, and every window carries the full toolbar including nbe.+- Con: `pup_eval` runs in the page's MAIN world, so a page with a strict Content-Security-Policy (wiki.adom.inc, claude.ai) refuses it with "Evaluating a string as JavaScript violates the following Content Security Policy". Use `pup_screenshot`, `pup_list_tabs`, `pup_navigate` and the other DOM-free verbs there, or `lane:"cdp"` if you truly must run script on such a page and accept that vendor sites will not load in that window.+- Note on screenshots: a window that produces no frames cannot be captured. On macOS the windows of a hidden app stop producing frames after about a minute, so every capture briefly unhides pup's own browser app BEHIND the user's frontmost app (no activation, never over the user's window) and re-hides it afterwards, one shared unhide for concurrent captures. `captureVisibleTab` is raced against a 3 s deadline and falls back to a debugger frame. The lane also launches with `CalculateNativeWinOcclusion` disabled, so a covered window keeps compositing. Background TABS are still throttled by Chrome as before; this is not the highFps anti-throttle set.+- Since 2.0.578 the extension lane ALSO has trusted input (`pup_click`, `pup_hover`, `pup_type`, `pup_press_key`, `pup_scroll`, `pup_fill`), element and full-resolution screenshots, `pup_select` (a `<select>` by value or label, 2.0.592), and CSP-proof `pup_eval` (a page whose Content-Security-Policy or Trusted Types policy refuses the injected eval is retried through DevTools automatically, 2.0.591; Google Calendar and Admin are the everyday cases). They ride nbe's DevTools driver inside the lane (`chrome.debugger`, attached to one tab on demand, detached after nbe's idle window; no debug port, no automation flag, `navigator.webdriver` stays false, no bar). Since 2.0.581 recording (`pup_record_start` / `pup_record_stop`) works on this lane too: it rides ab's native recorder (Windows Graphics Capture, hardware H.264 MP4, keyed on the window handle), which records a covered window live and emits frames only when the content changes. Still CDP-only: selector-driven `pup_download`, `realKeystrokes` logins, iframe targeting, separate profiles (backlog: pup-bridge#113), `highFps`, `webSecurity:false`. Those answer `lane_needs_cdp` with the fix.++**CDP lane**+- Pro: everything DevTools can do: trusted mouse and keyboard events, cross-origin iframes, `Runtime.evaluate` that ignores page CSP, element and full-res screenshots, annotate, recording, emulation, anti-throttle for a live view.+- Con: the debug port is a detection surface. A CDP window is what gets you "Access Denied" on the hard vendor sites.+- Con: `lane:"cdp"` on adom-you evicts the extension lane: pup closes every extension-lane window to hand the profile over, because Chrome runs one process per profile. Other threads lose their windows. That is why it is opt-in.+- Con: three toolbar icons, no nbe.++## The rule++1. Open windows with no `lane`. You get the extension lane.+2. `profile` and `isolated` need no lane (2.0.659: their own extension-lane browser). Only `highFps` and `webSecurity:false` still need `lane:"cdp"`; without it pup refuses with `cdp_lane_required` and says why. Since 2.0.563 nothing silently opens on CDP.+3. You almost never need `lane:"cdp"`: the extension lane speaks the full DevTools protocol through pup's extension, and `pup_devtools {method, params}` sends any command to an extension-lane window. Since 2.0.726, `lane:"cdp"` on a box with the CDP lane off opens on the extension lane with a `_laneNote` instead of refusing; never stop, and never move the task to another box, because CDP is off.+ A pup update reaches a running extension lane on its own: pup re-stages the extension, cycles the lane browser and relaunches every window from its file on the new extension (2.0.647; the 2.0.569 in-place reload broke the channel and is gone), so a new build takes effect without anyone closing a window. `pup_status.extensionLane.workerVersion` shows what the lane is running.+4. If a verb says something is not wired on the extension lane yet, its hint names the extension-lane way (a URL download, `pup_type` for a stubborn form, `pup_devtools` for iframes and anything else). Use that, and file the gap on adom/pup-bridge.++## How you tell them apart++The toolbar: four icons is the extension lane, three is CDP (John's own spec for the fourth icon). `pup_list_windows` reports `transport: "ext"` or `"cdp"` per window. `pup_status.extensionLane` shows the lane's health.++## What the extension lane can do since 2.0.648 (it used to say lane_needs_cdp)++- **Trusted input:** `pup_input_dispatch {type:"click"|"mouseMoved"|"mousePressed"|"mouseReleased"|"mouseWheel"|"keyDown"|"keyUp"|"rawKeyDown"|"char", x, y, ...}` works on the extension lane through the worker's DevTools driver, so the event is as trusted as the CDP lane's and lands inside cross-origin frames. Measured 2026-09-19: a click on the reCAPTCHA demo checkbox passed it and issued a token, no image grid. Captchas are driven here, never on the CDP lane and never handed to the human (the user's rule); the image-grid loop (screenshot, pick tiles, trusted tile clicks, Verify) is the caller's script on top of these verbs.+- **`pup_fetch_url`** runs in the named tab on this lane, tries the fetch with credentials and then without (a wildcard CORS header refuses credentials), returns `httpStatus`, `contentType`, `bytes`, `sha256`, and the body as `bodyBase64` (binary-safe) or `text`. `saveTo` takes your container path: the CLI writes the file from the returned body and reports `savedTo` plus `savedToFilesystem:"container"`; compare `sha256` (measured: a 269669-byte PDF, identical hash to the reporter's). A cancelled HTTP auth challenge comes back as `no_credential` with the host.+- **`isolated:true`** mints a throwaway `iso-<id>` profile (reported by `pup_window_info`), opens it in its OWN extension-lane browser (2.0.659, pup-bridge#113), and ends that browser and removes the profile when the window closes. No `lane` argument, no CDP. Measured: an isolated window on the extension lane read an empty cookie jar while a default-lane window kept working beside it.+- **Slow boxes:** a cold `pup_open_window` that would exceed ab's budget answers `ok:true, loading:true` with a progress frame once the window exists and finishes the navigate in the background; poll `pup_window_info` instead of opening again.++## The CDP lane is OFF by default (2.0.652, John 2026-09-19)++`pup_open_window {lane:"cdp"}` opens on the extension lane (with a `_laneNote`) unless the user has turned the CDP lane+on for that box with `pup_configure {cdpLane:"on"}`; before 2.0.726 it refused with `cdp_lane_disabled`, and AIs read+that as "pup can't do this here". John: "i want it almost NEVER used cuz then you may use it as a crutch instead of+fixing ext lane to solve all use cases." Trusted input, HTTP auth, fetch, screenshots and recording all run on the+extension lane; a feature that genuinely needs CDP is an extension-lane gap to file on adom/pup-bridge, not a reason+to open a CDP window. `isolated:true` and `profile:"<name>"` run on the extension lane too (2.0.659): each profile gets its own lane browser, its own staged extension and queue, so an isolated window is a separate browser with an empty jar, and a throwaway one ends with its last window.++## Windows keep their profile and their record across restarts (2.0.669 to 2.0.671)++Each profile has its own extension-lane browser (adom-you, plus one per explicit `profile`). On+2026-09-21 John saw windows lose their record after an upgrade on his laptop, and the cause was two bugs:++- **A window still on screen was forgotten.** After an upgrade the recovery pass counted each 15 s+ "still on screen, extension not answering yet" check as a failed relaunch and deleted the window's+ record on the third. The window stayed up with no owner, and its thread opened a duplicate.+ Since 2.0.669 a window on screen is never counted as a failure; its record is kept until it is re-adopted.+- **A browser was adopted into the wrong profile.** At boot pup restored only the adom-you lane, and the+ first unknown lane browser to say hello became adom-you, whatever folder it ran. adom-you windows then+ opened inside the "services" browser, with that profile's cookies. Since 2.0.670/671: every profile's lane+ is restored at boot and trusted only when its processes really run that profile's folder; the extension+ reports its profile in every hello, and an unknown browser is adopted only into that profile's lane, only+ after the boot restore; each window is re-adopted on its own profile's lane; and a window found inside+ another profile's browser is closed there and reopened in its own profile, at its page, in the background.++Proven on CRR with an adom-you window plus a second-profile window through an upgrade and a plain restart+(both kept, each in its own profile's browser, same windows), and on AdomLapper, where the three misplaced+adom-you windows were moved back into the adom-you browser automatically.++**Multi-tab windows after an upgrade (2.0.676).** An upgrade cycles the lane browser and reopens every window+with its tabs. Two bugs showed on AdomLapper on 2026-09-22: the extension opened a new tab in whatever window+had a real page when it could not find the named one, so 6 of one thread's tabs landed in another thread's+window; and the reopen de-duplicated URLs with the query removed, so different pages on one path (two YouTube+videos) collapsed into one. Now a new tab opens only in the window pup names (a live tab of it, or its Chrome+window id) and the extension refuses with window_not_found rather than guess; duplicates are exact URLs only.+Proven on CRR: a 4-tab window and a 1-tab window from another thread both came back complete and separate.++## The user's close is final, even during a restart or an upgrade (2.0.684)++On 2026-09-23 John closed the NSF thread's window on his laptop and it reopened, twice. An upgrade had ended pup's browser and was relaunching his windows one at a time; each close+landed between two relaunches, so the thread's next window came back 13 to 28 s later. The rules now:++- pup relaunches a missing window ONLY when it knows that window's browser died for this pass: pup ended it (an+ upgrade or pup_restart_browser), it crashed, or it was already dead when pup booted. Those windows are marked+ lost before anything relaunches.+- Any other missing window, one whose browser is running without it, was CLOSED. pup forgets it and never brings+ it back. This covers a close that happens while pup itself is restarting and cannot hear the extension.+- Closing a window while a relaunch queue runs cancels every queued relaunch of the same thread,+ and each relaunch first picks up closes reported since the previous one.++Do not decide "closed vs lost" from whether a browser is running NOW: the first relaunch of an upgrade restarts the+browser, and 2.0.681, which did exactly that, dropped every window after the first on every upgrade (caught on CRR+before it went public). Proven on CRR with three windows of one thread: an upgrade brings all three back, closing+the first mid-queue cancels the other two, and a window closed during a restart stays closed.+
skills/pup-screenshots-recording/SKILL.mdadded+120@@ -0,0 +1,120 @@+---+name: pup-screenshots-recording+user-invocable: true+description: "Capturing pup (Puppeteer bridge) windows: SCREENSHOTS (pup_screenshot — lossless PNG, full-page vs viewport, full-resolution via pup_screenshot_full_res, and how to actually READ the image) and RECORDING (pup_record_start/stop records ONE pup window through ab's native recorder, MP4, extension lane included; desktop_record_start/stop records the WHOLE desktop across apps). Covers framing a window at an exact size for a clean shot WITHOUT foregrounding it, and why pup shots are true-to-life (real rendered window, not headless). Read when screenshotting a page, capturing full-page vs above-the-fold, recording a demo/walkthrough of an app, or choosing the window-recorder vs the desktop-recorder. Trigger words: pup_screenshot, pup_screenshot_full_res, full page screenshot, screenshot the page, read the screenshot, pup_record_start, pup_record_stop, desktop_record_start, desktop_record_stop, record my app, record a demo, screen recording, walkthrough video, pup screenshot, pup recording."+---++**Parent skill:** pup-bridge · See also: `pup` (start here), `pup-windows-sessions-tabs`, `pup-browsers-and-chrome`.++# pup — screenshots & recording++pup windows are **real, GPU-rendered windows** (not headless), so screenshots and recordings are+true-to-life — fonts, canvas, WebGL, video, and CSS all render exactly as a user would see them. And it+all works while the window is in the **background** (see `pup-windows-sessions-tabs`), so you capture+without disturbing the user.++## Screenshots++```bash+adom-bridge-cli pup_screenshot '{"sessionId":"mytask-web"}' # viewport, lossless PNG+adom-bridge-cli pup_screenshot '{"sessionId":"mytask-web","fullPage":true}' # entire scrollable page+adom-bridge-cli pup_screenshot '{"sessionId":"mytask-web","tabId":"tab-2"}' # a specific tab+```++- Returns a **PNG saved on the container** (the machine running the CLI) — the response gives you the path.+ **`Read` that path** to actually see it; a returned path is not the same as having looked. Downscaled to+ stay under the image-read limit by default.+- **`fullPage:true`** stitches the whole scrollable page; omit for just the viewport (above the fold).+- **`pup_screenshot_full_res`** captures at full native resolution (no downscale) when you need to read+ fine detail — larger file; use when the default shrink loses text you need.+- **Framing for a clean shot without showing the user:** size/position the window on open —+ `pup_open_window {width,height,x,y}` — which places it **without foregrounding** (background stays+ background). Great for a deterministic, repeatable capture frame. To capture the OS window *plus* any+ owned popups/dialogs on top, use AD's `desktop_screenshot_window` instead (it returns the window with its+ child popups).+- **Sanity-check, don't assume:** a page that 404'd or hit a login wall still screenshots — pair with+ `pup_eval` on `document.title` when in doubt.+- **Rendering your OWN HTML to screenshot it?** Use **`pup_render_html {sessionId, html}`** — it renders+ the string via `page.setContent`, which is **UTF-8-correct**. (A `data:text/html,…` URL mojibakes accented+ characters — `Café` → `Café` — unless you hand-add `;charset=utf-8`; `render_html` just does it right.)+ Then screenshot/drive it like any page. Great for previewing generated markup without writing a temp file.++## Recording: one recorder, ab's, and pup's verbs ride it++**Desktop recording belongs to ab, not pup (John, 2026-09-07), and since 2.0.582 so does window+recording.** `pup_record_start {window}` and `pup_record_stop {window}` delegate to ab's native+recorder (`desktop_record_window_start` / `_stop` keyed on the pup window's handle) when this Mac's+Adom Bridge offers it; otherwise pup records the page itself, which needs ffmpeg (`brew install ffmpeg`,+re-detected without a restart). It works on the extension lane. Frames are emitted only when the window's content+changes, so a static page yields few frames and a small file; that is by design, not a dropped take.+The response carries `filePath`, `bytes`, `frames` and `recordedMs`; pull the file with+`adom-bridge pull_file {filePaths:[path]}`.++| Ask | Verb | Captures |+|---|---|---|+| "record my app", a UI walkthrough | `pup_record_start` / `pup_record_stop` | ONE pup window, whole window frame, MP4, no audio |+| "record my desktop / the demo with sound" | ab `desktop_record_start` / `desktop_record_stop` (ab 2.1.36+) | a whole monitor plus the system audio mix, MP4 |++The old Chrome screen-share recorder (`pup_screen_record_*` through the `pup_recorder_open` HUD) and the+DevTools screencast are retired to fallbacks pup uses only where ab's recorder is absent; do not offer+them. `pup_record_status` and `pup_record_list` report through ab too.++Say it the way the user will understand: "ab records your desktop natively (GPU accelerated); pup can+do it Meet-style if that is not available." Never present desktop recording as pup's own feature.++```bash+# Recording a pup window the user will watch → DO bring it forward first+adom-bridge-cli pup_raise_os_window '{"sessionId":"demo-web"}'+adom-bridge-cli pup_record_start '{"sessionId":"demo-web"}'+# ...drive the scene: navigate, click, type, scroll...+adom-bridge-cli pup_record_stop '{"sessionId":"demo-web"}' # → { path: "...webm" }+adom-bridge-cli pup_lower_os_window '{"sessionId":"demo-web"}' # tuck it away again when done+```++- `pup_record_stop` returns the saved video **path**. Status and list verbs: `pup_record_status` /+ `pup_record_list` (the tab/window recorder), `pup_screen_record_status` / `pup_screen_record_list`+ (pup's Meet-style desktop fallback), `desktop_record_status` / `desktop_record_list` (ab's native+ recorder; see the `adom-bridge-recording` skill). On an ab older than 2.1.36 the `desktop_record_*`+ names were routed to pup's fallback; pup now answers those with `wrong_bridge` and the fallback names.++## Driving & verifying tricky pages (shadow DOM, nested scroll) — use the first-class verbs++Component-heavy pages (an app, a wiki widget) break the naive path: `document.querySelector` misses shadow+content, `window.scrollTo` no-ops when the page scrolls inside a nested `<div>`, `fullPage` returns only the+viewport, and a sized window can even come up ~170px wide. As of **v1.8.21 pup has first-class verbs for+all of it** — reach for these instead of hand-rolling evals:++- **`pup_screenshot_element { sessionId, selector? | text?, closest?, padding?, deep? }`** — screenshot+ ONE element. Resolve by CSS **selector** (most reliable) or visible **text** (pierces shadow DOM), scrolled+ into view first. **This is usually the whole answer to "capture just this card."** Text matches the+ *tightest* text node — to grab the enclosing card add `closest:".card"`; and if the phrase is split across+ spans or appears in prose too, use a **selector** (e.g. `.download-card`).+- **`pup_scroll { sessionId, dy?/dx? | toSelector?/toText?, block?, behavior? }`** — scrolls the ACTUAL+ scrolling element (nearest scrollable ancestor, not `window`). Scroll by delta or to an element/text+ (shadow-piercing). Returns `{ scrollElement, scrollTop, scrollHeight, clientHeight, atBottom }` so you can+ confirm it landed — no more overshoot from hand-computed offsets.+- **`pup_set_viewport { sessionId, width, height, deviceScaleFactor? }`** — pins the viewport+ deterministically (fixes a too-small innerWidth). `pup_open_window { width, height }` now applies it on+ open, so a sized window renders at the size you asked for.+- **`$deep(sel)` / `$$deep(sel)` / `deepText(txt)`** are auto-injected into every page, so `pup_eval` can+ pierce shadow roots directly — no hand-rolled walk:++```bash+adom-bridge-cli pup_eval '{"sessionId":"...","expr":"$$deep(\"a[href]\").length"}' # counts across shadow roots+```++**Still verify by DOM, then by eye.** To count cards/links/rows, count in `pup_eval` by document-Y+(`rect.top + window.scrollY`) — immune to an overshot screenshot — then `pup_screenshot_element` (or+`pup_scroll` + `pup_screenshot`) to confirm. And **first check whether a page is even shadow DOM**+before assuming: a shadow survey (`$$deep` finding more than `querySelectorAll`) tells you; often an "empty"+result just means a wrong selector on plain DOM.++**fullPage caveat:** `pup_screenshot { fullPage:true }` only covers *document*-level scroll. On a+nested-scroll SPA it returns just the viewport and a `nestedScroll:true` hint — use `pup_screenshot_element`+for one region, or `pup_scroll` through the container.++Reminder: since v2.0.274 `pup_eval` accepts statement lists (semicolons fine — a syntax error in+the expression form retries with devtools-console semantics, and bad code fails honestly with+`eval_failed`); a comma-operator/IIFE still gives the cleanest single-expression result. If a+screenshot lands on the wrong content, don't trust it — it almost always means an inner scroll container;+re-scroll the element, don't nudge the window.
skills/pup-settings/SKILL.mdadded+105@@ -0,0 +1,105 @@+---+name: pup-settings+description: "The complete reference to pup's behavior on macOS: every Settings toggle and the feature behind it, the toolbar extension family, annotate/capture, foreground etiquette, the credential vault (auto-capture + autofill), in-page identity, and recovery/safety. Read to know exactly what pup does on the user's machine and how to change any of it. Change settings live with pup_configure {key:value} or the dashboard gear (Settings dialog)."+---++# pup Settings & features: the complete behavior reference++Every knob pup exposes, what it does, its default, and how to flip it. **Change any of these live**+with `pup_configure {<key>:"on"|"off"|<value>}` (no restart), or the gear icon on the pup dashboard+(`http://127.0.0.1:<port>/dashboard`). Settings persist in `~/.adom/pup-settings.json`. Defaults+below are what a fresh Adom user gets.++## Windows-only keys (stored, but nothing changes on a Mac)++macOS has no per-window taskbar button, so the Windows line's taskbar keys do nothing here:+`taskbarMode`, `taskbarGrouping`, `aumidIconStyle`, `overlayBadges`, `overlaySize`, `aumidIcons`,+`threadTiles`, `aumidShortcut`, `jumpLists` and the `jl*` jump-list tasks, `taskbarActivity` (reads+`off`) and `winAttentionFlash`. `pup_configure` stores them and reports `taskbarSupported:false`,+`platform:"darwin"`. Do not offer them to a Mac user.++## Toolbar extension family: buttons in the browser toolbar++pup ships a small family of MV3 toolbar tiles (pup dots, the identity avatar, the annotate pen).+They ride the Chrome-for-Testing launch (`--load-extension`); pup auto-enables Developer mode and+pins them at each launch (v2.0.114), so they appear with zero setup.++| Key | Default | What it does |+|---|---|---|+| `toolbarExtension` | **on** | The Adom Pup toolbar buttons (annotate, snip, view toggle, dashboard, owner, close). |+| `tbSnipHotkey` | **on** | `Alt+Shift+A` starts annotate in Auto; `Alt+Shift+M` starts it in Multi (compose shapes, capture on Done). Either hotkey on an open toolbar switches its mode. |+| `tbFloatyPencil` | **off** | A tiny pencil in the upper-right CORNER of each page; one click starts annotate. Off by default, the corner hover zone can fight buttons in app headers, and the toolbar pen already covers discovery. |+| `floatyHitPx` | **24** | Size (px) of the invisible corner target that reveals the floaty pencil. Applies live. |++## Annotate & capture++Works on both lanes since 2.0.569: on the default extension lane the overlay is file-injected, so+strict-CSP and Trusted-Types pages (Google Docs) take it too.++Draw on any pup page (pen / snip). Two capture modes, switchable in the overlay toolbar (v2.0.370):+**Multi** (the default since 2.0.701) accumulates shapes and captures ONCE on Enter or Done; **Single**+(called Auto before 2.0.701, still `mode:"auto"`) captures the instant the mouse lifts, the+draw-one-circle case. Keys: Enter finishes, Space toggles Multi/Single, M or S picks one, Esc cancels;+a hint line under the toolbar says so. Multi auto-advances the color after each shape+(red → blue → green → purple; never yellow or white, since 2.0.705) so every shape is distinct with zero clicks.+Colors are a one-click trio (red, blue, green: colors anyone can name when telling the AI which mark is which) with purple/yellow/white in a flyout, each swatch named in its tooltip; a manual+pick overrides the cycle, and the pen cursor carries the current color.++| Key | Default | What it does |+|---|---|---|+| `annCaptionBar` | **on** | Bake a thin context strip under each capture (thread, page, time) so any AI you paste it into sees where it came from. |++Drive it: `pup_annotate {sessionId, tool:"pen"|"snip", mode:"auto"|"multi"}`, the toolbar pen, the+`Alt+Shift+A` hotkey (`Alt+Shift+M` for Multi), or the (off-by-default) corner pencil.++## Foreground etiquette: pup never steals your screen++pup's browser app stays HIDDEN (the Cmd+H state), so its windows never cover your work or take focus,+and they stay fully drivable. When an AI *does* raise a window, it must give a reason, shown on-screen+first, THEN it unhides and activates the window. A background nudge (`pup_alert_window`) is a macOS+notification you can click; it never takes focus.++| Key | Default | What it does |+|---|---|---|+| `fgCaption` | **on** | When an AI brings a window to your screen, show its stated reason as an on-screen caption. |+| `fgCaptionMs` | **2000** | How long (ms) that foreground-reason caption shows. |+| `notMineCaption` | **off** | The single self-foreground caption control (v2.0.292: folded in the old `fgWatch`). Off by default. When on, pup watches for ~20s after a background open/navigate and, if a window pulls ITSELF to the front (JS/focus/Chrome, not pup), briefly captions that Adom did NOT do it. The window is no longer re-parked, off-screen birth means pup no longer needs a foreground-steal watchdog; this is now purely the "not me" caption. |++## Credential vault: pup manages your logins (no Chrome dialog)++pup runs its own Keychain-backed password manager so the AI signs you in without Chrome's foreground+picker. Chrome's native "Save password?" bubble is disabled; pup captures and fills silently.++| Key | Default | What it does |+|---|---|---|+| `captureLogins` | **on** | As you sign into sites, store the username + password in the macOS Keychain (encrypted; never logged/echoed) so return visits auto-fill. Captures on password blur to beat cross-origin SSO redirects. |+| `autoLogin` | **on** | Fill a matching saved login when a login page loads (background, no dropdown). pup never auto-SUBMITS, submitting is reason-gated via `pup_login {submit:true, submitReason}` which confirms the live domain (anti-phishing). |++Verbs: `pup_login` (fill/submit; `realKeystrokes:true` for React-masked fields like Gusto),+`pup_credentials` (list host+username only), `credential_set` / `credential_delete`. Per-site login+flows live in `login-recipes/` on the wiki page.++## In-page identity: which thread owns this window++| Key | Default | What it does |+|---|---|---|+| `ownerChip` | **off** (since 2.0.713) | A tiny chip, bottom-left, naming the owning AI thread + profile. Off by default: it covers whatever the page puts in that corner (a video player's controls, a chat box), and the window title already leads with the thread name. |+| `titleTag` | **off** | Append "(ai-thread: <name>)" to the window title. Off, many pages manage their own title. |+| `titlePrefix` | **off** | Prepend the owning thread (+ wiki signed-in/out) to the title. Off, pup does not mangle page titles by default (see the page-integrity doctrine). |++## Recovery & safety++| Key | Default | What it does |+|---|---|---|+| `pup_restart_browser` (verb, not a setting) | | The fix when a browser wedges: its windows stop responding to the mouse wheel and their tab strips ignore clicks, while dragging a scrollbar still works. That is Chrome's browser process spinning, not a slow page, and only a restart clears it. pup detects it within about 45 s, toasts you, and this verb saves every tab, ends the browser, and brings the windows and tabs back. |+| `idleBrowserQuitMin` | **10** | Minutes a pup browser may sit with NO windows before pup closes it and gives the RAM back (0 = never close it). An empty Chrome still costs hundreds of MB on your machine, most of it a GPU process holding allocations from pages you already closed. The next open relaunches it, still signed in, so the only cost is one cold start. |+| `sleepRecovery` | **on** | Restore windows after a pup restart, or after the browser CRASHES (a nonzero exit code, the only proof pup accepts). A window you close yourself is forgotten the moment it goes, and closing the last pup window ends the browser cleanly: nothing comes back. pup windows are fleeting (2.0.595). Never restarts a live Chrome. |+| `reportIssues` | **on** | Let pup invite AIs to file a wiki issue when they hit something broken. |+| `minimalTouch` | **on** | Safety mode from the Chrome-crash hunt: pup keeps its hands off your windows, opens + drives pages only, skips extra window machinery. The per-feature toggles above win over this master switch. |++## The one profile that matters: adom-you++Every pup window rides the shared durable **adom-you** profile by default, so logins persist and the+credential store grows over time. Isolation (`isolated:true`) is REASON-GATED (`isolationReason`+required) and a non-adom-you window wears the anonymous identity tile so it's visibly not-you. Never+log in on an isolated throwaway, the session (and 2FA-remember) won't persist.
skills/pup-vendor-login/SKILL.mdadded+180@@ -0,0 +1,180 @@+---+name: pup-vendor-login+description: "How to sign a user into a third-party vendor portal (Autodesk, Fusion, Mouser, DigiKey, and other SSO sites) inside a pup window, so the login PERSISTS and later visits are automatic. Covers the autonomous 'Continue with Google/Microsoft' path that needs no typed password (it reuses the profile's existing Google session), the OAuth-popup behaviour pup shows, driving Google's FedCM account chooser (which rejects ALL programmatic clicks — needs a real desktop_hover-then-click), which profile to use for crash-isolation, and the hard limit that some heavy account-admin SPAs (Autodesk's Kepler/React portal) JAM CDP so pup cannot drive the in-portal admin UI. Read this before opening any vendor login in pup. Trigger words: log into autodesk, autodesk sign in, sign into fusion, continue with google, vendor login, portal login, sso login in pup, log into mouser, digikey login, assign a license, autodesk account, manage.autodesk.com, store my login, oauth popup, account chooser."+---++# pup-vendor-login — signing a user into a vendor portal that persists++Goal: get the user signed into a third-party site (Autodesk, Fusion, Mouser, DigiKey, …) inside a+pup window so the session is **stored in the durable profile** and every later window is already+signed in. Autodesk is the worked example; the pattern generalises.++## The one-paragraph model++A vendor login is an OAuth/SSO flow. The **fastest autonomous path is "Continue with Google"** (or+Microsoft/Apple) because pup's durable `adom-you` profile usually already holds the user's Google+session — so no password is typed. The click sends the window (or an OAuth popup) to Google's+account chooser; you click the user's account; it redirects back signed-in; and the vendor cookie+persists on disk. Email+password works too but needs a credential you do not have — prefer SSO.++## The recipe (Autodesk, proven 2026-07-28)++1. **Open the login page in a durable profile, FRESH window, and pace slowly.**+ ```+ pup_open_window {"sessionId":"autodesk","url":"https://accounts.autodesk.com/logon"}+ ```+ Then WAIT ~10s before the first probe. Do NOT machine-gun navigate+eval+screenshot — rapid ops+ on these heavy SSO pages destabilise the CDP connection (measured: three Chrome crashes from+ churn alone). One op, settle, next op.++2. **Click the SSO button** (trusted CDP click via a text match; the button text often has hidden+ whitespace so match with a regex, not `===`):+ ```+ pup_eval {"sessionId":"autodesk","expr":"(()=>{const b=[...document.querySelectorAll('button,a,[role=button]')].find(e=>/continue with google/i.test(e.textContent||'')&&e.offsetParent!==null); b.click(); return 'clicked';})()"}+ ```+ Wait ~8s.++3. **Find where the OAuth landed — TWO shapes, handle both:**+ - The main window navigates to `accounts.google.com/.../accountchooser` (most common), OR+ - An OAuth **popup** opens. Autodesk's popup has **no opener**, so pup's popup-tracker skips it+ and the reclaim sweep adopts it as its **own session** named like `adom-you-win<rand>` at+ `signin.autodesk.com`. Run `pup_list_windows` and look for the new session; drive THAT one.++4. **Click the account — FIRST figure out WHICH chooser you got, because they are two different+ things with two different answers (corrected 2026-08-22, this is the important update):**++ **(A) The CLASSIC FULL-PAGE OAuth chooser — the common case, and it is FULLY BACKGROUND-DRIVABLE,+ NO foreground, NO real cursor.** After "Continue with Google" the WINDOW NAVIGATES (a real+ top-level navigation) to `accounts.google.com/v3/signin/accountchooser?...flowName=GeneralOAuthFlow...`+ — an ordinary Google web page, same-origin to Google, NOT an iframe. This accepts a properly+ dispatched synthetic click. **Proven 2026-08-22 signing John into Autodesk entirely in the+ background** (window never left z-bottom). The recipe that works — and the two traps that make a+ naive click fail:+ ```+ # find the account ROW (a <li>) whose text is JUST the account, not the whole list, get its center px:+ pup_eval {sessionId, expr:"(function(){var a=[...document.querySelectorAll('*')].filter(e=>/YOUR_EMAIL/i.test(e.innerText||'')&&!/another account/i.test(e.innerText||''));var t=a.sort((x,y)=>x.innerText.length-y.innerText.length)[0];var c=t.closest('li')||t;var r=c.getBoundingClientRect();return JSON.stringify({x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)});})()"}+ # then dispatch a FULL native event sequence on the element AT THAT POINT (elementFromPoint), not on the div you matched:+ pup_eval {sessionId, expr:"(function(){var x=X,y=Y;var el=document.elementFromPoint(x,y);var t=el.closest('[jsaction],li')||el;var o={bubbles:true,cancelable:true,view:window,clientX:x,clientY:y,button:0};['pointerover','pointerenter','pointerdown','mousedown','focus','pointerup','mouseup','click'].forEach(function(n){try{t.dispatchEvent(new (n.slice(0,7)==='pointer'?PointerEvent:n==='focus'?FocusEvent:MouseEvent)(n,o));}catch(e){t.dispatchEvent(new MouseEvent(n==='pointerdown'?'mousedown':n,o));}});return 'clicked';})()"}+ ```+ - **Trap 1: a bare `element.click()` or `pup_click {x,y}` (CDP mouse) does NOTHING here** — Google's+ rows are wired to a jsaction handler that wants the full pointer+mouse sequence, not a lone click.+ Measured 2026-08-22: `.click()` on the matched wrapper div = no-op; `pup_click` at the coords =+ no-op; the FULL dispatched sequence above = navigated straight to `profile.autodesk.com`.+ - **Trap 2: match the tight ROW, dispatch on `elementFromPoint`.** The element whose `innerText`+ contains the email is often a big container that ALSO contains "Use another account"; clicking it+ misses. Take the shortest-text match, resolve its `<li>`, dispatch on whatever is actually topmost+ at its center pixel.++ **(B) The FedCM One-Tap BUBBLE — the hardened case, needs a REAL OS cursor.** If "Continue with+ Google" shows the account WITHOUT a full navigation (a small "Sign in with Google" bubble/One-Tap,+ the URL stays on the vendor page, the account sits in a cross-origin FedCM iframe), then every+ programmatic click is rejected — DOM `.click()`, the synthetic sequence, `pup_click`, AND+ `desktop_ui_click` all fail (proven 2026-07-28). Only a real OS cursor HOVER-then-CLICK advances it:+ ```+ pup_raise_os_window {sessionId, foregroundReason:"<the user's words>"} # FedCM needs the window focused+ desktop_screenshot_window {hwnd} # get shotId + coordMap+ desktop_hover {space:"image", shotId, x:<account px>, y:<account px>} # MOVE the real cursor FIRST — this is the key+ desktop_click {space:"image", shotId, x:<same>, y:<same>} # then the real OS click+ ```+ The HOVER before the click satisfies FedCM's genuine-user-activation check. Aim by the SCREENSHOT+ pixel (iframe, so top-frame getBoundingClientRect can't locate it). If even that is refused, ask the USER to tap the visible account once.++ **So: try path (A) first — it is the common shape and needs nobody's screen.** Only fall to the+ real-cursor path (B) when there was no navigation and the account is in a FedCM bubble.++5. **Confirm the redirect landed** by SCREENSHOT, not eval — the eval often reads one beat before the+ redirect completes and reports the stale chooser URL while the screenshot already shows the+ signed-in dashboard ("Hi, <name>"). Trust the picture.++6. **FLUSH THE COOKIE BEFORE DOING ANYTHING ELSE — persistence is NOT automatic.** Reaching the+ dashboard does NOT mean the login is saved. Chrome writes new cookies to disk lazily (on idle /+ graceful shutdown), and a fresh vendor cookie can be LOST if the Chrome crashes before it flushes.+ Measured live 2026-07-28: sign-in reached the Autodesk dashboard, the very next navigation crashed+ the Chrome, and the login was gone — a later window opened SIGNED OUT (while Gmail, whose cookie was+ flushed days earlier, survived). So after the dashboard appears:+ a. Do nothing heavy for ~10-15s (let Chrome flush), OR better, `pup_close_window` the login+ window — a graceful close flushes cookies to disk.+ b. Reopen the vendor site in the durable profile and CONFIRM it is signed in (screenshot) BEFORE+ promising the user anything. Only a verified reopen proves persistence.+ Once flushed and confirmed, the cookie survives close, Chrome kill, and bridge restart like any+ other durable-profile login. Only say "you're logged in and it'll stick" once the+ reopen check passes — that claim was made falsely once and the window came up logged out.++## ORG DEFAULT (v1.9.162): ONE shared profile for everything++John's standing decision: ALL pup windows use the single durable `adom-you` profile so logins+accumulate into one growing store that gets nicer to use over time. So the "dedicated per-vendor+profile" advice below is the SAFER-but-not-default option — reach for `{profile:"autodesk"}` only+when a specific portal is known to crash and the user asks for isolation. By default, sign the user+in on the shared profile (no `profile` arg). The crash tradeoff is knowingly accepted: one Chrome+process, so a portal crash takes every session down together — which is exactly why the+close+reopen+verify flush discipline in step 6 matters even more.++## Which profile — the isolation option (not the default)++- **Persistence needs a durable profile** (default `adom-you`, or a named one). Never `isolated:true`+ for a login you want to keep.+- **Crash isolation argues for a DEDICATED durable profile per vendor**, e.g.+ `pup_open_window {"profile":"autodesk", ...}`. A crash/hang during a vendor login on the SHARED+ `adom-you` profile takes down EVERY other session in that one Chrome process — measured live: the+ Autodesk flow crashed the shared Chrome and killed the user's Gmail session twice, and lost the+ in-flight Autodesk login because its cookie had not flushed before the crash. A per-vendor durable+ profile keeps the login persistent AND contains the blast radius. The one cost: SSO in a dedicated+ profile only reuses an existing Google session if that profile already has one; the very first+ Google sign-in there is a real login.++## HARD LIMIT: heavy account-admin SPAs jam CDP++`manage.autodesk.com/billing/user-management/...` (Autodesk's "Kepler"/React account portal) reliably+**jams pup's CDP pipe**: the window signature is HUNG-alive / CDP-DEAD — the process lives, the log+shows `KEPLER: Auth is not yet ready to mount providers`, and every `page.eval`/`page.screenshot`+times out (20s), then the frame detaches. pup CANNOT reliably drive the in-portal admin UI (assigning+a license seat, editing users). Do not promise to click through it. Options, in order:+1. **Hand the in-portal action to the USER** — pup got them signed in; the window is theirs to finish+ the seat assignment by hand. This is the honest, reliable answer today.+2. **Use the vendor's API** instead of the web UI where one exists (Autodesk has account-management+ APIs) — no CDP, no jam.+3. Never deep-link straight to the admin URL right after login — it JAMS and can CRASH the Chrome,+ and if the login cookie has not flushed yet (step 6) that crash DESTROYS the fresh login. Flush+ first (close+reopen+verify), THEN, in a SEPARATE dedicated-profile window, hand the admin page+ to the user — so a portal crash can never cost the sign-in again.++## The RESPECTFUL way to do the FedCM click++`pup_assisted_click` is not available on macOS (`unsupported_platform`). For the account chooser,+ask the USER to click their account once in the raised window: toast and caption first, then wait for+them. If you drive it yourself with real-cursor input, follow the warn-and-wait protocol below and keep+the sequence TIGHT: any screenshot/eval between the raise and the click can steal focus and make the+click silently no-op (measured: interleaved ops failed 5x; a tight raise→capture→hover→click worked).++## Pitfalls recap (each paid for on 2026-07-28)+- Rapid ops crash the Chrome → pace slowly, one op + settle.+- Shared-profile crash kills all sessions + loses un-flushed logins → dedicated durable profile.+- Google FedCM chooser rejects ALL clicks (DOM/synthetic/page.mouse/UIA) → real OS desktop_hover THEN desktop_click; the hover is mandatory.+- Button text has hidden whitespace → regex match, not `===`.+- Eval reads stale mid-redirect → confirm with a screenshot.+- The account-admin SPA jams CDP → hand off to the user or the API; do not fight it.+- A fresh login is NOT persisted until Chrome flushes it → close+reopen+verify BEFORE claiming it+ stuck; a portal crash before flush silently destroys the just-completed sign-in (happened once).++## ⛔ BEFORE any real-cursor step: WARN THE USER AND WAIT (John, 2026-07-29)++The `desktop_hover`-then-`desktop_click` recipe below is **native OS input**. It does NOT target a+window — it goes to **whatever window has OS focus at that instant**. This user runs several AI+threads and alt-tabs back to their own work almost immediately after any window is foregrounded.++An agent fired `desktop_type` here without warning; the user had already alt-tabbed away and the+text landed in THEIR document. The agent then blamed the OS for "failing to foreground the window"+instead of the obvious: the human took their focus back because nobody asked them not to.++**So for EVERY native step in this skill:**+1. `notify_user` toast + `desktop_caption` BEFORE asking for the screen.+2. Say it explicitly: **"please don't type or click until I say done."**+3. Wait until the window is genuinely the OS foreground; if they alt-tab away, HOLD, don't dispatch.+4. Re-verify foreground **immediately before each event** — focus can move between two keystrokes,+ and half a password typed into the wrong window is real harm.+5. Caption when DONE so they know they can use their keyboard again.++Remember the ordering rule: **CDP input (`pup_click`/`pup_type`/`pup_hover`) needs no+foreground and disturbs nobody** — native input is the LAST resort, only for what CDP genuinely+cannot drive (the FedCM chooser, native OS dialogs, sites that reject synthetic events).
skills/pup-why-cft/SKILL.mdadded+114@@ -0,0 +1,114 @@+---+name: pup-why-cft+user-invocable: true+description: "Parent skill: pup. MAINTAINER detail (a user hears 'pup window'; do not volunteer the build, pup-bridge#114): WHY pup drives its own automation build of Chrome instead of installed Chrome/Edge, the five dated reasons (automation banner, --load-extension removal being the forcing one, version determinism, none of the consumer chrome, binary isolation), the honest trade-offs (~160 MB first fetch, the window title, no Widevine), and what did NOT change (profiles/logins are pup-owned either way). The policy flipped twice; docs/WHY-CFT.md on the wiki page is the dated canonical record and this skill is its AI-discoverable form. Read before questioning the browser choice, proposing native-first, or explaining a 'Google Chrome for Testing' window title to a user. Trigger words: why chrome for testing, why CfT, why not real chrome, why not installed chrome, why not edge, chrome for testing reasons, automation banner, controlled by automated test software, load-extension removed, browser policy pup, native-first, cft vs chrome, what is chrome for testing, google chrome for testing in task manager, widevine pup, drm pup."+---++# Why pup drives Chrome for Testing (the five reasons)++**Policy since 2026-08-10 (pup 2.0.53+), and it is final.** The policy flipped twice, early pup+was CfT-based, mid-2026 flipped to "drive the browser already on the machine" for instant first+runs, and production experience flipped it back for good. The dated technical record is+**`docs/WHY-CFT.md`** on the `adom/pup-bridge` wiki page (written so the reasons outlive the people+who remember them); this skill is the same content in the form an AI thread can discover. If you+are about to propose "just use the installed Chrome", these are the five things that proposal+re-breaks.++## 1. The automation banner you cannot turn off++The moment pup does real automation in branded Chrome, synthetic cursor, login fill, captcha+assist, Chrome asserts the infobar: **"Chrome is being controlled by automated test software."**+It blips in and out as automation starts and stops, reflowing the viewport each time, and it is+deliberately hard to suppress in the branded build: stripping `--enable-automation` changes input+and focus semantics, and newer branded builds re-assert automation indicators when DevTools+attaches regardless. Users watched the banner flicker over their windows all day. CfT is the build+Google ships FOR automation: no banner arms race.++## 2. Extensions: the FORCING reason++Branded Chrome 137 (mid-2025) **removed `--load-extension`** specifically to stop silent+side-loading. Verified live on branded 148 (2026-08-10), every alternative route for the Adom Pup+toolbar failed or was vetoed:++- CDP `Extensions.loadUnpacked` needs a `--remote-debugging-pipe` launch, incompatible with pup's+ detached-launch architecture (windows outlive the bridge and are re-adopted over the port), and+ the install is session-scoped anyway.+- Driving `chrome://extensions` + the native folder dialog puts a file dialog on the user's+ screen. Vetoed.+- The remaining branded paths are a Web Store click per profile, or an enterprise force-install+ policy that brands the browser "managed by your organization".++CfT still honors `--load-extension`, so the toolbar is just a **launch argument**: every window is+born with it pinned, no install step exists. This gap alone justifies the policy.++## 3. Version determinism (with a HARD limit: the pin must track the user's Chrome)++**Updated 2026-09-14, twice, and the second update retracts the first.** Determinism is still what+makes pup's tests reproducible. An earlier version of this section claimed that a stale CfT "reads as+a bot", citing analog.com answering HTTP 403 from AkamaiGHost on CfT 148 and serving the real page on+CfT 152.++**That claim is withdrawn. It was not a controlled comparison.** The CfT 148 trials were raw+`shell_execute` Chrome launches carrying a hand-copied flag list; the CfT 152 result came from pup+itself. Those two differ in far more than the build number (pup passes `--disable-infobars`, a+different feature-disable set and the identity shim; the raw launches visibly showed the automation+infobar). Re-measured with screenshots on 2026-09-14, analog.com rendered fine in pup on CfT 152 on the+adom-you profile. The intended fresh-jar arm was not fresh: before 2.0.659 `isolated:true` kept the adom-you+profile (pup-bridge#112). Since 2.0.659 it is its own extension-lane browser with an empty jar, so the arm can be+re-run; until it is, profile state remains UNTESTED.++**What is actually established:** sites in this class load when driven through pup's own launch path.+Which element of that launch is decisive has NOT been isolated, and it has NOT been shown to be the+CfT version. `typeof bmak === "undefined"` on a denied page is a SYMPTOM, not a cause: the Access+Denied page carries no sensor script.++**The rule:** keep `CFT_BUILD_ID` within about one milestone of the user's installed Chrome as+hygiene, because a large version gap is a plausible signal and costs nothing to avoid. Do NOT present+it to a user as the reason a vendor site loads or fails. pup fetches the pinned build in the+background when it is missing, so a bump does land on the fleet.++## 3. Version determinism++CfT is pinned to an exact build and **has no auto-updater**. Branded Chrome+updates itself under running profiles on Google's schedule, so CDP behavior can drift mid-session+and two windows opened an hour apart can be driving different browser versions. Deterministic+versions are what make pup's ralph tests reproducible and a CDP bug a fixable fact instead of a+moving target. pup owns CfT's update cadence via its own self-heal (`src/chrome.js`).++## 4. None of the consumer chrome++No first-run bubbles, no default-browser nagging, no profile-import prompts, no sign-in/sync+pressure, and no Finch field-trial experiments silently flipping behavior per machine. Branded+Chrome runs server-assigned experiments; CfT gives every machine the same browser.++## 5. Isolation from the user's own browser++A different binary means a different process tree: the user's real Chrome can update, crash, or be+closed without touching a single pup window, and pup's hidden browser app never tangles with+theirs. Profiles were always pup-owned; CfT extends the separation to the binary itself.++## The trade-offs: stated honestly (do not hide these from a user)++- **First-run download** (~160 MB) on a box without CfT cached, `pup_prewarm` does it ahead of+ time, and an installed Google Chrome (or Edge) in /Applications serves as the spawn-verified fallback+ meanwhile, so an+ open never blocks on the fetch.+- The window title, the Dock and Activity Monitor say **"Google Chrome for Testing"**. Expected; the rendered+ window is otherwise identical. Tell a user asking "what is this browser?" exactly that.+- **No Widevine DRM**, protected streaming video will not play in pup. That is an nb/nbe job+ anyway (the user's real browser, the user's real identity).+- A user who explicitly pins branded Chrome (`pup_use {browser:"chrome"}`) keeps everything+ working EXCEPT the toolbar extension and infobar suppression; pup says so when it matters.++## What did NOT change with the switch++Profiles, cookies, and logins live in pup's own userDataDirs, independent of the driving binary.+The shared adom-you credential store, window ownership, background parking, and recovery are all+binary-agnostic; the same profile simply renders under the automation build.++## How the policy is implemented (so nobody "fixes" the code backwards)++The browser crate (`rust/browser`) still lists installed Chrome → Edge → CfT as candidates, **on+purpose: that is the fallback ladder**, not the policy. The pinned/cached CfT default promoted to the+front of the list is what implements CfT-first. A healthy box reports+`browserKind: "chrome-for-testing"` in `pup_readiness`.
skills/pup-windows-sessions-tabs/SKILL.mdadded+152@@ -0,0 +1,152 @@+---+name: pup-windows-sessions-tabs+user-invocable: true+description: "pup (Puppeteer bridge) windows, sessions, and tabs: how sessionIds work and why to task-prefix them, WINDOW OWNERSHIP so you never steal another AI thread's window (owner + session_owned_by_another_thread + takeover), the BACKGROUND-by-default rule (foreground:true is the only way to show a window; sizing/positioning does NOT foreground), running MANY TABS in one window instead of many windows, switching/listing/closing sessions and tabs, and verifying what you actually opened. Read when opening/reusing pup windows, managing tabs, deciding background vs foreground, or seeing errorCode session_owned_by_another_thread. Trigger words: pup sessionId, pup_open_window owner, session_owned_by_another_thread, takeover window, pup background, foreground:true, pup_open_tab, pup_switch_tab, pup_list_tabs, pup_switch_window, pup_list_windows, pup_close_window, pup tabs, many tabs one window, don't steal window, pup_lower_os_window, pup_raise_os_window, pup_alert_window."+---++**Parent skill:** pup-bridge · See also: `pup` (start here), `pup-screenshots-recording`, `pup-browsers-and-chrome`.++# pup — windows, sessions & tabs++A **session** = one browser **window** that holds **many tabs**.+You address everything by `sessionId`. `profile` (defaults to `sessionId`) names the on-disk cookie jar.++## sessionIds — task-prefix them, and pass `owner`++`pup_open_window {sessionId}` either **creates** a window (new id) or **reuses/navigates** an existing+one (id already exists). Because sessions are **shared across every AI thread on this desktop**, a generic+id collides:++- **Task-prefix the id:** `"<your-task>-web"` (e.g. `wikifix-preview`), never `wiki` / `test` / `main`.+- **Always pass `owner:"<your-task>"`.** It's an advisory label that lets pup protect your window.+- Pick a **stable `profile`** across runs if you want the cookie jar to persist (user logs in once, reuse+ for months): `{"sessionId":"vendor-fetch","profile":"vendor","owner":"vendor-fetch", …}`.++## Window ownership — do NOT steal another AI thread's window++If you `pup_open_window` a sessionId that already exists **with a new url**, pup NAVIGATES that window+— destroying whatever another thread was doing there. pup guards this:++1. **Declared cross-owner reuse is REFUSED** — `errorCode: session_owned_by_another_thread` (also on+ `pup_navigate` / `pup_close` / `pup_close_window`). The `_hint` tells you the current owner+ + url + age.+2. **If YOU get that error** — the window isn't yours. Open your OWN with a unique task-prefixed sessionId.+ Pass **`takeover:true`** ONLY if the user explicitly told you to repurpose that window.+3. **Anonymous grabs are allowed but loudly reprimanded** in the `_hint` (with the previous url so you can+ put it back) — so ALWAYS declare `owner` to be a good citizen and to get protection.+4. **Reusing your OWN window is good** — same sessionId + same owner = a smooth refresh (e.g. re-rendering+ a page you just republished). Ownership only blocks *cross-thread* grabs.+5. **Unsure what exists?** `pup_status '{}'` lists every window with its `owner`, current `url`, and+ `ageMinutes`. Run it at the start of a task (especially after a context compaction) before picking an id.++```bash+# right — owned, task-prefixed+adom-bridge-cli pup_open_window '{"sessionId":"wikifix-preview","owner":"wikifix","url":"<url>"}'+# wrong — generic + anonymous: may hijack another thread's window (pup reprimands you in _hint)+adom-bridge-cli pup_open_window '{"sessionId":"wiki","url":"<url>"}'+```++## Background by default — pup NEVER pops onto the user's screen unless asked++**pup is the AI's workspace, not the user's.** Every window opens **in the background** (pup's browser app is hidden,+the Cmd+H state, so nothing covers the user and no focus is stolen) and stays fully drivable + screenshottable there — you do NOT lower it yourself.++- **`foreground:true` is the ONLY thing that shows a window — and it is GATED (v1.8.70).** Not+ sizing, not navigating, not screenshotting, not recording setup. Foregrounding requires BOTH+ `foreground:true` AND `foregroundReason:"<quote/paraphrase what the user said>"` (min 10 chars).+ Without the reason the open still succeeds but the window stays BACKGROUND+ (`foregroundDenied:'foreground_reason_required'`). The reason is logged, recorded in+ `lastAgentUpdate`, and surfaced as `lastForeground` in status — the user can always audit who+ put a window on their screen and why. Pass it ONLY when the user explicitly wants to WATCH (a+ demo, a "show me", a live recording); after the show-me, `pup_lower_os_window` to return it.+- **Sizing/positioning does NOT foreground** — `pup_open_window {width,height,x,y}` places the window+ at an exact size/spot **for clean screenshot framing** while it stays in the background. So you can frame+ a shot without ever disturbing the user's main work.+- **`pup_alert_window`** posts a macOS notification the user can click (a non-intrusive "it's+ ready/done" nudge that never takes focus) — prefer it over raising the window. Use it for an explicit end-of-task "it's ready".+- `pup_raise_os_window` / `pup_focus_window` bring a window forward — **GATED the same way:+ REFUSED (`foreground_reason_required`) unless you pass `foregroundReason`.** Use only for an+ explicit user show-me; `pup_lower_os_window` tucks it back after. **Raise steals focus and can+ leak a mid-typed keystroke into the page** (pup blurs the page's focused element first to reduce+ it) — to just signal "I updated this window", use `updateNote` / `pup_alert_window`, NOT raise.++## Signalling what you changed — `updateNote` (so the user follows along)++When you drive a background window the user can't tell you touched it — and across several tabs they lose+track of WHICH one you just changed. macOS has no taskbar button to flash, so say it in the record:++- A pure read should pass `pup_eval {"readOnly":true}` so a polling loop doesn't count as an update.+- **Say what you did:** pass **`updateNote":"<short what-I-did>"`** on any mutating verb. pup records+ `lastAgentUpdate {tabId, verb, note, ts}` and surfaces it **top-level and per-tab** in+ `pup_list_tabs` / `pup_list_windows` — so the user (or a Session Monitor) sees which tab you+ staged and why. Add an `updateNote` on the meaningful steps of a multi-tab task.++```bash+# default: background — drive it invisibly, user never sees a thing+adom-bridge-cli pup_open_window '{"sessionId":"job-web","owner":"job","url":"<url>"}'+adom-bridge-cli pup_screenshot '{"sessionId":"job-web"}'+# only if the user asked to SEE it:+adom-bridge-cli pup_open_window '{"sessionId":"demo-web","owner":"demo","url":"<url>","foreground":true}'+```++## One window, many tabs (multi-URL tasks)++For a vendor fetch / multi-doc review, put the URLs in **tabs of one window**, not separate windows:++```bash+adom-bridge-cli pup_open_window '{"sessionId":"task-web","owner":"task","url":"https://first.example.com"}'+adom-bridge-cli pup_open_tab '{"sessionId":"task-web","url":"https://second.example.com"}' # → { tabId: "tab-2", tabCount: 2 }+adom-bridge-cli pup_switch_tab '{"sessionId":"task-web","tabId":"tab-2"}'+adom-bridge-cli pup_screenshot '{"sessionId":"task-web","tabId":"tab-2"}' # tabId optional — omit for the active tab+adom-bridge-cli pup_close_tab '{"sessionId":"task-web","tabId":"tab-2"}'+```++- `pup_open_tab` returns `{tabId, tabCount, activeTabId}`; pass the `tabId` to+ `switch`/`close`/`screenshot`/`eval`/`navigate` (omit to target the active tab).+- **Closing the last tab closes the window.**+- Popup tabs (e.g. a PDF the page opens) auto-attach and appear in `pup_list_tabs`; a closed popup's+ last URL is retained so you can `pup_fetch_url` it if you missed it.++## Don't double-open; reuse correctly++- New session entirely → `pup_open_window`.+- Same session, new page in the same tab → `pup_navigate` (with `tabId` if multiple tabs).+- A second view in the same session → `pup_open_tab` (NOT a second window).+- Switch which window is "active" → `pup_switch_window`; list them → `pup_list_windows`.++Opening the same content under a new `sessionId` leaves the user staring at two identical windows — so+`pup_status` first and reuse when the URL is already open.++## Clean up after yourself — close windows when the task is done++pup **never auto-closes** a window (a "stale" one might belong to a thread that's just paused+between turns). So every window you open lingers on the **user's real desktop** until *you* close it.+Left alone, a day of tasks piles up a dozen dead pup windows the user has to find and close.++- **When you finish a task, `pup_close_window` the windows you opened for it.** Same for one-off+ windows (a quick screenshot/scrape) — close them right after.+- **Check what's yours:** `pup_status` lists every window with its `owner` and `ageMinutes`, and+ now nudges you (a `🧹 CLEANUP` hint) when windows are old (≥30 min) or piling up. Act on it — close+ the ones you're done with. Only close windows **you own**; never close another thread's.+- **Long-running work?** Reuse ONE window (`pup_navigate` / `pup_open_tab`) instead of opening+ a fresh window each step, so there's only one thing to clean up.+- If you truly need a window to persist across turns (a login you'll reuse for hours), that's fine —+ but it's a deliberate choice, and you still own closing it when done.++## Verify what you opened — `ok:true` is not enough++`pup_open_window` returns `ok:true` as soon as navigation *starts*, not when content rendered. Confirm+with `pup_eval '{"sessionId":"…","expr":"document.title"}'` (fastest — 404s, login walls, anti-bot+blocks all change the title) or a `pup_screenshot` when the title isn't discriminating.++## Verbs at a glance++| Verb | Does |+|---|---|+| `pup_open_window` | Create (or reuse) a session/window. Args: `sessionId`, `owner`, `url?`, `profile?`, `foreground?`, `width/height/x/y?`, `takeover?`, `updateNote?` |+| `pup_navigate` | Drive an existing tab to a URL (no new window) — `sessionId`, `url`, `tabId?` |+| `pup_open_tab` / `pup_switch_tab` / `pup_close_tab` / `pup_list_tabs` | Tabs within one window |+| `pup_switch_window` / `pup_list_windows` | Multiple sessions |+| `pup_status` | List every session + `owner`/`url`/`ageMinutes` (NOT a readiness check) |+| `pup_lower_os_window` / `pup_raise_os_window` / `pup_focus_window` / `pup_alert_window` | Hide / show / focus / notify |+| `pup_close_window` (one session) / `pup_close` (all) | Close — destructive |
tools/pup-dashboardadded+573@@ -0,0 +1,573 @@+#!/usr/bin/env node+/* pup-dashboard: the container-side status page behind pup's Dock Bar card (adom/pup-bridge#66).+ *+ * Shape follows adom/hydrogen-dock-sdk and the KiCad/Fusion reference bridges. The three rules in+ * the SDK exist because they broke BOTH other bridges' first attempts, so they are honored here+ * explicitly rather than discovered again:+ *+ * 1. serve SELF-BACKGROUNDS: print the URL, fork, exit 0. Hydrogen waits for the step to exit;+ * a foreground server is killed at the timeout and its URL is dead before the readiness probe.+ * 2. PATH: wiki CLIs land in ~/.local/bin, which is not on the exec shell's PATH. Every child+ * process here gets it prepended, so "works on my machine" cannot happen.+ * 3. serve is IDEMPOTENT: Hydrogen runs it on EVERY launch. If an instance is already up, detect+ * it, print ITS url, exit 0: never a second server, never a port fight.+ *+ * /api/status returns the SDK payload: per-LED {label, state, detail, _hint} plus a TOP-LEVEL+ * rollup state. It is CACHED and internally timed out: adom/fusion-bridge#37 is the cautionary+ * tale: a 3.1s status against a declared 2000ms budget reads to the dock as a grey LED.+ */+'use strict';+const http = require('http');+const fs = require('fs');+const os = require('os');+const path = require('path');+const { execFile } = require('child_process');++const HOME = os.homedir();+const BIN = path.join(HOME, '.local', 'bin');+// rule 2 (PATH), plus caller identity: pup REFUSES verbs from an unnamed caller (supplyIdentity),+// so a dashboard that omits ADOM_AI_THREAD gets an error back and paints a false "not installed".+const ENV = { ...process.env, PATH: `${BIN}:${process.env.PATH || ''}`, ADOM_AI_THREAD: process.env.ADOM_AI_THREAD || 'pup dashboard' };+const STATE = path.join(os.tmpdir(), 'pup-dashboard.state.json');+// Dock contract: version-aware reuse + webview takeover. TOOL_VERSION is the skillpack version this+// file shipped in (package.json sits one level up in the installed module), published in the state+// file and in /api/status as dashboardVersion. A running instance whose version differs from ours+// is restarted rather than reused: version-blind reuse pinned fusion's users to a stale server.+// "Say it" (John via the Dock Bar guide): teach users to ASK for pup by its noun. The phrases live+// in dockbar.json (the dock shows them on the interstitial and the card hover); the dashboard shows+// the same list at the very top, copy on click, and "Try it" stages one into the app's own thread.+let SAY = ['open your dashboard in pup', 'open my wiki page in pup', 'open my app in pup', 'screenshot this site in pup', 'record a clip of this page in pup', 'show me the logged-out view in pup'];+try { const dj = require(path.join(__dirname, '..', 'dockbar.json')); if (Array.isArray(dj.say) && dj.say.length) SAY = dj.say.slice(0, 6); } catch (e) {}+// Hydrogen control API (local ah; reachable from WSL2 under mirrored networking): find this app's+// thread by its app tag, then stage (never submit) a prompt into it.+let HYDROGEN_CTL = 'http://127.0.0.1:47084';+try { const u = fs.readFileSync(path.join(os.homedir(), '.adom', 'hydrogen-control-url'), 'utf8').trim(); if (u) HYDROGEN_CTL = u.replace(/\/$/, ''); } catch (e) {}+function hget(urlPath) {+ return new Promise((resolve) => {+ const req = http.get(HYDROGEN_CTL + urlPath, { timeout: 2500 }, (res) => { let b = ''; res.on('data', d => { b += d; }); res.on('end', () => { try { resolve({ status: res.statusCode, json: JSON.parse(b) }); } catch (e) { resolve({ status: res.statusCode, json: null }); } }); });+ req.on('error', (e) => resolve({ status: 0, error: e.message })); req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });+ });+}+function hpost(urlPath, body, timeoutMs) {+ return new Promise((resolve) => {+ const data = JSON.stringify(body);+ const req = http.request(HYDROGEN_CTL + urlPath, { method: 'POST', timeout: timeoutMs || 4000, headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } }, (res) => { let b = ''; res.on('data', d => { b += d; }); res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve({ status: res.statusCode, json: j, raw: b.slice(0, 300) }); }); });+ req.on('error', (e) => resolve({ status: 0, error: e.message })); req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });+ req.end(data);+ });+}+// Thread lookup against the live Hydrogen control API (shapes measured by the dock thread,+// 2026-08-23, now in the Dock Bar guide):+// CENSUS (open threads) GET /ai-threads -> {ok, groups:[{threads:[{tid, type, label, sid|null, tags:[...]}]}]}+// HISTORY (closed) GET /ai-threads/history?tag=... -> {ok, items:[{ai, id, title, updated_ms}], open_excluded}+// Census FIRST: history EXCLUDES open threads by design, so history-first comes back empty exactly+// when the thread is open. A closed thread is resumed (POST /ai-threads/resume {ai,id}) before staging.+const APP_TAG = 'app:adom/pup-bridge';+function censusThread(j) {+ for (const g of (j && j.groups) || []) for (const t of (g && g.threads) || []) {+ if (t && Array.isArray(t.tags) && t.tags.includes(APP_TAG)) return t;+ }+ return null;+}+// say-strip "Send to AI" (John's 2026-08-24 ruling): open a NEW AI thread named for the app,+// tagged dock:pup-bridge, and SUBMIT the phrase. New threads ONLY; never resume/target an existing+// tid. On any failure the page falls back to copying, so the user is never stranded.+//+// ONE CALL, NOT TWO (corrected 2026-08-24 against the authoritative hydrogen-ai-threads-api skill,+// after John pressed Send and got a tab with no prompt in it). The old code did+// POST /ai-threads/new -> read sid -> POST /ai-threads/run-prompt {sid}. That can NEVER work:+//+// "THE RESPONSE CARRIES NO THREAD ID, EVER (sid: null, tid: null, explicit since 1.0.143): a new+// thread has no identity until its first submitted prompt ... Do NOT open-then-target: `new`+// followed by `run-prompt {sid}` always fails because there is no sid to pass."+//+// So /new always opened the tab (which is why John SAW a thread appear) and the second call always+// bailed on the missing sid. The composite below opens the thread AND delivers the prompt in one+// request; `name` and `tags` stage exactly as they do on /new and attach when the sid appears.+// 2026-09-03 (dock-bar broadcast, Hydrogen 1.0.187+ Send-to-AI contract): the container-side+// send path (sendToNewThread -> /ai-threads/run-prompt, verified via delivered_to) is REMOVED. On+// 1.0.187+ delivered_to is never filled, so this path would have called every successful send a+// failure. Send to AI lives in the bridge-served dashboard (newbie-panel.js 2.22.0, job/state+// contract, polled through the bridge's /ai-threads/run-prompt/status proxy). The routes below+// answer 410 so a stale caller gets a pointer instead of a phantom failure.+// RELEASE-VERSION GATE, on the ONE endpoint that actually carries a release version.+// Corrected twice, so the record matters:+// build_sha - LIES on incremental builds (the build thread had to verify by code marker).+// build_epoch - ACTIVELY HARMFUL as a gate: it pins the feature to one machine's build clock,+// so every user on a stock signed release (older epoch) is refused forever.+// /version, /health, /buildinfo, page globals - carry NO release version (probed 2026-08-24).+// GET /updater/status -> updater.current_version - DOES. Measured on AdomLapper: "1.0.142".+// The gate exists because a pre-1.0.142 shell still carries the crash family that took John's+// session out this morning, and the user on a stock old release is exactly the person least able+// to diagnose it. UNREADABLE FAILS OPEN: refusing on a field we cannot read would block legitimate+// users, and the send path already fails honestly (toast + copy) when the shell cannot serve it.+const SEND_MIN_RELEASE = [1, 0, 142];+function cmpver(a, b) {+ for (let i = 0; i < 3; i++) { const d = (a[i] || 0) - (b[i] || 0); if (d) return d < 0 ? -1 : 1; }+ return 0;+}+let _shellGate = { at: 0, ok: true, why: '' };+async function shellSupportsSend() {+ if (Date.now() - _shellGate.at < 60000) return _shellGate;+ const r = await hget('/updater/status');+ const b = (r && r.json) || null;+ const u = (b && (b.updater || b)) || null;+ const raw = u && typeof u.current_version === 'string' ? u.current_version.trim() : '';+ const m = raw.match(/^(\d+)\.(\d+)\.(\d+)/);+ let out;+ if (!m) out = { ok: true, why: '' }; // endpoint down or version unparseable: FAIL OPEN+ else if (cmpver([+m[1], +m[2], +m[3]], SEND_MIN_RELEASE) < 0) {+ out = { ok: false, why: 'Your Hydrogen (' + raw + ') predates the Send to AI fix. Update to 1.0.142 or newer to enable it; the phrase was copied instead.' };+ } else out = { ok: true, why: '' };+ _shellGate = { at: Date.now(), ...out };+ return _shellGate;+}++let TOOL_VERSION = '0';+try { TOOL_VERSION = String(require(path.join(__dirname, '..', 'package.json')).version || '0'); } catch (e) {}+const DOCK_WEBVIEW = process.env.ADOM_DOCK_WEBVIEW || '';+const DOCK_APP = process.env.ADOM_DOCK_APP || '';+// Once the dashboard is serving, take over the tab the dock pre-opened (named ADOM_DOCK_WEBVIEW):+// navigate it to our direct URL and own it from then on. Skipped when the var is unset (hand-run).+function takeOverDockWebview(url) {+ if (!DOCK_WEBVIEW) return;+ try {+ const { spawn } = require('child_process');+ const c = spawn('adom-cli', ['hydrogen', 'webview', 'open-or-refresh', '--name', DOCK_WEBVIEW, '--url', url],+ { detached: true, stdio: 'ignore', env: ENV });+ c.on('error', () => {});+ c.unref();+ } catch (e) {}+}+function instanceVersion(port) {+ return new Promise((resolve) => {+ const req = http.get({ host: '127.0.0.1', port, path: '/api/status', timeout: 1500 }, (res) => {+ let b = ''; res.on('data', d => { b += d; }); res.on('end', () => { try { resolve(String(JSON.parse(b).dashboardVersion || '')); } catch (e) { resolve(''); } });+ });+ req.on('error', () => resolve('')); req.on('timeout', () => { req.destroy(); resolve(''); });+ });+}+// Dock contract (ah build thread, 2026-08-23): print the DIRECT loopback form. The dock's+// readiness gate consumes this URL from a local WSL2 workspace with mirrored networking,+// where the 'localhost' NAME resolves IPv6-first and costs ~800ms; 127.0.0.1 is instant.+// The :7380/proxy/<port>/ hop is unnecessary for a local consumer.+// THE dashboard URL (v2.0.337). John: "we can't have pup having 2 different dashboards."+// This helper used to SERVE its own launch page and hand the dock that URL, so the dock opened a+// header, six prompts and four pills while pup's real UI, the live-windows dashboard with the+// taskbar strip, window tree, icon provenance and ownership badges, sat undiscovered on the+// bridge's own port. The species rule is that a full app's MAIN UI is the dock target, so the dock+// now gets the bridge's dashboard and this process serves no page at all.+// The bridge listens on the DESKTOP; his container reaches it fine (measured: curl from inside the+// container to 127.0.0.1:8851/health returned 200 under mirrored networking), and so does the+// Hydrogen shell, so ONE url works for the webview AND for the dock's health poll.+const BRIDGE_PORT = Number(process.env.PUP_BRIDGE_PORT || 8851);+const BRIDGE_DASHBOARD = `http://127.0.0.1:${BRIDGE_PORT}/dashboard`;+const PROXY = () => BRIDGE_DASHBOARD;++// ── the box's pup bridge, via the ab CLI ────────────────────────────────────────────────────────+// Which desktop are we reporting on? A target-less call FAILS outright when more than one Adom+// Bridge is connected ("you didn't say which"), and that failure looked identical to "Chrome not+// installed" in the first cut. Resolve once, cache, and name the box in the payload so a two-desktop+// user can see which one the lights describe.+let _target = { at: 0, name: null, all: [] };+async function resolveTarget() {+ if (process.env.ADOM_DESKTOP_TARGET) return { name: process.env.ADOM_DESKTOP_TARGET, all: [process.env.ADOM_DESKTOP_TARGET], at: Date.now() };+ if (_target.name && Date.now() - _target.at < 60000) return _target;+ const d = await new Promise((resolve) => {+ execFile('adom-bridge', ['targets'], { env: ENV, timeout: 8000 }, (err, stdout) => {+ const raw = String(stdout || ''); const i = raw.indexOf('{');+ if (i < 0) return resolve({});+ try { resolve(JSON.parse(raw.slice(i))); } catch (e) { resolve({}); }+ });+ });+ const all = (d.targets || []).map(t => t.name).filter(Boolean);+ _target = { at: Date.now(), name: all[0] || null, all };+ return _target;+}++function ab(verb, args, timeoutMs, target) {+ return new Promise((resolve) => {+ const argv = target ? ['--target', target, verb, JSON.stringify(args || {})] : [verb, JSON.stringify(args || {})];+ const child = execFile('adom-bridge', argv,+ { env: ENV, timeout: timeoutMs, maxBuffer: 1 << 22 },+ (err, stdout) => {+ const raw = String(stdout || '');+ const i = raw.indexOf('{');+ if (i < 0) return resolve({ _err: err ? String(err.message).slice(0, 120) : 'no json' });+ try {+ let d = JSON.parse(raw.slice(i));+ if (typeof d.output === 'string' && d.output.trim().startsWith('{')) {+ try { d = { ...d, ...JSON.parse(d.output) }; } catch (e) {}+ }+ resolve(d);+ } catch (e) { resolve({ _err: 'unparseable' }); }+ });+ child.on('error', () => resolve({ _err: 'adom-bridge not found on PATH' }));+ });+}++// ── status, cached ──────────────────────────────────────────────────────────────────────────────+let _cache = { at: 0, body: null };+const CACHE_MS = 10000;+const BUDGET_MS = 1800; // under the manifest's declared 2000ms timeout, on purpose++async function buildStatus() {+ if (_cache.body && Date.now() - _cache.at < CACHE_MS) return _cache.body;++ const tgt = await resolveTarget();+ const [ready, status] = await Promise.all([+ ab('pup_readiness', {}, BUDGET_MS, tgt.name),+ ab('pup_status', {}, BUDGET_MS, tgt.name),+ ]);++ const leds = [];+ // An ERROR payload is not a healthy answer. pup returns {error, errorCode} in-band, so treating+ // "we got JSON back" as success is how a refused call paints a green-ish card.+ const bad = (d) => !!(d._err || d.error || d.errorCode || d.success === false);+ const offline = !tgt.name || (bad(ready) && bad(status));++ // Chrome for Testing: John's named example: the "ready for action" light.+ const cft = ready.chrome || ready.chromeForTesting || {};+ const cftOk = !bad(ready) && (ready.ready === true || ready.ok === true || cft.installed === true);+ leds.push({+ key: 'chrome',+ label: 'Chrome for Testing',+ state: offline ? 'fail' : (cftOk ? 'ok' : 'warn'),+ detail: offline ? (tgt.name ? 'the desktop bridge is not answering' : 'no Adom Bridge is connected')+ : (cftOk ? (cft.version ? `installed, pinned ${cft.version}` : 'installed and pinned')+ : (bad(ready) ? `readiness could not be read (${ready.error || ready.errorCode || ready._err})` : (ready.reason || 'not installed yet'))),+ short: offline ? 'bridge offline' : (cftOk ? 'installed' : 'not installed'),+ _hint: offline ? 'Start Adom Bridge on the desktop, then reload this page.'+ : (cftOk ? null : 'Run pup_prewarm to install and warm Chrome for Testing before the first window.'),+ });++ // Bridge: is the pup verb surface answering at all+ leds.push({+ key: 'bridge',+ label: 'Bridge',+ state: bad(status) ? 'fail' : 'ok',+ detail: bad(status) ? `pup_* verbs not answering (${status._err || status.error || status.errorCode})`+ : `pup ${status.version || '?'} answering on ${tgt.name}${tgt.all.length > 1 ? ` (of ${tgt.all.length} desktops: ${tgt.all.join(', ')})` : ''}`,+ short: bad(status) ? 'not answering' : 'answering',+ _hint: bad(status) ? 'Check Adom Bridge is running on the desktop and the puppeteer bridge is installed (bridge_install).' : null,+ });++ // Windows: what pup is doing right now+ const sessions = Array.isArray(status.sessions) ? status.sessions : [];+ const tabs = sessions.reduce((n, s) => n + (Array.isArray(s.tabs) ? s.tabs.length : 0), 0);+ leds.push({+ key: 'windows',+ label: 'Windows',+ state: bad(status) ? 'warn' : 'ok',+ detail: bad(status) ? 'unknown' : (sessions.length+ ? `${sessions.length} window(s), ${tabs} tab(s): ` + sessions.map(s => `${s.sessionId}${s.owner ? ` (${s.owner})` : ''}`).join(', ')+ : 'no pup windows open right now'),+ short: bad(status) ? 'unknown' : (sessions.length ? `${sessions.length} open` : 'none open'),+ _hint: bad(status) ? null : 'Ask your AI to open one: pup_open_window {url}. It opens in the BACKGROUND and never steals your screen.',+ sessions: sessions.map(s => ({ sessionId: s.sessionId, owner: s.owner || null, tabs: Array.isArray(s.tabs) ? s.tabs.length : null })),+ });++ // Profile: the durable jar that makes logins persist+ const prof = (status.settings && status.settings.profile) || null;+ leds.push({+ key: 'profile',+ label: 'Profile',+ state: bad(status) ? 'warn' : 'ok',+ detail: bad(status) ? 'unknown' : `durable profile "${prof || 'adom-you'}": logins persist across windows`,+ short: bad(status) ? 'unknown' : (prof || 'adom-you'),+ _hint: bad(status) ? null : 'Isolated throwaway instead: pup_open_window {isolated:true}.',+ });++ // TOP-LEVEL rollup: OUR call, so a warn facet does not yellow the card unless we mean it.+ // Only a dead bridge is a red card; "Chrome not installed yet" is a real warn (it self-heals on+ // first use), and "no windows open" is perfectly normal and must never colour the card.+ const bridgeLed = leds.find(l => l.key === 'bridge');+ const chromeLed = leds.find(l => l.key === 'chrome');+ const state = bridgeLed.state === 'fail' ? 'fail' : (chromeLed.state === 'ok' ? 'ok' : 'warn');++ const body = {+ state,+ label: 'pup',+ detail: state === 'ok' ? 'ready for action' : (state === 'fail' ? 'desktop bridge not answering' : 'ready after a one-time Chrome install'),+ version: status.version || null,+ dashboardVersion: TOOL_VERSION,+ desktop: tgt.name || null,+ desktops: tgt.all,+ leds,+ generated_at: new Date().toISOString(),+ };+ _cache = { at: Date.now(), body };+ return body;+}++// The launch page (its PAGE template and the header/newbie/pill CSS that went with it) was+// DELETED in 2.0.337 when the dock moved to the bridge's live-windows dashboard. The+// doubled-escape warning that lived here moved with the markup, into DASHBOARD_HTML in+// src/server.js, because that is the template literal it now applies to.+++// ── demo ────────────────────────────────────────────────────────────────────────────────────────+async function demoTarget() {+ // v2.0.318 (John ran the demo and saw NOTHING: with two desktops connected the demo drove the+ // FIRST target, which was the other machine). The user is at the desktop they just clicked+ // "Run the demo" on, and that machine's input-idle is seconds while the other's is minutes:+ // pick the target with the LOWEST desktop_user_idle_ms.+ const t = await resolveTarget();+ if (!t.all || t.all.length <= 1) return t;+ const idles = await Promise.all(t.all.map(async (name) => {+ const r = await ab('desktop_user_idle_ms', {}, 6000, name);+ const ms = Number(r.idleMs != null ? r.idleMs : (r.ms != null ? r.ms : NaN));+ return { name, idle: Number.isFinite(ms) ? ms : Infinity };+ }));+ idles.sort((a, b) => a.idle - b.idle);+ return { name: idles[0].name, all: t.all, at: Date.now(), idles };+}++// ── Controllable demo (John: "a play/pause/next/prev/exit ... easy way to stop", "pre-create the+// mp3 audio files so you don't generate them on the fly"). The demo is a list of STEPS; a stateful+// runner plays them and honors pause / next / prev / stop between and inside steps. Narration audio+// is pre-rendered to a cache at serve time so playback is instant. State + controls are exposed at+// /api/demo/{start,control,state}; the dashboard shows a transport bar while it runs.+const DEMO_AUDIO_DIR = path.join(os.tmpdir(), 'pup-demo-audio');+let demo = null; // { running, paused, idx, total, title, target, abort, jump, log, startedAt }++function demoSteps(sid) {+ // Each: { title, say, run(ctx) }. ctx: { ab, sid, tgt, set(k,v), get(k) }. Narration is the `say`+ // string (spoken + captioned by the runner before run()); keep run() to the ACTION.+ return [+ { title: 'Intro', say: 'Hi, this is the pup demo: an A I browser living on your desktop. I will narrate everything I do. You can pause, skip, or stop me any time from the controls.', run: async () => {} },+ { title: 'Open in background', say: 'Opening a window in the BACKGROUND: nothing pops over your work. Look at your taskbar for a new teal button wearing my thread name, with the page icon badged on it.',+ run: async (c) => { const o = await c.ab('pup_open_window', { sessionId: sid, url: 'https://wiki.adom.inc/adom/pup-bridge', owner: 'pup dock demo' }, 120000); if (o._err) throw new Error('open failed: ' + o._err); } },+ { title: 'Progress bar', say: 'Watch that button: I am driving its PROGRESS BAR by hand. pup runs one of these for the length of ANY verb or long operation, sized to how long it usually takes.',+ run: async (c) => { let hwnd = null; for (let i = 0; i < 4 && !hwnd; i++) { const wi = await c.ab('pup_window_info', { sessionId: sid }, 20000); hwnd = wi && wi.hwnd; if (!hwnd) await c.sleep(1200); } c.set('hwnd', hwnd); if (!hwnd) return; for (const v of [15, 40, 65, 85, 100]) { if (c.stopping()) break; await c.ab('desktop_taskbar', { hwnd, progress: { state: 'normal', value: v } }, 8000); await c.sleep(500); } await c.ab('desktop_taskbar', { hwnd, progress: { state: 'none' } }, 8000); } },+ { title: 'Foreground (captioned)', say: 'Bringing the window up so you can watch. pup always captions a foreground first: who, and why. Here it comes.',+ run: async (c) => { await c.ab('pup_raise_os_window', { sessionId: sid, foregroundReason: 'the pup demo you started: bringing the window up so you can watch me drive it' }, 30000); await c.sleep(900); } },+ { title: 'The cursor', say: 'That teal cursor is me: it glides to where I am about to act and says why.',+ run: async (c) => { await c.ab('pup_hover', { sessionId: sid, x: 700, y: 300, intent: 'this is my cursor', steps: 16 }, 20000); } },+ { title: 'Toolbar tiles', say: 'Top right, next to the address bar: three teal tiles. The pup dots are my menu. The pencil starts annotation. The avatar is your adom you identity: this window is signed in as YOU.',+ run: async (c) => { await c.ab('pup_hover', { sessionId: sid, x: 1180, y: 8, intent: 'look UP at the toolbar', steps: 14 }, 20000); } },+ { title: 'Screenshot', say: 'Screenshot now: the same progress bar runs on its own while I capture, even with the window buried in the background.',+ run: async (c) => { const sh = await c.ab('pup_screenshot', { sessionId: sid }, 60000); c.log('screenshot: ' + (sh._err ? sh._err : (sh.width + 'x' + sh.height))); } },+ { title: 'Annotate (feedback)', say: 'Now why annotation exists: FEEDBACK to your A I. Circle the thing you mean, and the capture goes to the A I thread that owns this window, and onto your clipboard for any A I chat. Watch.',+ run: async (c) => { const arm = await c.ab('pup_annotate', { sessionId: sid, tool: 'pen' }, 30000); if (arm._err) { c.log('annotate did not arm: ' + arm._err); return; } const draw = "(async()=>{const r=document.getElementById('__pup_annotate');if(!r)return 'no overlay';const cv=r.querySelector('canvas');if(!cv)return 'no canvas';const mk=(t,x,y)=>new PointerEvent(t,{bubbles:true,cancelable:true,clientX:x,clientY:y,pointerId:1,isPrimary:true,button:0,buttons:t==='pointerup'?0:1});const cx=Math.round(innerWidth*0.35),cy=Math.round(innerHeight*0.4),rad=Math.min(150,Math.round(innerHeight*0.2));cv.dispatchEvent(mk('pointerdown',cx+rad,cy));for(let a=0.12;a<=6.55;a+=0.12){cv.dispatchEvent(mk('pointermove',cx+rad*Math.cos(a),cy+rad*Math.sin(a)));await new Promise(q=>setTimeout(q,55));}cv.dispatchEvent(mk('pointerup',cx+rad,cy));return 'drew';})()"; const dr = await c.ab('pup_evaluate', { sessionId: sid, expression: draw }, 40000); c.log('draw: ' + (dr._err || dr.result || '?')); } },+ { title: 'Capture landed', say: 'Pen up: captured. It is on your clipboard with a strip naming this thread and page, and the owning A I thread sees it. Your A I now knows exactly what you meant.', run: async () => {} },+ { title: 'Jump list', say: 'Every pup button has a right-click menu. Opening it for you now, watch the bottom of your screen.',+ run: async (c) => { const uia = "Add-Type -AssemblyName UIAutomationClient; $tb=[System.Windows.Automation.AutomationElement]::RootElement.FindFirst('Children',(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty,'Shell_TrayWnd'))); $btn=$tb.FindAll('Descendants',(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty,[System.Windows.Automation.ControlType]::Button))) | Where-Object { $_.Current.Name -like '*pup dock demo*' } | Select-Object -First 1; if ($btn) { $r=$btn.Current.BoundingRectangle; [int]($r.X+$r.Width/2); [int]($r.Y+$r.Height/2) }"; const rc = await c.ab('desktop_run_script', { language: 'powershell', body: uia, timeoutSeconds: 30 }, 35000); const nums = String(rc.stdout || '').trim().split(/\r?\n/).map(Number).filter(Number.isFinite); if (nums.length >= 2) { await c.ab('desktop_right_click', { x: nums[0], y: nums[1] }, 15000); await c.sleep(1100); await c.ab('desktop_right_click', { x: nums[0], y: nums[1] }, 15000); await c.sleep(2200); await c.ab('desktop_press_key', { key: 'Escape' }, 10000); c.log('opened jump list'); } else c.log('jump-list rect not found'); } },+ { title: 'Cleanup + close', say: 'And when I am done, I clean up: cursor off, and closing the window now. Watch the button leave your taskbar. That is pup. Say, show me in pup, to any Adom A I.',+ run: async (c) => { await c.ab('pup_evaluate', { sessionId: sid, expression: "['__adom_cursor__','__pupCursor','__adom_cursor_ring__','__adom_cursor_label__'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});1" }, 15000).catch(() => {}); await c.ab('pup_close_window', { sessionId: sid }, 30000); c.log('closed the demo window'); } },+ ];+}++function audioFileFor(text) {+ let h = 0; for (let i = 0; i < text.length; i++) { h = (h * 31 + text.charCodeAt(i)) | 0; }+ return path.join(DEMO_AUDIO_DIR, 'a' + (h >>> 0).toString(16) + '.mp3');+}+function renderLine(text) {+ const f = audioFileFor(text);+ if (fs.existsSync(f)) return Promise.resolve(f);+ return new Promise((resolve) => {+ execFile('adom-tts', ['say', text, '--out', f, '--thread', 'pup demo', '--desc', 'pup demo narration'], { env: ENV, timeout: 45000 }, (err) => resolve(err ? null : f));+ });+}+async function preRenderDemoAudio() { // warm the cache at serve time so playback never generates on the fly+ try { fs.mkdirSync(DEMO_AUDIO_DIR, { recursive: true }); } catch (e) {}+ for (const st of demoSteps('pup-dock-demo')) { await renderLine(st.say); }+}+let _demoAudioChild = null;+function stopDemoAudio() { const c = _demoAudioChild; _demoAudioChild = null; if (c) { try { c.kill(); } catch (e) {} } }+function playLine(text) { // play the (already-rendered) mp3 on the shared player; returns when it finishes+ return new Promise((resolve) => {+ const c = execFile('adom-tts', ['say', text, '--out', audioFileFor(text), '--thread', 'pup demo', '--desc', 'pup demo narration', '--play'], { env: ENV, timeout: 40000 }, () => { if (_demoAudioChild === c) _demoAudioChild = null; resolve(); });+ _demoAudioChild = c;+ });+}++async function runDemoControllable(opts) {+ const sid = 'pup-dock-demo';+ const startMuted = !!(opts && opts.muted);+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));+ const tgt = await demoTarget();+ const steps = demoSteps(sid);+ demo = { running: true, paused: false, idx: 0, total: steps.length, title: steps[0].title, target: tgt.name, abort: false, jump: null, log: [], startedAt: Date.now(), muted: startMuted };+ await preRenderDemoAudio(); // fast if already warm+ const store = {};+ const capId = 'pup-demo';+ const ctx = {+ sid, tgt, sleep,+ ab: (verb, a, t) => ab(verb, a, t, tgt.name),+ set: (k, v) => { store[k] = v; }, get: (k) => store[k],+ log: (m) => demo.log.push(m),+ stopping: () => demo.abort || demo.jump != null,+ };+ // abort/jump-aware sleep so controls feel instant+ ctx.sleep = async (ms) => { const end = Date.now() + ms; while (Date.now() < end) { if (demo.abort || demo.jump != null) return; while (demo.paused && !demo.abort) await sleep(120); await sleep(Math.min(120, Math.max(1, end - Date.now()))); } };++ const stepBanner = () => { const s = steps[demo.idx]; return (demo.idx + 1) + '/' + steps.length + ': ' + s.title; };+ while (demo.idx >= 0 && demo.idx < steps.length && !demo.abort) {+ while (demo.paused && !demo.abort) { await sleep(150); }+ if (demo.abort) break;+ const step = steps[demo.idx];+ demo.title = step.title; demo.jump = null;+ // narrate: caption + spoken audio together (the pre-rendered mp3). The audio is RACED against an+ // abort/jump watcher so Stop and Next feel instant: if either fires, kill the clip and move on.+ ab('desktop_caption', { text: step.say, duration: Math.min(12000, Math.max(2500, 900 + step.say.split(/\s+/).length * 350)), id: capId }, 8000, tgt.name).catch(() => {}); // bridge default size/position (dash-demo fleet ruling)+ if (demo.muted) { await Promise.race([ sleep(Math.min(6000, Math.max(1500, step.say.split(/\s+/).length * 240))), (async () => { while (!demo.abort && demo.jump == null) await sleep(120); })() ]); }+ else { await Promise.race([ playLine(step.say), (async () => { while (!demo.abort && demo.jump == null) await sleep(120); stopDemoAudio(); })() ]); }+ if (!demo.abort && demo.jump == null) { try { await step.run(ctx); } catch (e) { demo.log.push('step ' + stepBanner() + ' failed: ' + e.message); } }+ if (demo.abort) break;+ if (demo.jump != null) { demo.idx = Math.max(0, Math.min(steps.length - 1, demo.jump)); demo.jump = null; }+ else demo.idx++;+ }+ const aborted = demo.abort;+ // always clean up the demo window and any lit progress bar+ try { await ab('pup_close_window', { sessionId: sid }, 20000, tgt.name); } catch (e) {}+ try { await ab('desktop_caption', { text: aborted ? 'pup demo stopped.' : 'pup demo done. Say, show me in pup, to any Adom AI.', duration: 3000, id: capId }, 8000, tgt.name); } catch (e) {}+ demo.running = false;+ return { done: true, aborted, log: demo.log };+}++function demoState() {+ if (!demo) return { running: false };+ return { running: demo.running, paused: demo.paused, muted: demo.muted, idx: demo.idx, total: demo.total, title: demo.title, target: demo.target, startedAt: demo.startedAt, log: demo.log.slice(-8) };+}+function demoControl(action) {+ if (!demo || !demo.running) return { ok: false, error: 'no demo is running' };+ if (action === 'pause') demo.paused = true;+ else if (action === 'resume') demo.paused = false;+ else if (action === 'next') { demo.jump = Math.min(demo.total - 1, demo.idx + 1); demo.paused = false; }+ else if (action === 'prev') { demo.jump = Math.max(0, demo.idx - 1); demo.paused = false; }+ else if (action === 'stop') { demo.abort = true; demo.paused = false; try { stopDemoAudio(); } catch (e) {} }+ else if (action === 'mute') { demo.muted = true; try { stopDemoAudio(); } catch (e) {} }+ else if (action === 'unmute') { demo.muted = false; }+ if (action === 'next' || action === 'prev') { try { stopDemoAudio(); } catch (e) {} }+ else return { ok: false, error: 'unknown action' };+ return { ok: true, ...demoState() };+}++// legacy synchronous entry (kept so the old button/callers still work): start + wait.+async function runDemo() { const r = await runDemoControllable(); return (demo && demo.log ? demo.log : []).concat(r.aborted ? ['(stopped)'] : ['(done)']); }++// ── serve ───────────────────────────────────────────────────────────────────────────────────────+function alive(port) {+ return new Promise((resolve) => {+ const req = http.get({ host: '127.0.0.1', port, path: '/api/status', timeout: 1200 }, (res) => {+ res.resume(); resolve(res.statusCode === 200);+ });+ req.on('error', () => resolve(false));+ req.on('timeout', () => { req.destroy(); resolve(false); });+ });+}++async function serve() {+ // rule 3: IDEMPOTENT: an instance already up wins; print ITS url and exit 0.+ try {+ const st = JSON.parse(fs.readFileSync(STATE, 'utf8'));+ if (st.port && await alive(st.port)) {+ const v = await instanceVersion(st.port);+ if (v === TOOL_VERSION) { takeOverDockWebview(PROXY(st.port)); process.stdout.write(PROXY(st.port) + '\n'); process.exit(0); }+ // a running instance of another version: stop it (by the pid it published) and start ours+ process.stderr.write(`pup-dashboard: running instance is ${v || 'unversioned'}, this is ${TOOL_VERSION}; restarting\n`);+ try { if (st.pid) process.kill(st.pid); } catch (e) {}+ try { fs.unlinkSync(STATE); } catch (e) {}+ await new Promise(r => setTimeout(r, 400));+ }+ } catch (e) {}++ // rule 1: SELF-BACKGROUND, done in the only order that is actually correct. The PARENT spawns a+ // detached child, waits for the CHILD to publish the port it really bound, prints THAT url, and+ // exits 0 so Hydrogen's step completes. My first cut had the parent print its own port and then+ // hand off to a child that bound a DIFFERENT one: the printed URL would have been dead on+ // arrival, which is precisely the failure rule 1 exists to prevent.+ if (!process.env.PUP_DASH_CHILD) {+ const { spawn } = require('child_process');+ try { fs.unlinkSync(STATE); } catch (e) {}+ const child = spawn(process.execPath, [__filename, 'serve'],+ { detached: true, stdio: 'ignore', env: { ...ENV, PUP_DASH_CHILD: '1' } });+ child.unref();+ for (let i = 0; i < 60; i++) { // up to ~15s, well inside the 60s step budget+ await new Promise(r => setTimeout(r, 250));+ try {+ const st = JSON.parse(fs.readFileSync(STATE, 'utf8'));+ if (st.port && await alive(st.port)) { takeOverDockWebview(PROXY(st.port)); process.stdout.write(PROXY(st.port) + '\n'); process.exit(0); }+ } catch (e) {}+ }+ process.stderr.write('pup-dashboard: the server did not come up in time\n');+ process.exit(1);+ }++ // child: bind, publish the real port, and live on.+ // Dock contract (ah build thread, 2026-08-23): the dock's health probe is a CROSS-ORIGIN fetch+ // from the Hydrogen shell page, so without CORS the browser discards a perfectly good 200 and the+ // card's LED reads red "not answering". Every /api/* and /health answer carries+ // Access-Control-Allow-Origin: * (same as kicad 1.0.45 / fusion-dashboard), and OPTIONS preflight+ // is answered for the POST endpoints.+ const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type' };+ const server = http.createServer(async (req, res) => {+ const u = (req.url || '/').split('?')[0].replace(/\/+$/, '') || '/';+ const isApi = u.includes('/api/') || u.endsWith('/health');+ if (isApi && req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }+ if (u.endsWith('/api/status') || u.endsWith('/health')) {+ let body; try { body = await buildStatus(); }+ catch (e) { body = { state: 'fail', label: 'pup', detail: 'status build failed: ' + e.message, leds: [] }; }+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS });+ return res.end(JSON.stringify(body));+ }+ if (u.endsWith('/api/demo/start') && req.method === 'POST') {+ if (demo && demo.running) { res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ started: false, alreadyRunning: true, ...demoState() })); }+ let body = ''; req.on('data', d => { body += d; });+ req.on('end', () => { let muted = false; try { muted = !!JSON.parse(body || '{}').muted; } catch (e) {} runDemoControllable({ muted }).catch((e) => { if (demo) demo.log.push('runner crashed: ' + e.message); if (demo) demo.running = false; }); res.writeHead(200, { 'content-type': 'application/json', ...CORS }); res.end(JSON.stringify({ started: true })); });+ return;+ }+ if (u.endsWith('/api/demo/control') && req.method === 'POST') {+ let body = ''; req.on('data', d => { body += d; });+ req.on('end', () => { let action = ''; try { action = String(JSON.parse(body).action || ''); } catch (e) {} const r = demoControl(action); res.writeHead(200, { 'content-type': 'application/json', ...CORS }); res.end(JSON.stringify(r)); });+ return;+ }+ if (u.endsWith('/api/demo/state')) {+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS }); return res.end(JSON.stringify(demoState()));+ }+ if (u.endsWith('/api/demo') && req.method === 'POST') { // legacy synchronous run (kept)+ let lines; try { lines = await runDemo(); } catch (e) { lines = ['demo failed: ' + e.message]; }+ res.writeHead(200, { 'content-type': 'application/json', ...CORS });+ return res.end(JSON.stringify({ lines }));+ }+ if ((u.endsWith('/api/say-send') || u.endsWith('/api/try')) && req.method === 'POST') {+ res.writeHead(410, { 'content-type': 'application/json', ...CORS });+ return res.end(JSON.stringify({ ok: false, gone: true, why: 'Send to AI moved to the bridge dashboard (newbie-panel 2.22.0, Hydrogen 1.0.187+ job contract); this container-side route no longer sends.' }));+ }+ if (u.endsWith('/api/open-settings') && req.method === 'POST') {+ const tgt = await resolveTarget();+ ab('pup_dashboard', {}, 15000, tgt.name).catch(() => {});+ res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ ok: true }));+ }+ if (u.endsWith('/api/say')) {+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS });+ return res.end(JSON.stringify({ say: SAY }));+ }+ // v2.0.329: NO-STORE on the page itself. Caught on AdomLapper during the fleet dashboard sweep:+ // /api/status reported dashboardVersion 2.0.328 while the rendered page was still running an+ // OLDER build's JS (status pills showed "pup 2" / "6 window", the pre-2.0.326 short-state logic).+ // The API routes already sent no-store; the HTML did not, so the webview could serve a cached+ // page across a version change and silently mask a redeploy. A dashboard that lies about its own+ // version is worse than one that reloads slowly.+ // RETIRED (v2.0.337): this process no longer renders a dashboard. Anything probing this port+ // gets pointed at the real one instead of being handed a second page to be confused by.+ res.writeHead(410, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', ...CORS });+ res.end('pup serves ONE dashboard, and it is not this one.\nOpen ' + BRIDGE_DASHBOARD + ' instead.\n');+ });+ server.listen(0, '127.0.0.1', () => {+ const port = server.address().port;+ try { fs.writeFileSync(STATE, JSON.stringify({ port, pid: process.pid, at: Date.now(), version: TOOL_VERSION, dockWebview: DOCK_WEBVIEW || null, dockApp: DOCK_APP || null })); } catch (e) {}+ // v2.0.322: pre-render the demo narration to mp3 now, so a later "Run the demo" plays instantly+ // (John: "pre-create these mp3 audio files so you don't generate them on the fly").+ preRenderDemoAudio().then(() => console.error('pup-dashboard: demo audio pre-rendered')).catch(() => {});+ });+}++const cmd = process.argv[2];+if (cmd === 'serve') serve();+else if (cmd === 'status') buildStatus().then(b => { console.log(JSON.stringify(b, null, 2)); process.exit(0); });+else {+ console.log('pup-dashboard: pup\'s Dock Bar status page\n\n pup-dashboard serve --print-url start (idempotent, self-backgrounding) and print the URL\n pup-dashboard status print the /api/status payload and exit');+ process.exit(cmd ? 1 : 0);+}
uninstall.shadded+28@@ -0,0 +1,28 @@+#!/usr/bin/env bash+# Remove the pup skill from the container. (Also cleans the maintainer sub-skills if an older+# pkg installed them, so an upgrade from the pre-1.8.21 layout leaves nothing behind.)+set -e+remove_from() {+ local BASE="$1"+ rm -rf "$BASE/pup" \+ "$BASE/adom-bridge-puppeteer-bridge" \+ "$BASE/pup-windows-sessions-tabs" \+ "$BASE/pup-screenshots-recording" \+ "$BASE/pup-browsers-and-chrome" \+ "$BASE/pup-adom-wiki" \+ "$BASE/pup-vendor-login" \+ "$BASE/pup-settings" \+ "$BASE/pup-credentials" \+ "$BASE/pup-lanes" \+ "$BASE/pup-why-cft" \+ "$BASE/pup-import-logins" \+ "$BASE/pup-taskbar-progress" \+ "$BASE/pup-progress" \+ "$BASE/pup-taskbar-states" \+ "$BASE/pup-bridge-dev" "$BASE/pup-bridge-publish"+ echo " <- $BASE"+}+echo "Removing pup for macOS skills:"+remove_from "$HOME/.claude/skills"+[ -d "$HOME/.codex" ] && remove_from "$HOME/.codex/skills"+echo "Done."