@evan_zhong/browser-attach 0.17.0 → 0.17.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.
@@ -126,7 +126,7 @@ export function createConnection(chrome, CONNECTION, controller) {
126
126
  respond({ ok: true });
127
127
  }
128
128
  if (message.type === 'release_all') {
129
- controller.releaseAll().then(() => respond({ ok: true }));
129
+ release().then(() => respond({ ok: true }), error => respond({ ok: false, error: error.message }));
130
130
  return true;
131
131
  }
132
132
  });
@@ -343,7 +343,7 @@ export function createController(api, notify = () => {}) {
343
343
  }
344
344
  const rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
345
345
  if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
346
- entries.push(...rendered);
346
+ for (const entry of rendered) entries.push(entry);
347
347
  }
348
348
  const metadata = value(await send(tabId, 'Runtime.evaluate', {
349
349
  expression: '({title: document.title, url: location.href})', returnByValue: true,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.17.0",
4
+ "version": "0.17.2",
5
5
  "description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
@@ -4,7 +4,13 @@ async function refresh() {
4
4
  document.querySelector('#profile').textContent = `${result.browser.family} · ${result.browser.profile_name}`;
5
5
  document.querySelector('#claims').textContent = `正在控制 ${result.claims} 个标签页`;
6
6
  }
7
- document.querySelector('#release').onclick = async () => { await chrome.runtime.sendMessage({ type: 'release_all' }); await refresh(); };
7
+ document.querySelector('#release').onclick = async () => {
8
+ try {
9
+ const result = await chrome.runtime.sendMessage({ type: 'release_all' });
10
+ await refresh();
11
+ if (!result.ok) document.querySelector('#status').textContent = `释放失败:${result.error}`;
12
+ } catch (error) { document.querySelector('#status').textContent = `释放失败:${error.message}`; }
13
+ };
8
14
  document.querySelector('#reconnect').onclick = async () => { await chrome.runtime.sendMessage({ type: 'reconnect' }); await refresh(); };
9
15
  document.querySelector('#settings').onclick = () => chrome.runtime.openOptionsPage();
10
16
  refresh().catch(error => { document.querySelector('#status').textContent = error.message; });
@@ -21,11 +21,15 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
21
21
  const parents = new Map();
22
22
  for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
23
23
  function keepText(node) {
24
- if (!node || kept.has(node.nodeId)) return;
25
- const role = node.role?.value;
26
- if (!textRoles.has(role) && !node.ignored && !['none', 'generic'].includes(role)) return;
27
- kept.add(node.nodeId);
28
- for (const child of node.childIds || []) keepText(byId.get(child));
24
+ const pending = [node];
25
+ while (pending.length) {
26
+ const current = pending.pop();
27
+ if (!current || kept.has(current.nodeId)) continue;
28
+ const role = current.role?.value;
29
+ if (!textRoles.has(role) && !current.ignored && !['none', 'generic'].includes(role)) continue;
30
+ kept.add(current.nodeId);
31
+ for (const child of current.childIds || []) pending.push(byId.get(child));
32
+ }
29
33
  }
30
34
  for (const node of nodes) {
31
35
  if (node.ignored || !node.backendDOMNodeId || !(controls.has(node.role?.value) ||
@@ -37,19 +41,33 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
37
41
  }
38
42
  }
39
43
  function plainText(node) {
40
- if (!node) return null;
41
- const role = node.role?.value;
42
- if (textRoles.has(role)) return String(node.name?.value || '');
43
- if (!node.ignored && !['none', 'generic'].includes(role)) return null;
44
- const parts = (node.childIds || []).map(id => plainText(byId.get(id)));
45
- return parts.includes(null) ? null : parts.join(' ');
44
+ const pending = [node], parts = [];
45
+ while (pending.length) {
46
+ const current = pending.pop();
47
+ if (current === ' ') { parts.push(' '); continue; }
48
+ if (!current) return null;
49
+ const role = current.role?.value;
50
+ if (textRoles.has(role)) { parts.push(String(current.name?.value || '')); continue; }
51
+ if (!current.ignored && !['none', 'generic'].includes(role)) return null;
52
+ const children = current.childIds || [];
53
+ for (let index = children.length - 1; index >= 0; index--) {
54
+ pending.push(byId.get(children[index]));
55
+ if (index > 0) pending.push(' ');
56
+ }
57
+ }
58
+ return parts.join('');
46
59
  }
47
- function render(node, depth, parentName = '') {
48
- if (!node || visited.has(node.nodeId)) return;
60
+ // Preserve preorder and ref numbering without using the JavaScript call stack.
61
+ const children = new Set();
62
+ for (const node of nodes) for (const child of node.childIds || []) children.add(child);
63
+ const pending = nodes.filter(node => !children.has(node.nodeId)).reverse().map(node => ({ node, depth: 0, parentName: '' }));
64
+ while (pending.length) {
65
+ const { node, depth, parentName } = pending.pop();
66
+ if (!node || visited.has(node.nodeId)) continue;
49
67
  visited.add(node.nodeId);
50
- if (kept && !kept.has(node.nodeId)) return;
68
+ if (kept && !kept.has(node.nodeId)) continue;
51
69
  const role = node.role?.value || 'node';
52
- if (role === 'InlineTextBox') return;
70
+ if (role === 'InlineTextBox') continue;
53
71
  let name = String(node.name?.value || '');
54
72
  let merged = false;
55
73
  if (['listitem', 'paragraph'].includes(role) && !name) {
@@ -77,11 +95,13 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
77
95
  }
78
96
  nextDepth++;
79
97
  }
80
- if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName : identityName);
98
+ if (!merged) {
99
+ const childIds = node.childIds || [];
100
+ for (let index = childIds.length - 1; index >= 0; index--) {
101
+ pending.push({ node: byId.get(childIds[index]), depth: nextDepth, parentName: skip ? parentName : identityName });
102
+ }
103
+ }
81
104
  }
82
- // AX trees normally have a single root, but ignored roots can be disconnected.
83
- const children = new Set(nodes.flatMap(node => node.childIds || []));
84
- for (const node of nodes) if (!children.has(node.nodeId)) render(node, 0);
85
105
  return entries;
86
106
  }
87
107
 
package/extension/wait.js CHANGED
@@ -70,7 +70,7 @@ export function inspectWait(query, inspect, args) {
70
70
  if (!element) return { satisfied: false, state: 'absent' };
71
71
  if (args.condition === 'attached') return { satisfied: true, state: 'attached' };
72
72
  if (args.condition === 'text') {
73
- const text = element.textContent || '';
73
+ const text = element.innerText ?? '';
74
74
  return { satisfied: args.previous != null ? text !== args.previous : text.includes(args.expected), text: text.slice(0, 500) };
75
75
  }
76
76
  const bounds = inspect.call(element);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evan_zhong/browser-attach",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "MCP for existing signed-in Chrome and Edge tabs through a locally paired extension",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -15,7 +15,7 @@ export const DATA_DIR = configuredHome
15
15
  : process.platform === 'win32' ? join(homedir(), '.browser-attach')
16
16
  : join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
17
17
  export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
18
- export const VERSION = '0.17.0';
18
+ export const VERSION = '0.17.2';
19
19
 
20
20
  export async function loadConfig(path = DEFAULT_CONFIG) {
21
21
  let config;
package/src/mcp.mjs CHANGED
@@ -147,7 +147,7 @@ export function createMcp(client) {
147
147
  }, null, args => { dragOptions(args); return args; });
148
148
  tool('wait', 'Wait without scrolling or input. actionable requires a stable, visible, enabled, unobscured target in the viewport; network_idle uses observed page requests. Timeout includes the last observed state.', {
149
149
  ...handle, ...dialogPolicy, timeout_ms: integerRange('timeout_ms', 100, 30000).default(5000), ...selector, ...frameScope, nth: target.nth,
150
- condition: z.enum(['attached', 'visible', 'actionable', 'text', 'url', 'network_idle']), expected: z.string().max(2000).optional().describe('For text: textContent substring; for url: main-frame URL substring. Text may use previous instead.'), previous: z.string().max(100000).optional().describe('Text only: wait for full textContent to differ; alternative to expected.'), idle_ms: integerRange('idle_ms', 100, 10000).default(500),
150
+ condition: z.enum(['attached', 'visible', 'actionable', 'text', 'url', 'network_idle']), expected: z.string().max(2000).optional().describe('For text: rendered innerText substring; for url: main-frame URL substring. Text may use previous instead.'), previous: z.string().max(100000).optional().describe('Text only: wait for full rendered innerText to differ; alternative to expected.'), idle_ms: integerRange('idle_ms', 100, 10000).default(500),
151
151
  }, null, args => { waitOptions(args); return args; });
152
152
  tool('select_option', 'Set one HTML select option without native UI, using synthetic input/change (events_trusted=false). Verifies retained selection only.', {
153
153
  ...handle, ...inputTimeout, ...target, value: z.string().optional().describe('Choose exactly one of value, label or zero-based index; value/label match exactly.'), label: z.string().optional(), index: integerRange('index', 0, Number.MAX_SAFE_INTEGER).optional(),