← Commit history
docs/PLAN-rust-port.md+48−23
@@ -1,32 +1,57 @@ # pup in Rust: the port plan -**Status (2026-09-05, evening): Phase 0 DONE, Phase 1 skeleton BUILT and measured on winvm.** Written-2026-09-05 against pup 2.0.396; John approved the plan the same day ("lets get back to the plan").+**Status (2026-09-05, night): Phases 0 to 3 BUILT and measured on winvm; Phase 4 (parity gate, exe+packaging, ship) is next.** Written 2026-09-05 against pup 2.0.396; John approved the plan the same day+("lets get back to the plan").  - Phase 0: fixtures recorded from the Node build into `docs/contract/*.json`; the five ab asks filed   (adom-bridge#144 send-to-bottom, #145 clipboard_set, #146 IconResource, #147 appId on window rows,   #148 prewarm question); workspace laid down; `tools/check-borders.sh` enforces the host border.-- Phase 1: `rust/` builds for `x86_64-pc-windows-gnu` (2.4 MB exe, 55 s release build); crates-  core, host, host-win, host-mac (stubs), cdp, browser, session, verbs, dashboard, bridge carry-  real code; every test passes. Measured on winvm with the exe hand-launched on a spare port and-  its own profile: /health, /status, pup_readiness native; `pup_open_window` launched Chrome for-  Testing in 769 ms and had a background window with a resolved handle, parked at the bottom and-  loaded, in 1,750 ms; pup_eval, pup_navigate, pup_screenshot (2012x1117 resized to 1568x871,-  228 KB, a correct render) and pup_close_window (Chrome gone) all answered in the Node shapes.-  The Node build on the same box was untouched throughout.-- Phase 2 (2026-09-05, evening): 48 verbs native. Tabs, trusted input (click, type, press_key,-  hover, scroll, fill, wait), history, reload, viewport pin, dialogs, console errors (one event-  pump per browser), render_html, fetch_url, maximize; raise gated on a reason with caption + toast,-  lower, alert, taskbar progress; configure / use / quit_idle / rescan; session recovery after a-  bridge kill (measured: the window, its handle and its page came back and answered eval and-  screenshot), the 15 s health loop; taskbar identity through ab (thread tile rendered by the-  browser's own canvas from the embedded font, AUMID register + stamp, jump list, favicon overlay-  badge); the dashboard page embedded with its SSE snapshot, action and image routes, /api/status,-  /identity; the four MV3 extensions embedded and loaded on Chrome for Testing with pinned prefs.-  Measured on winvm, all through the hand-launched exe on a spare port. Not yet in Phase 2: fg-watch-  re-park, the 5-minute identity read-back audit (waits on adom-bridge#147), screenshot_element /-  full_res, download, the highFps lease. Phase 3 (vault, wiki, annotate, recording, Send-to-AI)-  not started.+- Phase 1: `rust/` builds for `x86_64-pc-windows-gnu`; crates core, host, host-win, host-mac (stubs),+  cdp, browser, session, verbs, dashboard, bridge carry real code; every test passes. Measured on+  winvm with the exe hand-launched on a spare port and its own profile: /health, /status,+  pup_readiness native; `pup_open_window` launched Chrome for Testing in 769 ms and had a background+  window with a resolved handle, parked at the bottom and loaded, in 1,750 ms; eval, navigate,+  screenshot and close_window answered in the Node shapes. The Node build on the box was untouched.+- Phase 2: 48 verbs native. Tabs, trusted input, history, reload, viewport pin, dialogs, console+  errors (one event pump per browser), render_html, fetch_url, maximize; raise gated on a reason with+  caption + toast, lower, alert, taskbar progress; configure / use / quit_idle / rescan; session+  recovery after a bridge kill (measured), the 15 s health loop; taskbar identity through ab (thread+  tile rendered by the browser's own canvas from the embedded font, AUMID register + stamp, jump+  list, favicon overlay badge); the dashboard page with its SSE snapshot, action and image routes,+  /api/status, /identity; the four MV3 extensions embedded and loaded with pinned prefs.+- Phase 3 (2026-09-05, night): the rest of the verb surface, measured on winvm through the+  hand-launched exe. Credential vault on the OS keychain (host-win: CredRead/Write/Delete in the+  keytar layout, so the Node build's saved logins carry over): credential_set / pup_credentials /+  credential_delete, background autofill on load (`_autoLogin` names the live host), pup_login with+  the submit_reason_required gate, real-keystroke fill, 2FA/captcha wall detection+  (`blocked_on_user`), login capture from the page binding (deduped against the vault), the+  verified-origin-gated reveal behind a consent toast (measured: refused as origin_unverified from a+  plain caller, nothing echoed); payment cards (pup_card_set / pup_cards / pup_card_delete /+  pup_fill_card with per-fill consent; brand + last4 only in responses). Wiki: auto-sign from the+  desktop token (measured: window auto-signed as the box's user), public/authed view flip through the+  server's `?view=public`, wikiView/wikiAuthed/wikiLoginNeeded on every navigate. Annotate: the+  overlay armed by verb, floaty pencil, owner chip on every document (open, navigate, new tab),+  capture processing (clip, caption bar, iTXt sidecar, clipboard through ab's desktop_clipboard_set).+  Recording: tab screencast with frame ack (measured: 19 frames over 6.2 s of an animated page in an+  occluded window; a static page yields one frame, which is what screencast does), ffmpeg VP9 mux+  when present else the built-in MJPEG AVI; the Chrome screen-share recorder HUD (pup_recorder_open+  is reason-gated, pup_screen_record_* status/list); the highFps lease with the lapse relaunch.+  Extras: screenshot_element, screenshot_full_res (2012x1117 native), pup_download through+  Browser.setDownloadBehavior + downloadProgress (measured: 40-byte file landed where asked), the+  5-minute identity read-back audit (desktop_get_window_identity), launch, deps, install_toolbar,+  reap_profiles, uninstall. Dashboard: the Hydrogen (ah) proxy with dynamic port discovery+  (measured on winvm: responder on 47080 pointed at 47084), byte-faithful relays for+  /updater/status, /ai-threads/run-prompt and its status poll, /api/demo-send with the release gate+  and the 30 s state wait (measured: a job opened). Consent registry shared by reveal, card fill and+  raise. The placeholder wiki/annotate/record crates were removed: their code lives as modules in+  the verbs crate because every one of them needs the shared Ctx and the open/close verbs (highFps+  relaunch, autofill on load, wiki after-navigate); splitting them out means lifting Ctx and the+  window verbs into a runtime crate first, which is a Phase 4 refactor, not a stub.+- Phase 4 (next): golden-fixture parity run against `docs/contract/*.json`; `bridge.json` with+  `spawn.kind: exe` + sha-verified zip and the ship.sh build step; the wiped-box first-run gate+  (section 6b) proven on a fresh VM; the runtime-crate split above; then an insiders ship. Nothing+  on the public tier changes until John's words.  Commissioned by John: "make a version of pup next that is a rust binary like we've recently done for fusion and for altium ... make sure that you did not reimplement anything in ab already does for you
rust/Cargo.lockadded+1122
@@ -0,0 +1,1122 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-pup-bridge"+version = "2.0.399"+dependencies = [+ "pup-browser",+ "pup-cdp",+ "pup-core",+ "pup-dashboard",+ "pup-host",+ "pup-host-mac",+ "pup-host-win",+ "pup-session",+ "pup-vault",+ "pup-verbs",+ "serde_json",+ "tiny_http",+]++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "bitflags"+version = "1.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"++[[package]]+name = "block-buffer"+version = "0.10.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"+dependencies = [+ "generic-array",+]++[[package]]+name = "byteorder"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"++[[package]]+name = "bytes"+version = "1.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"++[[package]]+name = "cc"+version = "1.4.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "cpufeatures"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"+dependencies = [+ "libc",+]++[[package]]+name = "crc32fast"+version = "1.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"+dependencies = [+ "cfg-if",+]++[[package]]+name = "crypto-common"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"+dependencies = [+ "generic-array",+ "typenum",+]++[[package]]+name = "data-encoding"+version = "2.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"++[[package]]+name = "digest"+version = "0.10.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"+dependencies = [+ "block-buffer",+ "crypto-common",+]++[[package]]+name = "displaydoc"+version = "0.2.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "equivalent"+version = "1.0.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"++[[package]]+name = "fdeflate"+version = "0.3.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"+dependencies = [+ "simd-adler32",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"++[[package]]+name = "flate2"+version = "1.1.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"+dependencies = [+ "crc32fast",+ "miniz_oxide 0.9.1",+ "zlib-rs",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "generic-array"+version = "0.14.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"+dependencies = [+ "typenum",+ "version_check",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[package]]+name = "hashbrown"+version = "0.17.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"++[[package]]+name = "http"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"+dependencies = [+ "bytes",+ "itoa",+]++[[package]]+name = "httparse"+version = "1.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "icu_collections"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"+dependencies = [+ "displaydoc",+ "potential_utf",+ "utf8_iter",+ "yoke",+ "zerofrom",+ "zerovec",+]++[[package]]+name = "icu_locale_core"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"+dependencies = [+ "displaydoc",+ "litemap",+ "tinystr",+ "writeable",+ "zerovec",+]++[[package]]+name = "icu_normalizer"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"+dependencies = [+ "icu_collections",+ "icu_normalizer_data",+ "icu_properties",+ "icu_provider",+ "smallvec",+ "zerovec",+]++[[package]]+name = "icu_normalizer_data"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"++[[package]]+name = "icu_properties"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"+dependencies = [+ "displaydoc",+ "icu_collections",+ "icu_locale_core",+ "icu_properties_data",+ "icu_provider",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "icu_properties_data"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"++[[package]]+name = "icu_provider"+version = "2.3.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"+dependencies = [+ "displaydoc",+ "icu_locale_core",+ "writeable",+ "yoke",+ "zerofrom",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "idna"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"+dependencies = [+ "idna_adapter",+ "smallvec",+ "utf8_iter",+]++[[package]]+name = "idna_adapter"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[package]]+name = "indexmap"+version = "2.14.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"+dependencies = [+ "equivalent",+ "hashbrown",+]++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "libc"+version = "0.2.189"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"++[[package]]+name = "litemap"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"++[[package]]+name = "log"+version = "0.4.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "miniz_oxide"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "png"+version = "0.17.16"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"+dependencies = [+ "bitflags",+ "crc32fast",+ "fdeflate",+ "flate2",+ "miniz_oxide 0.8.9",+]++[[package]]+name = "potential_utf"+version = "0.1.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"+dependencies = [+ "zerovec",+]++[[package]]+name = "ppv-lite86"+version = "0.2.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"+dependencies = [+ "zerocopy",+]++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "pup-browser"+version = "2.0.399"+dependencies = [+ "pup-core",+ "pup-host",+ "serde",+ "serde_json",+ "thiserror",+ "ureq",+]++[[package]]+name = "pup-cdp"+version = "2.0.399"+dependencies = [+ "serde_json",+ "thiserror",+ "tungstenite",+ "ureq",+]++[[package]]+name = "pup-core"+version = "2.0.399"+dependencies = [+ "serde",+ "serde_json",+ "thiserror",+ "ureq",+]++[[package]]+name = "pup-dashboard"+version = "2.0.399"+dependencies = [+ "pup-cdp",+ "pup-core",+ "pup-identity",+ "pup-session",+ "pup-verbs",+ "serde_json",+ "ureq",+]++[[package]]+name = "pup-host"+version = "2.0.399"+dependencies = [+ "serde",+ "serde_json",+ "thiserror",+]++[[package]]+name = "pup-host-mac"+version = "2.0.399"+dependencies = [+ "pup-host",+]++[[package]]+name = "pup-host-win"+version = "2.0.399"+dependencies = [+ "pup-host",+ "windows-sys 0.59.0",+]++[[package]]+name = "pup-identity"+version = "2.0.399"+dependencies = [+ "serde_json",+]++[[package]]+name = "pup-session"+version = "2.0.399"+dependencies = [+ "pup-core",+ "serde",+ "serde_json",+ "thiserror",+]++[[package]]+name = "pup-vault"+version = "2.0.399"+dependencies = [+ "pup-core",+ "pup-host",+ "serde",+ "serde_json",+ "thiserror",+]++[[package]]+name = "pup-verbs"+version = "2.0.399"+dependencies = [+ "base64",+ "png",+ "pup-browser",+ "pup-cdp",+ "pup-core",+ "pup-host",+ "pup-identity",+ "pup-session",+ "pup-vault",+ "serde_json",+ "ureq",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "rand"+version = "0.8.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"+dependencies = [+ "libc",+ "rand_chacha",+ "rand_core",+]++[[package]]+name = "rand_chacha"+version = "0.3.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"+dependencies = [+ "ppv-lite86",+ "rand_core",+]++[[package]]+name = "rand_core"+version = "0.6.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"+dependencies = [+ "getrandom",+]++[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys 0.52.0",+]++[[package]]+name = "rustls"+version = "0.23.43"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "serde_json"+version = "1.0.151"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"+dependencies = [+ "indexmap",+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "sha1"+version = "0.10.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"+dependencies = [+ "cfg-if",+ "cpufeatures",+ "digest",+]++[[package]]+name = "shlex"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"++[[package]]+name = "simd-adler32"+version = "0.3.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"++[[package]]+name = "smallvec"+version = "1.16.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[package]]+name = "syn"+version = "2.0.119"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "syn"+version = "3.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "thiserror"+version = "1.0.69"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"+dependencies = [+ "thiserror-impl",+]++[[package]]+name = "thiserror-impl"+version = "1.0.69"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "tinystr"+version = "0.8.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"+dependencies = [+ "displaydoc",+ "zerovec",+]++[[package]]+name = "tungstenite"+version = "0.24.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"+dependencies = [+ "byteorder",+ "bytes",+ "data-encoding",+ "http",+ "httparse",+ "log",+ "rand",+ "sha1",+ "thiserror",+ "utf-8",+]++[[package]]+name = "typenum"+version = "1.20.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "2.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"+dependencies = [+ "base64",+ "flate2",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf-8"+version = "0.7.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "webpki-roots"+version = "0.26.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"+dependencies = [+ "webpki-roots 1.0.9",+]++[[package]]+name = "webpki-roots"+version = "1.0.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"+dependencies = [+ "rustls-pki-types",+]++[[package]]+name = "windows-sys"+version = "0.52.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-sys"+version = "0.59.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "writeable"+version = "0.6.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"++[[package]]+name = "yoke"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"+dependencies = [+ "stable_deref_trait",+ "yoke-derive",+ "zerofrom",+]++[[package]]+name = "yoke-derive"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+ "synstructure",+]++[[package]]+name = "zerocopy"+version = "0.8.56"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"+dependencies = [+ "zerocopy-derive",+]++[[package]]+name = "zerocopy-derive"+version = "0.8.56"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "zerofrom"+version = "0.1.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"+dependencies = [+ "zerofrom-derive",+]++[[package]]+name = "zerofrom-derive"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+ "synstructure",+]++[[package]]+name = "zeroize"+version = "1.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"++[[package]]+name = "zerotrie"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"+dependencies = [+ "displaydoc",+ "yoke",+ "zerofrom",+]++[[package]]+name = "zerovec"+version = "0.11.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"+dependencies = [+ "yoke",+ "zerofrom",+ "zerovec-derive",+]++[[package]]+name = "zerovec-derive"+version = "0.11.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "zlib-rs"+version = "0.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
rust/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [workspace] resolver = "2"-members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session", "identity", "vault", "wiki", "annotate", "record", "verbs", "dashboard", "bridge"]+members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session", "identity", "vault", "verbs", "dashboard", "bridge"]  # One version for the whole bridge, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both). [workspace.package]
rust/bridge/Cargo.toml+1
@@ -19,6 +19,7 @@ pup-browser = { path = "../browser" } pup-session = { path = "../session" } pup-verbs = { path = "../verbs" } pup-dashboard = { path = "../dashboard" }+pup-vault = { path = "../vault" }  [target.'cfg(windows)'.dependencies] pup-host-win = { path = "../host-win" }
rust/bridge/src/main.rs+4
@@ -42,7 +42,9 @@ fn main() {         host: h.clone(), ab, settings: Arc::new(Settings::new(&state_dir)), installer: Arc::new(pup_browser::Installer::new()),         sessions: Arc::new(pup_session::Sessions::new(&state_dir.join("pup-sessions"))), browsers: Mutex::new(Default::default()), tabstate: Ctx::new_tabstate(),         bridge_dir, port, started: Instant::now(), ad_version: Mutex::new(None), self_ref: Mutex::new(std::sync::Weak::new()), toasts: Mutex::new(Vec::new()),+        vault: pup_vault::Vault::new(&state_dir), cards: pup_vault::Cards::new(&state_dir), consent: pup_verbs::consent::Registry::new(), tab_recs: Default::default(), screen_rec: Default::default(), highfps: Default::default(), last_audit: Mutex::new(None),     });+    pup_verbs::install_event_hook(ctx.clone());     *ctx.self_ref.lock().unwrap() = Arc::downgrade(&ctx);      let server = match Server::http(format!("{bind}:{port}")) { Ok(s) => s, Err(e) => { eprintln!("[pup] cannot bind {bind}:{port}: {e}"); std::process::exit(2); } };@@ -66,6 +68,8 @@ fn main() {             pup_verbs::identity::boot(&c);             pup_verbs::recovery::recover_at_boot(&c);             pup_verbs::recovery::start_health_loop(c.clone());+            pup_verbs::highfps::start_loop(c.clone());+            pup_verbs::extras::start_identity_audit(c.clone());             if let Ok(v) = c.ab.call("bridge_info", json!({}), &Caller::default(), Duration::from_secs(4)) { *c.ad_version.lock().unwrap() = v.get("version").and_then(|x| x.as_str()).map(|s| s.to_string()); }         });     }
rust/browser/src/lib.rs+1
@@ -469,6 +469,7 @@ mod tests {     struct H(PathBuf);     impl pup_host::HostWindows for H { fn windows_for_pids(&self, _: &[u32], _: Option<&str>) -> Vec<pup_host::HostWindow> { vec![] } fn is_alive(&self, _: i64) -> bool { false } fn tag(&self, _: i64, _: &str) -> bool { false } fn has_tag(&self, _: i64, _: &str) -> bool { false } fn send_to_bottom_no_activate(&self, _: i64) -> bool { false } fn set_bounds_no_activate(&self, _: i64, _: i32, _: i32, _: i32, _: i32) -> bool { false } fn foreground(&self) -> i64 { 0 } fn work_area(&self) -> Option<(i32, i32, i32, i32)> { None } }     impl pup_host::HostProcess for H { fn alive(&self, _: u32) -> bool { false } fn spawn_detached(&self, _: &Path, _: &[String], _: Option<&Path>) -> std::io::Result<u32> { Ok(1) } fn kill(&self, _: u32) -> bool { false } }+    impl pup_host::HostSecrets for H { fn secret_get(&self, _: &str, _: &str) -> Option<String> { None } fn secret_set(&self, _: &str, _: &str, _: &str) -> Result<(), String> { Err("n/a".into()) } fn secret_delete(&self, _: &str, _: &str) -> bool { false } }     impl pup_host::HostPaths for H { fn home(&self) -> PathBuf { self.0.clone() } fn browser_roots(&self) -> Vec<PathBuf> { vec![] } fn cft_platform(&self) -> &'static str { "linux64" } fn cft_exe_relative(&self) -> PathBuf { PathBuf::from("chrome-linux64").join("chrome") } }      #[test] fn prune_and_detect() {
rust/dashboard/src/hydrogen.rsadded+151
@@ -0,0 +1,151 @@+//! The local Hydrogen (ah) control-API proxy: discovery of the dynamic control port, the+//! byte-faithful same-origin relays the dashboard's Send-to-AI module calls (/updater/status,+//! /ai-threads/run-prompt, /ai-threads/run-prompt/status), the release-version send gate, and the+//! /api/demo-send action that opens a "Pup - demo" thread and waits for Hydrogen to report it sent.+//! Ports server.js v2.0.351/388: the port is DYNAMIC and re-probed on every failure (ah restarts+//! move it), the relays forward status + content-type + body untouched, and an unreadable version+//! gate FAILS OPEN.++use crate::Reply;+use serde_json::{json, Value};+use std::io::Read;+use std::sync::Mutex;+use std::time::{Duration, Instant};++const DISCOVERY_PORTS: [u16; 4] = [47080, 48080, 49180, 50280];+const SEND_MIN_RELEASE: [u64; 3] = [1, 0, 142];+pub const DEMO_PROMPT: &str = "Run the pup demo and show me what pup can do on my machine.\n\n\+1. Open a pup window to https://wiki.adom.inc/adom/pup-bridge. Tell me it opened in the BACKGROUND, pup never steals my screen, 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.\n\+2. Screenshot it and show me the result, so I can see you can read the page without touching my browser.\n\+3. 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.\n\+4. 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.\n\+5. Read the pup dashboard 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\n\+Then close the demo window so nothing is left on my desktop.";++static CTL: Mutex<Option<(String, Instant)>> = Mutex::new(None);+static GATE: Mutex<Option<(Instant, bool, String)>> = Mutex::new(None);++pub struct Resp { pub status: u16, pub raw: String, pub content_type: Option<String>, pub error: Option<String> }+impl Resp {+    fn dead(e: impl Into<String>) -> Resp { Resp { status: 0, raw: String::new(), content_type: None, error: Some(e.into()) } }+    pub fn json(&self) -> Option<Value> { serde_json::from_str(&self.raw).ok() }+}++fn home() -> std::path::PathBuf { std::path::PathBuf::from(std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default()) }+fn agent(timeout: Duration) -> ureq::Agent { ureq::AgentBuilder::new().timeout(timeout.max(Duration::from_millis(300))).build() }++/// Where the control API lives right now: the container-side file when present, else the discovery responder.+pub fn control_url(force: bool) -> Option<String> {+    if !force { if let Some((u, at)) = CTL.lock().unwrap().clone() { if at.elapsed() < Duration::from_secs(300) { return Some(u); } } }+    if let Ok(u) = std::fs::read_to_string(home().join(".adom").join("hydrogen-control-url")) {+        let u = u.trim().trim_end_matches('/').to_string();+        if !u.is_empty() { *CTL.lock().unwrap() = Some((u.clone(), Instant::now())); return Some(u); }+    }+    for port in DISCOVERY_PORTS {+        let r = agent(Duration::from_millis(1200)).get(&format!("http://127.0.0.1:{port}/")).call();+        if let Ok(r) = r { if let Ok(j) = r.into_json::<Value>() { if let Some(u) = j["control_url"].as_str() {+            let u = u.trim_end_matches('/').to_string();+            pup_core::log(format!("[hydrogen] control API discovered at {u} (via responder on {port})"));+            *CTL.lock().unwrap() = Some((u.clone(), Instant::now())); return Some(u);+        } } }+    }+    *CTL.lock().unwrap() = None;+    None+}++fn read(r: Result<ureq::Response, ureq::Error>) -> Resp {+    match r {+        Ok(res) | Err(ureq::Error::Status(_, res)) => {+            let status = res.status(); let ct = res.header("content-type").map(|s| s.to_string());+            let mut raw = String::new(); let _ = res.into_reader().take(8 << 20).read_to_string(&mut raw);+            Resp { status, raw, content_type: ct, error: None }+        }+        Err(e) => Resp::dead(e.to_string()),+    }+}+fn get_once(base: Option<String>, path: &str, timeout: Duration) -> Resp {+    let Some(b) = base else { return Resp::dead("hydrogen control API not discovered") };+    read(agent(timeout).get(&format!("{b}{path}")).call())+}+fn post_once(base: Option<String>, path: &str, body: &str, timeout: Duration) -> Resp {+    let Some(b) = base else { return Resp::dead("hydrogen control API not discovered") };+    read(agent(timeout).post(&format!("{b}{path}")).set("content-type", "application/json").send_string(body))+}+/// A dead port means the cached URL is stale (ah restarted), not that Hydrogen is gone: re-probe once.+pub fn get(path: &str, timeout: Duration) -> Resp { let r = get_once(control_url(false), path, timeout); if r.status == 0 { get_once(control_url(true), path, timeout) } else { r } }+pub fn post(path: &str, body: &str, timeout: Duration) -> Resp { let r = post_once(control_url(false), path, body, timeout); if r.status == 0 { post_once(control_url(true), path, body, timeout) } else { r } }++/// Release-version gate on updater.current_version (the ONE endpoint with a release version). Unreadable = allowed.+pub fn shell_supports_send() -> (bool, String) {+    if let Some((at, ok, why)) = GATE.lock().unwrap().clone() { if at.elapsed() < Duration::from_secs(60) { return (ok, why); } }+    let r = get("/updater/status", Duration::from_millis(2500));+    let j = r.json().unwrap_or(Value::Null);+    let u = if j["updater"].is_object() { j["updater"].clone() } else { j };+    let raw = u["current_version"].as_str().unwrap_or("").trim().to_string();+    let mut out = (true, String::new());+    let v: Vec<u64> = raw.split(|c: char| !c.is_ascii_digit()).take(3).filter_map(|p| p.parse().ok()).collect();+    if v.len() == 3 && v.iter().zip(SEND_MIN_RELEASE.iter()).find_map(|(a, b)| if a != b { Some(a < b) } else { None }).unwrap_or(false) {+        out = (false, format!("Your Hydrogen ({raw}) predates the Send to AI fix. Update to 1.0.142 or newer to enable it; the phrase was copied instead."));+    }+    *GATE.lock().unwrap() = Some((Instant::now(), out.0, out.1.clone()));+    out+}++fn relay(r: Resp, hint: &str) -> Reply {+    if r.status == 0 { return Reply::Bytes(502, "application/json".into(), json!({ "error": r.error.unwrap_or_else(|| "hydrogen unreachable".into()), "_hint": hint }).to_string().into_bytes(), Some("no-store".into())); }+    Reply::Bytes(r.status, r.content_type.unwrap_or_else(|| "application/json".into()), r.raw.into_bytes(), Some("no-store".into()))+}++const UNREACHABLE_HINT: &str = "Hydrogen (ah) is not reachable on this desktop: its discovery responder answered on none of 47080/48080/49180/50280 and ~/.adom/hydrogen-control-url is absent. Start Hydrogen, then retry.";++/// The four routes. `query` is the raw query string (forwarded intact for the status poll).+pub fn route(method: &str, path: &str, query: &str, body: &str) -> Option<Reply> {+    match (method, path) {+        ("GET", "/updater/status") => Some(relay(get("/updater/status", Duration::from_secs(8)), UNREACHABLE_HINT)),+        ("POST", "/ai-threads/run-prompt") => {+            if serde_json::from_str::<Value>(body).is_err() { return Some(Reply::Bytes(400, "application/json".into(), b"{\"error\":\"bad json\"}".to_vec(), None)); }+            // 120s, matching the module's own AbortController: a loaded workbench can take 60s just to create a tab.+            Some(relay(post("/ai-threads/run-prompt", body, Duration::from_secs(120)), UNREACHABLE_HINT))+        }+        ("GET", "/ai-threads/run-prompt/status") => {+            let qs = if query.is_empty() { String::new() } else { format!("?{query}") };+            Some(relay(get(&format!("/ai-threads/run-prompt/status{qs}"), Duration::from_secs(8)), UNREACHABLE_HINT))+        }+        ("POST", "/api/demo-send") => Some(Reply::Json(200, demo_send())),+        _ => None,+    }+}++/// Open the "Pup - demo" thread and wait (30s) for Hydrogen to say the prompt was sent.+pub fn demo_send() -> Value {+    let (ok, why) = shell_supports_send();+    if !ok { return json!({ "ok": false, "why": why }); }+    let payload = json!({ "new": true, "prompt": DEMO_PROMPT, "submit": true, "name": "Pup - demo", "tags": ["dock:pup-bridge"] }).to_string();+    let r = post("/ai-threads/run-prompt", &payload, Duration::from_secs(120));+    if r.status == 0 { return json!({ "ok": false, "why": format!("Could not reach the AI panel ({}).", r.error.unwrap_or_else(|| "no response".into())) }); }+    let j = r.json().unwrap_or(json!({}));+    // Hydrogen 1.0.187+: {ok, job, state:'opening'} comes back in under a second; progress is the status poll.+    if let (Some(job), Some(mut st)) = (j["job"].as_str().map(|s| s.to_string()), j["state"].as_str().map(|s| s.to_string())) {+        const SENT: [&str; 3] = ["working", "needs_you", "done"];+        const FAIL: [&str; 4] = ["submit_failed", "submitted_unidentified", "submit_unconfirmed", "still_working"];+        let t0 = Instant::now();+        while t0.elapsed() < Duration::from_secs(30) && !SENT.contains(&st.as_str()) && !FAIL.contains(&st.as_str()) {+            std::thread::sleep(Duration::from_secs(1));+            let pr = get(&format!("/ai-threads/run-prompt/status?job={}", urlencode(&job)), Duration::from_secs(5));+            if pr.status == 404 { return json!({ "ok": false, "why": "This Hydrogen build has no run-prompt status route; the prompt may still be sending in the new tab." }); }+            if let Some(s) = pr.json().and_then(|v| v["state"].as_str().map(|s| s.to_string())) { st = s; }+        }+        if SENT.contains(&st.as_str()) { return json!({ "ok": true, "state": st, "job": job }); }+        if st == "submit_failed" { return json!({ "ok": false, "state": st, "why": "The demo prompt is sitting in the new AI tab but send never clicked: press Enter there to run it." }); }+        if FAIL.contains(&st.as_str()) { return json!({ "ok": false, "state": st, "why": format!("Hydrogen reported {st} for the new thread.") }); }+        return json!({ "ok": true, "state": st, "job": job, "why": format!("The new AI tab opened; still {st} after 30s.") });+    }+    // Older builds: delivered_to is an ARRAY, and an opened-but-unconfirmed thread is NOT a failure (a retry double-sends).+    let dt = &j["delivered_to"];+    let delivered = dt.as_array().map(|a| !a.is_empty()).unwrap_or(!dt.is_null()) || j["inject"]["delivered_in_conversation"] == json!(true);+    if delivered { return json!({ "ok": true, "state": "delivered" }); }+    if (200..300).contains(&r.status) { return json!({ "ok": true, "state": "opened", "why": j["_hints"]["note"] }); }+    json!({ "ok": false, "why": j["_hints"]["note"].as_str().or(j["error"].as_str()).map(|s| s.to_string()).unwrap_or_else(|| format!("AI threads returned {}", r.status)) })+}++fn urlencode(s: &str) -> String { s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect() }
rust/dashboard/src/lib.rs+2−2
@@ -5,6 +5,7 @@  pub mod assets_gen; +pub mod hydrogen; use pup_core::{Caller}; use pup_verbs::Ctx; use serde_json::{json, Value};@@ -221,7 +222,6 @@ pub fn route(ctx: &std::sync::Arc<Ctx>, caller: &Caller, method: &str, path: &st         ("POST", "/dashboard/action") => Some(Reply::Json(200, action(ctx, caller, body))),         ("GET", "/api/status") => Some(Reply::Json(200, api_status(ctx))),         ("GET", "/identity") => Some(Reply::Json(200, identity(ctx))),-        ("POST", "/api/demo-send") | ("POST", "/ai-threads/run-prompt") | ("GET", "/ai-threads/run-prompt/status") => Some(Reply::Json(501, json!({ "success": false, "errorCode": "not_in_rust_yet", "error": "Send-to-AI (Hydrogen proxy) lands in Phase 3 of the Rust port" }))),-        _ => None,+        _ => hydrogen::route(method, path, query, body),     } }
rust/host-mac/src/lib.rs+7−1
@@ -7,7 +7,7 @@ //! user's way (the Windows "bottom of the z-order, never activated" is `NSApp hide` / ordering //! the window back on macOS), and the browser spawned detached. -use pup_host::{HostPaths, HostProcess, HostWindow, HostWindows};+use pup_host::{HostPaths, HostProcess, HostSecrets, HostWindow, HostWindows}; use std::path::{Path, PathBuf};  pub struct MacHost;@@ -39,3 +39,9 @@ impl HostPaths for MacHost {         PathBuf::from(format!("chrome-{}", self.cft_platform())).join("Google Chrome for Testing.app").join("Contents").join("MacOS").join("Google Chrome for Testing")     } }++impl HostSecrets for MacHost {+    fn secret_get(&self, _service: &str, _account: &str) -> Option<String> { todo!("SecItemCopyMatching kSecClassGenericPassword service/account (Windows: CredReadW target \"service/account\")") }+    fn secret_set(&self, _service: &str, _account: &str, _secret: &str) -> Result<(), String> { todo!("SecItemAdd / SecItemUpdate (Windows: CredWriteW CRED_TYPE_GENERIC, UTF-8 blob)") }+    fn secret_delete(&self, _service: &str, _account: &str) -> bool { todo!("SecItemDelete (Windows: CredDeleteW)") }+}
rust/host-win/Cargo.toml+1
@@ -16,4 +16,5 @@ windows-sys = { version = "0.59", features = [   "Win32_System_Threading",   "Win32_System_ProcessStatus",   "Win32_Graphics_Gdi",+  "Win32_Security_Credentials", ] }
rust/host-win/src/lib.rs+43−1
@@ -2,7 +2,7 @@ //! Everything here mirrors what src/server.js + src/win32.js did with koffi and PowerShell; the //! matching Node function is named on each method so the behaviour can be cross-checked. -use pup_host::{HostPaths, HostProcess, HostWindow, HostWindows};+use pup_host::{HostPaths, HostProcess, HostSecrets, HostWindow, HostWindows}; use std::path::{Path, PathBuf};  pub struct WinHost;@@ -114,6 +114,48 @@ mod imp {     } } +#[cfg(windows)]+mod secrets {+    use super::*;+    use windows_sys::Win32::Security::Credentials::{CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_PERSIST_ENTERPRISE, CRED_TYPE_GENERIC};+    fn wide(s: &str) -> Vec<u16> { s.encode_utf16().chain(std::iter::once(0)).collect() }+    // keytar's layout: TargetName = "<service>/<account>", UserName = account, blob = UTF-8 secret+    impl HostSecrets for WinHost {+        fn secret_get(&self, service: &str, account: &str) -> Option<String> {+            let target = wide(&format!("{service}/{account}"));+            let mut p: *mut CREDENTIALW = std::ptr::null_mut();+            unsafe {+                if CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut p) == 0 || p.is_null() { return None; }+                let c = &*p;+                let bytes = std::slice::from_raw_parts(c.CredentialBlob, c.CredentialBlobSize as usize).to_vec();+                CredFree(p as *mut _);+                String::from_utf8(bytes).ok().or_else(|| None)+            }+        }+        fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String> {+            let mut target = wide(&format!("{service}/{account}"));+            let mut user = wide(account);+            let mut blob = secret.as_bytes().to_vec();+            let mut c: CREDENTIALW = unsafe { std::mem::zeroed() };+            c.Type = CRED_TYPE_GENERIC; c.TargetName = target.as_mut_ptr(); c.UserName = user.as_mut_ptr();+            c.CredentialBlob = blob.as_mut_ptr(); c.CredentialBlobSize = blob.len() as u32; c.Persist = CRED_PERSIST_ENTERPRISE;+            let ok = unsafe { CredWriteW(&c, 0) } != 0;+            if ok { Ok(()) } else { Err(format!("CredWriteW failed (error {})", std::io::Error::last_os_error())) }+        }+        fn secret_delete(&self, service: &str, account: &str) -> bool {+            let target = wide(&format!("{service}/{account}"));+            unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) != 0 }+        }+    }+}++#[cfg(not(windows))]+impl HostSecrets for WinHost {+    fn secret_get(&self, _s: &str, _a: &str) -> Option<String> { None }+    fn secret_set(&self, _s: &str, _a: &str, _v: &str) -> Result<(), String> { Err("no secret store on this dev box".into()) }+    fn secret_delete(&self, _s: &str, _a: &str) -> bool { false }+}+ #[cfg(not(windows))] mod imp {     // Cross-compiled checks on Linux: the Windows impl is cfg'd out; these stubs let the crate
rust/host/src/lib.rs+10−2
@@ -69,6 +69,14 @@ pub trait HostPaths: Send + Sync {     fn cft_exe_relative(&self) -> PathBuf; } +/// The OS secret store (Windows Credential Manager / macOS Keychain). Target names follow keytar's+/// `service/account` layout so the vault the Node build wrote is readable by this build.+pub trait HostSecrets: Send + Sync {+    fn secret_get(&self, service: &str, account: &str) -> Option<String>;+    fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String>;+    fn secret_delete(&self, service: &str, account: &str) -> bool;+}+ /// The whole host, as the binary hands it to every crate.-pub trait Host: HostWindows + HostProcess + HostPaths {}-impl<T: HostWindows + HostProcess + HostPaths> Host for T {}+pub trait Host: HostWindows + HostProcess + HostPaths + HostSecrets {}+impl<T: HostWindows + HostProcess + HostPaths + HostSecrets> Host for T {}
rust/session/src/lib.rs+5−1
@@ -39,11 +39,15 @@ pub struct Session {     pub next_tab_seq: u32,     pub emulated: Option<Value>,     pub last_note: Option<String>,+    pub pending_cred_hint: Option<String>,+    pub wiki_authed: Option<bool>,+    pub wiki_view: Option<String>,+    pub last_annotation: Option<Value>, }  impl Session {     pub fn new(id: &str, profile: &str, owner: Option<&str>, owner_container: Option<&str>, cdp_port: u16, pid: Option<u32>) -> Session {-        Session { id: id.into(), profile: profile.into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None }+        Session { id: id.into(), profile: profile.into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None, pending_cred_hint: None, wiki_authed: None, wiki_view: None, last_annotation: None }     }     pub fn next_tab_id(&mut self) -> String { let id = format!("tab-{}", self.next_tab_seq); self.next_tab_seq += 1; id }     pub fn active(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) }
rust/vault/Cargo.toml+2
@@ -9,3 +9,5 @@ description = "Credentials and payment cards: keyring storage, Luhn, brand, expi serde.workspace = true serde_json.workspace = true thiserror.workspace = true+pup-core = { path = "../core" }+pup-host = { path = "../host" }
rust/vault/src/lib.rs+236−3
@@ -1,3 +1,236 @@-//! pup-vault: Credentials and payment cards: keyring storage, Luhn, brand, expiry, autofill selectors, the reveal gate.-//!-//! Phase status: SKELETON (pup Rust port plan, docs/PLAN-rust-port.md).+//! pup-vault: credentials and payment cards. The SECRETS live in the OS store through+//! `HostSecrets` (keytar's `service/account` layout, so the Node build's vault is readable here);+//! the INDEXES (host + username, card nickname + brand + last4, never a password or PAN) live in+//! ~/.adom as JSON, same files and shapes as src/credential_vault.js and src/card_vault.js.+//! The page-side work (locate a login form, fill it, submit; find card fields) is JavaScript+//! returned by functions here and evaluated by the verbs crate through CDP.++use pup_host::HostSecrets;+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++pub const CRED_SERVICE: &str = "adom-desktop-pup";+pub const CARD_SERVICE: &str = "adom-pup-cards";++fn js_str(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) }++// ── host patterns (Node: registrableDomain / hostMatchesPattern / scorePattern) ─────────++const TWO_PART_TLDS: &[&str] = &["co.uk", "org.uk", "gov.uk", "ac.uk", "com.au", "net.au", "org.au", "co.nz", "co.jp", "co.kr", "com.br", "com.cn", "com.mx", "com.tw", "co.in", "co.za"];++pub fn registrable_domain(host: &str) -> String {+    if host.is_empty() { return String::new(); }+    let lower = host.to_lowercase();+    let parts: Vec<String> = lower.trim_end_matches('.').split('.').map(|s| s.to_string()).collect();+    if parts.len() <= 2 { return parts.join("."); }+    let last_two = parts[parts.len() - 2..].join(".");+    if TWO_PART_TLDS.contains(&last_two.as_str()) { parts[parts.len() - 3..].join(".") } else { last_two }+}++pub fn host_matches_pattern(host: &str, pattern: &str) -> bool {+    if host.is_empty() || pattern.is_empty() { return false; }+    let (h, p) = (host.to_lowercase(), pattern.to_lowercase());+    if p == h { return true; }+    if let Some(suffix) = p.strip_prefix('*') { if h == suffix.trim_start_matches('.') || h.ends_with(suffix) { return true; } }+    if !p.starts_with("*.") { let r = registrable_domain(&h); if !r.is_empty() && r == registrable_domain(&p) { return true; } }+    false+}++pub fn score_pattern(pattern: &str, host: &str) -> i64 {+    if !host_matches_pattern(host, pattern) { return -1; }+    let (h, p) = (host.to_lowercase(), pattern.to_lowercase());+    let exact = p == h; let glob = p.starts_with("*.");+    (if exact { 2000 } else if glob { 1000 } else { 500 }) + pattern.len() as i64+}++pub fn host_of(url: &str) -> Option<String> {+    let rest = url.split("//").nth(1)?; let hp = rest.split('/').next()?; let h = hp.split('@').last()?.split(':').next()?; if h.is_empty() { None } else { Some(h.to_lowercase()) }+}++// ── credentials ────────────────────────────────────────────────────────────────────────++pub struct Vault { pub state_dir: PathBuf }++#[derive(Debug, Clone)]+pub struct Credential { pub host: String, pub username: String, pub password: String, pub source_account: Option<String> }++impl Vault {+    pub fn new(state_dir: &Path) -> Vault { let _ = std::fs::create_dir_all(state_dir); Vault { state_dir: state_dir.to_path_buf() } }+    fn index_path(&self) -> PathBuf { self.state_dir.join("pup-credentials.index.json") }+    pub fn read_index(&self) -> Vec<Value> {+        std::fs::read_to_string(self.index_path()).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).and_then(|v| v["entries"].as_array().cloned()).unwrap_or_default()+    }+    fn write_index(&self, entries: &[Value]) { let _ = std::fs::write(self.index_path(), serde_json::to_string_pretty(&json!({ "version": 1, "entries": entries })).unwrap_or_default()); }++    pub fn set(&self, host: &dyn HostSecrets, hostname: &str, username: &str, password: &str) -> Result<(), String> {+        if hostname.is_empty() || username.is_empty() || password.is_empty() { return Err("host, username, and password are all required".into()); }+        host.secret_set(CRED_SERVICE, hostname, password)?;+        let mut entries = self.read_index();+        let now = pup_core::iso_now();+        match entries.iter_mut().find(|e| e["host"] == json!(hostname)) {+            Some(e) => { e["username"] = json!(username); e["updatedAt"] = json!(now); }+            None => entries.push(json!({ "host": hostname, "username": username, "addedAt": now, "updatedAt": now })),+        }+        self.write_index(&entries);+        Ok(())+    }++    /// Best-matching stored login for a URL (exact host > glob > sibling; then most recently used).+    pub fn get_for_url(&self, host: &dyn HostSecrets, url: &str, prefer_account: Option<&str>) -> Option<Credential> {+        let h = host_of(url)?;+        let mut matches: Vec<(i64, Value)> = self.read_index().into_iter().filter_map(|e| { let s = score_pattern(e["host"].as_str().unwrap_or(""), &h); if s >= 0 { Some((s, e)) } else { None } }).collect();+        if matches.is_empty() { return None; }+        let prefer = prefer_account.map(|p| p.to_lowercase());+        matches.sort_by(|a, b| {+            b.0.cmp(&a.0)+                .then_with(|| { let ap = prefer.as_ref().map(|p| a.1["sourceAccount"].as_str().unwrap_or("").to_lowercase() == *p).unwrap_or(false); let bp = prefer.as_ref().map(|p| b.1["sourceAccount"].as_str().unwrap_or("").to_lowercase() == *p).unwrap_or(false); bp.cmp(&ap) })+                .then_with(|| { let at = a.1["lastUsed"].as_str().or(a.1["updatedAt"].as_str()).unwrap_or(""); let bt = b.1["lastUsed"].as_str().or(b.1["updatedAt"].as_str()).unwrap_or(""); bt.cmp(at) })+        });+        let best = &matches[0].1;+        let account = best["keytarAccount"].as_str().or(best["host"].as_str()).unwrap_or("");+        let password = host.secret_get(CRED_SERVICE, account)?;+        Some(Credential { host: best["host"].as_str().unwrap_or("").into(), username: best["username"].as_str().unwrap_or("").into(), password, source_account: best["sourceAccount"].as_str().map(|s| s.into()) })+    }++    pub fn list(&self) -> Vec<Value> {+        self.read_index().into_iter().map(|e| json!({ "host": e["host"], "username": e["username"], "sourceAccount": e["sourceAccount"], "sourceBrowser": e["sourceBrowser"], "sourceProfile": e["sourceProfile"], "imported": e["imported"].as_bool().unwrap_or(false), "addedAt": e["addedAt"], "updatedAt": e["updatedAt"] })).collect()+    }++    pub fn delete(&self, host: &dyn HostSecrets, hostname: &str) -> bool {+        let entries = self.read_index();+        let mut removed = false;+        for e in entries.iter().filter(|e| e["host"] == json!(hostname)) { let account = e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or(""); if host.secret_delete(CRED_SERVICE, account) { removed = true; } }+        let kept: Vec<Value> = entries.into_iter().filter(|e| e["host"] != json!(hostname)).collect();+        self.write_index(&kept);+        removed+    }++    pub fn touch_used(&self, hostname: &str) {+        let mut entries = self.read_index();+        if let Some(e) = entries.iter_mut().find(|e| e["host"] == json!(hostname)) { e["lastUsed"] = json!(pup_core::iso_now()); self.write_index(&entries); }+    }+}++/// Locate the login form (marks the fields with data-pup-uf / data-pup-pf). Returns {ok, hasUser} or {ok:false, reason}.+pub fn locate_login_js() -> &'static str {+    r#"(() => {+    const isVisible = (el) => !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0;+    const isPwd = (el) => el && el.tagName === 'INPUT' && (el.type === 'password' || el.autocomplete === 'current-password' || el.autocomplete === 'new-password' || /(^|[^a-z])password([^a-z]|$)/i.test(el.name || '') || /(^|[^a-z])password([^a-z]|$)/i.test(el.id || ''));+    const pwd = [...document.querySelectorAll('input')].filter(isVisible).find(isPwd);+    if (!pwd) return { ok: false, reason: 'no_password_field' };+    const allInputs = [...document.querySelectorAll('input')];+    const pi = allInputs.indexOf(pwd);+    let user = [...document.querySelectorAll('input[autocomplete="username"], input[type="email"]')].find((e) => isVisible(e) && !isPwd(e));+    if (!user) { const cands = [...document.querySelectorAll('input[type="text"], input[type="tel"], input:not([type])')].filter((e) => isVisible(e) && !isPwd(e)); user = [...cands].reverse().find((c) => allInputs.indexOf(c) < pi) || cands[0] || null; }+    pwd.setAttribute('data-pup-pf', '1'); if (user) user.setAttribute('data-pup-uf', '1');+    return { ok: true, hasUser: !!user };+  })()"#+}++/// Background fill through the native value setter (React/Vue/Angular see input+change).+pub fn fill_login_js(username: &str, password: &str) -> String {+    format!(r#"((USERNAME, PASSWORD) => {{+    const setVal = (el, val) => {{ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; try {{ el.focus(); }} catch (e) {{}} setter.call(el, val); el.dispatchEvent(new Event('input', {{ bubbles: true }})); el.dispatchEvent(new Event('change', {{ bubbles: true }})); }};+    const u = document.querySelector('[data-pup-uf]'), p = document.querySelector('[data-pup-pf]');+    let fu = false; if (u && USERNAME) {{ setVal(u, USERNAME); fu = true; }} if (p) setVal(p, PASSWORD);+    return {{ fu, plen: p ? (p.value || '').length : 0 }};+  }})({}, {})"#, js_str(username), js_str(password))+}++/// Submit (or just unmark) the located form. Returns true when a submit was triggered.+pub fn submit_login_js(submit: bool) -> String {+    format!(r#"((SUBMIT) => {{+    const isVisible = (el) => !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0;+    const pwd = document.querySelector('[data-pup-pf]'); const form = pwd && pwd.form;+    const btn = SUBMIT ? ((form ? [...form.querySelectorAll('button[type="submit"], input[type="submit"], button')] : [...document.querySelectorAll('button, input[type="submit"]')]).find(isVisible)) : null;+    document.querySelectorAll('[data-pup-pf],[data-pup-uf]').forEach((e) => {{ e.removeAttribute('data-pup-pf'); e.removeAttribute('data-pup-uf'); }});+    if (!SUBMIT) return false;+    if (btn) {{ btn.click(); return true; }}+    if (form) {{ (form.requestSubmit ? form.requestSubmit() : form.submit()); return true; }}+    return false;+  }})({})"#, if submit { "true" } else { "false" })+}++// ── payment cards (Node: card_vault.js) ────────────────────────────────────────────────++pub fn normalize_pan(s: &str) -> Option<String> {+    let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();+    if digits.len() < 12 || digits.len() > 19 { return None; }+    let mut sum = 0; let mut alt = false;+    for ch in digits.chars().rev() { let mut d = ch.to_digit(10)? as u32; if alt { d *= 2; if d > 9 { d -= 9; } } sum += d; alt = !alt; }+    if sum % 10 == 0 { Some(digits) } else { None }+}++pub fn brand_of(pan: &str) -> &'static str {+    if pan.starts_with('4') { "Visa" }+    else if pan.starts_with("34") || pan.starts_with("37") { "Amex" }+    else if (51..=55).contains(&pan[..2.min(pan.len())].parse::<u32>().unwrap_or(0)) || (2221..=2720).contains(&pan[..4.min(pan.len())].parse::<u32>().unwrap_or(0)) { "Mastercard" }+    else if pan.starts_with("6011") || pan.starts_with("65") || pan.starts_with("644") || pan.starts_with("645") || pan.starts_with("646") || pan.starts_with("647") || pan.starts_with("648") || pan.starts_with("649") { "Discover" }+    else if pan.starts_with("35") { "JCB" }+    else if pan.starts_with("36") || pan.starts_with("38") || pan.starts_with("30") { "Diners" }+    else { "Card" }+}++/// "MM/YY" from "MM/YY", "MM/YYYY", "MMYY", "MM-YY".+pub fn normalize_expiry(s: &str) -> Option<String> {+    let d: String = s.chars().filter(|c| c.is_ascii_digit()).collect();+    let (m, y) = match d.len() { 4 => (&d[..2], &d[2..]), 6 => (&d[..2], &d[4..]), _ => return None };+    let mm: u32 = m.parse().ok()?; if !(1..=12).contains(&mm) { return None; }+    Some(format!("{m}/{y}"))+}++pub struct Cards { pub state_dir: PathBuf }+impl Cards {+    pub fn new(state_dir: &Path) -> Cards { Cards { state_dir: state_dir.to_path_buf() } }+    fn index_path(&self) -> PathBuf { self.state_dir.join("pup-cards.index.json") }+    pub fn read(&self) -> Vec<Value> { std::fs::read_to_string(self.index_path()).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).and_then(|v| v["cards"].as_array().cloned()).unwrap_or_default() }+    fn write(&self, cards: &[Value]) { let _ = std::fs::write(self.index_path(), serde_json::to_string_pretty(&json!({ "cards": cards })).unwrap_or_default()); }+    pub fn set(&self, host: &dyn HostSecrets, nickname: &str, number: &str, expiry: &str, cvv: &str, name: Option<&str>) -> Result<Value, String> {+        let nick = nickname.trim(); if nick.is_empty() { return Err("nickname required".into()); }+        let pan = normalize_pan(number).ok_or("card number failed the Luhn check (typo?)")?;+        let exp = normalize_expiry(expiry).ok_or("expiry must be MM/YY")?;+        let csc: String = cvv.chars().filter(|c| c.is_ascii_digit()).collect(); if csc.len() < 3 || csc.len() > 4 { return Err("cvv must be 3 or 4 digits".into()); }+        host.secret_set(CARD_SERVICE, nick, &json!({ "pan": pan, "cvv": csc }).to_string())?;+        let meta = json!({ "nickname": nick, "brand": brand_of(&pan), "last4": &pan[pan.len() - 4..], "expiry": exp, "name": name.unwrap_or(""), "addedAt": pup_core::iso_now() });+        let mut cards = self.read(); cards.retain(|c| c["nickname"] != json!(nick)); cards.push(meta.clone()); self.write(&cards);+        Ok(meta)+    }+    pub fn list(&self) -> Vec<Value> { self.read() }+    pub fn delete(&self, host: &dyn HostSecrets, nickname: &str) -> bool { let mut cards = self.read(); let before = cards.len(); cards.retain(|c| c["nickname"] != json!(nickname)); self.write(&cards); host.secret_delete(CARD_SERVICE, nickname); cards.len() < before }+    pub fn meta(&self, nickname: &str) -> Option<Value> { self.read().into_iter().find(|c| c["nickname"] == json!(nickname)) }+    /// The PAN + CVV; never leaves the bridge process (the fill types them into the page).+    pub fn secret(&self, host: &dyn HostSecrets, nickname: &str) -> Option<(String, String)> { let raw = host.secret_get(CARD_SERVICE, nickname)?; let v: Value = serde_json::from_str(&raw).ok()?; Some((v["pan"].as_str()?.into(), v["cvv"].as_str()?.into())) }+}++pub const CARD_FIELD_SELECTORS: &[(&str, &[&str])] = &[+    ("number", &["input[autocomplete=\"cc-number\"]", "input[name=\"cardnumber\"]", "input[name=\"cardNumber\"]", "input[id*=\"cardnumber\" i]", "input[name*=\"card-number\" i]", "input[id*=\"card-number\" i]", "input[name*=\"ccnumber\" i]", "input[placeholder*=\"card number\" i]"]),+    ("expiry", &["input[autocomplete=\"cc-exp\"]", "input[name=\"exp-date\"]", "input[name=\"expiry\"]", "input[id*=\"expir\" i]", "input[placeholder*=\"MM / YY\" i]", "input[placeholder*=\"MM/YY\" i]"]),+    ("cvv", &["input[autocomplete=\"cc-csc\"]", "input[name=\"cvc\"]", "input[name=\"cvv\"]", "input[id*=\"cvc\" i]", "input[id*=\"cvv\" i]", "input[name*=\"security\" i]", "input[placeholder*=\"CVC\" i]", "input[placeholder*=\"CVV\" i]"]),+    ("name", &["input[autocomplete=\"cc-name\"]", "input[name=\"ccname\"]", "input[id*=\"cardholder\" i]", "input[name*=\"cardholder\" i]", "input[placeholder*=\"name on card\" i]"]),+];++/// In one frame: find a visible field for `field`, scroll it into view, focus and select-all.+/// Returns the element center for the trusted keystrokes, or null.+pub fn card_field_js(field: &str) -> String {+    let sels = CARD_FIELD_SELECTORS.iter().find(|(f, _)| *f == field).map(|(_, s)| s.to_vec()).unwrap_or_default();+    format!(r#"(() => {{ const sels = {}; for (const sel of sels) {{ let el = null; try {{ el = document.querySelector(sel); }} catch (e) {{ continue; }} if (!el) continue; const r = el.getBoundingClientRect(); if (!(r.width > 2 && r.height > 2) || el.disabled || el.readOnly) continue; el.scrollIntoView({{ block: 'center' }}); el.focus(); try {{ el.select(); }} catch (e) {{}} const r2 = el.getBoundingClientRect(); return {{ x: r2.x + r2.width / 2, y: r2.y + r2.height / 2, tag: el.tagName, sel }}; }} return null; }})()"#, serde_json::to_string(&sels).unwrap_or("[]".into()))+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test] fn patterns() {+        assert!(host_matches_pattern("nxp.componentsearchengine.com", "*.componentsearchengine.com"));+        assert!(host_matches_pattern("componentsearchengine.com", "*.componentsearchengine.com"));+        assert!(host_matches_pattern("www.mouser.com", "mouser.com"));+        assert!(!host_matches_pattern("evil-mouser.com", "mouser.com"));+        assert!(score_pattern("www.x.com", "www.x.com") > score_pattern("*.x.com", "www.x.com"));+        assert_eq!(host_of("https://[email protected]:8443/p?q"), Some("a.b.com".into()));+    }+    #[test] fn cards() {+        assert_eq!(normalize_pan("4242 4242 4242 4242").as_deref(), Some("4242424242424242"));+        assert!(normalize_pan("4242424242424241").is_none());+        assert_eq!(brand_of("4242424242424242"), "Visa"); assert_eq!(brand_of("378282246310005"), "Amex"); assert_eq!(brand_of("5555555555554444"), "Mastercard");+        assert_eq!(normalize_expiry("12/2028").as_deref(), Some("12/28")); assert!(normalize_expiry("13/28").is_none());+    }+}
rust/verbs/Cargo.toml+2
@@ -15,3 +15,5 @@ pup-cdp = { path = "../cdp" } pup-browser = { path = "../browser" } pup-session = { path = "../session" } pup-identity = { path = "../identity" }+ureq.workspace = true+pup-vault = { path = "../vault" }
rust/verbs/assets/annotate-overlay.jsadded+328
@@ -0,0 +1,328 @@+window.__pupStartAnnotate = function (initTool, initMode) {+  if (window.__pupAnnotate) {+    // v2.0.372: a re-arm with an explicit mode SWITCHES the live overlay's mode (Alt+Shift+M on+    // an already-open toolbar). v2.0.81 rule preserved: a re-arm after emit-hide stays INVISIBLE.+    if (initMode && window.__pupAnnotate.setMode) { try { window.__pupAnnotate.setMode(initMode); } catch (e) {} }+    if (!window.__pupAnnotateHidden) window.__pupAnnotate.show();+    return 'shown';+  }+  initTool = initTool || 'pen';+  // v2.0.370 (John): two explicit capture modes. quick=true (Auto, the default) captures the+  // moment the mouse lifts — the one-red-circle case. quick=false (Multi) accumulates shapes —+  // a red circle here, a blue circle there — and captures ONCE on Done.+  var S = { tool: 'pen', color: '#f85149', strokes: [], cur: null, quick: initMode !== 'multi', seq: 0 };+  var wrap = document.createElement('div');+  wrap.id = '__pup_annotate';+  wrap.style.cssText = 'position:fixed;inset:0;z-index:2147483646;pointer-events:none;';+  var cv = document.createElement('canvas');+  cv.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:auto;touch-action:none;';+  wrap.appendChild(cv);+  var bar = document.createElement('div');+  bar.style.cssText = 'position:fixed;top:14px;left:50%;transform:translateX(-50%);display:flex;gap:6px;align-items:center;background:#161b22f2;border:1px solid #30363d;border-radius:999px;padding:7px 12px;pointer-events:auto;font:13px Satoshi,system-ui,sans-serif;color:#c9d1d9;box-shadow:0 6px 24px #000a;';+  wrap.appendChild(bar);+  // drag grip (floating-windows: draggable by a visible grip, clamped inside the viewport)+  var grip = document.createElement('div');+  grip.title = '';+  grip.style.cssText = 'width:14px;height:22px;cursor:grab;display:grid;grid-template-columns:1fr 1fr;gap:2px;align-content:center;justify-items:center;margin-right:2px;';+  for (var gi = 0; gi < 6; gi++) { var gd = document.createElement('div'); gd.style.cssText = 'width:3px;height:3px;border-radius:50%;background:#8b949e;'; grip.appendChild(gd); }+  bar.appendChild(grip);+  (function () {+    var drag = null;+    grip.addEventListener('pointerdown', function (e) {+      var r = bar.getBoundingClientRect();+      if (bar.style.transform) { bar.style.transform = ''; bar.style.left = r.left + 'px'; bar.style.top = r.top + 'px'; }+      drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };+      grip.setPointerCapture(e.pointerId); grip.style.cursor = 'grabbing'; e.preventDefault();+    });+    grip.addEventListener('pointermove', function (e) {+      if (!drag) return;+      var w = bar.offsetWidth, h = bar.offsetHeight, M = 8;+      var x = Math.min(Math.max(M, e.clientX - drag.dx), innerWidth - w - M);+      var y = Math.min(Math.max(M, e.clientY - drag.dy), innerHeight - h - M);+      bar.style.left = x + 'px'; bar.style.top = y + 'px';+    });+    grip.addEventListener('pointerup', function () { drag = null; grip.style.cursor = 'grab'; });+  })();+  // tooltip singleton (tooltips skill: fixed, body-level z-max, 600ms reveal, 0.18s ease+  // fade+translate, measured AFTER content set, clamped to the viewport on BOTH axes, flips+  // above when it would clip the bottom)+  var tipEl = document.createElement('div');+  tipEl.style.cssText = 'position:fixed;z-index:2147483647;max-width:280px;background:#21262d;border:1px solid #30363d;border-radius:8px;padding:7px 10px;font:12px Satoshi,system-ui,sans-serif;color:#c9d1d9;box-shadow:0 6px 20px #000b;pointer-events:none;opacity:0;transform:translateY(4px);transition:opacity 0.18s ease,transform 0.18s ease;text-transform:none;';+  wrap.appendChild(tipEl);+  var tipTimer = null;+  function tipShow(anchor, text) {+    clearTimeout(tipTimer);+    tipTimer = setTimeout(function () {+      tipEl.textContent = text;+      tipEl.style.opacity = '0';+      // measure only after content is set and laid out+      requestAnimationFrame(function () {+        var a = anchor.getBoundingClientRect(), M = 8;+        var w = tipEl.offsetWidth, h = tipEl.offsetHeight;+        var x = a.left + a.width / 2 - w / 2;+        var y = a.bottom + 10;+        if (y + h > innerHeight - M) y = a.top - h - 10;   // flip above instead of clipping+        x = Math.min(Math.max(M, x), innerWidth - w - M);+        y = Math.min(Math.max(M, y), innerHeight - h - M);+        tipEl.style.left = x + 'px'; tipEl.style.top = y + 'px';+        tipEl.style.opacity = '1'; tipEl.style.transform = 'translateY(0)';+      });+    }, 600);+  }+  function tipHide() { clearTimeout(tipTimer); tipEl.style.opacity = '0'; tipEl.style.transform = 'translateY(4px)'; }+  function tipFor(el, text) { el.addEventListener('mouseenter', function () { tipShow(el, text); }); el.addEventListener('mouseleave', tipHide); el.addEventListener('pointerdown', tipHide); el.removeAttribute('title'); }+  tipFor(grip, 'Drag to move this toolbar anywhere it is not in your way.');+  var ctx = cv.getContext('2d');+  // ── Photoshop-style tool cursors (v2.0.134, John's HN report: "pen mode shows the crosshair,+  // must be old code"): it was NOT old code. The pen/arrow cursors were CSS+  // url(data:image/svg+xml) images, and CSS cursor images load under the PAGE'S CSP img-src —+  // sites that don't allow data: (Hacker News: img-src 'self' ...) silently block the image and+  // CSS falls back to the declared crosshair, so one build looked new on the wiki and old on HN.+  // Fix: never FETCH a cursor at all. Hide the native cursor over the canvas and follow the+  // pointer with an inline-SVG sprite — DOM markup, which no CSP gates — hotspot preserved+  // (pen tip at 5,19; arrow tip at 2,2). Pen still carries the live color swatch.+  var spr = document.createElement('div');+  spr.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;display:none;will-change:transform;';+  wrap.appendChild(spr);+  var sprHot = [0, 0];+  function penSvg() {+    return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">'+      + '<path d="M5 19 L6.5 14.5 L16 5 a2 2 0 0 1 2.8 2.8 L9.5 17.5 Z" fill="#e5e7eb" stroke="#111" stroke-width="1.2"/>'+      + '<path d="M5 19 L6.5 14.5 L9.5 17.5 Z" fill="' + S.color + '" stroke="#111" stroke-width="0.8"/>'+      + '<circle cx="19.5" cy="19.5" r="3.5" fill="' + S.color + '" stroke="#fff" stroke-width="1"/></svg>';+  }+  function arrowSvg() {+    return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">'+      + '<path d="M2 2 L11 20 L13.5 13.5 L20 11 Z" fill="' + S.color + '" stroke="#fff" stroke-width="1.4"/></svg>';+  }+  function setCursor() {+    if (S.tool === 'pen' || S.tool === 'arrow') {+      cv.style.cursor = 'none';+      spr.innerHTML = S.tool === 'pen' ? penSvg() : arrowSvg();+      sprHot = S.tool === 'pen' ? [5, 19] : [2, 2];+      // shown on the next pointer move, so it never appears at a stale position+    } else {+      cv.style.cursor = S.tool === 'text' ? 'text' : 'crosshair';+      spr.style.display = 'none';+    }+  }+  function sprTrack(e) {+    if (S.tool !== 'pen' && S.tool !== 'arrow') return;+    spr.style.display = 'block';+    spr.style.transform = 'translate(' + (e.clientX - sprHot[0]) + 'px,' + (e.clientY - sprHot[1]) + 'px)';+  }+  cv.addEventListener('pointermove', sprTrack);+  cv.addEventListener('pointerenter', sprTrack);+  cv.addEventListener('pointerleave', function () { spr.style.display = 'none'; });+  function fit() { var d = window.devicePixelRatio || 1; cv.width = innerWidth * d; cv.height = innerHeight * d; ctx.setTransform(d, 0, 0, d, 0, 0); redraw(); }+  function drawStroke(st) {+    ctx.strokeStyle = st.color; ctx.fillStyle = st.color; ctx.lineWidth = 3.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round';+    if (st.tool === 'pen') { ctx.beginPath(); for (var i = 0; i < st.pts.length; i++) { var p = st.pts[i]; if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); } ctx.stroke(); }+    else if (st.tool === 'box') { var a = st.pts[0], b = st.pts[st.pts.length - 1]; ctx.strokeRect(Math.min(a[0],b[0]), Math.min(a[1],b[1]), Math.abs(b[0]-a[0]), Math.abs(b[1]-a[1])); }+    else if (st.tool === 'arrow') { var a2 = st.pts[0], b2 = st.pts[st.pts.length - 1]; ctx.beginPath(); ctx.moveTo(a2[0], a2[1]); ctx.lineTo(b2[0], b2[1]); ctx.stroke();+      var ang = Math.atan2(b2[1]-a2[1], b2[0]-a2[0]);+      ctx.beginPath(); ctx.moveTo(b2[0], b2[1]);+      ctx.lineTo(b2[0]-14*Math.cos(ang-0.42), b2[1]-14*Math.sin(ang-0.42));+      ctx.lineTo(b2[0]-14*Math.cos(ang+0.42), b2[1]-14*Math.sin(ang+0.42));+      ctx.closePath(); ctx.fill(); }+    else if (st.tool === 'text') { ctx.font = '600 17px system-ui,sans-serif'; ctx.fillText(st.text || '', st.pts[0][0], st.pts[0][1]); }+    else if (st.tool === 'snip') { var s1 = st.pts[0], s2 = st.pts[st.pts.length - 1]; ctx.save(); ctx.setLineDash([7, 5]); ctx.lineWidth = 2; ctx.strokeStyle = '#00d4cb'; ctx.strokeRect(Math.min(s1[0],s2[0]), Math.min(s1[1],s2[1]), Math.abs(s2[0]-s1[0]), Math.abs(s2[1]-s1[1])); ctx.restore(); }+  }+  function redraw() { ctx.clearRect(0, 0, innerWidth, innerHeight); S.strokes.forEach(drawStroke); if (S.cur) drawStroke(S.cur); }+  // Tight-crop bbox over ALL strokes, padded and clamped to the viewport (1a2).+  function bbox() {+    if (!S.strokes.length) return null;+    var x1 = 1e9, y1 = 1e9, x2 = -1e9, y2 = -1e9;+    S.strokes.forEach(function (st) {+      if (st.tool === 'text') {+        var w = (st.text || '').length * 9.5, p0 = st.pts[0];+        x1 = Math.min(x1, p0[0]); y1 = Math.min(y1, p0[1] - 18); x2 = Math.max(x2, p0[0] + w); y2 = Math.max(y2, p0[1] + 6);+      } else st.pts.forEach(function (p) { x1 = Math.min(x1, p[0]); y1 = Math.min(y1, p[1]); x2 = Math.max(x2, p[0]); y2 = Math.max(y2, p[1]); });+    });+    var M = 48;+    x1 = Math.max(0, x1 - M); y1 = Math.max(0, y1 - M);+    x2 = Math.min(innerWidth, x2 + M); y2 = Math.min(innerHeight, y2 + M);+    if (x2 - x1 < 24 || y2 - y1 < 24) return null;+    return { x: Math.round(x1), y: Math.round(y1), w: Math.round(x2 - x1), h: Math.round(y2 - y1) };+  }+  function emit(extra, hide) {+    S.seq++;+    var r = { done: true, shapes: S.strokes.length, ts: Date.now(), seq: S.seq, bbox: bbox() };+    if (extra) for (var k in extra) r[k] = extra[k];+    if (hide) { bar.style.display = 'none'; cv.style.pointerEvents = 'none'; window.__pupAnnotateHidden = true; }+    window.__pupAnnotateResult = r;+  }+  cv.addEventListener('pointerdown', function (e) {+    if (S.tool === 'text') { var t = prompt('Annotation text:'); if (t) { S.strokes.push({ tool: 'text', color: S.color, pts: [[e.clientX, e.clientY]], text: t }); redraw(); if (S.quick) { emit(null, true); removeEventListener('keydown', onKey, true); } else advanceColor(); } return; }+    S.cur = { tool: S.tool, color: S.color, pts: [[e.clientX, e.clientY]] }; cv.setPointerCapture(e.pointerId);+  });+  cv.addEventListener('pointermove', function (e) { if (S.cur) { S.cur.pts.push([e.clientX, e.clientY]); redraw(); } });+  cv.addEventListener('pointerup', function () {+    if (!S.cur) return;+    if (S.cur.tool === 'snip') {+      var p1 = S.cur.pts[0], p2 = S.cur.pts[S.cur.pts.length - 1];+      var r = { x: Math.min(p1[0], p2[0]), y: Math.min(p1[1], p2[1]), w: Math.abs(p2[0]-p1[0]), h: Math.abs(p2[1]-p1[1]) };+      S.cur = null; redraw();+      if (r.w > 8 && r.h > 8) emit({ snip: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h) } }, true);+      return;+    }+    S.strokes.push(S.cur); S.cur = null; redraw();+    // QUICK MODE (1a3): the moment the mouse lifts, capture fires — keep drawing and each lift+    // supersedes the last capture with the union bbox.+    if (S.quick) { emit(null, true); removeEventListener('keydown', onKey, true); }+    // v2.0.371 (John): in MULTI mode each finished shape auto-advances to the next color, so a+    // red circle then a blue circle needs zero clicks. White is excluded (invisible on light+    // pages); a manual swatch pick overrides and the cycle continues from there.+    else advanceColor();+  });+  function icon(paths, dash) {+    var n = document.createElementNS('http://www.w3.org/2000/svg', 'svg');+    n.setAttribute('viewBox', '0 0 24 24'); n.setAttribute('width', '14'); n.setAttribute('height', '14');+    n.setAttribute('fill', 'none'); n.setAttribute('stroke', 'currentColor');+    n.setAttribute('stroke-width', '1.9'); n.setAttribute('stroke-linecap', 'round'); n.setAttribute('stroke-linejoin', 'round');+    paths.forEach(function (dd, i) { var p2 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p2.setAttribute('d', dd); if (dash && i === dash - 1) p2.setAttribute('stroke-dasharray', '2.5 2'); n.appendChild(p2); });+    return n;+  }+  var ICONS = {+    pen: [['M4 20l1.5-4.5L16 5a2.1 2.1 0 0 1 3 3L8.5 18.5 4 20z', 'M13.5 7.5l3 3']],+    arrow: [['M5 19L17 7', 'M9 7h8v8']],+    box: [['M4.5 5.5h15v13h-15z']],+    text: [['M6 6h12', 'M12 6v12']],+    snip: [['M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3', 'M8.5 8.5h7v7h-7z'], 2],+    auto: [['M13 3L5 13h5l-1 8 8-10h-5l1-8']],+    multi: [['M4 4.5h10v10H4z', 'M10 9.5h10v10H10z']],+    undo: [['M8 5L4 9l4 4', 'M4 9h9a6 6 0 0 1 0 12h-2']],+    clear: [['M5 7h14', 'M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2', 'M7 7l1 13h8l1-13']],+    done: [['M4.5 12.5l5 5 10-11']],+    cancel: [['M6 6l12 12', 'M18 6L6 18']],+  };+  function btn(label, title, fn, id, iconKey) {+    var b = document.createElement('button');+    if (iconKey && ICONS[iconKey]) b.appendChild(icon(ICONS[iconKey][0], ICONS[iconKey][1]));+    b.appendChild(document.createTextNode(label)); b.title = title;+    b.style.cssText = 'background:none;border:1px solid transparent;border-radius:8px;color:#c9d1d9;padding:3px 8px;cursor:pointer;font:inherit;display:flex;align-items:center;gap:5px;';+    b.onmouseenter = function () { b.style.background = '#1c2128'; };+    b.onmouseleave = function () { b.style.background = S.tool === b.dataset.tool ? 'rgba(0,184,177,.13)' : 'none'; };+    b.onclick = fn; if (id) b.dataset.tool = id; bar.appendChild(b);+    if (title) { tipFor(b, title); }+    return b;+  }+  function selTool(id) { S.tool = id; setCursor(); [].forEach.call(bar.querySelectorAll('button[data-tool]'), function (x) { x.style.background = x.dataset.tool === id ? 'rgba(0,184,177,.13)' : 'none'; x.style.borderColor = x.dataset.tool === id ? 'rgba(0,184,177,.45)' : 'transparent'; }); }+  btn('Pen', 'Draw freehand. With Auto on, the capture fires the moment you release the mouse: cropped tight to your drawing, copied to your clipboard.', function () { selTool('pen'); }, 'pen', 'pen');+  btn('Arrow', 'Point at the thing you mean. Drag from tail to tip; the arrowhead lands where you release.', function () { selTool('arrow'); }, 'arrow', 'arrow');+  btn('Box', 'Frame a region with a rectangle. Drag corner to corner.', function () { selTool('box'); }, 'box', 'box');+  btn('Text', 'Drop a short text note onto the page. Click where the note should start.', function () { selTool('text'); }, 'text', 'text');+  btn('Snip', 'Capture exactly a region, like Win+Shift+S: drag a marquee and it is copied to your clipboard the moment you release.', function () { selTool('snip'); }, 'snip', 'snip');+  // v2.0.370 (John): the color control is now a one-click TRIO — red (default), blue, yellow —+  // with the rarer colors one click deeper in a flyout. The old single cycling dot made+  // red→blue three clicks; picking between about 3 colors must be instant.+  var SW_MAIN = ['#f85149', '#64abff', '#d29922'];+  var SW_MORE = ['#3fb950', '#8c6bf7', '#e6edf3'];+  var flyout = document.createElement('div');+  flyout.style.cssText = 'position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);display:none;gap:7px;background:#161b22f2;border:1px solid #30363d;border-radius:999px;padding:7px 10px;box-shadow:0 6px 24px #000a;pointer-events:auto;';+  var more = document.createElement('button');+  function setColor(c) {+    S.color = c; setCursor(); flyout.style.display = 'none';+    [].forEach.call(wrap.querySelectorAll('button[data-sw]'), function (x) { x.style.borderColor = x.dataset.sw === c ? '#fff' : 'rgba(255,255,255,.25)'; });+    more.style.outline = SW_MORE.indexOf(c) >= 0 ? '2px solid #fff' : 'none';+  }+  // v2.0.371: the multi-mode auto-advance cycle — the quick trio then green/purple, never white.+  function advanceColor() {+    var CYCLE = ['#f85149', '#64abff', '#d29922', '#3fb950', '#8c6bf7'];+    setColor(CYCLE[(CYCLE.indexOf(S.color) + 1) % CYCLE.length]);+  }+  function swBtn(c, parent) {+    var s = document.createElement('button');+    s.dataset.sw = c;+    s.style.cssText = 'width:18px;height:18px;border-radius:50%;cursor:pointer;background:' + c + ';border:2px solid ' + (S.color === c ? '#fff' : 'rgba(255,255,255,.25)') + ';padding:0;flex:none;';+    s.onclick = function () { setColor(c); };+    parent.appendChild(s);+    return s;+  }+  SW_MAIN.forEach(function (c) { swBtn(c, bar); });+  more.style.cssText = 'width:18px;height:18px;border-radius:50%;cursor:pointer;border:2px solid rgba(255,255,255,.25);padding:0;flex:none;background:conic-gradient(#f85149,#d29922,#3fb950,#64abff,#8c6bf7,#f85149);';+  more.onclick = function () { flyout.style.display = flyout.style.display === 'none' ? 'flex' : 'none'; };+  bar.appendChild(more); tipFor(more, 'More colors: green, purple, white.');+  SW_MORE.forEach(function (c) { swBtn(c, flyout); });+  bar.appendChild(flyout);+  // v2.0.370 (John): Auto vs Multi as an explicit two-state control, not a lone toggle.+  var modeWrap = document.createElement('div');+  modeWrap.style.cssText = 'display:flex;border:1px solid #30363d;border-radius:8px;overflow:hidden;margin-left:2px;';+  function modeBtn(label, iconKey, val, tip) {+    var b = document.createElement('button');+    if (ICONS[iconKey]) b.appendChild(icon(ICONS[iconKey][0], ICONS[iconKey][1]));+    b.appendChild(document.createTextNode(label));+    b.style.cssText = 'background:none;border:none;color:#c9d1d9;padding:3px 8px;cursor:pointer;font:inherit;display:flex;align-items:center;gap:5px;';+    b.onclick = function () { S.quick = val; paintMode(); };+    modeWrap.appendChild(b); tipFor(b, tip);+    return b;+  }+  var mAuto = modeBtn('Auto', 'auto', true, 'One-shot (the default): draw one thing, lift the mouse, and it is captured and copied to your clipboard instantly.');+  var mMulti = modeBtn('Multi', 'multi', false, 'Compose: draw several shapes and NOTHING is captured until you click Done. The color auto-advances after each shape (red, blue, yellow, green, purple) so every shape is distinct — click a swatch any time to override.');+  function paintMode() {+    mAuto.style.background = S.quick ? 'rgba(0,184,177,.13)' : 'none'; mAuto.style.color = S.quick ? '#00d4cb' : '#c9d1d9';+    mMulti.style.background = !S.quick ? 'rgba(0,184,177,.13)' : 'none'; mMulti.style.color = !S.quick ? '#00d4cb' : '#c9d1d9';+  }+  paintMode(); bar.appendChild(modeWrap);+  btn('Undo', 'Remove the last thing you drew. Click again to keep unwinding.', function () { S.strokes.pop(); redraw(); }, null, 'undo');+  btn('Clear', 'Erase every drawing on this page. The page itself is untouched.', function () { S.strokes = []; redraw(); }, null, 'clear');+  var done = btn('Done', 'Capture the annotated area (tight-cropped around your drawing), copy it to your clipboard, and leave the drawing visible so the AI thread that owns this window sees it too.', function () { emit(null, true); }, null, 'done');+  done.style.cssText += 'background:rgba(0,184,177,.13);border:1px solid rgba(0,184,177,.55);color:#00d4cb;font-weight:600;';+  btn('', 'Close the annotation toolbar and remove all drawings from the page (Esc does this too).', function () { exitAll(); }, null, 'cancel');+  function exitAll() { removeEventListener('keydown', onKey, true); wrap.remove(); window.__pupAnnotate = null; window.__pupAnnotateResult = { done: false, ts: Date.now() }; }+  function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); exitAll(); } }+  addEventListener('keydown', onKey, true);+  // v2.0.72: called by the bridge the moment the screenshot is TAKEN (never before, so the+  // flash is not in the shot): camera-flash feedback over the captured region, instant toast+  // with the preview, then the drawings fade out so the user is back to normal surfing.+  window.__pupCaptureDone = function (region, previewUrl, line1) {+    try {+      var fl = document.createElement('div');+      if (!region || !region.w) throw 0; // skip the flash only (caught below): John's rule, flash the captured region or nothing — toast and fade still run+      var rg = region;+      fl.style.cssText = 'position:fixed;left:' + rg.x + 'px;top:' + rg.y + 'px;width:' + rg.w + 'px;height:' + rg.h + 'px;z-index:2147483647;background:none;border:2px dashed #00d4cb;border-radius:6px;opacity:1;pointer-events:none;transition:opacity .45s ease;box-sizing:border-box;';+      document.documentElement.appendChild(fl);+      setTimeout(function () { fl.style.opacity = '0'; }, 550);+      setTimeout(function () { try { fl.remove(); } catch (e) {} }, 1100);+    } catch (e) {}+    try {+      var old = document.getElementById('__pup_ann_toast'); if (old) old.remove();+      var t = document.createElement('div'); t.id = '__pup_ann_toast';+      t.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:18px;z-index:2147483647;background:#21262df2;border:1px solid #30363d;border-radius:12px;padding:10px;box-shadow:0 8px 30px #000c;font:12px Satoshi,system-ui,sans-serif;color:#c9d1d9;max-width:340px;cursor:pointer;';+      var cap = document.createElement('div'); cap.textContent = line1 || 'Captured'; cap.style.cssText = 'margin-bottom:7px;color:#00d4cb;font-weight:500;';+      t.appendChild(cap);+      // v2.0.135: the preview arrives as RAW RGBA pixels and is PAINTED (putImageData), never+      // LOADED — an <img src="data:..."> is a resource load the page's CSP img-src can veto+      // (Hacker News does), which showed the user a broken-image icon. Pixel writes to a canvas+      // are not loads; no site's CSP can break this. (String form kept for mixed-version overlap.)+      if (previewUrl && previewUrl.b64 && previewUrl.w) {+        try {+          var pc = document.createElement('canvas'); pc.width = previewUrl.w; pc.height = previewUrl.h;+          pc.style.cssText = 'display:block;max-width:320px;max-height:200px;border-radius:8px;border:1px solid #30363d;';+          var bin = atob(previewUrl.b64), n = bin.length, arr = new Uint8ClampedArray(n);+          for (var i2 = 0; i2 < n; i2++) arr[i2] = bin.charCodeAt(i2);+          pc.getContext('2d').putImageData(new ImageData(arr, previewUrl.w, previewUrl.h), 0, 0);+          t.appendChild(pc);+        } catch (e2) {}+      } else if (previewUrl && typeof previewUrl === 'string') { var im = document.createElement('img'); im.src = previewUrl; im.style.cssText = 'display:block;max-width:320px;max-height:200px;border-radius:8px;border:1px solid #30363d;'; t.appendChild(im); }+      t.onclick = function () { t.remove(); };+      document.documentElement.appendChild(t);+      setTimeout(function () { try { t.remove(); } catch (e) {} }, 4500);+    } catch (e) {}+    // fade the drawings out (John: after the snapshot, back to normal surfing)+    setTimeout(function () {+      try {+        cv.style.transition = 'opacity .8s ease'; cv.style.opacity = '0';+        setTimeout(function () { try { wrap.remove(); window.__pupAnnotate = null; window.__pupCaptureDone = null; removeEventListener('keydown', onKey, true); } catch (e) {} }, 900);+      } catch (e) {}+    }, 2200);+  };+  window.__pupAnnotate = {+    show: function () { window.__pupAnnotateHidden = false; wrap.style.display = ''; bar.style.display = 'flex'; cv.style.pointerEvents = 'auto'; addEventListener('keydown', onKey, true); },+    setMode: function (m) { S.quick = m !== 'multi'; paintMode(); },+  };+  addEventListener('resize', fit);+  document.documentElement.appendChild(wrap);+  fit(); selTool(initTool);+  return 'started';+};\ No newline at end of file
rust/verbs/assets/capture-login.jsadded+58
@@ -0,0 +1,58 @@+(() => {+  if (window !== window.top) return;+  if (window.__pupCapV === '${BRIDGE_VERSION}') return;+  window.__pupCapV = '${BRIDGE_VERSION}';+  function vis(el){ return !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0; }+  // v2.0.123: a password field is not always type=password — Gusto and many SSO/Keycloak themes use+  // type=text with autocomplete="current-password" (or name/id "password") and mask it themselves.+  function isPwd(el){ return el && el.tagName === 'INPUT' && (el.type === 'password' || el.autocomplete === 'current-password' || el.autocomplete === 'new-password' || /(^|[^a-z])password([^a-z]|$)/i.test(el.name || '') || /(^|[^a-z])password([^a-z]|$)/i.test(el.id || '')); }+  function grab(form){+    try {+      var pwd = [].slice.call((form || document).querySelectorAll('input')).filter(vis).filter(isPwd)[0];+      if (!pwd || !pwd.value) return;+      var all = [].slice.call((form || document).querySelectorAll('input'));+      var pi = all.indexOf(pwd);+      var user = [].slice.call((form || document).querySelectorAll('input[autocomplete=username], input[type=email]')).filter(vis).filter(function(e){ return !isPwd(e); })[0];+      if (!user) {+        var cands = [].slice.call((form || document).querySelectorAll('input[type=text], input[type=tel], input:not([type])')).filter(vis).filter(function(e){ return !isPwd(e); });+        user = cands.slice().reverse().filter(function(c){ return all.indexOf(c) < pi; })[0] || cands[0] || null;+      }+      var rec = { host: location.hostname, u: user ? user.value : '', p: pwd.value, ts: Date.now() };+      // Prefer the SYNCHRONOUS binding — it reaches the bridge BEFORE the submit navigates the+      // page away (the global stash below would be wiped by that navigation, missing the capture).+      if (typeof window.__pupCredCapture === 'function') { try { window.__pupCredCapture(JSON.stringify(rec)); } catch (e) {} }+      window.__pupCapturedLogin = rec;   // fallback for SPA logins that never navigate (sweep harvests it)+    } catch (e) {}+  }+  function formHasPwd(f){ try { return !!f && [].slice.call(f.querySelectorAll('input')).some(isPwd); } catch (e) { return false; } }+  addEventListener('submit', function(e){ grab(e.target && e.target.tagName === 'FORM' ? e.target : null); }, true);+  addEventListener('click', function(e){+    try {+      var t = e.target && e.target.closest && e.target.closest('button, input[type=submit], [role=button], a');+      if (!t) return;+      var f = t.form || (t.closest && t.closest('form'));+      // v2.0.128 (consumer bug report #4): was a literal input[type=password] check, so a masked+      // type=text password field (Rails/Keycloak) never matched and capture missed on click. Use the+      // isPwd matcher; if there is no enclosing form, sweep the document (SPA login with no <form>).+      if (formHasPwd(f)) grab(f); else if (!f) grab(null);+    } catch (e2) {}+  }, true);+  // v2.0.121 (John, Gusto miss): ALSO capture the instant the password field loses focus or+  // changes — this fires BEFORE the form submits and, crucially, before a cross-origin SSO redirect+  // (login.gusto.com -> app.gusto.com) tears the page down. Submit-only capture raced that redirect+  // and lost; grabbing on blur/change delivers the creds through the synchronous binding first.+  addEventListener('change', function(e){ try { if (isPwd(e.target) && e.target.value) grab(e.target.form || null); } catch (e2) {} }, true);+  addEventListener('blur', function(e){ try { if (isPwd(e.target) && e.target.value) grab(e.target.form || null); } catch (e2) {} }, true);+  // v2.0.128 (consumer bug report #4): a STANDARD Rails login often submits PROGRAMMATICALLY —+  // form.submit() (and requestSubmit's older cousins) does NOT dispatch the 'submit' event, so+  // submit/click capture silently missed it. Hook the prototype so a programmatic submit still grabs.+  try {+    var _origSubmit = HTMLFormElement.prototype.submit;+    HTMLFormElement.prototype.submit = function(){ try { if (formHasPwd(this)) grab(this); } catch (e) {} return _origSubmit.apply(this, arguments); };+  } catch (e) {}+  // Last-resort net: on Enter inside a login form, and on pagehide before ANY navigation tears the+  // page down, grab whatever password field is currently filled. Cheap, synchronous, dedup is+  // downstream (same host+user+pwd within the session is coalesced).+  addEventListener('keydown', function(e){ try { if (e.key === 'Enter' && isPwd(document.activeElement) && document.activeElement.value) grab(document.activeElement.form || null); } catch (e2) {} }, true);+  addEventListener('pagehide', function(){ try { grab(null); } catch (e2) {} }, true);+})()\ No newline at end of file
rust/verbs/assets/cursor.jsadded+51
@@ -0,0 +1,51 @@+((tx, ty, doClick, lbl, markUri) => {+      var id = '__adom_cursor__';+      var c = document.getElementById(id);+      if (!c) {+        c = document.createElement('div');+        c.id = id;+        c.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;transition:left 0.15s,top 0.15s;display:none;';+        var g = document.createElement('div'); // pulsing teal glow ring+        g.id = '__adom_cursor_ring__';+        g.style.cssText = 'position:absolute;top:-16px;left:-16px;width:40px;height:40px;border-radius:50%;background:rgba(0,184,176,0.15);border:2px solid rgba(0,184,176,0.4);animation:adomCurP 1.5s ease-in-out infinite;';+        c.appendChild(g);+        var a = document.createElement('div'); // the teal Adom arrow (HD's exact SVG)+        a.innerHTML = '<svg width="20" height="28" viewBox="0 0 20 28" xmlns="http://www.w3.org/2000/svg"><path d="M1 1L1 22L6.5 16.5L10.5 26L14 24.5L10 15L17 15Z" fill="#00b8b0" stroke="#004d49" stroke-width="1.2" stroke-linejoin="round"/></svg>';+        a.style.cssText = 'position:absolute;top:0;left:0;filter:drop-shadow(0 0 6px rgba(0,184,176,0.7));';+        c.appendChild(a);+        var l = document.createElement('div'); // the intent label (+ Adom mark)+        l.id = '__adom_cursor_label__';+        l.style.cssText = 'position:absolute;top:32px;left:8px;white-space:nowrap;font-size:11px;color:#00b8b0;background:rgba(13,17,23,0.92);padding:2px 10px;border-radius:4px;border:1px solid rgba(0,184,176,0.6);font-family:monospace;display:flex;align-items:center;gap:6px;';+        c.appendChild(l);+        (document.body || document.documentElement).appendChild(c);+        if (!document.getElementById('__adom_cur_kf__')) {+          var s = document.createElement('style'); s.id = '__adom_cur_kf__';+          s.textContent = '@keyframes adomCurP{0%{transform:scale(1);opacity:0.7}50%{transform:scale(1.5);opacity:0.2}100%{transform:scale(1);opacity:0.7}}@keyframes adomCurClick{0%{transform:scale(1);opacity:0.85}45%{transform:scale(2.2);opacity:0.05}100%{transform:scale(1);opacity:0.7}}';+          document.head.appendChild(s);+        }+      }+      var lab = c.querySelector('#__adom_cursor_label__');+      if (lab) {+        var txt = (lbl != null ? lbl : 'Adom');+        // Adom mark FIRST (so "who is driving" reads before the narration), then the intent text.+        lab.innerHTML = '';+        if (markUri) {+          var im = document.createElement('img');+          im.src = markUri;+          im.style.cssText = 'width:13px;height:13px;flex:0 0 13px;display:block;';+          lab.appendChild(im);+        }+        var sp = document.createElement('span');+        sp.textContent = txt;+        lab.appendChild(sp);+        var nearRight = (tx != null) && (tx > (window.innerWidth - 250));+        if (nearRight) { lab.style.left = 'auto'; lab.style.right = '0px'; lab.style.textAlign = 'right'; }+        else { lab.style.left = '8px'; lab.style.right = 'auto'; lab.style.textAlign = 'left'; }+      }+      if (tx != null && ty != null) { c.style.left = tx + 'px'; c.style.top = ty + 'px'; }+      c.style.display = 'block';+      if (doClick) {+        var ring = c.querySelector('#__adom_cursor_ring__');+        if (ring) { ring.style.animation = 'none'; void ring.offsetWidth; ring.style.animation = 'adomCurClick 0.45s ease-out, adomCurP 1.5s ease-in-out infinite 0.45s'; }+      }+    })(__X__, __Y__, __CLICK__, __LABEL__, __MARK__);
rust/verbs/assets/floaty.jsadded+101
@@ -0,0 +1,101 @@+(() => {+  // v2.0.89: VERSIONED guard. evaluateOnNewDocument scripts from every prior bridge process stay+  // registered on long-lived pages and run oldest-first, so a plain boolean guard let the OLDEST+  // floaty win forever (John kept seeing the big hover zone after reloads). The newest module now+  // tears down any elder and rebuilds.+  if (window !== window.top) return;+  if (window.__pupFloatyV === '${BRIDGE_VERSION}:__HITPX__:__FLOATY_ON__:__HOTKEY_ON__') return;+  window.__pupFloatyV = '${BRIDGE_VERSION}:__HITPX__:__FLOATY_ON__:__HOTKEY_ON__'; window.__pupFloaty = true;+  var _oldHost = document.getElementById('__pup_floaty'); if (_oldHost) _oldHost.remove();+  function add() {+    // v2.0.95 (John: the corner hover zone fights buttons in app headers — off by default now+    // that the toolbar pen button covers discovery; the elder-host removal above still runs so+    // flipping the setting OFF tears the pencil out of every open page).+    if (!'__FLOATY_ON__') return;+    if (!document.body) { setTimeout(add, 400); return; }+    var host = document.createElement('div');+    host.id = '__pup_floaty';+    // v2.0.84 (John): TUCKED AWAY by default so it never interferes with page UI — a corner+    // hover zone reveals it (the taskbar-tuck pattern), tracked via mousemove (zero hit-testing+    // interference). Once dragged (grip, floating-windows skill), it pins visible where placed.+    host.style.cssText = 'position:fixed;top:14px;right:14px;z-index:2147483645;pointer-events:auto;display:flex;align-items:center;gap:4px;opacity:0;transform:translateY(-6px);transition:opacity .2s ease,transform .2s ease;';+    var root = host.attachShadow({ mode: 'closed' });+    var row = document.createElement('div');+    row.style.cssText = 'display:flex;align-items:center;gap:4px;';+    var grip = document.createElement('div');+    grip.setAttribute('aria-label', 'Move the annotate button');+    grip.style.cssText = 'width:12px;height:22px;cursor:grab;display:none;grid-template-columns:1fr 1fr;gap:2px;align-content:center;justify-items:center;';+    for (var gi = 0; gi < 6; gi++) { var gd = document.createElement('div'); gd.style.cssText = 'width:2.5px;height:2.5px;border-radius:50%;background:#8b949e;'; grip.appendChild(gd); }+    var btn = document.createElement('button');+    btn.setAttribute('aria-label', 'Annotate this page');+    btn.style.cssText = 'width:30px;height:30px;border-radius:8px;display:grid;place-items:center;background:rgba(13,17,23,.72);border:1px solid rgba(0,184,177,.45);cursor:pointer;padding:0;';+    btn.innerHTML = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.5-4.5L16 5a2.1 2.1 0 0 1 3 3L8.5 18.5 4 20z"/><path d="M13.5 7.5l3 3"/></svg>';+    var tip = document.createElement('div');+    tip.innerHTML = '<div style="font-weight:700;color:#e6edf3;margin-bottom:4px;font-family:Familjen Grotesk,Satoshi,sans-serif;">Annotate this page</div>'+      + '<div style="color:#c9d1d9;">Draw on the page to show your AI exactly what you mean; a red circle says it best. The moment you lift the mouse, the marked-up area is copied to your clipboard, ready to paste into any AI chat.</div>'+      + '<div style="margin-top:7px;color:#8b949e;font-size:11.5px;">Shortcut&nbsp;&nbsp;<span style="font-family:JetBrains Mono,monospace;font-size:10.5px;border:1px solid #30363d;border-radius:4px;padding:1px 6px;color:#c9d1d9;">Alt&nbsp;Shift&nbsp;A</span></div>';+    tip.style.cssText = 'position:fixed;z-index:2147483647;max-width:min(320px,calc(100vw - 40px));background:rgba(22,27,34,.96);color:#e6edf3;border:1px solid rgba(0,184,177,.28);border-radius:10px;padding:9px 13px;font:400 13px/1.4 Satoshi,system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.55);backdrop-filter:blur(16px);pointer-events:none;opacity:0;transform:translateY(4px);transition:opacity .18s ease,transform .18s ease;';+    row.appendChild(grip); row.appendChild(btn); root.appendChild(row); root.appendChild(tip);+    var timer = null, pinned = false, shown = false, hideTimer = null, idleTimer = null, moves = 0;+    // v2.0.93 (John: "why is the pen showing in the window you just opened?"): a window that+    // opens maximized UNDER a parked cursor gets ONE synthetic hover mousemove from Chromium at+    // load; if that lands in the corner zone the pencil revealed and stayed stuck (tuck only+    // runs on real movement). A reveal that saw almost no movement is that synthetic case:+    // auto-tuck it. Real hover-in produces dozens of mousemoves and is untouched.+    function reveal() {+      clearTimeout(hideTimer); clearTimeout(idleTimer); moves = 0;+      idleTimer = setTimeout(function () { if (!pinned && moves < 3) { shown = false; host.style.opacity = '0'; host.style.transform = 'translateY(-6px)'; grip.style.display = 'none'; tipHide(); } }, 3500);+      if (shown) return; shown = true; host.style.opacity = '1'; host.style.transform = 'translateY(0)'; grip.style.display = 'grid';+    }+    function tuck() { if (pinned) return; hideTimer = setTimeout(function () { shown = false; host.style.opacity = '0'; host.style.transform = 'translateY(-6px)'; grip.style.display = 'none'; tipHide(); }, 1200); }+    // corner hover zone via mousemove: NOTHING intercepts page clicks+    addEventListener('mousemove', function (e) {+      moves++;+      if (pinned) return;+      var hr = host.getBoundingClientRect();+      var inCorner = e.clientX > innerWidth - __HITPX__ && e.clientY < __HITPX__;   // v2.0.90: user-tunable corner target (floatyHitPx slider in Settings)+      var onHost = shown && e.clientX >= hr.left - 8 && e.clientX <= hr.right + 8 && e.clientY >= hr.top - 8 && e.clientY <= hr.bottom + 8;+      if (inCorner || onHost) reveal(); else if (shown) tuck();+    }, { passive: true });+    function tipShow() { clearTimeout(timer); timer = setTimeout(function () {+      var a = btn.getBoundingClientRect(), M = 8, w = tip.offsetWidth, h = tip.offsetHeight;+      var x = Math.min(Math.max(M, a.right - w), innerWidth - w - M);+      var y = a.bottom + 10; if (y + h > innerHeight - M) y = a.top - h - 10;+      y = Math.min(Math.max(M, y), innerHeight - h - M);+      tip.style.left = x + 'px'; tip.style.top = y + 'px'; tip.style.opacity = '1'; tip.style.transform = 'translateY(0)';+    }, 600); }+    function tipHide() { clearTimeout(timer); tip.style.opacity = '0'; tip.style.transform = 'translateY(4px)'; }+    btn.onmouseenter = tipShow; btn.onmouseleave = tipHide;+    btn.onclick = function () { tipHide(); btn.style.borderColor = '#00d4cb'; if (window.__pupStartAnnotate) { window.__pupStartAnnotate('pen'); } window.__pupFloatyClick = Date.now(); if (typeof window.__pupFloatyPush === 'function') { try { window.__pupFloatyPush('btn'); window.__pupFloatyClick = null; } catch (e) {} } setTimeout(function () { btn.style.borderColor = 'rgba(0,184,177,.45)'; }, 1500); };+    // drag by grip (floating-windows skill: visible grip, viewport-clamped); a drag PINS it+    (function () {+      var drag = null;+      grip.addEventListener('pointerdown', function (e) {+        var r = host.getBoundingClientRect();+        host.style.right = ''; host.style.left = r.left + 'px'; host.style.top = r.top + 'px';+        drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };+        grip.setPointerCapture(e.pointerId); grip.style.cursor = 'grabbing'; e.preventDefault();+      });+      grip.addEventListener('pointermove', function (e) {+        if (!drag) return;+        var w = host.offsetWidth, h = host.offsetHeight, M = 8;+        host.style.left = Math.min(Math.max(M, e.clientX - drag.dx), innerWidth - w - M) + 'px';+        host.style.top = Math.min(Math.max(M, e.clientY - drag.dy), innerHeight - h - M) + 'px';+      });+      grip.addEventListener('pointerup', function () { drag = null; pinned = true; grip.style.cursor = 'grab'; });+    })();+    document.body.appendChild(host);+  }+  add();+  if ('__HOTKEY_ON__') addEventListener('keydown', function (e) {+    // v2.0.372 (John): Alt+Shift+A = annotate in Auto; Alt+Shift+M = annotate straight into+    // MULTI (compose shapes, auto-advancing colors, capture once on Done). If the overlay is+    // already up, the hotkey switches its mode instead of being ignored.+    if (e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'A' || e.key === 'a' || e.key === 'M' || e.key === 'm')) {+      e.preventDefault(); e.stopPropagation();+      var mm = (e.key === 'M' || e.key === 'm') ? 'multi' : 'auto';+      if (window.__pupStartAnnotate) { window.__pupStartAnnotate('pen', mm); }+      window.__pupFloatyClick = Date.now(); if (typeof window.__pupFloatyPush === 'function') { try { window.__pupFloatyPush('hotkey'); window.__pupFloatyClick = null; } catch (e) {} }+    }+  }, true);+})()\ No newline at end of file
rust/verbs/assets/owner-chip.jsadded+25
@@ -0,0 +1,25 @@+((thread, profile, avatar, ident) => {+        try {+          const add = () => {+            try {+              const root = document.documentElement; if (!root) return;+              if (root.querySelector('div[data-pup-owner]')) return;+              const host = document.createElement('div');+              host.setAttribute('data-pup-owner', '1');+              host.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483647;pointer-events:none;margin:0;padding:0;';+              const sh = host.attachShadow ? host.attachShadow({ mode: 'closed' }) : null;+              const lead = avatar+                ? '<img class="av" alt="" src="data:image/png;base64,' + avatar + '">'+                : '<span class="hx">⬢</span>';+              const html = '<style>.c{display:flex;align-items:center;gap:6px;font:11px/1.35 "JetBrains Mono",ui-monospace,SFMono-Regular,monospace;color:#7fe9e2;background:rgba(13,17,23,.72);border:1px solid rgba(0,184,177,.45);border-radius:13px;padding:2px 9px 2px 3px;white-space:nowrap;opacity:.92;transition:opacity .5s ease;user-select:none}.c b{color:#e6edf3;font-weight:600}.av{width:20px;height:20px;border-radius:50%;object-fit:cover;flex:0 0 auto;border:1px solid rgba(0,184,177,.55)}.hx{padding-left:5px}.dim{opacity:.3}</style>'+                + '<div class="c" id="pupc" title="' + (ident ? String(ident) + ' — ' : '') + 'Adom pup">' + lead + '<span><b>' + String(thread) + '</b> · ' + String(profile) + '</span></div>';+              if (sh) { sh.innerHTML = html; } else { host.innerHTML = html; }+              root.appendChild(host);+              const el = sh ? sh.getElementById('pupc') : host.querySelector('#pupc');+              setTimeout(() => { try { if (el) el.classList.add('dim'); } catch (e) {} }, 4500);+            } catch (e) {}+          };+          if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add, { once: true });+          else add();+        } catch (e) {}+})(__THREAD__, __PROFILE__, __AVATAR__, __IDENT__);
rust/verbs/assets/page-tag.jsadded+68
@@ -0,0 +1,68 @@+((sid, minimal, label, tagOn) => {+      // v1.9.185: carry minimal-touch into the page so the title observer below can skip installing+      // itself (see the note at `observe`). Read at document-start, before any page script runs.+      try { if (minimal) window.__pupMinimalTouch = true; } catch (e) {}+      // TOLERANT marker check (v1.8.19): a tab's own suffix may be+      // " (session: <sid>)" OR " (session: <sid> | <tabId>)". This injector must+      // only ADD a marker when NONE is present — if it enforced an exact+      // " (session: <sid>)" it would fight addTabToSession's " | <tabId>" suffix:+      // two MutationObservers appending different strings ping-pong forever, peg the+      // renderer, and flood the CDP pipe (which HUNG pup_open_tab on Edge and+      // poisoned the whole bridge). Matching on the marker PREFIX makes it cooperate.+      const LBL = label || 'ai-thread';+      const MARKER = ' (' + LBL + ': ' + sid;   // no closing paren — matches both forms+      const BASE = ' (' + LBL + ': ' + sid + ')';+      // v1.9.49: do NOT require a pre-existing title. This guard used to be+      //     if (document.title && ...)+      // so a page that never sets a <title> (plenty of app routes and SPA shells serve one) kept an+      // EMPTY title, the session marker was never injected, and the OS window title stayed a bare+      // "Google Chrome for Testing". AD resolves pup windows by their "(session: <id>)" suffix, so+      // that window became INVISIBLE to every branding call: identity stamp, category flip, overlay+      // and jump list all failed with "No visible window with title containing (session: ...)".+      // That is the failure behind icons that look stale or never update — the window was findable+      // right up until it navigated to a title-less page. An empty title must still get the marker.+      // v1.9.56: SELF-HEALING against DUPLICATES. The old check only asked "is my marker absent?",+      // so once a second copy appeared it was never collapsed — measured live as+      //   "○ Public · Hydrogen Desktop - Adom Wiki (session: hdr-wiki) (session: hdr-wiki)"+      // (107-char title). Two independent MutationObservers touch this title (this session-suffix+      // injector and the wiki state-glyph injector); a page that rewrites its own title can let both+      // fire before either sees the other's write, and each appends. Duplicated tags bloat the+      // taskbar-thumbnail CAPTION (the surface we want for status) and, worse, AD resolves pup windows+      // by "(session: <id>" — the same duplicate-tag class that previously made branding land on the+      // WRONG window. So: count OUR OWN tag and normalise to exactly one. Only our own sid is touched,+      // so an adopted tab's re-tag (retagSessionTitle) is never fought.+      const RE_MINE = new RegExp('\\s*\\((?:session|ai-thread): ' + sid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[^)]*)\\)', 'g');+      const ensure = () => {+        try {+          const cur = String(document.title || '');+          if (!tagOn) { const clean = cur.replace(RE_MINE, ''); if (clean !== cur) document.title = clean; return; }  // OFF: keep title clean+          const n = (cur.match(RE_MINE) || []).length;+          if (n === 1) return;                                   // already exactly one — leave it alone+          if (n === 0) { document.title = cur + BASE; return; }  // none — add ours+          document.title = cur.replace(RE_MINE, '') + BASE;      // duplicates — collapse to one+        } catch (e) {}+      };+      const observe = () => {+        // v1.9.288: when the tag is OFF we strip once (above) but must NOT run a stripping observer -+        // it would instantly remove the TRANSIENT tag pup adds to resolve the window's hwnd, forcing+        // ambiguous geometry (two same-profile windows share a rect => same hwnd, John's dup-hwnd bug).+        if (!tagOn) return true;+        // v2.0.20: ONLY a real <title> node. The old head/documentElement fallback observed the+        // ENTIRE document subtree on titleless pages (raw JSON/API views), firing ensure() on every+        // DOM mutation — a renderer-pegging cost on exactly the pages least able to afford it. A+        // titleless page keeps its once-stamped tag; continuous enforcement is not worth a freeze.+        const t = document.querySelector('title');+        if (!t) return true;+        // v1.9.185 minimal-touch: in MINIMAL mode we stamp the title ONCE and do NOT install a+        // MutationObserver. On a heavy SPA (Google Slides/Sheets rewrite document.title constantly)+        // this observer re-fired on every one of those writes, each time writing the title BACK —+        // an in-page write loop pup itself created, on the exact windows John saw freeze. The+        // window still carries the tag from the initial stamp; only the continuous re-enforcement+        // is dropped. (window.name, set below, is the durable identity anyway.)+        if (window.__pupMinimalTouch) return true;+        try { window.__pupTagObs = new MutationObserver(ensure); window.__pupTagObs.observe(t, { childList: true, subtree: true, characterData: true }); } catch (e) {}+        return true;+      };+      ensure();+      if (!observe()) document.addEventListener('DOMContentLoaded', () => { observe(); ensure(); });+    })(__SID__, __MINIMAL__, __LABEL__, __TAGON__);
rust/verbs/assets/title.jsadded+25
@@ -0,0 +1,25 @@+(function () {+  if (window !== window.top) return;+  if (window.__pupTitleV === '${BRIDGE_VERSION}:__OWNER__') return;+  window.__pupTitleV = '${BRIDGE_VERSION}:__OWNER__';+  if (window.__pupTitleTimer) { clearInterval(window.__pupTitleTimer); window.__pupTitleTimer = null; }+  try {+    var prev = window.__pupTitlePre, t0 = document.title || '';+    if (prev && t0.slice(0, prev.length) === prev) document.title = t0.slice(prev.length);+  } catch (e) {}+  var OWNER = '__OWNER__';+  if (!OWNER) { window.__pupTitlePre = null; return; }+  // v2.0.96: PREFIX, not suffix — Chrome ellipsizes the tab at a fixed width, so a suffix+  // truncated out of sight and the whole point is that the thread name is DIRECTLY visible.+  var PRE = OWNER + ' \u00b7 ';+  window.__pupTitlePre = PRE;+  function apply() {+    try {+      var t = document.title || '';+      if (t.slice(0, PRE.length) === PRE) return;+      document.title = PRE + t;+    } catch (e) {}+  }+  apply();+  window.__pupTitleTimer = setInterval(apply, 1500);+})()\ No newline at end of file
rust/verbs/src/annotate.rsadded+116
@@ -0,0 +1,116 @@+//! Annotate and snip: the in-page overlay (src/extension-annotate + the embedded overlay script),+//! the capture watcher, tight-crop screenshot, caption bar (drawn by the page's own canvas), the+//! iTXt sidecar, the clipboard (ab desktop_clipboard_set, adom-bridge#145) and the on-page+//! confirmation toast. Ports startAnnotation / processAnnotateCapture / addCaptionBar / pngAddITXt.++use crate::input::resolve_tab;+use crate::{browser_for, touch, Ctx};+use pup_core::{err, ok, Caller};+use serde_json::{json, Value};+use std::sync::Arc;+use std::time::{Duration, Instant};++pub const OVERLAY_JS: &str = include_str!("../assets/annotate-overlay.js");+pub const FLOATY_JS: &str = include_str!("../assets/floaty.js");+const T: Duration = Duration::from_secs(10);++pub fn floaty_script(ctx: &Ctx) -> String {+    let px = ctx.settings.get("floatyHitPx", "24");+    FLOATY_JS.replace("${BRIDGE_VERSION}", pup_core::version()).replace("__HITPX__", &px).replace("__FLOATY_ON__", if ctx.settings.get("tbFloatyPencil", "off") == "on" { "1" } else { "" }).replace("__HOTKEY_ON__", if ctx.settings.get("tbSnipHotkey", "on") != "off" { "1" } else { "" })+}++/// pup_annotate {tool: pen|snip, mode: auto|multi}: arm the overlay and watch for the capture.+pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let tool = args["tool"].as_str().unwrap_or("pen").to_string();+    let mode = args["mode"].as_str().filter(|m| *m == "multi" || *m == "auto").map(|m| m.to_string());+    if let Err(e) = b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ if (!window.__pupStartAnnotate) {{ {OVERLAY_JS} }} return !!window.__pupStartAnnotate; }})()"), T) { return err("annotate_inject_failed", e.to_string(), None); }+    let call = format!("window.__pupStartAnnotate({}, {})", serde_json::to_string(&tool).unwrap(), mode.as_ref().map(|m| serde_json::to_string(m).unwrap()).unwrap_or("null".into()));+    if let Err(e) = b.cdp.evaluate(&t.cdp_session, &call, T) { return err("annotate_start_failed", e.to_string(), None); }+    pup_core::log(format!("[annotate] \"{}\" overlay started (by {}, tool={tool}, mode={})", s.id, caller.thread, mode.as_deref().unwrap_or("default")));+    if let Some(a) = ctx.arc() { watch(a, s.id.clone(), t.cdp_session.clone(), caller.clone()); }+    touch(ctx, &s.id, None);+    ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "tool": tool, "mode": mode, "armed": true }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner,+        "_hint": "Overlay armed on the page: the user draws (pen) or drags a region (snip). Auto mode captures on mouse-up; Multi mode captures on Done. The capture lands as a PNG in pup's screenshots folder AND on the user's clipboard (image + JSON sidecar); the owning thread's next pup_screenshot also shows the drawing. Nothing else to call." }))+}++/// Poll window.__pupAnnotateResult (500 ms, up to 15 min) and process each capture.+pub fn watch(ctx: Arc<Ctx>, sid: String, cdp_session: String, caller: Caller) {+    std::thread::Builder::new().name("annotate-watch".into()).spawn(move || {+        let t0 = Instant::now();+        while t0.elapsed() < Duration::from_secs(15 * 60) {+            std::thread::sleep(Duration::from_millis(500));+            let Some(b) = browser_for(&ctx, &ctx.sessions.get(&sid).map(|s| s.profile).unwrap_or_default()) else { return };+            let res = match b.cdp.evaluate(&cdp_session, "(() => { const r = window.__pupAnnotateResult; window.__pupAnnotateResult = null; return r || null; })()", Duration::from_secs(5)) { Ok(v) => v, Err(_) => return };+            if res.is_null() { continue; }+            if let Err(e) = process(&ctx, &sid, &cdp_session, &res, &caller) { pup_core::log(format!("[annotate] \"{sid}\" capture failed: {e}")); }+            if res["mode"] == json!("multi") || res["done"] == json!(true) { /* keep watching: the user may draw again */ }+        }+    }).ok();+}++fn process(ctx: &Ctx, sid: &str, cdp_session: &str, res: &Value, caller: &Caller) -> Result<(), String> {+    let s = ctx.sessions.get(sid).ok_or("session gone")?;+    let b = browser_for(ctx, &s.profile).ok_or("browser gone")?;+    let is_snip = res["snip"].is_object();+    let clip = if is_snip { res["snip"].clone() } else { res["bbox"].clone() };+    pup_core::log(format!("[annotate] \"{sid}\" capture: screenshotting ({})", if is_snip { "snip" } else { "tight-crop" }));+    let mut p = json!({ "format": "png", "captureBeyondViewport": false });+    if clip.is_object() { p["clip"] = json!({ "x": clip["x"].as_f64().unwrap_or(0.0).max(0.0), "y": clip["y"].as_f64().unwrap_or(0.0).max(0.0), "width": clip["w"].as_f64().unwrap_or(1.0).max(1.0), "height": clip["h"].as_f64().unwrap_or(1.0).max(1.0), "scale": 1 }); }+    let r = b.cdp.send(Some(cdp_session), "Page.captureScreenshot", p, Duration::from_secs(15)).map_err(|e| e.to_string())?;+    let b64 = r["data"].as_str().ok_or("no screenshot data")?.to_string();+    let owner = s.owner.clone().unwrap_or_else(|| s.id.clone());+    let url = s.active().map(|t| t.url.clone()).unwrap_or_default();+    let short_url = url.chars().take(80).collect::<String>();+    let when = pup_core::iso_now();+    // caption bar + preview thumbnail, drawn by the page's canvas (Node: sharp)+    let caption = format!("pup · {owner}'s window · {short_url} · {}", &when[11..16]);+    let js = format!(r#"(async () => {{ const img = new Image(); await new Promise((res, rej) => {{ img.onload = res; img.onerror = rej; img.src = 'data:image/png;base64,{b64}'; }});+      const BAR = {bar}; const cv = document.createElement('canvas'); cv.width = img.naturalWidth; cv.height = img.naturalHeight + BAR; const c = cv.getContext('2d');+      c.drawImage(img, 0, 0); if (BAR) {{ c.fillStyle = '#0d1117'; c.fillRect(0, img.naturalHeight, cv.width, BAR); c.fillStyle = '#00d4cb'; c.font = '12px Segoe UI, system-ui, sans-serif'; c.textBaseline = 'middle'; c.fillText({cap}, 8, img.naturalHeight + BAR / 2); }}+      const pv = document.createElement('canvas'); const pw = Math.min(320, img.naturalWidth); pv.width = pw; pv.height = Math.round(img.naturalHeight * pw / img.naturalWidth); pv.getContext('2d').drawImage(img, 0, 0, pv.width, pv.height);+      return {{ png: cv.toDataURL('image/png').split('base64,')[1], preview: {{ b64: pv.toDataURL('image/png').split('base64,')[1], w: pv.width, h: pv.height }} }}; }})()"#, bar = if ctx.settings.get("annCaptionBar", "on") != "off" { 26 } else { 0 }, cap = serde_json::to_string(&caption).unwrap());+    let composed = b.cdp.evaluate(cdp_session, &js, Duration::from_secs(15)).map_err(|e| e.to_string())?;+    let png_b64 = composed["png"].as_str().unwrap_or(&b64).to_string();+    let mut png = pup_cdp::base64_decode(&png_b64).map_err(|e| e.to_string())?;+    let sidecar = json!({ "kind": "pup-annotation", "thread": owner, "container": s.owner_container, "sessionId": sid, "url": url, "ts": when, "shapes": res["shapes"], "snip": if is_snip { res["snip"].clone() } else { Value::Null }, "viewport": res["viewport"] });+    png = png_add_itxt(&png, "pup-annotation", &sidecar.to_string());+    let dir = ctx.shots_dir();+    let fp = dir.join(format!("annotate-{}{}-{}.png", if is_snip { "snip-" } else { "" }, pup_session::sanitize_sid(sid), pup_core::now_ms()));+    std::fs::write(&fp, &png).map_err(|e| e.to_string())?;+    ctx.sessions.with_mut(sid, |x| x.last_annotation = Some(json!({ "path": fp.to_string_lossy(), "ts": pup_core::now_ms(), "shapes": res["shapes"], "snip": sidecar["snip"] })));+    let sidecar_text = format!("pup snippet — {owner}'s window — {url} — {when}\n{}", serde_json::to_string_pretty(&sidecar).unwrap_or_default());+    let clipped = ctx.ab.call("desktop_clipboard_set", json!({ "image": fp.to_string_lossy().replace('\\', "/"), "text": sidecar_text }), caller, Duration::from_secs(8)).is_ok();+    let line1 = format!("{} copied to your clipboard", if is_snip { "Snippet" } else { "Annotation" });+    let _ = b.cdp.evaluate(cdp_session, &format!("(() => {{ if (window.__pupCaptureDone) window.__pupCaptureDone({}, {}, {}); }})()", if clip.is_object() { clip.to_string() } else { "null".into() }, composed["preview"].to_string(), serde_json::to_string(&line1).unwrap()), Duration::from_secs(5));+    pup_core::log(format!("[annotate] \"{sid}\" CAPTURED {} -> {} | clipboard {}", if is_snip { format!("SNIP {}x{}", res["snip"]["w"], res["snip"]["h"]) } else if clip.is_object() { format!("tight-crop {}x{}", clip["w"], clip["h"]) } else { "full-view".into() }, fp.display(), if clipped { "ok" } else { "FAILED (ab desktop_clipboard_set)" }));+    Ok(())+}++/// Insert an iTXt chunk (keyword, uncompressed UTF-8 text) before IEND.+pub fn png_add_itxt(png: &[u8], keyword: &str, text: &str) -> Vec<u8> {+    let Some(iend) = png.windows(4).rposition(|w| w == b"IEND") else { return png.to_vec() };+    let start = iend - 4;+    let mut data = Vec::new();+    data.extend_from_slice(keyword.as_bytes()); data.push(0); data.push(0); data.push(0); data.push(0); data.push(0); data.extend_from_slice(text.as_bytes());+    let mut chunk = Vec::new();+    chunk.extend_from_slice(&(data.len() as u32).to_be_bytes()); chunk.extend_from_slice(b"iTXt"); chunk.extend_from_slice(&data);+    let mut crc_input = b"iTXt".to_vec(); crc_input.extend_from_slice(&data);+    chunk.extend_from_slice(&crc32(&crc_input).to_be_bytes());+    let mut out = png[..start].to_vec(); out.extend_from_slice(&chunk); out.extend_from_slice(&png[start..]); out+}++fn crc32(data: &[u8]) -> u32 {+    let mut table = [0u32; 256];+    for i in 0..256u32 { let mut c = i; for _ in 0..8 { c = if c & 1 != 0 { 0xEDB88320 ^ (c >> 1) } else { c >> 1 }; } table[i as usize] = c; }+    let mut crc = 0xFFFF_FFFFu32; for &b in data { crc = table[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8); } crc ^ 0xFFFF_FFFF+}++/// pup_floaty_click / the floaty binding: arm the overlay from the corner pencil.+pub fn floaty_click(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let mut a = args.clone(); a["tool"] = json!("pen"); start(ctx, caller, &a) }++#[cfg(test)]+mod tests {+    use super::*;+    #[test] fn itxt() { assert_eq!(crc32(b"123456789"), 0xCBF43926); let png = b"\x89PNG\r\n\x1a\n\0\0\0\0IEND\xaeB`\x82".to_vec(); let out = png_add_itxt(&png, "k", "v"); assert!(out.windows(4).any(|w| w == b"iTXt")); assert!(out.ends_with(b"IEND\xaeB`\x82")); }+}
rust/verbs/src/consent.rsadded+67
@@ -0,0 +1,67 @@+//! The human-consent registry behind pup's approval toasts (port of src/consent.js, pup-bridge#92).+//! A verb parked on the human's click waits in-verb for at most IN_VERB (45 s, under every ab+//! budget), then answers blocked_on_user with stillRunning:true while the toast stays up for+//! TOTAL_SEC; a re-call with the same key re-attaches (no second toast) and collects the decision+//! exactly once; a timed-out toast is retracted (notify_dismiss).++use pup_core::{AbClient, Caller};+use serde_json::{json, Value};+use std::collections::HashMap;+use std::sync::{Arc, Condvar, Mutex};+use std::time::{Duration, Instant};++pub const IN_VERB: Duration = Duration::from_secs(45);+pub const TOTAL_SEC: u64 = 120;+const PICKUP: Duration = Duration::from_secs(120);++pub struct Rec { pub id: String, pub key: String, pub verb: String, pub what: String, pub since: Instant, pub since_ms: u64, pub decided: Option<Instant>, pub decision: Option<String>, pub picked_up: bool, pub attached: u32 }++pub struct Registry { map: Mutex<HashMap<String, Arc<(Mutex<Rec>, Condvar)>>> }++pub enum Ask { Pending { consent_id: String, remaining_sec: u64, attached: u32 }, Decided { consent_id: String, decision: Option<String> } }++impl Registry {+    pub fn new() -> Registry { Registry { map: Mutex::new(HashMap::new()) } }++    fn find_pending(&self, key: &str) -> Option<Arc<(Mutex<Rec>, Condvar)>> {+        self.map.lock().unwrap().values().find(|r| { let g = r.0.lock().unwrap(); g.key == key && !g.picked_up }).cloned()+    }++    /// Show the toast (or attach to the pending one) and wait up to IN_VERB for the click.+    pub fn ask(&self, ab: Arc<dyn AbClient>, caller: &Caller, key: &str, verb: &str, what: &str, id_prefix: &str, toast: Value) -> Ask {+        let rec = match self.find_pending(key) {+            Some(r) => { r.0.lock().unwrap().attached += 1; r }+            None => {+                let id = format!("{id_prefix}-{:x}-{}", pup_core::now_ms(), std::process::id() % 1000);+                let r = Arc::new((Mutex::new(Rec { id: id.clone(), key: key.into(), verb: verb.into(), what: what.into(), since: Instant::now(), since_ms: pup_core::now_ms(), decided: None, decision: None, picked_up: false, attached: 0 }), Condvar::new()));+                self.map.lock().unwrap().insert(id.clone(), r.clone());+                let (r2, ab2, caller2, id2) = (r.clone(), ab.clone(), caller.clone(), id.clone());+                std::thread::Builder::new().name("consent".into()).spawn(move || {+                    let mut args = toast; args["id"] = json!(id2); args["wait"] = json!(true); args["timeoutSec"] = json!(TOTAL_SEC);+                    let decision = ab2.call("notify_user", args, &caller2, Duration::from_secs(TOTAL_SEC + 10)).ok().and_then(|v| v["action"].as_str().map(|s| s.to_string()));+                    if decision.is_none() { let _ = ab2.call("notify_dismiss", json!({ "id": id2 }), &caller2, Duration::from_secs(8)); }+                    { let mut g = r2.0.lock().unwrap(); g.decision = decision.clone(); g.decided = Some(Instant::now()); pup_core::log(format!("[consent] {} {} ({}, attached {}x)", g.verb, decision.as_ref().map(|d| format!("ANSWERED \"{d}\"")).unwrap_or_else(|| "TIMED OUT (toast retracted)".into()), g.id, g.attached)); }+                    r2.1.notify_all();+                }).ok();+                r+            }+        };+        // wait for a decision, up to IN_VERB+        let deadline = Instant::now() + IN_VERB;+        let mut g = rec.0.lock().unwrap();+        while g.decided.is_none() { let left = deadline.saturating_duration_since(Instant::now()); if left.is_zero() { break; } let (ng, _) = rec.1.wait_timeout(g, left).unwrap(); g = ng; }+        if g.decided.is_none() { let remaining = TOTAL_SEC.saturating_sub(g.since.elapsed().as_secs()); return Ask::Pending { consent_id: g.id.clone(), remaining_sec: remaining, attached: g.attached }; }+        g.picked_up = true;+        let out = Ask::Decided { consent_id: g.id.clone(), decision: g.decision.clone() };+        let id = g.id.clone(); drop(g);+        self.map.lock().unwrap().remove(&id);+        out+    }++    /// Rows for pup_readiness.active[]; decided-but-uncollected records expire after PICKUP.+    pub fn rows(&self) -> Vec<Value> {+        let mut map = self.map.lock().unwrap();+        map.retain(|_, r| { let g = r.0.lock().unwrap(); !(g.decided.map(|d| d.elapsed() > PICKUP).unwrap_or(false)) });+        map.values().map(|r| { let g = r.0.lock().unwrap(); let decided = g.decided.is_some(); json!({ "id": g.id, "verb": g.verb, "phase": if decided { "consent_decided" } else { "consent" }, "stepLabel": if decided { format!("The user answered; re-call {} with the same args to collect the decision", g.verb) } else { g.what.clone() }, "percent": Value::Null, "elapsedSec": g.since.elapsed().as_secs(), "estimatedSec": Value::Null, "etaSec": Value::Null, "confidence": "measured", "blockedOnUser": !decided, "blockedBy": if decided { Value::Null } else { json!("your Approve/Deny click on the desktop toast") }, "reattach": format!("{} again with the SAME args", g.verb) }) }).collect()+    }+}
rust/verbs/src/events.rs+5
@@ -13,6 +13,10 @@ use std::time::Duration;  pub type TabStates = Arc<Mutex<HashMap<String, TabState>>>; +type Hook = Box<dyn Fn(&str, &str, &Value) + Send + Sync>;+static HOOK: Mutex<Option<Arc<Hook>>> = Mutex::new(None);+pub fn set_hook(h: Hook) { *HOOK.lock().unwrap() = Some(Arc::new(h)); }+ const ERROR_CAP: usize = 300;  pub fn start_pump(profile: String, cdp: Cdp, sessions: Arc<Sessions>, states: TabStates) {@@ -36,6 +40,7 @@ pub fn start_pump(profile: String, cdp: Cdp, sessions: Arc<Sessions>, states: Ta                     st.entry(sid.clone()).or_default().dialog = Some(json!({ "type": ev.params["type"], "message": ev.params["message"], "defaultValue": ev.params["defaultPrompt"], "url": ev.params["url"], "since": pup_core::now_ms() }));                     pup_core::log(format!("[dialog] {} dialog opened on session {}: {}", ev.params["type"].as_str().unwrap_or("?"), &sid[..sid.len().min(8)], ev.params["message"].as_str().unwrap_or("").chars().take(80).collect::<String>()));                 }+                "Runtime.bindingCalled" | "Page.screencastFrame" => { let h = HOOK.lock().unwrap().clone(); if let Some(h) = h { h(&ev.method, &sid, &ev.params); } }                 "Page.javascriptDialogClosed" => { states.lock().unwrap().entry(sid.clone()).or_default().dialog = None; }                 "Page.frameNavigated" if ev.params["frame"]["parentId"].is_null() => {                     let url = ev.params["frame"]["url"].as_str().unwrap_or("").to_string();
rust/verbs/src/extras.rsadded+124
@@ -0,0 +1,124 @@+//! The remaining Phase 2 verbs: pup_screenshot_element, pup_screenshot_full_res, pup_download,+//! the 5-minute identity read-back audit (ab desktop_get_window_identity, adom-bridge#147),+//! and the small lifecycle verbs (pup_launch, pup_deps, pup_install_toolbar, pup_reap_profiles,+//! pup_uninstall, pup_move_to_workspace placeholder).++use crate::input::resolve_tab;+use crate::{touch, Ctx};+use pup_core::{err, ok, Caller};+use serde_json::{json, Value};+use std::sync::Arc;+use std::time::Duration;++const T: Duration = Duration::from_secs(10);++pub fn screenshot_element(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let (sel, txt, closest) = (args["selector"].as_str(), args["text"].as_str(), args["closest"].as_str());+    if sel.is_none() && txt.is_none() { return err("args_required", "pup_screenshot_element requires a selector OR text", Some("e.g. {\"selector\":\".download-card\"} or {\"text\":\"Download\"}")); }+    let js = format!(r#"(() => {{ const sel = {}, txt = {}, closest = {};+      const deep = (root, out) => {{ for (const n of root.querySelectorAll('*')) {{ out.push(n); if (n.shadowRoot) deep(n.shadowRoot, out); }} }};+      let el = null;+      if (sel) {{ try {{ el = document.querySelector(sel); }} catch (e) {{ return {{ error: 'bad selector: ' + e.message }}; }} if (!el) {{ const all = []; deep(document, all); el = all.find(n => {{ try {{ return n.matches(sel); }} catch (e) {{ return false; }} }}) || null; }} }}+      if (!el && txt) {{ const all = []; deep(document, all); const t = txt.toLowerCase(); const marks = all.filter(n => n.children.length < 8 && (n.innerText || '').trim().toLowerCase().includes(t)); el = marks[0] || null; if (closest) {{ for (const m of marks) {{ const c = m.closest(closest); if (c) {{ el = c; break; }} }} }} }}+      else if (el && closest) {{ el = el.closest(closest) || el; }}+      if (!el) return null;+      el.scrollIntoView({{ block: 'center', inline: 'center' }});+      const r = el.getBoundingClientRect(); return {{ x: r.x, y: r.y, w: r.width, h: r.height, tag: el.tagName }}; }})()"#, sel.map(|x| serde_json::to_string(x).unwrap()).unwrap_or("null".into()), txt.map(|x| serde_json::to_string(x).unwrap()).unwrap_or("null".into()), closest.map(|x| serde_json::to_string(x).unwrap()).unwrap_or("null".into()));+    let rect = match b.cdp.evaluate(&t.cdp_session, &js, T) { Ok(v) if v.is_null() => return err("element_not_found", format!("pup_screenshot_element: no element matched {}", sel.map(|x| format!("selector \"{x}\"")).unwrap_or_else(|| format!("text \"{}\"", txt.unwrap_or("")))), Some("pup_eval document.querySelectorAll(...) to check the selector; shadow roots are searched, iframes are not.")), Ok(v) if v.get("error").is_some() => return err("bad_selector", v["error"].as_str().unwrap_or("bad selector"), None), Ok(v) => v, Err(e) => return err("eval_failed", e.to_string(), None) };+    std::thread::sleep(Duration::from_millis(120));+    let pad = args["padding"].as_f64().unwrap_or(0.0).max(0.0);+    let clip = json!({ "x": (rect["x"].as_f64().unwrap_or(0.0) - pad).max(0.0), "y": (rect["y"].as_f64().unwrap_or(0.0) - pad).max(0.0), "width": (rect["w"].as_f64().unwrap_or(1.0) + pad * 2.0).max(1.0), "height": (rect["h"].as_f64().unwrap_or(1.0) + pad * 2.0).max(1.0), "scale": 1 });+    let r = match b.cdp.send(Some(&t.cdp_session), "Page.captureScreenshot", json!({ "format": "png", "captureBeyondViewport": true, "clip": clip }), Duration::from_secs(15)) { Ok(r) => r, Err(e) => return err("screenshot_failed", e.to_string(), None) };+    let png = match r["data"].as_str().and_then(|d| pup_cdp::base64_decode(d).ok()) { Some(p) => p, None => return err("screenshot_failed", "no image data", None) };+    let (buf, w, h, ow, oh, resized) = match crate::image::fit_png(&png, crate::MAX_SCREENSHOT_DIM) { Ok(x) => x, Err(e) => return err("screenshot_decode_failed", e, None) };+    let path = ctx.shots_dir().join(format!("pup-el-{}.png", pup_core::now_ms()));+    if let Err(e) = std::fs::write(&path, &buf) { return err("screenshot_write_failed", e.to_string(), None); }+    touch(ctx, &s.id, None);+    ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "sizeKB": buf.len() / 1024, "element": rect, "_hint": "PNG of ONE element (scrolled into view first). READ the image before asserting what it shows." }))+}++pub fn screenshot_full_res(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let full = args["fullPage"].as_bool().unwrap_or(false);+    let png = match b.cdp.screenshot_png(&t.cdp_session, full, Duration::from_secs(25)) { Ok(p) => p, Err(e) => return err("screenshot_failed", e.to_string(), None) };+    let (w, h) = pup_identity::png_size(&png).unwrap_or((0, 0));+    let path = ctx.shots_dir().join(format!("pup-full-{}.png", pup_core::now_ms()));+    if let Err(e) = std::fs::write(&path, &png) { return err("screenshot_write_failed", e.to_string(), None); }+    ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "sizeKB": png.len() / 1024, "fullPage": full, "resized": false, "_hint": "FULL-resolution PNG, NOT downscaled: for saving/handing to the user, not for Read (it can exceed the image limit). Use pup_screenshot for an AI-readable copy." }))+}++/// pup_download: click a real download control or an in-page a[download]; Chrome saves it.+pub fn download(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let (sel, url) = (args["selector"].as_str(), args["url"].as_str());+    if sel.is_none() && url.is_none() { return err("args_required", "pup_download needs {selector} (a real download control to click) OR {url} (downloaded via an in-page a[download] click)", None); }+    let dir = args["saveDir"].as_str().map(|d| d.to_string()).unwrap_or_else(|| ctx.host.home().join("Downloads").to_string_lossy().to_string());+    let _ = std::fs::create_dir_all(&dir);+    if let Err(e) = b.cdp.send(None, "Browser.setDownloadBehavior", json!({ "behavior": "allow", "downloadPath": dir, "eventsEnabled": true }), T) { return err("download_setup_failed", e.to_string(), None); }+    let began = b.cdp.subscribe(None, Some("Browser.downloadWillBegin"));+    let progress = b.cdp.subscribe(None, Some("Browser.downloadProgress"));+    let triggered = if let Some(sel) = sel { crate::input::click(ctx, caller, &json!({ "window": s.id, "selector": sel, "takeover": true }))["ok"] == json!(true) }+        else { b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ const a = document.createElement('a'); a.href = {}; {} a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000); return true; }})()", serde_json::to_string(url.unwrap()).unwrap(), args["saveAs"].as_str().map(|n| format!("a.download = {};", serde_json::to_string(n).unwrap())).unwrap_or_else(|| "a.setAttribute('download', '');".into())), T).ok() == Some(json!(true)) };+    if !triggered { return err("download_trigger_failed", "the download control could not be clicked", Some("Check the selector with pup_eval; the button may need a hover menu open first.")); }+    let max = Duration::from_millis(args["waitMs"].as_u64().unwrap_or(90_000).min(240_000));+    let t0 = std::time::Instant::now();+    let mut suggested: Option<String> = None; let mut done: Option<Value> = None;+    while t0.elapsed() < max {+        if let Ok(ev) = began.try_recv() { suggested = ev.params["suggestedFilename"].as_str().map(|x| x.to_string()); }+        if let Ok(ev) = progress.recv_timeout(Duration::from_millis(500)) { if ev.params["state"] == json!("completed") { done = Some(ev.params.clone()); break; } if ev.params["state"] == json!("canceled") { return err("download_canceled", "the browser canceled the download", None); } }+    }+    let Some(ev) = done else { return err("download_timeout", format!("no download completed within {}s", max.as_secs()), Some("Nothing may have started (wrong control), or the file is large: pass waitMs for a longer wait, then pup_download again.")) };+    let fname = suggested.clone().or_else(|| args["saveAs"].as_str().map(|x| x.to_string())).unwrap_or_else(|| format!("download-{}", ev["guid"].as_str().unwrap_or("file")));+    let mut abs = std::path::PathBuf::from(&dir).join(&fname);+    if !abs.is_file() { if let Ok(rd) = std::fs::read_dir(&dir) { if let Some(newest) = rd.flatten().filter(|e| e.path().is_file()).filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|m| (m, e.path()))).filter(|(m, _)| m.elapsed().map(|d| d < Duration::from_secs(120)).unwrap_or(false)).max_by_key(|(m, _)| *m) { abs = newest.1; } } }+    let size = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);+    touch(ctx, &s.id, None);+    ok(json!({ "success": true, "output": json!({ "ok": true, "savedTo": abs.to_string_lossy().replace('\\', "/"), "filename": abs.file_name().map(|f| f.to_string_lossy().to_string()), "bytes": ev["totalBytes"].as_u64().or(ev["receivedBytes"].as_u64()).unwrap_or(size), "sizeOnDisk": size, "triggeredBy": sel.map(|x| format!("click {x}")).unwrap_or_else(|| format!("a[download] {}", url.unwrap_or(""))) }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "savedTo": abs.to_string_lossy().replace('\\', "/"), "_hint": "Downloaded through the browser with the user's real session and cookies. Pull it to the container with adom-bridge pull_file {filePaths:[...]} if you need the bytes." }))+}++/// Every 5 min: read back what the OS says each window is stamped with (ab desktop_get_window_identity).+pub fn start_identity_audit(ctx: Arc<Ctx>) {+    std::thread::Builder::new().name("identity-audit".into()).spawn(move || loop {+        std::thread::sleep(Duration::from_secs(300));+        let wins: Vec<(String, i64, Option<String>)> = ctx.sessions.all().into_iter().filter_map(|s| s.hwnd.map(|h| (s.id.clone(), h, s.app_id.clone()))).collect();+        if wins.is_empty() { continue; }+        let caller = crate::identity::internal_caller();+        let Ok(r) = ctx.ab.call("desktop_get_window_identity", json!({ "hwnds": wins.iter().map(|w| w.1).collect::<Vec<_>>() }), &caller, Duration::from_secs(10)) else { continue };+        let rows = r["rows"].as_array().or(r["windows"].as_array()).cloned().unwrap_or_default();+        let mut repaired = 0;+        for (sid, hwnd, want) in wins {+            let actual = rows.iter().find(|x| x["hwnd"].as_i64() == Some(hwnd)).and_then(|x| x["appId"].as_str().map(|s| s.to_string()));+            if want.is_some() && actual != want { pup_core::log(format!("[identity-audit] \"{sid}\" stamped {:?} but the OS says {:?} — re-stamping", want, actual)); crate::identity::stamp(&ctx, &sid, &caller, true); repaired += 1; }+        }+        ctx.last_audit.lock().unwrap().replace(json!({ "at": pup_core::now_ms(), "checked": rows.len(), "repaired": repaired, "ok": repaired == 0 }));+    }).ok();+}++pub fn launch(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let mut a = args.clone(); if a.get("url").is_none() { a["url"] = json!("about:blank"); } crate::open_window(ctx, caller, &a) }++pub fn deps(_ctx: &Ctx) -> Value { ok(json!({ "success": true, "output": json!({ "ok": true, "shell": "rust", "deps": "none: this build is a single exe (no Node, no npm, no native modules)" }).to_string(), "_hint": "There is nothing to install: the Rust pup exe carries everything but Chrome for Testing, which pup_readiness / pup_prewarm manage." })) }++pub fn install_toolbar(ctx: &Ctx) -> Value {+    let dirs = pup_browser::stage_extensions(&*ctx.host, ctx.port, "adom-you", true);+    ok(json!({ "success": true, "staged": dirs.iter().map(|d| d.to_string_lossy().replace('\\', "/")).collect::<Vec<_>>(), "_hint": "The Adom toolbar extensions are staged and ride every Chrome for Testing launch (--load-extension, pinned). A warm Chrome never reloads them: pup_quit_idle_browsers then reopen the window to pick up new extension code." }))+}++pub fn reap_profiles(ctx: &Ctx, args: &Value) -> Value {+    let root = pup_browser::profiles_dir(&*ctx.host);+    let live: Vec<String> = ctx.browsers.lock().unwrap().keys().cloned().collect();+    let mut removed = Vec::new();+    if let Ok(rd) = std::fs::read_dir(&root) { for e in rd.flatten() { let name = e.file_name().to_string_lossy().to_string(); let keep = name == "adom-you" || name == "pup-dashboard" || name == "_recorder" || live.contains(&name) || pup_browser::existing_port(&e.path()).is_some(); if !keep && (name.starts_with("iso-") || args["all"].as_bool() == Some(true)) { if std::fs::remove_dir_all(e.path()).is_ok() { removed.push(name); } } } }+    ok(json!({ "success": true, "removed": removed, "kept": live, "_hint": "Throwaway (isolated) profiles with no live browser were deleted; durable profiles are never touched." }))+}++pub fn uninstall(ctx: &Ctx, args: &Value) -> Value {+    if args["confirm"].as_bool() != Some(true) { return err("confirm_required", "pup_uninstall REMOVES pup's footprint from this machine (profiles, sessions, staged extensions, icons). Pass confirm:true.", None); }+    let mut removed = Vec::new();+    for d in [pup_browser::profiles_dir(&*ctx.host), ctx.host.state_dir().join("pup-sessions"), ctx.host.temp_dir().join("pup-thread-tiles"), ctx.host.temp_dir().join("pup-icons"), ctx.host.temp_dir().join("pup-badge-debug")] { if std::fs::remove_dir_all(&d).is_ok() { removed.push(d.to_string_lossy().replace('\\', "/")); } }+    for (_, dst, _) in pup_browser::ext_assets::EXTENSIONS { let d = ctx.host.state_dir().join(dst); if std::fs::remove_dir_all(&d).is_ok() { removed.push(d.to_string_lossy().replace('\\', "/")); } }+    for s in ctx.sessions.all() { if let Some(a) = &s.app_id { let _ = ctx.ab.call("desktop_unregister_app_identity", json!({ "appId": a }), &crate::identity::internal_caller(), Duration::from_secs(5)); } }+    ok(json!({ "success": true, "removed": removed, "_hint": "pup's on-disk footprint is gone (the vault in the OS keychain is kept unless credential_delete is called per host). ab's bridge_uninstall removes the bridge itself." }))+}++pub fn move_to_workspace(_ctx: &Ctx) -> Value { err("not_in_rust_yet", "pup_move_to_workspace (send a window back to a Hydrogen webview) lands after the Hydrogen endpoint contract settles (docs/PLAN-interactive-surface.md phase 3)", None) }
rust/verbs/src/highfps.rsadded+62
@@ -0,0 +1,62 @@+//! The highFps lease (Node: highFpsGrant/Extend/Info/Downgrade): a background window renders full+//! rate only while a lease runs (20 min default, 120 max); a lapsed lease relaunches the window+//! without the anti-throttle flags so a forgotten demo window can never flatten a battery (issue #14).++use crate::Ctx;+use pup_core::{err, ok, Caller};+use serde_json::{json, Value};+use std::collections::HashMap;+use std::sync::{Arc, Mutex};+use std::time::{Duration, Instant};++pub const DEFAULT_MIN: u64 = 20;+pub const MAX_MIN: u64 = 120;++#[derive(Clone)]+pub struct Lease { pub minutes: u64, pub granted: Instant, pub expires: Instant, pub warned: bool }+pub type Leases = Arc<Mutex<HashMap<String, Lease>>>;++pub fn grant(ctx: &Ctx, sid: &str, minutes: u64) -> Lease {+    let m = minutes.clamp(1, MAX_MIN);+    let l = Lease { minutes: m, granted: Instant::now(), expires: Instant::now() + Duration::from_secs(m * 60), warned: false };+    ctx.highfps.lock().unwrap().insert(sid.into(), l.clone());+    pup_core::log(format!("[highfps] \"{sid}\" lease granted: {m} min"));+    l+}++pub fn info(ctx: &Ctx, sid: &str) -> Value {+    match ctx.highfps.lock().unwrap().get(sid) { Some(l) => json!({ "active": true, "minutes": l.minutes, "msRemaining": l.expires.saturating_duration_since(Instant::now()).as_millis() as u64, "recordingActive": ctx.tab_recs.lock().unwrap().contains_key(sid) }), None => Value::Null }+}++pub fn extend(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    let minutes = args["minutes"].as_u64().unwrap_or(DEFAULT_MIN).clamp(1, MAX_MIN);+    let mut m = ctx.highfps.lock().unwrap();+    let Some(l) = m.get_mut(&s.id) else { return err("not_high_fps", format!("window \"{}\" is not a high-fps window — nothing to extend", s.id), Some("Open with pup_open_window {highFps:true} (or start a recording) to get a lease.")) };+    l.expires = Instant::now() + Duration::from_secs(minutes * 60); l.minutes = minutes; l.warned = false;+    drop(m);+    pup_core::log(format!("[highfps] \"{}\" lease extended: +{minutes} min", s.id));+    ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "highFps": info(ctx, &s.id) }).to_string(), "highFps": info(ctx, &s.id), "_hint": format!("High-fps lease extended to {minutes} min. While a recording runs the lease cannot lapse.") }))+}++/// Every minute: warn 2 min before expiry, downgrade (relaunch without the flags) on expiry.+pub fn start_loop(ctx: Arc<Ctx>) {+    std::thread::Builder::new().name("highfps".into()).spawn(move || loop {+        std::thread::sleep(Duration::from_secs(60));+        let now = Instant::now();+        let expired: Vec<String> = { let mut m = ctx.highfps.lock().unwrap(); let recording: Vec<String> = ctx.tab_recs.lock().unwrap().keys().cloned().collect();+            for (sid, l) in m.iter_mut() { if recording.contains(sid) { l.expires = now + Duration::from_secs(5 * 60); } else if !l.warned && l.expires.saturating_duration_since(now) < Duration::from_secs(120) { l.warned = true; pup_core::log(format!("[highfps] \"{sid}\" lease expires in ~2 min — pup_highfps_extend {{sessionId,minutes}} keeps it")); } }+            m.iter().filter(|(_, l)| l.expires <= now).map(|(s, _)| s.clone()).collect() };+        for sid in expired {+            ctx.highfps.lock().unwrap().remove(&sid);+            let Some(s) = ctx.sessions.get(&sid) else { continue };+            let urls: Vec<String> = s.tabs.iter().map(|t| t.url.clone()).filter(|u| u != "about:blank").collect();+            pup_core::log(format!("[highfps] \"{sid}\" DOWNGRADING to normal power (lease lapsed) — relaunching {} tab(s) without the high-fps flags", urls.len()));+            let caller = Caller { thread: s.owner.clone().unwrap_or_default(), reason: "highFps lease lapsed".into(), ..Default::default() };+            let _ = crate::close_window(&ctx, &caller, &json!({ "window": sid, "takeover": true }));+            let first = urls.first().cloned().unwrap_or_else(|| "about:blank".into());+            let _ = crate::open_window(&ctx, &caller, &json!({ "window": sid, "url": first, "profile": s.profile, "takeover": true }));+            for u in urls.iter().skip(1) { let _ = crate::tabs::open_tab(&ctx, &caller, &json!({ "window": sid, "url": u, "takeover": true })); }+        }+    }).ok();+}
rust/verbs/src/input.rs+2
@@ -71,6 +71,8 @@ fn key_event(cdp: &Cdp, sid: &str, kind: &str, key: &str, code: &str, vk: u32, t }  /// One key press (down + up) by name or single character, with the CDP modifier bits held.+pub fn press_pub(cdp: &Cdp, sid: &str, name: &str, modifiers: u32) -> Result<(), Value> { press(cdp, sid, name, modifiers) }+ fn press(cdp: &Cdp, sid: &str, name: &str, modifiers: u32) -> Result<(), Value> {     let (key, code, vk, text): (String, String, u32, Option<String>) = match keys::lookup(name) {         Some(d) => (d.key.into(), d.code.into(), d.key_code, d.text.map(|t| t.to_string()).or_else(|| if d.key.chars().count() == 1 { Some(d.key.to_string()) } else { None })),   // puppeteer: a 1-char key IS its text
rust/verbs/src/lib.rs+117−4
@@ -23,6 +23,16 @@ pub mod recovery; pub mod window; pub mod settings; pub mod identity;+pub mod consent;+pub mod vault;+pub mod wiki;+pub mod annotate;+pub mod record;+pub mod highfps;+pub mod extras;++pub fn assets_recorder_html() -> Option<&'static [u8]> { Some(include_bytes!("../../../src/recorder.html")) }+pub const OWNER_CHIP_JS: &str = include_str!("../assets/owner-chip.js");  /// Per-tab state the event pump maintains (keyed by CDP session id). #[derive(Debug, Clone, Default)]@@ -48,6 +58,13 @@ pub struct Ctx {     pub ad_version: Mutex<Option<String>>,     pub self_ref: Mutex<std::sync::Weak<Ctx>>,     pub toasts: Mutex<Vec<Value>>,+    pub vault: pup_vault::Vault,+    pub cards: pup_vault::Cards,+    pub consent: consent::Registry,+    pub tab_recs: record::TabRecs,+    pub screen_rec: record::ScreenRecState,+    pub highfps: highfps::Leases,+    pub last_audit: Mutex<Option<Value>>, }  impl Ctx {@@ -57,10 +74,44 @@ impl Ctx {     pub fn shots_dir(&self) -> PathBuf { let d = self.bridge_dir.join("screenshots"); let _ = std::fs::create_dir_all(&d); d } } +/// The walls only a human can pass after a login submit (Node: detectHumanChallenge).+pub fn detect_wall(cdp: &Cdp, sid: &str) -> Option<Value> {+    let js = r#"(() => { const q = (s) => { try { return document.querySelector(s); } catch (e) { return null; } }; const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };+      const otp = q('input[autocomplete="one-time-code"]') || q('input[name*="otp" i]') || q('input[id*="otp" i]') || q('input[name*="verification" i][inputmode="numeric"]') || q('input[name*="mfa" i]') || q('input[name*="totp" i]') || q('input[name*="2fa" i]');+      if (vis(otp)) return { kind: 'two_factor', evidence: 'one-time-code input: ' + (otp.getAttribute('name') || otp.getAttribute('id') || otp.getAttribute('autocomplete')) };+      const cap = Array.from(document.querySelectorAll('iframe')).find(f => /recaptcha|hcaptcha|turnstile|arkose|funcaptcha/i.test(f.src || '')); if (cap && vis(cap)) return { kind: 'captcha', evidence: 'captcha iframe: ' + cap.src.split('/')[2] };+      const t = (document.body && document.body.innerText || '').slice(0, 20000);+      if (/verification code|two[- ]factor|2-step verification|authenticator app|enter the code we sent|security code|one-time (pass)?code/i.test(t) && !q('input[type="password"]')) return { kind: 'two_factor', evidence: 'page text mentions a verification code and no password field is present' };+      if (/i'?m not a robot|prove you are human|verify you are human|security check/i.test(t)) return { kind: 'captcha', evidence: 'page text asks for a human check' };+      return null; })()"#;+    cdp.evaluate(sid, js, Duration::from_secs(4)).ok().filter(|v| v.is_object())+}++/// Route the events the pump cannot handle alone (bindings, screencast frames) to their owners.+pub fn install_event_hook(ctx: Arc<Ctx>) {+    events::set_hook(Box::new(move |method, sid, params| {+        match method {+            "Runtime.bindingCalled" => {+                let name = params["name"].as_str().unwrap_or(""); let payload = params["payload"].as_str().unwrap_or("").to_string();+                match name {+                    "__pupCredCapture" => vault::on_capture(&ctx, sid, &payload),+                    "__pupFloatyPush" => { if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.tabs.iter().any(|t| t.cdp_session == sid)) { let caller = Caller { thread: "user-floaty".into(), reason: "the user clicked the corner pencil".into(), ..Default::default() }; let tab = s.tabs.iter().find(|t| t.cdp_session == sid).map(|t| t.tab_id.clone()); pup_core::log(format!("[floaty] \"{}\" pencil clicked — arming capture", s.id)); let _ = annotate::start(&ctx, &caller, &json!({ "window": s.id, "tabId": tab, "tool": "pen", "takeover": true })); } }+                    _ => {}+                }+            }+            "Page.screencastFrame" => { if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.tabs.iter().any(|t| t.cdp_session == sid)) { if let Some(b) = browser_for(&ctx, &s.profile) { record::on_frame(&ctx.tab_recs, &b.cdp, sid, params); } } }+            _ => {}+        }+    }));+}+ /// The verbs this build serves natively. pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewarm", "pup_status", "pup_list_windows", "pup_my_windows", "pup_open_window", "pup_navigate", "pup_eval", "pup_evaluate", "pup_screenshot", "pup_close_window", "pup_close", "pup_window_info",     "pup_open_tab", "pup_list_tabs", "pup_switch_tab", "pup_close_tab", "pup_switch_window", "pup_click", "pup_hover", "pup_type", "pup_press_key", "pup_scroll", "pup_fill", "pup_wait", "pup_reload", "pup_back", "pup_forward", "pup_set_viewport", "pup_errors", "pup_dialog", "pup_input_dispatch", "pup_render_html", "pup_fetch_url", "pup_maximize",-    "pup_raise_os_window", "pup_focus_window", "pup_lower_os_window", "pup_alert_window", "pup_progress", "pup_configure", "pup_use", "pup_quit_idle_browsers", "pup_rescan", "pup_set_identity", "pup_audit_icons", "pup_dashboard"];+    "pup_raise_os_window", "pup_focus_window", "pup_lower_os_window", "pup_alert_window", "pup_progress", "pup_configure", "pup_use", "pup_quit_idle_browsers", "pup_rescan", "pup_set_identity", "pup_audit_icons", "pup_dashboard",+    "pup_credentials", "credential_list", "credential_set", "credential_delete", "pup_login", "pup_credential_reveal", "pup_card_set", "pup_cards", "pup_card_delete", "pup_fill_card", "pup_wiki_set_view", "pup_annotate", "pup_floaty_click",+    "pup_record_start", "pup_record_stop", "pup_record_status", "pup_record_list", "pup_recorder_open", "pup_recorder_close", "pup_screen_record_start", "pup_screen_record_stop", "pup_screen_record_status", "pup_screen_record_list", "pup_highfps_extend",+    "pup_screenshot_element", "pup_screenshot_full_res", "pup_download", "pup_launch", "pup_deps", "pup_install_toolbar", "pup_reap_profiles", "pup_uninstall", "pup_move_to_workspace"];  pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {     let t0 = Instant::now();@@ -119,10 +170,45 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {             pup_core::merge(r, json!({ "sessionId": "pup-dashboard", "dashboardUrl": format!("http://127.0.0.1:{}/dashboard", ctx.port), "reused": existed, "foregrounded": human, "_hint": if human { "The pup dashboard was raised because a human clicked the taskbar task." } else { "The pup dashboard is open in its own pup window (session pup-dashboard), BACKGROUNDED like any pup window; raise it with pup_raise_os_window {foregroundReason} if the user asked to see it." } }))         }         "pup_audit_icons" => identity::audit(ctx),+        "pup_credentials" | "credential_list" => vault::credentials(ctx),+        "credential_set" => vault::credential_set(ctx, args),+        "credential_delete" => vault::credential_delete(ctx, args),+        "pup_login" => vault::login(ctx, caller, args),+        "pup_credential_reveal" => vault::reveal(ctx, caller, args),+        "pup_card_set" => vault::card_set(ctx, args),+        "pup_cards" => vault::cards(ctx),+        "pup_card_delete" => vault::card_delete(ctx, args),+        "pup_fill_card" => vault::fill_card(ctx, caller, args),+        "pup_wiki_set_view" => wiki::set_view(ctx, caller, args),+        "pup_annotate" => annotate::start(ctx, caller, args),+        "pup_floaty_click" => annotate::floaty_click(ctx, caller, args),+        "pup_record_start" => record::start(ctx, caller, args),+        "pup_record_stop" => record::stop(ctx, caller, args),+        "pup_record_status" => record::status(ctx, caller, args),+        "pup_record_list" => record::list(ctx),+        "pup_recorder_open" => record::recorder_open(ctx, args),+        "pup_recorder_close" => record::recorder_close(ctx),+        "pup_screen_record_start" => record::screen_start(ctx, args),+        "pup_screen_record_stop" => record::screen_stop(ctx),+        "pup_screen_record_status" => record::screen_status(ctx),+        "pup_screen_record_list" => record::list(ctx),+        "pup_highfps_extend" => highfps::extend(ctx, caller, args),+        "pup_screenshot_element" => extras::screenshot_element(ctx, caller, args),+        "pup_screenshot_full_res" => extras::screenshot_full_res(ctx, caller, args),+        "pup_download" => extras::download(ctx, caller, args),+        "pup_launch" => extras::launch(ctx, caller, args),+        "pup_deps" => extras::deps(ctx),+        "pup_install_toolbar" => extras::install_toolbar(ctx),+        "pup_reap_profiles" => extras::reap_profiles(ctx, args),+        "pup_uninstall" => extras::uninstall(ctx, args),+        "pup_move_to_workspace" => extras::move_to_workspace(ctx),         v if v.starts_with("pup_") || v.starts_with("desktop_record") || v.starts_with("desktop_recorder") || v.starts_with("credential_") => err("not_in_rust_yet", format!("{v} is not served by the Rust bridge yet (Phase 1 carries {} verbs)", PHASE1_VERBS.len()), Some("This is the Rust port of pup (docs/PLAN-rust-port.md). The verb exists in the contract and lands in a later phase; the Node build still serves it on the public tier.")),         v => err("unknown_verb", format!("unknown verb {v}"), Some("pup_describe lists every verb this bridge serves.")),     };-    if let Some(o) = r.as_object_mut() { o.entry("durationMs").or_insert(json!(t0.elapsed().as_millis() as u64)); o.insert("shell".into(), json!("rust")); }+    if let Some(o) = r.as_object_mut() {+        o.entry("durationMs").or_insert(json!(t0.elapsed().as_millis() as u64)); o.insert("shell".into(), json!("rust"));+        if let Some(sid) = o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()) { if let Some(h) = ctx.sessions.with_mut(&sid, |x| x.pending_cred_hint.take()).flatten() { o.insert("_credentialCaptured".into(), json!(h)); } }+    }     r } @@ -217,12 +303,28 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) - pub fn attach_tab(s: &mut Session, cdp: &Cdp, target_id: &str) -> Result<String, Value> {     let sid = cdp.attach(target_id).map_err(|e| err("cdp_attach_failed", e.to_string(), None))?;     let _ = cdp.page_enable(&sid);+    for name in ["__pupCredCapture", "__pupFloatyPush"] { let _ = cdp.send(Some(&sid), "Runtime.addBinding", json!({ "name": name }), Duration::from_secs(5)); }+    let capture = vault::capture_login_script();+    let _ = cdp.send(Some(&sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": capture }), Duration::from_secs(5));+    let _ = cdp.evaluate(&sid, &capture, Duration::from_secs(5));     let tab_id = s.next_tab_id();     s.tabs.push(Tab { tab_id: tab_id.clone(), target_id: target_id.into(), cdp_session: sid, url: "about:blank".into(), title: String::new(), error_count: 0 });     s.active_tab = s.tabs.len() - 1;     Ok(tab_id) } +/// Per-page scripts that need settings (floaty pencil + hotkey, owner chip). Idempotent.+pub fn inject_page_scripts(ctx: &Ctx, s: &Session, cdp: &Cdp, sid: &str) {+    let floaty = annotate::floaty_script(ctx);+    let _ = cdp.send(Some(sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": floaty }), Duration::from_secs(5));+    let _ = cdp.evaluate(sid, &floaty, Duration::from_secs(5));+    if ctx.settings.get("ownerChip", "on") != "off" {+        let chip = OWNER_CHIP_JS.replace("__THREAD__", &serde_json::to_string(&s.owner.clone().unwrap_or_default()).unwrap()).replace("__PROFILE__", &serde_json::to_string(&s.profile).unwrap()).replace("__AVATAR__", &serde_json::to_string(&ctx.settings.get("identityAvatarB64", "")).unwrap()).replace("__IDENT__", &serde_json::to_string(&ctx.settings.get("identityName", "")).unwrap());+        let _ = cdp.send(Some(sid), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": chip }), Duration::from_secs(5));+        let _ = cdp.evaluate(sid, &chip, Duration::from_secs(5));+    }+}+ pub fn refresh_tab(cdp: &Cdp, t: &mut Tab) {     if let Ok(info) = cdp.target_info(&t.cdp_session) {         if let Some(u) = info.get("url").and_then(|u| u.as_str()) { t.url = u.into(); }@@ -256,6 +358,7 @@ fn active_rows(ctx: &Ctx) -> Vec<Value> {             rows.push(json!({ "id": "cft-download", "verb": "pup_prewarm", "phase": "download", "stepLabel": "Downloading Chrome for Testing", "percent": rd.get("installProgressPct"), "elapsedSec": rd.get("downloadElapsedSec"), "estimatedSec": rd.get("downloadElapsedSec").and_then(|e| e.as_u64()).zip(rd.get("downloadEtaSec").and_then(|e| e.as_u64())).map(|(a, b)| a + b), "etaSec": rd.get("downloadEtaSec"), "confidence": if rd.get("downloadEtaSec").is_some_and(|v| !v.is_null()) { "measured" } else { "estimated" }, "startKind": "cold" }));         }     }+    rows.extend(ctx.consent.rows());     rows } @@ -289,7 +392,7 @@ fn status(ctx: &Ctx) -> Value {         "version": pup_core::version(), "shell": "rust", "uptimeSec": ctx.started.elapsed().as_secs(),         "sessionCount": list.len(), "activeSession": ctx.sessions.active_id(), "sessions": list,         "settings": { "taskbarMode": if ctx.settings.get("aumidIcons", "on") != "on" { "plain" } else if ctx.settings.get("threadTiles", "on") == "on" { "tiles" } else { "aumid" }, "aumidIcons": ctx.settings.get("aumidIcons", "on"), "threadTiles": ctx.settings.get("threadTiles", "on"), "overlayBadges": ctx.settings.get("overlayBadges", "on"), "overlaySize": ctx.settings.get("overlaySize", "75"), "jumpLists": ctx.settings.get("jumpLists", "on") },-        "win32": { "inProcess": true, "why": Value::Null }, "enumWindows": { "total": 0, "lastMin": 0 }, "win32Calls": {}, "log": [], "loops": [], "iconAudit": Value::Null,+        "win32": { "inProcess": true, "why": Value::Null }, "enumWindows": { "total": 0, "lastMin": 0 }, "win32Calls": {}, "log": [], "loops": [], "iconAudit": ctx.last_audit.lock().unwrap().clone(),         "adVersion": ctx.ad_version.lock().unwrap().clone(),     })) }@@ -333,6 +436,7 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {             let tid = match b.cdp.create_target(&url, false, None) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", e.to_string(), None) };             let mut s = existing.clone();             let tab_id = match attach_tab(&mut s, &b.cdp, &tid) { Ok(t) => t, Err(e) => return e };+            if let Some(cs) = s.tabs.last().map(|t| t.cdp_session.clone()) { inject_page_scripts(ctx, &s, &b.cdp, &cs); }             if let Some(t) = s.tabs.last_mut() { let _ = b.cdp.navigate(&t.cdp_session, &url, Duration::from_secs(15)); refresh_tab(&b.cdp, t); }             let title = s.active().map(|t| t.title.clone()).unwrap_or_default();             let tab_count = s.tabs.len();@@ -366,7 +470,9 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         parked = ctx.host.send_to_bottom_no_activate(h);         let _ = ctx.ab.call("desktop_set_window_state", json!({ "hwnd": h, "state": "bottom", "force": true }), caller, Duration::from_secs(5));     }+    if let Some(cs) = s.tabs.last().map(|t| t.cdp_session.clone()) { inject_page_scripts(ctx, &s, &b.cdp, &cs); }     let loaded = if let Some(t) = s.tabs.last_mut() { let r = b.cdp.navigate(&t.cdp_session, &url, Duration::from_secs(20)).map(|(_, l)| l).unwrap_or(false); refresh_tab(&b.cdp, t); r } else { false };+    let (auto_login_note, wiki_extra) = match s.tabs.last() { Some(t) => (vault::autofill_on_load(ctx, &s, &t.cdp_session, &url), wiki::after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &url, args["wikiView"].as_str(), args["isolated"].as_bool().unwrap_or(false))), None => (None, json!({})) };     if let Some(h) = hwnd { ctx.host.send_to_bottom_no_activate(h); }   // the first cross-origin paint can raise it: re-assert (Node: reassertBottomAfterNav)     let title = s.active().map(|t| t.title.clone()).unwrap_or_default();     ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);@@ -384,6 +490,9 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         "_hint": "Opened in the BACKGROUND (behind the user's windows, did NOT steal focus). Fully drivable while hidden: pup_screenshot / pup_eval / pup_navigate resolve to this window with no ids. Only bring it to the user's screen if they explicitly want to watch.",     }));     if b.launched.candidate.kind != "chrome-for-testing" { resp["_toolbarNotice"] = json!(format!("This window launched on {}: the Adom toolbar (pen/dots/identity tiles) rides ONLY Chrome for Testing, so it has NO toolbar. pup_readiness shows why that browser was picked.", b.launched.candidate.kind)); }+    if let Some(n) = auto_login_note { resp["_autoLogin"] = json!(n); }+    resp = pup_core::merge(resp, wiki_extra);+    if args["highFps"].as_bool() == Some(true) { highfps::grant(ctx, &sid, args["highFpsMinutes"].as_u64().unwrap_or(highfps::DEFAULT_MIN)); resp["highFps"] = highfps::info(ctx, &sid); }     if hwnd.is_none() { resp["_windowNotice"] = json!("pup could not resolve the OS window handle for this window (no Chrome_WidgetWin_1 appeared), so it was not parked or tagged; it may be on top. This is the Phase 1 Rust bridge; report it with the box name."); }     resp }@@ -402,7 +511,11 @@ fn navigate(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {             if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }             if let Some(ss) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&ss); }             if let Some(a) = ctx.arc() { identity::schedule(a, s.id.clone(), caller.clone(), false); }-            ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "url": t.url, "title": t.title, "loaded": loaded, "_verifyRender": VERIFY_RENDER, "_hint": if loaded { "Navigation finished (load event). Verify with a screenshot before asserting success." } else { "Page.navigate returned but the load event did not fire within 25s: the page is still moving (SPA, slow site). Screenshot to see where it is." }, "_next": ["pup_screenshot", "pup_eval"] }))+            let note = vault::autofill_on_load(ctx, &s, &t.cdp_session, &t.url);+            let wiki_extra = wiki::after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &t.url, args["wikiView"].as_str(), false);+            let mut r = ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "url": t.url, "title": t.title, "loaded": loaded, "_verifyRender": VERIFY_RENDER, "_hint": if loaded { "Navigation finished (load event). Verify with a screenshot before asserting success." } else { "Page.navigate returned but the load event did not fire within 25s: the page is still moving (SPA, slow site). Screenshot to see where it is." }, "_next": ["pup_screenshot", "pup_eval"] }));+            if let Some(n) = note { r["_autoLogin"] = json!(n); }+            pup_core::merge(r, wiki_extra)         }         Err(e) => err("navigate_failed", e.to_string(), Some("Check the URL; a container-local localhost is not reachable from the desktop (use the proxy URL).")),     }
rust/verbs/src/record.rsadded+244
@@ -0,0 +1,244 @@+//! Recording. Tab recording (pup_record_*): CDP screencast frames to JPEGs, muxed to WebM by+//! ffmpeg when the box has it, else packed as an MJPEG AVI (pure Rust, no dependency) so a video+//! always comes out. The Chrome screen-share recorder (pup_screen_record_* / pup_recorder_*): the+//! embedded recorder.html in its own `_recorder` profile window, WebM chunks streamed over a+//! binding. ab-core owns whole-desktop recording natively (desktop_record_*) since ab 2.1.36.++use crate::input::resolve_tab;+use crate::{browser_for, Ctx};+use pup_core::{err, ok, Caller};+use pup_cdp::Cdp;+use serde_json::{json, Value};+use std::collections::HashMap;+use std::io::Write;+use std::path::{Path, PathBuf};+use std::sync::{Arc, Mutex};+use std::time::{Duration, Instant};++pub struct TabRec { pub id: String, pub session: String, pub tab_id: String, pub cdp_session: String, pub frames_dir: PathBuf, pub file: PathBuf, pub fps: u32, pub started: Instant, pub frames: u32, pub stamps: Vec<f64>, pub sizes: Vec<(u32, u32)> }+pub type TabRecs = Arc<Mutex<HashMap<String, TabRec>>>;   // by session id++pub fn recordings_dir(ctx: &Ctx) -> PathBuf { let d = ctx.bridge_dir.join("recordings"); let _ = std::fs::create_dir_all(&d); d }++pub fn ffmpeg_path() -> Option<PathBuf> {+    if std::process::Command::new("ffmpeg").arg("-version").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().map(|s| s.success()).unwrap_or(false) { return Some(PathBuf::from("ffmpeg")); }+    let lad = std::env::var("LOCALAPPDATA").unwrap_or_default();+    for p in ["C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe", "C:\\ffmpeg\\bin\\ffmpeg.exe", "C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"] { if Path::new(p).is_file() { return Some(PathBuf::from(p)); } }+    if let Ok(rd) = std::fs::read_dir(format!("{lad}\\Microsoft\\WinGet\\Packages")) { for e in rd.flatten() { if e.file_name().to_string_lossy().starts_with("Gyan.FFmpeg") { if let Ok(rd2) = std::fs::read_dir(e.path()) { for f in rd2.flatten() { let c = f.path().join("bin").join("ffmpeg.exe"); if c.is_file() { return Some(c); } } } } } }+    None+}++/// Frames arrive on the event pump (Page.screencastFrame); this is the handler it calls.+pub fn on_frame(recs: &TabRecs, cdp: &Cdp, cdp_session: &str, params: &Value) {+    let ack = params["sessionId"].clone();+    let acked = cdp.send(Some(cdp_session), "Page.screencastFrameAck", json!({ "sessionId": ack }), Duration::from_secs(3));+    let mut m = recs.lock().unwrap();+    if let Err(e) = acked { if let Some(r) = m.values().find(|r| r.cdp_session == cdp_session) { if r.frames < 3 { pup_core::log(format!("[{}] ack error: {e}", r.id)); } } }+    let Some(r) = m.values_mut().find(|r| r.cdp_session == cdp_session) else { return };+    let Some(b64) = params["data"].as_str() else { return };+    let Ok(bytes) = pup_cdp::base64_decode(b64) else { return };+    r.frames += 1;+    if r.frames == 1 { pup_core::log(format!("[{}] FIRST screencast frame ({} bytes)", r.id, bytes.len())); }+    let name = format!("f{:06}.jpg", r.frames);+    let _ = std::fs::write(r.frames_dir.join(name), &bytes);+    r.stamps.push(params["metadata"]["timestamp"].as_f64().unwrap_or(r.started.elapsed().as_secs_f64()));+    let (w, h) = (params["metadata"]["deviceWidth"].as_u64().unwrap_or(0) as u32, params["metadata"]["deviceHeight"].as_u64().unwrap_or(0) as u32);+    if w > 0 && h > 0 { r.sizes.push((w, h)); }+}++pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    if ctx.tab_recs.lock().unwrap().contains_key(&s.id) { return err("recording_in_progress", format!("window \"{}\" is already recording", s.id), Some("pup_record_stop first.")); }+    let fps = args["fps"].as_u64().unwrap_or(30).clamp(1, 60) as u32;+    let quality = args["quality"].as_u64().unwrap_or(85).clamp(10, 100);+    let ts = pup_core::now_ms();+    let file = recordings_dir(ctx).join(format!("rec-tab-{}-{}-{ts}.webm", pup_session::sanitize_sid(&s.id), t.tab_id));+    let frames_dir = recordings_dir(ctx).join(format!("rec-tab-{}-{}-{ts}.frames", pup_session::sanitize_sid(&s.id), t.tab_id));+    let _ = std::fs::create_dir_all(&frames_dir);+    let id = format!("rec-{}", ts % 100000);+    ctx.tab_recs.lock().unwrap().insert(s.id.clone(), TabRec { id: id.clone(), session: s.id.clone(), tab_id: t.tab_id.clone(), cdp_session: t.cdp_session.clone(), frames_dir, file: file.clone(), fps, started: Instant::now(), frames: 0, stamps: Vec::new(), sizes: Vec::new() });+    let every = (60 / fps).max(1);+    // Node: page.bringToFront() before the screencast so Chrome's compositor stays warm — an occluded,+    // never-activated tab emits no screencast frames at all. Re-park the OS window right after.+    let _ = b.cdp.send(Some(&t.cdp_session), "Page.bringToFront", json!({}), Duration::from_secs(5));+    if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }+    if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Page.startScreencast", json!({ "format": "jpeg", "quality": quality, "everyNthFrame": every }), Duration::from_secs(10)) { ctx.tab_recs.lock().unwrap().remove(&s.id); return err("record_start_failed", e.to_string(), None); }+    crate::highfps::grant(ctx, &s.id, args["minutes"].as_u64().unwrap_or(20));+    pup_core::log(format!("[{id}] CDP screencast started @ {fps}fps on \"{}\" -> {}", s.id, file.display()));+    ok(json!({ "success": true, "output": json!({ "ok": true, "recordingId": id, "sessionId": s.id, "tabId": t.tab_id, "fps": fps, "filePath": file.to_string_lossy().replace('\\', "/"), "encoder": if ffmpeg_path().is_some() { "ffmpeg (VP9 WebM)" } else { "built-in (MJPEG AVI, ffmpeg not found)" } }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "recordingId": id,+        "_hint": "Recording this tab's page content (background is fine). Drive the page, then pup_record_stop {} returns the video path. The window rides a highFps lease while recording." }))+}++pub fn stop(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    let Some(rec) = ctx.tab_recs.lock().unwrap().remove(&s.id) else { return err("no_recording", format!("window \"{}\" is not recording", s.id), None) };+    if let Some(b) = browser_for(ctx, &s.profile) { let _ = b.cdp.send(Some(&rec.cdp_session), "Page.stopScreencast", json!({}), Duration::from_secs(5)); }+    let duration_ms = rec.started.elapsed().as_millis() as u64;+    if rec.frames == 0 { let _ = std::fs::remove_dir_all(&rec.frames_dir); return err("no_frames", "zero frames captured — no video produced", Some("The page never painted while recording (a background window that Chrome fully throttled?). Open with highFps:true or make the page change.")); }+    let actual_fps = rec.frames as f64 / (duration_ms.max(1) as f64 / 1000.0);+    let (out_path, encoder) = match ffmpeg_path() {+        Some(ff) => match mux_ffmpeg(&ff, &rec, actual_fps) { Ok(()) => (rec.file.clone(), "ffmpeg VP9 WebM".to_string()), Err(e) => { pup_core::log(format!("[{}] ffmpeg mux failed: {e} — packing MJPEG AVI instead", rec.id)); let avi = rec.file.with_extension("avi"); match pack_avi(&rec, &avi, actual_fps) { Ok(()) => (avi, "built-in MJPEG AVI (ffmpeg failed)".into()), Err(e2) => return err("mux_failed", format!("ffmpeg: {e}; avi: {e2}"), None) } } },+        None => { let avi = rec.file.with_extension("avi"); match pack_avi(&rec, &avi, actual_fps) { Ok(()) => (avi, "built-in MJPEG AVI (no ffmpeg on this box)".into()), Err(e) => return err("mux_failed", e, None) } }+    };+    let _ = std::fs::remove_dir_all(&rec.frames_dir);+    let size = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);+    pup_core::log(format!("[{}] {} frames over {:.1}s -> {} ({size} bytes, {encoder})", rec.id, rec.frames, duration_ms as f64 / 1000.0, out_path.display()));+    ok(json!({ "success": true, "output": json!({ "ok": true, "recordingId": rec.id, "filePath": out_path.to_string_lossy().replace('\\', "/"), "frames": rec.frames, "durationMs": duration_ms, "fps": actual_fps.round(), "sizeKB": size / 1024, "encoder": encoder }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "filePath": out_path.to_string_lossy().replace('\\', "/"),+        "_hint": if encoder.contains("AVI") { "Saved as MJPEG AVI because ffmpeg is not installed (winget install Gyan.FFmpeg gives VP9 WebM next time). Pull it with adom-bridge pull_file." } else { "Saved. Pull it with adom-bridge pull_file {filePaths:[...]} or hand the path to the user." } }))+}++fn mux_ffmpeg(ff: &Path, rec: &TabRec, actual_fps: f64) -> Result<(), String> {+    let mut lines = Vec::new();+    for i in 0..rec.frames as usize {+        lines.push(format!("file 'f{:06}.jpg'", i + 1));+        let dur = match (rec.stamps.get(i), rec.stamps.get(i + 1)) { (Some(a), Some(b)) => (b - a).max(0.001), _ => (1.0 / actual_fps.max(1.0)).max(0.001) };+        lines.push(format!("duration {dur:.4}"));+    }+    lines.push(format!("file 'f{:06}.jpg'", rec.frames));+    let concat = rec.frames_dir.join("concat.txt");+    std::fs::write(&concat, lines.join("\n")).map_err(|e| e.to_string())?;+    let out = std::process::Command::new(ff).args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"]).arg(&concat).args(["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32", "-pix_fmt", "yuv420p", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2"]).arg(&rec.file).output().map_err(|e| e.to_string())?;+    if out.status.success() && rec.file.is_file() { Ok(()) } else { Err(String::from_utf8_lossy(&out.stderr).chars().take(300).collect()) }+}++/// MJPEG in a RIFF AVI (the simplest container every player and browser reads).+fn pack_avi(rec: &TabRec, out: &Path, fps: f64) -> Result<(), String> {+    let (w, h) = rec.sizes.last().cloned().unwrap_or((1280, 720));+    let mut frames: Vec<Vec<u8>> = Vec::with_capacity(rec.frames as usize);+    for i in 1..=rec.frames { let p = rec.frames_dir.join(format!("f{i:06}.jpg")); if let Ok(b) = std::fs::read(p) { frames.push(b); } }+    if frames.is_empty() { return Err("no frames on disk".into()); }+    let fps_u = fps.round().clamp(1.0, 60.0) as u32;+    let mut movi = Vec::new(); let mut idx = Vec::new();+    for f in &frames {+        let off = movi.len() as u32 + 4;+        movi.extend_from_slice(b"00dc"); movi.extend_from_slice(&(f.len() as u32).to_le_bytes()); movi.extend_from_slice(f); if f.len() % 2 == 1 { movi.push(0); }+        idx.extend_from_slice(b"00dc"); idx.extend_from_slice(&0x10u32.to_le_bytes()); idx.extend_from_slice(&off.to_le_bytes()); idx.extend_from_slice(&(f.len() as u32).to_le_bytes());+    }+    let mut avih = Vec::new();+    avih.extend_from_slice(&(1_000_000 / fps_u).to_le_bytes()); avih.extend_from_slice(&0u32.to_le_bytes()); avih.extend_from_slice(&0u32.to_le_bytes()); avih.extend_from_slice(&0x10u32.to_le_bytes());+    avih.extend_from_slice(&(frames.len() as u32).to_le_bytes()); avih.extend_from_slice(&0u32.to_le_bytes()); avih.extend_from_slice(&1u32.to_le_bytes()); avih.extend_from_slice(&0u32.to_le_bytes());+    avih.extend_from_slice(&w.to_le_bytes()); avih.extend_from_slice(&h.to_le_bytes()); avih.extend_from_slice(&[0u8; 16]);+    let mut strh = Vec::new();+    strh.extend_from_slice(b"vids"); strh.extend_from_slice(b"MJPG"); strh.extend_from_slice(&[0u8; 12]); strh.extend_from_slice(&1u32.to_le_bytes()); strh.extend_from_slice(&fps_u.to_le_bytes()); strh.extend_from_slice(&0u32.to_le_bytes()); strh.extend_from_slice(&(frames.len() as u32).to_le_bytes()); strh.extend_from_slice(&0u32.to_le_bytes()); strh.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); strh.extend_from_slice(&0u32.to_le_bytes()); strh.extend_from_slice(&[0u8, 0, 0, 0, (w & 0xff) as u8, (w >> 8) as u8, (h & 0xff) as u8, (h >> 8) as u8]);+    let mut strf = Vec::new();+    strf.extend_from_slice(&40u32.to_le_bytes()); strf.extend_from_slice(&(w as i32).to_le_bytes()); strf.extend_from_slice(&(h as i32).to_le_bytes()); strf.extend_from_slice(&1u16.to_le_bytes()); strf.extend_from_slice(&24u16.to_le_bytes()); strf.extend_from_slice(b"MJPG"); strf.extend_from_slice(&(w * h * 3).to_le_bytes()); strf.extend_from_slice(&[0u8; 16]);+    let chunk = |id: &[u8], body: &[u8]| { let mut v = Vec::new(); v.extend_from_slice(id); v.extend_from_slice(&(body.len() as u32).to_le_bytes()); v.extend_from_slice(body); if body.len() % 2 == 1 { v.push(0); } v };+    let list = |kind: &[u8], body: &[u8]| { let mut inner = kind.to_vec(); inner.extend_from_slice(body); chunk(b"LIST", &inner) };+    let strl = list(b"strl", &[chunk(b"strh", &strh), chunk(b"strf", &strf)].concat());+    let hdrl = list(b"hdrl", &[chunk(b"avih", &avih), strl].concat());+    let movi_list = list(b"movi", &movi);+    let idx1 = chunk(b"idx1", &idx);+    let mut riff_body = b"AVI ".to_vec(); riff_body.extend_from_slice(&hdrl); riff_body.extend_from_slice(&movi_list); riff_body.extend_from_slice(&idx1);+    let mut f = std::fs::File::create(out).map_err(|e| e.to_string())?;+    f.write_all(b"RIFF").and_then(|_| f.write_all(&(riff_body.len() as u32).to_le_bytes())).and_then(|_| f.write_all(&riff_body)).map_err(|e| e.to_string())+}++pub fn status(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    let m = ctx.tab_recs.lock().unwrap();+    let cur = m.get(&s.id).map(|r| json!({ "recordingId": r.id, "tabId": r.tab_id, "fps": r.fps, "frames": r.frames, "durationMs": r.started.elapsed().as_millis() as u64, "filePath": r.file.to_string_lossy().replace('\\', "/") }));+    ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "recording": cur.is_some(), "current": cur }).to_string(), "sessionId": s.id, "window": s.id, "recording": cur.is_some() }))+}++pub fn list(ctx: &Ctx) -> Value {+    let mut rows = Vec::new();+    if let Ok(rd) = std::fs::read_dir(recordings_dir(ctx)) { for e in rd.flatten() { let p = e.path(); let ext = p.extension().map(|x| x.to_string_lossy().to_lowercase()).unwrap_or_default(); if ["webm", "avi", "mp4"].contains(&ext.as_str()) { if let Ok(md) = p.metadata() { rows.push(json!({ "filePath": p.to_string_lossy().replace('\\', "/"), "sizeKB": md.len() / 1024, "mtime": md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64) })); } } } }+    rows.sort_by(|a, b| b["mtime"].as_u64().cmp(&a["mtime"].as_u64()));+    ok(json!({ "success": true, "output": json!({ "ok": true, "recordings": rows }).to_string(), "recordings": rows, "count": rows.len() }))+}++// ── the Chrome screen-share recorder (pup_screen_record_* / pup_recorder_*) ────────────++pub struct ScreenRec { pub cdp: Cdp, pub cdp_session: String, pub pid: u32, pub reason: String, pub current: Option<(String, PathBuf, std::fs::File, Instant, u32)>, pub clips: Vec<Value>, pub counter: u32 }+pub type ScreenRecState = Arc<Mutex<Option<ScreenRec>>>;++pub const SCREEN_HINT: &str = "This is pup's CHROME screen-share recorder (getDisplayMedia -> WebM VP9, consent = the visible recorder HUD, system audio + mic mixing, cross-platform). For a whole-monitor or single-window recording with no browser use ab-core's native desktop_record_start (WGC + system audio to MP4, ab 2.1.36+).";++fn recorder_html_path(ctx: &Ctx) -> PathBuf { let p = ctx.host.temp_dir().join("pup-recorder").join("recorder.html"); if !p.is_file() { let _ = std::fs::create_dir_all(p.parent().unwrap()); if let Some(b) = crate::assets_recorder_html() { let _ = std::fs::write(&p, b); } } p }++fn ensure_hud(ctx: &Ctx, reason: &str) -> Result<(), Value> {+    if reason.trim().is_empty() { return Err(err("reason_required", "reason is required — explain to the user why you're recording", Some("It is rendered in the recorder HUD title; a recording never starts silently."))); }+    let mut st = ctx.screen_rec.lock().unwrap();+    if let Some(r) = st.as_mut() { if r.cdp.is_alive() { let _ = r.cdp.evaluate(&r.cdp_session, &format!("window.setSessionReason && window.setSessionReason({})", serde_json::to_string(reason).unwrap()), Duration::from_secs(5)); r.reason = reason.into(); return Ok(()); } }+    *st = None;+    let cand = pup_browser::detect_cft(&*ctx.host).or_else(|| pup_browser::list_system_browsers(&*ctx.host).into_iter().next()).ok_or_else(|| err("no_browser", "no browser to host the recorder HUD", None))?;+    let profile_dir = pup_browser::profiles_dir(&*ctx.host).join("_recorder");+    let _ = std::fs::create_dir_all(&profile_dir);+    let html = recorder_html_path(ctx);+    let url = format!("file:///{}?reason={}", html.to_string_lossy().replace('\\', "/"), reason.bytes().map(|b| if b.is_ascii_alphanumeric() { (b as char).to_string() } else { format!("%{b:02X}") }).collect::<String>());+    let port_file = profile_dir.join("DevToolsActivePort"); let _ = std::fs::remove_file(&port_file);+    let args = vec![format!("--user-data-dir={}", profile_dir.display()), "--remote-debugging-port=0".into(), format!("--app={url}"), "--window-size=420,260".into(), "--use-fake-ui-for-media-stream".into(), "--auto-select-desktop-capture-source=Entire screen".into(), "--enable-features=GetDisplayMediaSet".into(), "--auto-accept-this-tab-capture".into(), "--no-first-run".into(), "--no-default-browser-check".into(), "--disable-features=CalculateNativeWinOcclusion,MediaRouter".into()];+    let pid = ctx.host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| err("recorder_launch_failed", e.to_string(), None))?;+    let t0 = Instant::now(); let mut port = None;+    while t0.elapsed() < Duration::from_secs(20) { if let Ok(s) = std::fs::read_to_string(&port_file) { if let Some(p) = s.lines().next().and_then(|l| l.trim().parse::<u16>().ok()) { port = Some(p); break; } } std::thread::sleep(Duration::from_millis(200)); }+    let port = port.ok_or_else(|| err("recorder_launch_failed", "the recorder window published no DevTools port", None))?;+    let cdp = Cdp::connect_port(port, Duration::from_secs(10)).map_err(|e| err("recorder_connect_failed", e.to_string(), None))?;+    let target = cdp.targets().unwrap_or_default().into_iter().find(|t| t["type"] == json!("page")).and_then(|t| t["targetId"].as_str().map(|s| s.to_string())).ok_or_else(|| err("recorder_launch_failed", "no page target", None))?;+    let sid = cdp.attach(&target).map_err(|e| err("recorder_connect_failed", e.to_string(), None))?;+    let _ = cdp.page_enable(&sid);+    for name in ["_saveChunk", "_recordingStopped", "_resizeWindow", "_recorderClosed", "_openRecordingsFolder"] { let _ = cdp.send(Some(&sid), "Runtime.addBinding", json!({ "name": name }), Duration::from_secs(5)); }+    let _ = cdp.evaluate(&sid, "(() => { for (const n of ['_saveChunk','_recordingStopped','_resizeWindow','_recorderClosed','_openRecordingsFolder']) { const b = window[n]; window[n] = (a) => b(typeof a === 'string' ? a : JSON.stringify(a)); } return true; })()", Duration::from_secs(5));+    // bottom-right, always on top is the HUD's own job; park it out of the way of pup windows+    if let Ok((wid, _)) = cdp.window_for_target(&target) { if let Some((x, y, w, h)) = ctx.host.work_area() { let _ = cdp.set_window_bounds(wid, json!({ "left": x + w - 440, "top": y + h - 280, "width": 420, "height": 260 })); } }+    let rx = cdp.subscribe(Some(&sid), Some("Runtime.bindingCalled"));+    let state = ctx.screen_rec.clone(); let dir = recordings_dir(ctx);+    std::thread::Builder::new().name("screen-rec".into()).spawn(move || {+        while let Ok(ev) = rx.recv() {+            let name = ev.params["name"].as_str().unwrap_or(""); let payload = ev.params["payload"].as_str().unwrap_or("").to_string();+            let mut st = state.lock().unwrap();+            let Some(r) = st.as_mut() else { break };+            match name {+                "_saveChunk" => { if let Some((_, _, f, _, n)) = r.current.as_mut() { if let Ok(b) = pup_cdp::base64_decode(payload.trim_matches('"')) { let _ = f.write_all(&b); *n += 1; } } }+                "_recordingStopped" => { if let Some((id, path, f, t0, _)) = r.current.take() { drop(f); let ms = t0.elapsed().as_millis() as u64; let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); let _ = std::fs::write(path.with_extension("json"), json!({ "recordingId": id, "reason": r.reason, "durationMs": ms, "sizeKB": size / 1024, "filePath": path.to_string_lossy() }).to_string()); r.clips.push(json!({ "recordingId": id, "filePath": path.to_string_lossy().replace('\\', "/"), "durationMs": ms, "sizeKB": size / 1024 })); pup_core::log(format!("[recorder] {id} stopped: {} bytes, {ms}ms", size)); } }+                "_recorderClosed" => { pup_core::log("[recorder] HUD closed by the user"); *st = None; break; }+                "_openRecordingsFolder" => { let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn(); }+                _ => {}+            }+        }+    }).ok();+    *st = Some(ScreenRec { cdp, cdp_session: sid, pid, reason: reason.into(), current: None, clips: Vec::new(), counter: 0 });+    Ok(())+}++pub fn recorder_open(ctx: &Ctx, args: &Value) -> Value {+    if let Err(e) = ensure_hud(ctx, args["reason"].as_str().unwrap_or("")) { return e; }+    ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "opened": true, "reason": args["reason"] }).to_string() }))+}++pub fn screen_start(ctx: &Ctx, args: &Value) -> Value {+    if args["confirmDesktopNotTabRecording"].as_bool() != Some(true) { return err("desktop_record_needs_confirmation", "pup_screen_record_start records the WHOLE SCREEN through Chrome screen-share, not a pup tab.", Some("For a pup window/tab use pup_record_start (CDP screencast, background-safe). For a native no-browser MP4 use ab-core desktop_record_start. If you truly want the Chrome share picker path, re-call with confirmDesktopNotTabRecording:true.")); }+    if let Err(e) = ensure_hud(ctx, args["reason"].as_str().unwrap_or("")) { return e; }+    let mut st = ctx.screen_rec.lock().unwrap(); let r = st.as_mut().unwrap();+    if r.current.is_some() { return err("recording_in_progress", "a screen recording is already running; stop it first", None); }+    r.counter += 1; let id = format!("rec-{}", r.counter);+    let path = recordings_dir(ctx).join(format!("rec-desktop-{}.webm", pup_core::now_ms()));+    let f = match std::fs::File::create(&path) { Ok(f) => f, Err(e) => return err("recorder_file_failed", e.to_string(), None) };+    r.current = Some((id.clone(), path.clone(), f, Instant::now(), 0));+    let opts = json!({ "fps": args["fps"].as_u64().unwrap_or(30), "audio": args["audio"].as_bool().unwrap_or(false), "videoBitsPerSecond": args["videoBitsPerSecond"].as_u64().unwrap_or(4_000_000), "reason": r.reason, "maxDurationMs": args["maxDurationMs"].as_u64().unwrap_or(600_000) });+    if let Err(e) = r.cdp.evaluate(&r.cdp_session, &format!("window.startRecording && window.startRecording({opts})"), Duration::from_secs(10)) { r.current = None; return err("recorder_start_failed", e.to_string(), None); }+    ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "recordingId": id, "filePath": path.to_string_lossy().replace('\\', "/"), "fps": opts["fps"], "audio": opts["audio"] }).to_string(), "recordingId": id }))+}++pub fn screen_stop(ctx: &Ctx) -> Value {+    let (cdp, sid, id) = { let st = ctx.screen_rec.lock().unwrap(); let Some(r) = st.as_ref() else { return err("no_recorder", "the recorder HUD is not open", None) }; let Some((id, ..)) = r.current.as_ref() else { return err("no_recording", "no screen recording is running", None) }; (r.cdp.clone(), r.cdp_session.clone(), id.clone()) };+    let _ = cdp.evaluate(&sid, "window.stopRecording && window.stopRecording()", Duration::from_secs(10));+    let t0 = Instant::now();+    loop { { let st = ctx.screen_rec.lock().unwrap(); if let Some(r) = st.as_ref() { if r.current.is_none() { let clip = r.clips.last().cloned().unwrap_or(json!({})); return ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": pup_core::merge(json!({ "ok": true, "recordingId": id }), clip.clone()).to_string(), "filePath": clip["filePath"] })); } } else { break; } } if t0.elapsed() > Duration::from_secs(15) { break; } std::thread::sleep(Duration::from_millis(200)); }+    err("recorder_stop_timeout", "the recorder did not confirm the stop within 15s", None)+}++pub fn screen_status(ctx: &Ctx) -> Value {+    let st = ctx.screen_rec.lock().unwrap();+    let (hud, reason, cur, clips) = match st.as_ref() { Some(r) => (r.cdp.is_alive(), Some(r.reason.clone()), r.current.as_ref().map(|(id, p, _, t0, n)| json!({ "recordingId": id, "durationMs": t0.elapsed().as_millis() as u64, "filePath": p.to_string_lossy().replace('\\', "/"), "chunks": n })), r.clips.clone()), None => (false, None, None, vec![]) };+    ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "hudOpen": hud, "reason": reason, "currentRecording": cur, "clipsThisSession": clips }).to_string(), "hudOpen": hud }))+}++pub fn recorder_close(ctx: &Ctx) -> Value {+    let mut st = ctx.screen_rec.lock().unwrap();+    let Some(r) = st.take() else { return ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "closed": false }).to_string() })) };+    if r.current.is_some() { let _ = r.cdp.evaluate(&r.cdp_session, "window.stopRecording && window.stopRecording()", Duration::from_secs(10)); std::thread::sleep(Duration::from_millis(800)); }+    let _ = r.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5)); r.cdp.close();+    if r.pid > 0 { std::thread::sleep(Duration::from_millis(500)); if ctx.host.alive(r.pid) { ctx.host.kill(r.pid); } }+    let summary = json!({ "clipCount": r.clips.len(), "totalSizeKB": r.clips.iter().map(|c| c["sizeKB"].as_u64().unwrap_or(0)).sum::<u64>(), "reason": r.reason, "clips": r.clips });+    ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "sessionSummary": summary }).to_string() }))+}
rust/verbs/src/tabs.rs+2
@@ -18,8 +18,10 @@ pub fn open_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let target = match b.cdp.create_target(&url, false, None) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", e.to_string(), None) };     let mut sess = s.clone();     let tab_id = match attach_tab(&mut sess, &b.cdp, &target) { Ok(t) => t, Err(e) => return e };+    if let Some(cs) = sess.tabs.last().map(|t| t.cdp_session.clone()) { crate::inject_page_scripts(ctx, &sess, &b.cdp, &cs); }   // owner chip + floaty ride every new document from here on     let _ = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": target }), Duration::from_secs(5));     if url != "about:blank" { if let Some(t) = sess.tabs.last_mut() { let rx = b.cdp.subscribe(Some(&t.cdp_session), Some("Page.loadEventFired")); let _ = pup_cdp::Cdp::wait_event(&rx, Duration::from_secs(15)); refresh_tab(&b.cdp, t); } }+    if let Some(cs) = sess.tabs.last().map(|t| t.cdp_session.clone()) { crate::inject_page_scripts(ctx, &sess, &b.cdp, &cs); }     let row = sess.tabs.last().map(|t| tab_row(ctx, &sess, t, true)).unwrap_or(Value::Null);     let count = sess.tabs.len();     ctx.sessions.insert(sess.clone()); ctx.sessions.save_file(&sess);
rust/verbs/src/vault.rsadded+195
@@ -0,0 +1,195 @@+//! Credential and payment-card verbs: pup_credentials / credential_list, credential_set,+//! credential_delete, pup_login (fill, reason-gated submit, 2FA/captcha walls), the+//! X-Adom-Verified-Origin-gated pup_credential_reveal behind a consent toast, pup_card_set /+//! pup_cards / pup_card_delete / pup_fill_card (per-fill consent, digits typed in-page only), the+//! login-capture binding, and autofill-on-load. Ports server.js + credential_vault.js + card_vault.js.++use crate::input::resolve_tab;+use crate::{consent::Ask, detect_wall, touch, Ctx};+use pup_core::{blocked_on_user, err, ok, Caller};+use pup_session::Session;+use serde_json::{json, Value};+use std::time::Duration;++const T: Duration = Duration::from_secs(10);+pub const CAPTURE_LOGIN_JS: &str = include_str!("../assets/capture-login.js");++pub fn capture_login_script() -> String { CAPTURE_LOGIN_JS.replace("${BRIDGE_VERSION}", pup_core::version()) }++/// Runtime.bindingCalled "__pupCredCapture" from the capture script: store the login.+pub fn on_capture(ctx: &Ctx, sid_cdp: &str, payload: &str) {+    if ctx.settings.get("captureLogins", "on") == "off" { return; }+    let rec: Value = serde_json::from_str(payload).unwrap_or(json!({}));+    let (host, user, pass) = (rec["host"].as_str().unwrap_or(""), rec["u"].as_str().unwrap_or(""), rec["p"].as_str().unwrap_or(""));+    if host.is_empty() || pass.is_empty() { return; }+    if let Some(c) = ctx.vault.get_for_url(&*ctx.host, &format!("https://{host}/"), if user.is_empty() { None } else { Some(user) }) {+        if c.password == pass && (user.is_empty() || c.username == user) { ctx.vault.touch_used(&c.host); return; }   // already stored: blur + submit + autofill all re-fire the capture+    }+    match ctx.vault.set(&*ctx.host, host, if user.is_empty() { "(unknown)" } else { user }, pass) {+        Ok(_) => {+            pup_core::log(format!("[cred] captured login for {host}{} → OS keychain", if user.is_empty() { String::new() } else { format!(" ({user})") }));+            for s in ctx.sessions.all() { if s.tabs.iter().any(|t| t.cdp_session == sid_cdp) { ctx.sessions.with_mut(&s.id, |x| x.pending_cred_hint = Some(format!("pup CAPTURED this login for \"{host}\"{} into the OS keychain (encrypted at rest; never logged, never echoed). Next visit pup auto-fills it.", if user.is_empty() { String::new() } else { format!(" (username {user})") }))); } }+        }+        Err(e) => pup_core::log(format!("[cred] capture store failed: {e}")),+    }+}++pub fn credentials(ctx: &Ctx) -> Value {+    let list = ctx.vault.list();+    ok(json!({ "success": true, "count": list.len(), "credentials": list, "output": json!({ "ok": true, "credentials": list }).to_string(), "_hint": "Host + username only; passwords stay in the OS keychain. pup_login {submit:true, submitReason} signs a window in; pup_credential_reveal (local callers only) hands one out after the user clicks Reveal." }))+}++pub fn credential_set(ctx: &Ctx, args: &Value) -> Value {+    let (h, u, p) = (args["host"].as_str().unwrap_or("").trim(), args["username"].as_str().unwrap_or("").trim(), args["password"].as_str().unwrap_or(""));+    match ctx.vault.set(&*ctx.host, h, u, p) { Ok(_) => ok(json!({ "success": true, "host": h, "username": u, "output": json!({ "ok": true, "host": h, "username": u }).to_string(), "_hint": "Stored in the OS keychain. The password was not echoed and will not be." })), Err(e) => err("credential_set_failed", e, Some("host, username and password are all required; host may be a pattern like *.example.com.")) }+}++pub fn credential_delete(ctx: &Ctx, args: &Value) -> Value {+    let h = args["host"].as_str().unwrap_or("").trim();+    if h.is_empty() { return err("host_required", "credential_delete needs {host}", None); }+    let removed = ctx.vault.delete(&*ctx.host, h);+    ok(json!({ "success": true, "host": h, "removed": removed, "output": json!({ "ok": true, "removed": removed }).to_string() }))+}++/// pup_login: fill (and optionally submit, reason-gated) the login form in the window's active tab.+pub fn login(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let cur_url = t.url.clone();+    let live_host = pup_vault::host_of(&cur_url).unwrap_or_else(|| "<unknown>".into());+    let (username, password, source): (String, String, String) = match (args["username"].as_str(), args["password"].as_str()) {+        (Some(u), Some(p)) if !p.is_empty() => {+            if args["save"].as_bool() == Some(true) { let _ = ctx.vault.set(&*ctx.host, &live_host, u, p); }+            (u.into(), p.into(), "args".into())+        }+        _ => match ctx.vault.get_for_url(&*ctx.host, &cur_url, args["preferAccount"].as_str()) {+            Some(c) => (c.username, c.password, format!("vault:{}", c.host)),+            None => return pup_core::merge(err("no_credential", format!("no saved login matches {live_host}"), Some("Pass {username, password} (add save:true to remember it for this host), or store one first with credential_set {host, username, password}.")), json!({ "output": json!({ "ok": false, "url": cur_url }).to_string() })),+        },+    };+    let submit = args["submit"].as_bool() == Some(true);+    if submit && args["submitReason"].as_str().map(|r| r.trim().len() >= 10) != Some(true) {+        return pup_core::merge(err("submit_reason_required", "CONFIRM THE DOMAIN BEFORE SENDING CREDENTIALS", Some(&format!("You asked to submit a stored login: the password goes to the LIVE host \"{live_host}\". Re-call pup_login with submit:true and submitReason:\"<why {live_host} is the right site>\" (10+ chars) so a lookalike, typo or redirected domain is caught first."))), json!({ "output": json!({ "ok": false, "url": cur_url, "host": live_host }).to_string(), "liveHost": live_host }));+    }+    if submit { pup_core::log(format!("[cred] pup_login submit to {live_host} by \"{}\" — reason: {}", caller.thread, args["submitReason"].as_str().unwrap_or("").chars().take(120).collect::<String>())); }+    let keystrokes = args["realKeystrokes"].as_bool() == Some(true) || args["keystrokes"].as_bool() == Some(true);+    let located = match b.cdp.evaluate(&t.cdp_session, pup_vault::locate_login_js(), T) { Ok(v) => v, Err(e) => return err("login_fill_failed", e.to_string(), None) };+    if located["ok"] != json!(true) {+        return pup_core::merge(err("no_login_form", "No password field found on this page", Some("It may not be the login page yet, or the form is inside an iframe / shadow DOM pup did not reach. Navigate to the actual login page (screenshot to see), then call pup_login again.")), json!({ "output": json!({ "ok": false, "reason": located["reason"], "url": cur_url }).to_string() }));+    }+    let (filled_user, filled_pass) = if keystrokes {+        let type_into = |mark: &str, text: &str| -> bool {+            let focus = format!("(() => {{ const el = document.querySelector('[data-pup-{mark}]'); if (!el) return false; el.scrollIntoView({{ block: 'center' }}); el.focus(); return true; }})()");+            if b.cdp.evaluate(&t.cdp_session, &focus, T).ok() != Some(json!(true)) { return false; }+            let _ = crate::input::press_pub(&b.cdp, &t.cdp_session, "a", 2); let _ = crate::input::press_pub(&b.cdp, &t.cdp_session, "Backspace", 0);+            for ch in text.chars() { let cs = ch.to_string(); if crate::input::press_pub(&b.cdp, &t.cdp_session, &cs, if ch.is_uppercase() { 8 } else { 0 }).is_err() { let _ = b.cdp.send(Some(&t.cdp_session), "Input.insertText", json!({ "text": cs }), T); } }+            b.cdp.evaluate(&t.cdp_session, &format!("(document.querySelector('[data-pup-{mark}]') || {{ value: '' }}).value.length"), T).ok().and_then(|v| v.as_u64()).unwrap_or(0) > 0+        };+        let fu = located["hasUser"] == json!(true) && !username.is_empty() && type_into("uf", &username);+        let mut fp = type_into("pf", &password);+        if !fp { fp = type_into("pf", &password); }+        (fu, fp)+    } else {+        match b.cdp.evaluate(&t.cdp_session, &pup_vault::fill_login_js(&username, &password), T) { Ok(v) => (located["hasUser"] == json!(true) && v["fu"] == json!(true), v["plen"].as_u64().unwrap_or(0) > 0), Err(e) => return err("login_fill_failed", e.to_string(), None) }+    };+    let submitted = b.cdp.evaluate(&t.cdp_session, &pup_vault::submit_login_js(submit), T).ok() == Some(json!(true));+    if source.starts_with("vault:") { ctx.vault.touch_used(source.trim_start_matches("vault:")); }+    touch(ctx, &s.id, None);+    if submitted {+        std::thread::sleep(Duration::from_millis(1800));+        if let Some(w) = detect_wall(&b.cdp, &t.cdp_session) {+            let url_now = b.cdp.target_info(&t.cdp_session).ok().and_then(|i| i["url"].as_str().map(|s| s.to_string())).unwrap_or(cur_url.clone());+            pup_core::log(format!("[login] {live_host}: submitted, now on a {} wall — blocked_on_user", w["kind"].as_str().unwrap_or("?")));+            return blocked_on_user(false, if w["kind"] == json!("captcha") { "The user must solve the captcha in the pup window" } else { "The user must enter the one-time / two-factor code in the pup window" }, &format!("The login was filled and submitted; the site now needs the HUMAN ({}). Raise the window with pup_raise_os_window {{foregroundReason:\"finish the sign-in for {live_host}\"}} and tell them what to do, then continue (screenshot to confirm). Do not re-submit the password.", w["kind"].as_str().unwrap_or("?")), json!({ "reason": w["kind"], "evidence": w["evidence"], "output": json!({ "ok": false, "url": url_now, "submitted": true, "filledUsername": filled_user, "filledPassword": filled_pass, "wall": w["kind"] }).to_string() }));+        }+    }+    let react_miss = !keystrokes && !filled_pass;+    ok(json!({ "success": true, "output": json!({ "ok": true, "url": cur_url, "credentialSource": source, "filledUsername": filled_user, "filledPassword": filled_pass, "submitted": submitted, "reactMaskedFieldSuspected": react_miss }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner,+        "_hint": if react_miss { "The password did NOT land: this field looks React-controlled/masked (SSO/Keycloak logins). Re-call pup_login with realKeystrokes:true (raises the window briefly so trusted keystrokes register)." } else if submitted { "Login filled and SUBMITTED. Screenshot to confirm the landing page; pup_errors for console noise." } else { "Login form filled in the BACKGROUND (not submitted). Pass submit:true with submitReason to submit, or drive the submit button yourself." } }))+}++/// pup_credential_reveal: local callers only, one host, the user clicks Reveal on the desktop.+pub fn reveal(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let host = args["host"].as_str().unwrap_or("").trim().to_lowercase();+    let reason = args["reason"].as_str().unwrap_or("").trim().to_string();+    if host.is_empty() || reason.len() < 8 { return err("args_required", "pup_credential_reveal needs {host, reason} (reason 8+ chars; it is shown to the user)", None); }+    if caller.verified_origin != "local" || !caller.container.is_empty() {+        let relay = caller.verified_origin == "relay" || !caller.container.is_empty();+        pup_core::log(format!("[credential] reveal REFUSED for {host}: verified-origin={} caller \"{}\"", if caller.verified_origin.is_empty() { "MISSING" } else { &caller.verified_origin }, caller.thread));+        return err(if relay { "relay_forbidden" } else { "origin_unverified" }, if relay { "pup_credential_reveal is for LOCAL callers on the desktop only; a relayed (cloud container) call is refused".to_string() } else { format!("pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin \"{}\"", if caller.verified_origin.is_empty() { "MISSING" } else { &caller.verified_origin }) }, Some(if relay { "From a cloud thread do not ask for the secret: pup_login {submit:true, submitReason} signs the site in inside a pup window without the credential ever leaving the vault." } else { "Call through the adom-bridge CLI on this desktop (ab 2.0.96+)." }));+    }+    let Some(cred) = ctx.vault.get_for_url(&*ctx.host, &format!("https://{host}/"), None) else { return err("no_credential", format!("no saved login for {host}"), Some("pup_credentials {} lists the hosts the vault knows.")) };+    let who = if caller.thread.is_empty() { "an unnamed local caller".to_string() } else { caller.thread.clone() };+    let ask = ctx.consent.ask(ctx.ab.clone(), caller, &format!("pup_credential_reveal|{host}|{}", caller.thread), "pup_credential_reveal", &format!("Waiting for the user to allow revealing the {host} login to {who}"), "credreveal",+        json!({ "level": "warning", "scenario": "reminder", "title": format!("Reveal your {host} login to {who}?"), "body": format!("{who} is asking pup for the saved login for {host} (username {}). Reason given: \"{reason}\". Reveal sends the password to that caller ONCE. Deny sends nothing. This request came from a process on THIS desktop.", cred.username), "buttons": [{ "label": "Reveal" }, { "label": "Deny" }] }));+    match ask {+        Ask::Pending { consent_id, remaining_sec, .. } => blocked_on_user(true, &format!("The user must click Reveal or Deny on the desktop toast \"Reveal your {host} login to {who}?\""), &format!("Not an error. The toast stays up for {remaining_sec}s more. Call pup_credential_reveal again with the SAME args to re-attach and collect the decision (no second toast), or poll pup_readiness {{}} and watch active[] for consentId {consent_id}."), json!({ "consentId": consent_id, "remainingSec": remaining_sec })),+        Ask::Decided { decision: Some(d), .. } if d == "Reveal" => {+            pup_core::log(format!("[credential] reveal GRANTED for {host} (user {}) to \"{who}\" — user clicked Reveal", cred.username));+            ok(json!({ "success": true, "output": json!({ "ok": true, "host": cred.host, "username": cred.username, "password": cred.password, "sourceAccount": cred.source_account, "revealedTo": who, "_hint": "Released ONCE because the user clicked Reveal. Use it, then drop it: never log it, never echo it into another verb's args or a response." }).to_string() }))+        }+        Ask::Decided { decision, .. } => { let code = if decision.is_some() { "consent_declined" } else { "consent_timeout" }; pup_core::log(format!("[credential] reveal {} for {host} by \"{who}\"", code.to_uppercase())); err(code, if decision.is_some() { "the user declined on the desktop; nothing was revealed" } else { "the user did not answer the consent toast; nothing was revealed" }, Some("Do not retry in a loop: the user saw the request. Ask them in chat, then call once more if they say yes.")) }+    }+}++pub fn card_set(ctx: &Ctx, args: &Value) -> Value {+    match ctx.cards.set(&*ctx.host, args["nickname"].as_str().unwrap_or(""), args["number"].as_str().unwrap_or(""), args["expiry"].as_str().unwrap_or(""), args["cvv"].as_str().unwrap_or(""), args["name"].as_str()) {+        Ok(meta) => ok(json!({ "success": true, "output": json!({ "ok": true, "card": meta }).to_string(), "card": meta, "_hint": "Stored: PAN + CVV in the OS keychain, only brand/last4/expiry in the index. pup_fill_card types it into a checkout after the user approves each fill." })),+        Err(e) => err("card_set_failed", e, Some("pup_card_set {nickname, number, expiry:\"MM/YY\", cvv, name?}")),+    }+}+pub fn cards(ctx: &Ctx) -> Value { let l = ctx.cards.list(); ok(json!({ "success": true, "cards": l, "output": json!({ "ok": true, "cards": l }).to_string() })) }+pub fn card_delete(ctx: &Ctx, args: &Value) -> Value { let n = args["nickname"].as_str().unwrap_or(""); let r = ctx.cards.delete(&*ctx.host, n); ok(json!({ "success": true, "removed": r, "output": json!({ "ok": true, "removed": r }).to_string() })) }++/// pup_fill_card: per-fill human approval, then trusted keystrokes into the checkout (frames included).+pub fn fill_card(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let nickname = args["nickname"].as_str().unwrap_or("").trim().to_string();+    let Some(meta) = ctx.cards.meta(&nickname) else { return err("no_card", format!("no saved card \"{nickname}\""), Some("pup_cards {} lists the nicknames; pup_card_set stores one.")) };+    let merchant = args["merchant"].as_str().map(|m| m.to_string()).filter(|m| !m.trim().is_empty()).or_else(|| pup_vault::host_of(&t.url)).unwrap_or_else(|| "this page".into());+    let amount = args["amount"].as_str().unwrap_or("").to_string();+    let who = if caller.thread.is_empty() { "an unnamed caller".to_string() } else { caller.thread.clone() };+    let (brand, last4) = (meta["brand"].as_str().unwrap_or("Card").to_string(), meta["last4"].as_str().unwrap_or("????").to_string());+    let ask = ctx.consent.ask(ctx.ab.clone(), caller, &format!("pup_fill_card|{nickname}|{merchant}|{}", caller.thread), "pup_fill_card", &format!("Waiting for the user to approve the {brand} …{last4} fill on {merchant}"), "cardfill",+        json!({ "level": "warning", "scenario": "reminder", "title": format!("Pay with {brand} …{last4} on {merchant}?"), "body": format!("{who} wants to fill your card \"{nickname}\" ({brand} ending {last4}) into the checkout on {merchant}{}. Approve fills the card fields in the pup window (the digits never leave this machine). Deny fills nothing.", if amount.is_empty() { String::new() } else { format!(" for {amount}") }), "buttons": [{ "label": "Approve" }, { "label": "Deny" }] }));+    match ask {+        Ask::Pending { consent_id, remaining_sec, .. } => return blocked_on_user(true, &format!("The user must click Approve or Deny on the desktop toast \"Pay with {brand} …{last4} on {merchant}?\""), &format!("Not an error. The toast stays up for {remaining_sec}s more. Call pup_fill_card again with the SAME args to re-attach and collect the decision, or poll pup_readiness active[] for consentId {consent_id}."), json!({ "consentId": consent_id, "remainingSec": remaining_sec })),+        Ask::Decided { decision: Some(d), .. } if d == "Approve" => {}+        Ask::Decided { decision, .. } => return err(if decision.is_some() { "consent_declined" } else { "consent_timeout" }, if decision.is_some() { "the user declined the card fill on the desktop; nothing was filled" } else { "the user did not answer the approval toast; nothing was filled" }, Some("Do not retry in a loop: ask the user in chat first.")),+    }+    let Some((pan, cvv)) = ctx.cards.secret(&*ctx.host, &nickname) else { return err("no_card", "the card vanished from the keychain between listing and filling", None) };+    let expiry = meta["expiry"].as_str().unwrap_or("").to_string(); let name = meta["name"].as_str().unwrap_or("").to_string();+    // frames: the page session plus every child frame session (Stripe/Adyen iframes)+    let mut sessions_to_try = vec![t.cdp_session.clone()];+    if let Ok(r) = b.cdp.send(Some(&t.cdp_session), "Target.setAutoAttach", json!({ "autoAttach": true, "waitForDebuggerOnStart": false, "flatten": true }), T) { let _ = r; }+    std::thread::sleep(Duration::from_millis(300));+    for tgt in b.cdp.targets().unwrap_or_default() { if tgt["type"] == json!("iframe") && tgt["openerId"].is_null() { if let Ok(sid) = b.cdp.attach(tgt["targetId"].as_str().unwrap_or("")) { let _ = b.cdp.send(Some(&sid), "Runtime.enable", json!({}), T); sessions_to_try.push(sid); } } }+    let mut filled = json!({ "number": false, "expiry": false, "cvv": false, "name": false });+    for (field, value) in [("number", pan.as_str()), ("expiry", expiry.as_str()), ("cvv", cvv.as_str()), ("name", name.as_str())] {+        if value.is_empty() { continue; }+        for sid in &sessions_to_try {+            if filled[field] == json!(true) { break; }+            if let Ok(v) = b.cdp.evaluate(sid, &pup_vault::card_field_js(field), T) { if !v.is_null() {+                let mut okk = true;+                for ch in value.chars() { let cs = ch.to_string(); if crate::input::press_pub(&b.cdp, sid, &cs, 0).is_err() { if b.cdp.send(Some(sid), "Input.insertText", json!({ "text": cs }), T).is_err() { okk = false; break; } } std::thread::sleep(Duration::from_millis(20)); }+                if okk { filled[field] = json!(true); }+            } }+        }+    }+    pup_core::log(format!("[card] fill GRANTED+DONE {nickname} (…{last4}) on {merchant} by \"{who}\": {filled} ({} frame session(s))", sessions_to_try.len()));+    touch(ctx, &s.id, None);+    let missing: Vec<&str> = ["number", "expiry", "cvv", "name"].iter().filter(|f| filled[**f] != json!(true) && (**f != "name" || !name.is_empty())).cloned().collect();+    ok(json!({ "success": true, "output": json!({ "ok": true, "filled": filled, "framesScanned": sessions_to_try.len(), "last4": last4, "brand": brand, "_hint": if missing.is_empty() { "All card fields typed with trusted keystrokes; the digits never left this machine. Screenshot to confirm, then submit the checkout yourself." } else { "Some fields were not found (see filled); the checkout may use unusual markup. Screenshot, then pup_fill or pup_type the remaining fields with values from the user, never the PAN." } }).to_string(), "missing": missing }))+}++/// Autofill on load (Node: autoLoginOnLoad): fill, never submit, and note it on the response.+pub fn autofill_on_load(ctx: &Ctx, s: &Session, cdp_session: &str, url: &str) -> Option<String> {+    if ctx.settings.get("autoLogin", "on") == "off" { return None; }+    let b = crate::browser_for(ctx, &s.profile)?;+    let cred = ctx.vault.get_for_url(&*ctx.host, url, None)?;+    let located = b.cdp.evaluate(cdp_session, pup_vault::locate_login_js(), T).ok()?;+    if located["ok"] != json!(true) { return None; }+    let v = b.cdp.evaluate(cdp_session, &pup_vault::fill_login_js(&cred.username, &cred.password), T).ok()?;+    let _ = b.cdp.evaluate(cdp_session, &pup_vault::submit_login_js(false), T);+    if v["plen"].as_u64().unwrap_or(0) == 0 { return None; }+    Some(format!("pup AUTO-FILLED this login for saved host \"{}\" (username {}) in the BACKGROUND, NOT submitted. CONFIRM the live domain is the intended site, then pup_login {{submit:true, submitReason}} to sign in.", cred.host, cred.username))+}
rust/verbs/src/wiki.rsadded+80
@@ -0,0 +1,80 @@+//! Wiki verbs and the wiki sign-in: pup_wiki_set_view (authed | public | toggle, the server-side+//! ?view=public render), the auth probe (/auth/me with the page's own cookies), and the one-shot+//! auto sign-in from the desktop token (~/.adom/session.json -> wiki browser-session magic URL).+//! Ports checkWikiAuth / readDesktopWikiToken / mintWikiMagicUrl / autoSignWikiFromDesktop.++use crate::input::resolve_tab;+use crate::{browser_for, touch, Ctx};+use pup_core::{err, ok, Caller};+use serde_json::{json, Value};+use std::time::Duration;++const T: Duration = Duration::from_secs(10);++pub fn public_url(u: &str) -> String { if u.contains("view=public") { u.into() } else if u.contains('?') { format!("{u}&view=public") } else { format!("{u}?view=public") } }+pub fn strip_public(u: &str) -> String { u.replace("?view=public&", "?").replace("&view=public", "").replace("?view=public", "") }++/// {authed, user?, unknown?}: an indeterminate probe is never reported as logged out.+pub fn check_auth(cdp: &pup_cdp::Cdp, sid: &str) -> Value {+    let js = r#"(async () => { try { const r = await fetch('/auth/me', { credentials: 'same-origin' }); if (!r.ok) return (r.status === 401 || r.status === 403) ? { authed: false } : { authed: false, unknown: true }; const j = await r.json(); return { authed: !!(j && j.user), user: j && j.user && (j.user.displayName || j.user.username) }; } catch (e) { return { authed: false, unknown: true }; } })()"#;+    cdp.evaluate(sid, js, Duration::from_secs(5)).ok().filter(|v| v.is_object()).unwrap_or(json!({ "authed": false, "unknown": true }))+}++pub fn desktop_token(ctx: &Ctx) -> Option<(String, Option<String>)> {+    let v: Value = serde_json::from_str(&std::fs::read_to_string(ctx.host.state_dir().join("session.json")).ok()?).ok()?;+    let tok = v["session_token"].as_str()?.to_string();+    Some((tok, v["user_name"].as_str().or(v["user_email"].as_str()).map(|s| s.to_string())))+}++pub fn mint_magic_url(ctx: &Ctx, next_path: &str) -> Option<String> {+    let (tok, _) = desktop_token(ctx)?;+    let r = ureq::AgentBuilder::new().timeout(Duration::from_secs(12)).build().post("https://wiki.adom.inc/api/v1/auth/browser-session").set("content-type", "application/json").set("authorization", &format!("Bearer {tok}")).set("user-agent", "adom-pup-bridge").send_json(json!({})).ok()?;+    let j: Value = r.into_json().ok()?;+    let url = j["url"].as_str()?;+    Some(format!("{url}{}next={}", if url.contains('?') { "&" } else { "?" }, urlencode(next_path)))+}++fn urlencode(s: &str) -> String { let mut o = String::new(); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => o.push(b as char), _ => o.push_str(&format!("%{b:02X}")) } } o }++/// Sign the page in from the desktop token, then confirm with the auth probe. Returns who.+pub fn auto_sign(ctx: &Ctx, cdp: &pup_cdp::Cdp, sid: &str, next_path: &str) -> Option<String> {+    let magic = mint_magic_url(ctx, next_path)?;+    let _ = cdp.navigate(sid, &magic, Duration::from_secs(25));+    for _ in 0..6 { let a = check_auth(cdp, sid); if a["authed"] == json!(true) { return Some(a["user"].as_str().unwrap_or("authed").to_string()); } std::thread::sleep(Duration::from_millis(500)); }+    desktop_token(ctx).and_then(|(_, u)| u).or(Some("authed".into()))+}++/// After an open/navigate onto an Adom URL: probe, auto-sign when the desktop has a token, and+/// return the wiki hint fields for the response (Node: the open path's _wikiAuth block).+pub fn after_navigate(ctx: &Ctx, s: &pup_session::Session, cdp: &pup_cdp::Cdp, sid: &str, url: &str, view: Option<&str>, isolated: bool) -> Value {+    if !pup_identity::is_adom_url(url) { return json!({}); }+    if view == Some("public") { return json!({ "wikiView": "public", "_wikiHint": "Wiki PUBLIC view: the SERVER rendered this page exactly as a signed-out visitor sees it (?view=public). Reopen without wikiView (or view:\"authed\") for the logged-in view." }); }+    let mut auth = check_auth(cdp, sid);+    if auth["authed"] == json!(false) && auth["unknown"] != json!(true) && !isolated && desktop_token(ctx).is_some() {+        let next = url.split("//").nth(1).and_then(|r| r.find('/').map(|i| r[i..].to_string())).unwrap_or_else(|| "/adom".into());+        if let Some(who) = auto_sign(ctx, cdp, sid, &next) { auth = json!({ "authed": true, "user": who, "autoSigned": true }); pup_core::log(format!("[wikiview] \"{}\" AUTO-SIGNED from the desktop token ({who})", s.id)); }+    }+    let authed = auth["authed"] == json!(true);+    ctx.sessions.with_mut(&s.id, |x| { x.wiki_authed = Some(authed); x.wiki_view = Some(if authed { "authed" } else { "public" }.into()); });+    json!({ "wikiView": if authed { "authed" } else { "public" }, "wikiAuthed": authed, "wikiUser": auth["user"], "wikiLoginNeeded": if !authed && auth["unknown"] != json!(true) { json!(true) } else { Value::Null },+        "_wikiHint": if authed { format!("Wiki opened LOGGED IN (as {}): private source, drafts, owner-only cards. For the anonymous view reopen with wikiView:\"public\" (same window, server-side render).", auth["user"].as_str().unwrap_or("the user")) } else if auth["unknown"] == json!(true) { "Wiki auth state could not be probed on this page (not evidence of logout).".to_string() } else { "Wiki is LOGGED OUT in the shared profile and no desktop token could sign it in. One-time login: pup_raise_os_window {foregroundReason:\"sign into the Adom wiki\"} and let the user complete the SSO; every pup window is then signed in.".to_string() } })+}++pub fn set_view(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let url = t.url.clone();+    if !pup_identity::is_adom_url(&url) { return err("not_wiki", "wiki view toggle applies only to Adom (adom.inc) URLs", None); }+    let cur = if url.contains("view=public") { "public" } else { "authed" };+    let want = match args["view"].as_str().unwrap_or("toggle") { "toggle" => if cur == "public" { "authed" } else { "public" }, "authed" | "public" => args["view"].as_str().unwrap(), other => return err("bad_view", format!("view must be one of authed|public|toggle (got \"{other}\")"), None) };+    if want == cur { return ok(json!({ "success": true, "output": json!({ "ok": true, "view": want, "unchanged": true, "url": url }).to_string(), "view": want, "unchanged": true })); }+    let target = if want == "public" { public_url(&url) } else { strip_public(&url) };+    let loaded = b.cdp.navigate(&t.cdp_session, &target, Duration::from_secs(25)).map(|(_, l)| l).unwrap_or(false);+    let extra = after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &target, Some(want), false);+    if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }+    touch(ctx, &s.id, None);+    if let Some(a) = ctx.arc() { crate::identity::schedule(a, s.id.clone(), caller.clone(), true); }+    pup_core::merge(ok(json!({ "success": true, "output": json!({ "ok": true, "view": want, "url": target, "loaded": loaded }).to_string(), "view": want, "url": target, "sessionId": s.id, "window": s.id, "aiThread": s.owner })), extra)+}++#[allow(dead_code)]+fn _unused(ctx: &Ctx) { let _ = browser_for(ctx, "x"); let _ = T; }