@evan_zhong/browser-attach 0.22.1 → 0.23.1

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.
@@ -1,6 +1,6 @@
1
1
  # 架构与设计边界
2
2
 
3
- 当前实现为 **0.22.1**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
3
+ 当前实现为 **0.23.1**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
4
4
 
5
5
  ## 组件与数据流
6
6
 
@@ -34,6 +34,7 @@ MCP 客户端
34
34
  | `extension/outcomes.js` | 动作前临时 DOM 观察器、未知结果后的有界只读探测与证据封装,不推断业务成功 |
35
35
  | `extension/fetch.js`、`fetch-page.js` | 单页读取参数与缓存分页、临时页创建/导航/采集/关闭;复用 controller 的 attach 和标签队列 |
36
36
  | `extension/observation.js`、`snapshot.js` | 文档/frame 观察版本、AX 采集、ref 身份与失效历史;AX 渲染和快照分页 |
37
+ | `extension/fields.js` | 每次观察按 debugger session 采集 DOMSnapshot,按 frame/backend 节点身份关联 AX,提取结构化字段事实和原生文件选择状态 |
37
38
  | `extension/network.js` | 一份被动请求与文档响应状态,供 fetch、网络空闲等待及诊断日志使用 |
38
39
  | `extension/diagnostics.js`、`log-data.js` | 日志缓冲与分页、可选正文捕获、截断及脱敏;不拥有网络空闲或文档状态 |
39
40
  | `extension/{navigation,evaluate}.js` | 导航等待、有界求值结果缓存与分页 |
@@ -82,6 +83,7 @@ HTTP 状态复用 network.js 的 Network.responseReceived 观察,按主文档
82
83
  - **交互视图**:可选 `profile=interactive` 在同一渲染器中仅保留 AX 控件角色、可聚焦/可编辑节点及其祖先链和控件内文本,不以 backendNodeId/ref 是否存在判断交互性。独立文本和合并正文可隐藏或截取预览,控件名称、值和 ref 的原始 AX 身份保留;默认 `full` 行为不变。筛选仍读取完整 AX 树,只降低输出与分页成本;视图参数绑定分页观察。
83
84
  - **文本查询与精简输出**:`snapshot(query=...)` 复用同一次 AX 渲染,按当前视图中节点的完整名称(含合并正文)、value 或字段 context 做不区分大小写的字面子串筛选,只输出匹配行和对应 frame 标题;不搜索 role/ref 或任意 DOM 属性,不新增索引缓存。新查询仍采集新观察并使旧 refs 失效,query 与分页绑定。默认省略 viewport、snapshot_id 和重复规则说明,`verbose=true` 返回前两项;frame 覆盖、视图、预算和游标语义保留。
84
85
  - **字段上下文**:表单控件可附带 context 并参与 query,保留至多 160 字符的前一兄弟分支 AX 文本;只在四层以内 generic/none/group 祖先、单控件容器中查找,每次容器/文本遍历分别限制 64/32 节点。不将其伪装为 accessible name,不用于 label 定位或 ref 身份验证;interactive/text=false 也保留提示。
86
+ - **结构化字段**:profile=fields 复用相同观察状态、ref 注册、上下文提取、双预算分页与失效模型,以 fields 数组输出 DOM 与 AX 事实。每个 debugger session 在本次观察内至多一次 DOMSnapshot.captureSnapshot;按文档 frameId 与 backendNodeId 关联,不用选择器或名称推断身份,不逐字段读取普通 DOM 属性。DOMSnapshot 同时保留闭合 Shadow DOM、原生实时值与选项状态;只对文件 input 在隔离世界读取 FileList 的数量/名称,并释放 object group。默认限于 AX 暴露的原生字段与 AX 字段角色;include_hidden 显式加入 AX 缺失的原生字段,其 ref 可空。密码值不进入字段结果及查询。名称来自 AX,labels 来自 HTML label 名称来源,context 保持提示语义;原生/AX 值不合并成业务提交值。会话内 DOM 捕获只存在于本次采集,续读使用既有 entries,不增加持久 DOM 缓存。AX/DOM/FileList 与跨 frame 读取不是原子事务。
85
87
  - **ref 解析**:ref 保存 `backendNodeId`、所属 frame/session 和观察时的 AX role/name;动作时重新 `DOM.resolveNode` 并用局部 AX 查询核对语义身份。输入与求值不清空 refs,只使快照分页失效;原节点仍连接时可复用,节点替换、role/name 改变、文档或 frame 变化时安全失败,不按 role/name 猜测替代节点。
86
88
  - **ref 失效诊断**:controller 每标签保留最近至多 1000 个已清理 ref 的 cause/frame_id,不保留 DOM 对象、字段值或替代定位信息。新观察、文档/frame 变化及对话框清理记录各自原因;解析时单独报告节点脱离、session 或 AX 身份变化。释放、断连、关闭和 detach 清空历史,淘汰后返回 unknown_ref。
87
89
  - **双预算**:`max_nodes` 限制节点数,`max_chars` 限制每行序列化后的累计字符数;两者独立,任一耗尽都会返回 `next_cursor`。
@@ -100,7 +102,7 @@ HTTP 状态复用 network.js 的 Network.responseReceived 观察,按主文档
100
102
  → 等待可信事件回执
101
103
  ```
102
104
 
103
- - **type 与 fill**:fill 替换文字,type 保留选区,逐字符复用原生按键与 keydown 回执;字符之间可延迟,不把 insertText 伪装成键盘序列。浏览器处理 keypress/input 及 preventDefault,非 ASCII 不模拟系统 IME。
105
+ - **type 与 fill**:fill 替换文字,type 保留选区,逐字符复用原生按键与 keydown 回执;字符之间可延迟,不把 insertText 伪装成键盘序列。浏览器处理 keypress/input 及 preventDefault,非 ASCII 不模拟系统 IME。fill 对 input/textarea 精确核验 value;contenteditable 的 native 模式仅核验 innerText,统一 CRLF/CR 为 LF,并仅允许请求中的普通空格在同位置以 NBSP 保留;dom 模式精确核验 textContent,不互相兜底、不合并空白或空行。核验只读一次,不等待异步保存,差异仍报告结果未知。
104
106
  - **焦点输入与语义定位**:type 的 focused=true 与 ref/selector 互斥,复用 press_key 的 frame/开放 Shadow DOM 焦点解析,固定最初可编辑元素并逐字检查,不重新聚焦。selector_type 的 label/placeholder 在隔离世界按真实 HTML 关联/属性查询,role/name 每次读取所选 frame 的 AX 树并严格匹配,以 AX 树顺序支持 nth;统一返回原有节点结构,继续复用输入、命中核验及 batch 流程,无组件库适配器。
105
107
  - **只重试准备**:等待阶段可以反复轮询,但一旦派发就不再重试。超时分为"未派发"和"已派发、结果未知"两种错误文本。
106
108
  - **原生指针就绪检查**:准备前和派发前各做一次至多 500 ms 的有界检查;要求未冻结/丢弃的 tab 与未最小化的窗口。无修饰键的左键单击可在非活动标签尝试执行;hover、drag、其他按钮、双击及带修饰键的点击仍要求活动标签。检查失败以 TAB_NOT_READY 明确报告当前指针输入未派发,并提示 tab_control(action=activate) → snapshot → 所请求动作。click/hover 成功结果的 page_state.tab_active 记录派发前检查观察到的活动状态。普通操作不主动激活;仅显式 tab_control(action=activate) 在 focus_window=true 时请求窗口聚焦和 Page.bringToFront。
@@ -1,9 +1,25 @@
1
1
  # 验证报告
2
2
 
3
- 当前版本 **0.22.1**(2026-10-25)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
3
+ 当前版本 **0.23.1**(2026-10-25)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
4
4
 
5
5
  ## 当前版本
6
6
 
7
+ - `npm test`:273 项通过。新增 contenteditable 按输入模式核验的 19 个子用例,覆盖 native 渲染换行、CRLF/CR、普通空格以 NBSP 保留、显式 NBSP、空文本和 Unicode;DOM 精确文本;拒绝另一种文本表示掩盖的段落、字符、空格与空行变化,失败保持未知结果且不补发。
8
+ - Chromium 156、Edge 155 headless 完整 stdio MCP `live-smoke`:各 302 次调用、32 项检查通过。真实原生输入验证首尾 NBSP、CRLF/CR 与中文多行;DOM 精确文本和同步格式化造成的跨表示不匹配经过扩展 → Hub → MCP 返回 `value_not_retained`。两种模式的受控不匹配均只产生 1 次 input,不切换模式或重放;多行原生插入本身可产生多个 input 事件。
9
+ - `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、61 个发布文件,无配对密钥。未增加工具、参数、依赖或扩展权限,版本按校验修复递增至 0.23.1。
10
+ - 本机部署:协调升级因已有 MCP 连接拒绝后,按 stop/start 重启;Hub 为 0.23.1 / ready,Chrome、Edge 扩展均为 0.23.1。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 43 个文件已核对:manifest 版本/身份一致,其余非生成源码/资源 SHA-256 一致。
11
+ - 当前边界:native contenteditable 不裁剪空白、不合并空行,富文本占位换行或异步重建仍可能返回结果未知;不增加编辑器适配、自动重试或保存核验。未在真实 Gmail 草稿上验证;本机已有 MCP 客户端需重连并重新发现、接管。
12
+
13
+ ## 0.23.0 验证
14
+
15
+ - `npm test`:253 项通过。新增结构化字段保留重复标签、真实 HTML label 来源与附近 context、DOM/AX 值区分、原生多选与 mixed 状态、隐藏字段显式读取、密码值省略、节点身份缺失不按名称合并、完整行双预算分页、session 级 DOM 捕获复用与文件选择只读采集。覆盖输入后 cursor 失效且 ref 保留、新观察替换旧 ref、子 frame 变化只移除子树、文档捕获中导航不恢复旧引用。
16
+ - Chromium 156、Edge 155 headless 完整 stdio MCP `live-smoke`:各 281 次调用、31 项检查通过。真实 DOMSnapshot 与 AX 关联验证重复 HTML 标签、TEXTS28 字段名查询、格式化/只读值、多选原始值/标签、checkbox mixed、隐藏输入、密码省略、闭合 Shadow DOM、同源 iframe、OOPIF、文件选择数量/中文文件名、分页与字段 ref 操作;单纯观察不派发 input。保留输入、截图、上传、对话框、日志与 12000 节点 AX 分页回归。
17
+ - `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、61 个发布文件,无配对密钥;新增 fields 模块纳入包边界检查。没有新工具、依赖或扩展权限,观察能力扩展升至 0.23.0。
18
+ - 本机部署:协调升级因已有 MCP 连接拒绝后,按 stop/start 重启;Hub 为 0.23.0 / ready,Chrome、Edge 扩展均为 0.23.0。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 43 个文件已核对:manifest 版本一致,其余非生成源码/资源 SHA-256 一致。
19
+ - 当前边界:跨 frame、AX/DOM/FileList 分步采集不具备事务或并发写入归属;自定义组件内部模型、最终提交值和服务端附件状态仍需任务相关检查。隐藏原生字段可能无 AX/ref,未关联 DOM 身份的 AX 行省略值;缺失或失败的 frame 保留 omitted_frames。已有 MCP 客户端需重连刷新字段视图参数并重新发现/接管。
20
+
21
+ ## 0.22.1 验证
22
+
7
23
  - `npm test`:246 项通过。新增隔离世界选择器诊断经 CDP 异常对象读取、单步/read_text/wait/batch 传递及 frame 路径底层原因保留;诊断读取失败保留原异常并清理 wrapper。补充准备/派发超时分类、派发后不沿用准备诊断、fill 值不匹配保留未知结果且不回显字段值。后续新增的 click 目标变化原因断言另通过 15 项指针回归。
8
24
  - Chromium 156、Edge 155 headless 完整 stdio MCP `live-smoke`:各 267 次工具调用、29 项检查通过。新增真实 CSS/XPath 语法错误、CSS/AX role 歧义、缺失 selector、read_text/wait/batch 错误原因经过扩展 → Hub → MCP 的检查;原生 fill 同步格式化返回 value_not_retained、input_dispatched=true 及 post_action,真实 input 事件仅 1 次。保留上传、对话框、OOPIF、日志、后台输入与大 AX 树回归。
9
25
  - Edge 首次与 Chromium 并行执行时,原有遮挡检查在 500 ms 准备预算内超时,未派发输入;未修改该检查预算,独立复跑完整脚本通过。
package/docs/USAGE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # BrowserAttach 使用指南
2
2
 
3
- 适用于 **0.22.1**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md),宿主配置见 [Agent 集成](AGENT-INTEGRATION.md),内部机制见 [架构说明](ARCHITECTURE.md)。
3
+ 适用于 **0.23.1**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md),宿主配置见 [Agent 集成](AGENT-INTEGRATION.md),内部机制见 [架构说明](ARCHITECTURE.md)。
4
4
 
5
5
  本文供按需查阅,不会自动通过 MCP 发送给 agent。MCP 初始化 `instructions` 提供通用规则,工具 description 与参数 schema 提供调用说明;宿主应把 instructions 纳入模型上下文。
6
6
 
@@ -34,7 +34,7 @@
34
34
  | `browser_open_tab` | 在指定配置中打开 HTTP(S) 标签 |
35
35
  | `browser_claim_tab` | 独占接管已发现的标签 |
36
36
  | `browser_tab_control` | 释放、激活或关闭标签 |
37
- | `browser_snapshot` | AX 语义树、元素 ref、查询与 frame 覆盖 |
37
+ | `browser_snapshot` | AX 语义树或结构化字段数组、元素 ref、查询与 frame 覆盖 |
38
38
  | `browser_read_text` | 正文、字段值或支持的编辑器模型 |
39
39
  | `browser_fetch` | 使用登录态在临时后台标签读取一个 URL |
40
40
  | `browser_click` | 原生点击或显式 DOM 点击 |
@@ -138,20 +138,21 @@ snapshot 返回 AX 语义树,每行 `[token:n] role "name" 状态`;`[token:n
138
138
 
139
139
  | 参数 | 用途与默认值 |
140
140
  |---|---|
141
- | `profile` | `full`;interactive 只保留控件、可聚焦/编辑节点及祖先链,自定义控件可能被省略 |
141
+ | `profile` | `full`;interactive 保留控件、可聚焦/编辑节点及祖先链;fields 返回结构化字段数组,见下文 |
142
+ | `include_hidden` | 仅 fields,默认 false;true 额外读取未暴露到 AX 的原生字段,如隐藏 input |
142
143
  | `text` | true;false 隐去独立正文,保留控件名称和值 |
143
144
  | `text_limit` | 正文名称预览上限 0–10000;interactive 默认 80,full 默认不截断;控件名称和值保留完整 |
144
145
  | `query` | 1–2000 字符的名称/value/context 字面子串,不区分大小写;零命中正常返回 |
145
146
  | `max_nodes` / `max_chars` | 600 / 24000;范围分别为 1–3000 / 1–2000000,两个预算独立 |
146
147
  | `verbose` | false;true 额外返回 viewport 和 snapshot_id |
147
148
 
148
- 查询在当前视图中匹配完整名称/value,再截取正文预览;不搜索 role、ref 或未暴露给 AX 的属性。阅读查询保留 full,interactive 会先筛掉正文。视图和查询减少返回量,不减少完整 AX 树采集。
149
+ full/interactive 查询在当前视图中匹配完整名称/value/context,再截取正文预览;不搜索 role、ref 或未暴露给 AX 的属性。阅读查询保留 full,interactive 会先筛掉正文。视图和查询减少返回量,不减少完整 AX 树采集。
149
150
 
150
151
  ```json
151
152
  {"tab_handle":"...","query":"保存","max_nodes":20}
152
153
  ```
153
154
 
154
- `next_cursor` 继续同一份观察,保留 refs。续读可省略 profile/text/text_limit/query;显式提供时须与原观察一致。单个节点放不进预算会报出所需大小。主 frame、同进程 iframe 和跨进程 iframe(OOPIF)均可覆盖;未能读取或期间变化的 frame 见 omitted_frames。
155
+ `next_cursor` 继续同一份观察,保留 refs。续读可省略 profile/text/text_limit/query/include_hidden;显式提供时须与原观察一致。单个节点或字段放不进预算会报出所需大小。主 frame、同进程 iframe 和跨进程 iframe(OOPIF)均可覆盖;未能读取或期间变化的 frame 见 omitted_frames。
155
156
 
156
157
  | 事件 | ref / 快照分页的影响 |
157
158
  |---|---|
@@ -168,6 +169,32 @@ ref 错误的 diagnostics.reason=stale_ref,cause 区分 snapshot_replaced、do
168
169
 
169
170
  表单控件可附带 `context="性别"` 等邻近文本,在 interactive 或 text=false 下也保留,并参与 query。context 来自有限祖先范围内、仅含一个控件的容器中前一兄弟节点的 AX 文本;只是观察提示,不代表真实标签关联,不改写 AX name,也不参与 ref 身份校验。复杂布局可能没有 context。
170
171
 
172
+ ### 结构化字段观察
173
+
174
+ ```json
175
+ {"tab_handle":"...","profile":"fields"}
176
+ {"tab_handle":"...","profile":"fields","query":"TEXTS28"}
177
+ {"tab_handle":"...","profile":"fields","include_hidden":true,"query":"rawFee"}
178
+ ```
179
+
180
+ fields 视图用 `fields` 数组替代 snapshot 文本,保留重复标签和字段名,避免以标签作为键覆盖数据。`node_count/total_node_count` 与 max_nodes 计数字段,max_chars 仍按完整序列化项计算。`frames` 列出本次观察的 frame_id/URL,读取失败及变化见 omitted_frames。每行示例:
181
+
182
+ ```json
183
+ {
184
+ "ref":"token:1", "frame_id":"frame-id",
185
+ "labels":["签证费"], "context":null,
186
+ "ax":{"role":"textbox","name":"签证费","value":"1,234.00","readonly":true},
187
+ "dom":{"tag":"input","id":"fee","name":"TEXTS28","type":"text","value":"1,234.00","value_source":"input_value"}
188
+ }
189
+ ```
190
+
191
+ - `ax` 是浏览器提供的名称、角色、值及 disabled/readonly/required/invalid/checked/selected/expanded/multiselectable 状态;缺失属性不推断为 false。`labels` 来自浏览器 AX 名称的真实 HTML label 来源,context 则是现有有界附近文本,两者不改写 AX name。
192
+ - `dom.id/name/type/placeholder` 来自原生 DOM 快照属性;input 缺少 type 时记录 text。`dom.value` 的 value_source 区分 input_value、textarea_value、selected_option、text_content。select 附 multiple 和 selected_options 的值/标签;checkbox/radio 附原生 checked,AX checked=mixed 另行保留。contenteditable/output 的值是捕获的 textContent,不声称为编辑器完整模型。
193
+ - 默认包括暴露到 AX 的原生 input/textarea/select/output/contenteditable,以及 AX 字段角色的自定义控件;不把普通按钮当字段。文件 input 即使 AX 角色是 button 也识别为字段。include_hidden=true 才额外包括未暴露到 AX 的原生字段;此类行 ax/ref 可为 null,只提供可观察的 DOM 事实。密码字段省略 DOM 与 AX 值,返回 value_redacted=true。
194
+ - 文件 input 的 `dom.files={available,count,names}` 只报告隔离世界读取时的当前本地选择,不读取文件内容,不代表服务端附件数量或接受结果;读取失败为 available=false。上传后组件清空 input 时 count 可为 0。
195
+ - query 在 AX 名称/值、labels/context、DOM id/name/placeholder/value 和选中选项值/标签上作字面子串匹配。text/text_limit 只控制正文,不截断字段值。分页续读不重新采集;要核对当前值应发起新的字段观察。
196
+ - DOM 与 AX 按所属 frame/session 的 backend 节点身份关联,支持闭合 Shadow DOM 和 OOPIF。两次捕获之间节点被替换时不按 id/name 猜测新节点,只有 AX 事实的行 dom=null;无法判断原生字段类型时也省略 AX value。各 frame 与文件列表分步读取,不提供跨来源原子快照、业务提交值、组件内部模型或并发写入归属判断。读取最终保存结果仍需任务相关的页面或接口证据。
197
+
171
198
  ### browser_read_text
172
199
 
173
200
  `browser_read_text({tab_handle})` 默认读取主 frame 的可见正文。可用 selector 限定范围,或用 frame/frame_id 选择子 frame。
@@ -312,7 +339,9 @@ network_idle 的 idle_ms 默认 500,范围 100–10000。它只覆盖接管后
312
339
 
313
340
  支持文本类 input(text/search/email/password/tel/url)、textarea 和 contenteditable。native(默认)聚焦、选中并插入文本,不产生逐字 keydown;dom 写入值并派发合成 input/change,不主动聚焦。
314
341
 
315
- 两种模式均核对最终文本,返回 value_verified=true;失焦、替换或同步格式化导致不匹配时报错,不回显字段内容。工具不自动 blur 或按 Enter。需要失焦校验时显式 Tab 或点击其他目标;自定义下拉须在输入过滤后点击具体选项。
342
+ 两种模式均核对写入后的文本,返回 value_verified=true。input/textarea 按 value 精确比较;contenteditable 的 native 模式只比较渲染文本 innerText,将 CRLF/CR 视为 LF,并允许请求中的普通空格在相同位置以 NBSP 保留(显式请求的 NBSP 仍须原样保留);dom 模式只按 textContent 精确比较。不裁剪首尾空白、不合并空格或空行,也不以另一种文本表示兜底。富文本占位换行、节点替换或页面格式化仍可能导致不匹配,此时返回结果未知,不回显字段内容,不自动重放。核验不等待编辑器异步稳定或保存。
343
+
344
+ 工具不自动 blur 或按 Enter。需要失焦校验时显式 Tab 或点击其他目标;自定义下拉须在输入过滤后点击具体选项。
316
345
 
317
346
  ### browser_type
318
347
 
@@ -0,0 +1,130 @@
1
+ import { axContext, FIELD_ROLES, registerAXRef } from './snapshot.js';
2
+
3
+ const INPUT_BUTTONS = new Set(['button', 'submit', 'reset', 'image']);
4
+ const AX_STATES = new Set(['disabled', 'readonly', 'required', 'invalid', 'checked', 'selected', 'expanded', 'multiselectable']);
5
+
6
+ // One native DOM capture per debugger session, joined by frame and backend id.
7
+ // No DOM query, component getter or per-field resolve is needed for ordinary fields.
8
+ export function createFieldReader(send, value) {
9
+ const captures = new Map();
10
+ return async (target, frameId, nodes, token, refs, options) => {
11
+ const key = target.sessionId ?? '';
12
+ if (!captures.has(key)) captures.set(key, await send(target, 'DOMSnapshot.captureSnapshot', { computedStyles: [] }));
13
+ const capture = captures.get(key);
14
+ const document = capture.documents.find(doc => capture.strings[doc.frameId] === frameId);
15
+ if (!document) throw new Error('Frame document is unavailable in the DOM field capture');
16
+ const entries = renderFields(nodes, document.nodes, capture.strings, token, refs, frameId, target.sessionId, options);
17
+ const files = entries.filter(entry => entry.field.dom?.tag === 'input' && entry.field.dom.type === 'file');
18
+ if (files.length) {
19
+ const group = 'browser-attach-fields';
20
+ try {
21
+ const { executionContextId } = await send(target, 'Page.createIsolatedWorld', { frameId, worldName: group });
22
+ for (const entry of files) {
23
+ try {
24
+ const { object } = await send(target, 'DOM.resolveNode', { backendNodeId: entry.backendNodeId, executionContextId, objectGroup: group });
25
+ if (!object?.objectId) throw new Error('File input detached');
26
+ entry.field.dom.files = value(await send(target, 'Runtime.callFunctionOn', { objectId: object.objectId,
27
+ functionDeclaration: readFileSelection.toString(), returnByValue: true }));
28
+ } catch (error) { entry.field.dom.files = { available: false, error: error.message }; }
29
+ }
30
+ } finally { await send(target, 'Runtime.releaseObjectGroup', { objectGroup: group }).catch(() => {}); }
31
+ }
32
+ return entries;
33
+ };
34
+ }
35
+
36
+ function readFileSelection() {
37
+ if (!this.isConnected || this.tagName !== 'INPUT' || this.type !== 'file') return { available: false, error: 'File input changed during observation' };
38
+ return { available: true, count: this.files.length, names: Array.from(this.files, file => file.name) };
39
+ }
40
+
41
+ export function renderFields(axNodes, domNodes, strings, token, refs, frameId, sessionId, options) {
42
+ const { fieldContext } = axContext(axNodes);
43
+ const axByBackend = new Map();
44
+ for (const node of axNodes) if (node.backendDOMNodeId && (!node.ignored || !axByBackend.has(node.backendDOMNodeId))) axByBackend.set(node.backendDOMNodeId, node);
45
+ const rare = data => new Map((data?.index || []).map((index, n) => [index, data.value?.[n]]));
46
+ const inputValues = rare(domNodes.inputValue), textValues = rare(domNodes.textValue);
47
+ const checked = new Set(domNodes.inputChecked?.index || []), selected = new Set(domNodes.optionSelected?.index || []);
48
+ const children = new Map();
49
+ for (let i = 0; i < domNodes.parentIndex.length; i++) {
50
+ const parent = domNodes.parentIndex[i];
51
+ if (!children.has(parent)) children.set(parent, []);
52
+ children.get(parent).push(i);
53
+ }
54
+ const attributes = index => {
55
+ const list = domNodes.attributes[index] || [], result = new Map();
56
+ for (let i = 0; i < list.length; i += 2) result.set(strings[list[i]], strings[list[i + 1]]);
57
+ return result;
58
+ };
59
+ function descendants(index, visit) {
60
+ const pending = [index];
61
+ while (pending.length) {
62
+ const current = pending.pop(); visit(current);
63
+ const list = children.get(current) || [];
64
+ for (let i = list.length - 1; i >= 0; i--) pending.push(list[i]);
65
+ }
66
+ }
67
+ function textContent(index) {
68
+ const parts = [];
69
+ descendants(index, current => { if (domNodes.nodeType[current] === 3) parts.push(strings[domNodes.nodeValue[current]] || ''); });
70
+ return parts.join('');
71
+ }
72
+ const entries = [], seen = new Set(), query = options.query?.toLowerCase();
73
+ function add(node, dom, backendNodeId) {
74
+ const protectedValue = dom?.tag === 'input' && dom.type === 'password';
75
+ const ax = node && !node.ignored ? { role: node.role?.value, name: String(node.name?.value || '') } : null;
76
+ if (ax) {
77
+ if (dom && node.value?.value != null && !protectedValue) ax.value = node.value.value;
78
+ for (const property of node.properties || []) if (AX_STATES.has(property.name)) ax[property.name] = property.value?.value;
79
+ }
80
+ const labels = [];
81
+ for (const source of node?.name?.sources || []) {
82
+ if (!['label', 'labelfor', 'labelwrapped'].includes(source.nativeSource) || source.invalid) continue;
83
+ const related = source.nativeSourceValue?.relatedNodes ?? source.value?.relatedNodes ?? [];
84
+ const texts = related.map(item => item.text).filter(text => typeof text === 'string');
85
+ if (!texts.length && typeof source.value?.value === 'string') texts.push(source.value.value);
86
+ for (const text of texts) { const label = text.replace(/\s+/g, ' ').trim(); if (label && !labels.includes(label)) labels.push(label); }
87
+ }
88
+ const context = ax ? fieldContext(node) : null;
89
+ const field = { ref: null, frame_id: frameId, labels, context, ax, dom };
90
+ const terms = [ax?.name, ax?.value, context, ...labels, dom?.id, dom?.name, dom?.placeholder, dom?.value,
91
+ ...(dom?.selected_options || []).flatMap(option => [option.value, option.label])];
92
+ if (query != null && !terms.some(term => term != null && String(term).toLowerCase().includes(query))) return;
93
+ if (ax) field.ref = registerAXRef(node, token, refs, frameId, sessionId);
94
+ entries.push({ node: true, frameId, backendNodeId, field });
95
+ }
96
+ for (let index = 0; index < domNodes.backendNodeId.length; index++) {
97
+ if (domNodes.nodeType[index] !== 1) continue;
98
+ const backendNodeId = domNodes.backendNodeId[index], node = axByBackend.get(backendNodeId);
99
+ const attrs = attributes(index), tag = strings[domNodes.nodeName[index]].toLowerCase();
100
+ const type = tag === 'input' ? (attrs.get('type') || 'text').toLowerCase() : null;
101
+ const editable = attrs.has('contenteditable') && ['', 'true', 'plaintext-only'].includes(attrs.get('contenteditable').toLowerCase());
102
+ const native = tag === 'input' && !INPUT_BUTTONS.has(type) || ['textarea', 'select', 'output'].includes(tag) || editable;
103
+ if (!native && (!node || node.ignored || !FIELD_ROLES.has(node.role?.value))) continue;
104
+ if ((!node || node.ignored) && !options.include_hidden) continue;
105
+ seen.add(backendNodeId);
106
+ const dom = { tag, id: attrs.get('id') ?? null, name: attrs.get('name') ?? null };
107
+ if (type != null) dom.type = type;
108
+ if (attrs.has('placeholder')) dom.placeholder = attrs.get('placeholder');
109
+ if (type === 'password') dom.value_redacted = true;
110
+ else if (tag === 'input' && inputValues.has(index)) { dom.value = strings[inputValues.get(index)]; dom.value_source = 'input_value'; }
111
+ else if (tag === 'textarea' && textValues.has(index)) { dom.value = strings[textValues.get(index)]; dom.value_source = 'textarea_value'; }
112
+ else if (tag === 'select') {
113
+ dom.multiple = attrs.has('multiple'); dom.selected_options = [];
114
+ descendants(index, current => {
115
+ if (strings[domNodes.nodeName[current]] !== 'OPTION' || !selected.has(current)) return;
116
+ const optionAttrs = attributes(current), text = textContent(current).replace(/[\t\n\f\r ]+/g, ' ').replace(/^ | $/g, '');
117
+ dom.selected_options.push({ value: optionAttrs.get('value') ?? text, label: optionAttrs.get('label') ?? text });
118
+ });
119
+ dom.value = dom.selected_options[0]?.value ?? ''; dom.value_source = 'selected_option';
120
+ } else if (editable || tag === 'output') { dom.value = textContent(index); dom.value_source = 'text_content'; }
121
+ if (['checkbox', 'radio'].includes(type)) dom.checked = checked.has(index);
122
+ add(node, dom, backendNodeId);
123
+ }
124
+ // A DOM replacement between the AX and DOM captures must not be joined to
125
+ // a new element just because its name/id looks the same.
126
+ for (const node of axNodes) if (!node.ignored && node.backendDOMNodeId && !seen.has(node.backendDOMNodeId) && FIELD_ROLES.has(node.role?.value)) {
127
+ add(node, null, node.backendDOMNodeId);
128
+ }
129
+ return entries;
130
+ }
@@ -126,7 +126,7 @@ export function createInput(cdp, value, sessions) {
126
126
  functionDeclaration: prepareFill.toString(), arguments: [{ value: mode }, { value: args.text }] }));
127
127
  if (mode === 'native') await send('Input.insertText', { text: args.text });
128
128
  const matches = value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
129
- functionDeclaration: verifyFill.toString(), arguments: [{ value: args.text }] }));
129
+ functionDeclaration: verifyFill.toString(), arguments: [{ value: args.text }, { value: mode }] }));
130
130
  if (matches !== true) throw Object.assign(new Error(`Fill did not retain the requested text. Input may have been swallowed, focus may have moved, or the page may have reformatted it. Outcome is unknown. ${BUSY}`), {
131
131
  outcome_unknown: true, diagnostics: { reason: 'value_not_retained', frame_id: node.frameId },
132
132
  });
@@ -280,9 +280,19 @@ function prepareFill(mode, text) {
280
280
  return true;
281
281
  }
282
282
 
283
- function verifyFill(text) {
284
- // Native editing may represent line breaks using nested blocks or <br>.
285
- return this.isConnected && (this.isContentEditable ? this.textContent === text || this.innerText === text : this.value === text);
283
+ function verifyFill(text, mode) {
284
+ if (!this.isConnected) return false;
285
+ if (!this.isContentEditable) return this.value === text;
286
+ if (mode === 'dom') return this.textContent === text;
287
+ // Native editing uses rendered line boundaries; raw textContent can lose them.
288
+ const actual = this.innerText.replace(/\r\n?/g, '\n'), expected = text.replace(/\r\n?/g, '\n');
289
+ if (actual.length !== expected.length) return false;
290
+ for (let index = 0; index < expected.length; index++) {
291
+ // Chromium may preserve an inserted ordinary space as NBSP. Keep its position
292
+ // and count, and still require an explicitly requested NBSP to be retained.
293
+ if (actual[index] !== expected[index] && !(expected[index] === ' ' && actual[index] === '\u00a0')) return false;
294
+ }
295
+ return true;
286
296
  }
287
297
 
288
298
  function clickElement() {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.22.1",
4
+ "version": "0.23.1",
5
5
  "description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
@@ -1,4 +1,5 @@
1
1
  import { renderAX, snapshotOptions, snapshotPage } from './snapshot.js';
2
+ import { createFieldReader } from './fields.js';
2
3
 
3
4
  // Observation identity is separate from debugger session ownership.
4
5
  export function createObservations(cdp, value, sessions, viewport, currentClaim, changed) {
@@ -130,6 +131,7 @@ export function createObservations(cdp, value, sessions, viewport, currentClaim,
130
131
  const knownFrames = new Set(frames.map(frame => frame.id));
131
132
  const token = crypto.randomUUID().slice(0, 8);
132
133
  const refs = new Map(), entries = [], omittedFrames = [];
134
+ const readFields = options.profile === 'fields' ? createFieldReader(send, value) : null;
133
135
  for (const frame of frames) {
134
136
  let nodes;
135
137
  let target;
@@ -156,15 +158,27 @@ export function createObservations(cdp, value, sessions, viewport, currentClaim,
156
158
  const url = srcIndex >= 0 ? new URL(attributes[srcIndex + 1], frame.url).href : 'about:blank';
157
159
  knownFrames.add(childId); frames.push({ id: childId, url });
158
160
  }
159
- const rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
160
- if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
161
+ let rendered;
162
+ if (readFields) {
163
+ try { rendered = await readFields(target, frame.id, nodes, token, refs, options); }
164
+ catch (error) {
165
+ if (frame.id === mainId) throw error;
166
+ for (const [ref, node] of refs) if (node.frameId === frame.id) refs.delete(ref);
167
+ omittedFrames.push({ frame_id: frame.id, url: frame.url, reason: error.message });
168
+ continue;
169
+ }
170
+ } else {
171
+ rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
172
+ if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
173
+ }
161
174
  for (const entry of rendered) entries.push(entry);
162
175
  }
163
176
  const metadata = value(await send(tabId, 'Runtime.evaluate', {
164
177
  expression: '({title: document.title, url: location.href})', returnByValue: true,
165
178
  }));
166
179
  const state = { id: token, options, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
167
- metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames } };
180
+ metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames,
181
+ ...(readFields ? { frames: frames.map(frame => ({ frame_id: frame.id, url: frame.url })) } : {}) } };
168
182
  if (documentVersion !== documents.get(tabId)) throw new Error('Document changed while reading the snapshot. Take a new browser_snapshot.');
169
183
  for (const frame of frames) if (versions.get(frame.id) !== frameStates.get(tabId)?.versions.get(frame.id)) {
170
184
  omitFrame(tabId, state, frame.id, 'Frame changed while reading the snapshot; take a new snapshot to observe it');
@@ -1,21 +1,20 @@
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, query } = {}) {
3
- if (!['full', 'interactive'].includes(profile)) throw new Error('profile must be full or interactive');
2
+ export function snapshotOptions({ profile = 'full', text = true, text_limit, query, include_hidden } = {}) {
3
+ if (!['full', 'interactive', 'fields'].includes(profile)) throw new Error('profile must be full, interactive or fields');
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
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
+ if (include_hidden != null && (profile !== 'fields' || typeof include_hidden !== 'boolean')) throw new Error('include_hidden is a boolean for profile=fields only');
8
+ return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null), ...(query != null ? { query } : {}),
9
+ ...(profile === 'fields' ? { include_hidden: include_hidden ?? false } : {}) };
8
10
  }
9
11
 
10
- export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
12
+ export const FIELD_ROLES = new Set(['checkbox', 'combobox', 'listbox', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'textbox']);
13
+ const CONTROLS = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
14
+ 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
15
+
16
+ export function axContext(nodes) {
11
17
  const byId = new Map(nodes.map(node => [node.nodeId, node]));
12
- const visited = new Set();
13
- const entries = [];
14
- const query = options.query?.toLowerCase();
15
- const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
16
- const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
17
- 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
18
- const fields = new Set(['checkbox', 'combobox', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'textbox']);
19
18
  const parents = new Map();
20
19
  for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
21
20
  function contextText(node) {
@@ -53,7 +52,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
53
52
  while (pending.length && ++visited <= 64 && count < 2) {
54
53
  const current = pending.pop();
55
54
  if (!current) return null;
56
- if (!current.ignored && controls.has(current.role?.value)) count++;
55
+ if (!current.ignored && CONTROLS.has(current.role?.value)) count++;
57
56
  if (visited + pending.length + (current.childIds?.length || 0) > 64) return null;
58
57
  for (const id of current.childIds || []) pending.push(byId.get(id));
59
58
  }
@@ -67,6 +66,21 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
67
66
  }
68
67
  return null;
69
68
  }
69
+ return { byId, parents, fieldContext };
70
+ }
71
+
72
+ export function registerAXRef(node, token, refs, frameId, sessionId) {
73
+ if (!node.backendDOMNodeId || node.ignored) return null;
74
+ const ref = `${token}:${refs.size + 1}`;
75
+ refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
76
+ return ref;
77
+ }
78
+
79
+ export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
80
+ const { byId, parents, fieldContext } = axContext(nodes);
81
+ const visited = new Set(), entries = [];
82
+ const query = options.query?.toLowerCase();
83
+ const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
70
84
  let kept;
71
85
  if (options.profile === 'interactive') {
72
86
  kept = new Set();
@@ -82,7 +96,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
82
96
  }
83
97
  }
84
98
  for (const node of nodes) {
85
- if (node.ignored || !node.backendDOMNodeId || !(controls.has(node.role?.value) ||
99
+ if (node.ignored || !node.backendDOMNodeId || !(CONTROLS.has(node.role?.value) ||
86
100
  node.properties?.some(prop => prop.name === 'focusable' && prop.value?.value === true ||
87
101
  prop.name === 'editable' && prop.value?.value && prop.value.value !== 'false'))) continue;
88
102
  for (const child of node.childIds || []) keepText(byId.get(child));
@@ -128,7 +142,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
128
142
  ['disabled', 'focused', 'expanded', 'checked', 'selected', 'required', 'invalid', 'level'].includes(prop.name)
129
143
  ).map(prop => `${prop.name}=${JSON.stringify(prop.value?.value)}`);
130
144
  if (node.value?.value != null) states.push(`value=${JSON.stringify(node.value.value)}`);
131
- const context = fields.has(role) && !node.ignored ? fieldContext(node) : null;
145
+ const context = FIELD_ROLES.has(role) && !node.ignored ? fieldContext(node) : null;
132
146
  if (context && context !== name) states.push(`context=${JSON.stringify(context)}`);
133
147
  const attributes = states.join(' ');
134
148
  const skip = node.ignored || (!options.text && textRoles.has(role)) || (['none', 'generic'].includes(role) && !name && !attributes) ||
@@ -141,8 +155,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
141
155
  let nextDepth = depth;
142
156
  if (!skip) {
143
157
  if (query == null || identityName.toLowerCase().includes(query) || String(node.value?.value ?? '').toLowerCase().includes(query) || context?.toLowerCase().includes(query)) {
144
- const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
145
- if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
158
+ const ref = registerAXRef(node, token, refs, frameId, sessionId);
146
159
  entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
147
160
  }
148
161
  nextDepth++;
@@ -164,28 +177,30 @@ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor
164
177
  let offset = 0;
165
178
  if (cursor != null) {
166
179
  if (cursor !== state.nextCursor) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
167
- for (const key of ['profile', 'text', 'text_limit', 'query']) if (view[key] != null && view[key] !== state.options[key]) {
168
- throw new Error('Snapshot cursor uses the original profile/text/text_limit/query; take a new snapshot to change the view.');
180
+ for (const key of ['profile', 'text', 'text_limit', 'query', 'include_hidden']) if (view[key] != null && view[key] !== state.options[key]) {
181
+ throw new Error('Snapshot cursor uses the original profile/text/text_limit/query/include_hidden; take a new snapshot to change the view.');
169
182
  }
170
183
  offset = state.nextOffset;
171
184
  }
172
- const lines = [];
185
+ const lines = [], fields = [];
186
+ const structured = state.options.profile === 'fields';
173
187
  let count = 0, chars = 0, index = offset, reason = null;
174
188
  for (; index < state.entries.length; index++) {
175
189
  const entry = state.entries[index];
176
190
  if (state.invalidFrames.has(entry.frameId)) continue;
177
- const size = JSON.stringify(entry.line).length + 2;
191
+ const size = JSON.stringify(structured ? entry.field : entry.line).length + 2;
178
192
  if (entry.node && count >= max_nodes) { reason = 'node_limit'; break; }
179
193
  if (chars + size > max_chars) {
180
- if (!lines.length) throw new Error(`A snapshot line requires max_chars >= ${size}. Increase max_chars to continue without losing text.`);
194
+ if (!lines.length && !fields.length) throw new Error(`A snapshot ${structured ? 'field' : 'line'} requires max_chars >= ${size}. Increase max_chars to continue without losing text.`);
181
195
  reason = 'text_budget'; break;
182
196
  }
183
- lines.push(entry.line); chars += size; count += entry.node ? 1 : 0;
197
+ if (structured) fields.push(entry.field); else lines.push(entry.line);
198
+ chars += size; count += entry.node ? 1 : 0;
184
199
  }
185
200
  const truncated = index < state.entries.length;
186
201
  state.nextOffset = index;
187
202
  state.nextCursor = truncated ? `${state.id}@${index}` : null;
188
203
  const { viewport, ...metadata } = state.metadata;
189
- return { ...metadata, ...state.options, ...(verbose ? { viewport, snapshot_id: state.id } : {}), snapshot: lines.join('\n'), node_count: count,
204
+ return { ...metadata, ...state.options, ...(verbose ? { viewport, snapshot_id: state.id } : {}), ...(structured ? { fields } : { snapshot: lines.join('\n') }), node_count: count,
190
205
  total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor };
191
206
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evan_zhong/browser-attach",
3
- "version": "0.22.1",
3
+ "version": "0.23.1",
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",
@@ -15,7 +15,7 @@ for (const path of names) {
15
15
  if (/config\.generated\.js|(^|\/)(\.local|\.venv|test-results|node_modules)\//.test(path)) throw new Error(`Private file included in package: ${path}`);
16
16
  }
17
17
  for (const path of ['extension/connection.js', 'extension/version.js', 'extension/errors.js', 'extension/page-url.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
18
- for (const path of ['src/hub-runtime.mjs', 'extension/observation.js', 'extension/pointer.js', 'extension/dialogs.js', 'extension/network.js', 'extension/log-data.js', 'extension/fetch-page.js', 'extension/screenshot.js', 'extension/upload.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
18
+ for (const path of ['src/hub-runtime.mjs', 'extension/observation.js', 'extension/fields.js', 'extension/pointer.js', 'extension/dialogs.js', 'extension/network.js', 'extension/log-data.js', 'extension/fetch-page.js', 'extension/screenshot.js', 'extension/upload.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
19
19
  for (const path of ['bin/browser-attach.mjs', 'src/mcp.mjs', 'src/hub.mjs', 'extension/manifest.json', 'extension/evaluate.js', 'extension/fetch.js', 'extension/locator.js', 'extension/batch.js', 'extension/wait.js', 'extension/outcomes.js', 'extension/scroll.js', 'extension/drag.js', 'extension/text.js', 'extension/diagnostics.js', 'extension/sessions.js', 'extension/actionability.js', 'extension/limits.js', 'extension/icons/BrowserAttach.png']) {
20
20
  if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
21
21
  }
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.22.1';
18
+ export const VERSION = '0.23.1';
19
19
 
20
20
  export async function loadConfig(path = DEFAULT_CONFIG) {
21
21
  let config;
package/src/mcp.mjs CHANGED
@@ -103,13 +103,14 @@ export function createMcp(client) {
103
103
  if (args.focus_window != null) throw new Error('focus_window is only valid for action=activate');
104
104
  return { tab_handle: args.tab_handle };
105
105
  });
106
- 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.', {
106
+ tool('snapshot', 'Read an AX tree, or profile=fields for structured DOM/AX field facts with refs, HTML labels, nearby context and native values. Field rows distinguish DOM values from AX values, not submitted data or application success. A new observation replaces refs; next_cursor continues the captured view.', {
107
107
  ...handle, max_nodes: integerRange('max_nodes', 1, 3000).default(600),
108
108
  max_chars: integerRange('max_chars', 1, 2000000).default(24000), cursor: z.string().optional(),
109
- profile: z.enum(['full', 'interactive']).optional().describe('Default full; interactive keeps controls, focusable/editable nodes and ancestors.'),
109
+ profile: z.enum(['full', 'interactive', 'fields']).optional().describe('Default full; interactive keeps controls and ancestors; fields returns a fields array instead of AX text.'),
110
+ include_hidden: z.boolean().optional().describe('fields only, default false: also include native fields omitted from AX, such as hidden inputs. These may have ref=null. Password values are omitted.'),
110
111
  text: z.boolean().optional().describe('Default true; false hides prose, preserving control names and values.'),
111
112
  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.'),
112
- query: z.string().min(1).max(2000).optional().describe('Case-insensitive literal substring of AX names, values or field context, before text previews. Context is nearby text, not an accessible name. Returns matching lines and refs; zero matches is not an error.'),
113
+ query: z.string().min(1).max(2000).optional().describe('Case-insensitive literal substring of names, values or nearby context. fields also searches HTML labels, DOM id/name/placeholder and selected options. Zero matches is not an error.'),
113
114
  verbose: z.boolean().default(false).describe('Include viewport and snapshot_id.'),
114
115
  });
115
116
  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.', {
@@ -165,7 +166,7 @@ export function createMcp(client) {
165
166
  }, null, args => { targetOptions(args); selectOptions(args); return args; });
166
167
  const focused = { focused: z.literal(true).optional().describe('Alternative to ref/selector: pin the currently focused editable element without refocusing it. Stops if focus changes.') };
167
168
  tool('type', 'Type printable characters at the current selection using trusted key events, without clearing the field. Choose ref, selector or focused=true. Stops on lost focus or detachment.', { ...handle, ...inputTimeout, ...target, ...focused, text: z.string().max(10000), delay_ms: typeDelay }, null, args => { targetOptions(args, { focused: true }); typeOptions(args); return args; });
168
- 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; });
169
+ 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. Contenteditable verification uses rendered text in native mode (CRLF/CR as LF; NBSP may retain a requested space), exact textContent in DOM mode. 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; });
169
170
  const batchTarget = { ...selector, selector: z.string().min(1).max(10000), ...frameScope, nth: target.nth };
170
171
  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() };
171
172
  const stepOptions = { timeout_ms: integerRange('step timeout_ms', 100, 30000).optional(), dialog_policy: z.enum(['ask', 'accept', 'dismiss']).optional() };