dsh-site-bridge 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -123,11 +123,14 @@ The popup UI follows the browser language: English, Russian and Simplified Chine
123
123
  ## Usage
124
124
 
125
125
  ```text
126
- site_status → bridge state, pairing path, current tab
127
- 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
128
128
  site_network {action:"start"} → begin recording, then act on the page
129
129
  site_capture {screenshot:true} → write the bundle into the workspace
130
- 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
131
134
  site_cursor {x,y,click:true} → virtual cursor, real mouse event
132
135
  site_shot {fullPage:true} → screenshot to captures/shots/*.png
133
136
  site_inject {js:"..."} → run your script in the page to verify it
package/README.ru.md CHANGED
@@ -123,11 +123,14 @@ Chrome покажет плашку «началась отладка этого
123
123
  ## Использование
124
124
 
125
125
  ```text
126
- site_status → состояние моста, путь к токену, текущая вкладка
127
- site_tabs {action:"list"} → вкладки; {action:"claim", tabId} привязывает одну
128
- site_network {action:"start"} → начать запись, затем действуйте на странице
129
- site_capture {screenshot:true} → записать пакет в рабочее пространство
130
- 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"} → человек кликает элемент и пишет примечание
131
134
  site_cursor {x,y,click:true} → виртуальный курсор, настоящее событие мыши
132
135
  site_shot {fullPage:true} → скриншот в captures/shots/*.png
133
136
  site_inject {js:"..."} → выполнить ваш скрипт в странице для проверки
package/README.zh-CN.md CHANGED
@@ -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
  编写页内扩展的典型循环:
@@ -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.1",
5
+ "version": "0.5.1",
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/icon.svg ADDED
@@ -0,0 +1,27 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="DSH Site Bridge">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#5b7cff"/>
5
+ <stop offset="1" stop-color="#2a45e0"/>
6
+ </linearGradient>
7
+ <linearGradient id="ink" x1="0" y1="0" x2="0" y2="1">
8
+ <stop offset="0" stop-color="#3f5cf0"/>
9
+ <stop offset="1" stop-color="#2a3fd6"/>
10
+ </linearGradient>
11
+ </defs>
12
+
13
+ <rect width="128" height="128" rx="26" fill="url(#bg)"/>
14
+
15
+ <!-- optical size: inverted tile keeps contrast at 16px -->
16
+ <rect x="13" y="15" width="102" height="98" rx="20" fill="#ffffff"/>
17
+
18
+ <!-- the browser bar, so the tile reads as a page and not a card -->
19
+ <line x1="13" y1="41" x2="115" y2="41" stroke="url(#ink)" stroke-width="7"/>
20
+ <circle cx="27" cy="28" r="3.4" fill="url(#ink)"/>
21
+ <circle cx="39" cy="28" r="3.4" fill="url(#ink)" opacity="0.65"/>
22
+
23
+ <!-- whale cut out of the page -->
24
+ <path d="M72 60 L 96 43 L 88 65 L 96 87 L 70 76 Z" fill="url(#ink)"/>
25
+ <path d="M24 86 C 24 67, 42 53, 63 55 C 82 57, 91 68, 90 80 C 89 94, 72 101, 53 97 C 37 94, 24 93, 24 86 Z"
26
+ fill="url(#ink)"/>
27
+ </svg>
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,7 +1,8 @@
1
1
  {
2
2
  "name": "dsh-site-bridge",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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
+ "icon": "./icon.svg",
5
6
  "type": "module",
6
7
  "main": "./lib/index.js",
7
8
  "exports": {
@@ -15,7 +16,8 @@
15
16
  "docs",
16
17
  "scripts",
17
18
  "tools",
18
- "cordis.patch.yml"
19
+ "cordis.patch.yml",
20
+ "icon.svg"
19
21
  ],
20
22
  "keywords": [
21
23
  "deepseek-harness",
@@ -40,7 +42,8 @@
40
42
  "version:check": "node tools/version.mjs check",
41
43
  "version:bump": "node tools/version.mjs bump",
42
44
  "stage": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install.ps1 -StageOnly",
43
- "test:picker": "node test/picker.mjs"
45
+ "test:picker": "node test/picker.mjs",
46
+ "test:ops": "node test/browser-ops.mjs"
44
47
  },
45
48
  "dsh": {
46
49
  "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,