← Commit history

rust: Phase 4 parity gate (tools/parity.py) green 37/37 on winvm; Node response envelope ported into dispatch (verb catalog, bug invite, output mirror, render probe), session rows carry lastAgentUpdate/ownerSource/overlay/flash, loop cadences in pup_status, default permissions granted at launch, Hydrogen proxy

John Lauer ·f389b1e658 ·1mo ago ·parent 4869577
16 files changed +2088−39
docs/PLAN-rust-port.md+15−1
@@ -48,7 +48,21 @@ packaging, ship) is next.** Written 2026-09-05 against pup 2.0.396; John approve   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+- Phase 4 (in progress, 2026-09-05 night): the parity gate is BUILT and GREEN. `tools/parity.py+  --target <box>` replays the 37-step fixture sequence against the exe on a spare port, pulls the+  responses back and diffs every one against its golden (missing keys, JSON-type drift, and the same+  inside `output`); keys the ab CLI stamps on the way back (`_timeoutHint`, `timeoutSeconds`,+  `savedTo`, `statusVerb`) are excluded, extra keys are allowed. Measured on winvm: 37 of 37 match+  (pup_fetch_url's golden was a CORS failure on the Node build; the Rust build fetched fine, so that+  one compares scenarios, not shapes). Getting there ported the Node response envelope into+  `dispatch`: the verb catalog (`rust/verbs/assets/verb-catalog.json`, generated from the golden+  pup_describe minus the dropped desktop_ aliases) stamps hint/related/next/pitfalls, the bug-report+  invite fires on failures and once per process, the `output` mirror carries the advisory fields,+  render verbs run the render probe (404 text, blank, unstyled, stack traces, broken images) and lead+  the hint with the verdict; the session row carries lastAgentUpdate {tabId, verb, note, ts},+  ownerSource, overlay, flash/progress counters; pup_status.loops reports real loop cadences; and the+  32 permissive default permissions are granted at launch (they were missing from the Rust build+  until the gate caught the count). Still to do in Phase 4: `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.
rust/core/src/lib.rs+26
@@ -202,6 +202,32 @@ pub fn iso_now() -> String {     format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}.000Z") } +/// Background-loop cadence stats (Node: _loopStats / loopReport) for pup_status.loops.+pub mod loops {+    use serde_json::{json, Value};+    use std::collections::HashMap;+    use std::sync::Mutex;+    #[derive(Default, Clone)]+    struct Stat { cadence_ms: u64, ticks: u64, total_ms: u64, last_ms: u64, max_ms: u64, slow_ticks: u64, last_at: u64, minute_at: u64, minute_ticks: u64 }+    static STATS: Mutex<Option<HashMap<String, Stat>>> = Mutex::new(None);+    pub fn tick(name: &str, cadence_ms: u64, dur_ms: u64) {+        let now = super::now_ms();+        let mut g = STATS.lock().unwrap(); let m = g.get_or_insert_with(HashMap::new);+        let s = m.entry(name.to_string()).or_default();+        s.cadence_ms = cadence_ms; s.ticks += 1; s.total_ms += dur_ms; s.last_ms = dur_ms; s.max_ms = s.max_ms.max(dur_ms); s.last_at = now;+        if dur_ms > cadence_ms / 2 { s.slow_ticks += 1; }+        if now.saturating_sub(s.minute_at) > 60_000 { s.minute_at = now; s.minute_ticks = 0; }+        s.minute_ticks += 1;+    }+    pub fn report() -> Value {+        let now = super::now_ms();+        let g = STATS.lock().unwrap();+        let mut out = serde_json::Map::new();+        if let Some(m) = g.as_ref() { for (n, s) in m { out.insert(n.clone(), json!({ "cadenceMs": s.cadence_ms, "ticks": s.ticks, "lastMin": if now.saturating_sub(s.minute_at) <= 60_000 { s.minute_ticks } else { 0 }, "avgMs": if s.ticks > 0 { s.total_ms / s.ticks } else { 0 }, "lastMs": s.last_ms, "maxMs": s.max_ms, "slowTicks": s.slow_ticks, "lastAt": s.last_at })); } }+        Value::Object(out)+    }+}+ #[cfg(test)] mod tests {     use super::*;
rust/session/src/lib.rs+11−1
@@ -43,11 +43,21 @@ pub struct Session {     pub wiki_authed: Option<bool>,     pub wiki_view: Option<String>,     pub last_annotation: Option<Value>,+    pub last_verb: Option<String>,+    pub last_tab: Option<String>,+    pub last_foreground: Option<Value>,+    pub overlay_desc: Option<String>,+    pub flash_count: u32,+    pub progress_count: u32,+    pub progress_ms: u64,+    pub owner_from_caller: bool, }  impl Session {+    /// Node: session._lastAgentUpdate = {tabId, verb, note, ts} | null.+    pub fn last_agent_update(&self) -> Value { match &self.last_verb { Some(v) => json!({ "tabId": self.last_tab, "verb": v, "note": self.last_note, "ts": self.last_agent_update_ms }), None => Value::Null } }     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, pending_cred_hint: None, wiki_authed: None, wiki_view: None, last_annotation: 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, last_verb: None, last_tab: None, last_foreground: None, overlay_desc: None, flash_count: 0, progress_count: 0, progress_ms: 0, owner_from_caller: false }     }     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/verbs/assets/render-probe.jsadded+10
@@ -0,0 +1,10 @@+(async () => {+  try { if (document.readyState !== 'complete') { await Promise.race([new Promise((r) => window.addEventListener('load', r, { once: true })), new Promise((r) => setTimeout(r, 1500))]); } } catch (e) {}+  const t = (document.body ? (document.body.innerText || '') : '').trim();+  return {+    title: document.title || '', bodyChars: t.length, head: t.slice(0, 400),+    elements: document.body ? document.body.querySelectorAll('*').length : 0,+    styleSheets: document.styleSheets ? document.styleSheets.length : 0,+    brokenImgs: (document.readyState === 'complete' && document.images) ? Array.from(document.images).filter((i) => { const src = i.getAttribute('src'); return !!(src && src.trim()) && i.complete && i.naturalWidth === 0; }).length : null,+  };+})()
rust/verbs/assets/verb-catalog.jsonadded+1682
@@ -0,0 +1,1682 @@+{+ "progressContract": {+  "activeKey": "active",+  "blockedByPhases": [+   "consent",+   "dialog"+  ],+  "blockedOnUser": {+   "errorCode": "blocked_on_user",+   "inVerbWaitSec": 45,+   "promptLifetimeSec": 120,+   "rule": "ab BRIDGE_SDK 2.1.30: a verb parked on a HUMAN never bare-timeouts; it answers this shape",+   "stillRunning": "true = the wait continues in the background: re-call the SAME verb with the SAME args to re-attach (no second prompt) or watch pup_readiness.active[]; false = the human must act first, then re-issue the verb"+  },+  "slowVerbs": [+   "pup_open_window",+   "pup_prewarm",+   "pup_use"+  ],+  "statusVerb": "pup_readiness",+  "timings": {+   "open_window_warm": {+    "confidence": "measured",+    "estimatedSec": 11,+    "samples": 20+   }+  }+ },+ "verbs": [+  {+   "example": {},+   "hint": "START HERE for window management: everything YOUR thread owns \u2014 windows, tabs, urls, titles, state \u2014 with ready-to-run next steps. Zero args. You NEVER need an hwnd, desktop_find_window, or desktop_screenshot_window for a pup window: pup keys every window to its owning --ai-thread name and resolves ids for you \u2014 omit sessionId in any pup verb to target your own window, or pass \"ai-thread\" to address a thread by name (the CLI's --ai-thread flag, same spelling). pup_screenshot {} screenshots it; pup_reload {} refreshes it.",+   "input": {+    "ai-thread": "optional str \u2014 inspect another thread's windows (read-only listing); default = the calling thread. The CLI flag's exact spelling; aiThread also accepted"+   },+   "longRunning": false,+   "name": "pup_my_windows",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "START HERE for window management: everything YOUR thread owns \u2014 windows, tabs, urls, titles, state \u2014 with ready-to-run next steps. Zero args. You NEVER need an hwnd, desktop_find_window, or desktop_screenshot_window for a pup window: pup keys every window to its owning --ai-thread name and resolves ids for you \u2014 omit sessionId in any pup verb to target your own window, or pass \"ai-thread\" to address a thread by name (the CLI's --ai-thread flag, same spelling). pup_screenshot {} screenshots it; pup_reload {} refreshes it.",+   "timeoutSeconds": 10+  },+  {+   "example": {+    "height": 540,+    "owner": "mytask",+    "sessionId": "mytask-web",+    "url": "https://example.com",+    "width": 960+   },+   "hint": "Opens a generic, fully-controllable pup window (a fresh isolated Chrome/Edge process \u2014 never your real signed-in browser) that you then drive by sessionId. To drive the user's REAL Chrome/Edge, use the Adom browser extension's nbrowser_* verbs instead.",+   "input": {+    "appIconB64": "optional base64 PNG \u2014 override the app icon worn in the taskbar overlay. Usually UNNECESSARY: pup auto-fetches the page's own <link rel=\"icon\"> favicon (the adom-ui-design standard artifact every app must serve) and wears it automatically. An app-looking URL with NO favicon gets a reprimand hint.",+    "appName": "optional str \u2014 the APP this window is driving (e.g. \"shotlog\"); shown in the hover tooltip",+    "autoFlash": "optional bool (default true) \u2014 mutating verbs auto-flash this window's taskbar (debounced, never steals focus) so the user notices the agent touched it; pass false to make this window silent by default",+    "foreground": "optional bool \u2014 request showing the window on the user's screen. GATED (v1.8.70): requires foregroundReason too, else the open succeeds but stays BACKGROUND with foregroundDenied set. Only when the USER explicitly asked to watch.",+    "foregroundReason": "required WITH foreground:true \u2014 quote/paraphrase what the user said that justifies putting a window on their screen (min 10 chars; logged + recorded in lastAgentUpdate, auditable)",+    "height": "optional int px",+    "isolated": "optional bool \u2014 private throwaway profile: fresh-login tests, acting as a different user. REASON-GATED (v2.0.101): requires isolationReason or the open is refused \u2014 every window defaults to the shared adom-you profile",+    "isolationReason": "required WITH isolated:true \u2014 why this window must NOT ride the shared adom-you profile (min 10 chars; logged, auditable). webSecurity:false and wikiView:public carry their own built-in reason",+    "owner": "optional str \u2014 your thread/task label; lets pup protect this window from other threads",+    "profile": "optional str \u2014 a named durable jar; DEFAULT is the shared \"adom-you\" profile (signed-in state shared across windows). isolated:true overrides to a throwaway",+    "sessionId": "optional str \u2014 OMIT IT (recommended): your thread gets its OWN window keyed on your --ai-thread name; the first open creates it, later opens become TABS in it, and no other thread can touch it. Pass an explicit id only when you need a SEPARATE window (task-prefix it, e.g. \"mytask-web\")",+    "silent": "optional bool \u2014 suppress the auto-flash for just this call",+    "takeover": "optional bool \u2014 claim a window owned by another thread (only when the user explicitly asks)",+    "updateNote": "optional str \u2014 a short \"what I did\" note, surfaced back in list_tabs/list_windows as lastAgentUpdate so the user knows what changed",+    "url": "optional str",+    "webSecurity": "optional false \u2014 dev escape hatch for cross-origin eval; FORCES isolation so insecure flags never touch the credentialed profile",+    "width": "optional int px \u2014 sizes for screenshot framing, stays background",+    "wikiView": "optional \"authed\"|\"public\" \u2014 for wiki.adom.inc URLs. THREE distinct cases: (1) OMIT = the shared adom-you profile, LOGGED IN as the user if they have signed into the wiki (the usual case); this is NOT anonymous. (2) \"public\" = the SERVER renders the page exactly as a signed-out visitor sees it via the ?view=public param (adom/wiki#126: request user nulled, byte-identical to logged-out, private pages 404) \u2014 the window STAYS on the shared profile, session untouched, no isolated jar; use this to verify a publish did not leak private source. (3) \"authed\" = the explicit logged-in view (30-day SSO; first use may need a one-time login \u2014 response says wikiLoginNeeded). To compare \"what I see\" vs \"what the world sees\", open one \"authed\" (or omit) AND one \"public\" tab \u2014 do NOT rely on omit for the public view.",+    "x": "optional int px",+    "y": "optional int px"+   },+   "longRunning": false,+   "name": "pup_open_window",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "sessionId is OPTIONAL: omit it and your thread gets its OWN window (keyed on your --ai-thread name; later opens become tabs in it). Pass one only for a deliberate separate window \u2014 it is then your handle for every later call",+    "profile defaults to the durable shared 'adom-you' profile (logins/localStorage persist + are shared across all pup windows); isolated:true for a private throwaway \u2014 omit it for a plain \"open in pup\"",+    "sessionIds are SHARED across all AI threads on this desktop \u2014 a generic id (\"wiki\", \"test\") may collide with another thread's window and navigating it away steals their work. Use a task-prefixed sessionId + pass owner:\"<your-task>\" so pup can protect it",+    "reusing an existing sessionId with a new url NAVIGATES that window \u2014 check pup_status (shows owner per window) first if unsure"+   ],+   "related": [+    "pup_navigate",+    "pup_list_windows",+    "pup_close_window"+   ],+   "statusVerb": null,+   "summary": "Open a generic pup window \u2014 a fresh isolated Chrome/Edge process (never your real signed-in browser), fully drivable via CDP. ALWAYS opens in the BACKGROUND (does not steal focus; never disturbs the user's main work; still fully screenshottable/drivable) UNLESS you pass foreground:true \u2014 do that ONLY when the user explicitly wants to watch. Pass width+height (+ optional x,y, screen px) to place it at a specific size/position for screenshot framing \u2014 this NO LONGER foregrounds it (sizing stays in the background too; default unsized is maximized). profile defaults to the durable shared 'adom-you' profile (logins/localStorage persist + are shared across all pup windows); isolated:true for a private throwaway. OWNERSHIP: sessionIds are shared across ALL AI threads \u2014 use a task-prefixed sessionId + owner:\"<your-task>\" so pup can refuse other threads stealing your window (reusing an existing id with a new url navigates it away; declared cross-owner reuse errors with session_owned_by_another_thread unless takeover:true). (For the user's REAL Chrome/Edge, use the Adom extension's nbrowser_* verbs.)",+   "timeoutSeconds": 60+  },+  {+   "example": {+    "sessionId": "work",+    "url": "https://example.com"+   },+   "hint": "Adds a tab to an existing session; returns the new tabId.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "silent": "optional bool \u2014 suppress the auto-flash for this call",+    "updateNote": "optional str \u2014 short \"what I did\" note surfaced in list_tabs as lastAgentUpdate",+    "url": "optional str"+   },+   "longRunning": false,+   "name": "pup_open_tab",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "the session must already exist (pup_open_window first)"+   ],+   "related": [+    "pup_switch_tab",+    "pup_list_tabs",+    "pup_close_tab"+   ],+   "statusVerb": null,+   "summary": "Open a new tab in an EXISTING session/window (returns {tabId, tabCount, activeTabId} \u2014 no need to diff list_tabs). This is the right verb for multi-URL work: ONE window with many tabs (one taskbar button), not many windows. The window stays in the background; the tab auto-flashes the taskbar (debounced) so the user notices. The USER can click the window's taskbar button at any time to view it \u2014 pup never pulls it back down.",+   "timeoutSeconds": 30+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "Closes one tab; the session stays open.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "tabId": "optional str (default active)"+   },+   "longRunning": false,+   "name": "pup_close_tab",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "omit tabId to close the active tab \u2014 closing the last tab may close the window"+   ],+   "related": [+    "pup_close_window",+    "pup_list_tabs"+   ],+   "statusVerb": null,+   "summary": "Close a tab.",+   "timeoutSeconds": 15+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "Closes the whole session and all its tabs. Destructive.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_close_window",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "this ends the session \u2014 its sessionId is no longer valid afterward"+   ],+   "related": [+    "pup_list_windows"+   ],+   "statusVerb": null,+   "summary": "Close a window/session (its tabs go too).",+   "timeoutSeconds": 20+  },+  {+   "example": {},+   "hint": "Everything about ONE pup window as JSON \u2014 owner AI thread, container, profile, tabs, age, last agent action, and the window's hwnd (for desktop_screenshot_window when you need the browser chrome in frame). For an AI caller this is a PURE DATA query: nothing is drawn on the user's screen. Visual surfaces are user-initiated only \u2014 a jump-list click shows the native taskbar caption, a toolbar-popup click shows the in-page floaty card. showCaption:true opts an AI caller into the caption ONLY when the user asked to have the window identified on screen. Read-only, never owner-gated.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "showCaption": "optional bool \u2014 draw the ~12s taskbar caption too; ONLY when the user asked to see which window this is"+   },+   "longRunning": false,+   "name": "pup_window_info",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Everything about ONE pup window as JSON \u2014 owner AI thread, container, profile, tabs, age, last agent action, and the window's hwnd (for desktop_screenshot_window when you need the browser chrome in frame). For an AI caller this is a PURE DATA query: nothing is drawn on the user's screen. Visual surfaces are user-initiated only \u2014 a jump-list click shows the native taskbar caption, a toolbar-popup click shows the in-page floaty card. showCaption:true opts an AI caller into the caption ONLY when the user asked to have the window identified on screen. Read-only, never owner-gated.",+   "timeoutSeconds": 15+  },+  {+   "example": {+    "sessionId": "wiki-bmv080",+    "view": "authed"+   },+   "hint": "Flip a WIKI pup window between the logged-in and public view IN PLACE: every wiki tab reloads with the server-side ?view=public param appended (public) or stripped (authed) \u2014 adom/wiki#126 renders the request exactly as a signed-out visitor while the session stays untouched, so there is no relaunch, no cookie wipe, no profile switch (the legacy jar relaunch survives only as a fallback for servers that ignore the param). This is the callback for the per-window taskbar jump-list task (WINDOWS ONLY) \u2014 a USER-initiated action. AI threads should NOT call this; pass wikiView:\"authed\"|\"public\" on pup_open_window instead.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "view": "required \"authed\"|\"public\"|\"toggle\" \u2014 toggle flips whatever the window currently shows (the Alt+Shift+V hotkey form)"+   },+   "longRunning": false,+   "name": "pup_wiki_set_view",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Flip a WIKI pup window between the logged-in and public view IN PLACE: every wiki tab reloads with the server-side ?view=public param appended (public) or stripped (authed) \u2014 adom/wiki#126 renders the request exactly as a signed-out visitor while the session stays untouched, so there is no relaunch, no cookie wipe, no profile switch (the legacy jar relaunch survives only as a fallback for servers that ignore the param). This is the callback for the per-window taskbar jump-list task (WINDOWS ONLY) \u2014 a USER-initiated action. AI threads should NOT call this; pass wikiView:\"authed\"|\"public\" on pup_open_window instead.",+   "timeoutSeconds": 45+  },+  {+   "example": {+    "sessionId": "work",+    "url": "https://example.com"+   },+   "hint": "Drives the active tab to url and waits for load. Verify with a screenshot before asserting success.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "url": "required str"+   },+   "longRunning": false,+   "name": "pup_navigate",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "navigation can race SPA routing \u2014 confirm with pup_screenshot",+    "some sites need pup_wait for a selector after navigate"+   ],+   "related": [+    "pup_screenshot",+    "pup_wait",+    "pup_reload"+   ],+   "statusVerb": null,+   "summary": "Navigate the active tab to a URL.",+   "timeoutSeconds": 45+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "History back on the active tab.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_back",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [+    "pup_forward",+    "pup_navigate"+   ],+   "statusVerb": null,+   "summary": "History back.",+   "timeoutSeconds": 20+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "History forward on the active tab.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_forward",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [+    "pup_back",+    "pup_navigate"+   ],+   "statusVerb": null,+   "summary": "History forward.",+   "timeoutSeconds": 20+  },+  {+   "example": {},+   "hint": "Paint the Windows taskbar PROGRESS BAR on your window during a LONG multi-stage task (crawling a site, login->search->download loops, batch harvesting datasheets/STEP files) so the user sees work happening at a glance. mode:\"indeterminate\" (pulsing) to start, mode:\"value\" with value:0-100 as stages complete, mode:\"off\" when done or aborted (always clear!). Optional note:\"12/40 datasheets\" shows on the button hover tooltip. YOUR judgment: a single quick page open does NOT warrant a progress bar. See the pup-progress skill.",+   "input": {+    "mode": "\"indeterminate\" | \"value\" | \"off\"",+    "note": "optional short stage label for the hover tooltip",+    "sessionId": "optional \u2014 defaults to your thread's own window",+    "value": "optional 0-100 (mode:\"value\")"+   },+   "longRunning": false,+   "name": "pup_progress",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Paint the Windows taskbar PROGRESS BAR on your window during a LONG multi-stage task (crawling a site, login->search->download loops, batch harvesting datasheets/STEP files) so the user sees work happening at a glance. mode:\"indeterminate\" (pulsing) to start, mode:\"value\" with value:0-100 as stages complete, mode:\"off\" when done or aborted (always clear!). Optional note:\"12/40 datasheets\" shows on the button hover tooltip. YOUR judgment: a single quick page open does NOT warrant a progress bar. See the pup-progress skill.",+   "timeoutSeconds": 8+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "Reloads the active tab.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_reload",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "A page with unsaved changes raises a native \"Reload site?\" dialog that BLOCKS the window: the reload appears to time out and so does everything else. Answer it with pup_dialog."+   ],+   "related": [+    "pup_navigate",+    "pup_wait",+    "pup_dialog"+   ],+   "statusVerb": null,+   "summary": "Reload the active tab.",+   "timeoutSeconds": 30+  },+  {+   "example": {+    "selector": "button#go",+    "sessionId": "work"+   },+   "hint": "Clicks by CSS selector (preferred) or by x,y coords. Selector is more robust across layouts.",+   "input": {+    "button": "optional \"left\"|\"right\"|\"middle\"",+    "clickCount": "optional int 1-3",+    "domClick": "optional bool \u2014 dispatch a synthetic element.click() instead of a trusted CDP click; for React controls that ignore trusted clicks",+    "selector": "optional str",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "x": "optional int",+    "y": "optional int"+   },+   "longRunning": false,+   "name": "pup_click",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "selector must match exactly one visible element \u2014 verify with pup_evaluate first",+    "coords are viewport-relative; a scrolled element may be off-screen"+   ],+   "related": [+    "pup_type",+    "pup_input_dispatch",+    "pup_screenshot"+   ],+   "statusVerb": null,+   "summary": "Click an element (by selector or coords). Selector clicks LOCATE the element once then click by its center coordinates (fast on huge/animated DOMs, where page.click used to hit the 15s wall). Response includes method. If a React toggle/like/heart does NOT change state after a normal click, that control ignores trusted clicks \u2014 retry with domClick:true to fire a synthetic element.click().",+   "timeoutSeconds": 20+  },+  {+   "example": {+    "label": "your Google account",+    "reason": "clicking your Google account to finish signing into Autodesk",+    "sessionId": "adsk",+    "x": 900,+    "y": 379+   },+   "hint": "RESPECTFUL real-cursor click for a surface pup CDP cannot drive (a NATIVE OS dialog, or a cross-origin FedCM \"Sign in with Google\" account chooser that rejects every in-page click). Do NOT hand-roll raise+desktop_click for these \u2014 use THIS. It: captions the reason + toasts BEFORE touching the screen; foregrounds; then WAITS, detecting if the user alt-tabbed away and holding (captioning \"Adom is waiting \u2014 click back when ready\") until the user is actually on the window; then runs the TIGHT real-cursor hover->click that FedCM accepts (interleaving any screenshot/eval between raise and click makes the click silently no-op); captions each step. Times out as user_not_ready clicking NOTHING if they never return. First take a desktop_screenshot_window of the session to read the target's WINDOW-pixel x,y.",+   "input": {+    "label": "required str \u2014 WHAT you are clicking (e.g. \"your Google account\")",+    "maxWaitMs": "optional int \u2014 how long to wait for the user (default 120000)",+    "reason": "required str \u2014 WHY you must click (shown to the user as a caption)",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "x": "required int \u2014 WINDOW-image pixel of the target (from desktop_screenshot_window)",+    "y": "required int"+   },+   "longRunning": false,+   "name": "pup_assisted_click",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "RESPECTFUL real-cursor click for a surface pup CDP cannot drive (a NATIVE OS dialog, or a cross-origin FedCM \"Sign in with Google\" account chooser that rejects every in-page click). Do NOT hand-roll raise+desktop_click for these \u2014 use THIS. It: captions the reason + toasts BEFORE touching the screen; foregrounds; then WAITS, detecting if the user alt-tabbed away and holding (captioning \"Adom is waiting \u2014 click back when ready\") until the user is actually on the window; then runs the TIGHT real-cursor hover->click that FedCM accepts (interleaving any screenshot/eval between raise and click makes the click silently no-op); captions each step. Times out as user_not_ready clicking NOTHING if they never return. First take a desktop_screenshot_window of the session to read the target's WINDOW-pixel x,y.",+   "timeoutSeconds": 130+  },+  {+   "example": {+    "clear": true,+    "selector": "#user",+    "sessionId": "work",+    "text": "adominc1"+   },+   "hint": "Types into the focused or selector-targeted field.",+   "input": {+    "clear": "optional bool \u2014 empty the field first (replace, not append)",+    "selector": "optional str",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "text": "required str"+   },+   "longRunning": false,+   "name": "pup_type",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "focus the field first (pup_click the input) or pass selector",+    "does not clear existing text \u2014 select-all + delete if you need a clean field"+   ],+   "related": [+    "pup_click",+    "pup_press_key"+   ],+   "statusVerb": null,+   "summary": "Type text into a field via real CDP keystrokes (isTrusted). Reads the value back and returns valueLanded \u2014 real keystrokes only register in a FOREGROUNDED window, so if valueLanded:false the field stayed empty (the silent no-op): use pup_fill (background-safe) for a non-React input, or pup_login {realKeystrokes:true} for a login form. Pass clear:true to REPLACE instead of appending.",+   "timeoutSeconds": 20+  },+  {+   "example": {+    "selector": "#email",+    "sessionId": "work",+    "text": "[email protected]"+   },+   "hint": "Set a field's value in the BACKGROUND (no foreground, no keystrokes): writes .value via the native setter and fires input+change so React/Vue/Angular controlled inputs pick it up. The fix for pup_type's silent no-op on a backgrounded window. Reads the value back and returns valueLanded. For React-MASKED password fields that reject programmatic writes, use pup_login {realKeystrokes:true} instead.",+   "input": {+    "selector": "required str",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "text": "required str"+   },+   "longRunning": false,+   "name": "pup_fill",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Set a field's value in the BACKGROUND (no foreground, no keystrokes): writes .value via the native setter and fires input+change so React/Vue/Angular controlled inputs pick it up. The fix for pup_type's silent no-op on a backgrounded window. Reads the value back and returns valueLanded. For React-MASKED password fields that reject programmatic writes, use pup_login {realKeystrokes:true} instead.",+   "timeoutSeconds": 20+  },+  {+   "example": {+    "key": "a",+    "modifiers": [+     "Control"+    ],+    "sessionId": "work"+   },+   "hint": "Presses a key or chord (Enter, Ctrl+A, Tab).",+   "input": {+    "key": "required str",+    "modifiers": "optional array \u2014 any of [\"Control\",\"Shift\",\"Alt\",\"Meta\"] (aliases ctrl/cmd/command/option accepted); held down around the key press",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_press_key",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "chord syntax is \"Ctrl+A\" \u2014 verify the page has focus on the intended element"+   ],+   "related": [+    "pup_type",+    "pup_input_dispatch"+   ],+   "statusVerb": null,+   "summary": "Press a key, optionally with modifier chords held (Ctrl+A, Ctrl+V, Shift+Tab).",+   "timeoutSeconds": 15+  },+  {+   "example": {},+   "hint": "\u26d4 READ BEFORE USING AD's desktop_click / desktop_type / desktop_press_key ON A PUP WINDOW. Native OS input does NOT target a window \u2014 it goes to WHATEVER WINDOW HAS OS FOCUS at that instant. This user runs many AI threads and alt-tabs away from a foregrounded window almost immediately. If you fire native input without warning them, your keystrokes land in THEIR document (this happened: a username was typed into the user's own window). REQUIRED: notify_user toast + desktop_caption FIRST, tell them explicitly \"don't type or click until I say done\", wait until the window is genuinely foreground, re-verify immediately before EACH event, and caption when done. pup_assisted_click already does all of this \u2014 use it. And ALWAYS prefer CDP input (pup_click/pup_type/pup_hover): it needs no foreground and disturbs nobody. A native verb returning ok:true means the event was dispatched to the OS, NOT that it reached your window.",+   "input": {},+   "longRunning": false,+   "name": "pup_NATIVE_INPUT_WARNING",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "\u26d4 READ BEFORE USING AD's desktop_click / desktop_type / desktop_press_key ON A PUP WINDOW. Native OS input does NOT target a window \u2014 it goes to WHATEVER WINDOW HAS OS FOCUS at that instant. This user runs many AI threads and alt-tabs away from a foregrounded window almost immediately. If you fire native input without warning them, your keystrokes land in THEIR document (this happened: a username was typed into the user's own window). REQUIRED: notify_user toast + desktop_caption FIRST, tell them explicitly \"don't type or click until I say done\", wait until the window is genuinely foreground, re-verify immediately before EACH event, and caption when done. pup_assisted_click already does all of this \u2014 use it. And ALWAYS prefer CDP input (pup_click/pup_type/pup_hover): it needs no foreground and disturbs nobody. A native verb returning ok:true means the event was dispatched to the OS, NOT that it reached your window.",+   "timeoutSeconds": 0+  },+  {+   "example": {+    "reset": true,+    "sessionId": "work"+   },+   "hint": "Pin the page viewport (CDP device-metrics) to an exact size for deterministic screenshots \u2014 OR clear that pin. \u26a0 A pin PERSISTS: it survives navigation and hard reload, and while set the window stops tracking OS resizing (a human resizing it sees no reflow, which looks exactly like an app layout bug). ALWAYS clear it when done. Reported as emulatedViewport in pup_status / pup_list_windows.",+   "input": {+    "deviceScaleFactor": "optional number (default 1)",+    "height": "int \u2014 required unless reset",+    "reset": "optional bool \u2014 CLEAR the override and track the OS window again (width/height not needed)",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "width": "int \u2014 required unless reset"+   },+   "longRunning": false,+   "name": "pup_set_viewport",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Pin the page viewport (CDP device-metrics) to an exact size for deterministic screenshots \u2014 OR clear that pin. \u26a0 A pin PERSISTS: it survives navigation and hard reload, and while set the window stops tracking OS resizing (a human resizing it sees no reflow, which looks exactly like an app layout bug). ALWAYS clear it when done. Reported as emulatedViewport in pup_status / pup_list_windows.",+   "timeoutSeconds": 15+  },+  {+   "example": {},+   "hint": "Status/trigger for the toolbar extension, which AUTO-INSTALLS invisibly at every profile launch (transient pipe-launch + CDP Extensions.loadUnpacked; persists in the profile). Running profiles pick it up on their next launch; cold profiles install immediately.",+   "input": {+    "profile": "optional, default adom-you"+   },+   "longRunning": false,+   "name": "pup_install_toolbar",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Status/trigger for the toolbar extension, which AUTO-INSTALLS invisibly at every profile launch (transient pipe-launch + CDP Extensions.loadUnpacked; persists in the profile). Running profiles pick it up on their next launch; cold profiles install immediately.",+   "timeoutSeconds": 30+  },+  {+   "example": {},+   "hint": "TEST SEAM: simulate a user click on the floaty pencil (fires the same host click handler a mouse does; the sweep then starts annotate within ~4s). Built for the recurring floaty ralph \u2014 assert the annotate overlay appears after calling this.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "tabId": "optional tab"+   },+   "longRunning": false,+   "name": "pup_floaty_click",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "TEST SEAM: simulate a user click on the floaty pencil (fires the same host click handler a mouse does; the sweep then starts annotate within ~4s). Built for the recurring floaty ralph \u2014 assert the annotate overlay appears after calling this.",+   "timeoutSeconds": 10+  },+  {+   "example": {},+   "hint": "Start the HUMAN annotation overlay on a window's active tab (pen/arrow/box/text toolbar injected in-page; also on every window's taskbar jump list as \"Annotate this page\"). Two capture modes the user can also flip in the toolbar: \"auto\" (default) captures the instant the mouse lifts \u2014 the one-red-circle case; \"multi\" accumulates shapes (color swatches: red/blue/yellow one click, more in a flyout) and captures ONCE on Done. Done captures a PNG AND leaves the drawing in the DOM, so the owning thread's next pup_screenshot shows exactly what the user marked \u2014 check session.lastAnnotation via pup_list_windows. action:\"cancel\" removes the overlay.",+   "input": {+    "action": "optional \"cancel\"",+    "mode": "optional \"auto\"|\"multi\"",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_annotate",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Start the HUMAN annotation overlay on a window's active tab (pen/arrow/box/text toolbar injected in-page; also on every window's taskbar jump list as \"Annotate this page\"). Two capture modes the user can also flip in the toolbar: \"auto\" (default) captures the instant the mouse lifts \u2014 the one-red-circle case; \"multi\" accumulates shapes (color swatches: red/blue/yellow one click, more in a flyout) and captures ONCE on Done. Done captures a PNG AND leaves the drawing in the DOM, so the owning thread's next pup_screenshot shows exactly what the user marked \u2014 check session.lastAnnotation via pup_list_windows. action:\"cancel\" removes the overlay.",+   "timeoutSeconds": 15+  },+  {+   "example": {},+   "hint": "Open the LIVE pup management dashboard in its OWN window (its own profile, so its own taskbar icon). Shows every window pup is driving as taskbar button -> window(s) -> tabs with each tab favicon, owning AI thread, foreground/background + the reason given to foreground it, highFps lease, uptime, and WHY each overlay icon was chosen. Live over SSE. Reuses the one window on repeat calls; backgrounded by default like any pup window.",+   "input": {},+   "longRunning": false,+   "name": "pup_dashboard",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Open the LIVE pup management dashboard in its OWN window (its own profile, so its own taskbar icon). Shows every window pup is driving as taskbar button -> window(s) -> tabs with each tab favicon, owning AI thread, foreground/background + the reason given to foreground it, highFps lease, uptime, and WHY each overlay icon was chosen. Live over SSE. Reuses the one window on repeat calls; backgrounded by default like any pup window.",+   "timeoutSeconds": 30+  },+  {+   "example": {},+   "hint": "Report pup dependency health (wiki issue #15): depsPresent, selfInstalledThisBoot, lastError. Use this to answer \"is pup broken?\" without reading spawn logs.",+   "input": {},+   "longRunning": false,+   "name": "pup_deps",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Report pup dependency health (wiki issue #15): depsPresent, selfInstalledThisBoot, lastError. Use this to answer \"is pup broken?\" without reading spawn logs.",+   "timeoutSeconds": 10+  },+  {+   "example": {+    "selector": "#menu",+    "sessionId": "work"+   },+   "hint": "Move the pointer over a target WITHOUT clicking (reveals tooltips / hover menus). Glides the visible Adom cursor there too.",+   "input": {+    "intent": "optional str \u2014 narrated on the cursor label",+    "selector": "optional str",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "steps": "optional int (default 8)",+    "x": "optional int",+    "y": "optional int"+   },+   "longRunning": false,+   "name": "pup_hover",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Move the pointer over a target WITHOUT clicking (reveals tooltips / hover menus). Glides the visible Adom cursor there too.",+   "timeoutSeconds": 15+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "Maximize (or restore/fullscreen/minimize) a pup window via CDP \u2014 no AD desktop verb required. Does NOT foreground it.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "state": "optional \"maximized\"|\"normal\"|\"minimized\"|\"fullscreen\" (default maximized)"+   },+   "longRunning": false,+   "name": "pup_maximize",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Maximize (or restore/fullscreen/minimize) a pup window via CDP \u2014 no AD desktop verb required. Does NOT foreground it.",+   "timeoutSeconds": 15+  },+  {+   "example": {+    "avatarB64": "<base64 png>",+    "name": "John Lauer"+   },+   "hint": "Set the Adom identity shown in the floating owner chip on every pup window (avatar + name). Chrome's own profile chip cannot take a custom local image (Google sign-in only), so the user's Adom avatar lives here instead. Pass a pre-circle-cropped PNG as base64. Applies to live windows immediately + persists.",+   "input": {+    "avatarB64": "optional str \u2014 base64 PNG (ideally ~48px, circle-cropped); the leading data: prefix is stripped if present",+    "clear": "optional bool \u2014 remove the stored avatar + name",+    "enabled": "optional bool \u2014 turn the whole owner chip on/off",+    "name": "optional str \u2014 display name shown in the chip tooltip"+   },+   "longRunning": false,+   "name": "pup_set_identity",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Set the Adom identity shown in the floating owner chip on every pup window (avatar + name). Chrome's own profile chip cannot take a custom local image (Google sign-in only), so the user's Adom avatar lives here instead. Pass a pre-circle-cropped PNG as base64. Applies to live windows immediately + persists.",+   "timeoutSeconds": 15+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "PNG of the page. Your primary way to SEE state \u2014 call it after navigate/click to confirm.",+   "input": {+    "fullPage": "optional bool",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_screenshot",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "default is the viewport; pass fullPage:true for the whole scroll height"+   ],+   "related": [+    "pup_screenshot_full_res",+    "pup_navigate"+   ],+   "statusVerb": null,+   "summary": "Screenshot the page (PNG; fullPage optional). NOTE: fullPage only covers DOCUMENT-level scroll \u2014 on SPAs that scroll inside a nested container it returns just the viewport + a nestedScroll hint; use pup_screenshot_element or pup_scroll for those.",+   "timeoutSeconds": 30+  },+  {+   "example": {+    "selector": ".download-card",+    "sessionId": "work"+   },+   "hint": "Screenshot ONE element, resolved by CSS selector OR visible text (pierces shadow DOM), scrolled into view first. The clean way to grab a single card/widget on a component-heavy page. Text matches the TIGHTEST text node \u2014 to capture the enclosing CARD, add closest (e.g. {text:\"Download\u2026\", closest:\".download-card\"}); with closest, an ambiguous phrase still lands on the real widget (skips prose that has no such ancestor). Returns base64 + filePath like pup_screenshot.",+   "input": {+    "closest": "optional selector \u2014 climb to the nearest ancestor matching it (the card container)",+    "deep": "optional bool \u2014 pierce shadow DOM (default true)",+    "padding": "optional int px around the element",+    "selector": "CSS selector (or use text)",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "text": "visible text to match (or use selector)"+   },+   "longRunning": false,+   "name": "pup_screenshot_element",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Screenshot ONE element, resolved by CSS selector OR visible text (pierces shadow DOM), scrolled into view first. The clean way to grab a single card/widget on a component-heavy page. Text matches the TIGHTEST text node \u2014 to capture the enclosing CARD, add closest (e.g. {text:\"Download\u2026\", closest:\".download-card\"}); with closest, an ambiguous phrase still lands on the real widget (skips prose that has no such ancestor). Returns base64 + filePath like pup_screenshot.",+   "timeoutSeconds": 30+  },+  {+   "example": {+    "sessionId": "work",+    "toText": "Download"+   },+   "hint": "Scroll the ACTUAL scrolling element (nearest ancestor whose scrollHeight>clientHeight, NOT just window \u2014 window.scrollTo no-ops on nested-scroll SPAs). Scroll by delta {dy,dx} OR to an element {toSelector|toText} (pierces shadow DOM). Returns {scrollElement, scrollTop, scrollHeight, clientHeight} so you can confirm it landed.",+   "input": {+    "behavior": "optional auto|smooth",+    "block": "optional start|center|end (for scroll-to; default center)",+    "dx": "optional int px",+    "dy": "optional int px (down+/up-)",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "toSelector": "optional CSS selector to scroll to",+    "toText": "optional visible text to scroll to"+   },+   "longRunning": false,+   "name": "pup_scroll",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Scroll the ACTUAL scrolling element (nearest ancestor whose scrollHeight>clientHeight, NOT just window \u2014 window.scrollTo no-ops on nested-scroll SPAs). Scroll by delta {dy,dx} OR to an element {toSelector|toText} (pierces shadow DOM). Returns {scrollElement, scrollTop, scrollHeight, clientHeight} so you can confirm it landed.",+   "timeoutSeconds": 30+  },+  {+   "example": {+    "height": 900,+    "sessionId": "work",+    "width": 1280+   },+   "hint": "Set the page viewport (CDP device-metrics) deterministically, independent of the OS window size \u2014 fixes a too-small innerWidth so fullPage/element shots render at a known width. width+height required.",+   "input": {+    "deviceScaleFactor": "optional number (default 1)",+    "height": "required int px",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "width": "required int px"+   },+   "longRunning": false,+   "name": "pup_set_viewport",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Set the page viewport (CDP device-metrics) deterministically, independent of the OS window size \u2014 fixes a too-small innerWidth so fullPage/element shots render at a known width. width+height required.",+   "timeoutSeconds": 15+  },+  {+   "example": {+    "expression": "document.title",+    "sessionId": "work"+   },+   "hint": "Runs JS in the page and returns the result \u2014 use it to read DOM state or verify selectors before acting.",+   "input": {+    "expression": "required str \u2014 aliases accepted: expr, script, js, code",+    "frame": "optional str \u2014 run inside this iframe instead of the top document",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_evaluate",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "the expression must be serializable JSON in its return value",+    "runs in the page context, not Node"+   ],+   "related": [+    "pup_click",+    "pup_fetch_url"+   ],+   "statusVerb": null,+   "summary": "Evaluate JS in the page and return the result \u2014 THE verb for reading DOM state / asserting on a page (same handler as pup_eval). Add frame:\"<name|url-substring|index>\" to run inside an iframe.",+   "timeoutSeconds": 30+  },+  {+   "example": {+    "sessionId": "work",+    "url": "https://api.example.com"+   },+   "hint": "Fetches a URL using the tab's live cookies/session \u2014 for authed APIs the bare CLI can't reach. Returns bodyBase64.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "url": "required str"+   },+   "longRunning": false,+   "name": "pup_fetch_url",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "for session-scoped URLs pass the tabId whose cookies you need",+    "returns base64 \u2014 decode it caller-side"+   ],+   "related": [+    "pup_navigate",+    "pup_evaluate"+   ],+   "statusVerb": null,+   "summary": "Fetch a URL's content via the browser (cookies/session intact).",+   "timeoutSeconds": 45+  },+  {+   "example": {+    "selector": "button.download",+    "sessionId": "work"+   },+   "hint": "Download a file THROUGH THE BROWSER, exactly as the user clicking it would \u2014 real session, real cookies, real anti-bot cookies \u2014 and Chrome saves it to the Downloads folder. This is the right verb for vendor files (datasheets, KiCad/Altium libraries, STEP 3D models) that a server-side fetch/curl can never get past the site's bot wall. Pass selector to click the actual download button (authentic), or url to force-download via an in-page a[download]. Confirms completion via Chrome's download events and returns the ACTUAL saved path + byte count + file magic. For a gated file, log in on the window first (pup_login).",+   "input": {+    "saveAs": "optional str \u2014 filename override",+    "saveDir": "optional str \u2014 default the user's Downloads folder",+    "selector": "optional str \u2014 the real download control to click",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "url": "optional str \u2014 download this URL via an in-page a[download] click"+   },+   "longRunning": false,+   "name": "pup_download",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Download a file THROUGH THE BROWSER, exactly as the user clicking it would \u2014 real session, real cookies, real anti-bot cookies \u2014 and Chrome saves it to the Downloads folder. This is the right verb for vendor files (datasheets, KiCad/Altium libraries, STEP 3D models) that a server-side fetch/curl can never get past the site's bot wall. Pass selector to click the actual download button (authentic), or url to force-download via an in-page a[download]. Confirms completion via Chrome's download events and returns the ACTUAL saved path + byte count + file magic. For a gated file, log in on the window first (pup_login).",+   "timeoutSeconds": 100+  },+  {+   "example": {+    "sessionId": "work",+    "type": "mousePressed",+    "x": 100,+    "y": 200+   },+   "hint": "Low-level CDP mouse/keyboard input at coords \u2014 beats some overlays/iframes that swallow synthetic clicks.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "type": "required str",+    "x": "optional int",+    "y": "optional int"+   },+   "longRunning": false,+   "name": "pup_input_dispatch",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "coords are viewport pixels",+    "last-resort when pup_click can't reach the element \u2014 still cannot pass Cloudflare interactive challenges"+   ],+   "related": [+    "pup_click",+    "pup_press_key"+   ],+   "statusVerb": null,+   "summary": "Low-level input (mouse/keyboard) at coords \u2014 beats some overlays.",+   "timeoutSeconds": 20+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "Starts video recording of the page for this session.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_record_start",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "one recording per session \u2014 stop the current one before starting another"+   ],+   "related": [+    "pup_record_stop",+    "pup_record_status"+   ],+   "statusVerb": null,+   "summary": "Start recording the page (video).",+   "timeoutSeconds": 20+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "Stops recording and returns the saved file path.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_record_stop",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "no-op if nothing is recording for this session"+   ],+   "related": [+    "pup_record_start",+    "pup_record_list"+   ],+   "statusVerb": null,+   "summary": "Stop recording; returns the saved file.",+   "timeoutSeconds": 30+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "Whether a recording is active for the session.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_record_status",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [+    "pup_record_start",+    "pup_record_stop"+   ],+   "statusVerb": null,+   "summary": "Recording status for a session.",+   "timeoutSeconds": 10+  },+  {+   "example": {},+   "hint": "Lists saved page recordings.",+   "input": {},+   "longRunning": false,+   "name": "pup_record_list",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [+    "pup_record_start"+   ],+   "statusVerb": null,+   "summary": "List recordings.",+   "timeoutSeconds": 10+  },+  {+   "example": {},+   "hint": "Open pup's Chrome screen-share recorder HUD (WebM; consent = the visible HUD). Canonical name (pup-bridge#94).",+   "input": {+    "reason": "str (required)"+   },+   "longRunning": false,+   "name": "pup_recorder_open",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Open pup's Chrome screen-share recorder HUD (WebM; consent = the visible HUD). Canonical name (pup-bridge#94).",+   "timeoutSeconds": 20+  },+  {+   "example": {},+   "hint": "Start a whole-screen recording through Chrome screen-share (WebM VP9, system audio + mic). Canonical name; for a background no-browser MP4 of one window use ab-core desktop_record_window_start.",+   "input": {+    "audio": "bool",+    "confirmDesktopNotTabRecording": "true",+    "fps": "int",+    "maxDurationMs": "int",+    "reason": "str (required)"+   },+   "longRunning": false,+   "name": "pup_screen_record_start",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Start a whole-screen recording through Chrome screen-share (WebM VP9, system audio + mic). Canonical name; for a background no-browser MP4 of one window use ab-core desktop_record_window_start.",+   "timeoutSeconds": 20+  },+  {+   "example": {},+   "hint": "Stop the Chrome screen recording; returns the .webm path.",+   "input": {},+   "longRunning": false,+   "name": "pup_screen_record_stop",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Stop the Chrome screen recording; returns the .webm path.",+   "timeoutSeconds": 30+  },+  {+   "example": {},+   "hint": "Status of the Chrome screen recorder + any active clip.",+   "input": {},+   "longRunning": false,+   "name": "pup_screen_record_status",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Status of the Chrome screen recorder + any active clip.",+   "timeoutSeconds": 10+  },+  {+   "example": {},+   "hint": "List completed Chrome screen recordings on disk.",+   "input": {},+   "longRunning": false,+   "name": "pup_screen_record_list",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "List completed Chrome screen recordings on disk.",+   "timeoutSeconds": 10+  },+  {+   "example": {},+   "hint": "Close the Chrome screen-recorder HUD (auto-stops a clip).",+   "input": {},+   "longRunning": false,+   "name": "pup_recorder_close",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Close the Chrome screen-recorder HUD (auto-stops a clip).",+   "timeoutSeconds": 15+  },+  {+   "example": {},+   "hint": "Cold-start readiness probe: {ready, chromeForTestingInstalled, installing, installProgressPct}. On a fresh PC, poll this until ready:true before pup_open_window \u2014 the bridge auto-installs Chrome for Testing on its own.",+   "input": {},+   "longRunning": false,+   "name": "pup_readiness",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "ready:false with installing:true just means Chrome for Testing is still downloading \u2014 wait + poll, do NOT treat it as an error",+    "installPhase:\"failed\" \u2192 surface lastError to the user (usually network/proxy blocking the download, or low disk)"+   ],+   "related": [+    "pup_prewarm",+    "pup_open_window"+   ],+   "statusVerb": null,+   "summary": "Readiness + which browser pup will drive (installed Chrome/Edge, or cached Chrome for Testing) + the full candidate list and current default. READ-ONLY; never downloads.",+   "timeoutSeconds": 10+  },+  {+   "example": {},+   "hint": "Installs Chrome for Testing (the browser pup drives) WITHOUT opening a window \u2014 the warm-before-first-use path. Returns fast; poll pup_readiness for completion.",+   "input": {+    "setDefault": "optional bool (default true) \u2014 pin CfT as the default",+    "wait": "optional bool \u2014 block until installed"+   },+   "longRunning": false,+   "name": "pup_prewarm",+   "output": {+    "success": "bool"+   },+   "pitfalls": [+    "the ~150 MB download needs network access to storage.googleapis.com",+    "does not open a window \u2014 call pup_open_window once readiness reports ready:true"+   ],+   "related": [+    "pup_readiness",+    "pup_open_window"+   ],+   "statusVerb": null,+   "summary": "Install Chrome for Testing WITHOUT opening a window, and pin it as the default. Use when you specifically want CfT rather than the machine's installed Chrome/Edge.",+   "timeoutSeconds": 240+  },+  {+   "example": {},+   "hint": "Pin (or clear) the browser pup drives by default: chrome | edge | cft | auto (or an explicit executablePath). \"cft\" fetches Chrome for Testing then pins it. With {browser:\"chrome\", install:true} it DOWNLOADS & INSTALLS real Google Chrome on an Edge-only box, then pins it \u2014 SILENT on a normal PC; on a locked-down box (VM/managed device) it needs a Windows approval, so the bridge pops a native \"click YES on the UAC\" notification and, if it expires, re-notifies with a one-tap \"Approve now\" (all autonomous \u2014 no AI turn). Poll pup_readiness.chromeStablePhase: installing \u2192 awaiting_uac \u2192 ready. Persists across restarts; launch still spawn-verifies + falls back.",+   "input": {+    "browser": "\"chrome\" | \"edge\" | \"cft\" | \"auto\"",+    "elevate": "optional \"auto\"(default)|true|false \u2014 auto: quiet install, prompt only if it needs admin; false: never prompt; true: go straight to the prompt",+    "executablePath": "optional \u2014 pin an exact browser binary",+    "install": "optional bool \u2014 with browser:\"chrome\", install real Chrome if missing"+   },+   "longRunning": false,+   "name": "pup_use",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Pin (or clear) the browser pup drives by default: chrome | edge | cft | auto (or an explicit executablePath). \"cft\" fetches Chrome for Testing then pins it. With {browser:\"chrome\", install:true} it DOWNLOADS & INSTALLS real Google Chrome on an Edge-only box, then pins it \u2014 SILENT on a normal PC; on a locked-down box (VM/managed device) it needs a Windows approval, so the bridge pops a native \"click YES on the UAC\" notification and, if it expires, re-notifies with a one-tap \"Approve now\" (all autonomous \u2014 no AI turn). Poll pup_readiness.chromeStablePhase: installing \u2192 awaiting_uac \u2192 ready. Persists across restarts; launch still spawn-verifies + falls back.",+   "timeoutSeconds": 240+  },+  {+   "example": {},+   "hint": "Send a pup window to the BACKGROUND (behind the user's windows) without stealing focus \u2014 still fully drivable. The default for open; use this to re-hide a window you foregrounded.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_lower_os_window",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Send a pup window to the BACKGROUND (behind the user's windows) without stealing focus \u2014 still fully drivable. The default for open; use this to re-hide a window you foregrounded.",+   "timeoutSeconds": 15+  },+  {+   "example": {+    "foregroundReason": "user said \"show me the wiki page\""+   },+   "hint": "Bring a pup window to the FOREGROUND (user's screen). GATED (v1.8.70): REFUSED with foreground_reason_required unless you pass foregroundReason \u2014 a quote/paraphrase of what the user said that justifies it (min 10 chars; logged, recorded in lastAgentUpdate, auditable). Allowed ONLY when the user EXPLICITLY must SEE it (a demo, recording, \"show me\"). It STEALS focus \u2014 to just signal \"I updated this window\", use pup_alert_window (or the auto-flash), NOT raise. After the show-me: pup_lower_os_window to return it to the background. Alias: pup_focus_window. IMPORTANT: if you are foregrounding in order to CLICK a native dialog or a cross-origin FedCM/Google account chooser, do NOT raise-then-click yourself \u2014 use pup_assisted_click, which waits for the user to actually be on the window (alt-tab aware) and narrates via captions, so you never act while they are not ready.",+   "input": {+    "foregroundReason": "REQUIRED \u2014 why the user asked to see this window",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_raise_os_window",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Bring a pup window to the FOREGROUND (user's screen). GATED (v1.8.70): REFUSED with foreground_reason_required unless you pass foregroundReason \u2014 a quote/paraphrase of what the user said that justifies it (min 10 chars; logged, recorded in lastAgentUpdate, auditable). Allowed ONLY when the user EXPLICITLY must SEE it (a demo, recording, \"show me\"). It STEALS focus \u2014 to just signal \"I updated this window\", use pup_alert_window (or the auto-flash), NOT raise. After the show-me: pup_lower_os_window to return it to the background. Alias: pup_focus_window. IMPORTANT: if you are foregrounding in order to CLICK a native dialog or a cross-origin FedCM/Google account chooser, do NOT raise-then-click yourself \u2014 use pup_assisted_click, which waits for the user to actually be on the window (alt-tab aware) and narrates via captions, so you never act while they are not ready.",+   "timeoutSeconds": 15+  },+  {+   "example": {+    "sessionId": "work",+    "submit": true,+    "submitReason": "confirmed the tab is on the real vendor domain I intended to sign into"+   },+   "hint": "Fill (and optionally submit) an HTML login form in the CURRENT tab, in the BACKGROUND \u2014 no Chrome autofill dropdown, no foregrounding, React/Vue-safe. Creds from the keychain vault (matched on the tab URL; captured automatically as you log in, or stored via credential_set / save:true) or passed explicitly. Passwords are NEVER echoed back. Vault-matched pages AUTO-FILL on load (_autoLogin on the response). SUBMIT IS REASON-GATED: submit:true requires submitReason AND makes you confirm the live domain (the refusal regurgitates the exact host so a lookalike/phishing site is caught before the secret is sent). pup_configure {autoLogin:\"off\"} disables auto-fill; {captureLogins:\"off\"} stops auto-capture.",+   "input": {+    "host": "optional str \u2014 override the vault host key when saving",+    "password": "optional str \u2014 with username, fills these instead of the vault",+    "realKeystrokes": "optional bool \u2014 type with REAL key events instead of a background .value-set. Needed for React-controlled MASKED fields (Gusto/Keycloak SSO) that ignore .value and would submit an empty password. It briefly FOREGROUNDS the window (real keystrokes only register on-screen). Use when a normal fill returned filledPassword:false / reactMaskedFieldSuspected, or when the login-recipe for the site says so.",+    "save": "optional bool \u2014 with explicit creds, store them (OS keychain) so next time is automatic",+    "sessionId": "optional str \u2014 omit to target the window your own thread owns",+    "submit": "optional bool \u2014 submit after filling; REQUIRES submitReason",+    "submitReason": "required WITH submit:true \u2014 why you are confident the CURRENT domain is the correct, intended site to send these credentials to (min 10 chars; the refusal shows you the live host to confirm; logged, auditable)",+    "username": "optional str"+   },+   "longRunning": false,+   "name": "pup_login",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Fill (and optionally submit) an HTML login form in the CURRENT tab, in the BACKGROUND \u2014 no Chrome autofill dropdown, no foregrounding, React/Vue-safe. Creds from the keychain vault (matched on the tab URL; captured automatically as you log in, or stored via credential_set / save:true) or passed explicitly. Passwords are NEVER echoed back. Vault-matched pages AUTO-FILL on load (_autoLogin on the response). SUBMIT IS REASON-GATED: submit:true requires submitReason AND makes you confirm the live domain (the refusal regurgitates the exact host so a lookalike/phishing site is caught before the secret is sent). pup_configure {autoLogin:\"off\"} disables auto-fill; {captureLogins:\"off\"} stops auto-capture.",+   "timeoutSeconds": 20+  },+  {+   "example": {},+   "hint": "List saved logins (host + username only; passwords are in the OS keychain and NEVER returned). pup captures these automatically as you sign into sites and auto-fills them in the background on return visits.",+   "input": {},+   "longRunning": false,+   "name": "pup_credentials",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "List saved logins (host + username only; passwords are in the OS keychain and NEVER returned). pup captures these automatically as you sign into sites and auto-fills them in the background on return visits.",+   "timeoutSeconds": 10+  },+  {+   "example": {+    "host": "github.com",+    "reason": "signing into GitHub in my own browser to open a PR for the user"+   },+   "hint": "Reveal ONE saved login (username + password) to a LOCAL caller on the desktop, gated by a sticky consent toast the user must click. One host per call, never bulk. A relayed (cloud container) call is refused with relay_forbidden: from a cloud thread use pup_login instead, which signs the site in inside a pup window without the secret ever leaving the vault. The secret is returned ONCE and must never be logged or echoed.",+   "input": {+    "host": "required str \u2014 the site, e.g. \"github.com\"",+    "reason": "required str (min 10 chars) \u2014 shown to the user on the consent toast; say what you will do with it"+   },+   "longRunning": false,+   "name": "pup_credential_reveal",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Reveal ONE saved login (username + password) to a LOCAL caller on the desktop, gated by a sticky consent toast the user must click. One host per call, never bulk. A relayed (cloud container) call is refused with relay_forbidden: from a cloud thread use pup_login instead, which signs the site in inside a pup window without the secret ever leaving the vault. The secret is returned ONCE and must never be logged or echoed.",+   "timeoutSeconds": 130+  },+  {+   "example": {},+   "hint": "Store a payment card in the OS-keychain card vault under a nickname. Ask the USER for the details \u2014 never harvest them from a page. Validates Luhn + expiry + cvv. No verb ever returns the number or CVV afterward.",+   "input": {+    "cvv": "required 3-4 digits",+    "expiry": "required MM/YY",+    "name": "optional cardholder name",+    "nickname": "required str, e.g. \"adom-amex\"",+    "number": "required str"+   },+   "longRunning": false,+   "name": "pup_card_set",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Store a payment card in the OS-keychain card vault under a nickname. Ask the USER for the details \u2014 never harvest them from a page. Validates Luhn + expiry + cvv. No verb ever returns the number or CVV afterward.",+   "timeoutSeconds": 15+  },+  {+   "example": {},+   "hint": "List saved payment cards: nickname, brand, last4, expiry, cardholder only. The number and CVV never leave the keychain.",+   "input": {},+   "longRunning": false,+   "name": "pup_cards",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "List saved payment cards: nickname, brand, last4, expiry, cardholder only. The number and CVV never leave the keychain.",+   "timeoutSeconds": 10+  },+  {+   "example": {},+   "hint": "Remove a saved card from the keychain + index by nickname.",+   "input": {+    "nickname": "required str"+   },+   "longRunning": false,+   "name": "pup_card_delete",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Remove a saved card from the keychain + index by nickname.",+   "timeoutSeconds": 10+  },+  {+   "example": {+    "amount": "$350",+    "merchant": "venturedallas.com",+    "nickname": "adom-amex"+   },+   "hint": "Fill a saved card into the checkout page of a pup window WITHOUT the digits ever reaching you: pup types them into the card fields (walking payment-processor iframes like Stripe) and returns booleans. EVERY fill is gated by a desktop approval toast naming merchant, amount and last4 \u2014 the user authorizes the specific charge in the moment. Pass merchant+amount so the toast is honest. Allowed from cloud threads (the human click is the gate). After ok:true, screenshot to verify, then submit.",+   "input": {+    "amount": "recommended str, e.g. \"$350\"",+    "merchant": "recommended str \u2014 shown on the approval toast",+    "nickname": "required str",+    "window": "optional (defaults to your thread's window)"+   },+   "longRunning": false,+   "name": "pup_fill_card",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Fill a saved card into the checkout page of a pup window WITHOUT the digits ever reaching you: pup types them into the card fields (walking payment-processor iframes like Stripe) and returns booleans. EVERY fill is gated by a desktop approval toast naming merchant, amount and last4 \u2014 the user authorizes the specific charge in the moment. Pass merchant+amount so the toast is honest. Allowed from cloud threads (the human click is the gate). After ok:true, screenshot to verify, then submit.",+   "timeoutSeconds": 130+  },+  {+   "example": {+    "hostFilter": [+     "snapeda",+     "ultralibrarian",+     "digikey"+    ]+   },+   "hint": "Seed pup's vault from the user's OWN saved Chrome/Edge/Brave passwords, so pup can sign in as them on sites they already use (vendor CAD portals, etc). Runs the SYSTEM-elevated decrypt LOCALLY \u2014 the user approves ONE consent dialog + ONE Windows UAC prompt on the desktop (a cloud/relay caller cannot do this; only pup, a local bridge, can ask). Merges the identity-tagged rows into the keychain vault and DROPS the plaintext. Returns COUNTS ONLY. hostFilter narrows the import to matching hosts (surgical, e.g. [\"snapeda\",\"digikey\"]); omit to import everything saved. Pair with pup_import_scan (read-only preview of how many logins exist).",+   "input": {+    "browsers": "optional str[] subset of [\"chrome\",\"edge\",\"brave\"] (default: all found)",+    "hostFilter": "optional str or str[] \u2014 only import logins whose host contains one of these (surgical import)"+   },+   "longRunning": false,+   "name": "pup_import_browser_logins",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Seed pup's vault from the user's OWN saved Chrome/Edge/Brave passwords, so pup can sign in as them on sites they already use (vendor CAD portals, etc). Runs the SYSTEM-elevated decrypt LOCALLY \u2014 the user approves ONE consent dialog + ONE Windows UAC prompt on the desktop (a cloud/relay caller cannot do this; only pup, a local bridge, can ask). Merges the identity-tagged rows into the keychain vault and DROPS the plaintext. Returns COUNTS ONLY. hostFilter narrows the import to matching hosts (surgical, e.g. [\"snapeda\",\"digikey\"]); omit to import everything saved. Pair with pup_import_scan (read-only preview of how many logins exist).",+   "timeoutSeconds": 190+  },+  {+   "example": {+    "dryRun": true+   },+   "hint": "Delete LEAKED isolated profile directories \u2014 the iso-* throwaway jars minted by isolated:true opens (and by wikiView:public before 2.0.132, when the public view moved to the server-side ?view=public param) whose window is long gone (they used to pile up, 100s per machine). Only removes an iso-* dir that no live session or session file owns, that no Chrome is holding, and that is >15 min old; durable jars (adom-you, named profiles) are NEVER touched. Runs automatically every 15 min too. dryRun:true previews the list without deleting.",+   "input": {+    "dryRun": "optional bool \u2014 preview which iso-* profiles would be removed, delete nothing"+   },+   "longRunning": false,+   "name": "pup_reap_profiles",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Delete LEAKED isolated profile directories \u2014 the iso-* throwaway jars minted by isolated:true opens (and by wikiView:public before 2.0.132, when the public view moved to the server-side ?view=public param) whose window is long gone (they used to pile up, 100s per machine). Only removes an iso-* dir that no live session or session file owns, that no Chrome is holding, and that is >15 min old; durable jars (adom-you, named profiles) are NEVER touched. Runs automatically every 15 min too. dryRun:true previews the list without deleting.",+   "timeoutSeconds": 20+  },+  {+   "example": {},+   "hint": "MOVE this pup window's page into the user's Hydrogen workspace: the active tab's URL is handed to Hydrogen (ah) on this machine via its Browser Picker (webview by default, honoring the user's per-domain choice), then the pup window closes \u2014 its owner initiated the move. keepWindow:true keeps the pup window open too. Needs Hydrogen running locally (hydrogen_unreachable otherwise; the window is left untouched on failure). Cloud Hydrogen (hc) is not yet supported.",+   "input": {+    "keepWindow": "optional bool \u2014 hand the URL over but keep the pup window open",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_move_to_workspace",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "MOVE this pup window's page into the user's Hydrogen workspace: the active tab's URL is handed to Hydrogen (ah) on this machine via its Browser Picker (webview by default, honoring the user's per-domain choice), then the pup window closes \u2014 its owner initiated the move. keepWindow:true keeps the pup window open too. Needs Hydrogen running locally (hydrogen_unreachable otherwise; the window is left untouched on failure). Cloud Hydrogen (hc) is not yet supported.",+   "timeoutSeconds": 25+  },+  {+   "example": {+    "dryRun": true+   },+   "hint": "DESTRUCTIVE \u2014 cleanly remove pup's WHOLE footprint from this machine: the HKCU AppUserModelId Adom.Pup registry keys, all staged extensions, ALL pup profiles + sessions, the OS-keychain credential vault, pup-settings.json, the badge-debug temp dir, and the jump-list launcher. ALWAYS preview first with dryRun:true (removes nothing, returns the plan). To actually remove, pass confirm:\"uninstall\". keepCredentials:true preserves the saved-login vault; keepProfiles:true preserves profiles/sessions. Chrome-for-Testing + firewall rules are SHARED and left alone unless removeChrome:true / removeFirewallRules:true. Closes every window + quits every browser first to release profile locks. (The bridge itself is managed by Adom Bridge \u2014 remove pup from Bridge to stop it re-seeding.)",+   "input": {+    "confirm": "required str \"uninstall\" to actually remove",+    "dryRun": "optional bool \u2014 preview the removal plan without removing anything (do this first)",+    "keepCredentials": "optional bool \u2014 keep the saved-login vault",+    "keepProfiles": "optional bool \u2014 keep pup profiles + sessions",+    "removeChrome": "optional bool \u2014 also delete the Chrome-for-Testing install (shared; default keep)",+    "removeFirewallRules": "optional bool \u2014 also remove the CfT firewall rules (needs admin)"+   },+   "longRunning": false,+   "name": "pup_uninstall",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "DESTRUCTIVE \u2014 cleanly remove pup's WHOLE footprint from this machine: the HKCU AppUserModelId Adom.Pup registry keys, all staged extensions, ALL pup profiles + sessions, the OS-keychain credential vault, pup-settings.json, the badge-debug temp dir, and the jump-list launcher. ALWAYS preview first with dryRun:true (removes nothing, returns the plan). To actually remove, pass confirm:\"uninstall\". keepCredentials:true preserves the saved-login vault; keepProfiles:true preserves profiles/sessions. Chrome-for-Testing + firewall rules are SHARED and left alone unless removeChrome:true / removeFirewallRules:true. Closes every window + quits every browser first to release profile locks. (The bridge itself is managed by Adom Bridge \u2014 remove pup from Bridge to stop it re-seeding.)",+   "timeoutSeconds": 60+  },+  {+   "example": {},+   "hint": "Nudge the user about a pup window WITHOUT stealing focus \u2014 on Windows it flashes the taskbar button; on macOS (no taskbar, and no API to bounce another app's Dock tile) it unhides the window at its z-position and posts an AD toast whose click focuses it. Same contract on both, so call it identically; just never tell a Mac user to look for a flashing icon. NOTE (v1.8.23): mutating verbs already AUTO-FLASH (debounced, one per ~5s burst per window) so the user always knows the agent touched a window \u2014 use this for an explicit end-of-task \"it's ready\" nudge on top of that. STRONGLY prefer this (or the auto-flash) over pup_raise_os_window, which steals focus and can leak a mid-typed keystroke into the page.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "stop": "optional bool \u2014 CLEAR the highlight instead of flashing"+   },+   "longRunning": false,+   "name": "pup_alert_window",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Nudge the user about a pup window WITHOUT stealing focus \u2014 on Windows it flashes the taskbar button; on macOS (no taskbar, and no API to bounce another app's Dock tile) it unhides the window at its z-position and posts an AD toast whose click focuses it. Same contract on both, so call it identically; just never tell a Mac user to look for a flashing icon. NOTE (v1.8.23): mutating verbs already AUTO-FLASH (debounced, one per ~5s burst per window) so the user always knows the agent touched a window \u2014 use this for an explicit end-of-task \"it's ready\" nudge on top of that. STRONGLY prefer this (or the auto-flash) over pup_raise_os_window, which steals focus and can leak a mid-typed keystroke into the page.",+   "timeoutSeconds": 15+  },+  {+   "example": {+    "html": "<h1>Caf\u00e9</h1>",+    "sessionId": "work"+   },+   "hint": "Render a local HTML string in the tab via page.setContent \u2014 UTF-8-correct (a data: URL mojibakes accented chars without an explicit charset). Great for previewing generated markup without a temp file. Auto-flashes the window.",+   "input": {+    "html": "required HTML string",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "silent": "optional bool \u2014 suppress the auto-flash",+    "updateNote": "optional note for lastAgentUpdate",+    "waitUntil": "optional (domcontentloaded default)"+   },+   "longRunning": false,+   "name": "pup_render_html",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Render a local HTML string in the tab via page.setContent \u2014 UTF-8-correct (a data: URL mojibakes accented chars without an explicit charset). Great for previewing generated markup without a temp file. Auto-flashes the window.",+   "timeoutSeconds": 30+  },+  {+   "example": {},+   "hint": "List all open pup sessions + their tabs (NOT a readiness check \u2014 use pup_readiness for that).",+   "input": {},+   "longRunning": false,+   "name": "pup_status",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "List all open pup sessions + their tabs (NOT a readiness check \u2014 use pup_readiness for that).",+   "timeoutSeconds": 10+  },+  {+   "example": {},+   "hint": "List every open session/window with its tabs.",+   "input": {},+   "longRunning": false,+   "name": "pup_list_windows",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "List every open session/window with its tabs.",+   "timeoutSeconds": 10+  },+  {+   "example": {+    "expr": "document.querySelector(\"#userId\").value",+    "frame": "logonbox",+    "sessionId": "work"+   },+   "hint": "pup_eval accepts frame:\"<name|url-substring|index>\" to run inside an IFRAME (login forms usually live in one). Cross-origin frames work. Omit for the top document; a bad value returns frame_not_found WITH the list of frames in the tab.",+   "input": {+    "expr": "required str",+    "frame": "optional str",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_eval_frame_note",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "pup_eval accepts frame:\"<name|url-substring|index>\" to run inside an IFRAME (login forms usually live in one). Cross-origin frames work. Omit for the top document; a bad value returns frame_not_found WITH the list of frames in the tab.",+   "timeoutSeconds": 20+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "Make a session the active one (for verbs that default to active).",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_switch_window",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Make a session the active one (for verbs that default to active).",+   "timeoutSeconds": 10+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "List the tabs in a session.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_list_tabs",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "List the tabs in a session.",+   "timeoutSeconds": 10+  },+  {+   "example": {+    "sessionId": "work",+    "tabId": "tab-2"+   },+   "hint": "Activate a tab WITHIN its session/window (idempotent \u2014 safe to call when the tab is already active). Does NOT raise a background window to the user's screen: the tab becomes the window's visible tab wherever the window is, and the taskbar auto-flash tells the user something changed. Also self-heals the window (re-park if a bug ever stranded it off-screen) and refreshes the Adom taskbar badge \u2014 good citizenship for long-lived shared windows.",+   "input": {+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "silent": "optional bool",+    "tabId": "required str",+    "updateNote": "optional str"+   },+   "longRunning": false,+   "name": "pup_switch_tab",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Activate a tab WITHIN its session/window (idempotent \u2014 safe to call when the tab is already active). Does NOT raise a background window to the user's screen: the tab becomes the window's visible tab wherever the window is, and the taskbar auto-flash tells the user something changed. Also self-heals the window (re-park if a bug ever stranded it off-screen) and refreshes the Adom taskbar badge \u2014 good citizenship for long-lived shared windows.",+   "timeoutSeconds": 10+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "FULL-resolution page screenshot (NOT downscaled \u2014 for saving to disk, NOT for Read: it can exceed Claude's image limit). Use pup_screenshot for AI-readable shots.",+   "input": {+    "fullPage": "optional bool",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_screenshot_full_res",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "FULL-resolution page screenshot (NOT downscaled \u2014 for saving to disk, NOT for Read: it can exceed Claude's image limit). Use pup_screenshot for AI-readable shots.",+   "timeoutSeconds": 30+  },+  {+   "example": {+    "sessionId": "work"+   },+   "hint": "Console errors + failed network requests for a session/tab (clears by default).",+   "input": {+    "clear": "optional bool (default true)",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)",+    "tabId": "optional str"+   },+   "longRunning": false,+   "name": "pup_errors",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Console errors + failed network requests for a session/tab (clears by default).",+   "timeoutSeconds": 10+  },+  {+   "example": {+    "ms": 500,+    "sessionId": "work"+   },+   "hint": "Wait N ms (or for a selector) before the next step.",+   "input": {+    "ms": "optional int",+    "selector": "optional str",+    "sessionId": "optional str \u2014 OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)"+   },+   "longRunning": false,+   "name": "pup_wait",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Wait N ms (or for a selector) before the next step.",+   "timeoutSeconds": 60+  },+  {+   "example": {},+   "hint": "Recover orphaned sessions: reconnect dropped CDP sockets + rebuild sessions from window titles. Pass adoptOrphans:true to also adopt untagged tabs.",+   "input": {+    "adoptOrphans": "optional bool"+   },+   "longRunning": false,+   "name": "pup_rescan",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Recover orphaned sessions: reconnect dropped CDP sockets + rebuild sessions from window titles. Pass adoptOrphans:true to also adopt untagged tabs.",+   "timeoutSeconds": 30+  },+  {+   "example": {},+   "hint": "Close ALL pup sessions + windows at once (destructive). NOTE: `{}` here does NOT mean \"the active session\" the way it does on other session-scoped verbs \u2014 with no args this closes EVERY window, across every AI thread on the machine, with no confirmation. To close ONE window use pup_close_window {sessionId}. Pass {sessionId} here only if you genuinely mean that one session.",+   "input": {},+   "longRunning": false,+   "name": "pup_close",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Close ALL pup sessions + windows at once (destructive). NOTE: `{}` here does NOT mean \"the active session\" the way it does on other session-scoped verbs \u2014 with no args this closes EVERY window, across every AI thread on the machine, with no confirmation. To close ONE window use pup_close_window {sessionId}. Pass {sessionId} here only if you genuinely mean that one session.",+   "timeoutSeconds": 20+  },+  {+   "example": {+    "taskbarGrouping": "grouped"+   },+   "hint": "Get/set user-facing pup preferences (persisted across bridge updates). taskbarGrouping: \"split\" (default \u2014 one taskbar button PER WINDOW, each badged with its own tab count; best for telling AI threads apart) | \"grouped\" (all pup windows stack under ONE \"Adom Pup\" button, badge shows window count; tidy taskbar). Applies to live windows immediately. Ask the USER which they prefer before changing it \u2014 this is their taskbar. WINDOWS ONLY: macOS has no taskbar, so the setting is stored but INERT there; the reply carries platform + taskbarSupported so branch on those, not on this text.",+   "input": {+    "autoLogin": "optional \"on\"|\"off\" \u2014 auto-fill a vault-matched login form on page load (fill only, never submits), on by default",+    "overlaySize": "optional \"50\"|\"100\" \u2014 how big the favicon badge is drawn on the taskbar button. \"50\" (the DEFAULT) draws it at half size so it stays clear of the thread tile's bottom line of text; \"100\" is the older, larger badge",+    "reportIssues": "optional \"on\"|\"off\" \u2014 pup's bug-report invitation (_reportIssues), on by default",+    "taskbarActivity": "optional \"progress\"|\"flash\"|\"off\" \u2014 how pup signals it is working on a window, on that window's taskbar button: a determinate progress bar sized to the operation's running-average time (default), the old orange flash, or nothing. Replaces the former openFlash/autoFlash/agentActivity toggles.",+    "taskbarGrouping": "optional \"split\"|\"grouped\""+   },+   "longRunning": false,+   "name": "pup_configure",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [],+   "statusVerb": null,+   "summary": "Get/set user-facing pup preferences (persisted across bridge updates). taskbarGrouping: \"split\" (default \u2014 one taskbar button PER WINDOW, each badged with its own tab count; best for telling AI threads apart) | \"grouped\" (all pup windows stack under ONE \"Adom Pup\" button, badge shows window count; tidy taskbar). Applies to live windows immediately. Ask the USER which they prefer before changing it \u2014 this is their taskbar. WINDOWS ONLY: macOS has no taskbar, so the setting is stored but INERT there; the reply carries platform + taskbarSupported so branch on those, not on this text.",+   "timeoutSeconds": 15+  },+  {+   "example": {},+   "hint": "This list \u2014 the bridge's verbs + schemas + hints, for AD's Verbs tab. Read it once to learn the surface.",+   "input": {},+   "longRunning": false,+   "name": "pup_describe",+   "output": {+    "success": "bool"+   },+   "pitfalls": [],+   "related": [+    "pup_status"+   ],+   "statusVerb": null,+   "summary": "This list \u2014 the bridge's verbs + schemas, for AD's Verbs tab.",+   "timeoutSeconds": 10+  }+ ]+}\ No newline at end of file
rust/verbs/src/extras.rs+2
@@ -81,6 +81,8 @@ pub fn download(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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 _t0 = std::time::Instant::now();+        let _ltick = crate::LoopTick("identity-audit", 300_000, _t0);         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();
rust/verbs/src/highfps.rs+5
@@ -9,6 +9,9 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +struct LoopTick(Instant);+impl Drop for LoopTick { fn drop(&mut self) { pup_core::loops::tick("highfps-lease-janitor", 60_000, self.0.elapsed().as_millis() as u64); } }+ pub const DEFAULT_MIN: u64 = 20; pub const MAX_MIN: u64 = 120; @@ -44,6 +47,8 @@ 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 _t0 = Instant::now();+        let _guard = LoopTick(_t0);         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() };
rust/verbs/src/input.rs+12−9
@@ -171,7 +171,7 @@ pub fn scroll(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {       if (!target && toText) {{ const t = toText.toLowerCase(); for (const c of document.querySelectorAll('*')) {{ const own = (c.innerText || '').trim().toLowerCase(); if (own && own.indexOf(t) !== -1 && c.children.length < 6) {{ target = c; break; }} }} }}       if (toSel || toText) {{ if (!target) return {{ found: false }}; target.scrollIntoView({{ block: block || 'center', behavior: 'instant' }}); const r = target.getBoundingClientRect(); return {{ found: true, scrolledTo: toSel || toText, top: r.top }}; }}       const el = scroller(); const before = el.scrollTop; el.scrollBy({{ top: dy, left: dx, behavior: 'instant' }});-      return {{ found: true, scrollElement: el === document.scrollingElement || el === document.documentElement ? 'html' : (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '')), scrollTop: el.scrollTop, scrollLeft: el.scrollLeft, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, moved: el.scrollTop - before }};+      return {{ found: true, scrollElement: el === document.scrollingElement || el === document.documentElement ? 'html' : (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '')), scrollTop: el.scrollTop, scrollLeft: el.scrollLeft, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, atBottom: el.scrollTop + el.clientHeight >= el.scrollHeight - 2, moved: el.scrollTop - before }};     }})()"#, dy = args.get("dy").and_then(|v| v.as_f64()).unwrap_or(0.0), dx = args.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0), ts = args.get("toSelector").and_then(|v| v.as_str()).map(js_str).unwrap_or("null".into()), tt = args.get("toText").and_then(|v| v.as_str()).map(js_str).unwrap_or("null".into()), bl = args.get("block").and_then(|v| v.as_str()).map(js_str).unwrap_or("null".into()));     match b.cdp.evaluate(&t.cdp_session, &expr, T) {         Ok(v) if v["found"] == json!(false) => err("scroll_target_not_found", "pup_scroll: the selector/text was not found", None),@@ -191,7 +191,7 @@ pub fn fill(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {       return {{ found: true, value: el.value !== undefined ? String(el.value).length : null, tag: el.tagName }}; }})()"#, sel = js_str(sel), val = js_str(text));     match b.cdp.evaluate(&t.cdp_session, &expr, T) {         Ok(v) if v["found"] == json!(false) => err("selector_not_found", format!("pup_fill: selector \"{sel}\" not found"), None),-        Ok(v) => { touch(ctx, &s.id, None); out(&s, &t, json!({ "filled": sel, "valueLength": v["value"], "tag": v["tag"], "_hint": "Value set through the native setter with input+change events (React/Vue/Angular see it). No keystrokes, no foreground needed." })) }+        Ok(v) => { touch(ctx, &s.id, None); out(&s, &t, json!({ "filled": sel, "valueLength": v["value"], "valueLanded": v["value"].as_u64().map(|n| n > 0), "fieldValueLength": v["value"], "tag": v["tag"], "_hint": "Value set through the native setter with input+change events (React/Vue/Angular see it). No keystrokes, no foreground needed." })) }         Err(e) => err("eval_failed", e.to_string(), None),     } }@@ -224,7 +224,7 @@ pub fn reload(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let loaded = match nav_wait(&b.cdp, &t.cdp_session, || b.cdp.send(Some(&t.cdp_session), "Page.reload", json!({ "ignoreCache": args.get("ignoreCache").and_then(|x| x.as_bool()).unwrap_or(false) }), T), Duration::from_secs(25)) { Ok(l) => l, Err(e) => return e };     if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }     touch(ctx, &s.id, None);-    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "loaded": loaded, "renderCheck": Value::Null, "_verifyRender": crate::VERIFY_RENDER, "_next": ["pup_screenshot"] }))+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "loaded": loaded, "renderCheck": if loaded { crate::render_check(&b.cdp, &t.cdp_session, None) } else { Value::Null }, "_verifyRender": crate::VERIFY_RENDER, "_next": ["pup_screenshot"] })) }  pub fn history(ctx: &Ctx, caller: &Caller, args: &Value, back: bool) -> Value {@@ -232,25 +232,27 @@ pub fn history(ctx: &Ctx, caller: &Caller, args: &Value, back: bool) -> Value {     let h = match b.cdp.send(Some(&t.cdp_session), "Page.getNavigationHistory", json!({}), T) { Ok(h) => h, Err(e) => return err("navigate_failed", e.to_string(), None) };     let cur = h["currentIndex"].as_i64().unwrap_or(0); let entries = h["entries"].as_array().cloned().unwrap_or_default();     let idx = if back { cur - 1 } else { cur + 1 };-    if idx < 0 || idx as usize >= entries.len() { return out(&s, &t, json!({ "moved": false, "_hint": if back { "No history entry to go back to." } else { "No history entry to go forward to." } })); }+    if idx < 0 || idx as usize >= entries.len() { return out(&s, &t, json!({ "moved": false, "direction": if back { "back" } else { "forward" }, "_hint": if back { "No history entry to go back to." } else { "No history entry to go forward to." } })); }     let id = entries[idx as usize]["id"].clone(); let url = entries[idx as usize]["url"].clone();     let loaded = match nav_wait(&b.cdp, &t.cdp_session, || b.cdp.send(Some(&t.cdp_session), "Page.navigateToHistoryEntry", json!({ "entryId": id }), T), Duration::from_secs(20)) { Ok(l) => l, Err(e) => return e };     touch(ctx, &s.id, None);-    out(&s, &t, json!({ "moved": true, "url": url, "loaded": loaded }))+    out(&s, &t, json!({ "moved": true, "direction": if back { "back" } else { "forward" }, "url": url, "loaded": loaded, "_hint": format!("Now at {}.", url.as_str().unwrap_or("?")) })) }  pub fn set_viewport(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 dims = |cdp: &Cdp, sid: &str| -> Value { cdp.evaluate(sid, "({ innerWidth: innerWidth, innerHeight: innerHeight, outerWidth: outerWidth, outerHeight: outerHeight, devicePixelRatio: devicePixelRatio })", T).unwrap_or(json!({})) };+    let outer_before = dims(&b.cdp, &t.cdp_session)["outerWidth"].clone();     if args.get("reset").and_then(|r| r.as_bool()) == Some(true) {         if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Emulation.clearDeviceMetricsOverride", json!({}), T) { return err("viewport_failed", e.to_string(), None); }         ctx.sessions.with_mut(&s.id, |x| x.emulated = None);-        return out(&s, &t, json!({ "emulated": false, "reset": true, "_hint": "Viewport override cleared; the page tracks the OS window size again." }));+        return out(&s, &t, pup_core::merge(json!({ "emulated": false, "reset": true, "_hint": "Viewport override cleared; the page tracks the OS window size again." }), dims(&b.cdp, &t.cdp_session)));     }     let (Some(w), Some(h)) = (args.get("width").and_then(|v| v.as_u64()), args.get("height").and_then(|v| v.as_u64())) else { return err("args_required", "pup_set_viewport needs {width, height} (or reset:true)", None) };     let dsf = args.get("deviceScaleFactor").and_then(|v| v.as_f64()).unwrap_or(1.0);     if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Emulation.setDeviceMetricsOverride", json!({ "width": w, "height": h, "deviceScaleFactor": dsf, "mobile": false }), T) { return err("viewport_failed", e.to_string(), None); }     ctx.sessions.with_mut(&s.id, |x| x.emulated = Some(json!({ "width": w, "height": h, "deviceScaleFactor": dsf })));-    out(&s, &t, json!({ "emulated": true, "applied": { "width": w, "height": h, "deviceScaleFactor": dsf }, "_hint": "Viewport pinned (CDP device metrics). It PERSISTS across navigations until pup_set_viewport {reset:true}." }))+    out(&s, &t, pup_core::merge(json!({ "emulated": true, "realOuterWidth": outer_before, "applied": { "width": w, "height": h, "deviceScaleFactor": dsf }, "_hint": "Viewport pinned (CDP device metrics). It PERSISTS across navigations until pup_set_viewport {reset:true}." }), dims(&b.cdp, &t.cdp_session))) }  pub fn errors(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {@@ -310,7 +312,7 @@ pub fn fetch_url(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let expr = format!(r#"(async () => {{ try {{ const r = await fetch({u}, {{ method: {m}, credentials: 'include', headers: {h} }}); const ct = r.headers.get('content-type') || ''; const text = await r.text(); return {{ ok: r.ok, status: r.status, contentType: ct, length: text.length, text: text.slice(0, 1000000) }}; }} catch (e) {{ return {{ error: e.message }}; }} }})()"#, u = js_str(url), m = js_str(method), h = args.get("headers").cloned().unwrap_or(json!({})));     match b.cdp.evaluate(&t.cdp_session, &expr, Duration::from_secs(40)) {         Ok(v) if v.get("error").is_some() => err("fetch_failed", format!("pup_fetch_url failed: {}", v["error"].as_str().unwrap_or("?")), Some("The page's origin must be allowed to fetch that URL (CORS); navigate to the URL instead when it is cross-origin.")),-        Ok(v) if v.get("status").is_some() => ok(json!({ "success": true, "output": v.to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "url": url, "status": v["status"], "contentType": v["contentType"], "length": v["length"] })),+        Ok(v) if v.get("status").is_some() => ok(json!({ "success": true, "output": v.to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "url": url, "httpStatus": v["status"], "contentType": v["contentType"], "length": v["length"] })),         Ok(v) => err("fetch_failed", format!("pup_fetch_url: unexpected evaluate result {}", v.to_string().chars().take(200).collect::<String>()), None),         Err(e) => err("fetch_failed", e.to_string(), None),     }@@ -323,5 +325,6 @@ pub fn maximize(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     if let Err(e) = b.cdp.set_window_bounds(wid, json!({ "windowState": "normal" })) { return err("window_failed", e.to_string(), None); }     if state != "normal" { if let Err(e) = b.cdp.set_window_bounds(wid, json!({ "windowState": state })) { return err("window_failed", e.to_string(), None); } }     if let Some(h) = s.hwnd { if state != "minimized" { ctx.host.send_to_bottom_no_activate(h); } }-    out(&s, &t, json!({ "state": state, "windowId": wid, "_hint": "Window state changed via CDP without foregrounding it." }))+    let bounds = b.cdp.send(None, "Browser.getWindowBounds", json!({ "windowId": wid }), T).map(|v| v["bounds"].clone()).unwrap_or(Value::Null);+    out(&s, &t, json!({ "state": state, "windowId": wid, "bounds": bounds, "_hint": "Window state changed via CDP without foregrounding it." })) }
rust/verbs/src/lib.rs+145−18
@@ -1,8 +1,11 @@ //! pup-verbs: the verb handlers. Each is a function of (ctx, caller, args) -> Value and knows-//! nothing about HTTP. Phase 1 carries the ten verbs that open, drive, capture and close a-//! window; every other name in the contract answers `not_in_rust_yet` with the Node fallback hint.+//! nothing about HTTP. `dispatch` routes every pup_* / credential_* name and applies the response+//! envelope the Node build's sendJSON applied (catalog hint/related/pitfalls, the bug-report invite,+//! render verdicts, the `output` mirror), so callers see the same shapes as docs/contract/*.json. //! Response shapes follow docs/contract/*.json (recorded from the Node build) field for field. +#![recursion_limit = "512"]+ use pup_browser::{Candidate, Installer, Launched}; use pup_cdp::Cdp; use pup_core::{blocked_on_user, err, ok, AbClient, Caller, Settings};@@ -33,6 +36,65 @@ 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");+pub const RENDER_PROBE_JS: &str = include_str!("../assets/render-probe.js");+/// The verb catalog (name/summary/input/timeoutSeconds/example/hint/related/pitfalls), generated by+/// tools/gen-assets.py from the Node build's pup_describe golden. Node: VERB_META + the V() rows.+const VERB_CATALOG_JSON: &str = include_str!("../assets/verb-catalog.json");+static VERB_CATALOG: std::sync::OnceLock<Value> = std::sync::OnceLock::new();+pub fn catalog() -> &'static Value { VERB_CATALOG.get_or_init(|| serde_json::from_str(VERB_CATALOG_JSON).unwrap_or(json!({ "verbs": [] }))) }+pub fn catalog_meta(verb: &str) -> Option<&'static Value> { catalog()["verbs"].as_array()?.iter().find(|v| v["name"] == json!(verb)) }++/// Ticks a loop stat when dropped (so early `continue`s still count).+pub struct LoopTick(pub &'static str, pub u64, pub std::time::Instant);+impl Drop for LoopTick { fn drop(&mut self) { pup_core::loops::tick(self.0, self.1, self.2.elapsed().as_millis() as u64); } }++const PUP_ISSUES_REF: &str = "adom/pup-bridge";+const BUG_INVITE_EVERY: u64 = 40;+static BUG_INVITE: Mutex<(bool, u64)> = Mutex::new((false, 0));+/// Node: bugReportInvite(isError).+pub fn bug_report_invite(is_error: bool) -> String {+    let lead = if is_error { "THIS MAY BE A PUP BUG — PLEASE REPORT IT." } else { "pup is actively developed and bug reports are genuinely wanted." };+    format!("{lead} If a pup verb misbehaves (wrong result, a hang, a window that never appears, a hint that turned out to be false, a process left running), file it on pup's own wiki repo so the maintainers see it: `adom-wiki issue list {PUP_ISSUES_REF}` to check for a duplicate first, then `adom-wiki issue create {PUP_ISSUES_REF} --title \"<one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON, and the bridge version from bridge_list>\"`. Include the machine's OS and whether it is Windows or macOS. Reports from real machines are the only way this bridge gets correct — the maintainers cannot reproduce every environment.")+}+const PROBE_VERBS: [&str; 9] = ["pup_readiness", "pup_status", "pup_describe", "pup_prewarm", "pup_list_windows", "pup_list_tabs", "pup_rescan", "pup_configure", "pup_import_scan"];+const RENDER_VERBS: [&str; 4] = ["pup_open_window", "pup_open_tab", "pup_navigate", "pup_reload"];+/// Verbs that do not count as "the agent drove this window" for lastAgentUpdate.+const PASSIVE_VERBS: [&str; 6] = ["pup_window_info", "pup_my_windows", "pup_errors", "pup_switch_window", "pup_use", "pup_audit_icons"];++/// Node: PERMISSIVE_DEFAULT_PERMISSIONS, applied per permission with Browser.setPermission on the+/// default context (origin omitted = every origin) so no permission prompt ever blocks a debug flow.+pub const DEFAULT_PERMISSIONS: [&str; 32] = ["window-management", "clipboard-read", "clipboard-write", "clipboard-sanitized-write", "notifications", "geolocation", "camera", "microphone", "midi", "background-sync", "background-fetch", "periodic-background-sync", "payment-handler", "persistent-storage", "durable-storage", "storage-access", "top-level-storage-access", "idle-detection", "display-capture", "local-fonts", "nfc", "screen-wake-lock", "system-wake-lock", "accelerometer", "gyroscope", "magnetometer", "ambient-light-sensor", "sensors", "protected-media-identifier", "captured-surface-control", "speaker-selection", "window-placement"];+pub fn grant_default_permissions(cdp: &Cdp) -> usize {+    let mut n = 0;+    for p in DEFAULT_PERMISSIONS { if cdp.send(None, "Browser.setPermission", json!({ "permission": { "name": p }, "setting": "granted" }), Duration::from_secs(3)).is_ok() { n += 1; } }+    n+}++/// Node: probeRender — judge what just landed (404 text, blank, unstyled, stack traces, broken images).+pub fn render_check(cdp: &Cdp, sid: &str, http_status: Option<u64>) -> Value {+    let mut signals: Vec<String> = Vec::new();+    let mut out = json!({ "httpStatus": http_status, "ok": true });+    match cdp.evaluate(sid, RENDER_PROBE_JS, Duration::from_secs(6)) {+        Ok(p) if p.is_object() => {+            out["title"] = p["title"].clone(); out["bodyChars"] = p["bodyChars"].clone(); out["elements"] = p["elements"].clone();+            let head = p["head"].as_str().unwrap_or("").to_string(); let low = head.to_lowercase();+            let (chars, elements, sheets) = (p["bodyChars"].as_u64().unwrap_or(0), p["elements"].as_u64().unwrap_or(0), p["styleSheets"].as_u64().unwrap_or(0));+            if let Some(st) = http_status { if st >= 400 { signals.push(format!("http_{st}")); } }+            if low.contains("page not found") || low.contains("not found") || low.split(|c: char| !c.is_ascii_digit()).any(|w| w == "404") { signals.push("page_says_not_found".into()); }+            if low.contains("cannot get /") || low.contains("cannot post /") { signals.push("dev_server_cannot_get".into()); }+            if low.contains("internal server error") || low.contains("bad gateway") || low.contains("service unavailable") { signals.push("server_error_text".into()); }+            if low.contains("traceback (most recent call last)") || low.contains("unhandled exception") || low.contains("unhandled rejection") || low.contains("referenceerror") || low.contains("typeerror:") || low.contains("syntaxerror:") { signals.push("stack_trace_visible".into()); }+            if chars < 12 && elements < 8 { signals.push("effectively_blank".into()); }+            if chars > 40 && sheets == 0 { signals.push("no_stylesheets_loaded".into()); }+            if let Some(b) = p["brokenImgs"].as_u64() { if b > 0 { signals.push(format!("broken_images_{b}")); } }+            if !signals.is_empty() { out["excerpt"] = json!(head.chars().take(180).collect::<String>()); }+        }+        Ok(_) => signals.push("probe_timed_out".into()),+        Err(_) => signals.push("probe_failed".into()),+    }+    out["ok"] = json!(signals.is_empty()); out["signals"] = json!(signals);+    out+}  /// Per-tab state the event pump maintains (keyed by CDP session id). #[derive(Debug, Clone, Default)]@@ -205,9 +267,52 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {         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.")),     };+    // lastAgentUpdate {tabId, verb, note, ts}: every driving verb that names a window (Node: touch sites)+    if !PROBE_VERBS.contains(&verb) && !PASSIVE_VERBS.contains(&verb) {+        if let Some(sid) = r.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()) {+            let tab = r.get("tabId").and_then(|v| v.as_str()).map(|s| s.to_string());+            ctx.sessions.with_mut(&sid, |x| { x.last_verb = Some(verb.to_string()); if tab.is_some() { x.last_tab = tab; } x.last_agent_update_ms = pup_core::now_ms(); });+        }+    }     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"));+        // Node sendJSON: the catalog's canonical hint/related/next/pitfalls when the handler set none+        if let Some(m) = catalog_meta(verb) {+            if !o.contains_key("_hint") { if let Some(h) = m["hint"].as_str() { o.insert("_hint".into(), json!(h)); } }+            if !o.contains_key("related") { if m["related"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("related".into(), m["related"].clone()); } }+            if !o.contains_key("_next") { if m["related"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("_next".into(), m["related"].clone()); } }+            if !o.contains_key("pitfalls") { if m["pitfalls"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("pitfalls".into(), m["pitfalls"].clone()); } }+        }+        // Node maybeAttachBugInvite: every failure carries the invite; substantive successes carry it once per process, then every 40th+        if ctx.settings.get("reportIssues", "on") != "off" && !o.contains_key("_reportIssues") {+            let is_error = o.get("success") == Some(&json!(false)) || o.contains_key("errorCode") || o.get("error").and_then(|e| e.as_str()).map(|e| !e.is_empty()).unwrap_or(false);+            if is_error { o.insert("_reportIssues".into(), json!(bug_report_invite(true))); }+            else if !PROBE_VERBS.contains(&verb) {+                let mut g = BUG_INVITE.lock().unwrap(); g.1 += 1;+                if !g.0 || g.1 % BUG_INVITE_EVERY == 0 { g.0 = true; o.insert("_reportIssues".into(), json!(bug_report_invite(false))); }+            }+        }+        // render verbs: the verdict rides the response (Node: RENDER_VERBS block in sendJSON)+        if RENDER_VERBS.contains(&verb) && o.get("ok") != Some(&json!(false)) && o.get("success") != Some(&json!(false)) {+            if let Some(rc) = o.get("renderCheck").cloned() { if rc["ok"] == json!(false) { if let Some(sig) = rc["signals"].as_array() { if !sig.is_empty() {+                let st = rc["httpStatus"].as_u64().map(|s| format!(" (HTTP {s})")).unwrap_or_default();+                let lead = format!("⛔ THE PAGE IS STILL BROKEN: {}{st}{}. Do NOT report success. See _verifyRender. ", sig.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "), rc["excerpt"].as_str().map(|e| format!(" — it says \"{}\"", e.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(120).collect::<String>())).unwrap_or_default());+                let cur = o.get("_hint").and_then(|h| h.as_str()).unwrap_or("").to_string();+                if !cur.contains("STILL BROKEN") && !cur.contains("IS BROKEN") { o.insert("_hint".into(), json!(format!("{lead}{cur}"))); }+            } } } }+            if !o.contains_key("_verifyRender") { o.insert("_verifyRender".into(), json!(VERIFY_RENDER)); }+        }         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)); } }+        // Node v1.9.98: ab/the CLI unwrap `output` and drop the envelope on success, so the advisory+        // fields have to live INSIDE output too (never overwriting what the handler put there).+        if let Some(inner_s) = o.get("output").and_then(|v| v.as_str()).filter(|s| s.starts_with('{')).map(|s| s.to_string()) {+            if let Ok(mut inner) = serde_json::from_str::<Value>(&inner_s) { if let Some(i) = inner.as_object_mut() {+                let sid = i.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()).or_else(|| o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()));+                if let Some(sid) = sid { if !i.contains_key("window") { i.insert("window".into(), json!(sid)); if !i.contains_key("aiThread") { if let Some(owner) = ctx.sessions.get(&sid).and_then(|x| x.owner) { i.insert("aiThread".into(), json!(owner)); } } } }+                for k in ["_reportIssues", "_cleanupReport", "_autoLogin", "_credentialCaptured", "_hint", "_next", "related", "pitfalls", "renderCheck", "_verifyRender"] { if let Some(v) = o.get(k) { if !i.contains_key(k) { i.insert(k.into(), v.clone()); } } }+                o.insert("output".into(), json!(inner.to_string()));+            } }+        }     }     r }@@ -262,6 +367,7 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -             pup_core::log(format!("[launch] reconnected to a live {} on :{port} for profile \"{profile}\"", cand.kind));             let pid = cdp.send(None, "SystemInfo.getProcessInfo", json!({}), Duration::from_secs(5)).ok().and_then(|v| v["processInfo"].as_array().and_then(|a| a.iter().find(|p| p["type"] == json!("browser")).and_then(|p| p["id"].as_u64()))).unwrap_or(0) as u32;             let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid, cdp_port: port, candidate: cand, profile_dir, reused: true } });+            grant_default_permissions(&h.cdp);             events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());             ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());             return Ok(h);@@ -287,6 +393,7 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -                     pup_core::log(format!("[launch] {}({}) up on :{} pid {} in {}ms", c.kind, c.source, l.cdp_port, l.pid, t0.elapsed().as_millis()));                     pup_browser::write_default(&*ctx.host, c, false);                     let h = Arc::new(BrowserHandle { cdp, launched: l });+                    grant_default_permissions(&h.cdp);                     events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());                     ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());                     return Ok(h);@@ -335,15 +442,16 @@ pub fn refresh_tab(cdp: &Cdp, t: &mut Tab) { // ── the verbs ──────────────────────────────────────────────────────────────────────────  fn describe(ctx: &Ctx) -> Value {-    let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());-    ok(json!({-        "version": pup_core::version(), "shell": "rust", "phase": "1 of 4 (docs/PLAN-rust-port.md)",-        "verbs": PHASE1_VERBS, "nativeCount": PHASE1_VERBS.len(),-        "notYetInRust": "every other pup_* / credential_* / desktop_record* verb answers errorCode not_in_rust_yet; the Node build on the public tier still serves them",-        "readiness": rd,-        "progressContract": { "statusVerb": "pup_readiness", "activeKey": "active", "slowVerbs": ["pup_open_window", "pup_prewarm"], "blockedByPhases": ["consent", "dialog"], "blockedOnUser": { "errorCode": "blocked_on_user", "rule": "a verb parked on a HUMAN never bare-timeouts; it answers this shape", "stillRunning": "true = re-call the SAME verb with the SAME args; false = the human must act first" } },-        "_hint": "This is pup built as one Rust exe (spawn.kind exe): no Node, no npm, no postinstall. Same contract as the Node build; the verbs listed are native, the rest land phase by phase.",-    }))+    let c = catalog();+    let body = json!({+        "success": true, "bridge": "puppeteer", "version": pup_core::version(), "shell": "rust",+        "progressContract": c["progressContract"], "verbs": c["verbs"], "statusVerb": "pup_readiness",+        "readiness": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()),+        "_hint": "This list — the bridge's verbs + schemas + hints, for AD's Verbs tab. Read it once to learn the surface. This build is pup as one Rust exe (spawn.kind exe): no Node, no npm, no postinstall; same contract as the Node build.",+        "_next": ["pup_status"], "related": ["pup_status"],+    });+    let mut r = body.clone(); r["output"] = json!(body.to_string()); r["error"] = json!(""); r["status"] = json!("ok"); r["ok"] = json!(true);+    r }  fn active_rows(ctx: &Ctx) -> Vec<Value> {@@ -370,7 +478,12 @@ fn readiness(ctx: &Ctx) -> Value {         else if installing { "Chrome for Testing is downloading (active[] carries percent, elapsed and ETA). Poll this verb; do not start a second download. A system Chrome/Edge, if present, is drivable already." }         else if rd.get("installPhase").and_then(|p| p.as_str()) == Some("failed") { "The Chrome for Testing install FAILED (lastError). Retry pup_prewarm once the cause (network/proxy, disk) is fixed; a system Chrome/Edge is drivable meanwhile." }         else { "No browser yet: pup_prewarm {} starts the Chrome for Testing download; pup_open_window starts it too and answers chrome_for_testing_installing until it lands." };-    pup_core::merge(ok(rd), json!({ "success": true, "statusVerb": "pup_readiness", "active": active_rows(ctx), "_hint": hint, "_next": ["pup_open_window", "pup_prewarm"] }))+    let mut r = pup_core::merge(ok(rd.clone()), json!({ "success": true, "statusVerb": "pup_readiness", "active": active_rows(ctx), "_hint": hint, "_next": ["pup_open_window", "pup_prewarm"], "error": "",+        // the Node build's real-Chrome installer phases (pup_use {browser:"chrome", install:true}); the Rust build has no stable-Chrome installer yet, so the phase is always idle+        "chromeStablePhase": "idle", "chromeStableAwaitingApproval": false, "chromeStableError": Value::Null, "chromeStableNotifyChannel": Value::Null, "chromeStableNotifyPeers": Value::Null }));+    let mut inner = rd; if let Some(i) = inner.as_object_mut() { i.insert("active".into(), r["active"].clone()); i.insert("_hint".into(), json!(hint)); for k in ["chromeStablePhase", "chromeStableAwaitingApproval", "chromeStableError", "chromeStableNotifyChannel", "chromeStableNotifyPeers"] { i.insert(k.into(), r[k].clone()); } }+    r["output"] = json!(inner.to_string());+    r }  fn prewarm(ctx: &Ctx, args: &Value) -> Value {@@ -383,7 +496,10 @@ fn prewarm(ctx: &Ctx, args: &Value) -> Value {  pub fn session_row(ctx: &Ctx, s: &Session) -> Value {     let active_tab = s.active();-    json!({ "sessionId": s.id, "window": s.id, "url": active_tab.map(|t| t.url.clone()), "title": active_tab.map(|t| t.title.clone()), "active": ctx.sessions.active_id().as_deref() == Some(&s.id), "owner": s.owner, "aiThread": s.owner, "profile": s.profile, "tabCount": s.tabs.len(), "hwnd": s.hwnd, "foreground": s.foreground, "ageMinutes": s.age_minutes(), "emulatedViewport": s.emulated, "lastAgentUpdate": s.last_agent_update_ms, "errorCount": s.tabs.iter().map(|t| ctx.tabstate.lock().unwrap().get(&t.cdp_session).map(|x| x.errors.len()).unwrap_or(0)).sum::<usize>() })+    json!({ "sessionId": s.id, "window": s.id, "url": active_tab.map(|t| t.url.clone()), "title": active_tab.map(|t| t.title.clone()), "active": ctx.sessions.active_id().as_deref() == Some(&s.id), "owner": s.owner, "aiThread": s.owner, "profile": s.profile, "tabCount": s.tabs.len(), "hwnd": s.hwnd, "foreground": s.foreground, "ageMinutes": s.age_minutes(), "emulatedViewport": s.emulated, "lastAgentUpdate": s.last_agent_update(), "lastForeground": s.last_foreground, "errorCount": s.tabs.iter().map(|t| ctx.tabstate.lock().unwrap().get(&t.cdp_session).map(|x| x.errors.len()).unwrap_or(0)).sum::<usize>(),+        "activeTabId": active_tab.map(|t| t.tab_id.clone()), "ownerSource": if s.owner.is_some() { if s.owner_from_caller { json!("caller-identity") } else { json!("declared") } } else { Value::Null },+        "overlay": s.overlay_desc.clone().unwrap_or_else(|| "none (not yet applied)".into()), "flash": "none", "flashCount": s.flash_count, "progressBarCount": s.progress_count, "progressBarMs": s.progress_ms,+        "background": if s.foreground { "still-foreground" } else if s.hwnd.is_some() { "backgrounded" } else { "no-window" } }) }  fn status(ctx: &Ctx) -> Value {@@ -392,7 +508,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": ctx.last_audit.lock().unwrap().clone(),+        "win32": { "inProcess": true, "why": Value::Null }, "enumWindows": { "total": 0, "lastMin": 0 }, "win32Calls": {}, "log": [], "loops": pup_core::loops::report(), "pupshotUrl": Value::Null, "iconAudit": ctx.last_audit.lock().unwrap().clone(),         "adVersion": ctx.ad_version.lock().unwrap().clone(),     })) }@@ -408,7 +524,9 @@ fn my_windows(ctx: &Ctx, caller: &Caller) -> Value {     if caller.thread.is_empty() { return err("caller_identity_required", "pup_my_windows needs --ai-thread", Some("Pass --ai-thread <your thread name>; windows are keyed on it.")); }     let mine: Vec<Value> = ctx.sessions.owned_by(&caller.thread).iter().map(|s| session_row(ctx, s)).collect();     let default_id = thread_default_session_id(&caller.thread);-    ok(json!({ "aiThread": caller.thread, "count": mine.len(), "windows": mine, "defaultWindow": default_id, "_hint": if mine.is_empty() { "You own no pup window yet: pup_open_window {url} creates yours (no ids needed), later verbs resolve to it." } else { "Verbs with no ids resolve to your window; name another with {window:\"<sessionId>\"}." }, "_next": ["pup_open_window", "pup_screenshot", "pup_navigate"] }))+    let body = json!({ "ok": true, "aiThread": caller.thread, "ai-thread": caller.thread, "count": mine.len(), "windows": mine, "defaultWindow": default_id, "_hint": if mine.is_empty() { "You own no pup window yet: pup_open_window {url} creates yours (no ids needed), later verbs resolve to it." } else { "Verbs with no ids resolve to your window; name another with {window:\"<sessionId>\"}." }, "_next": ["pup_open_window", "pup_screenshot", "pup_navigate"] });+    let mut r = ok(body.clone()); r["success"] = json!(true); r["output"] = json!(body.to_string());+    r }  fn birth_rect(ctx: &Ctx) -> (i32, i32, i32, i32) {@@ -453,6 +571,7 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let before: std::collections::HashSet<i64> = ctx.host.windows_for_pids(&[], Some("Chrome_WidgetWin_1")).into_iter().map(|w| w.hwnd).collect();     let target = match b.cdp.create_target("about:blank", true, Some((OFFSCREEN, OFFSCREEN, bw, bh))) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", format!("Target.createTarget: {e}"), Some("The browser is up but refused a new window; pup_readiness shows its state, pup_close {} restarts it.")) };     let mut s = Session::new(&sid, &profile, if caller.thread.is_empty() { None } else { Some(&caller.thread) }, Some(&caller.container), b.launched.cdp_port, if b.launched.pid > 0 { Some(b.launched.pid) } else { None });+    s.owner_from_caller = s.owner.as_deref() == Some(caller.thread.as_str()) && !caller.thread.is_empty() && args.get("owner").is_none();     let tab_id = match attach_tab(&mut s, &b.cdp, &target) { Ok(t) => t, Err(e) => return e };     // park: on-screen rect via CDP (moves without z-order change), then bottom + tag via the host     let mut hwnd: Option<i64> = None;@@ -479,12 +598,17 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     if let Some(a) = ctx.arc() { identity::schedule(a, sid.clone(), caller.clone(), true); }     pup_core::log(format!("[window] \"{sid}\" opened for \"{}\" on {}({}) hwnd={:?} parked={parked} loaded={loaded} in {}ms", caller.thread, b.launched.candidate.kind, b.launched.candidate.source, hwnd, t0.elapsed().as_millis()));     let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());+    let strict = args["strictPermissions"].as_bool() == Some(true);+    let render_check_val = match s.tabs.last() { Some(t) if loaded => render_check(&b.cdp, &t.cdp_session, None), _ => Value::Null };     let mut resp = ok(json!({         "sessionId": sid, "window": sid, "aiThread": s.owner, "url": url, "title": title, "tabId": tab_id, "activeTabId": tab_id, "tabCount": 1, "reused": false, "active": true, "loaded": loaded,         "background": "backgrounded", "windowEtiquette": "background", "placement": "background", "hwnd": hwnd, "parked": parked,         "browser": b.launched.candidate.to_json(), "availableBrowsers": cands.iter().map(|c| format!("{}({})", c.kind, c.source)).collect::<Vec<_>>(),         "defaultBrowser": pup_browser::read_default(&*ctx.host).map(|d| json!({ "kind": d.get("kind"), "source": d.get("source"), "forced": d.get("forced").and_then(|f| f.as_bool()).unwrap_or(false), "pendingInstall": false })),-        "profile": profile, "owner": s.owner, "ownerSource": if caller.thread.is_empty() { "none" } else { "caller" }, "ageMinutes": 0, "highFps": high_fps, "emulatedViewport": Value::Null, "errorCount": 0,+        "profile": profile, "owner": s.owner, "ownerSource": if s.owner.is_some() { if s.owner_from_caller { json!("caller-identity") } else { json!("declared") } } else { Value::Null }, "ageMinutes": 0, "highFps": high_fps, "emulatedViewport": Value::Null, "errorCount": 0,+        "defaultPermissionsApplied": !strict, "defaultPermissionsCount": if strict { 0 } else { DEFAULT_PERMISSIONS.len() }, "defaultPermissionsNote": if strict { "strictPermissions: nothing pre-granted" } else { "full browser permissions pre-granted (clipboard, camera, geolocation, window-management, ...) — pup_describe has the exact list" },+        "downloadsEnabled": !strict, "downloadPath": ctx.host.home().join("Downloads").to_string_lossy().replace('\\', "/"), "flash": "none", "flashCount": 0, "lastAgentUpdate": Value::Null, "lastForeground": Value::Null, "overlay": "none (not yet applied)", "progressBarCount": 0, "progressBarMs": 0, "restoredTabsClosed": 0,+        "renderCheck": render_check_val,         "size": { "width": bw, "height": bh }, "progress": { "phase": "done", "elapsedMs": t0.elapsed().as_millis() as u64, "startKind": if b.launched.reused { "warm" } else { "cold" } },         "_verifyRender": VERIFY_RENDER, "_next": ["pup_screenshot", "pup_navigate", "pup_eval", "pup_close_window"],         "_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.",@@ -513,7 +637,7 @@ fn navigate(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {             if let Some(a) = ctx.arc() { identity::schedule(a, s.id.clone(), caller.clone(), false); }             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"] }));+            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, "renderCheck": if loaded { render_check(&b.cdp, &t.cdp_session, None) } else { Value::Null }, "_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)         }@@ -557,7 +681,10 @@ fn screenshot(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { fn window_info(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };     let fg = ctx.host.foreground();-    ok(pup_core::merge(session_row(ctx, &s), json!({ "output": json!({ "sessionId": s.id, "hwnd": s.hwnd, "isForeground": s.hwnd.is_some() && s.hwnd == Some(fg), "tabs": s.tabs.iter().map(|t| json!({ "tabId": t.tab_id, "url": t.url, "title": t.title })).collect::<Vec<_>>() }).to_string(), "lastAgentUpdate": s.last_agent_update_ms, "ownerContainer": s.owner_container })))+    let tabs: Vec<Value> = s.tabs.iter().enumerate().map(|(i, t)| json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "active": i == s.active_tab })).collect();+    ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "owner": s.owner, "ownerContainer": s.owner_container, "profile": s.profile, "ageMinutes": s.age_minutes(), "tabs": tabs,+        "lastAgentUpdate": s.last_agent_update(), "hwnd": s.hwnd, "foreground": s.hwnd.is_some() && s.hwnd == Some(fg), "lastTakeoverBy": Value::Null, "cardShown": false, "captionShown": false,+        "_hint": "Info returned as JSON only — nothing was drawn on the user's screen (hwnd included for desktop_screenshot_window when you need the browser chrome in frame). A USER clicking the jump-list/toolbar Info action gets the on-screen card; pass showCaption:true to draw the native card yourself." })) }  pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
rust/verbs/src/record.rs+1−1
@@ -74,7 +74,7 @@ pub fn stop(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     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.")); }+    if rec.frames == 0 { let _ = std::fs::remove_dir_all(&rec.frames_dir); return err("no_frames", "zero frames captured — no video produced", Some("Chrome's screencast only emits a frame when the page PAINTS: a static page yields none however long you record (measured: the same window records an animated page fine). Make the page change while recording (scroll, navigate, drive it), or use pup_screen_record_start / ab's native desktop_record_start for a wall-clock capture.")); }     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) } } },
rust/verbs/src/recovery.rs+5−2
@@ -111,7 +111,7 @@ pub fn health_tick(ctx: &Ctx) { }  pub fn start_health_loop(ctx: Arc<Ctx>) {-    std::thread::Builder::new().name("health".into()).spawn(move || loop { std::thread::sleep(Duration::from_secs(15)); health_tick(&ctx); }).ok();+    std::thread::Builder::new().name("health".into()).spawn(move || loop { std::thread::sleep(Duration::from_secs(15)); let t0 = std::time::Instant::now(); health_tick(&ctx); pup_core::loops::tick("health+user-activity", 15_000, t0.elapsed().as_millis() as u64); }).ok(); }  /// pup_rescan: reconnect dropped connections and rebuild sessions from files; adoptOrphans:true@@ -147,7 +147,10 @@ pub fn rescan(ctx: &Ctx, args: &Value) -> Value {             }         }     }-    ok(json!({ "rescanned": true, "liveSessions": ctx.sessions.count(), "before": before, "reattached": reattached, "orphansAdopted": adopted, "profilesReconnected": reconnected, "profilesUnreachable": unreachable, "disconnectedSessions": 0, "_hint": "Reconnected dropped CDP sockets and rebuilt sessions from their files. pup_list_windows shows the result." }))+    let live: Vec<String> = ctx.sessions.all().iter().filter(|s| crate::browser_for(ctx, &s.profile).is_some()).map(|s| s.id.clone()).collect();+    let disconnected: Vec<String> = ctx.sessions.all().iter().filter(|s| crate::browser_for(ctx, &s.profile).is_none()).map(|s| s.id.clone()).collect();+    let hint = if disconnected.is_empty() { format!("All known sessions are live: [{}].", live.join(", ")) } else { format!("{} session(s) still disconnected: [{}] — their Chrome is gone; pup_open_window recreates them.", disconnected.len(), disconnected.join(", ")) };+    ok(json!({ "rescanned": reconnected.len() + unreachable.len(), "liveSessions": live, "disconnectedSessions": disconnected, "before": before, "reattached": reattached, "orphansAdopted": adopted, "profilesReconnected": reconnected.len(), "profilesUnreachable": unreachable.len(), "_hint": hint })) }  #[allow(dead_code)]
rust/verbs/src/settings.rs+4−3
@@ -43,7 +43,7 @@ pub fn configure(ctx: &Ctx, args: &Value) -> Value {             if k == "taskbarMode" { let (a, t) = match sv.as_str() { "plain" => ("off", "off"), "aumid" => ("on", "off"), _ => ("on", "on") }; let _ = ctx.settings.set("aumidIcons", json!(a)); let _ = ctx.settings.set("threadTiles", json!(t)); }         }     }-    let out = json!({ "ok": true, "settings": snapshot(ctx), "changed": changed, "rejected": rejected, "_hint": if changed.is_empty() { "Current pup preferences (persisted in ~/.adom/pup-settings.json). Pass {key:value} to change one; every key applies live, no restart." } else { "Saved. Live windows pick the change up on their next identity pass; toolbarExtension changes apply to the NEXT browser launch." } });+    let out = json!({ "ok": true, "settings": snapshot(ctx), "changed": changed, "rejected": rejected, "platform": if cfg!(windows) { "win32" } else if cfg!(target_os = "macos") { "darwin" } else { "linux" }, "taskbarSupported": cfg!(windows), "_hint": if changed.is_empty() { "Current pup preferences (persisted in ~/.adom/pup-settings.json). Pass {key:value} to change one; every key applies live, no restart." } else { "Saved. Live windows pick the change up on their next identity pass; toolbarExtension changes apply to the NEXT browser launch." } });     ok(json!({ "success": true, "output": out.to_string(), "settings": out["settings"], "changed": out["changed"], "rejected": out["rejected"] })) } @@ -54,7 +54,7 @@ pub fn use_browser(ctx: &Ctx, args: &Value) -> Value {     let picked = match want.as_str() {         "" | "auto" => {             let p = ctx.host.cft_cache_dir().join("pup-browser-default.json"); let _ = std::fs::remove_file(p);-            return ok(json!({ "success": true, "cleared": true, "picked": cands.first().map(|c| c.to_json()), "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": Value::Null, "output": json!({ "ok": true, "cleared": true }).to_string(), "_hint": "Default pin cleared: pup picks Chrome for Testing when cached (the toolbar build), else the installed browser. Relaunch windows to converge." }));+            return ok(json!({ "success": true, "cleared": true, "picked": cands.first().map(|c| c.to_json()), "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": Value::Null, "output": json!({ "ok": true, "cleared": true, "picked": cands.first().map(|c| c.to_json()), "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": Value::Null, "readiness": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()), "_hint": "Default pin cleared: pup picks Chrome for Testing when cached (the toolbar build), else the installed browser. Relaunch windows to converge." }).to_string(), "_hint": "Default pin cleared: pup picks Chrome for Testing when cached (the toolbar build), else the installed browser. Relaunch windows to converge." }));         }         "cft" | "chrome-for-testing" => {             match pup_browser::detect_cft(&*ctx.host) { Some(c) => c, None => { ctx.installer.ensure_background(ctx.host.clone()); return pup_core::merge(err("chrome_for_testing_installing", "Chrome for Testing is not cached yet; fetching it now", Some("Poll pup_readiness until chromeForTestingInstalled:true, then pup_use {browser:\"cft\"} again (or just open a window: it converges on its own).")), json!({ "installing": true, "stillRunning": true, "statusVerb": "pup_readiness" })); } }@@ -64,7 +64,8 @@ pub fn use_browser(ctx: &Ctx, args: &Value) -> Value {     };     pup_browser::write_default(&*ctx.host, &picked, true);     if temporary { let p = ctx.host.cft_cache_dir().join("pup-browser-default.json"); if let Ok(s) = std::fs::read_to_string(&p) { if let Ok(mut v) = serde_json::from_str::<Value>(&s) { v["temporary"] = json!(true); let _ = std::fs::write(&p, v.to_string()); } } }-    let out = json!({ "ok": true, "picked": picked.to_json(), "temporary": temporary });+    let use_hint = if picked.kind != "chrome-for-testing" { "Pinned. NOTE: the Adom toolbar rides ONLY Chrome for Testing; windows on this browser have no toolbar. A window already open keeps its browser; new launches use the pin." } else { "Pinned Chrome for Testing as the default. The next pup_open_window drives it." };+    let out = json!({ "ok": true, "picked": picked.to_json(), "cleared": false, "temporary": temporary, "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": pup_browser::read_default(&*ctx.host), "readiness": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()), "_hint": use_hint });     ok(json!({ "success": true, "picked": picked.to_json(), "cleared": false, "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": pup_browser::read_default(&*ctx.host), "output": out.to_string(),         "_hint": if picked.kind != "chrome-for-testing" { "Pinned. NOTE: the Adom toolbar rides ONLY Chrome for Testing; windows on this browser have no toolbar. A window already open keeps its browser; new launches use the pin." } else { "Pinned to Chrome for Testing (the toolbar build). New launches use it." } })) }
rust/verbs/src/tabs.rs+3−3
@@ -26,7 +26,7 @@ pub fn open_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let count = sess.tabs.len();     ctx.sessions.insert(sess.clone()); ctx.sessions.save_file(&sess);     touch(ctx, &sess.id, args.get("updateNote").and_then(|n| n.as_str()));-    pup_core::merge(ok(row), json!({ "tabCount": count, "activeTabId": tab_id, "renderCheck": Value::Null, "_hint": "New tab opened and activated in the existing window (no new OS window). Screenshot to verify the render.", "_next": ["pup_screenshot", "pup_switch_tab", "pup_close_tab"] }))+    pup_core::merge(ok(row), json!({ "tabCount": count, "activeTabId": tab_id, "renderCheck": sess.tabs.last().map(|t| crate::render_check(&b.cdp, &t.cdp_session, None)).unwrap_or(Value::Null), "_verifyRender": crate::VERIFY_RENDER, "_hint": "New tab opened and activated in the existing window (no new OS window). Screenshot to verify the render.", "_next": ["pup_screenshot", "pup_switch_tab", "pup_close_tab"] })) }  pub fn list_tabs(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {@@ -34,7 +34,7 @@ pub fn list_tabs(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     if let Some(b) = browser_for(ctx, &s.profile) { for t in s.tabs.iter_mut() { refresh_tab(&b.cdp, t); } ctx.sessions.with_mut(&s.id, |x| x.tabs = s.tabs.clone()); }     let active = s.active().map(|t| t.tab_id.clone());     let rows: Vec<Value> = s.tabs.iter().map(|t| tab_row(ctx, &s, t, Some(&t.tab_id) == active.as_ref())).collect();-    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "count": rows.len(), "tabs": rows, "activeTabId": active, "lastAgentUpdate": s.last_agent_update_ms }))+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "count": rows.len(), "tabs": rows, "activeTabId": active, "lastAgentUpdate": s.last_agent_update() })) }  pub fn switch_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {@@ -64,5 +64,5 @@ pub fn close_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { pub fn switch_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };     ctx.sessions.set_active(&s.id);-    pup_core::merge(ok(session_row(ctx, &s)), json!({ "activeTabId": s.active().map(|t| t.tab_id.clone()), "background": if s.foreground { "foreground" } else { "backgrounded" }, "ownerSource": "session", "emulatedViewport": Value::Null, "errorCount": 0, "lastAgentUpdate": s.last_agent_update_ms, "_hint": "This window is now the active one for verbs that default to it. It was NOT raised." }))+    pup_core::merge(ok(session_row(ctx, &s)), json!({ "activeTabId": s.active().map(|t| t.tab_id.clone()), "background": if s.foreground { "foreground" } else { "backgrounded" }, "ownerSource": "session", "emulatedViewport": Value::Null, "errorCount": 0, "lastAgentUpdate": s.last_agent_update(), "_hint": "This window is now the active one for verbs that default to it. It was NOT raised." })) }
rust/verbs/src/window.rs+1−1
@@ -20,7 +20,7 @@ pub fn raise(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };     let reason = args.get("foregroundReason").and_then(|r| r.as_str()).map(|r| r.trim()).filter(|r| r.len() >= 8);     let Some(reason) = reason else {-        return pup_core::merge(err("foreground_reason_required", "Raise refused: foregroundReason is required.", Some("pup windows stay in the BACKGROUND by default. Pass foregroundReason:\"<why the user asked to see this window>\"; it is captioned on their screen. For a nudge without stealing focus use pup_alert_window.")), json!({ "window": s.id, "sessionId": s.id }));+        return pup_core::merge(pup_core::merge(err("foreground_reason_required", "Raise refused: foregroundReason is required.", Some("pup windows stay in the BACKGROUND by default. Pass foregroundReason:\"<why the user asked to see this window>\"; it is captioned on their screen. For a nudge without stealing focus use pup_alert_window.")), json!({ "window": s.id, "sessionId": s.id })), json!({ "ok": false, "output": "" }));     };     let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", Some("pup_rescan {} re-resolves handles; if the window exists it will be tagged again.")) };     if ctx.host.foreground() == h {
tools/gen-assets.py+9
@@ -33,3 +33,12 @@ for name, p in items: out.append(f'    ("{name}", include_bytes!("{rel(p, "dashb out.append('];'); out.append(''); out.append('pub fn get(name: &str) -> Option<&\'static [u8]> { FILES.iter().find(|(n, _)| *n == name).map(|(_, b)| *b) }') open(os.path.join(root, 'rust', 'dashboard', 'src', 'assets_gen.rs'), 'w').write("\n".join(out) + "\n") print(f"ext_assets.rs: {len(dirs)} folders; assets_gen.rs: {len(items)} files")++# verb catalog: the describe entries (name/summary/input/timeoutSeconds/example/hint/related/pitfalls) recorded+# from the Node build's pup_describe (docs/contract/pup_describe.json), minus the desktop_ aliases dropped in+# 2.0.400. The Rust dispatcher stamps _hint/related/_next/pitfalls from it (Node: VERB_META in sendJSON).+import json+_g = json.load(open(os.path.join(root, 'docs', 'contract', 'pup_describe.json'))); _o = json.loads(_g['output'])+_verbs = [v for v in _o['verbs'] if not v['name'].startswith('desktop_')]+json.dump({'progressContract': _o['progressContract'], 'verbs': _verbs}, open(os.path.join(root, 'rust', 'verbs', 'assets', 'verb-catalog.json'), 'w'), indent=1)+print(f"verb-catalog.json: {len(_verbs)} verbs")
tools/parity.pyadded+157
@@ -0,0 +1,157 @@+#!/usr/bin/env python3+"""Golden-fixture parity gate for the Rust pup exe (Phase 4 of docs/PLAN-rust-port.md).++Runs the SAME verb sequence the Node 2.0.399 fixtures in docs/contract/ were recorded from, against+the Rust exe on a desktop (hand-launched on a spare port, its own profile), pulls every response back,+and diffs each one against its golden: top-level keys missing/extra, JSON-type drift on shared keys,+and the same again inside the `output` string when it is JSON. `_hint`/`_next` text differences are+not shape drift and are ignored; a MISSING `_hint` is reported.++  tools/parity.py --target winvm [--port 8867] [--exe-dir C:/Users/azureuser/pup-rs] [--profile rs-smoke]+  tools/parity.py --compare <responses.json>     # re-diff a pulled file without re-running++Exit 0 = every fixture matches (no missing keys, no type drift); 1 = drift; 2 = harness failure.+"""+import argparse, json, os, subprocess, sys, tempfile+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))+GOLD = os.path.join(ROOT, 'docs', 'contract')+W = 'contract-fx'+# (fixture name, verb, args) in the order the goldens were recorded. Window "contract-fx", example.com.+SEQUENCE = [+    ('pup_readiness', 'pup_readiness', {}),+    ('pup_status', 'pup_status', {}),+    ('pup_describe', 'pup_describe', {}),+    ('pup_open_window', 'pup_open_window', {'window': W, 'url': 'https://example.com/', 'profile': '__PROFILE__'}),+    ('pup_list_windows', 'pup_list_windows', {}),+    ('pup_my_windows', 'pup_my_windows', {}),+    ('pup_window_info', 'pup_window_info', {'window': W}),+    ('pup_navigate', 'pup_navigate', {'window': W, 'url': 'https://example.com/?nav=1'}),+    ('pup_eval', 'pup_eval', {'window': W, 'expr': 'document.title'}),+    ('pup_screenshot', 'pup_screenshot', {'window': W}),+    ('pup_errors', 'pup_errors', {'window': W}),+    ('pup_open_tab', 'pup_open_tab', {'window': W, 'url': 'https://example.com/?tab2'}),+    ('pup_list_tabs', 'pup_list_tabs', {'window': W}),+    ('pup_switch_tab', 'pup_switch_tab', {'window': W, 'tabId': 'tab-1'}),+    ('pup_close_tab', 'pup_close_tab', {'window': W, 'tabId': 'tab-2'}),+    ('pup_click', 'pup_click', {'window': W, 'selector': 'a'}),+    ('pup_hover', 'pup_hover', {'window': W, 'selector': 'a'}),+    ('pup_wait', 'pup_wait', {'window': W, 'ms': 300}),+    ('pup_type', 'pup_type', {'window': W, 'text': 'x'}),+    ('pup_fill', 'pup_fill', {'window': W, 'selector': 'body', 'text': 'x'}),+    ('pup_press_key', 'pup_press_key', {'window': W, 'key': 'Escape'}),+    ('pup_scroll', 'pup_scroll', {'window': W, 'direction': 'down'}),+    ('pup_reload', 'pup_reload', {'window': W}),+    ('pup_back', 'pup_back', {'window': W}),+    ('pup_set_viewport', 'pup_set_viewport', {'window': W, 'width': 1000, 'height': 700}),+    ('pup_set_viewport_reset', 'pup_set_viewport', {'window': W, 'reset': True}),+    ('pup_render_html', 'pup_render_html', {'window': W, 'html': '<h1>hi</h1>'}),+    ('pup_fetch_url', 'pup_fetch_url', {'window': W, 'url': 'https://example.com/'}),+    ('pup_maximize', 'pup_maximize', {'window': W}),+    ('pup_raise_os_window_refused', 'pup_raise_os_window', {'window': W}),+    ('pup_lower_os_window', 'pup_lower_os_window', {'window': W}),+    ('pup_alert_window', 'pup_alert_window', {'window': W}),+    ('pup_configure', 'pup_configure', {'overlaySize': '75'}),+    ('pup_rescan', 'pup_rescan', {}),+    ('pup_use', 'pup_use', {}),+    ('pup_switch_window', 'pup_switch_window', {'window': W}),+    ('pup_close_window', 'pup_close_window', {'window': W}),+]+IGNORE_TEXT = {'_hint', '_next', 'pitfalls', 'related', '_timeoutHint', 'durationMs'}+# Keys the ab CLI / relay stamps on the way back (the goldens were captured through the CLI), so the bridge itself never emits them.+CLI_ADDED = {'_timeoutHint', 'timeoutSeconds', 'savedTo', 'statusVerb', '_reportIssues', '_addressing'}++def ps_script(port, exe_dir, profile, out_path):+    steps = json.dumps([[n, v, json.loads(json.dumps(a).replace('__PROFILE__', profile))] for n, v, a in SEQUENCE])+    return f"""$ErrorActionPreference = 'Continue'+$dir = '{exe_dir}'+Get-Process adom-pup-bridge -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue+$p = Start-Process -FilePath "$dir\\adom-pup-bridge.exe" -ArgumentList '--port','{port}' -WorkingDirectory $dir -RedirectStandardOutput "$dir\\parity.log" -RedirectStandardError "$dir\\parity.err" -WindowStyle Hidden -PassThru+Start-Sleep -Seconds 3+$thread = 'pup-bridge'+function Post($verb, $a) {{ $body = @{{ command = $verb; args = $a }} | ConvertTo-Json -Compress -Depth 8; try {{ $r = Invoke-WebRequest -Uri 'http://127.0.0.1:{port}/command' -Method Post -ContentType 'application/json' -Headers @{{ 'X-Adom-Caller-Thread' = $thread }} -Body $body -TimeoutSec 120 -UseBasicParsing; return $r.Content }} catch {{ $resp = $_.Exception.Response; if ($resp) {{ $sr = New-Object IO.StreamReader($resp.GetResponseStream()); return $sr.ReadToEnd() }} else {{ return ('{{"_harness_error":' + (ConvertTo-Json $_.Exception.Message) + '}}') }} }} }}+$steps = ConvertFrom-Json @'+{steps}+'@+$out = [ordered]@{{}}+foreach ($s in $steps) {{ $n = $s[0]; $v = $s[1]; $a = @{{}}; if ($s[2]) {{ $s[2].PSObject.Properties | ForEach-Object {{ $a[$_.Name] = $_.Value }} }}+  $raw = Post $v $a; try {{ $out[$n] = ($raw | ConvertFrom-Json) }} catch {{ $out[$n] = @{{ _harness_error = 'non-json'; raw = $raw }} }}+  Write-Output ("{{0,-30}} {{1}}" -f $n, $raw.Substring(0, [Math]::Min(90, $raw.Length)))+  if ($n -eq 'pup_open_window' -or $n -eq 'pup_open_tab' -or $n -eq 'pup_navigate' -or $n -eq 'pup_click') {{ Start-Sleep -Seconds 2 }} }}+[IO.File]::WriteAllText('{out_path}', ($out | ConvertTo-Json -Depth 12 -Compress))+Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue+Write-Output "exe stopped"+"""++def jtype(v):+    if v is None: return 'null'+    if isinstance(v, bool): return 'bool'+    if isinstance(v, (int, float)): return 'number'+    if isinstance(v, str): return 'string'+    if isinstance(v, list): return 'array'+    return 'object'++def diff_obj(g, r, where):+    out = []+    for k in g:+        if k in CLI_ADDED and k not in r: continue+        if k == 'error' and g[k] == '' and k not in r: continue   # CLI normalises error:"" on success+        if k not in r: out.append(f'{where}MISSING {k}'); continue+        if k in IGNORE_TEXT: continue+        tg, tr = jtype(g[k]), jtype(r[k])+        if tg != tr and 'null' not in (tg, tr): out.append(f'{where}TYPE {k}: node={tg} rust={tr}')+    for k in r:+        if k not in g: out.append(f'{where}extra {k}')+    return out++def compare(responses):+    drift = 0; extras = 0+    for n, _, _ in SEQUENCE:+        gp = os.path.join(GOLD, n + '.json')+        if not os.path.exists(gp): print(f'{n:30} (no golden)'); continue+        g = json.load(open(gp)); r = responses.get(n)+        if not isinstance(r, dict): print(f'{n:30} NO RESPONSE'); drift += 1; continue+        if '_harness_error' in r: print(f'{n:30} HARNESS {r}'); drift += 1; continue+        gerr = bool(g.get('ok') is False or g.get('errorCode') or (isinstance(g.get('error'), str) and g.get('error')))+        rerr = bool(r.get('ok') is False or r.get('errorCode') or r.get('success') is False)+        if gerr != rerr: print(f"{n:30} SCENARIO  golden was {'an error' if gerr else 'a success'} ({g.get('errorCode') or g.get('error') or ''}), rust was {'an error' if rerr else 'a success'} ({r.get('errorCode') or ''}): shapes not comparable"); continue+        d = diff_obj(g, r, '')+        go, ro = g.get('output'), r.get('output')+        if isinstance(go, str) and go.startswith('{') and '_timeoutHint' in go and 'output' not in r: d = [x for x in d if x != 'MISSING output']   # the CLI mirrored the envelope into output+        if isinstance(go, str) and go.startswith('{'):+            try:+                gj = json.loads(go); rj = json.loads(ro) if isinstance(ro, str) and ro.startswith('{') else None+                if '_timeoutHint' in gj: pass                      # the CLI mirrored the whole envelope into output: not the bridge's shape+                elif rj is None: d.append('output: node is JSON, rust is not')+                else: d += diff_obj(gj, rj, 'output.')+            except Exception as e: d.append(f'output parse: {e}')+        bad = [x for x in d if not x.startswith('extra') and not x.startswith('output.extra')]+        ext = [x for x in d if x not in bad]+        extras += len(ext)+        if bad: drift += 1; print(f'{n:30} DRIFT  ' + '; '.join(bad) + (('  [+' + str(len(ext)) + ' extra]') if ext else ''))+        else: print(f'{n:30} ok' + (('     [+' + str(len(ext)) + ' extra: ' + ', '.join(x.split()[-1] for x in ext)[:120] + ']') if ext else ''))+    print(f'\n{len(SEQUENCE) - drift}/{len(SEQUENCE)} fixtures match; {extras} extra keys in rust responses (allowed)')+    return 0 if drift == 0 else 1++def ab(target, verb, args):+    p = subprocess.run(['adom-bridge', '--target', target, '--ai-thread', 'pup-bridge parity', verb, json.dumps(args)], capture_output=True, text=True, timeout=600)+    try: return json.loads(p.stdout)+    except Exception: return {'error': p.stdout[-400:] + p.stderr[-400:]}++def main():+    ap = argparse.ArgumentParser(); ap.add_argument('--target'); ap.add_argument('--port', default='8867'); ap.add_argument('--exe-dir', default='C:/Users/azureuser/pup-rs'); ap.add_argument('--profile', default='rs-smoke'); ap.add_argument('--compare')+    a = ap.parse_args()+    if a.compare: sys.exit(compare(json.load(open(a.compare))))+    if not a.target: ap.error('--target or --compare')+    exe_dir = a.exe_dir.replace('/', '\\'); out = a.exe_dir.rstrip('/') + '/parity-responses.json'+    script = ps_script(a.port, exe_dir, a.profile, out.replace('/', '\\'))+    r = ab(a.target, 'desktop_write_file', {'path': a.exe_dir.rstrip('/') + '/parity.ps1', 'content': script, 'reason': 'golden-fixture parity run for the Rust pup exe'})+    if r.get('status') != 'ok': print('write failed', r); sys.exit(2)+    r = ab(a.target, 'shell_execute', {'command': f'powershell -NoProfile -ExecutionPolicy Bypass -File {a.exe_dir.rstrip("/")}/parity.ps1', 'timeoutSeconds': 500, 'reason': 'run the parity sequence against the Rust exe'})+    print(r.get('stdout') or r.get('output') or r)+    r = ab(a.target, 'pull_file', {'filePaths': [out]})+    paths = [f.get('path') for f in (r.get('files') or []) if isinstance(f, dict) and f.get('path')]+    if not paths: print('pull failed', json.dumps(r)[:600]); sys.exit(2)+    local = paths[0]; print('responses:', local)+    sys.exit(compare(json.load(open(local))))++if __name__ == '__main__': main()