dsh-site-bridge 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ Two halves, one bridge:
12
12
 
13
13
  | Half | What it does |
14
14
  |---|---|
15
- | **DSH plugin** (`lib/`) | Owns the loopback bridge and exposes eight tools to the model. Writes the snapshot bundle into the session workspace. |
15
+ | **DSH plugin** (`lib/`) | Owns the loopback bridge and exposes eleven tools to the model. Writes the snapshot bundle into the session workspace. |
16
16
  | **MV3 extension** (`extension/`) | Lives in your Chrome/Edge profile, works with your cookies, captures markup and network over CDP, drives the page with real input events. Localized: English, Русский, 中文. |
17
17
 
18
18
  Neither half is useful alone: the plugin holds the model-facing surface and the files, the extension
@@ -70,9 +70,8 @@ candidates checked for uniqueness, and the site's own xhr/fetch calls with heade
70
70
  written to files an agent can read. Automation exists so that "this selector works" and "this BFF call
71
71
  passes with my cookies" are checked against the live page instead of assumed.
72
72
 
73
- The practical consequence: for generic web browsing, a dedicated automation plugin will have more
74
- tools than this one, and that is fine — it is solving a different problem. For writing an extension
75
- that embeds into one specific site, you need the capture, and the input comes with it.
73
+ The practical consequence: this plugin is built for writing an extension for one specific site, and
74
+ the input tools exist to verify that work against the live page rather than to browse the web.
76
75
 
77
76
  ## Requirements
78
77
 
@@ -124,11 +123,14 @@ The popup UI follows the browser language: English, Russian and Simplified Chine
124
123
  ## Usage
125
124
 
126
125
  ```text
127
- site_status → bridge state, pairing path, current tab
128
- site_tabs {action:"list"} → tabs; {action:"claim", tabId} binds one
126
+ site_status → bridge state, pairing path, tabs
127
+ site_tabs {action:"list"} → tabs; claim/use/release/open/focus
129
128
  site_network {action:"start"} → begin recording, then act on the page
130
129
  site_capture {screenshot:true} → write the bundle into the workspace
131
- site_act {steps:"[...]"} → click/fill/type/select/press/scroll/upload/wait/eval
130
+ site_snapshot {limit:40} → interactive elements with stable selectors
131
+ site_text {selector:"#cart"} → visible text, capped, for verification
132
+ site_act {steps:"[...]"} → click/fill/type/press/scroll/upload/wait/back/eval
133
+ site_pick {action:"wait"} → the human clicks an element and adds a note
132
134
  site_cursor {x,y,click:true} → virtual cursor, real mouse event
133
135
  site_shot {fullPage:true} → screenshot to captures/shots/*.png
134
136
  site_inject {js:"..."} → run your script in the page to verify it
@@ -181,12 +183,12 @@ The wire protocol is small and documented in [docs/protocol.md](docs/protocol.md
181
183
  puts a secret under an unusual key, redaction will not recognise it. Treat a capture bundle as
182
184
  sensitive, exactly like any traffic dump.
183
185
 
184
- ### Position on trust
186
+ ### Trust model
185
187
 
186
- This is a deliberate trade-off, not a claim of superiority. The bridge requires a token and the
187
- extension asks for `debugger` rather than `<all_urls>`, which costs convenience — a pairing step and
188
- the Chrome debugging banner — and buys a smaller blast radius. Plugins that skip the token or take
189
- host permissions are not doing it wrong; they optimise for a different thing.
188
+ The bridge accepts only requests that carry the pairing token. The extension asks for `debugger`
189
+ instead of `<all_urls>` and does not inject a content script into every page. The cost is a pairing
190
+ step and Chrome's debugging banner. In exchange the permission is granted per tab, for the duration
191
+ of a session, and requests are read where they are made — no credential is copied out of the browser.
190
192
 
191
193
  ## Limitations
192
194
 
package/README.ru.md CHANGED
@@ -70,9 +70,8 @@ site_capture → captures/<host>/<timestamp>/
70
70
  которые агент читает. Автоматизация нужна, чтобы «этот селектор работает» и «этот вызов BFF проходит
71
71
  с моими куками» проверялись на живой странице, а не предполагались.
72
72
 
73
- Практическое следствие: для обычного браузинга специализированный плагин автоматизации даст больше
74
- инструментов, и это нормально — он решает другую задачу. Для написания расширения, встраивающегося в
75
- конкретный сайт, нужен снимок, а ввод идёт в комплекте.
73
+ Практическое следствие: плагин сделан для написания расширения под конкретный сайт, а инструменты
74
+ ввода нужны, чтобы проверять эту работу на живой странице, а не для браузинга.
76
75
 
77
76
  ## Требования
78
77
 
@@ -124,11 +123,14 @@ Chrome покажет плашку «началась отладка этого
124
123
  ## Использование
125
124
 
126
125
  ```text
127
- site_status → состояние моста, путь к токену, текущая вкладка
128
- site_tabs {action:"list"} → вкладки; {action:"claim", tabId} привязывает одну
129
- site_network {action:"start"} → начать запись, затем действуйте на странице
130
- site_capture {screenshot:true} → записать пакет в рабочее пространство
131
- site_act {steps:"[...]"} → click/fill/type/select/press/scroll/upload/wait/eval
126
+ site_status → состояние моста, путь к спариванию, вкладки
127
+ site_tabs {action:"list"} → вкладки; claim/use/release/open/focus
128
+ site_network {action:"start"} → начать запись, затем действовать на странице
129
+ site_capture {screenshot:true} → записать пакет снимка в рабочее пространство
130
+ site_snapshot {limit:40} → интерактивные элементы с устойчивыми селекторами
131
+ site_text {selector:"#cart"} → видимый текст с лимитом, для проверки
132
+ site_act {steps:"[...]"} → click/fill/type/press/scroll/upload/wait/back/eval
133
+ site_pick {action:"wait"} → человек кликает элемент и пишет примечание
132
134
  site_cursor {x,y,click:true} → виртуальный курсор, настоящее событие мыши
133
135
  site_shot {fullPage:true} → скриншот в captures/shots/*.png
134
136
  site_inject {js:"..."} → выполнить ваш скрипт в странице для проверки
@@ -180,12 +182,12 @@ site_inject {js:"..."} → выполнить ваш скрипт в с
180
182
  сайт кладёт секрет под нестандартным ключом, редакция его не узнает. Считайте пакет снимка
181
183
  чувствительным, как и любой дамп трафика.
182
184
 
183
- ### Позиция по доверию
185
+ ### Модель доверия
184
186
 
185
- Это осознанный трейд-офф, а не заявка на превосходство. Мост требует токен, а расширение просит
186
- `debugger` вместо `<all_urls>`: теряется удобство — шаг сопряжения и плашка отладки Chrome — и
187
- уменьшается радиус поражения. Плагины, которые обходятся без токена или берут права на все сайты, не
188
- делают что-то неправильно; они оптимизируют другое.
187
+ Мост принимает только запросы с токеном сопряжения. Расширение просит `debugger`, а не
188
+ `<all_urls>`, и не внедряет content script в каждую страницу. Цена — шаг сопряжения и плашка отладки
189
+ Chrome. Взамен право выдаётся на вкладку и на время сессии, а запросы читаются там, где они сделаны:
190
+ учётные данные не покидают браузер.
189
191
 
190
192
  ## Ограничения
191
193
 
package/README.zh-CN.md CHANGED
@@ -56,8 +56,8 @@ site_capture → captures/<host>/<timestamp>/
56
56
  xhr/fetch(含请求头与请求体),全部写成智能体可读的文件。自动化的作用是让「这个选择器可用」和
57
57
  「这个 BFF 调用能带着我的 Cookie 通过」在活页面上被验证,而不是被假设。
58
58
 
59
- 实际后果:如果只是让智能体浏览网页,专门的自动化插件会提供更多工具,这很正常——它解决的是另一个
60
- 问题。如果要写嵌入某个具体网站的扩展,你需要抓取,而输入能力随之而来。
59
+ 实际后果:本项目是为编写面向某个具体网站的扩展而做的,输入工具用来在活页面上验证这些工作,而不是
60
+ 用来浏览网页。
61
61
 
62
62
  ## 环境要求
63
63
 
@@ -102,14 +102,17 @@ Chrome 会显示「已开始调试此浏览器」提示条:这正是全部功
102
102
  ## 用法
103
103
 
104
104
  ```text
105
- site_status → 网桥状态、令牌路径、当前标签页
106
- site_tabs {action:"list"} → 标签页列表;{action:"claim", tabId} 绑定其一
105
+ site_status → 网桥状态、配对路径、标签页
106
+ site_tabs {action:"list"} → 标签页;claim/use/release/open/focus
107
107
  site_network {action:"start"} → 开始录制,然后在页面上操作
108
108
  site_capture {screenshot:true} → 把快照包写入工作区
109
- site_act {steps:"[...]"} → click/fill/type/select/press/scroll/upload/wait/eval
109
+ site_snapshot {limit:40} → 带稳定选择器的可交互元素
110
+ site_text {selector:"#cart"} → 可见文本(带上限),用于验证
111
+ site_act {steps:"[...]"} → click/fill/type/press/scroll/upload/wait/back/eval
112
+ site_pick {action:"wait"} → 由人点击元素并写备注
110
113
  site_cursor {x,y,click:true} → 虚拟光标,真实鼠标事件
111
- site_shot {fullPage:true} → 截图到 captures/shots/*.png
112
- site_inject {js:"..."} → 在页面中执行你的脚本以验证
114
+ site_shot {fullPage:true} → 截图保存到 captures/shots/*.png
115
+ site_inject {js:"..."} → 在页面里执行你的脚本以验证
113
116
  ```
114
117
 
115
118
  编写页内扩展的典型循环:
@@ -147,11 +150,11 @@ site_inject {js:"..."} → 在页面中执行你的脚本以验证
147
150
  - **仅回环。** 网桥只绑定 `127.0.0.1`,任何没有 32 字节配对令牌的请求都会被拒绝。`site_status` 打印的是令牌文件路径,而不是令牌本身。
148
151
  - **残余风险。** 请求头与请求体仍会经过 DSH 进程的内存。如果站点把密钥放在非常规字段名下,脱敏无法识别。请像对待任何流量转储一样,把快照包视为敏感数据。
149
152
 
150
- ### 关于信任的取舍
153
+ ### 信任模型
151
154
 
152
- 这是有意的权衡,而不是优越性声明。网桥要求令牌,扩展申请 `debugger` 而不是 `<all_urls>`:代价是
153
- 便利性——需要配对步骤、Chrome 会显示调试提示条——换来更小的爆炸半径。省略令牌或申请全站权限的插件
154
- 并没有做错,它们优化的是另一件事。
155
+ 网桥只接受携带配对令牌的请求。扩展申请 `debugger` 而不是 `<all_urls>`,也不会向每个页面注入
156
+ content script。代价是配对步骤与 Chrome 的调试提示条。换来的是:权限按标签页、按会话授予,请求在
157
+ 产生的地方被读取,凭据不离开浏览器。
155
158
 
156
159
  ## 限制
157
160
 
@@ -10,12 +10,12 @@
10
10
  # - real, working code (not a placeholder) ✔
11
11
  # - repository at least 1 day old ⏳ created 2026-11-01, check before submitting
12
12
  # - dsh-plugin topic on the repository — add with gh
13
- # - description states only what the code does ✔ (8 tools, MV3 extension)
13
+ # - description states only what the code does ✔ (11 tools, MV3 extension)
14
14
 
15
15
  url: https://github.com/kesha4/dsh-site-bridge
16
16
  name: kesha4/dsh-site-bridge
17
17
  category: browser
18
18
  icon: https://raw.githubusercontent.com/kesha4/dsh-site-bridge/main/extension/icons/icon-128.png
19
19
  description:
20
- en: Browser bridge for DeepSeek Harness — captures page markup and BFF traffic into a workspace bundle, drives pages with trusted CDP input, and verifies injected scripts; ships a DSH plugin with eight tools and an MV3 extension that uses the browser's own session.
21
- zh: 面向 DeepSeek Harness 的浏览器桥接——把页面结构与 BFF 流量抓取为工作区快照包,用可信 CDP 输入驱动页面并验证注入脚本;包含带八个工具的 DSH 插件与使用浏览器自身会话的 MV3 扩展。
20
+ en: Browser bridge for DeepSeek Harness — captures page markup and BFF traffic into a workspace bundle, drives pages with trusted CDP input, and verifies injected scripts; ships a DSH plugin with eleven tools and an MV3 extension that uses the browser's own session.
21
+ zh: 面向 DeepSeek Harness 的浏览器桥接——把页面结构与 BFF 流量抓取为工作区快照包,用可信 CDP 输入驱动页面并验证注入脚本;包含带十一个工具的 DSH 插件与使用浏览器自身会话的 MV3 扩展。
@@ -143,5 +143,11 @@
143
143
  },
144
144
  "pickArmed": {
145
145
  "message": "Click an element on the page — Enter adds a note, Esc cancels"
146
+ },
147
+ "attachAnother": {
148
+ "message": "Attach another tab"
149
+ },
150
+ "disconnectTab": {
151
+ "message": "Disconnect this tab"
146
152
  }
147
153
  }
@@ -143,5 +143,11 @@
143
143
  },
144
144
  "pickArmed": {
145
145
  "message": "Кликните по элементу на странице — Enter добавит примечание, Esc отменит"
146
+ },
147
+ "attachAnother": {
148
+ "message": "Подключить ещё вкладку"
149
+ },
150
+ "disconnectTab": {
151
+ "message": "Отключить эту вкладку"
146
152
  }
147
153
  }
@@ -143,5 +143,11 @@
143
143
  },
144
144
  "pickArmed": {
145
145
  "message": "点击页面上的元素 — Enter 添加备注,Esc 取消"
146
+ },
147
+ "attachAnother": {
148
+ "message": "连接另一个标签页"
149
+ },
150
+ "disconnectTab": {
151
+ "message": "断开此标签页"
146
152
  }
147
153
  }
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "default_locale": "en",
4
4
  "name": "__MSG_appName__",
5
- "version": "0.4.0",
5
+ "version": "0.5.0",
6
6
  "description": "__MSG_appDesc__",
7
7
  "minimum_chrome_version": "116",
8
8
  "permissions": [
@@ -57,7 +57,7 @@
57
57
  @keyframes spin { to { transform: rotate(360deg); } }
58
58
  @media (prefers-reduced-motion: reduce) { .spin { animation-duration: 2.4s; } }
59
59
 
60
- .target { margin: 8px 0; padding: 7px 8px; background: #15181e; border: 1px solid var(--line); border-radius: 7px; }
60
+ .target { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin: 8px 0; padding: 7px 8px; background: #15181e; border: 1px solid var(--line); border-radius: 7px; }
61
61
  .target-title { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
62
62
  .target-url { margin-top: 1px; color: var(--muted); font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
63
63
  .target.empty .target-title { color: var(--muted); font-weight: 400; }
@@ -74,6 +74,7 @@
74
74
  }
75
75
  button:hover:not(:disabled) { border-color: #3a4150; background: #242935; }
76
76
  button:disabled { opacity: 0.45; cursor: default; }
77
+ button.primary#claim-more { margin-top: 8px; font-size: 12px; padding: 7px 10px; }
77
78
  button.primary { width: 100%; background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
78
79
  button.primary:hover:not(:disabled) { background: #6b8aff; border-color: #6b8aff; }
79
80
  button.primary:disabled { background: #20242d; border-color: var(--line); color: var(--muted); font-weight: 500; }
@@ -92,6 +93,7 @@
92
93
  }
93
94
  button.link:hover:not(:disabled) { color: #7e98ff; background: none; }
94
95
 
96
+ .target-text { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
95
97
  .capture-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); }
96
98
  .capture-row button { padding: 5px 9px; font-size: 11.5px; }
97
99
  select {
@@ -139,22 +141,26 @@
139
141
  <section class="card">
140
142
  <div class="card-head">
141
143
  <span class="pill"><i class="dot" id="dot"></i><i class="spin" id="spin" hidden></i><span id="connection-text" data-i18n="statusConnecting">Проверяю…</span></span>
142
- <span style="display:flex;gap:6px;align-items:center">
143
- <button class="icon" id="pick" data-i18n-title="pickHint" title="Pick an element">
144
- <svg width="14" height="14" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 2 L3 16 L7.2 12.2 L9.6 17.6 L12.2 16.4 L9.8 11.2 L15 11.2 Z" fill="currentColor"/></svg>
145
- </button>
146
- <button class="ghost" id="release" hidden data-i18n="disconnect">Отключить</button>
147
- </span>
144
+ <button class="icon" id="pick" data-i18n-title="pickHint" title="Pick an element">
145
+ <svg width="14" height="14" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 2 L3 16 L7.2 12.2 L9.6 17.6 L12.2 16.4 L9.8 11.2 L15 11.2 Z" fill="currentColor"/></svg>
146
+ </button>
148
147
  </div>
149
148
 
150
149
  <div class="target empty" id="target-block">
151
- <div class="target-title" id="target-title" data-i18n="targetNoneTitle">Вкладка не подключена</div>
152
- <div class="target-url" id="target-url" data-i18n="targetNoneUrl">Откройте нужную страницу и подключите её</div>
150
+ <div class="target-text">
151
+ <div class="target-title" id="target-title" data-i18n="targetNoneTitle">Вкладка не подключена</div>
152
+ <div class="target-url" id="target-url" data-i18n="targetNoneUrl">Откройте нужную страницу и подключите её</div>
153
+ </div>
154
+ <button class="icon" id="release" hidden data-i18n-title="disconnectTab" title="Отключить вкладку">
155
+ <svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
156
+ </button>
153
157
  </div>
154
158
 
155
159
  <button class="primary" id="claim" data-i18n="connectTab">Подключить эту вкладку</button>
156
160
  <p class="status-note" id="status-note" hidden></p>
157
161
 
162
+ <button class="primary" id="claim-more" hidden data-i18n="attachAnother">Подключить ещё вкладку</button>
163
+
158
164
  <div class="capture-row" id="tabs-row" hidden>
159
165
  <span class="muted" data-i18n="tabsLabel">Вкладки</span>
160
166
  <select id="tabs-select"></select>
@@ -101,6 +101,7 @@ function render(status) {
101
101
  const targets = Array.isArray(status.targets) ? status.targets : []
102
102
  const tabsRow = $('tabs-row')
103
103
  const tabsSelect = $('tabs-select')
104
+ $('claim-more').hidden = !hasTarget
104
105
  if (targets.length > 1) {
105
106
  tabsRow.hidden = false
106
107
  tabsSelect.textContent = ''
@@ -267,6 +268,12 @@ $('pick').addEventListener('click', async () => {
267
268
  await refresh()
268
269
  })
269
270
 
271
+ $('claim-more').addEventListener('click', async () => {
272
+ const result = await send({ type: 'claim' })
273
+ if (!result?.ok && result?.error) setNote(String(result.error), true)
274
+ await refresh()
275
+ })
276
+
270
277
  $('tabs-select').addEventListener('change', async (event) => {
271
278
  const tabId = Number(event.target.value)
272
279
  if (!Number.isFinite(tabId)) return
package/extension/sw.js CHANGED
@@ -665,6 +665,99 @@ async function readPick(tabId) {
665
665
  return result?.value ?? null
666
666
  }
667
667
 
668
+ /**
669
+ * Injected into the page: a compact list of what a person can act on, with a
670
+ * stable selector for each, so the agent can pick one without reading the
671
+ * whole snapshot bundle.
672
+ */
673
+ function compactSnapshotInPage(options) {
674
+ const limit = Math.max(1, Math.min(Number(options?.limit ?? 120), 400))
675
+ const cssEsc = (value) => (window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'))
676
+ const count = (selector) => {
677
+ try { return document.querySelectorAll(selector).length } catch { return -1 }
678
+ }
679
+
680
+ function selectorFor(el) {
681
+ const candidates = []
682
+ if (el.id) candidates.push('#' + cssEsc(el.id))
683
+ for (const attr of ['data-testid', 'data-test', 'data-qa', 'data-cy']) {
684
+ const value = el.getAttribute(attr)
685
+ if (value) candidates.push('[' + attr + '="' + value.replace(/"/g, '\\"') + '"]')
686
+ }
687
+ if (el.getAttribute('name')) {
688
+ candidates.push(el.tagName.toLowerCase() + '[name="' + el.getAttribute('name').replace(/"/g, '\\"') + '"]')
689
+ }
690
+ if (el.getAttribute('aria-label')) {
691
+ candidates.push('[aria-label="' + el.getAttribute('aria-label').replace(/"/g, '\\"') + '"]')
692
+ }
693
+ const classes = [...el.classList].filter((name) => /^[a-zA-Z][\w-]{2,}$/.test(name)).slice(0, 2)
694
+ if (classes.length > 0) {
695
+ candidates.push(el.tagName.toLowerCase() + classes.map((name) => '.' + cssEsc(name)).join(''))
696
+ }
697
+ const parts = []
698
+ let current = el
699
+ while (current && current.nodeType === 1 && parts.length < 8) {
700
+ let part = current.tagName.toLowerCase()
701
+ if (current.id) { parts.unshift('#' + cssEsc(current.id)); break }
702
+ const parent = current.parentElement
703
+ if (parent) {
704
+ const siblings = [...parent.children].filter((node) => node.tagName === current.tagName)
705
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')'
706
+ }
707
+ parts.unshift(part)
708
+ current = parent
709
+ }
710
+ candidates.push(parts.join(' > '))
711
+ const unique = candidates.find((selector) => count(selector) === 1)
712
+ return unique ?? candidates[candidates.length - 1]
713
+ }
714
+
715
+ const SELECTOR = [
716
+ 'a[href]', 'button', 'input', 'select', 'textarea', 'summary',
717
+ '[role="button"]', '[role="link"]', '[role="tab"]', '[role="menuitem"]',
718
+ '[role="checkbox"]', '[role="radio"]', '[role="switch"]',
719
+ '[contenteditable=""]', '[contenteditable="true"]', '[onclick]'
720
+ ].join(',')
721
+
722
+ const nodes = [...document.querySelectorAll(SELECTOR)]
723
+ const items = []
724
+ for (const el of nodes) {
725
+ if (el.closest('#__dsh_pick_overlay__')) continue
726
+ const style = getComputedStyle(el)
727
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue
728
+ const rect = el.getBoundingClientRect()
729
+ if (rect.width === 0 && rect.height === 0) continue
730
+ const label = (el.getAttribute('aria-label') ?? '') ||
731
+ (el.getAttribute('placeholder') ?? '') ||
732
+ (el.innerText ?? '').trim().replace(/\s+/g, ' ') ||
733
+ (el.getAttribute('title') ?? '') ||
734
+ (el.getAttribute('value') ?? '')
735
+ const item = {
736
+ tag: el.tagName.toLowerCase(),
737
+ name: String(label).slice(0, 80),
738
+ selector: selectorFor(el),
739
+ box: { x: Math.round(rect.left), y: Math.round(rect.top), w: Math.round(rect.width), h: Math.round(rect.height) }
740
+ }
741
+ if (el.getAttribute('type')) item.type = el.getAttribute('type')
742
+ if (el.getAttribute('role')) item.role = el.getAttribute('role')
743
+ if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') item.disabled = true
744
+ if (el.checked === true) item.checked = true
745
+ if (typeof el.value === 'string' && el.value !== '') item.value = el.value.slice(0, 60)
746
+ if (el.href && el.tagName === 'A') item.href = String(el.href).slice(0, 200)
747
+ items.push(item)
748
+ if (items.length >= limit) break
749
+ }
750
+
751
+ return {
752
+ url: location.href,
753
+ title: document.title,
754
+ total: nodes.length,
755
+ returned: items.length,
756
+ truncated: nodes.length > items.length,
757
+ items
758
+ }
759
+ }
760
+
668
761
  /* ------------------------------------------------------------------ cursor */
669
762
 
670
763
  const CURSOR_ID = '__dsh_site_bridge_cursor__'
@@ -978,6 +1071,13 @@ async function runStep(op, step) {
978
1071
  return { uploaded: files.length, to: step.selector }
979
1072
  }
980
1073
  case 'goto': {
1074
+ if (step.back === true || step.forward === true) {
1075
+ await cdp('Runtime.evaluate', {
1076
+ expression: step.back === true ? 'history.back(); true' : 'history.forward(); true',
1077
+ returnByValue: true
1078
+ })
1079
+ return { navigated: step.back === true ? 'back' : 'forward' }
1080
+ }
981
1081
  await cdp('Page.navigate', { url: String(step.url) })
982
1082
  return { navigated: step.url }
983
1083
  }
@@ -990,19 +1090,53 @@ async function runStep(op, step) {
990
1090
  await sleep(Math.min(Number(step.ms), 60000))
991
1091
  return { waitedMs: Number(step.ms) }
992
1092
  }
1093
+ if (step.state === 'settle') {
1094
+ const quietMs = Math.min(Number(step.quietMs ?? 400), 5000)
1095
+ const budget = Math.min(Number(step.timeoutMs ?? 15000), 60000)
1096
+ const { result } = await cdp('Runtime.evaluate', {
1097
+ expression: `new Promise((resolve) => {
1098
+ let timer = null;
1099
+ const done = () => { observer.disconnect(); resolve(true); };
1100
+ const restart = () => { clearTimeout(timer); timer = setTimeout(done, ${quietMs}); };
1101
+ const observer = new MutationObserver(restart);
1102
+ observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
1103
+ restart();
1104
+ setTimeout(() => { observer.disconnect(); resolve(false); }, ${budget});
1105
+ })`,
1106
+ awaitPromise: true,
1107
+ returnByValue: true
1108
+ })
1109
+ if (result?.value !== true) throw new Error(`page did not settle within ${budget} ms`)
1110
+ return { settled: true, quietMs }
1111
+ }
1112
+ const wantHidden = step.state === 'hidden'
993
1113
  const deadline = Date.now() + Math.min(Number(step.timeoutMs ?? 15000), 60000)
1114
+ let lastValue = null
994
1115
  while (Date.now() < deadline) {
995
- const { result } = await cdp('Runtime.evaluate', {
1116
+ const { result, exceptionDetails } = await cdp('Runtime.evaluate', {
996
1117
  expression: `(() => {
997
1118
  ${PAGE_RESOLVER}
998
- return Boolean(__dshDeep(${JSON.stringify(step.selector)}));
1119
+ const hit = __dshDeep(${JSON.stringify(step.selector)});
1120
+ if (!hit || !hit.el) return ${wantHidden ? 'true' : 'false'};
1121
+ if (${wantHidden ? 'true' : 'false'}) {
1122
+ const el = hit.el;
1123
+ const style = getComputedStyle(el);
1124
+ const rect = el.getBoundingClientRect();
1125
+ return style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 ||
1126
+ (rect.width === 0 && rect.height === 0);
1127
+ }
1128
+ return true;
999
1129
  })()`,
1000
1130
  returnByValue: true
1001
1131
  })
1002
- if (result?.value === true) return { appeared: step.selector }
1132
+ if (exceptionDetails) throw new Error(`не удалось проверить ${step.selector}: ${exceptionDetails.exception?.description ?? exceptionDetails.text ?? 'ошибка в странице'}`)
1133
+ lastValue = result?.value
1134
+ if (lastValue === true) {
1135
+ return wantHidden ? { hidden: step.selector } : { appeared: step.selector }
1136
+ }
1003
1137
  await sleep(200)
1004
1138
  }
1005
- throw new Error(`timed out waiting for ${step.selector}`)
1139
+ throw new Error(`timed out waiting for ${step.selector} to be ${wantHidden ? 'hidden' : 'visible'} (last value: ${JSON.stringify(lastValue)})`)
1006
1140
  }
1007
1141
  case 'eval': {
1008
1142
  const { result, exceptionDetails } = await cdp('Runtime.evaluate', {
@@ -1353,6 +1487,48 @@ async function dispatch(op, args) {
1353
1487
  const active = activeSession()
1354
1488
  return { target: active?.target ?? null, attached: state.activeTabId, tabs: [...state.sessions.keys()] }
1355
1489
  }
1490
+ case 'tab.open': {
1491
+ const tab = await chrome.tabs.create({ url: String(args.url ?? 'about:blank'), active: args.active !== false })
1492
+ await attach(tab.id)
1493
+ return { target: state.sessions.get(tab.id)?.target ?? { id: tab.id, url: tab.url, title: tab.title } }
1494
+ }
1495
+ case 'tab.focus': {
1496
+ const session = requireSession(args.tabId)
1497
+ await chrome.tabs.update(session.tabId, { active: true })
1498
+ const tab = await chrome.tabs.get(session.tabId)
1499
+ if (tab.windowId != null) await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
1500
+ return { focused: session.tabId }
1501
+ }
1502
+ case 'snapshot.compact': {
1503
+ const session = requireSession(args.tabId)
1504
+ const { result } = await cdp('Runtime.evaluate', {
1505
+ expression: `(${compactSnapshotInPage.toString()})(${JSON.stringify({ limit: args.limit })})`,
1506
+ returnByValue: true
1507
+ }, session.tabId)
1508
+ return result?.value ?? { items: [], total: 0, returned: 0, truncated: false }
1509
+ }
1510
+ case 'text.get': {
1511
+ const session = requireSession(args.tabId)
1512
+ const maxChars = Math.max(20, Math.min(Number(args.maxChars ?? 8000), 40000))
1513
+ const root = args.selector ? `__dshDeep(${JSON.stringify(String(args.selector))})` : 'document.body'
1514
+ const { result } = await cdp('Runtime.evaluate', {
1515
+ expression: `(() => {
1516
+ ${PAGE_RESOLVER}
1517
+ const root = ${root};
1518
+ if (!root) return { error: 'element not found' };
1519
+ const text = (root.innerText ?? root.textContent ?? '').replace(/\\n{3,}/g, '\\n\\n').trim();
1520
+ return {
1521
+ text: text.slice(0, ${maxChars}),
1522
+ chars: text.length,
1523
+ truncated: text.length > ${maxChars},
1524
+ url: location.href,
1525
+ title: document.title
1526
+ };
1527
+ })()`,
1528
+ returnByValue: true
1529
+ }, session.tabId)
1530
+ return result?.value ?? { text: '', chars: 0, truncated: false }
1531
+ }
1356
1532
  case 'net.start': {
1357
1533
  const session = requireSession(args.tabId)
1358
1534
  return { recording: await setRecording(session, true), tabId: session.tabId }
package/lib/index.js CHANGED
@@ -42,8 +42,8 @@ const DOCS = {
42
42
  'press {key}',
43
43
  'scroll {selector?} | scroll {dx, dy}',
44
44
  'upload {selector, files: ["C:\\\\abs\\\\path.pdf"]} — absolute paths on this machine',
45
- 'goto {url} | reload {hard?}',
46
- 'wait {selector, timeoutMs?} | wait {ms}',
45
+ 'goto {url} | goto {back:true} | goto {forward:true} | reload {hard?}',
46
+ 'wait {selector, state:"visible"|"hidden", timeoutMs?} | wait {state:"settle", quietMs?} | wait {ms}',
47
47
  'exists {selector}',
48
48
  'eval {expression}',
49
49
  'screenshot {fullPage?} — лучше site_shot: он пишет файл, а не base64 в контекст'
@@ -258,14 +258,31 @@ export function apply(ctx, config) {
258
258
  tool(
259
259
  'site_tabs',
260
260
  'Вкладки браузера и выбор рабочей вкладки. Можно держать подключёнными несколько вкладок: ' +
261
- 'claim добавляет ещё одну, use переключает активную, release отключает. Вкладка должна быть http(s) или file://.',
261
+ 'claim добавляет ещё одну, use переключает активную, release отключает, open открывает новую и сразу ' +
262
+ 'подключает её, focus выводит вкладку на передний план. Вкладка должна быть http(s) или file://.',
262
263
  {
263
- action: { type: 'string', description: 'list | claim | use | release' },
264
- tabId: { type: 'number', description: 'id вкладки для claim / use / release (из action=list)' }
264
+ action: { type: 'string', description: 'list | claim | use | release | open | focus' },
265
+ tabId: { type: 'number', description: 'id вкладки для claim / use / release / focus (из action=list)' },
266
+ url: { type: 'string', description: 'адрес для action=open (по умолчанию about:blank)' }
265
267
  },
266
268
  async (args) => {
267
269
  const instance = await ensureBridge()
268
270
  const action = String(args.action ?? 'list')
271
+ if (action === 'open') {
272
+ const result = await instance.enqueue(
273
+ 'tab.open',
274
+ { url: String(args.url ?? 'about:blank') },
275
+ { timeout: 30000 }
276
+ )
277
+ const target = result?.target ?? null
278
+ return ok(`Открыта и подключена вкладка: ${target?.title || ''} — ${target?.url || ''}`, { target })
279
+ }
280
+ if (action === 'focus') {
281
+ const tabId = Number(args.tabId ?? instance.state().activeTabId)
282
+ if (!Number.isFinite(tabId)) throw new Error('нечего выводить на передний план: нет подключённой вкладки')
283
+ await instance.enqueue('tab.focus', { tabId }, { timeout: 20000 })
284
+ return ok(`Вкладка ${tabId} на переднем плане.`, { tabId })
285
+ }
269
286
  if (action === 'claim' || action === 'use' || action === 'release') {
270
287
  const tabId = Number(args.tabId)
271
288
  if (!Number.isFinite(tabId)) throw new Error(`tabId is required for action=${action}`)
@@ -653,6 +670,63 @@ export function apply(ctx, config) {
653
670
  )
654
671
  )
655
672
 
673
+ ctx.tools.register(
674
+ tool(
675
+ 'site_snapshot',
676
+ 'Компактный список интерактивных элементов страницы (кнопки, ссылки, поля, role=button и подобное): ' +
677
+ 'подпись, тип, состояние и устойчивый CSS-селектор для каждого. Нужен, чтобы выбрать элемент для site_act, ' +
678
+ 'не открывая пакет снимка целиком.',
679
+ {
680
+ limit: { type: 'number', description: 'Сколько элементов вернуть (по умолчанию 120, максимум 400)' },
681
+ tab: { type: 'number', description: 'id вкладки (по умолчанию активная)' }
682
+ },
683
+ async (args) => {
684
+ const instance = await ensureBridge()
685
+ pickTarget(instance, args.tab)
686
+ const data = await instance.enqueue(
687
+ 'snapshot.compact',
688
+ { limit: args.limit, ...tabArgs(args) },
689
+ { timeout: 30000 }
690
+ )
691
+ const items = Array.isArray(data?.items) ? data.items : []
692
+ if (items.length === 0) return ok('Интерактивных элементов не найдено.', { snapshot: data })
693
+ const lines = items.map((item, index) => {
694
+ const kind = item.type ? `${item.tag}[${item.type}]` : item.tag
695
+ const flags = item.disabled ? ' — недоступен' : item.checked ? ' — отмечен' : ''
696
+ return `${index + 1}. ${kind} «${item.name || '(без подписи)'}» → ${item.selector}${flags}`
697
+ })
698
+ const head = `Интерактивные элементы: ${data?.returned ?? items.length} из ${data?.total ?? items.length}${data?.truncated ? ' (список обрезан)' : ''}`
699
+ return ok(`${head}\n${lines.join('\n')}`, { snapshot: data })
700
+ }
701
+ )
702
+ )
703
+
704
+ ctx.tools.register(
705
+ tool(
706
+ 'site_text',
707
+ 'Видимый текст страницы или её поддерева — проверить, что после действия появилось нужное сообщение. ' +
708
+ 'Возвращает текст с лимитом и пометкой обрезки.',
709
+ {
710
+ selector: { type: 'string', description: 'Поддерево (по умолчанию вся страница)' },
711
+ maxChars: { type: 'number', description: 'Лимит символов (по умолчанию 8000, максимум 40000)' },
712
+ tab: { type: 'number', description: 'id вкладки (по умолчанию активная)' }
713
+ },
714
+ async (args) => {
715
+ const instance = await ensureBridge()
716
+ pickTarget(instance, args.tab)
717
+ const data = await instance.enqueue(
718
+ 'text.get',
719
+ { selector: args.selector, maxChars: args.maxChars, ...tabArgs(args) },
720
+ { timeout: 30000 }
721
+ )
722
+ if (data?.error) throw new Error(String(data.error))
723
+ const text = String(data?.text ?? '')
724
+ const note = data?.truncated ? `\n… обрезано, всего ${data.chars} символов` : ''
725
+ return ok(`${text || '(пусто)'}${note}`, { text, chars: data?.chars ?? text.length, truncated: data?.truncated === true })
726
+ }
727
+ )
728
+ )
729
+
656
730
  if (settings.autoStart) {
657
731
  void ensureBridge().catch((error) => {
658
732
  ctx.logger?.warn?.(`[dsh-site-bridge] bridge did not start: ${String(error?.message ?? error)}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-site-bridge",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Chrome/Edge MV3 bridge for DeepSeek Harness: capture page markup (DOM, styles, selectors) and BFF traffic (xhr/fetch with headers and bodies) into a workspace snapshot bundle, drive the page with trusted CDP input, and verify injected scripts. Authorization never leaves the browser and is never written to disk.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -40,7 +40,8 @@
40
40
  "version:check": "node tools/version.mjs check",
41
41
  "version:bump": "node tools/version.mjs bump",
42
42
  "stage": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install.ps1 -StageOnly",
43
- "test:picker": "node test/picker.mjs"
43
+ "test:picker": "node test/picker.mjs",
44
+ "test:ops": "node test/browser-ops.mjs"
44
45
  },
45
46
  "dsh": {
46
47
  "bundle": {
@@ -21,6 +21,14 @@ const chromePath = process.env.CHROME_PATH ?? 'C:\\Program Files\\Google\\Chrome
21
21
  const sleep = (ms) => new Promise((done) => setTimeout(done, ms))
22
22
 
23
23
  const statuses = {
24
+ 'connected-one': {
25
+ ok: true, configured: true, connected: true, port: 47321, recording: false, captured: 0,
26
+ activeTabId: 1,
27
+ target: { id: 1, title: 'Example Store — Products', url: 'https://shop.example/products' },
28
+ targets: [
29
+ { id: 1, title: 'Example Store — Products', url: 'https://shop.example/products', recording: false, captured: 0 }
30
+ ]
31
+ },
24
32
  connected: {
25
33
  ok: true, configured: true, connected: true, port: 47321, recording: true, captured: 12,
26
34
  activeTabId: 1,