@angelky7/dsh-browser-control 1.4.0 → 1.6.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.en.md CHANGED
@@ -64,8 +64,8 @@ In one line: **for "use MY browser" tasks (logged-in Bilibili, Zhihu, admin pane
64
64
 
65
65
  | File | Purpose |
66
66
  |---|---|
67
- | [DSH-Browser-Control-v1.4.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.4.0/DSH-Browser-Control-v1.4.0.zip) | Chrome extension (unzip and load; navigation waiting is now event-driven — reinstall required) |
68
- | [dsh-browser-control-plugin-v1.4.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.4.0/dsh-browser-control-plugin-v1.4.0.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
67
+ | [DSH-Browser-Control-v1.6.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.6.0/DSH-Browser-Control-v1.6.0.zip) | Chrome extension (unzip and load; this release adds the windows commands — reinstall required) |
68
+ | [dsh-browser-control-plugin-v1.6.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.6.0/dsh-browser-control-plugin-v1.6.0.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
69
69
 
70
70
  ## Install the Chrome extension (30 seconds)
71
71
 
@@ -104,7 +104,7 @@ npm install @angelky7/dsh-browser-control
104
104
 
105
105
  ```bash
106
106
  # Straight from GitHub
107
- dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.4.0"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.6.0"
108
108
 
109
109
  # Local checkout for debugging (note: the explicit file: prefix is required)
110
110
  dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
@@ -263,11 +263,13 @@ curl -X POST http://127.0.0.1:9777/api/command \
263
263
  | `browser_network_log` | Captured HTTP requests, listed with method/status/URL (v1.0.7+) |
264
264
  | `browser_network_clear` | Clear the network log buffer on its own (v1.0.7+) |
265
265
  | `browser_pdf` | Export the current page as PDF (v1.0.7+) |
266
+ | `browser_archive` | Save the whole page as a single-file MHTML (text/images/styles inline; a greppable offline evidence snapshot — embedded scripts do not run, v1.5.0+) |
267
+ | `browser_windows` | Window control: list / open / update state & geometry / close a whole window / move tabs across windows (the only tool that may touch window geometry, v1.6.0+) |
266
268
  | `browser_emulate` | Switch to a device viewport (mobile / desktop / custom, v1.0.7+) |
267
269
  | `browser_cleanup` | Clean up temp files |
268
270
  | `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
269
271
 
270
- 21 tools in total. `browser_wait`, `browser_find`, `browser_dialog` and `browser_info` existed inside the extension up to v1.0.8 but were never registered as tools, so the agent could not reach them; `browser_network_clear` existed in code and was simply missing from this table. `verify/verify_surface.mjs` now reconciles the extension command table, the commands the plugin calls, this table and the built bundle, so any drift fails `npm run verify`.
272
+ 24 tools in total. `browser_wait`, `browser_find`, `browser_dialog` and `browser_info` existed inside the extension up to v1.0.8 but were never registered as tools, so the agent could not reach them; `browser_network_clear` existed in code and was simply missing from this table. `verify/verify_surface.mjs` now reconciles the extension command table, the commands the plugin calls, this table and the built bundle, so any drift fails `npm run verify`.
271
273
 
272
274
  ## Architecture
273
275
 
package/README.md CHANGED
@@ -64,8 +64,8 @@ Chrome 扩展(CDP 驱动)
64
64
 
65
65
  | 文件 | 说明 |
66
66
  |---|---|
67
- | [DSH-Browser-Control-v1.4.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.4.0/DSH-Browser-Control-v1.4.0.zip) | Chrome 扩展(解压后加载;这一版扩展的导航等待换成事件驱动,**必须重装**) |
68
- | [dsh-browser-control-plugin-v1.4.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.4.0/dsh-browser-control-plugin-v1.4.0.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
67
+ | [DSH-Browser-Control-v1.6.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.6.0/DSH-Browser-Control-v1.6.0.zip) | Chrome 扩展(解压后加载;这一版扩展新增 windows 窗口控制命令,**必须重装**) |
68
+ | [dsh-browser-control-plugin-v1.6.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.6.0/dsh-browser-control-plugin-v1.6.0.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
69
69
 
70
70
  ## 安装 Chrome 扩展(30 秒)
71
71
 
@@ -104,7 +104,7 @@ npm install @angelky7/dsh-browser-control
104
104
 
105
105
  ```bash
106
106
  # 直接从 GitHub 安装
107
- dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.4.0"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.6.0"
108
108
 
109
109
  # 本地目录调试(注意:必须显式 file: 前缀)
110
110
  dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
@@ -257,11 +257,13 @@ curl -X POST http://127.0.0.1:9777/api/command \
257
257
  | `browser_network_log` | 抓取 HTTP 请求/响应并逐条给出方法/状态/URL(v1.0.7+) |
258
258
  | `browser_network_clear` | 单独清空网络日志缓冲(v1.0.7+) |
259
259
  | `browser_pdf` | 当前页导出 PDF(v1.0.7+) |
260
+ | `browser_archive` | 整页存成单文件 MHTML(正文/图片/样式全内联,可当文本 grep 的离线证据;是快照,内嵌脚本不会跑,v1.5.0+) |
260
261
  | `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7+) |
262
+ | `browser_windows` | 窗口控制:清单/开窗/改状态与几何/关整窗/跨窗挪标签(唯一能碰窗口几何的工具,v1.6.0+) |
261
263
  | `browser_cleanup` | 清理临时文件 |
262
264
  | `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
263
265
 
264
- 共 21 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这四条现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
266
+ 共 24 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
265
267
 
266
268
  ## 架构
267
269
 
package/lib/index.js CHANGED
@@ -118,7 +118,8 @@ const WRITE_COMMANDS = /* @__PURE__ */ new Set([
118
118
  "tabs.activate",
119
119
  "dialog",
120
120
  "cleanup",
121
- "hold"
121
+ "hold",
122
+ "windows.open"
122
123
  ]);
123
124
  /** 该命令是否可能留下副作用。 */
124
125
  function isWriteCommand(command) {
@@ -874,6 +875,7 @@ const READ_ONLY_ALLOWED = /* @__PURE__ */ new Set([
874
875
  "tabs.list",
875
876
  "console.log",
876
877
  "network.log",
878
+ "windows.list",
877
879
  "cookies.get"
878
880
  ]);
879
881
  /**
@@ -1183,6 +1185,10 @@ function requireTabId(args, action) {
1183
1185
  if (typeof args.tabId !== "number") throw new Error(`${action} requires tabId`);
1184
1186
  return args.tabId;
1185
1187
  }
1188
+ function requireWindowId(args, action) {
1189
+ if (typeof args.windowId !== "number") throw new Error(`${action} requires windowId`);
1190
+ return args.windowId;
1191
+ }
1186
1192
  /**
1187
1193
  * Every tab the caller asked to close. `tabId` and `tabIds` are both accepted
1188
1194
  * (a batch call that repeats one id, or passes both, must not close it twice),
@@ -1238,6 +1244,30 @@ async function savePdf(controller, payload, requestedPath) {
1238
1244
  ...payload.tabUrl === void 0 ? {} : { url: payload.tabUrl }
1239
1245
  };
1240
1246
  }
1247
+ /** Write one MHTML archive payload to `path` (absolute or under `controller.shotsDir`).
1248
+ * Mirrors savePdf's contract; MHTML is text, so it is written verbatim as UTF-8. */
1249
+ async function saveMhtml(controller, payload, requestedPath) {
1250
+ const dir = controller.shotsDir;
1251
+ if (dir === void 0) throw new Error("browser-bridge is not configured yet");
1252
+ let file;
1253
+ if (requestedPath && path.isAbsolute(requestedPath)) {
1254
+ file = requestedPath;
1255
+ await mkdir(path.dirname(file), { recursive: true });
1256
+ } else {
1257
+ await mkdir(dir, { recursive: true });
1258
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1259
+ const random = Math.random().toString(36).slice(2, 6);
1260
+ const name = requestedPath ? path.basename(requestedPath) : `${stamp}-${random}.mhtml`;
1261
+ file = path.join(dir, name);
1262
+ }
1263
+ const buffer = Buffer.from(payload.mhtml, "utf8");
1264
+ await writeFile(file, buffer);
1265
+ return {
1266
+ file,
1267
+ bytes: buffer.length,
1268
+ tabId: payload.tabId
1269
+ };
1270
+ }
1241
1271
  /** Register every `browser_*` tool; each is a thin adapter over one extension command. */
1242
1272
  function applyBrowserTools(ctx, controller) {
1243
1273
  ctx.systemPrompt.section({
@@ -2101,6 +2131,132 @@ function applyBrowserTools(ctx, controller) {
2101
2131
  }
2102
2132
  }
2103
2133
  }));
2134
+ ctx.tools.register(defineTool({
2135
+ name: "browser_windows",
2136
+ description: "Inspect and control browser windows: list windows with geometry and state, open a new window (optionally positioned/sized), update state (minimize / maximize / fullscreen / focus) or geometry, close a whole window (all its tabs go too), or move tabs between windows. Geometry and state changes happen only through this tool. open is the only non-idempotent action: if it times out, run action:\"list\" first — the window may already exist.",
2137
+ parameters: {
2138
+ action: {
2139
+ type: "string",
2140
+ required: true,
2141
+ description: "One of: list, open, update, close, moveTab."
2142
+ },
2143
+ windowId: {
2144
+ type: "number",
2145
+ description: "Target window for update/close/moveTab."
2146
+ },
2147
+ url: {
2148
+ type: "string",
2149
+ description: "For open: a URL or an array of URLs (one tab each); defaults to about:blank."
2150
+ },
2151
+ state: {
2152
+ type: "string",
2153
+ description: "For open/update: normal | minimized | maximized | fullscreen."
2154
+ },
2155
+ left: {
2156
+ type: "number",
2157
+ description: "For open/update: window left edge in screen pixels."
2158
+ },
2159
+ top: {
2160
+ type: "number",
2161
+ description: "For open/update: window top edge in screen pixels."
2162
+ },
2163
+ width: {
2164
+ type: "number",
2165
+ description: "For open/update: window width in screen pixels."
2166
+ },
2167
+ height: {
2168
+ type: "number",
2169
+ description: "For open/update: window height in screen pixels."
2170
+ },
2171
+ focused: {
2172
+ type: "boolean",
2173
+ description: "For open/update: focus the window. focused:true on a minimized window restores it WITHOUT un-maximizing (state:\"normal\" would un-maximize)."
2174
+ },
2175
+ alwaysOnTop: {
2176
+ type: "boolean",
2177
+ description: "For update: keep the window above others."
2178
+ },
2179
+ drawAttention: {
2180
+ type: "boolean",
2181
+ description: "For update: flash the taskbar entry to get the user's attention without stealing focus."
2182
+ },
2183
+ incognito: {
2184
+ type: "boolean",
2185
+ description: "For open: open in incognito. Fails honestly when the extension is not allowed in incognito."
2186
+ },
2187
+ tabId: {
2188
+ type: "number",
2189
+ description: "For moveTab: one tab to move."
2190
+ },
2191
+ tabIds: {
2192
+ type: "array",
2193
+ items: { type: "number" },
2194
+ description: "For moveTab: several tabs to move in order; each is reported individually."
2195
+ },
2196
+ index: {
2197
+ type: "number",
2198
+ description: "For moveTab: target position in the destination window; omit for the end."
2199
+ }
2200
+ },
2201
+ output: {
2202
+ schema: {
2203
+ type: "object",
2204
+ additionalProperties: true
2205
+ },
2206
+ render: (_args, value) => [{
2207
+ type: "text",
2208
+ text: clampRendered(JSON.stringify(value))
2209
+ }]
2210
+ },
2211
+ presentCall: (args) => ({
2212
+ card: "generic",
2213
+ title: `Browser windows: ${args.action}`,
2214
+ kind: "other"
2215
+ }),
2216
+ async execute(args, exec) {
2217
+ switch (args.action) {
2218
+ case "list": return await controller.execute("windows.list", {}, exec.signal);
2219
+ case "open": {
2220
+ const params = {};
2221
+ if (args.url !== void 0) params.url = args.url;
2222
+ if (args.state !== void 0) params.state = args.state;
2223
+ if (args.incognito === true) params.incognito = true;
2224
+ for (const k of [
2225
+ "left",
2226
+ "top",
2227
+ "width",
2228
+ "height"
2229
+ ]) if (typeof args[k] === "number") params[k] = args[k];
2230
+ if (args.focused !== void 0) params.focused = args.focused;
2231
+ return await controller.execute("windows.open", params, exec.signal);
2232
+ }
2233
+ case "update": {
2234
+ const params = { windowId: requireWindowId(args, "update") };
2235
+ if (args.state !== void 0) params.state = args.state;
2236
+ for (const k of [
2237
+ "left",
2238
+ "top",
2239
+ "width",
2240
+ "height"
2241
+ ]) if (typeof args[k] === "number") params[k] = args[k];
2242
+ if (args.focused !== void 0) params.focused = args.focused;
2243
+ if (args.alwaysOnTop !== void 0) params.alwaysOnTop = args.alwaysOnTop;
2244
+ if (args.drawAttention === true) params.drawAttention = true;
2245
+ return await controller.execute("windows.update", params, exec.signal);
2246
+ }
2247
+ case "close": return await controller.execute("windows.close", { windowId: requireWindowId(args, "close") }, exec.signal);
2248
+ case "moveTab": {
2249
+ if (args.tabId === void 0 && args.tabIds === void 0) throw new Error("moveTab requires tabId or tabIds");
2250
+ const params = { windowId: requireWindowId(args, "moveTab") };
2251
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2252
+ if (args.tabIds !== void 0) params.tabIds = args.tabIds;
2253
+ if (args.index !== void 0) params.index = args.index;
2254
+ return await controller.execute("windows.moveTab", params, exec.signal);
2255
+ }
2256
+ default: throw new Error(`unknown windows action: ${String(args.action)} (use list|open|update|close|moveTab)`);
2257
+ }
2258
+ }
2259
+ }));
2104
2260
  /** Page-side ceiling of the eval race in the extension; the bridge budget adds slack on top. */
2105
2261
  const EVAL_MAX_MS = 12e4;
2106
2262
  ctx.tools.register(defineTool({
@@ -2590,6 +2746,54 @@ function applyBrowserTools(ctx, controller) {
2590
2746
  return await savePdf(controller, await controller.execute("pdf", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
2591
2747
  }
2592
2748
  }));
2749
+ ctx.tools.register(defineTool({
2750
+ name: "browser_archive",
2751
+ description: "Save the whole page as a single-file MHTML archive (text, images and styles inline; no network needed to reopen it). Returns the absolute path. The archive is a snapshot of when it was taken — scripts inside do not run and later page changes are not reflected; read it back with file tools or grep it like text.",
2752
+ parameters: {
2753
+ tabId: {
2754
+ type: "number",
2755
+ description: "Target tab; defaults to the active tab."
2756
+ },
2757
+ path: {
2758
+ type: "string",
2759
+ description: "Absolute path. Omit to save under the configured shotsDir with a timestamped .mhtml name."
2760
+ }
2761
+ },
2762
+ output: {
2763
+ schema: {
2764
+ type: "object",
2765
+ additionalProperties: false,
2766
+ properties: {
2767
+ file: {
2768
+ type: "string",
2769
+ required: true
2770
+ },
2771
+ bytes: {
2772
+ type: "number",
2773
+ required: true
2774
+ },
2775
+ tabId: {
2776
+ type: "number",
2777
+ required: true
2778
+ }
2779
+ }
2780
+ },
2781
+ render: (_args, value) => [{
2782
+ type: "text",
2783
+ text: `MHTML archive written to ${value.file} (${(value.bytes / 1024).toFixed(1)} KB)`
2784
+ }]
2785
+ },
2786
+ presentCall: (args) => ({
2787
+ card: "generic",
2788
+ title: `Save page archive${args.path ? " → " + args.path : ""}`,
2789
+ kind: "other"
2790
+ }),
2791
+ async execute(args, exec) {
2792
+ const params = {};
2793
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2794
+ return await saveMhtml(controller, await controller.execute("archive", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
2795
+ }
2796
+ }));
2593
2797
  ctx.tools.register(defineTool({
2594
2798
  name: "browser_emulate",
2595
2799
  description: "Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing. `device:\"reset\"` restores the user's actual viewport. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.",
@@ -48,6 +48,10 @@ const WRITE_COMMANDS = new Set([
48
48
  // 往页面里插一个宿主元素 = 改页面 DOM。超时/掉线之后重试,可能多留一层浮层
49
49
  //(旧的那层还挂着),所以报 unknown 而不是「没动过页面」。
50
50
  'hold',
51
+ // 开窗不是幂等的:超时/掉线后重试可能真的开出第二个窗口——必须报 unknown。
52
+ // update/close/moveTab 是幂等的(重设同一几何、关已关的窗、挪到同一位置),
53
+ // 按判据算读,登记在 verify_errors 的 classifiedRead 里。
54
+ 'windows.open',
51
55
  ]);
52
56
  /** 该命令是否可能留下副作用。 */
53
57
  export function isWriteCommand(command) {
@@ -289,6 +289,11 @@ function requireTabId(args, action) {
289
289
  throw new Error(`${action} requires tabId`);
290
290
  return args.tabId;
291
291
  }
292
+ function requireWindowId(args, action) {
293
+ if (typeof args.windowId !== 'number')
294
+ throw new Error(`${action} requires windowId`);
295
+ return args.windowId;
296
+ }
292
297
  /**
293
298
  * Every tab the caller asked to close. `tabId` and `tabIds` are both accepted
294
299
  * (a batch call that repeats one id, or passes both, must not close it twice),
@@ -351,6 +356,28 @@ async function savePdf(controller, payload, requestedPath) {
351
356
  ...(payload.tabUrl === undefined ? {} : { url: payload.tabUrl }),
352
357
  };
353
358
  }
359
+ /** Write one MHTML archive payload to `path` (absolute or under `controller.shotsDir`).
360
+ * Mirrors savePdf's contract; MHTML is text, so it is written verbatim as UTF-8. */
361
+ async function saveMhtml(controller, payload, requestedPath) {
362
+ const dir = controller.shotsDir;
363
+ if (dir === undefined)
364
+ throw new Error('browser-bridge is not configured yet');
365
+ let file;
366
+ if (requestedPath && path.isAbsolute(requestedPath)) {
367
+ file = requestedPath;
368
+ await mkdir(path.dirname(file), { recursive: true });
369
+ }
370
+ else {
371
+ await mkdir(dir, { recursive: true });
372
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
373
+ const random = Math.random().toString(36).slice(2, 6);
374
+ const name = requestedPath ? path.basename(requestedPath) : `${stamp}-${random}.mhtml`;
375
+ file = path.join(dir, name);
376
+ }
377
+ const buffer = Buffer.from(payload.mhtml, 'utf8');
378
+ await writeFile(file, buffer);
379
+ return { file, bytes: buffer.length, tabId: payload.tabId };
380
+ }
354
381
  /** Register every `browser_*` tool; each is a thin adapter over one extension command. */
355
382
  function applyBrowserTools(ctx, controller) {
356
383
  ctx.systemPrompt.section({
@@ -920,6 +947,89 @@ function applyBrowserTools(ctx, controller) {
920
947
  }
921
948
  },
922
949
  }));
950
+ // Window control is an explicit, opt-in surface: no other tool ever touches
951
+ // window geometry or state (the input path only focuses), so everything the
952
+ // model does to the user's windows is visible as a browser_windows call.
953
+ ctx.tools.register(defineTool({
954
+ name: 'browser_windows',
955
+ description: 'Inspect and control browser windows: list windows with geometry and state, open a new window (optionally positioned/sized), update state (minimize / maximize / fullscreen / focus) or geometry, close a whole window (all its tabs go too), or move tabs between windows. Geometry and state changes happen only through this tool. open is the only non-idempotent action: if it times out, run action:"list" first — the window may already exist.',
956
+ parameters: {
957
+ action: { type: 'string', required: true, description: 'One of: list, open, update, close, moveTab.' },
958
+ windowId: { type: 'number', description: 'Target window for update/close/moveTab.' },
959
+ url: { type: 'string', description: 'For open: a URL or an array of URLs (one tab each); defaults to about:blank.' },
960
+ state: { type: 'string', description: 'For open/update: normal | minimized | maximized | fullscreen.' },
961
+ left: { type: 'number', description: 'For open/update: window left edge in screen pixels.' },
962
+ top: { type: 'number', description: 'For open/update: window top edge in screen pixels.' },
963
+ width: { type: 'number', description: 'For open/update: window width in screen pixels.' },
964
+ height: { type: 'number', description: 'For open/update: window height in screen pixels.' },
965
+ focused: { type: 'boolean', description: 'For open/update: focus the window. focused:true on a minimized window restores it WITHOUT un-maximizing (state:"normal" would un-maximize).' },
966
+ alwaysOnTop: { type: 'boolean', description: 'For update: keep the window above others.' },
967
+ drawAttention: { type: 'boolean', description: 'For update: flash the taskbar entry to get the user\'s attention without stealing focus.' },
968
+ incognito: { type: 'boolean', description: 'For open: open in incognito. Fails honestly when the extension is not allowed in incognito.' },
969
+ tabId: { type: 'number', description: 'For moveTab: one tab to move.' },
970
+ tabIds: { type: 'array', items: { type: 'number' }, description: 'For moveTab: several tabs to move in order; each is reported individually.' },
971
+ index: { type: 'number', description: 'For moveTab: target position in the destination window; omit for the end.' },
972
+ },
973
+ output: {
974
+ schema: { type: 'object', additionalProperties: true },
975
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
976
+ },
977
+ presentCall: args => ({ card: 'generic', title: `Browser windows: ${args.action}`, kind: 'other' }),
978
+ async execute(args, exec) {
979
+ switch (args.action) {
980
+ case 'list':
981
+ return await controller.execute('windows.list', {}, exec.signal);
982
+ case 'open': {
983
+ const params = {};
984
+ if (args.url !== undefined)
985
+ params.url = args.url;
986
+ if (args.state !== undefined)
987
+ params.state = args.state;
988
+ if (args.incognito === true)
989
+ params.incognito = true;
990
+ for (const k of ['left', 'top', 'width', 'height']) {
991
+ if (typeof args[k] === 'number')
992
+ params[k] = args[k];
993
+ }
994
+ if (args.focused !== undefined)
995
+ params.focused = args.focused;
996
+ return await controller.execute('windows.open', params, exec.signal);
997
+ }
998
+ case 'update': {
999
+ const params = { windowId: requireWindowId(args, 'update') };
1000
+ if (args.state !== undefined)
1001
+ params.state = args.state;
1002
+ for (const k of ['left', 'top', 'width', 'height']) {
1003
+ if (typeof args[k] === 'number')
1004
+ params[k] = args[k];
1005
+ }
1006
+ if (args.focused !== undefined)
1007
+ params.focused = args.focused;
1008
+ if (args.alwaysOnTop !== undefined)
1009
+ params.alwaysOnTop = args.alwaysOnTop;
1010
+ if (args.drawAttention === true)
1011
+ params.drawAttention = true;
1012
+ return await controller.execute('windows.update', params, exec.signal);
1013
+ }
1014
+ case 'close':
1015
+ return await controller.execute('windows.close', { windowId: requireWindowId(args, 'close') }, exec.signal);
1016
+ case 'moveTab': {
1017
+ if (args.tabId === undefined && args.tabIds === undefined)
1018
+ throw new Error('moveTab requires tabId or tabIds');
1019
+ const params = { windowId: requireWindowId(args, 'moveTab') };
1020
+ if (args.tabId !== undefined)
1021
+ params.tabId = args.tabId;
1022
+ if (args.tabIds !== undefined)
1023
+ params.tabIds = args.tabIds;
1024
+ if (args.index !== undefined)
1025
+ params.index = args.index;
1026
+ return await controller.execute('windows.moveTab', params, exec.signal);
1027
+ }
1028
+ default:
1029
+ throw new Error(`unknown windows action: ${String(args.action)} (use list|open|update|close|moveTab)`);
1030
+ }
1031
+ },
1032
+ }));
923
1033
  /** Page-side ceiling of the eval race in the extension; the bridge budget adds slack on top. */
924
1034
  const EVAL_MAX_MS = 120_000;
925
1035
  ctx.tools.register(defineTool({
@@ -1251,6 +1361,37 @@ function applyBrowserTools(ctx, controller) {
1251
1361
  return await savePdf(controller, payload, typeof args.path === 'string' ? args.path : undefined);
1252
1362
  },
1253
1363
  }));
1364
+ ctx.tools.register(defineTool({
1365
+ name: 'browser_archive',
1366
+ description: 'Save the whole page as a single-file MHTML archive (text, images and styles inline; no network needed to reopen it). Returns the absolute path. The archive is a snapshot of when it was taken — scripts inside do not run and later page changes are not reflected; read it back with file tools or grep it like text.',
1367
+ parameters: {
1368
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
1369
+ path: { type: 'string', description: 'Absolute path. Omit to save under the configured shotsDir with a timestamped .mhtml name.' },
1370
+ },
1371
+ output: {
1372
+ schema: {
1373
+ type: 'object',
1374
+ additionalProperties: false,
1375
+ properties: {
1376
+ file: { type: 'string', required: true },
1377
+ bytes: { type: 'number', required: true },
1378
+ tabId: { type: 'number', required: true },
1379
+ },
1380
+ },
1381
+ render: (_args, value) => [{
1382
+ type: 'text',
1383
+ text: `MHTML archive written to ${value.file} (${(value.bytes / 1024).toFixed(1)} KB)`,
1384
+ }],
1385
+ },
1386
+ presentCall: args => ({ card: 'generic', title: `Save page archive${args.path ? ' → ' + args.path : ''}`, kind: 'other' }),
1387
+ async execute(args, exec) {
1388
+ const params = {};
1389
+ if (args.tabId !== undefined)
1390
+ params.tabId = args.tabId;
1391
+ const payload = await controller.execute('archive', params, exec.signal);
1392
+ return await saveMhtml(controller, payload, typeof args.path === 'string' ? args.path : undefined);
1393
+ },
1394
+ }));
1254
1395
  ctx.tools.register(defineTool({
1255
1396
  name: 'browser_emulate',
1256
1397
  description: 'Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing. `device:"reset"` restores the user\'s actual viewport. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.',
@@ -24,6 +24,8 @@ export const READ_ONLY_ALLOWED = new Set([
24
24
  'content', 'find', 'snapshot', 'screenshot', 'pdf', 'wait',
25
25
  // 读扩展采集到的缓冲
26
26
  'tabs.list', 'console.log', 'network.log',
27
+ // 读窗口清单(几何/状态/tabIds);open/update/close/moveTab 改窗口,不在此列
28
+ 'windows.list',
27
29
  // 读浏览器自己的状态(cookie 是凭据,但 get 不改任何东西;set/remove 不在此列)
28
30
  'cookies.get',
29
31
  ]);
package/llms.txt CHANGED
@@ -35,7 +35,7 @@ Chrome 浏览器扩展 + DeepSeek Harness 插件,让 AI Agent 通过 CDP 驱
35
35
 
36
36
  ## 关键文件
37
37
 
38
- - `src/index.ts` - 22 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
38
+ - `src/index.ts` - 24 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
39
39
  - `src/policy.ts` - 三张互不相同的表:只读白名单、脚本命令、插件本地破坏性命令(`browser_cleanup` 不经过 `execute()`,必须自己站闸门)
40
40
  - `src/server.ts` - WebSocket 服务 + HTTP 面(鉴权、来源校验、状态页)
41
41
  - `src/ws.ts` - RFC 6455 帧编解码
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@angelky7/dsh-browser-control",
3
3
  "description": "Local WebSocket bridge between dsh and the DSH Browser Control browser extension, with model-facing browser_* tools and a Settings-managed on/off switch",
4
- "version": "1.4.0",
4
+ "version": "1.6.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -37,7 +37,7 @@
37
37
  "build": "tsc -p tsconfig.json && tsdown",
38
38
  "typecheck": "tsc -p tsconfig.json --noEmit",
39
39
  "check": "npm run typecheck && npm run build && publint",
40
- "verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs && node verify/verify_nav_waituntil.mjs"
40
+ "verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs && node verify/verify_nav_waituntil.mjs && node verify/verify_archive.mjs && node verify/verify_windows.mjs"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public",