@evan_zhong/browser-attach 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ Drive the Chrome / Edge tabs you already have open and already signed in from an
15
15
  ## What it does
16
16
 
17
17
  - Discover multiple browser profiles, pick one precisely, claim a tab exclusively, and release it when done.
18
- - Read the accessibility tree, field values and supported editor models; click, replace-fill and type character by character through current snapshot refs or strict CSS/XPath locators.
18
+ - Read the accessibility tree, query it by literal name/value, inspect field values and supported editor models; click, replace-fill and type character by character through current snapshot refs or strict CSS/XPath locators.
19
19
  - Native pointer hover, stepped drags and conditional waits; select HTML options without opening a native popup.
20
20
  - Navigate and go back/forward, open or close tabs, handle JavaScript dialogs, upload local files, and capture the whole page or a single element.
21
21
  - Evaluate JavaScript expressions in the main page; read console, page-error and network lifecycle logs captured after claiming; opt in to bounded, redacted request/response details when XHR/fetch needs verification.
@@ -90,7 +90,7 @@ Claiming and ordinary input never activate a tab or window; call `browser_tab_co
90
90
  | `browser_claim_tab` | Exclusively claim a discovered tab |
91
91
  | `browser_open_tab` / `browser_tab_control` | Create a tab; explicit `release/activate/close` of a claimed tab |
92
92
  | `browser_fetch` | Read one HTTP(S) URL in a temporary background tab using the profile's sign-in; `text`/`html`/`json`, optional HTTP status and cursor continuation |
93
- | `browser_snapshot` / `browser_read_text` | AX snapshot, element refs and page text |
93
+ | `browser_snapshot` / `browser_read_text` | AX snapshot, literal name/value query with actionable refs, and page text |
94
94
  | `browser_wait` | Read-only wait for an element, text, URL or network idle |
95
95
  | `browser_click` / `browser_hover` / `browser_drag` / `browser_select_option` / `browser_type` / `browser_fill` / `browser_press_key` / `browser_scroll` | Native/DOM input, pointer hover and drag, option selection, character typing and scrolling |
96
96
  | `browser_batch` | Run 1–20 click/fill/type/press_key/hover/scroll/select_option/wait steps in one queue; stops on the first failure and returns partial results |
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
  ## 能做什么
16
16
 
17
17
  - 发现多个浏览器配置,精确选择并独占接管标签页,完成后释放。
18
- - 读取可访问性树、字段值和支持的编辑器模型,通过当前快照引用或严格 CSS/XPath 定位点击、替换填写和逐字输入。
18
+ - 读取可访问性树,按字面名称或 value 查询并取得可操作引用,检查字段值和支持的编辑器模型,通过当前快照引用或严格 CSS/XPath 定位点击、替换填写和逐字输入。
19
19
  - 原生指针悬停、分步拖拽、条件等待;无需打开系统弹层即可选择 HTML 下拉选项。
20
20
  - 导航与历史前进/后退、打开或关闭标签页,处理 JavaScript 对话框,上传本机文件并截取整页或单个元素。
21
21
  - 在主页面执行 JavaScript 表达式,读取接管后的控制台、页面异常和网络生命周期日志;需要校验 XHR/fetch 时显式开启有界、脱敏的请求/响应详情。
@@ -90,7 +90,7 @@ Windows 客户端若要求完整命令扩展名,使用 `npx.cmd`。已全局
90
90
  | `browser_claim_tab` | 独占接管已发现的标签 |
91
91
  | `browser_open_tab` / `browser_tab_control` | 创建标签;显式 `release/activate/close` 释放控制、激活或关闭已接管的标签 |
92
92
  | `browser_fetch` | 在临时后台标签中复用登录态读取一个 HTTP(S) URL;支持 `text`/`html`/`json`、可选 HTTP 状态和游标续读 |
93
- | `browser_snapshot` / `browser_read_text` | AX 快照、元素引用和正文 |
93
+ | `browser_snapshot` / `browser_read_text` | AX 快照、按字面名称/value 查询及可操作引用、正文 |
94
94
  | `browser_wait` | 只读等待元素、文本、URL 或网络空闲 |
95
95
  | `browser_click` / `browser_hover` / `browser_drag` / `browser_select_option` / `browser_type` / `browser_fill` / `browser_press_key` / `browser_scroll` | 原生/DOM 输入、指针悬停与拖拽、下拉选择、逐字输入与滚动 |
96
96
  | `browser_batch` | 单队列顺序执行 1–20 步 click/fill/type/press_key/hover/scroll/select_option/wait,失败即停并返回部分结果 |
@@ -1,6 +1,6 @@
1
1
  # 架构与设计边界
2
2
 
3
- 当前实现为 **0.15.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
3
+ 当前实现为 **0.16.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
4
4
 
5
5
  ## 组件与数据流
6
6
 
@@ -26,6 +26,7 @@ MCP 客户端
26
26
  | `extension/{click,input,actionability,scroll}.js` | 原生/DOM 输入、逐字按键、可操作性、容器滚动与有界查找 |
27
27
  | `extension/{drag,wait}.js` | 复用鼠标准备和严格定位的拖拽、只读条件等待;网络空闲复用被动请求表 |
28
28
  | `extension/batch.js` | 有限输入序列的参数预检;执行复用 controller 的单步流程与标签队列 |
29
+ | `extension/motion.js` | 无状态的可选落点/延迟/拖拽参数校验、区间采样及拖拽插值;不维护动作历史或改变焦点 |
29
30
  | `extension/outcomes.js` | 临时 DOM 变动观察器与有界只读探测,不推断业务成功 |
30
31
  | `extension/fetch.js` | 单页读取的参数、隔离世界采集与每 owner 有界缓存分页;临时页生命周期复用 controller 的 attach、导航和标签队列 |
31
32
  | `extension/{snapshot,navigation,evaluate,diagnostics}.js` | 观察、导航、有界求值结果缓存与分页、日志缓冲 |
@@ -67,6 +68,7 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
67
68
 
68
69
  - **AX 渲染**:从 `Accessibility.getFullAXTree` 读取,移除 `InlineTextBox`,合并纯文本列表项与段落名称,丢弃 `generic` / `none` 且无名称无状态的节点,只保留少量状态属性和浏览器提供的 value、invalid。每行 `[token:n] role "name"`,`token` 是本次观察的随机前缀;这些字段不证明应用已保存。
69
70
  - **交互视图**:可选 `profile=interactive` 在同一渲染器中仅保留 AX 控件角色、可聚焦/可编辑节点及其祖先链和控件内文本,不以 backendNodeId/ref 是否存在判断交互性。独立文本和合并正文可隐藏或截取预览,控件名称、值和 ref 的原始 AX 身份保留;默认 `full` 行为不变。筛选仍读取完整 AX 树,只降低输出与分页成本;视图参数绑定分页观察。
71
+ - **文本查询与精简输出**:`snapshot(query=...)` 复用同一次 AX 渲染,按当前视图中节点的完整名称(含合并正文)或 value 做不区分大小写的字面子串筛选,只输出匹配行和对应 frame 标题;不搜索 role/ref 或任意 DOM 属性,不新增索引缓存。新查询仍采集新观察并使旧 refs 失效,query 与分页绑定。默认省略 viewport、snapshot_id 和重复规则说明,`verbose=true` 返回前两项;frame 覆盖、视图、预算和游标语义保留。
70
72
  - **ref 解析**:ref 保存 `backendNodeId`、所属 frame/session 和观察时的 AX role/name;动作时重新 `DOM.resolveNode` 并用局部 AX 查询核对语义身份。输入与求值不清空 refs,只使快照分页失效;原节点仍连接时可复用,节点替换、role/name 改变、文档或 frame 变化时安全失败,不按 role/name 猜测替代节点。
71
73
  - **双预算**:`max_nodes` 限制节点数,`max_chars` 限制每行序列化后的累计字符数;两者独立,任一耗尽都会返回 `next_cursor`。
72
74
  - **续读**:续读沿用同一份 `state`(`entries`、`refs`、`nextOffset`),所以 refs 在分页过程中保持有效。单个节点放不进预算时直接报错要求提高预算,避免静默截断尾部。
@@ -90,9 +92,11 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
90
92
  - **焦点信号边界**:OS 窗口失焦、遮挡或 document.visibilityState=hidden 不一定阻止 CDP 输入;document.hasFocus() 在 iframe 导航后也可能为 false。这些值作为 page_state 诊断,不独立拒绝点击。实际送达仍由隔离世界的可信事件回执校验;没有回执时不自动重放。
91
93
  - **送达预算**:原生 click/hover/press_key 的派发及可信回执另有 1250 ms 上限,清理最多 250 ms;系统菜单捕获输入时快速释放队列,仍报告结果未知并提示 Esc,不自动重放。
92
94
  - **hover 与 drag**:hover 复用点击准备与隔离校验,仅派发一次 mouseMoved,清理不移走指针。drag 显式区分 pointer 与 HTML5;后者从真实 dragstart 截获 DragData,再用 CDP 派发 dragover/drop,校验可信 dragstart/drop/dragend。目的节点在按住后滚入视口;所有步骤共用标签队列和总预算,失败时只取消/释放本次手势。
95
+ - **可选输入参数**:显式 offset 在元素 content quad 上插值后沿用命中与 OOPIF 边界检查;settle_ms 在 click/drag 首次派发前等待并复查就绪。drag trajectory 只替换既有插值段,采用法向控制点、平滑进度及随机间隔权重;type 的 delay_ms 可逐间隔独立区间采样。所有等待共用总预算,超时后没有继续派发的链;省略参数时不使用随机数,沿用既有确定性路径。不新增 tab 级历史、冷却、自动激活或重放路径。
93
96
  - **条件等待**:wait 复用严格 selector、可操作性和命中检查,支持出现、可见、可操作、文本/URL 子串及网络空闲;只读轮询,错误附带条件和最后状态。
94
97
  - **原生下拉**:select_option 直接用 selectedIndex setter 和合成 input/change 设置一个 HTML 选项,核验保留结果并返回 events_trusted=false。native click 的 select 命中(含 UA shadow 内部节点和关联 label)在派发前拒绝。
95
98
  - **命中提示**:普通命中保留实际 hit node,沿父元素、Shadow host、slot 查找最近的 HTML/ARIA 可点击候选,返回 clickable_target;hint=false 表示未识别候选,不代表无法响应事件。CSSPseudoElement 先解析为事件所属的 originating element,提示标记 pseudo_element=true,并使用该元素安装事件校验。语义候选不会被用于替换点击目标。
99
+ - **遮挡失败诊断**:当前 frame 的遮挡直接复用已读取的 hit_target;祖先 frame 遮挡额外用至多 200 ms 读取命中描述,诊断节点消失时保留原遮挡错误与 backend node ID。错误包含命中点和受阻 frame。根据共享预算的派发状态区分“未派发”和“结果未知”,避免 drag 按住后准备目的节点时误报零输入。
96
100
  - **送达不等于业务完成**:`target_verified` / `delivery_verified` / `value_verified` 分别证明可信事件命中、keydown 送达、字段文本写入;点击、按键、fill 和 batch 返回 `application_result: "not_checked"`。fill 不证明 React 状态、选项提交、blur 校验或保存完成。
97
101
  - **有限多步输入**:batch 在现有标签队列内执行 1–20 步 click/fill/type/press_key/hover/scroll/select_option/wait,共享至多 30 秒预算,每步可另设更短超时;不增加后台任务、循环或脚本引擎;batch 本身只接受每步重新解析的 selector。所有参数先验证,selector 每步重解析,尚未出现的目标只在准备阶段等待;歧义立即报错。首个失败、主文档变化或接管结束后停止,返回此前结果与失败索引;不回滚、不重放、不自动激活或切换输入模式。start_at 显式跳过已完成前缀,返回 failed_step、next_unattempted_step、retry_failed_step_safe;没有后台作业或自动选择续跑点。
98
102
  - **OOPIF 坐标**:点击从顶层派发。逐层用 `DOM.getBoxModel` 把坐标换算到祖先文档,并用 `DOM.getNodeForLocation` 确认该位置确实落在 frame owner 上,防止祖先页面的遮挡被跳过。
@@ -0,0 +1,27 @@
1
+ # 0.15.1 工具效率与设计取舍
2
+
3
+ 本次对照 `src/mcp.mjs`、`src/hub.mjs`、扩展实现与现有测试核查九项建议。结论是:返回内容和失败诊断存在可直接改进之处;等待、显式 frame、接管和输入结果边界主要是有意的可靠性策略。不能单凭一次调用的字符数或延迟判定策略有缺陷。题述 BA/PW 对照未在同一页面、版本和序列化口径下重跑,以下不把这些数值当作本次验收结果。
4
+
5
+ ## 逐项判断
6
+
7
+ | 建议 | 当前实现与性质 | 通用性、复杂度及本次选择 |
8
+ |---|---|---|
9
+ | 1. 默认精简、interactive、delta | full 同时服务阅读与交互,是覆盖面策略;每页重复规则和固定诊断元数据则是输出冗余。input/evaluate 使 cursor 失效,但仍连接且语义未变的 refs 可复用,并非每次输入都必须重新 snapshot。 | 精简通用且成本低,已移除重复说明,viewport/snapshot_id 改为 verbose 可选。保留 full 默认与结果验证字段。interactive 已有,按任务选择。delta 需处理新旧观察、删除/替换、祖先上下文和子 frame 失效,复杂度高,本次不引入。 |
10
+ | 2. AX 索引查询 | 当前只能把树传给客户端后查找,确有返回量缺口;read_text 不能替代可操作 ref。 | 已在现有 snapshot 加 query,复用渲染、ref、frame 和分页,工具仍为 23 个。查询新采集 AX,不引入可能过期的第二份索引缓存。仅支持字面子串;正则及独立 find 工具暂不增加。能省返回内容和一次往返,不能保证 AX 采集变快。 |
11
+ | 3. 日志摘要与明细 | 当前已有 after_seq、limit、max_chars、level、url_contains 与有界缓冲;默认不保存 headers/body,必须显式启用 capture。网络输出是 request/redirect/response/finished 等事件,并非一条完整请求对象。 | 请求摘要具有通用价值,但正确合并需要处理 session/request_id/hop、未完成请求、分页与淘汰;单次投影也不能自然变成完整请求的耗时列表。seq 明细可复用 logs 扩展,无须先增加工具。默认隐藏静态资源会漏掉 CSS/脚本故障,本次保留,先用现有过滤与预算。 |
12
+ | 4. 零命中立即失败、诊断原因 | resolveSelector 的零命中等待是支持异步渲染和 batch 前一步创建后一步目标的明确策略;多命中和语法错误已立即失败。deadline 已保留最后的 selector/可见性/尺寸等状态。遮挡分支却只给泛化错误,属于诊断不足。 | 已补实际 hit_target、点和受阻 frame,不增加准备重试或动作重放。普通点击明确未派发;drag 已按住后的目的节点准备失败仍报告结果未知。保留零命中等待,紧预算可用 timeout_ms(最小 100 ms),只读查询零命中不等待出现。 |
13
+ | 5. deep-css、自动跨 frame | 当前 CSS/XPath 明确只解析指定文档;ref 可操作 AX 暴露的 Shadow 节点;evaluate(result_type=element) 可显式选取 open shadow 元素,frame URL/title/path 已可逐次解析。属于范围和消歧策略。 | 显式 deep-css 对组件站点可能有用,但要定义复合选择器、遍历顺序、nth 和多 root 歧义,成本中等。跨 frame 再涉及 OOPIF、重复控件、未加载/分离文档和多层命中校验,成本更高。本次保留显式 frame 与现有 ref/element 路径。 |
14
+ | 6. batch 结果传递 | 当前 batch 是预检后的有限输入序列;没有 evaluate 或 drag 步骤,element 步骤只用 selector。因此单加占位符不能实现“evaluate 测量 → drag”。这是有限编排的边界,也确有限制。 | 动态数据流需增加结果生产步骤、类型/路径检查、替换后的再校验,以及部分完成的恢复规则,复杂度中高。原生输入仍不能由页面 JS 调用。常见“定位 → 输入”已可用 selector 或 evaluate 返回元素 ref;坐标测量保留独立检查点,本次不扩展 batch。 |
15
+ | 7. tabs 一站式 | MCP 已把浏览器/标签发现合并为 discover,把 release/activate/close 合并为 tab_control。发现后按本连接观察的 tab_id 独占 claim 是授权模型,激活则是另一个显式意图。 | 客户端可保存 profile 与连接内句柄减少重复 setup。再增加 tabs 聚合入口主要重复现有分支;若自动选标签或激活,反而改变边界。本次不改。工具入口数量与必须经过的授权步骤不能简单等同。 |
16
+ | 8. 渐进披露 schema | 描述已在 0.13.1 分层精简;本次修改前实际 tools/list 紧凑 JSON 为 36701 字符、36703 UTF-8 字节,格式化 JSON 为 75945 字节,正好对应题述约 75.9 KB,23 个工具。两种序列化是同一 schema,不意味着宿主实际省掉一半 token。是否每轮重新计入、是否缓存,取决于 MCP 宿主,仓库不能保证。 | 工具分组对更大工具集有价值,但会新增启动配置、发现差异和支持组合。当前不加 caps 开关;query 复用已有工具,新增少量 schema 描述以保持自描述。包内文档链接不能替代模型调用所需的关键约束。 |
17
+ | 9. 可回放执行记录 | 当前不持久化操作轨迹;batch 调用输入本身就是可保存的步骤 JSON,输出已有部分完成、failed_step/start_at 等恢复信息。缺少长期审计是能力缺口,不是当前执行可靠性缺陷。 | 审计记录与可重新执行的计划应区分:已送达的动作不能因“可回放”自动重发,ref 也不能跨观察/连接复用。默认回传完整 steps 又与节省输出冲突,持久化还会包含表单数据。需要明确的保存/导出需求后再做显式功能,本次不新增存储或回放引擎。 |
18
+
19
+ ## 实际改动
20
+
21
+ - `extension/snapshot.js`:query 在当前视图的完整 AX 名称(包括合并正文)或 value 上做大小写不敏感的字面子串匹配,只为匹配行生成 refs。复用原预算和游标;query 绑定观察,零命中返回空结果。
22
+ - `extension/controller.js`:查询仅输出有匹配行的 frame 标题;仍报告不可读或失效的 frame。每个新查询沿用新快照的采集与 ref 生命周期,续页不重复采集。
23
+ - 快照默认不再输出 viewport、snapshot_id 和重复说明;verbose 可恢复前两项。保留 profile/text/text_limit、frame 覆盖与分页字段。click/evaluate 的事件送达、分页或捕获损失等语义字段继续保留。
24
+ - `extension/click.js`:当前 frame 直接复用已有命中诊断;祖先 frame 最多额外读 200 ms,失败时保留原遮挡错误。读取预算已派发标记,防止复用准备逻辑的 drag 被误标为“无输入”。
25
+ - 版本按本次明确要求设为 **0.15.1**。没有新增工具、运行依赖、扩展权限、用户 UI、自动重放或独立状态管理层。
26
+
27
+ 使用示例和边界见 [使用指南](USAGE.md),验证结果见 [验证报告](TEST-REPORT.md)。
@@ -1,9 +1,26 @@
1
1
  # 验证报告
2
2
 
3
- 当前版本 **0.15.0**(2026-10-22)。验证使用本地 fixture 和临时 profile,链路为扩展 → 本机 Hub → stdio MCP。
3
+ 当前版本 **0.16.0**(2026-10-22)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
4
4
 
5
5
  ## 当前版本
6
6
 
7
+ - `npm test`:184 项通过。新增 offset 的变换/越界处理、延迟区间校验、默认路径不采样随机数、拖拽曲线与精确终点、batch 参数预检、真实 stdio MCP 参数转发,以及 settle/拖拽超时后停止和调试分离取消待点击的回归。
8
+ - Chromium 156 与 Edge 155 headless 输入集成:各 19 个场景通过(扩展 → 独立 Hub 客户端)。新增非中心可信点击、offset hover、settle、区间逐字输入、曲线 pointer 与 HTML5 拖拽;实际记录移动坐标、可信事件和按键时间,保留原有默认路径及跨屏拖拽验证。不据此声称能通过任意网站的行为验证。
9
+ - Chromium 156 完整 stdio MCP 集成:244 次调用通过,保留发现/接管、输入、导航、OOPIF、日志、分页及临时页清理覆盖。
10
+ - `npm run test:package`:仓库外安装运行通过,23 个工具、48 个发布文件,版本 0.16.0;无新增依赖或扩展权限,不包含本机配对密钥。
11
+ - 本机部署:Hub 已从工作区重启,健康接口返回 0.16.0;扩展同步至 `F:\.nosis\browser-attach\extension`,29 个源码/资源文件哈希一致,manifest 版本为 0.16.0。Chrome 与 Edge 均已重新连接并上报 0.16.0;MCP 客户端需刷新连接以获取新 schema。
12
+
13
+ ## 0.15.1 验证
14
+
15
+ - `npm test`:176 项通过。新增 AX 字面查询(名称/合并正文/value、预览前匹配、interactive 视图、零命中)、查询分页与新旧 ref 生命周期、默认精简/verbose 元数据、当前/祖先 frame 的遮挡诊断、诊断节点失效保留主错误,以及 drag 已派发预算不能误报“未派发”的回归。
16
+ - Chromium 156 与 Edge 155 headless 完整 stdio MCP 集成:各 244 次调用通过。新增真实 AX 查询返回的 ref 可用于原生 hover、查询分页和 verbose 续页、零命中及 ancestor frame 的实际遮挡文字;保留输入、导航、OOPIF、日志、分页和临时页关闭链路。
17
+ - 同一构造表单 AX(2 个字段、2 个按钮,固定 URL/ref 前缀)比较 JSON 字符数:0.15.0 默认 908,0.15.1 默认 443(减少 51.2%),query=save 为 332。这里只衡量固定 fixture 的序列化输出,不代表题述表单、token 数或 AX 采集耗时。
18
+ - 实际 MCP tools/list:仍为 23 个工具;紧凑 JSON 从 36703 增至 37116 UTF-8 字节(新增 query/verbose 参数及说明,约 +1.1%),格式化 JSON 从 75945 增至 76502 字节。未声称 schema 总量下降;不新增工具分组、依赖或扩展权限。
19
+ - `npm run test:package`:仓库外安装运行通过,23 个工具、47 个发布文件,版本为 0.15.1;发布包不含本机配对密钥。
20
+ - 本机部署:已把扩展同步至 `F:\.nosis\browser-attach\extension`,关键模块与源码一致,Hub 健康接口返回 0.15.1。临时 Chromium 加载该实际副本连接共享 Hub,AX 查询 → 原生点击 → 查询确认页面变化通过。验证结束时用户原有浏览器尚未重新连接;仍需在 Chrome/Edge 重新加载扩展,并刷新 MCP 连接以获取新 schema。
21
+
22
+ ## 0.15.0 验证
23
+
7
24
  - `npm test`:172 项通过。新增 commit / DOMContentLoaded、等待超时后的降级采集、主文档替换后跟随 loader、采集期间只读重试、等待完成后换文档的身份核对、HTTP 错误响应关联及空体判断,以及标签创建/关闭超时和迟到清理;保留断连、控制权变化与不重放导航的回归覆盖。
8
25
  - Chromium 156 与 Edge 155 headless 完整 stdio MCP 集成:各 238 次调用通过。新增阻塞 load 时的早期等待及超时降级、load 前 location.replace、SSE 阻塞 network_idle,以及空体 418/500/503 的状态码和正文来源;同时验证既有输入、导航、OOPIF、分页与临时页关闭链路。
9
26
  - Chromium 临时配置访问真实 URL:`https://httpbin.org/status/500` 与 `/status/503` 分别在约 689 ms、384 ms 返回对应状态、`ok=false`、空正文及 `http_error_response` 标记。`https://chatgpt.com/` 在约 2253 ms 返回 403 验证页的文档结果,未复现客户端跳转,不将该结果视为登录应用验收。
package/docs/USAGE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # BrowserAttach 使用指南
2
2
 
3
- 适用于 **0.15.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md);内部实现与安全模型见 [架构说明](ARCHITECTURE.md)。
3
+ 适用于 **0.16.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md);内部实现与安全模型见 [架构说明](ARCHITECTURE.md)。
4
4
 
5
5
  工具发现采用三层说明:初始化 `instructions` 提供跨工具规则;工具 description 说明用途、独有行为与结果语义;参数 schema 及其短说明提供类型、默认值和选择规则。本文保留完整原理、边界和示例。客户端集成时应把 MCP 初始化响应中的 instructions 传入模型上下文;仅读取 tools/list 不会包含完整的通用调用规则。
6
6
 
@@ -35,7 +35,7 @@
35
35
 
36
36
  | 工具 | 主要参数 | 说明 |
37
37
  |---|---|---|
38
- | `browser_snapshot` | `profile`, `text`, `text_limit`, `max_nodes=600`, `max_chars=24000`, `cursor` | AX 语义树、元素 ref、frame 覆盖、待处理文件选择器 |
38
+ | `browser_snapshot` | `profile`, `text`, `text_limit`, `query`, `verbose=false`, `max_nodes=600`, `max_chars=24000`, `cursor` | AX 语义树、按名称/value 查询、元素 ref、frame 覆盖、待处理文件选择器 |
39
39
  | `browser_wait` | `condition`, `selector`, `expected` / `previous`, `idle_ms=500`, `timeout_ms=5000` | 只读等待元素、文本、URL 或被动网络空闲 |
40
40
  | `browser_fetch` | `browser_id`, `url`, `wait_until=load`, `format=text`, `max_chars=20000`, `cursor` | 后台临时页读取一次,关闭后按缓存续读 |
41
41
  | `browser_read_text` | `mode`, `selector`, `offset=0`, `max_chars=20000` | 主 frame 的 DOM、字段值或支持的编辑器模型 |
@@ -94,7 +94,7 @@ browser_fetch({browser_id, cursor: next_cursor, max_chars: 20000})
94
94
 
95
95
  ## 观察:快照与引用
96
96
 
97
- `browser_snapshot` 返回 `snapshot` 文本(每行 `[token:n] role "name" 状态`)、`snapshot_id`、`node_count`、`total_node_count`、`truncated`、`truncation_reason`(`node_limit` / `text_budget`)、`next_cursor`、`viewport`、`omitted_frames`,以及当前 `title` / `url`。
97
+ `browser_snapshot` 返回 `snapshot` 文本(每行 `[token:n] role "name" 状态`)、`node_count`、`total_node_count`、`truncated`、`truncation_reason`(`node_limit` / `text_budget`)、`next_cursor`、`omitted_frames`,以及当前 `title` / `url`;`verbose: true` 时额外返回 `viewport` 和 `snapshot_id`。
98
98
 
99
99
  - 渲染时移除 `InlineTextBox`,合并纯文本列表项与段落名称,保留 `disabled`、`focused`、`expanded`、`checked`、`selected`、`required`、`invalid`、`level` 等状态,以及浏览器提供的 `value`;`[token:n]` 即后续动作使用的 `ref`。未提供 invalid/selected 等属性不代表有效或已提交;value 也可能只是下拉框的过滤文本。
100
100
  - `max_nodes` 为 1–3000(默认 600);`max_chars` 为 1–2000000(默认 24000),按行序列化长度累计,与节点数是两个独立预算。
@@ -102,9 +102,19 @@ browser_fetch({browser_id, cursor: next_cursor, max_chars: 20000})
102
102
  - `text` 默认 `true`;`false` 隐去独立文本和纯文本段落/列表项的合并正文,仍保留可访问名称及控件值。`text_limit` 为 0–10000,限制每个文本/合并正文名称并以 `…` 标明省略;`interactive` 默认 80,`full` 默认不截断。需要更多正文时重新请求 `full`,或显式提高 `text_limit`。控件名称、值及 ref 校验所用身份始终完整。
103
103
  - 返回 `profile` / `text` / `text_limit` 表明本次视图;`node_count` 和 `total_node_count` 统计筛选后的节点。`truncated` / `next_cursor` 只表示该视图尚有分页,不表示视图包含全部网页正文。筛选发生在完整 AX 树读取后,减少返回内容与分页次数,不减少 CDP 的 AX 树采集量。
104
104
  - `truncated: true` 时把 `next_cursor` 传回同一个工具继续读取同一份观察,refs 在续读期间保持有效;也可以调整预算。单个超长节点放不下时会明确报出所需预算,不静默丢尾部。
105
- - 续读可省略视图参数,沿用原 `profile` / `text` / `text_limit`;显式提供时必须与原观察相同。调整视图须请求新快照,并按下表使旧 ref 失效。
105
+ - 续读可省略视图参数,沿用原 `profile` / `text` / `text_limit` / `query`;显式提供时必须与原观察相同。调整视图须请求新快照,并按下表使旧 ref 失效。
106
106
  - 覆盖主 frame、同进程 iframe 与递归 OOPIF;读取失败、读取期间变化或附着失败的 frame 列入 `omitted_frames`。
107
107
 
108
+ 默认输出省略 viewport、snapshot_id 和重复的使用说明;需要视口几何与观察标识时传 `verbose: true`,也可在同一 cursor 的续页上开启。frame 覆盖、分页与截断字段始终保留。默认仍是 full 视图,阅读与交互任务可自行选择。
109
+
110
+ 已知目标文字时可直接查询,无需先返回整棵树:
111
+
112
+ ```json
113
+ {"tab_handle":"...","query":"保存","max_nodes":20}
114
+ ```
115
+
116
+ `query`(1–2000 字符,不能全为空白)在当前视图的 AX 完整名称(含合并正文)或 value 中匹配不区分大小写的字面子串,保留原始空格,不解释正则;不匹配 role、ref 或未暴露给 AX 的 DOM 内容。匹配发生在文本预览截取之前,返回匹配行及所属 frame 标题,有 backend node 的节点带可操作 ref。`total_node_count` 统计匹配节点;零命中正常返回空 snapshot,不等待目标出现。多命中按原有 max_nodes/max_chars 和 cursor 续读。每个新查询仍读取完整 AX 树、替换旧观察并使旧 refs 失效;只减少返回量,不能据此推断采集时间缩短。`profile=interactive` 会先筛掉正文,阅读查询保留默认 full。
117
+
108
118
  失效规则:
109
119
 
110
120
  | 事件 | 影响 |
@@ -148,6 +158,8 @@ ref 每次用 frame/session + backendNodeId 重新解析,并以局部 AX 查
148
158
  | `button` | `left`(默认)/ `middle` / `right` | |
149
159
  | `click_count` | `1`(默认)/ `2` | 双击按顺序重复 down/up |
150
160
  | `modifiers` | `Alt` / `Control` / `Meta` / `Shift` | 默认空数组 |
161
+ | `offset` | `{x_ratio, y_ratio}`,两项均为 0–1 | 原生元素目标的 content quad 内落点;省略沿用可见区域中心 |
162
+ | `settle_ms` | 0–1000 或 `[min,max]` | 原生点击准备后的派发前等待;区间每次均匀抽样,省略为 0 |
151
163
  | `mode` | `native`(默认)/ `dom` | DOM 模式接受 ref 或 selector |
152
164
 
153
165
  原生模式按序派发 move → down → up,显式设置 `buttons` 与修饰键,并在隔离世界安装命中校验:成功返回 `target_verified: true`、`application_result: "not_checked"`。左键单击 / 双击分别校验 `click` / `dblclick`,中键校验 `auxclick`,右键校验 `contextmenu`。目标被遮挡、在 hover 后移动或派发中变化时会明确报错并阻止不匹配事件;已送达的前序事件可能已有副作用,不能撤销或自动重试。
@@ -180,6 +192,18 @@ hover 接受与原生 click 相同的目标,执行一次 mouseMoved,不派
180
192
 
181
193
  drag 的 source / destination 各自接受 ref、严格 selector(可带 frame_id/nth)或 x/y(可带 coordinate_space)。mode 必须按页面交互选择,默认 pointer;steps 默认 20,范围 2–100,每步间隔约 16 ms。
182
194
 
195
+ 可选参数不改变默认输入路径:
196
+
197
+ - click、hover 以及 drag 的 source/destination 元素目标可指定 `offset: {x_ratio: 0.3, y_ratio: 0.65}`。比例沿 content quad 两个方向插值,支持旋转/倾斜元素;指定点不在视口或被遮挡时失败,不改投中心。多行元素按浏览器返回的 quad 顺序选择第一个落点在视口内的片段。坐标目标和 DOM click 不接受 offset。
198
+ - 原生 click 与 drag 可传 `settle_ms: [80,250]`,也可传固定毫秒数;在目标准备和校验器安装后、首次鼠标派发前等待,之后重新检查标签就绪。不引入自动激活。hover 不增加停留等待。
199
+ - drag 可传 `trajectory: {curve_px: 12, duration_ms: 600}`,并沿用 `steps` 控制采样数。curve_px 为二次曲线控制点相对路径法线的有符号偏移(−100–100 CSS px),实际曲线最大偏移约为其一半;duration_ms 为插值段等待总和(100–5000 ms)。采用平滑变速和独立随机权重分配间隔,最后一点严格等于目标。源点到起拖阈值的移动仍沿用现有实现,HTML5 仍使用真实截获的拖拽数据。总耗时还包含准备、CDP 和收尾;曲线离开视口时停止并释放本次手势。
200
+
201
+ ```json
202
+ {"tab_handle":"...","source":{"selector":"#card","offset":{"x_ratio":0.3,"y_ratio":0.6}},"destination":{"selector":"#column"},"steps":24,"settle_ms":[80,250],"trajectory":{"curve_px":12,"duration_ms":600}}
203
+ ```
204
+
205
+ 这些参数复用原有总 timeout、命中校验和失败清理,不保证满足网站的行为验证。省略时不采样随机数,保持原有落点、拖拽直线和 16 ms 间隔。batch 的 click/hover 支持对应 offset,click 支持 settle_ms。
206
+
183
207
  两端均为 document 坐标且能同时容纳于一个顶层视口时,在按下前统一滚动位置,保留两端的 x/y 差值。已在视口内的 document 坐标不重复居中;需要滚动时只调整越界的轴。跨屏目标仍在按住后滚入视口,因此返回的 from/to 是各自派发时的视口坐标,不能用两者之差推断跨屏的文档距离。
184
208
 
185
209
  ```json
@@ -233,7 +257,7 @@ fill 是替换文本,原生路径用 insertText,不制造逐字 keydown。
233
257
  {"tab_handle":"...","selector":"input[role=combobox]","text":"上海","delay_ms":50}
234
258
  ```
235
259
 
236
- delay_ms 为 0–1000,是字符之间的最小额外间隔,准备与协议开销会增加实际耗时;整串 text 最多 10000 个 UTF-16 单位,仍受同一 30 秒以内的总预算约束。换行/Tab/控制字符用显式 press_key。检查原字段连接与焦点,观察到失焦或替换后停止;失败报告已核验字符数,当前字符仍可能已送达,不能重放整串。delivery_verified 证明 keydown,不证明站点接受文本或保存。
260
+ delay_ms 接受固定的 0–1000 毫秒数(默认 0),或有序区间 `[min,max]`(两端均为 0–1000 整数),如 `[40,180]`;区间在每两个字符之间独立均匀抽样,末字符后不额外等待。它是字符之间的最小额外间隔,准备与协议开销会增加实际耗时;整串 text 最多 10000 个 UTF-16 单位,仍受同一 30 秒以内的总预算约束。batch 的 type 步骤接受相同参数。换行/Tab/控制字符用显式 press_key。检查原字段连接与焦点,观察到失焦或替换后停止;失败报告已核验字符数,当前字符仍可能已送达,不能重放整串。delivery_verified 证明 keydown,不证明站点接受文本或保存。
237
261
 
238
262
  ### browser_batch
239
263
 
@@ -400,5 +424,6 @@ handle_dialog 返回原始 `dialog.type/message/default_prompt` 和 `accepted`
400
424
  - 标签拖动或浏览器原生菜单可能捕获原生输入,CDP 返回成功不等于页面收到事件;结束该交互后再依赖原生输入。不要用 `Input.setIgnoreInputEvents(false)` 强行解除用户的菜单 / 拖动状态。
401
425
  - 后台操作要求浏览器运行且页面未冻结;网站自己弹窗或权限 UI 仍可能改变焦点。
402
426
  - `target_verified`、`delivery_verified`、`value_verified` 只证明事件送达或文本写入,不证明提交、付款、服务端上传等业务完成。
427
+ - 点击/hover 准备时发现遮挡会返回 `hit_target`、命中点及受阻 frame;祖先 frame 的节点描述若失效则保留 backend node ID。普通点击在此失败尚未派发输入;drag 若已经按住,再准备目的节点时遇到遮挡仍按结果未知处理。不可见/禁用/尺寸不稳定及 selector 尚未出现沿用准备等待与最后状态诊断。
403
428
  - Hub 日志位于配对文件同目录的 `hub.log`;`browser-attach status` 查看连接状态,`browser-attach stop` 停止 Hub。
404
429
  - 宿主把大结果保存为 Artifact 时,长行可能被宿主截断;这是宿主读取行为,不是快照游标分页。相关说明见 [Agent 集成](AGENT-INTEGRATION.md)。
@@ -3,6 +3,7 @@ import { clickOptions } from './click.js';
3
3
  import { inputMode, keyParameters, selectOptions, typeOptions } from './input.js';
4
4
  import { scrollOptions } from './scroll.js';
5
5
  import { waitOptions } from './wait.js';
6
+ import { offsetOption } from './motion.js';
6
7
 
7
8
  export function validateSteps(steps, startAt = 0) {
8
9
  if (!Array.isArray(steps) || steps.length < 1 || steps.length > 20) throw new Error('steps must contain 1..20 actions');
@@ -24,6 +25,7 @@ export function validateSteps(steps, startAt = 0) {
24
25
  targetOptions(step);
25
26
  if (['click', 'fill'].includes(step.action)) inputMode(step);
26
27
  if (step.action === 'click') clickOptions(step);
28
+ if (step.action === 'hover') offsetOption(step);
27
29
  if (step.action === 'fill' && typeof step.text !== 'string') throw new Error('Batch fill requires text');
28
30
  if (step.action === 'type') typeOptions(step);
29
31
  if (step.action === 'select_option') selectOptions(step);
@@ -1,4 +1,5 @@
1
1
  import { deadline, waitForTarget, delivery } from './actionability.js';
2
+ import { offsetOption, timingOption, pause } from './motion.js';
2
3
 
3
4
  // Native mouse dispatch stays in protocol order without serial compositor acknowledgments.
4
5
  export function createClick(cdp, value, viewport, position, sessions, getTab, getWindow) {
@@ -45,10 +46,14 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
45
46
  }
46
47
  function cancel(tabId) { pending.get(tabId)?.reject(new Error('Tab detached during click; outcome is unknown. Observe before retrying.')); }
47
48
  async function prepare(tabId, node, args) {
49
+ offsetOption(args);
48
50
  if (!!node === (args.x != null || args.y != null)) throw new Error('Provide either ref/selector or x/y');
49
51
  if (args.coordinate_space != null && !['viewport', 'document'].includes(args.coordinate_space)) throw new Error('coordinate_space must be viewport or document');
50
52
  if (node && args.coordinate_space != null) throw new Error('coordinate_space applies to coordinate clicks only');
51
53
  const budget = args.budget || deadline(args.timeout_ms);
54
+ const obstruction = detail => Object.assign(new Error(`${detail}. ${budget.has_dispatched()
55
+ ? 'Input has already been dispatched. Outcome is unknown; observe before continuing. Do not replay automatically.'
56
+ : 'No input was dispatched.'}`), { input_dispatched: budget.has_dispatched() });
52
57
  let target = node?.target || { tabId };
53
58
  const send = (method, params) => budget.run(() => cdp(target, method, params));
54
59
  let point, view;
@@ -59,7 +64,7 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
59
64
  const { quads } = await send('DOM.getContentQuads', { backendNodeId: node.backendNodeId });
60
65
  view = await budget.run(() => viewport(target));
61
66
  for (const quad of quads || []) {
62
- point = clippedPoint(quad, view);
67
+ point = clippedPoint(quad, view, args.offset);
63
68
  if (point) break;
64
69
  }
65
70
  if (!point) throw new Error('Element has no visible click point. Inspect a screenshot.');
@@ -120,7 +125,7 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
120
125
  objectId: node.objectId, functionDeclaration: 'function(hit) { for (let e = hit; e; e = e.parentNode || e.host) if (e === this) return true; return false; }',
121
126
  arguments: [{ objectId: hit.object.objectId }], returnByValue: true,
122
127
  }));
123
- if (!unblocked) throw new Error('Another element covers this ref. Inspect a new snapshot or screenshot.');
128
+ if (!unblocked) throw obstruction(`Another element covers this ref. hit_target=${JSON.stringify(hitTarget)}; point=${JSON.stringify(point)}; frame_id=${JSON.stringify(node.frameId)}`);
124
129
  }
125
130
  // Dispatch through the root widget so overlays in ancestor documents cannot
126
131
  // be bypassed by sending directly to an OOPIF widget. Guard every boundary.
@@ -136,7 +141,16 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
136
141
  const parentHit = await budget.run(() => cdp(parent.target, 'DOM.getNodeForLocation', {
137
142
  x: Math.floor(rootPoint.x + parentView.scroll_x), y: Math.floor(rootPoint.y + parentView.scroll_y), includeUserAgentShadowDOM: true,
138
143
  }));
139
- if (parentHit.backendNodeId !== owner.backendNodeId) throw new Error('Another element covers this frame. Inspect a new snapshot or screenshot.');
144
+ if (parentHit.backendNodeId !== owner.backendNodeId) {
145
+ let blocker;
146
+ try {
147
+ const diagnostic = budget.child(200);
148
+ const resolved = await diagnostic.run(() => cdp(parent.target, 'DOM.resolveNode', { backendNodeId: parentHit.backendNodeId, objectGroup: 'browser-attach' }));
149
+ blocker = value(await diagnostic.run(() => cdp(parent.target, 'Runtime.callFunctionOn', { objectId: resolved.object.objectId,
150
+ functionDeclaration: describeHit.toString(), returnByValue: true })));
151
+ } catch { /* Preserve the obstruction error even if its node disappears. */ }
152
+ throw obstruction(`Another element covers this frame. hit_target=${JSON.stringify(blocker ?? { backend_node_id: parentHit.backendNodeId })}; point=${JSON.stringify(rootPoint)}; blocked_frame_id=${JSON.stringify(parent.frameId)}`);
153
+ }
140
154
  const ownerFrameId = parent.ownerFrameId || (await budget.run(() => cdp(parent.target, 'Page.getFrameTree'))).frameTree.frame.id;
141
155
  boundaries.push({ target: parent.target, backendNodeId: owner.backendNodeId, frameId: ownerFrameId });
142
156
  current = parent.target; currentView = parentView;
@@ -151,7 +165,8 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
151
165
  if (!options.hover && hitTarget.native_select) throw new Error("Native select popup is unsupported. No input was dispatched. Use browser_select_option by label/value/index; press Esc if a native popup is already open.");
152
166
  const name = `__attachClick_${crypto.randomUUID().replaceAll('-', '')}`;
153
167
  const guards = [], contexts = new Set();
154
- const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject }));
168
+ let failure;
169
+ const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject(error) { failure = error; reject(error); } }));
155
170
  receipt.catch(() => {});
156
171
  try {
157
172
  const targets = [{ target, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, leaf: true }, ...boundaries];
@@ -173,7 +188,10 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
173
188
  }
174
189
  // Recheck the root page after target preparation: a user may have changed
175
190
  // tabs/focus while we resolved a frame or installed the event guards.
191
+ await pause(budget, args.settle_ms);
192
+ if (failure) throw failure;
176
193
  const pageState = await assertReady(tabId, budget);
194
+ if (failure) throw failure;
177
195
  budget.dispatch();
178
196
  const dispatch = params => budget.run(() => cdp(tabId, 'Input.dispatchMouseEvent', { ...params, ...rootPoint }));
179
197
  const commands = [dispatch({ type: 'mouseMoved', button: 'none', buttons: 0, modifiers: options.modifiers })];
@@ -259,7 +277,13 @@ function installGuard(name, options, expiryMs, verifyDelivery) {
259
277
  return { stop };
260
278
  }
261
279
 
262
- export function clippedPoint(quad, view) {
280
+ export function clippedPoint(quad, view, offset) {
281
+ if (offset) {
282
+ const { x_ratio: x, y_ratio: y } = offset;
283
+ const point = { x: (1 - y) * ((1 - x) * quad[0] + x * quad[2]) + y * ((1 - x) * quad[6] + x * quad[4]),
284
+ y: (1 - y) * ((1 - x) * quad[1] + x * quad[3]) + y * ((1 - x) * quad[7] + x * quad[5]) };
285
+ return point.x >= 0 && point.y >= 0 && point.x < view.width && point.y < view.height ? point : null;
286
+ }
263
287
  let polygon = Array.from({ length: 4 }, (_, i) => ({ x: quad[i * 2], y: quad[i * 2 + 1] }));
264
288
  for (const [axis, bound, lower] of [['x', 0, true], ['x', view.width - 0.01, false], ['y', 0, true], ['y', view.height - 0.01, false]]) {
265
289
  const input = polygon; polygon = [];
@@ -287,6 +311,11 @@ export function clippedPoint(quad, view) {
287
311
  }
288
312
 
289
313
  export function clickOptions(args) {
314
+ offsetOption(args);
315
+ if (args.settle_ms != null) {
316
+ timingOption(args.settle_ms, 'settle_ms');
317
+ if (args.mode === 'dom') throw new Error('settle_ms requires native pointer input');
318
+ }
290
319
  const button = args.button ?? 'left', click_count = args.click_count ?? 1, modifiers = args.modifiers ?? [];
291
320
  const buttons = { left: [1, 0], middle: [4, 1], right: [2, 2] }, masks = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
292
321
  if (!Object.hasOwn(buttons, button)) throw new Error('button must be left, middle or right');
@@ -341,8 +341,9 @@ export function createController(api, notify = () => {}) {
341
341
  const url = srcIndex >= 0 ? new URL(attributes[srcIndex + 1], frame.url).href : 'about:blank';
342
342
  knownFrames.add(childId); frames.push({ id: childId, url });
343
343
  }
344
- entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
345
- entries.push(...renderAX(nodes, token, refs, frame.id, target.sessionId, options));
344
+ const rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
345
+ if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
346
+ entries.push(...rendered);
346
347
  }
347
348
  const metadata = value(await send(tabId, 'Runtime.evaluate', {
348
349
  expression: '({title: document.title, url: location.href})', returnByValue: true,
package/extension/drag.js CHANGED
@@ -1,14 +1,18 @@
1
1
  import { deadline, delivery } from './actionability.js';
2
2
  import { targetOptions } from './locator.js';
3
+ import { offsetOption, timingOption, pause, trajectoryOption, dragPath } from './motion.js';
3
4
 
4
5
  export function dragOptions(args) {
5
6
  if (!args.source || !args.destination) throw new Error('drag requires source and destination');
6
7
  for (const target of [args.source, args.destination]) {
7
8
  targetOptions(target, { coordinates: true });
9
+ offsetOption(target);
8
10
  if (target.coordinate_space === 'document' && (target.x < 0 || target.y < 0)) throw new Error('Document coordinates must be finite and nonnegative');
9
11
  }
10
12
  if (!['pointer', 'html5'].includes(args.mode ?? 'pointer')) throw new Error('drag mode must be pointer or html5');
11
13
  if (!Number.isInteger(args.steps ?? 20) || (args.steps ?? 20) < 2 || (args.steps ?? 20) > 100) throw new Error('steps must be 2..100');
14
+ timingOption(args.settle_ms ?? 0, 'settle_ms');
15
+ trajectoryOption(args.trajectory);
12
16
  }
13
17
 
14
18
  export function createDrag(cdp, value, mouse, viewport) {
@@ -87,6 +91,7 @@ export function createDrag(cdp, value, mouse, viewport) {
87
91
  try {
88
92
  await guard(source, 'source');
89
93
  if (html5) { interceptEnabled = true; await send('Input.setInterceptDrags', { enabled: true }); }
94
+ await pause(budget, args.settle_ms);
90
95
  await mouse.assertReady(tabId, budget);
91
96
  budget.dispatch();
92
97
  await move(point);
@@ -100,12 +105,19 @@ export function createDrag(cdp, value, mouse, viewport) {
100
105
  const destination = await mouse.prepare(tabId, await resolve(args.destination, budget), { ...args.destination, budget });
101
106
  await guard(destination, 'destination');
102
107
  const from = point;
108
+ const path = dragPath(from, destination.point, steps, args.trajectory);
109
+ if (args.trajectory) {
110
+ const view = await budget.run(() => viewport(tabId));
111
+ if (path.some(({ point }) => point.x < 0 || point.y < 0 || point.x >= view.width || point.y >= view.height)) {
112
+ throw new Error('Drag trajectory leaves the viewport. Outcome is unknown; observe before continuing.');
113
+ }
114
+ }
103
115
  if (html5) await send('Input.dispatchDragEvent', { type: 'dragEnter', ...point, data });
104
- for (let index = 1; index <= steps; index++) {
105
- point = { x: from.x + (destination.point.x - from.x) * index / steps, y: from.y + (destination.point.y - from.y) * index / steps };
116
+ for (const sample of path) {
117
+ point = sample.point;
106
118
  if (html5) await send('Input.dispatchDragEvent', { type: 'dragOver', ...point, data });
107
119
  else await move(point);
108
- await budget.run(() => new Promise(resolve => setTimeout(resolve, 16)));
120
+ await budget.run(() => new Promise(resolve => setTimeout(resolve, sample.dt)));
109
121
  }
110
122
  if (html5) await send('Input.dispatchDragEvent', { type: 'drop', ...point, data });
111
123
  await send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', buttons: 0, clickCount: 1 });
@@ -1,5 +1,6 @@
1
1
  import { deadline, waitForTarget, delivery } from './actionability.js';
2
2
  import { clickOptions } from './click.js';
3
+ import { timingOption, pause } from './motion.js';
3
4
 
4
5
  const BUSY = 'Browser UI may be capturing input (a tab drag or native menu). Finish that interaction, then observe the page before deciding whether to try again. Never replay an unknown outcome automatically.';
5
6
 
@@ -157,7 +158,7 @@ export function createInput(cdp, value, sessions) {
157
158
  try { await pressKey(tabId, character, { budget, character, expectedNode: node }); }
158
159
  catch (error) { error.message = `Typing stopped after ${typed} verified characters; the current character may already have been delivered. ${error.message}`; throw error; }
159
160
  typed++;
160
- if (args.delay_ms && typed < length) await budget.run(() => new Promise(resolve => setTimeout(resolve, args.delay_ms)));
161
+ if (typed < length) await pause(budget, args.delay_ms);
161
162
  }
162
163
  return { typed, method: 'native', events_trusted: true, delivery_verified: true, application_result: 'not_checked', observe_again: true };
163
164
  }
@@ -304,7 +305,7 @@ export function selectElement(choice) {
304
305
 
305
306
  export function typeOptions(args) {
306
307
  if (typeof args.text !== 'string' || args.text.length > 10000 || /[\r\n\t\x00-\x1f]/.test(args.text)) throw new Error('type text must contain at most 10000 characters');
307
- if (!Number.isInteger(args.delay_ms ?? 0) || (args.delay_ms ?? 0) < 0 || (args.delay_ms ?? 0) > 1000) throw new Error('delay_ms must be 0..1000');
308
+ timingOption(args.delay_ms ?? 0, 'delay_ms');
308
309
  }
309
310
 
310
311
  export function characterParameters(character) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.15.0",
4
+ "version": "0.16.0",
5
5
  "description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
@@ -0,0 +1,50 @@
1
+ export function timingOption(value, name, max = 1000) {
2
+ const values = Array.isArray(value) ? value : [value];
3
+ if ((Array.isArray(value) && value.length !== 2) || values.some(n => !Number.isInteger(n) || n < 0 || n > max) || values.length === 2 && values[0] > values[1]) {
4
+ throw new Error(`${name} must be 0..${max} or an ordered [min, max] integer pair`);
5
+ }
6
+ }
7
+
8
+ export function sampleTiming(value = 0) {
9
+ return Array.isArray(value) ? value[0] + Math.floor(Math.random() * (value[1] - value[0] + 1)) : value;
10
+ }
11
+
12
+ export async function pause(budget, value) {
13
+ const ms = sampleTiming(value);
14
+ if (ms) await budget.run(() => new Promise(resolve => setTimeout(resolve, ms)));
15
+ }
16
+
17
+ export function offsetOption(args) {
18
+ if (args.offset == null) return;
19
+ if ((!args.ref && !args.selector) || args.mode === 'dom') throw new Error('offset requires a native element target');
20
+ const offset = args.offset;
21
+ if (typeof offset !== 'object' || Array.isArray(offset) || Object.keys(offset).some(key => !['x_ratio', 'y_ratio'].includes(key)) ||
22
+ ![offset.x_ratio, offset.y_ratio].every(n => Number.isFinite(n) && n >= 0 && n <= 1)) throw new Error('offset requires x_ratio and y_ratio in 0..1');
23
+ }
24
+
25
+ export function trajectoryOption(trajectory) {
26
+ if (trajectory == null) return;
27
+ if (typeof trajectory !== 'object' || Array.isArray(trajectory) || Object.keys(trajectory).some(key => !['curve_px', 'duration_ms'].includes(key)) ||
28
+ !Number.isFinite(trajectory.curve_px) || Math.abs(trajectory.curve_px) > 100) throw new Error('trajectory requires curve_px in -100..100 and duration_ms in 100..5000');
29
+ if (!Number.isInteger(trajectory.duration_ms) || trajectory.duration_ms < 100 || trajectory.duration_ms > 5000) throw new Error('trajectory duration_ms must be 100..5000');
30
+ }
31
+
32
+ // Keep both endpoints exact; vary progress and per-step timing only when opted in.
33
+ export function dragPath(from, to, steps, trajectory) {
34
+ const dx = to.x - from.x, dy = to.y - from.y, length = Math.hypot(dx, dy);
35
+ if (!trajectory) return Array.from({ length: steps }, (_, index) => ({
36
+ point: { x: from.x + dx * (index + 1) / steps, y: from.y + dy * (index + 1) / steps }, dt: 16,
37
+ }));
38
+ const weights = Array.from({ length: steps }, () => 0.5 + Math.random());
39
+ const total = weights.reduce((sum, n) => sum + n, 0);
40
+ let elapsed = 0;
41
+ return Array.from({ length: steps }, (_, index) => {
42
+ const dt = weights[index] / total * trajectory.duration_ms;
43
+ elapsed += dt;
44
+ const t = index === steps - 1 ? 1 : elapsed / trajectory.duration_ms;
45
+ const progress = t * t * (3 - 2 * t);
46
+ const bend = length ? 2 * progress * (1 - progress) * trajectory.curve_px : 0;
47
+ return { point: index === steps - 1 ? { ...to } : { x: from.x + dx * progress - (length ? dy / length * bend : 0),
48
+ y: from.y + dy * progress + (length ? dx / length * bend : 0) }, dt };
49
+ });
50
+ }
@@ -1,15 +1,17 @@
1
1
  // Render semantic AX nodes once; page through the same observation without losing refs.
2
- export function snapshotOptions({ profile = 'full', text = true, text_limit } = {}) {
2
+ export function snapshotOptions({ profile = 'full', text = true, text_limit, query } = {}) {
3
3
  if (!['full', 'interactive'].includes(profile)) throw new Error('profile must be full or interactive');
4
4
  if (typeof text !== 'boolean') throw new Error('text must be a boolean');
5
5
  if (text_limit != null && (!Number.isInteger(text_limit) || text_limit < 0 || text_limit > 10000)) throw new Error('text_limit must be 0..10000');
6
- return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null) };
6
+ if (query != null && (typeof query !== 'string' || !query.trim() || query.length > 2000)) throw new Error('query must contain 1..2000 characters and not be blank');
7
+ return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null), ...(query != null ? { query } : {}) };
7
8
  }
8
9
 
9
10
  export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
10
11
  const byId = new Map(nodes.map(node => [node.nodeId, node]));
11
12
  const visited = new Set();
12
13
  const entries = [];
14
+ const query = options.query?.toLowerCase();
13
15
  const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
14
16
  const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
15
17
  'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
@@ -68,9 +70,11 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
68
70
  }
69
71
  let nextDepth = depth;
70
72
  if (!skip) {
71
- const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
72
- if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
73
- entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
73
+ if (query == null || identityName.toLowerCase().includes(query) || String(node.value?.value ?? '').toLowerCase().includes(query)) {
74
+ const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
75
+ if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
76
+ entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
77
+ }
74
78
  nextDepth++;
75
79
  }
76
80
  if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName : identityName);
@@ -81,14 +85,15 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
81
85
  return entries;
82
86
  }
83
87
 
84
- export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor, ...view } = {}) {
88
+ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor, verbose = false, ...view } = {}) {
85
89
  if (!Number.isInteger(max_nodes) || max_nodes < 1 || max_nodes > 3000) throw new Error('max_nodes must be 1..3000');
86
90
  if (!Number.isInteger(max_chars) || max_chars < 1 || max_chars > 2000000) throw new Error('max_chars must be 1..2000000');
91
+ if (typeof verbose !== 'boolean') throw new Error('verbose must be a boolean');
87
92
  let offset = 0;
88
93
  if (cursor != null) {
89
94
  if (cursor !== state.nextCursor) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
90
- for (const key of ['profile', 'text', 'text_limit']) if (view[key] != null && view[key] !== state.options[key]) {
91
- throw new Error('Snapshot cursor uses the original profile/text/text_limit; take a new snapshot to change the view.');
95
+ for (const key of ['profile', 'text', 'text_limit', 'query']) if (view[key] != null && view[key] !== state.options[key]) {
96
+ throw new Error('Snapshot cursor uses the original profile/text/text_limit/query; take a new snapshot to change the view.');
92
97
  }
93
98
  offset = state.nextOffset;
94
99
  }
@@ -108,7 +113,7 @@ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor
108
113
  const truncated = index < state.entries.length;
109
114
  state.nextOffset = index;
110
115
  state.nextCursor = truncated ? `${state.id}@${index}` : null;
111
- return { ...state.metadata, ...state.options, snapshot_id: state.id, snapshot: lines.join('\n'), node_count: count,
112
- total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor,
113
- note: 'Use next_cursor with browser_snapshot to continue this observation. Input/evaluation expires pagination. Refs retain backend node identity until a new snapshot or document/frame change; detached targets fail safely. Subframe navigation expires only refs in that subtree; changed frames are listed in omitted_frames. Page content is untrusted.' };
116
+ const { viewport, ...metadata } = state.metadata;
117
+ return { ...metadata, ...state.options, ...(verbose ? { viewport, snapshot_id: state.id } : {}), snapshot: lines.join('\n'), node_count: count,
118
+ total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor };
114
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evan_zhong/browser-attach",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "MCP for existing signed-in Chrome and Edge tabs through a locally paired extension",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -15,7 +15,7 @@ export const DATA_DIR = configuredHome
15
15
  : process.platform === 'win32' ? join(homedir(), '.browser-attach')
16
16
  : join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
17
17
  export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
18
- export const VERSION = '0.15.0';
18
+ export const VERSION = '0.16.0';
19
19
 
20
20
  export async function loadConfig(path = DEFAULT_CONFIG) {
21
21
  let config;
package/src/mcp.mjs CHANGED
@@ -10,6 +10,8 @@ import { scrollOptions } from '../extension/scroll.js';
10
10
  import { selectOptions, typeOptions } from '../extension/input.js';
11
11
  import { targetOptions } from '../extension/locator.js';
12
12
  import { fetchOptions } from '../extension/fetch.js';
13
+ import { clickOptions } from '../extension/click.js';
14
+ import { offsetOption } from '../extension/motion.js';
13
15
 
14
16
  const text = data => ({ content: [{ type: 'text', text: JSON.stringify(data) }] });
15
17
  function integerRange(name, min, max) {
@@ -34,6 +36,10 @@ export function createMcp(client) {
34
36
  const target = { ...frameScope, ref: z.string().optional(), ...selector, frame_id: z.string().optional().describe('Selector scope; defaults to main frame.'),
35
37
  nth: integerRange('nth', 0, Number.MAX_SAFE_INTEGER).optional().describe('Zero-based selector match index in document order.') };
36
38
  const inputTimeout = { ...dialogPolicy, timeout_ms: integerRange('timeout_ms', 100, 30000).default(5000).describe('Covers preparation, dispatch and verification.') };
39
+ const timing = name => z.union([integerRange(name, 0, 1000), z.tuple([integerRange(name, 0, 1000), integerRange(name, 0, 1000)]).refine(([min, max]) => min <= max, 'Use an ordered [min, max] pair')]);
40
+ const offset = { offset: z.object({ x_ratio: z.number().min(0).max(1), y_ratio: z.number().min(0).max(1) }).strict().optional().describe('Native element only: ratios within its content quad; offscreen/covered points fail.') };
41
+ const settle = { settle_ms: timing('settle_ms').optional().describe('Pause after target preparation, before dispatch: fixed ms or uniform [min, max].') };
42
+ const typeDelay = timing('delay_ms').default(0).describe('Between characters: fixed ms or independently sampled uniform [min, max].');
37
43
  function tool(name, description, schema, method, transform) {
38
44
  server.registerTool(`browser_${name}`, { description, inputSchema: z.object(schema) }, async args => {
39
45
  try {
@@ -86,12 +92,14 @@ export function createMcp(client) {
86
92
  if (args.focus_window != null) throw new Error('focus_window is only valid for action=activate');
87
93
  return { tab_handle: args.tab_handle };
88
94
  });
89
- tool('snapshot', 'Read an AX tree with refs and frame coverage. next_cursor continues the same observation with unchanged view options.', {
95
+ tool('snapshot', 'Read an AX tree with refs and frame coverage, or find nodes by query without returning the whole tree. A new observation replaces refs; next_cursor continues unchanged view options.', {
90
96
  ...handle, max_nodes: integerRange('max_nodes', 1, 3000).default(600),
91
97
  max_chars: integerRange('max_chars', 1, 2000000).default(24000), cursor: z.string().optional(),
92
98
  profile: z.enum(['full', 'interactive']).optional().describe('Default full; interactive keeps controls, focusable/editable nodes and ancestors.'),
93
99
  text: z.boolean().optional().describe('Default true; false hides prose, preserving control names and values.'),
94
100
  text_limit: integerRange('text_limit', 0, 10000).optional().describe('Prose preview length, marked with …; control names/values stay complete. Default 80 for interactive, unlimited for full.'),
101
+ query: z.string().min(1).max(2000).optional().describe('Case-insensitive literal substring of AX names/values in this view, before text previews. Returns matching lines and refs; zero matches is not an error. Uses normal budgets/cursor.'),
102
+ verbose: z.boolean().default(false).describe('Include viewport and snapshot_id.'),
95
103
  });
96
104
  tool('read_text', 'Read rendered page/frame text, optionally scoped to a selector. mode=value reads a field; mode=editor reads a Monaco/CodeMirror 5 model. complete_source reports completeness; next_offset continues live text, which may change.', {
97
105
  ...handle, ...selector, ...frameScope, mode: z.enum(['visible', 'text_content', 'value', 'editor']).default('visible'),
@@ -120,17 +128,21 @@ export function createMcp(client) {
120
128
  return args;
121
129
  });
122
130
  tool('click', 'Click one ref, selector or x/y; element targets auto-scroll. Native mode verifies the hit target; native select hits require select_option.', {
131
+ ...offset, ...settle,
123
132
  ...handle, ...inputTimeout, ...target, button: z.enum(['left', 'middle', 'right']).default('left'), click_count: z.union([z.literal(1), z.literal(2)]).default(1), modifiers: z.array(z.enum(['Alt', 'Control', 'Meta', 'Shift'])).default([]), x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional().describe('Coordinates only; document scrolls the top-level page first.'), mode: z.enum(['native', 'dom']).default('native').describe('DOM click requires an element target and a left single click without modifiers.'),
124
133
  }, null, args => {
125
134
  targetOptions(args, { coordinates: true });
135
+ clickOptions(args);
126
136
  if (args.mode === 'dom' && !args.ref && !args.selector) throw new Error('DOM click requires ref or selector; coordinates are unsupported.');
127
137
  return args;
128
138
  });
129
139
  tool('hover', 'Move the native pointer to a ref, selector or x/y without clicking; verify trusted mousemove and leave it at the target.', {
140
+ ...offset,
130
141
  ...handle, ...inputTimeout, ...target, x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional(),
131
- }, null, args => { targetOptions(args, { coordinates: true }); return args; });
132
- const dragTarget = z.object({ ...target, x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional() }).strict();
142
+ }, null, args => { targetOptions(args, { coordinates: true }); offsetOption(args); return args; });
143
+ const dragTarget = z.object({ ...target, ...offset, x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional() }).strict();
133
144
  tool('drag', 'Perform one native pointer or HTML5 drag; destination auto-scrolls into view while held.', {
145
+ ...settle, trajectory: z.object({ curve_px: z.number().min(-100).max(100), duration_ms: integerRange('duration_ms', 100, 5000) }).strict().optional().describe('Opt in to curved, variable-speed steps and varied intervals. Signed curve control-point offset in CSS px; duration covers interpolation waits only.'),
134
146
  ...handle, ...inputTimeout, source: dragTarget, destination: dragTarget, mode: z.enum(['pointer', 'html5']).default('pointer').describe('pointer for mouse-driven dragging; html5 for HTML drag-and-drop.'), steps: integerRange('steps', 2, 100).default(20),
135
147
  }, null, args => { dragOptions(args); return args; });
136
148
  tool('wait', 'Wait without scrolling or input. actionable requires a stable, visible, enabled, unobscured target in the viewport; network_idle uses observed page requests. Timeout includes the last observed state.', {
@@ -140,7 +152,7 @@ export function createMcp(client) {
140
152
  tool('select_option', 'Set one HTML select option without native UI, using synthetic input/change (events_trusted=false). Verifies retained selection only.', {
141
153
  ...handle, ...inputTimeout, ...target, value: z.string().optional().describe('Choose exactly one of value, label or zero-based index; value/label match exactly.'), label: z.string().optional(), index: integerRange('index', 0, Number.MAX_SAFE_INTEGER).optional(),
142
154
  }, null, args => { targetOptions(args); selectOptions(args); return args; });
143
- tool('type', 'Type printable characters at the current selection using trusted key events, without clearing the field. Stops on lost focus or detachment.', { ...handle, ...inputTimeout, ...target, text: z.string().max(10000), delay_ms: integerRange('delay_ms', 0, 1000).default(0).describe('Pause between characters.') }, null, args => { targetOptions(args); typeOptions(args); return args; });
155
+ tool('type', 'Type printable characters at the current selection using trusted key events, without clearing the field. Stops on lost focus or detachment.', { ...handle, ...inputTimeout, ...target, text: z.string().max(10000), delay_ms: typeDelay }, null, args => { targetOptions(args); typeOptions(args); return args; });
144
156
  tool('fill', 'Replace input/textarea/contenteditable text. Native mode focuses/selects and inserts text without character keydown; DOM mode sets text without focus and emits input/change. Does not blur or commit dropdown selections.', { ...handle, ...inputTimeout, ...target, text: z.string(), mode: z.enum(['native', 'dom']).default('native') }, null, args => { targetOptions(args); return args; });
145
157
  const batchTarget = { ...selector, selector: z.string().min(1).max(10000), ...frameScope, nth: target.nth };
146
158
  const scrollFields = { until: z.object({ text: z.string().min(1).max(2000), selector: z.string().min(1).optional() }).strict().optional(), max_steps: integerRange('max_steps', 1, 500).default(100), mode: z.enum(['dom', 'wheel']).default('dom'), delta_y: z.number().optional().describe('Positive scrolls down.'), delta_x: z.number().default(0), x: z.number().optional(), y: z.number().optional() };
@@ -149,10 +161,11 @@ export function createMcp(client) {
149
161
  ...handle, ...dialogPolicy, start_at: integerRange('start_at', 0, 19).default(0).describe('Explicitly skip a reviewed completed prefix.'), timeout_ms: integerRange('timeout_ms', 100, 30000).default(15000),
150
162
  steps: z.array(z.discriminatedUnion('action', [
151
163
  z.object({ action: z.literal('click'), ...stepOptions, ...batchTarget, mode: z.enum(['native', 'dom']).default('native'),
164
+ ...offset, ...settle,
152
165
  button: z.enum(['left', 'middle', 'right']).default('left'), click_count: z.union([z.literal(1), z.literal(2)]).default(1), modifiers: z.array(z.enum(['Alt', 'Control', 'Meta', 'Shift'])).default([]) }).strict(),
153
166
  z.object({ action: z.literal('fill'), ...stepOptions, ...batchTarget, text: z.string(), mode: z.enum(['native', 'dom']).default('native') }).strict(),
154
- z.object({ action: z.literal('type'), ...stepOptions, ...batchTarget, text: z.string().max(10000), delay_ms: integerRange('delay_ms', 0, 1000).default(0) }).strict(),
155
- z.object({ action: z.literal('hover'), ...stepOptions, ...batchTarget }).strict(),
167
+ z.object({ action: z.literal('type'), ...stepOptions, ...batchTarget, text: z.string().max(10000), delay_ms: typeDelay }).strict(),
168
+ z.object({ action: z.literal('hover'), ...stepOptions, ...batchTarget, ...offset }).strict(),
156
169
  z.object({ action: z.literal('select_option'), ...stepOptions, ...batchTarget, value: z.string().optional(), label: z.string().optional(), index: integerRange('index', 0, Number.MAX_SAFE_INTEGER).optional() }).strict(),
157
170
  z.object({ action: z.literal('scroll'), ...stepOptions, ...selector, ...frameScope, nth: target.nth, ...scrollFields }).strict(),
158
171
  z.object({ action: z.literal('wait'), ...stepOptions, ...selector, ...frameScope, nth: target.nth, condition: z.enum(['attached', 'visible', 'actionable', 'text', 'url', 'network_idle']), expected: z.string().optional(), previous: z.string().optional(), idle_ms: integerRange('idle_ms', 100, 10000).default(500) }).strict(),