@glittering0717/ai-browser 2.1.0 → 2.1.2

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
@@ -955,6 +955,10 @@ the single source for `evaluate` limits, enforced identically on both transports
955
955
  Issues and PRs are welcome, especially reproduction cases where the semantic tree gets a page wrong —
956
956
  those are the most useful bug reports this project can get.
957
957
 
958
+ Questions, "the tree got this page wrong" reports, and agent run logs are best posted in
959
+ [GitHub Discussions](https://github.com/Glittering/ai-browser/discussions) — that is where the most
960
+ useful feedback for this project tends to land.
961
+
958
962
  What we try to hold the line on:
959
963
 
960
964
  - **Keep the core generic.** Site-specific knowledge belongs outside `src/main` and `src/preload`.
Binary file
package/build/icon.png ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittering0717/ai-browser",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "AI-native browser — renders pixels for humans, structured semantic-tree API for agents. No screenshots, no OCR, no coordinate guessing.",
5
5
  "main": "src/main/index.js",
6
6
  "bin": {
@@ -15,7 +15,9 @@
15
15
  "examples/AGENT_INSTRUCTIONS.md",
16
16
  "examples/mcp-config.json",
17
17
  "llms-install.md",
18
- "LICENSE"
18
+ "LICENSE",
19
+ "build/icon.png",
20
+ "build/icon.icns"
19
21
  ],
20
22
  "type": "module",
21
23
  "license": "MIT",
@@ -53,6 +55,7 @@
53
55
  "scripts": {
54
56
  "start": "node scripts/start.cjs",
55
57
  "test": "vitest run",
58
+ "release": "node scripts/release.cjs",
56
59
  "test:watch": "vitest",
57
60
  "smoke": "node e2e/smoke_ws.cjs",
58
61
  "capabilities": "node e2e/capabilities_ws.cjs",
@@ -72,6 +75,7 @@
72
75
  "audit": "node e2e/audit_ws.cjs",
73
76
  "e2e:all": "node e2e/run_all.cjs",
74
77
  "readcov": "node e2e/readcov_ws.cjs",
78
+ "winopen": "node e2e/window_open_ws.cjs",
75
79
  "coverage": "node tools/coverage_probe.cjs"
76
80
  },
77
81
  "dependencies": {
@@ -33,7 +33,7 @@ NPM_DL=$(curl -s --max-time 15 "https://api.npmjs.org/downloads/point/last-week/
33
33
  mcp_fetch() {
34
34
  for i in 1 2 3; do
35
35
  local out
36
- out=$(curl -s --max-time 20 "https://registry.modelcontextprotocol.io/v0/servers?search=ai-browser" 2>/dev/null)
36
+ out=$(curl -s --max-time 20 "https://registry.modelcontextprotocol.io/v0/servers/io.github.Glittering%2Fai-browser/versions" 2>/dev/null)
37
37
  if [ -n "$out" ] && echo "$out" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then
38
38
  echo "$out"
39
39
  return 0
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ // scripts/release.cjs — 一条命令走完发版闭环:版本号 → 测试 → commit → tag →
3
+ // push(GitHub) → npm publish → MCP registry publish。
4
+ //
5
+ // 为什么要这个脚本(而不是每次手工敲 8 条命令):
6
+ // 发版最容易漏的不是编译,是**同步**。版本号有 4 处(package.json、
7
+ // server.json 两处、src/shared/version.js),GitHub 有 2 条分支 + 1 个 tag,
8
+ // 发布有 2 个仓库(npm、MCP registry)。漏一处不会报错,只会让用户在
9
+ // issue 里报一个不存在的版本、或者让 npx 拉到的还是旧版 —— 而且要到
10
+ // 下一次有人装的时候才发现。机器不会漏,人会。
11
+ //
12
+ // 用法:
13
+ // node scripts/release.cjs patch # 2.1.2 → 2.1.3
14
+ // node scripts/release.cjs minor --dry # 只看会做什么
15
+ // node scripts/release.cjs patch --notes notes.txt
16
+ //
17
+ // 设计原则:**每一步失败都不静默**。git 成功但 npm 失败时,脚本以非零退出并
18
+ // 打印"已完成的步骤 + 怎么补救",而不是假装整条链路都成功了。
19
+ 'use strict';
20
+
21
+ const { execFileSync, spawnSync } = require('node:child_process');
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ const ROOT = path.resolve(__dirname, '..');
26
+ const args = process.argv.slice(2);
27
+ const dry = args.includes('--dry');
28
+ const bumpKind = args.find((a) => ['patch', 'minor', 'major'].includes(a)) || 'patch';
29
+ const notesIdx = args.indexOf('--notes');
30
+ const notesFile = notesIdx >= 0 ? args[notesIdx + 1] : null;
31
+ // 全量测试 4 分钟起,发版时默认只跑"会拦住错误发版"的那几个:
32
+ // 版本一致性、导入完整性、schema 预算。其余靠 CI / 平时。
33
+ const fullTests = args.includes('--full-test');
34
+
35
+ const c = (s) => `\x1b[${s}m`;
36
+ const ok = (m) => console.log(`${c(32)}✓${c(0)} ${m}`);
37
+ const warn = (m) => console.log(`${c(33)}!${c(0)} ${m}`);
38
+ const fail = (m) => console.log(`${c(31)}✗${c(0)} ${m}`);
39
+ const step = (m) => console.log(`\n${c(36)}▸ ${m}${c(0)}`);
40
+
41
+ function run(cmd, argv, opts = {}) {
42
+ const r = spawnSync(cmd, argv, { cwd: ROOT, encoding: 'utf8', ...opts });
43
+ if (r.status !== 0 && !opts.allowFail) {
44
+ throw new Error(`${cmd} ${argv.join(' ')} 失败:\n${(r.stderr || r.stdout || '').trim()}`);
45
+ }
46
+ return r;
47
+ }
48
+ function git(...argv) { return run('git', argv); }
49
+ function gitOut(...argv) { return run('git', argv).stdout.trim(); }
50
+
51
+ const done = [];
52
+ function mark(m) { done.push(m); }
53
+
54
+ // ---- 0. 前置检查 ----------------------------------------------------------
55
+ step('前置检查');
56
+ const branch = gitOut('rev-parse', '--abbrev-ref', 'HEAD');
57
+ if (branch !== 'main' && branch !== 'dev') {
58
+ fail(`当前分支是 ${branch},发版只允许在 main 或 dev 上做`);
59
+ process.exit(1);
60
+ }
61
+ const dirty = gitOut('status', '--porcelain');
62
+ if (dirty) {
63
+ fail('工作区不干净,先提交或 stash:');
64
+ console.log(dirty.split('\n').map((l) => ' ' + l).join('\n'));
65
+ process.exit(1);
66
+ }
67
+ ok(`分支 ${branch},工作区干净`);
68
+
69
+ // ---- 1. 版本号 ------------------------------------------------------------
70
+ step('计算并写入版本号');
71
+ const pkgPath = path.join(ROOT, 'package.json');
72
+ const serverPath = path.join(ROOT, 'server.json');
73
+ const verPath = path.join(ROOT, 'src', 'shared', 'version.js');
74
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
75
+ const from = pkg.version;
76
+ const [maj, min, pat] = from.split('.').map(Number);
77
+ const to = bumpKind === 'major' ? `${maj + 1}.0.0`
78
+ : bumpKind === 'minor' ? `${maj}.${min + 1}.0`
79
+ : `${maj}.${min}.${pat + 1}`;
80
+ console.log(` ${from} → ${to} (${bumpKind})`);
81
+
82
+ if (dry) {
83
+ warn('--dry:只打印,不写文件、不提交、不发布');
84
+ process.exit(0);
85
+ }
86
+
87
+ function rewrite(file, fn) {
88
+ const p = path.join(ROOT, file);
89
+ const before = fs.readFileSync(p, 'utf8');
90
+ const after = fn(before);
91
+ if (after === before) throw new Error(`${file} 没发生替换 —— 版本号格式变了?请人工确认`);
92
+ fs.writeFileSync(p, after);
93
+ }
94
+ rewrite('package.json', (s) => s.replace(`"version": "${from}"`, `"version": "${to}"`));
95
+ // server.json 里有两处(顶层 + packages[0]),必须都改:只改顶层会让
96
+ // registry 认为"清单说 2.1.3、实际包是 2.1.2",校验直接不通过。
97
+ rewrite('server.json', (s) => s.split(`"version": "${from}"`).join(`"version": "${to}"`));
98
+ rewrite('src/shared/version.js', (s) => s.replace(`VERSION = '${from}'`, `VERSION = '${to}'`));
99
+ ok('4 处版本号已同步(package.json / server.json×2 / version.js)');
100
+ mark('版本号');
101
+
102
+ // ---- 2. 测试 --------------------------------------------------------------
103
+ step(fullTests ? '全量测试' : '发版闸门测试');
104
+ const testFiles = fullTests
105
+ ? []
106
+ : ['tests/test_version.js', 'tests/test_imports.js', 'tests/test_api_budget.js', 'tests/test_mcp_tools.js'];
107
+ const t = spawnSync('npx', ['vitest', 'run', ...testFiles, '--no-file-parallelism'], {
108
+ cwd: ROOT, encoding: 'utf8',
109
+ });
110
+ if (t.status !== 0) {
111
+ fail('测试未通过,发版中止。版本号改动已写在工作区,可用 git checkout 撤销。');
112
+ console.log((t.stdout || '').split('\n').slice(-25).join('\n'));
113
+ process.exit(1);
114
+ }
115
+ ok('测试通过');
116
+ mark('测试');
117
+
118
+ // ---- 3. commit + tag ------------------------------------------------------
119
+ step('提交并打 tag');
120
+ const notes = notesFile && fs.existsSync(path.join(ROOT, notesFile))
121
+ ? fs.readFileSync(path.join(ROOT, notesFile), 'utf8').trim()
122
+ : `v${to}`;
123
+ git('add', 'package.json', 'server.json', 'src/shared/version.js');
124
+ git('commit', '-m', `chore(release): v${to}`);
125
+ ok(`commit chore(release): v${to}`);
126
+ mark('commit');
127
+
128
+ // tag 打在 main 上(历史 v2.1.x 都在 main)。dev 上发版就先 ff 到 main。
129
+ if (branch === 'dev') {
130
+ git('checkout', 'main');
131
+ try { git('merge', '--ff-only', 'dev'); } catch (e) {
132
+ fail('main 无法快进到 dev(有分叉),需要人工处理。tag 未创建。');
133
+ git('checkout', 'dev');
134
+ process.exit(1);
135
+ }
136
+ ok('main 已快进到 dev');
137
+ }
138
+ git('tag', '-a', `v${to}`, '-m', notes);
139
+ ok(`tag v${to}`);
140
+ mark('tag');
141
+
142
+ // ---- 4. push --------------------------------------------------------------
143
+ step('推送到 GitHub');
144
+ try {
145
+ git('push', 'origin', 'main');
146
+ git('push', 'origin', 'dev');
147
+ git('push', 'origin', `v${to}`);
148
+ ok('main / dev / tag 已推送');
149
+ mark('git push');
150
+ } catch (e) {
151
+ fail('推送失败(网络或权限):' + String(e.message).split('\n')[0]);
152
+ warn(`本地 commit 与 tag 都在,网络恢复后执行:git push origin main dev v${to}`);
153
+ }
154
+
155
+ // ---- 5. npm ---------------------------------------------------------------
156
+ step('发布到 npm');
157
+ const npm = spawnSync('npm', ['publish'], { cwd: ROOT, encoding: 'utf8' });
158
+ if (npm.status === 0) {
159
+ ok(`@glittering0717/ai-browser@${to} 已发布`);
160
+ mark('npm publish');
161
+ } else {
162
+ const out = `${npm.stdout || ''}${npm.stderr || ''}`;
163
+ // token 失效的特征:whoami 401、PUT 404(npm 用 404 掩盖"没权限")
164
+ if (/E401|E404|Unauthorized|Not found/i.test(out)) {
165
+ fail('npm 认证失效(401/404)。需要重新登录一次,之后本脚本可全自动:');
166
+ console.log(' npm login # 浏览器登录');
167
+ console.log(' 或:把新的 granular access token 写进 ~/.npmrc 的 //registry.npmjs.org/:_authToken');
168
+ } else if (/EPUBLISHCONFLICT|cannot publish over/i.test(out)) {
169
+ fail(`npm 上已存在 ${to},版本号需要再往上走一格`);
170
+ } else {
171
+ fail('npm publish 失败:' + out.split('\n').filter(Boolean).slice(-4).join(' | '));
172
+ }
173
+ warn(`补救:token 恢复后执行 npm publish (当前目录版本已是 ${to})`);
174
+ }
175
+
176
+ // ---- 6. MCP registry ------------------------------------------------------
177
+ step('发布到 MCP registry');
178
+ const publisher = ['mcp-publisher', '/tmp/mcp-pub/mcp-publisher', '/opt/homebrew/bin/mcp-publisher']
179
+ .find((p) => { try { return fs.existsSync(p) || execFileSync('which', [p]).length > 0; } catch { return false; } });
180
+ if (!publisher) {
181
+ warn('未找到 mcp-publisher,跳过。安装:gh release download --repo modelcontextprotocol/registry \\');
182
+ console.log(' --pattern "mcp-publisher_darwin_arm64.tar.gz" && tar xzf ...');
183
+ } else {
184
+ const pub = spawnSync(publisher, ['publish'], { cwd: ROOT, encoding: 'utf8' });
185
+ if (pub.status === 0) {
186
+ ok('MCP registry 已发布');
187
+ mark('mcp registry');
188
+ } else {
189
+ const out = `${pub.stdout || ''}${pub.stderr || ''}`;
190
+ fail('MCP registry 发布失败:' + out.split('\n').filter(Boolean).slice(-6).join(' | '));
191
+ if (/auth|login|token|unauthor/i.test(out)) {
192
+ warn('需要先登录一次:mcp-publisher login github(device flow,点一下浏览器)');
193
+ }
194
+ warn('补救:登录后执行 mcp-publisher publish');
195
+ }
196
+ }
197
+
198
+ // ---- 摘要 -----------------------------------------------------------------
199
+ console.log(`\n${c(36)}── v${to} 发版摘要 ──${c(0)}`);
200
+ console.log(' 已完成: ' + (done.length ? done.join(' → ') : '(无)'));
201
+ const skipped = ['版本号', '测试', 'commit', 'tag', 'git push', 'npm publish', 'mcp registry']
202
+ .filter((s) => !done.includes(s));
203
+ if (skipped.length) console.log(` 未完成: ${skipped.join(', ')}`);
204
+ process.exit(skipped.length ? 2 : 0);
@@ -46,8 +46,8 @@ ai-browser call ui.get_tree '{"tab":1,"subset":"interactive"}' # 交互树
46
46
  ai-browser call ui.get_tree '{"tab":1,"ax":true}' # 阅读树(长正文用它)
47
47
  ai-browser call ui.act '{"action":"click","target":"e:a-3"}'
48
48
  ai-browser call ui.act '{"action":"type","target":"e:input-1","text":"关键词"}'
49
- ai-browser call ui.evaluate '{"expr":"document.title"}'
50
- ai-browser call ui.evaluate '{"frame_ref":"frame-1","expr":"document.body.innerText"}'
49
+ ai-browser call ui.evaluate '{"js":"document.title"}'
50
+ ai-browser call ui.evaluate '{"frame_ref":"frame-1","js":"document.body.innerText"}'
51
51
  ai-browser call ui.wait '{"condition":"text_contains","text":"加载完成"}'
52
52
  ai-browser call ui.network_list '{"tab":1}'
53
53
  ai-browser call ui.screenshot '{"tab":1}'
@@ -42,7 +42,10 @@ function valueOf(pv) {
42
42
  // The only "rule" we keep (plan §5): fold that into a semantic `textbox`.
43
43
  function editorTypeFrom(role, editable) {
44
44
  if (editable === 'richtext' || editable === 'contenteditable') return 'richtext';
45
- if (role === 'textbox') {
45
+ // searchbox 与 textbox 同为可输入面,只是语义上被标成搜索框。此前只认
46
+ // textbox,导致 role=searchbox 的输入框**完全不出现在树的可编辑索引里** ——
47
+ // 于是"两个 editable"的场景里少算了一个,primary 反而可能指错。
48
+ if (role === 'textbox' || role === 'searchbox') {
46
49
  if (editable === 'multiline') return 'textarea';
47
50
  if (editable === 'plaintext') return 'textbox';
48
51
  }
@@ -100,6 +103,14 @@ let nodeStore = null; // current normalize pass's node map (see normalizeAxTree)
100
103
  // wrappers (§buildThroughIgnored); an AX node that a wrapper re-parents must not
101
104
  // be emitted twice.
102
105
  let seenNodes = null;
106
+ // 本次 pass 收集到的"可编辑节点"清单,pass 结束时汇总成 tree.editors(见
107
+ // summarizeEditors)。为什么要在树根上放索引:一个页面上有多个 editable 时,
108
+ // 树的结构本身**不告诉你哪个是正文**。2026-10-19 知乎实机:顶部搜索框和
109
+ // 正文编辑器都带 editable,按 editable 过滤先抓到搜索框(DOM 顺序在前),
110
+ // 第一次粘贴直接糊进搜索框,靠字数校验才拦住没提交。
111
+ // 面积是这里唯一可靠的区分信号(搜索框 300×36 vs 正文 720×600,差 30 倍),
112
+ // 而它已经在每个节点的 bounds 里 —— 不需要额外读 DOM。
113
+ let editables = null;
103
114
 
104
115
  // Descendants of an AX node that is `ignored: true`. AX marks presentational
105
116
  // wrappers (a `span` in a flex row, `<b>` inside a paragraph) as ignored, but
@@ -138,7 +149,10 @@ function build(node, idFor) {
138
149
  },
139
150
  };
140
151
  if (node.backendDOMNodeId != null) tn.backendDOMNodeId = node.backendDOMNodeId;
141
- if (editorType) tn.editor_type = editorType;
152
+ if (editorType) {
153
+ tn.editor_type = editorType;
154
+ noteEditable(tn, editorType, node);
155
+ }
142
156
 
143
157
  if (role === 'textbox' || role === 'combobox' || role === 'listbox' || role === 'searchbox') {
144
158
  let v = valueOf(node.value);
@@ -183,6 +197,74 @@ function build(node, idFor) {
183
197
  return tn;
184
198
  }
185
199
 
200
+ // 「这个输入框是不是搜索框」的判据。role=searchbox 是最强信号;其次是
201
+ // placeholder/label 里的词。两者都没有时就靠面积(搜索框永远比正文小得多)。
202
+ // 只用来**标注**,不用来排除 —— 排除错一个真编辑器,代价是 agent 找不到
203
+ // 能写的地方。
204
+ const SEARCH_LIKE = /搜索|查找|检索|search|find/i;
205
+
206
+ // 面积下限:AX 里存在 1px 的表单元素(隐藏 input、脚手架占位),它们的
207
+ // bounds 是 0 或 1,算进清单里只会制造噪音。
208
+ const EDITABLE_MIN_AREA = 800;
209
+ // 「像个正文编辑器」的下限。200×100 是保守值:真实正文区都在 400×200 以上,
210
+ // 而搜索框在 300×36 量级。达不到就不给 primary —— 宁可让调用方自己看图,
211
+ // 也不要指错。
212
+ const PRIMARY_MIN_AREA = 20000;
213
+
214
+ function noteEditable(tn, editorType, node) {
215
+ if (!editables) return;
216
+ const b = tn.bounds || {};
217
+ const w = Number(b.width) || 0;
218
+ const h = Number(b.height) || 0;
219
+ const area = w * h;
220
+ if (area < EDITABLE_MIN_AREA) return;
221
+ const ph = String(valueOf(prop(nodeStore, node.nodeId, 'placeholder')) || '');
222
+ const label = String(tn.label || '');
223
+ const searchlike = tn.role === 'searchbox'
224
+ || SEARCH_LIKE.test(ph)
225
+ || SEARCH_LIKE.test(label);
226
+ editables.push({
227
+ ref: tn.id,
228
+ role: tn.role,
229
+ editor_type: editorType,
230
+ label: label.slice(0, 40),
231
+ ...(ph ? { placeholder: ph.slice(0, 40) } : {}),
232
+ w,
233
+ h,
234
+ area,
235
+ readonly: (tn.states || []).indexOf('readonly') >= 0,
236
+ ...(searchlike ? { searchlike: true } : {}),
237
+ });
238
+ }
239
+
240
+ /**
241
+ * 把本 pass 收集到的可编辑节点汇总成树根上的 `editors` 索引。
242
+ *
243
+ * 存在的唯一理由:**树的结构不告诉你哪个 editable 是正文**。按 DOM 顺序
244
+ * 过滤 `editable` 拿到的是页面上第一个可输入的东西 —— 在知乎是顶部搜索框,
245
+ * 在掘金是登录框。agent 没法从 `[textbox] editable=richtext` 这一行判断
246
+ * "这是不是我要写的那个",于是它要么猜,要么一个个试,要么把自己的正文
247
+ * 糊进搜索框(2026-10-19 真实发生,靠字数校验才拦住)。
248
+ *
249
+ * 输出按面积降序,`primary: true` 标给最大的那个非搜索非只读编辑区。
250
+ * 只有 1 个可编辑节点时**不输出**:没有歧义就不该多花 token。
251
+ *
252
+ * @returns {Array|undefined} 0/1 个节点返回 undefined
253
+ */
254
+ export function summarizeEditors(list) {
255
+ if (!Array.isArray(list) || list.length < 2) return undefined;
256
+ const sorted = list.slice().sort((a, b) => b.area - a.area).slice(0, 8);
257
+ let primary = null;
258
+ for (const e of sorted) {
259
+ if (e.readonly || e.searchlike) continue;
260
+ if (e.area < PRIMARY_MIN_AREA) break; // 最大的可用编辑区都不像正文
261
+ primary = e.ref;
262
+ break;
263
+ }
264
+ // area 不外露:w/h 已经够了,重复字段只多花 token。
265
+ return sorted.map(({ area, ...rest }) => (rest.ref === primary ? { ...rest, primary: true } : rest));
266
+ }
267
+
186
268
  /**
187
269
  * `InlineTextBox` 是 CDP 把一个 `StaticText` 按**换行和样式**切出来的文字碎片:
188
270
  * 一段两行的正文会变成两个 inline box,一个加粗的词再切一个。
@@ -225,6 +307,7 @@ export function normalizeAxTree(rawNodes, opts = {}) {
225
307
  nodeStore = new Map(all.map((n) => [n.nodeId, n]));
226
308
  seenNodes = new Set();
227
309
  readValue = opts.readValue || null;
310
+ editables = [];
228
311
  const idFor = opts.idFor || ((n) => 'ax-' + n.nodeId);
229
312
 
230
313
  // Parent map over ALL nodes, so "is this a root?" accounts for ignored
@@ -249,6 +332,11 @@ export function normalizeAxTree(rawNodes, opts = {}) {
249
332
  ? built[0]
250
333
  : { id: 'root', role: 'generic', label: '', states: ['visible'], actions: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, children: built };
251
334
  tree._debug = { layer: 'ax', version: VERSION_TAG };
335
+ // 有歧义时才给索引(见 summarizeEditors)。挂在树根而不是散在各节点上:
336
+ // 调用方读一次根就能挑好目标,不用把整棵树扫一遍再自己比面积。
337
+ const editors = summarizeEditors(editables);
338
+ if (editors) tree.editors = editors;
339
+ editables = null;
252
340
  nodeStore = null;
253
341
  seenNodes = null;
254
342
  readValue = null;
package/src/main/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // One process, one WS server, multiple tabs with real tab bar.
3
3
  import { app, BrowserWindow, BrowserView, ipcMain, net, session, dialog } from 'electron';
4
4
  import path from 'node:path';
5
+ import fs from 'node:fs';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { startWSServer } from './ws_server.js';
7
8
  import { PageManager } from './page_manager.js';
@@ -326,10 +327,40 @@ if (gotLock) {
326
327
  app.quit();
327
328
  }
328
329
 
330
+ // === 应用图标(2026-10-20)===
331
+ //
332
+ // macOS 上从源码启动时 Dock 图标是 Electron 的默认火箭 —— 因为进程本体就是
333
+ // node_modules/electron/dist/Electron.app,**Dock 跟的是可执行文件,不是我们的
334
+ // 代码**。打包版没有这个问题(Info.plist 里已经是自定义 icns),但 `npm start`
335
+ // / `npx` 启动占日常使用的大头;图标错了,用户的判断就是"这不是 AI Browser"。
336
+ // (2026-10-20 原话:"我看现在系统里运行的 ai-browser 都是默认图标了"。)
337
+ //
338
+ // app.dock.setIcon 是 Electron 提供的运行时入口,不影响签名与打包。
339
+ // Windows/Linux 走 BrowserWindow 的 icon 选项(见 createWindow)。
340
+ function appIconPath() {
341
+ const p = path.join(__dirname, '..', '..', 'build', 'icon.png');
342
+ try { return fs.existsSync(p) ? p : null; } catch (_) { return null; }
343
+ }
344
+
345
+ function applyAppIcon() {
346
+ if (process.platform !== 'darwin') return false; // 其他平台由窗口 icon 选项负责
347
+ const icon = appIconPath();
348
+ if (!icon) return false; // 装的是精简包(没带 build/)→ 静默跳过,不值得启动失败
349
+ try {
350
+ if (app.dock && typeof app.dock.setIcon === 'function') { app.dock.setIcon(icon); return true; }
351
+ } catch (e) {
352
+ console.error('[icon] dock.setIcon failed:', e && e.message);
353
+ }
354
+ return false;
355
+ }
356
+
329
357
  function createWindow() {
330
358
  mainWindow = new BrowserWindow({
331
359
  width: 1280,
332
360
  height: 800,
361
+ // Windows/Linux 的任务栏图标来自这里(macOS 走 app.dock.setIcon,
362
+ // 见 applyAppIcon:Dock 跟的是进程可执行文件,不是窗口选项)。
363
+ icon: appIconPath() || undefined,
333
364
  // 先不显示:如何显示由 showInitialWindow() 按焦点策略决定 —— 窗口必须真实可见
334
365
  // (产品承诺人可随时介入),但被 agent 拉起时不该把用户正在做的事打断。
335
366
  show: false,
@@ -764,6 +795,7 @@ function normalizeUrlInput(input) {
764
795
  }
765
796
 
766
797
  app.whenReady().then(() => {
798
+ applyAppIcon();
767
799
  createWindow();
768
800
 
769
801
  // === 自检钩子:AI_BROWSER_SELFTEST=crash ===
@@ -26,7 +26,7 @@ export const MCP_TOOLS = [
26
26
  {
27
27
  name: 'browse_get_tree',
28
28
  title: 'Read semantic tree',
29
- description: 'Return the page semantic UI tree (roles, labels, actions, bounds, urls).',
29
+ description: 'Return the page semantic tree (roles, labels, actions, bounds). Root adds editors (primary) when 2+ editable.',
30
30
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
31
31
  inputSchema: {
32
32
  type: 'object',
@@ -42,7 +42,7 @@ export const MCP_TOOLS = [
42
42
  {
43
43
  name: 'browse_act',
44
44
  title: 'Act on element',
45
- description: 'Act on a data-ai-id element. type appends at caret, setContent replaces all. Verb-specific params live in the group of the same name (click/read/press/wheel/drag).',
45
+ description: 'Act on a data-ai-id element. type appends at caret, setContent replaces all. page_changed:false = dispatched but page unchanged (wrapper).',
46
46
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
47
47
  inputSchema: {
48
48
  type: 'object',
@@ -50,7 +50,7 @@ export const MCP_TOOLS = [
50
50
  action: { type: 'string', enum: ['click', 'type', 'setContent', 'clear', 'focus', 'hover', 'scroll_to', 'upload', 'get_value', 'drag', 'press', 'wheel', 'paste'], description: 'Verb. Verb-specific params live in the group of the same name.' },
51
51
  target: { type: 'string', description: 'data-ai-id from the tree (e.g. e:button-12) — NOT the visible label. Optional for press; drag may use drag.from; upload also takes a CSS selector.' },
52
52
  text: { type: 'string', description: 'type: appended at the caret. setContent: replaces all. paste: plain fallback when html is absent.' },
53
- html: { type: 'string', description: 'paste: HTML, preferred — editors keep headings/lists/bold. Plain text loses formatting.' },
53
+ html: { type: 'string', description: 'paste: HTML, preferred (keeps headings/bold). landed_chars:null = unreadable.' },
54
54
  value: { type: 'string', description: 'Option value for a <select>.' },
55
55
  file: { type: 'string', description: 'upload: absolute path. target may be the input, the trigger, or a CSS selector.' },
56
56
  timeout_ms: { type: 'integer', description: 'Deadline ms (default 15000, 1s..300s). On timeout: success=false, timed_out=true, may have partially applied.' },
@@ -15,6 +15,12 @@ import { WINDOW_OPEN_SHIM_SOURCE } from './window_open_shim_source.js';
15
15
  import { pickOpenedTab, openedTabGraceMs } from '../shared/opened_tab.js';
16
16
  // 动作回执自带的"页面到底动没动"三态,以及 ref 过期诊断(两个纯函数模块)。
17
17
  import { withEffectState, isTargetMiss as isTargetMissError } from '../shared/effect_state.js';
18
+ // 前后快照探针:动作返回 success 只证明"命令发出去了",不证明"页面接住了"。
19
+ // 后者必须由掌握渲染进程的一侧给出(见 shared/page_probe.js 顶部事故说明)。
20
+ import pageProbe from '../shared/page_probe.js';
21
+ // 默认读法(DOM extractor)的"哪个 editable 才是正文"索引。AX 路径在
22
+ // normalizeAxTree 里已经自带,这条补的是默认路径 —— 见文件内注释。
23
+ import editorsProbe from '../shared/editors_probe.js';
18
24
  import { newTracker, noteTree, diagnoseMissingRef } from '../shared/ref_epoch.js';
19
25
  import { diffValues, previewOf, safeByteLength, DEFAULT_SNAPSHOT_JS, DIFF_MAX_ENTRIES, evictOldest } from './snapshot_diff.js';
20
26
  import { withDeadline, actionDeadlineMs } from '../shared/deadline.js';
@@ -81,6 +87,11 @@ const CANVAS_BRIDGE_KEY = '__aiCanvasBridge';
81
87
  // 请求头都拿不到)。关闭开关:AI_BROWSER_NETWORK_CAPTURE=0。
82
88
  const NETWORK_CAPTURE_DEFAULT = !(process.env.AI_BROWSER_NETWORK_CAPTURE === '0' || process.env.AI_BROWSER_NETWORK_CAPTURE === 'false');
83
89
 
90
+ // window.open("") 派生 tab 的"等垫片再导航"上限。超过就照常导航(退化成
91
+ // 该 tab 没有垫片),页面绝不能因为垫片安装慢而永久空白。
92
+ // 实测正常路径约 120–300ms(about:blank 的 did-finish-load + 两次 CDP 命令)。
93
+ const WINDOW_OPEN_NAV_WAIT_MS = 2000;
94
+
84
95
  // 具名按键 → CDP Input.dispatchKeyEvent 参数。agent 用自然名字('Delete'、
85
96
  // 'Escape'、'ArrowRight'、'a')而不是让人去查 Windows 虚拟键码。
86
97
  // vk 用 Windows 虚拟键码(Chromium 在 macOS 上也按它解释)。
@@ -174,6 +185,12 @@ class PageManager {
174
185
  this._openedTabs = new Map(); // sourceTabId -> { id, url } (new tab opened by window.open)
175
186
  this._windowOpenTokens = new Map(); // token -> { tabId, sourceTabId } (window.open shim, preload bridge.cjs)
176
187
  this._windowOpenShimTabs = new Set(); // 已注入 window.open 垫片的 tabId
188
+ // tabId -> Promise<void>:该 tab 的垫片**注册完成**的信号(不是"注入成功",
189
+ // 失败也 resolve —— 调用方要的是"可以往下走了",不是"一切顺利")。
190
+ // 存在的唯一理由:window.open("") 派生的 tab 里,ai:window_open_nav 必须等
191
+ // 垫片登记完再导航,否则真实文档先到、垫片只对"下一次文档"生效,那个 tab
192
+ // 就成了没有垫片的窗中窗(见 newTab 末尾长注释)。
193
+ this._windowOpenShimReady = new Map();
177
194
  this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
178
195
  this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
179
196
  this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
@@ -328,16 +345,41 @@ class PageManager {
328
345
  const entry = this._windowOpenTokens.get(p.token);
329
346
  const url = typeof p.url === 'string' ? p.url : '';
330
347
  if (!entry || !url) return;
331
- const view = this._getView(entry.tabId);
332
- if (view) {
333
- // 占位 tab(about:blank)→ 真实学习页。loadURL 的 rejection 由
334
- // 页面自身处理(站点可能会立刻再导航一次),这里只防 unhandled。
348
+ const nav = () => {
349
+ const view = this._getView(entry.tabId);
350
+ if (!view) {
351
+ // tab 已被用户/Agent 关掉:赋值静默丢弃(真实浏览器里关掉的
352
+ // 窗口被赋 location 也是无人接收)。
353
+ console.warn('[window_open] nav target tab gone:', entry.tabId, url);
354
+ return;
355
+ }
356
+ // loadURL 的 rejection 由页面自身处理(站点可能会立刻再导航一次),
357
+ // 这里只防 unhandled。
335
358
  view.webContents.loadURL(url).catch(() => {});
336
- } else {
337
- // tab 已被用户/Agent 关掉:赋值静默丢弃(真实浏览器里关掉的
338
- // 窗口被赋 location 也是无人接收)。
339
- console.warn('[window_open] nav target tab gone:', entry.tabId, url);
340
- }
359
+ };
360
+ // ⚠️ **必须等垫片登记完再导航**(2026-10-20 修,超星"进入学习"第二层)。
361
+ //
362
+ // 原来这里直接 loadURL。看上去时序够用(站点 ajax 往返几十毫秒,
363
+ // 而 about:blank 的 did-finish-load 通常更早),但那是**赌**:
364
+ // 只要 nav 抢在 did-finish-load 之前到达,真实文档就先把 renderer 占住,
365
+ // 随后 addScriptToEvaluateOnNewDocument 只对"下一次文档"生效 ——
366
+ // 新 tab 里 window.open 是原生实现,站点再走一次
367
+ // `window.open("") + w.location = url` 就得到 null,静默不跳转。
368
+ // 实测(2026-10-20):列表页的"进入学习"能开,新 tab 的 shim 是 false。
369
+ const ready = this._windowOpenShimReady.get(entry.tabId);
370
+ if (!ready) { nav(); return; } // 老路径/测试桩:没有信号就直接走
371
+ let timer = null;
372
+ const cap = new Promise((resolve) => {
373
+ timer = setTimeout(resolve, WINDOW_OPEN_NAV_WAIT_MS);
374
+ if (timer.unref) timer.unref();
375
+ });
376
+ Promise.race([ready, cap]).then(() => {
377
+ if (timer) clearTimeout(timer);
378
+ nav();
379
+ }, () => {
380
+ if (timer) clearTimeout(timer);
381
+ nav();
382
+ });
341
383
  });
342
384
 
343
385
  ipcMain.on('ai:window_open_close', (_event, payload) => {
@@ -717,9 +759,45 @@ class PageManager {
717
759
  // 「ref 过期了」(页面变过,重读树即可)和「ref 从来没对过」(改传参)。
718
760
  // 见 shared/ref_epoch.js 顶部注释。
719
761
  this._noteTreeRefs(tid, res && res.tree);
762
+ // 多编辑器的消歧索引(见 shared/editors_probe.js)。AX 路径的树根已经带
763
+ // `editors`,这里只补默认路径;focused_only 的子树没有"整页有哪几个编辑区"
764
+ // 这层语义,跳过。
765
+ if (res && res.tree && !res.tree.editors && !focusedOnly) {
766
+ const idx = await this._editorsIndex(tid, res.tree);
767
+ if (idx) res.tree.editors = idx;
768
+ }
720
769
  return res;
721
770
  }
722
771
 
772
+ /**
773
+ * 扫一遍页面上的可编辑区,按面积给出 primary。
774
+ * 失败静默返回 null —— 消歧索引是**增强**,读不到不该让 get_tree 失败。
775
+ */
776
+ async _editorsIndex(tid, tree) {
777
+ const view = this._getView(tid);
778
+ if (!view) return null;
779
+ let rows = null;
780
+ try {
781
+ const d = view.webContents.debugger;
782
+ if (!d || typeof d.isAttached !== 'function' || !d.isAttached()) return null;
783
+ const r = await d.sendCommand('Runtime.evaluate', {
784
+ expression: editorsProbe.EDITORS_PROBE_JS,
785
+ returnByValue: true,
786
+ });
787
+ rows = r && r.result && r.result.value;
788
+ } catch (_e) {
789
+ return null;
790
+ }
791
+ const known = new Set();
792
+ const walk = (n) => {
793
+ if (!n) return;
794
+ if (n.id) known.add(n.id);
795
+ for (const c of n.children || []) walk(c);
796
+ };
797
+ walk(tree);
798
+ return editorsProbe.buildEditorsIndex(rows, known);
799
+ }
800
+
723
801
  /** per-tab 的 ref 版本追踪器(懒建,避免动构造函数)。 */
724
802
  _refTracker(tid) {
725
803
  if (!this._refTrackers) this._refTrackers = new Map();
@@ -1333,16 +1411,59 @@ class PageManager {
1333
1411
  * 放在这一层而不是各个子路径里,是因为挂住的位置不止一个(AX handles、指针目标
1334
1412
  * 解析、拖拽按键分发都可能),逐个补等于逐个漏。
1335
1413
  */
1414
+ /**
1415
+ * 动作前后各拍一次页面指纹(只读、不写属性、不触发事件)。
1416
+ *
1417
+ * 探针失效一律返回 null 并让调用方跳过 —— 观测能力不足不能反过来让动作失败。
1418
+ * 三种失效都真实发生过:debugger 没 attach、主线程被占住导致 CDP 不返回、
1419
+ * 页面返回了 __err。全部走静默降级,但**绝不用 null 冒充"没变化"**:
1420
+ * diffProbe 对 null 显式返回 changed:null,回执里就不会出现 page_changed。
1421
+ */
1422
+ async _pageProbe(tid, action) {
1423
+ if (!pageProbe.shouldProbe(action)) return null;
1424
+ const view = this._getView(tid);
1425
+ if (!view) return null;
1426
+ try {
1427
+ const d = view.webContents.debugger;
1428
+ if (!d || typeof d.isAttached !== 'function' || !d.isAttached()) return null;
1429
+ // 1.5s 硬上限:主线程卡住时 sendCommand 永不返回,等它等于把 ui.act
1430
+ // 又挂死一次(这一层存在的全部意义就是不许挂)。
1431
+ const call = d.sendCommand('Runtime.evaluate', {
1432
+ expression: pageProbe.PAGE_PROBE_JS,
1433
+ returnByValue: true,
1434
+ });
1435
+ const timer = new Promise((r) => setTimeout(() => r(null), 1500));
1436
+ const r = await Promise.race([call.catch(() => null), timer]);
1437
+ const v = r && r.result && r.result.value;
1438
+ return v && !v.__err ? v : null;
1439
+ } catch (_e) {
1440
+ return null;
1441
+ }
1442
+ }
1443
+
1336
1444
  async executeAction(action, target, params = {}, tabId) {
1337
1445
  const ms = actionDeadlineMs(action, params);
1338
1446
  const tid = tabId !== undefined ? tabId : this.activeTab;
1447
+ // 动作前先拍一张。为什么拍在 withDeadline 外面:deadline 只应该盖住动作
1448
+ // 本身,探针有它自己的 1.5s 上限,混进同一个预算会让"动作超时"和
1449
+ // "探针慢"两种完全不同的故障共用一个错误码。
1450
+ const probeBefore = await this._pageProbe(tid, action);
1339
1451
  try {
1340
1452
  const res = await withDeadline(
1341
1453
  this._executeActionInner(action, target, params, tabId),
1342
1454
  ms,
1343
1455
  `action:${action}`
1344
1456
  );
1345
- return this._annotateActResult(res, action, target, tid);
1457
+ let out = this._annotateActResult(res, action, target, tid);
1458
+ // 动作后拍第二张。settle 是必须的:paste/type 内部已经等过一轮,但 click
1459
+ // 触发的弹窗/路由要等框架提交 + 一帧渲染,立刻拍必然拿到旧指纹 ——
1460
+ // 那会把每一次成功的弹窗点击都误报成"页面没变",比不报更糟。
1461
+ if (probeBefore) {
1462
+ await new Promise((r) => setTimeout(r, pageProbe.settleMs(action)));
1463
+ const probeAfter = await this._pageProbe(tid, action);
1464
+ out = pageProbe.applyProbe(out, probeBefore, probeAfter, action);
1465
+ }
1466
+ return out;
1346
1467
  } catch (e) {
1347
1468
  if (e && e.__deadline) {
1348
1469
  // 明确告诉 agent 两件事:① 它该重试/换策略;② 这个动作**可能已经部分生效**
@@ -2454,6 +2575,30 @@ class PageManager {
2454
2575
  // 派发不会向下传播到子节点,监听器收不到,defaultPrevented 恒为 false。
2455
2576
  // 实测:B站 read-editor 拆两步派发 → 编辑器完全无反应;合并 + 选定最深层
2456
2577
  // 可编辑元素后成功。
2578
+ // 页面内"读回编辑器正文"的共用片段。参数是**已经在前面的表达式里定义好的
2579
+ // 变量名**(不是选择器),因为不同时机能拿到的句柄不同:paste 那一刻有
2580
+ // `ed`,之后重采样只能重新按 ref 查。
2581
+ //
2582
+ // 取"多个候选里最长的那个"而不是"第一个非空的":页面上同时存在搜索框、
2583
+ // 标题框、正文编辑器时,第一个非空的很可能是别的东西(知乎顶部搜索框就
2584
+ // 这么骗过一次粘性校验)。而正文的长度必然最大 —— 这条启发式在
2585
+ // 知乎/掘金/CSDN/OSChina/B站 五个站上都成立。
2586
+ const landedProbe = (v) =>
2587
+ `var cands=[],push=function(n,s){if(n&&cands.length<6)cands.push([n,s]);};` +
2588
+ `push(${v},'target');` +
2589
+ // 深层 activeElement(穿透 shadow root):编辑面常常在编辑器组件自己的
2590
+ // shadow DOM 里,document.activeElement 只到宿主元素。
2591
+ `try{var a=document.activeElement,g=0;while(a&&a.shadowRoot&&a.shadowRoot.activeElement&&g++<8)a=a.shadowRoot.activeElement;push(a,'active');}catch(_e){}` +
2592
+ // 目标内部的第一个编辑面(Draft.js / ProseMirror 的正文都是容器里的子节点)
2593
+ `try{var q=${v}&&${v}.querySelector&&${v}.querySelector('[contenteditable=true],textarea,.cm-content,.ProseMirror,[data-lexical-editor=true]');push(q,'inner');}catch(_e){}` +
2594
+ // 上层编辑器容器:目标可能只是工具栏/占位层,正文在它的祖先里
2595
+ `try{var hh=${v}&&${v}.closest&&${v}.closest('.cm-editor,.cm-scroller,.CodeMirror,.ProseMirror,.DraftEditor-root,[contenteditable=true],form,article,main');push(hh,'host');}catch(_e){}` +
2596
+ `var best={n:0,src:'',p:''};` +
2597
+ `for(var i=0;i<cands.length;i++){var e=cands[i][0],s=cands[i][1],t='';` +
2598
+ `try{t=(e.value!=null)?String(e.value):String(e.innerText||e.textContent||'');}catch(_e){}` +
2599
+ `t=t.replace(/\\s+/g,' ');var L=t.replace(/ /g,'').length;` +
2600
+ `if(L>best.n)best={n:L,src:s,p:t.slice(0,200)};}`;
2601
+
2457
2602
  const payload = JSON.stringify({ html, text });
2458
2603
  const res = await run(
2459
2604
  `var p=${payload};` +
@@ -2471,7 +2616,11 @@ class PageManager {
2471
2616
  `dt.setData('text/plain',p.text||p.html.replace(/<[^>]+>/g,' ').replace(/&nbsp;/g,' ').replace(/\\s+/g,' ').trim());` +
2472
2617
  `var ev=new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true,composed:true});` +
2473
2618
  `var notPrevented=ed.dispatchEvent(ev);` +
2474
- `return {ok:true,prevented:!notPrevented,tag:ed.tagName};`
2619
+ // 同步读一次:CodeMirror 6 / ProseMirror / Quill 都在 paste handler 里
2620
+ // 同步写文档,这一刻就能读到;异步提交的(Draft.js 走 setTimeout)
2621
+ // 读不到,交给后面的延迟采样。
2622
+ landedProbe('ed') +
2623
+ `return {ok:true,prevented:!notPrevented,tag:ed.tagName,landed0:best};`
2475
2624
  );
2476
2625
  if (!res || !res.ok) {
2477
2626
  return { success: false, error: (res && res.err) || 'paste failed', method: 'synthetic-clipboard-event' };
@@ -2486,19 +2635,45 @@ class PageManager {
2486
2635
  consumed: false,
2487
2636
  };
2488
2637
  }
2489
- // 落盘校验:读回编辑器内容。归一化沿用 P0-2 修过的口径(换行会被编辑器
2490
- // 膨胀,逐字比对必然失配)。只给 html 时无法文本比对,退而检查"有内容"。
2491
- await new Promise((r) => setTimeout(r, 350)); // 编辑器处理粘贴多为异步(React 提交)
2492
- const landed = await run(`var e=${deepActiveExpr()};return {text:(e&&(e.innerText||e.value||''))||'',html:(e&&e.innerHTML||'')};`);
2493
- const landedText = (landed && landed.text) || '';
2494
- const landedLen = landedText.replace(/\s+/g, '').length;
2638
+ // 落盘校验:读回编辑器内容。
2639
+ //
2640
+ // 为什么不能只读一次、也不能只读 activeElement(2026-10-19 掘金实机):
2641
+ // 掘金(CodeMirror 6)粘贴成功、正文完整,回执却是 landed_chars: 0。
2642
+ // 根因是读回走了 deepActive —— 那一刻 activeElement 并不是编辑面(编辑器
2643
+ // 把焦点放在 .cm-scroller 上,真正的内容在 .cm-content),于是读到空。
2644
+ // 调用方看到 0 就再粘一次,正文变成两份。**假阴性比漏报贵得多**:它直接
2645
+ // 诱发重复写入,而重复写入是这件工具最不该制造的事故。
2646
+ //
2647
+ // 所以现在:① 以**刚粘贴的那个元素**为主,activeElement / 内部编辑面 /
2648
+ // 上层容器依次兜底;② 三次采样(同步 / +350ms / +900ms)取最大值,覆盖
2649
+ // 异步提交;③ 真读不到时给 landed_chars: null 而不是 0 —— 0 是"确定空",
2650
+ // null 是"读不到",这两种事实对调用方的决策完全相反。
2651
+ let landed = res.landed0 && res.landed0.n > 0 ? res.landed0 : null;
2652
+ let samples = 1;
2653
+ for (const wait of [350, 900]) {
2654
+ if (landed) break;
2655
+ await new Promise((r) => setTimeout(r, wait));
2656
+ const again = await run(
2657
+ `var t=${findRefExpr(JSON.stringify(aiId))}||document.activeElement;if(!t)return {n:0,src:'none',p:''};` +
2658
+ landedProbe('t') + `return best;`
2659
+ ).catch(() => null);
2660
+ samples++;
2661
+ if (again && again.n > 0) landed = again;
2662
+ }
2663
+ const landedLen = landed ? landed.n : 0;
2495
2664
  if (!landedLen) {
2496
2665
  return {
2497
2666
  success: false,
2498
- error: 'paste was consumed but nothing landed — the editor may have rejected the payload (unsanitized HTML?). Read the editor content back to confirm.',
2667
+ // 措辞从 "nothing landed"(断言)改成 "could not be read back"(观测)。
2668
+ // 前者在掘金那种场景里是错的 —— 它会把一次成功的粘贴报成失败,而
2669
+ // agent 对 success:false 的标准反应就是重试一次。
2670
+ error: 'paste was consumed but the editor content could not be read back — this is NOT proof that nothing landed (CodeMirror-style editors keep the document outside the focused node). '
2671
+ + 'Verify with get_value on the same ref before doing anything else; re-pasting on a live editor duplicates the content.',
2499
2672
  method: 'synthetic-clipboard-event',
2500
2673
  consumed: true,
2501
- landed_chars: 0,
2674
+ landed_chars: null,
2675
+ landed_verified: false,
2676
+ landed_samples: samples,
2502
2677
  };
2503
2678
  }
2504
2679
  return {
@@ -2507,8 +2682,11 @@ class PageManager {
2507
2682
  consumed: true,
2508
2683
  in_frame: inFrame,
2509
2684
  landed_chars: landedLen,
2510
- // 只给 html 时调用方无法预知文本形态,把读回内容捎上,省一轮查询。
2511
- ...(html && !text ? { landed_preview: landedText.slice(0, 200) } : {}),
2685
+ landed_verified: true,
2686
+ // 读回来源:'target' 最可信;'host' 是容器兜底,可能把页面别处的文字
2687
+ // 算进来 —— 长度只用于"有没有落地",不做逐字比对,所以偏大是安全方向。
2688
+ landed_source: landed.src,
2689
+ ...(html && !text ? { landed_preview: landed.p || '' } : {}),
2512
2690
  };
2513
2691
  }
2514
2692
 
@@ -3093,11 +3271,18 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3093
3271
  const wc0 = view.webContents;
3094
3272
  const targetUrl = url || null;
3095
3273
  if (wc0 && !wc0.isDestroyed()) {
3274
+ // 垫片就绪信号:先挂上去,再去加载 about:blank。顺序不能反 ——
3275
+ // ai:window_open_nav 可能在这一行之后立刻到达(IPC 保序,但它不等我们)。
3276
+ let signalReady = () => {};
3277
+ const ready = new Promise((resolve) => { signalReady = resolve; });
3278
+ this._windowOpenShimReady.set(tabId, ready);
3096
3279
  let proceeded = false;
3097
3280
  const proceed = () => {
3098
3281
  if (proceeded) return;
3099
3282
  proceeded = true;
3100
3283
  Promise.resolve(this._ensureWindowOpenShim(tabId, view)).finally(() => {
3284
+ // 失败也放行:宁可这个 tab 没有垫片,也不能把它卡在 about:blank。
3285
+ signalReady();
3101
3286
  if (targetUrl && wc0 && !wc0.isDestroyed()) {
3102
3287
  wc0.loadURL(targetUrl).catch(() => {});
3103
3288
  }
@@ -3338,6 +3523,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3338
3523
  if (entry.tabId === tabId) this._windowOpenTokens.delete(token);
3339
3524
  }
3340
3525
  this._windowOpenShimTabs.delete(tabId);
3526
+ this._windowOpenShimReady.delete(tabId);
3341
3527
  // Release per-tab network resources so a closed tab can never leak its
3342
3528
  // debugger attachment or cached request entries / stale monitors.
3343
3529
  this._cleanupTabResources(tabId);
@@ -3815,6 +4001,10 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3815
4001
  );
3816
4002
  return true;
3817
4003
  } catch (e) {
4004
+ // 失败时撤掉记账:留着就等于给这个 tab 判了"永久无垫片"。
4005
+ // 撤掉后下一次 window.open 还有机会补上(CDP 偶发挂住是环境问题,
4006
+ // 不是这个 tab 的固有属性)。
4007
+ this._windowOpenShimTabs.delete(tabId);
3818
4008
  console.error('[window_open] shim inject failed:', tabId, e && e.message);
3819
4009
  return false;
3820
4010
  }
@@ -49,7 +49,10 @@ export const WINDOW_OPEN_SHIM_SOURCE = `(function () {
49
49
  window.open = function (url, name, _features) {
50
50
  var target = (url === undefined || url === null) ? '' : String(url);
51
51
  var token = 'wo' + (++seq) + '_' + Date.now();
52
- var ok = send('open', token, target, name || '');
52
+ // ⚠️ 相对地址必须在这里就解析成绝对。主进程拿到的 '/foo.html' 没有 base,
53
+ // loadURL 会直接报 ERR_INVALID_URL(表现为一个 file:// 错误页)。
54
+ // 实测 2026-10-20:window.open('/l2.html','_blank') 开出的是错误页。
55
+ var ok = send('open', token, target ? abs(target) : '', name || '');
53
56
  if (!ok) {
54
57
  // 没有桥:退回原生语义(deny → null),至少不像成功那样骗站点。
55
58
  return null;
@@ -206,7 +206,19 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
206
206
  break;
207
207
  }
208
208
  case 'ui.evaluate': {
209
- const js = String(params.js ?? '');
209
+ // 参数名是 js;expr 是 MCP 层的叫法,作为别名保留 —— 文档里写错一次
210
+ // (2026-10-20 技能里写成 expr)就会得到 `{value: undefined}`,
211
+ // 看上去像"页面没东西",实际是表达式根本没执行。别名 + 空表达式报错,
212
+ // 把"静默返回 undefined"这条路彻底堵掉。
213
+ const rawJs = params.js !== undefined ? params.js : params.expr;
214
+ const js = String(rawJs === undefined || rawJs === null ? '' : rawJs);
215
+ if (!js.trim()) {
216
+ send({
217
+ jsonrpc: '2.0', id,
218
+ error: { code: -32602, message: 'empty expression: pass {"js":"<expression>"} (alias: "expr")' },
219
+ });
220
+ break;
221
+ }
210
222
  // Guard rails (shared single source) — enforce length + reject
211
223
  // obvious Node-exfil patterns. The MCP layer applies the same guard;
212
224
  // a direct ws client bypasses MCP, so enforce the same contract here.
@@ -160,10 +160,42 @@ function doPaste(el, params) {
160
160
  if (notPrevented) {
161
161
  return {
162
162
  success: false,
163
+ consumed: false,
163
164
  error: "paste event was not consumed by the page (defaultPrevented=false) — try a child node ref, or setContent for plain text"
164
165
  };
165
166
  }
166
- return { success: true, method: "synthetic-clipboard-event" };
167
+ // 读回口径与 page_manager._pasteViaCdp 完全一致:候选里取最长,读不到给
168
+ // null 而不是 0。为什么必须有:preload 这条路以前**只要页面 preventDefault
169
+ // 就报 success**,连"有没有内容"都不看 —— 这正是"工具说成功、页面没变"
170
+ // 最纯粹的一种来源。0 表示确定空(可安全重试),null 表示读不到(先核对)。
171
+ var best = { n: 0, src: "target", p: "" };
172
+ var cands = [editor];
173
+ try {
174
+ if (document.activeElement && document.activeElement !== editor) cands.push(document.activeElement);
175
+ } catch (_e) {}
176
+ try {
177
+ var q = editor.querySelector && editor.querySelector("[contenteditable=true],textarea,.cm-content,.ProseMirror");
178
+ if (q) cands.push(q);
179
+ } catch (_e) {}
180
+ for (var i = 0; i < cands.length; i++) {
181
+ var e = cands[i], t = "";
182
+ try { t = (e.value != null) ? String(e.value) : String(e.innerText || e.textContent || ""); } catch (_e) {}
183
+ t = t.replace(/\s+/g, " ");
184
+ var L = t.replace(/ /g, "").length;
185
+ if (L > best.n) best = { n: L, src: (i === 0 ? "target" : "candidate"), p: t.slice(0, 200) };
186
+ }
187
+ var out = {
188
+ success: true,
189
+ method: "synthetic-clipboard-event",
190
+ consumed: true,
191
+ landed_chars: best.n > 0 ? best.n : null,
192
+ landed_verified: best.n > 0,
193
+ };
194
+ if (best.n > 0) out.landed_source = best.src;
195
+ if (!best.n) {
196
+ out.effect_hint = "paste was consumed but the editor content could not be read back — verify with get_value before retrying; re-pasting on a live editor duplicates the content.";
197
+ }
198
+ return out;
167
199
  }
168
200
 
169
201
  // setContent — write to any editor type including Draft.js/ProseMirror
@@ -0,0 +1,96 @@
1
+ // shared/editors_probe.js — DOM 侧的可编辑区清单,给**默认读法**补消歧信息。
2
+ //
3
+ // 为什么 AX 那边(main/axExtractor.js 的 summarizeEditors)已经有了一份、
4
+ // 这里还要再来一份:两条读法覆盖的是同一批页面,但 browse_get_tree 的**默认**
5
+ // 路径走 DOM extractor,不产出 `editable` 状态串,也就没有可编辑区索引。
6
+ // 只在 AX 路径修,等于告诉用户"想知道哪个是正文请加 ax:true" —— 而默认路径
7
+ // 恰恰是最常被用的那条。坑必须在自己所在的那条路上修掉。
8
+ //
9
+ // 只读、只碰四个属性(rect / placeholder / aria-label / data-ai-id),不写 DOM。
10
+ export const EDITORS_PROBE_JS = `(function(){
11
+ try{
12
+ var sel='input,textarea,[contenteditable=true],[contenteditable=""],[role=searchbox]';
13
+ var all=document.querySelectorAll(sel),out=[];
14
+ for(var i=0;i<all.length&&out.length<40;i++){
15
+ var e=all[i],r;
16
+ try{r=e.getBoundingClientRect();}catch(_e){continue;}
17
+ var w=Math.round(r.width),h=Math.round(r.height);
18
+ if(w*h<800) continue;
19
+ var tag=e.tagName.toLowerCase();
20
+ if(tag==='input'){
21
+ var ty=String(e.type||'text').toLowerCase();
22
+ if(/^(hidden|submit|button|checkbox|radio|file|reset|image|color|range)$/.test(ty)) continue;
23
+ }
24
+ var ce=e.getAttribute('contenteditable');
25
+ var et=(ce==='true'||ce==='')?'richtext':(tag==='textarea'?'textarea':'textbox');
26
+ var ph=String(e.getAttribute('placeholder')||'').slice(0,40);
27
+ out.push({
28
+ ref:String(e.getAttribute('data-ai-id')||''),
29
+ role:String(e.getAttribute('role')||'textbox'),
30
+ editor_type:et,w:w,h:h,
31
+ label:String(e.getAttribute('aria-label')||'').slice(0,40),
32
+ placeholder:ph,
33
+ readonly:!!e.readOnly,
34
+ type:tag==='input'?String(e.type||'text').toLowerCase():''
35
+ });
36
+ }
37
+ return out;
38
+ }catch(e){return {__err:String((e&&e.message)||e)}}
39
+ })()`;
40
+
41
+ // 与 axExtractor 的 SEARCH_LIKE 必须一致 —— 两处口径分叉会让"同一个页面在
42
+ // ax:true 与默认读法下给出相反的 primary",那比没有索引更糟。这里不 import
43
+ // 是因为 axExtractor 属于 main 进程模块,而本文件也被 preload 侧测试引用。
44
+ export const SEARCH_LIKE = /搜索|查找|检索|search|find/i;
45
+
46
+ const MIN_AREA = 800;
47
+ const PRIMARY_MIN_AREA = 20000;
48
+
49
+ /**
50
+ * 把 DOM 探针的原始行转换成与 AX 路径**同构**的 editors 数组。
51
+ * 纯函数,可直接单测(不需要 Electron)。
52
+ *
53
+ * @param {Array} rows EDITORS_PROBE_JS 的返回值
54
+ * @param {Set} knownRefs 树里真实存在的 ref(探针是全局扫的,包含被剪枝的节点)
55
+ * @returns {Array|null} <2 个有效编辑区时返回 null
56
+ */
57
+ export function buildEditorsIndex(rows, knownRefs) {
58
+ if (!Array.isArray(rows)) return null;
59
+ const known = knownRefs instanceof Set ? knownRefs : new Set(knownRefs || []);
60
+ const list = [];
61
+ for (const r of rows) {
62
+ if (!r || !r.ref || (known.size && !known.has(r.ref))) continue;
63
+ const w = Number(r.w) || 0;
64
+ const h = Number(r.h) || 0;
65
+ const area = w * h;
66
+ if (area < MIN_AREA) continue;
67
+ const ph = String(r.placeholder || '');
68
+ const label = String(r.label || '');
69
+ const searchlike = r.role === 'searchbox'
70
+ || r.type === 'search'
71
+ || SEARCH_LIKE.test(ph)
72
+ || SEARCH_LIKE.test(label);
73
+ list.push({
74
+ ref: r.ref,
75
+ role: r.role || 'textbox',
76
+ editor_type: r.editor_type || 'textbox',
77
+ label: label.slice(0, 40),
78
+ ...(ph ? { placeholder: ph.slice(0, 40) } : {}),
79
+ w, h, area,
80
+ readonly: !!r.readonly,
81
+ ...(searchlike ? { searchlike: true } : {}),
82
+ });
83
+ }
84
+ if (list.length < 2) return null;
85
+ const sorted = list.sort((a, b) => b.area - a.area).slice(0, 8);
86
+ let primary = null;
87
+ for (const e of sorted) {
88
+ if (e.readonly || e.searchlike) continue;
89
+ if (e.area < PRIMARY_MIN_AREA) break;
90
+ primary = e.ref;
91
+ break;
92
+ }
93
+ return sorted.map(({ area, ...rest }) => (rest.ref === primary ? { ...rest, primary: true } : rest));
94
+ }
95
+
96
+ export default { EDITORS_PROBE_JS, buildEditorsIndex, SEARCH_LIKE };
@@ -67,6 +67,11 @@ const PARTIAL_PATTERNS = [
67
67
  /may have partially applied/i,
68
68
  /not consumed by the page/i,
69
69
  /consumed but nothing landed/i,
70
+ // 2026-10-19 之后 paste 的读回口径:读不到 ≠ 没落地(CodeMirror 把正文放在
71
+ // 焦点节点之外)。这条必须落在 partial 而不是 no-effect 里 —— 判成 none 会
72
+ // 告诉 agent"照原样重试是安全的",而在活着的编辑器上重试就是内容翻倍。
73
+ // 旧文案保留在上面,历史回执与日志仍要能被正确分类。
74
+ /could not be read back/i,
70
75
  /interrupted|disconnected|target closed|debugger detached/i,
71
76
  /deadline exceeded|timed? ?out/i,
72
77
  ];
@@ -0,0 +1,154 @@
1
+ // shared/page_probe.js — "这一步到底改动了页面没有"必须由工具回答,不能让 agent 猜。
2
+ //
3
+ // 事故(2026-10-19,B站投稿):
4
+ // browse_act click 返回 success:true,agent 以为投递成功,继续往下走。
5
+ // 实际点中的是**外层** `<div class="submit-container">`,真正的可点元素是它
6
+ // 里面的 `<span class="submit-add">`。页面一个字节都没变,回执却是一片绿。
7
+ // 错误在第三步之后才暴露 —— 这时已经不可能靠"回头看"把因果连起来。
8
+ //
9
+ // 为什么这必须是原语而不是让调用方自查:
10
+ // 调用方(模型)没有"我刚才那一手到底生效了吗"的直觉。它拿到的 success
11
+ // 只说明**命令发出去了**,不说明**页面接住了**。中间这层语义只能由掌握
12
+ // 渲染进程的工具补上。否则每个站点、每个动作都要调用方自己写一遍快照对比,
13
+ // 而它连"该在什么时候拍快照"都不知道(拍早了页面还没渲染)。
14
+ //
15
+ // 设计取舍:
16
+ // 1. 只读、极轻。一次 evaluate 拿 5 个标量,不碰 AX 树、不截图。
17
+ // 动作用户关心的时延是"点击后多久有反馈",多 2 次 evaluate(各 ~1ms)
18
+ // 换掉一整类静默失败,值得。
19
+ // 2. 指纹取**全量文本 hash + DOM 节点数 + 焦点元素内容长度 + URL**。
20
+ // 只看文本会漏掉"弹窗打开但文案相同",只看节点数会漏掉"同位置替换"。
21
+ // 四个信号任一变化即视为 changed —— 宁可报 changed:true 放过,
22
+ // 不可报 false 冤枉(false 会诱导 agent 重复提交)。
23
+ // 3. changed:false 不等于失败。点击一个已选中的 radio、关闭一个已关的抽屉,
24
+ // 都是合法的无变化。所以这里只**陈述观测**,把结论留给调用方,并配一句
25
+ // 可执行的下一步。真正"该有变化却无变化"的判定放在 MUST_HAVE_EFFECT 里,
26
+ // 只对写类动作(输入/粘贴/上传)生效 —— 那些动作没有"合法无变化"的情形。
27
+ //
28
+ // 4. 主文档视角的边界:探针只读 top document。OOPIF 子帧里的变化它看不见,
29
+ // 因此 changed:false 的措辞是"主文档未变化",不是"什么都没发生"。这个区别
30
+ // 写进 hint 里,避免把 B站 read-editor 那种跨进程编辑器误判成失败。
31
+
32
+ // 页面内执行的探针。纯读,不写任何属性,不触发任何事件。
33
+ // 返回的字段都用单字母:它要跨 RPC 边界回传,越短越好,语义在下面注释里。
34
+ // t = body 全量文本长度(折叠空白后,与 DEFAULT_SNAPSHOT_JS 的口径一致)
35
+ // h = 该文本的 djb2 hash(base36)—— 长度相同的内容替换靠它抓
36
+ // d = 当前**可编辑**焦点元素的内容长度(深浅穿透 shadow root)—— 输入类动作的主信号。
37
+ // 只算 input/textarea/select/contenteditable:点击一个普通 div 也会让
38
+ // activeElement 变过去,若把它的 innerText 算进来,每次"点了个没反应的
39
+ // 按钮"都会因为焦点跑过去而报 page_changed:true —— 那等于把这个探针
40
+ // 最该抓的场景(点了没反应)全丢掉。实测于 2026-10-22 的 fixture 回归。
41
+ // n = DOM 元素总数(粗粒度结构变化:弹窗、列表插入)
42
+ // u = location.href(SPA 路由跳转)
43
+ export const PAGE_PROBE_JS = `(function(){
44
+ try{
45
+ var b=document.body||document.documentElement;
46
+ var t=(b&&(b.innerText||b.textContent))||'';
47
+ t=t.replace(/\\s+/g,' ');
48
+ var h=5381;
49
+ for(var i=0;i<t.length;i++)h=((h<<5)+h+t.charCodeAt(i))|0;
50
+ var a=document.activeElement,g=0;
51
+ try{ while(a&&a.shadowRoot&&a.shadowRoot.activeElement&&g++<8)a=a.shadowRoot.activeElement; }catch(_e){}
52
+ var dv='';
53
+ try{
54
+ if(a){
55
+ var tg=a.tagName;
56
+ var ed=(a.isContentEditable===true)||tg==='INPUT'||tg==='TEXTAREA'||tg==='SELECT';
57
+ if(ed) dv=(a.value!=null)?String(a.value):(a.innerText||a.textContent||'');
58
+ }
59
+ }catch(_e){}
60
+ var n=0;
61
+ try{ n=document.getElementsByTagName('*').length; }catch(_e){}
62
+ return {t:t.length,h:(h>>>0).toString(36),d:(dv||'').replace(/\\s+/g,'').length,n:n,u:String(location.href)};
63
+ }catch(e){ return {__err:String((e&&e.message)||e)}; }
64
+ })()`;
65
+
66
+ // 哪些动作值得拍前后快照。只读动作(get_value / focus / hover / scroll_to)
67
+ // 不拍:它们本来就不该改页面,拍了只是白花两次往返。
68
+ // wheel 也不拍:滚动会改变 scroll 位置但 body.innerText 不变,节点数也不变,
69
+ // 必然报 false —— 那是个稳定的假阴性,不如不报。
70
+ const PROBED_ACTIONS = new Set([
71
+ 'click', 'type', 'setContent', 'clear', 'paste', 'upload', 'drag', 'press',
72
+ ]);
73
+
74
+ // 这些动作**没有"合法无变化"的情形**:输入/粘贴/上传成功就一定有内容落地。
75
+ // 它们报 page_changed:false 时给强提示(并说明先读回再决定)。
76
+ const MUST_HAVE_EFFECT = new Set(['type', 'setContent', 'clear', 'paste', 'upload']);
77
+
78
+ export function shouldProbe(action) {
79
+ return PROBED_ACTIONS.has(String(action || ''));
80
+ }
81
+
82
+ // 动作返回后到拍快照之间要等多久,让 React/Vue 的提交与路由跳转落地。
83
+ // 写类动作内部已经等过一轮(paste 350ms、type 有 input 校验),只需短等;
84
+ // click 触发弹窗/路由时框架往往在下一个 microtask + 一帧后才渲染,给足 220ms。
85
+ export function settleMs(action) {
86
+ return MUST_HAVE_EFFECT.has(String(action || '')) ? 80 : 220;
87
+ }
88
+
89
+ const HINT_WRITE =
90
+ 'page_changed=false: the action returned success but the main document is byte-identical '
91
+ + '(same text hash, same DOM node count, same focused-field length). The payload most likely '
92
+ + 'never landed — a wrapper element was targeted, or the editor listens on a child surface. '
93
+ + 'Read the element back with get_value BEFORE retrying: a second paste on a live editor '
94
+ + 'duplicates the content instead of fixing it.';
95
+
96
+ const HINT_CLICK =
97
+ 'page_changed=false: the action returned success but the main document did not change. Two '
98
+ + 'common causes: (a) the click hit a wrapper element and the real handler is on a deeper '
99
+ + 'node — re-read the tree and target the inner <span>/<a>/<button>; (b) the control was '
100
+ + 'already in the requested state, in which case there is nothing to retry. If the effect was '
101
+ + 'expected inside an out-of-process iframe, this probe cannot see it — verify with '
102
+ + 'get_tree/screenshot instead.';
103
+
104
+ /**
105
+ * 比较前后两次探针。纯函数,无 Electron 依赖,可直接单测。
106
+ * @returns {{changed: boolean|null, delta: object|null}}
107
+ * changed=null 表示数据不可比(某次探针失败),调用方应忽略而不是当成 false。
108
+ */
109
+ export function diffProbe(before, after) {
110
+ const ok = (p) => p && typeof p === 'object' && !p.__err && typeof p.h === 'string';
111
+ if (!ok(before) || !ok(after)) return { changed: null, delta: null };
112
+ const delta = {
113
+ text_chars: (Number(after.t) || 0) - (Number(before.t) || 0),
114
+ dom_nodes: (Number(after.n) || 0) - (Number(before.n) || 0),
115
+ focused_chars: (Number(after.d) || 0) - (Number(before.d) || 0),
116
+ };
117
+ const changed = before.h !== after.h
118
+ || (Number(before.n) || 0) !== (Number(after.n) || 0)
119
+ || (Number(before.d) || 0) !== (Number(after.d) || 0)
120
+ || before.u !== after.u;
121
+ return { changed, delta };
122
+ }
123
+
124
+ /**
125
+ * 把观测结果写进动作回执。纯函数(返回新对象)。
126
+ *
127
+ * 只在 changed === false 时附加 page_delta 和 effect_hint:
128
+ * 正常路径(changed:true)不多说一个字,避免每步回执都多两行噪音。
129
+ */
130
+ export function applyProbe(res, before, after, action) {
131
+ if (!res || typeof res !== 'object') return res;
132
+ const d = diffProbe(before, after);
133
+ if (d.changed === null) return res;
134
+ const out = { ...res, page_changed: d.changed };
135
+ if (d.changed) return out;
136
+ out.page_delta = d.delta;
137
+ const isWrite = MUST_HAVE_EFFECT.has(String(action || ''));
138
+ if (!out.effect_hint) {
139
+ out.effect_hint = isWrite ? HINT_WRITE : HINT_CLICK;
140
+ }
141
+ // 写类动作 + 主文档零变化 = 内容很可能没落地。把 effect_state 从 committed
142
+ // 降回 unknown,否则回执是自相矛盾的:success:true + effect_state:committed
143
+ // 等于告诉 agent"这一手已经生效,别重试",而观测说它没生效。
144
+ //
145
+ // 为什么是 unknown 而不是 none:探针只看主文档,OOPIF 子帧里的编辑器
146
+ // (B站 read-editor)变化它看不见。unknown + verify_first 是唯一诚实的选择。
147
+ if (isWrite && out.success === true) {
148
+ out.effect_state = 'unknown';
149
+ out.retry = 'verify_first';
150
+ }
151
+ return out;
152
+ }
153
+
154
+ export default { PAGE_PROBE_JS, shouldProbe, settleMs, diffProbe, applyProbe };
@@ -2,4 +2,4 @@
2
2
  // Kept in JS (not imported from package.json) to avoid Node import-attribute
3
3
  // (JSON module) compatibility issues inside Electron. package.json "version"
4
4
  // is manually synced to this value; tests assert they match.
5
- export const VERSION = '2.1.0';
5
+ export const VERSION = '2.1.2';