@angelky7/dsh-browser-control 1.3.0 → 1.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.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.3.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.3.0/DSH-Browser-Control-v1.3.0.zip) | Chrome extension (unzip and load; extension code is unchanged since 1.2.0 — no reinstall needed) |
68
- | [dsh-browser-control-plugin-v1.3.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.3.0/dsh-browser-control-plugin-v1.3.0.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
67
+ | [DSH-Browser-Control-v1.5.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.5.0/DSH-Browser-Control-v1.5.0.zip) | Chrome extension (unzip and load; this release adds the archive command — reinstall required) |
68
+ | [dsh-browser-control-plugin-v1.5.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.5.0/dsh-browser-control-plugin-v1.5.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.3.0"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.5.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,12 @@ 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+) |
266
267
  | `browser_emulate` | Switch to a device viewport (mobile / desktop / custom, v1.0.7+) |
267
268
  | `browser_cleanup` | Clean up temp files |
268
269
  | `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
269
270
 
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`.
271
+ 23 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
272
 
272
273
  ## Architecture
273
274
 
package/README.md CHANGED
@@ -64,8 +64,8 @@ Chrome 扩展(CDP 驱动)
64
64
 
65
65
  | 文件 | 说明 |
66
66
  |---|---|
67
- | [DSH-Browser-Control-v1.3.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.3.0/DSH-Browser-Control-v1.3.0.zip) | Chrome 扩展(解压后加载;与 1.2.0 相比扩展代码无改动,已装 1.2.0 不必重装) |
68
- | [dsh-browser-control-plugin-v1.3.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.3.0/dsh-browser-control-plugin-v1.3.0.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
67
+ | [DSH-Browser-Control-v1.5.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.5.0/DSH-Browser-Control-v1.5.0.zip) | Chrome 扩展(解压后加载;这一版扩展新增 archive 存档命令,**必须重装**) |
68
+ | [dsh-browser-control-plugin-v1.5.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.5.0/dsh-browser-control-plugin-v1.5.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.3.0"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.5.0"
108
108
 
109
109
  # 本地目录调试(注意:必须显式 file: 前缀)
110
110
  dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
@@ -257,11 +257,12 @@ 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+) |
261
262
  | `browser_cleanup` | 清理临时文件 |
262
263
  | `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
263
264
 
264
- 共 21 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这四条现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
265
+ 共 23 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
265
266
 
266
267
  ## 架构
267
268
 
package/lib/index.js CHANGED
@@ -942,6 +942,18 @@ function policyRefusal(command, flags, params) {
942
942
  /** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
943
943
  const NAV_MAX_MS = 12e4;
944
944
  /**
945
+ * Same tier table as the extension's NAV_TIERS (extension/background.js). Both
946
+ * literals must stay in lockstep — verify_nav_waituntil.mjs extracts the
947
+ * extension's by regex and asserts the two agree (cross-side literals are wire
948
+ * protocol: a value only one side knows is a lie on the other).
949
+ */
950
+ const NAV_TIERS = {
951
+ none: 0,
952
+ commit: 4e3,
953
+ domcontentloaded: 4e3,
954
+ load: 15e3
955
+ };
956
+ /**
945
957
  * Extra time handed to the bridge on top of the navigation budget. Without it a
946
958
  * caller-requested budget longer than the bridge default (60s) would be beaten by
947
959
  * the bridge, and the model would get `command_timeout` (effect unknown) instead
@@ -1226,6 +1238,30 @@ async function savePdf(controller, payload, requestedPath) {
1226
1238
  ...payload.tabUrl === void 0 ? {} : { url: payload.tabUrl }
1227
1239
  };
1228
1240
  }
1241
+ /** Write one MHTML archive payload to `path` (absolute or under `controller.shotsDir`).
1242
+ * Mirrors savePdf's contract; MHTML is text, so it is written verbatim as UTF-8. */
1243
+ async function saveMhtml(controller, payload, requestedPath) {
1244
+ const dir = controller.shotsDir;
1245
+ if (dir === void 0) throw new Error("browser-bridge is not configured yet");
1246
+ let file;
1247
+ if (requestedPath && path.isAbsolute(requestedPath)) {
1248
+ file = requestedPath;
1249
+ await mkdir(path.dirname(file), { recursive: true });
1250
+ } else {
1251
+ await mkdir(dir, { recursive: true });
1252
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1253
+ const random = Math.random().toString(36).slice(2, 6);
1254
+ const name = requestedPath ? path.basename(requestedPath) : `${stamp}-${random}.mhtml`;
1255
+ file = path.join(dir, name);
1256
+ }
1257
+ const buffer = Buffer.from(payload.mhtml, "utf8");
1258
+ await writeFile(file, buffer);
1259
+ return {
1260
+ file,
1261
+ bytes: buffer.length,
1262
+ tabId: payload.tabId
1263
+ };
1264
+ }
1229
1265
  /** Register every `browser_*` tool; each is a thin adapter over one extension command. */
1230
1266
  function applyBrowserTools(ctx, controller) {
1231
1267
  ctx.systemPrompt.section({
@@ -1246,9 +1282,13 @@ function applyBrowserTools(ctx, controller) {
1246
1282
  type: "number",
1247
1283
  description: "Target tab; defaults to the active tab."
1248
1284
  },
1285
+ waitUntil: {
1286
+ type: "string",
1287
+ description: "When the navigation counts as arrived: \"load\" (default) waits for the page load event (15s budget), \"domcontentloaded\" returns once the DOM is parsed (async components may still be mounting — follow with browser_wait if an element is missing), \"commit\" once the new document's address lands, \"none\" returns immediately with no arrival check at all."
1288
+ },
1249
1289
  timeoutMs: {
1250
1290
  type: "number",
1251
- description: `How long to wait for the page load to settle: defaults to 15000, capped at ${NAV_MAX_MS}. Raise it for slow pages instead of retrying; drop it to 3000-5000 when you do not need the page to finish loading and will read/wait afterwards.`
1291
+ description: `How long to wait for the page load to settle: defaults to the waitUntil tier (load 15000, commit/domcontentloaded 4000), capped at ${NAV_MAX_MS}. An explicit value always wins over the tier.`
1252
1292
  }
1253
1293
  },
1254
1294
  output: {
@@ -1264,7 +1304,10 @@ function applyBrowserTools(ctx, controller) {
1264
1304
  title: { type: "string" },
1265
1305
  pendingUrl: { type: "string" },
1266
1306
  loadPending: { type: "boolean" },
1267
- siteUnreachable: { type: "boolean" }
1307
+ siteUnreachable: { type: "boolean" },
1308
+ waitUntil: { type: "string" },
1309
+ waitedMs: { type: "number" },
1310
+ readyState: { type: "string" }
1268
1311
  }
1269
1312
  },
1270
1313
  render: (_args, value) => {
@@ -1298,17 +1341,26 @@ function applyBrowserTools(ctx, controller) {
1298
1341
  kind: "other"
1299
1342
  }),
1300
1343
  async execute(args, exec) {
1301
- const params = { url: args.url };
1344
+ const waitUntil = args.waitUntil === void 0 ? "load" : String(args.waitUntil);
1345
+ if (!(waitUntil in NAV_TIERS)) throw new Error(`browser_navigate: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
1346
+ const params = {
1347
+ url: args.url,
1348
+ waitUntil
1349
+ };
1302
1350
  if (args.tabId !== void 0) params.tabId = args.tabId;
1303
- const budgetMs = typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs) ? Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs))) : 15e3;
1304
- params.timeoutMs = budgetMs;
1305
- const raw = await controller.execute("nav", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
1351
+ let budgetMs = NAV_TIERS[waitUntil];
1352
+ if (typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)) budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
1353
+ if (waitUntil !== "none") params.timeoutMs = budgetMs;
1354
+ const raw = await controller.execute("nav", params, exec.signal, waitUntil === "none" ? void 0 : budgetMs + BRIDGE_MARGIN_MS);
1306
1355
  const out = { tabId: raw.tabId };
1307
1356
  if (typeof raw.url === "string") out.url = raw.url;
1308
1357
  if (typeof raw.title === "string") out.title = raw.title;
1309
1358
  if (typeof raw.pendingUrl === "string") out.pendingUrl = raw.pendingUrl;
1310
1359
  if (raw.loadPending === true) out.loadPending = true;
1311
1360
  if (raw.siteUnreachable === true || typeof raw.siteUnreachable === "object" && raw.siteUnreachable !== null) out.siteUnreachable = true;
1361
+ if (typeof raw.waitUntil === "string") out.waitUntil = raw.waitUntil;
1362
+ if (typeof raw.waitedMs === "number") out.waitedMs = raw.waitedMs;
1363
+ if (typeof raw.readyState === "string" && raw.readyState.length > 0) out.readyState = raw.readyState;
1312
1364
  return out;
1313
1365
  }
1314
1366
  }));
@@ -2011,6 +2063,14 @@ function applyBrowserTools(ctx, controller) {
2011
2063
  type: "boolean",
2012
2064
  description: "For open: put the new tab in the DSH tab group. Defaults to true; pass false to keep it loose."
2013
2065
  },
2066
+ waitUntil: {
2067
+ type: "string",
2068
+ description: "For open: when the new tab counts as arrived — same tiers as browser_navigate (load default / domcontentloaded / commit / none)."
2069
+ },
2070
+ timeoutMs: {
2071
+ type: "number",
2072
+ description: "For open: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000)."
2073
+ },
2014
2074
  windowId: {
2015
2075
  type: "number",
2016
2076
  description: "For ungroup: only dissolve that window’s DSH group; omit to clear every window."
@@ -2036,10 +2096,19 @@ function applyBrowserTools(ctx, controller) {
2036
2096
  case "list": return await controller.execute("tabs.list", {}, exec.signal);
2037
2097
  case "open": {
2038
2098
  if (typeof args.url !== "string" || args.url.length === 0) throw new Error("open requires url");
2039
- const params = { url: args.url };
2099
+ const waitUntil = args.waitUntil === void 0 ? "load" : String(args.waitUntil);
2100
+ if (!(waitUntil in NAV_TIERS)) throw new Error(`browser_tabs open: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
2101
+ const params = {
2102
+ url: args.url,
2103
+ waitUntil
2104
+ };
2040
2105
  if (args.active !== void 0) params.active = args.active;
2041
2106
  if (args.group !== void 0) params.group = args.group;
2042
- return await controller.execute("tabs.open", params, exec.signal);
2107
+ if (waitUntil === "none") return await controller.execute("tabs.open", params, exec.signal);
2108
+ let budgetMs = NAV_TIERS[waitUntil];
2109
+ if (typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)) budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
2110
+ params.timeoutMs = budgetMs;
2111
+ return await controller.execute("tabs.open", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
2043
2112
  }
2044
2113
  case "close": {
2045
2114
  const ids = closeTabIds(args);
@@ -2545,6 +2614,54 @@ function applyBrowserTools(ctx, controller) {
2545
2614
  return await savePdf(controller, await controller.execute("pdf", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
2546
2615
  }
2547
2616
  }));
2617
+ ctx.tools.register(defineTool({
2618
+ name: "browser_archive",
2619
+ 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.",
2620
+ parameters: {
2621
+ tabId: {
2622
+ type: "number",
2623
+ description: "Target tab; defaults to the active tab."
2624
+ },
2625
+ path: {
2626
+ type: "string",
2627
+ description: "Absolute path. Omit to save under the configured shotsDir with a timestamped .mhtml name."
2628
+ }
2629
+ },
2630
+ output: {
2631
+ schema: {
2632
+ type: "object",
2633
+ additionalProperties: false,
2634
+ properties: {
2635
+ file: {
2636
+ type: "string",
2637
+ required: true
2638
+ },
2639
+ bytes: {
2640
+ type: "number",
2641
+ required: true
2642
+ },
2643
+ tabId: {
2644
+ type: "number",
2645
+ required: true
2646
+ }
2647
+ }
2648
+ },
2649
+ render: (_args, value) => [{
2650
+ type: "text",
2651
+ text: `MHTML archive written to ${value.file} (${(value.bytes / 1024).toFixed(1)} KB)`
2652
+ }]
2653
+ },
2654
+ presentCall: (args) => ({
2655
+ card: "generic",
2656
+ title: `Save page archive${args.path ? " → " + args.path : ""}`,
2657
+ kind: "other"
2658
+ }),
2659
+ async execute(args, exec) {
2660
+ const params = {};
2661
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2662
+ return await saveMhtml(controller, await controller.execute("archive", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
2663
+ }
2664
+ }));
2548
2665
  ctx.tools.register(defineTool({
2549
2666
  name: "browser_emulate",
2550
2667
  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.",
@@ -26,6 +26,13 @@ import { BridgeError, classify } from "./errors.js";
26
26
  import { policyRefusal } from "./policy.js";
27
27
  /** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
28
28
  const NAV_MAX_MS = 120_000;
29
+ /**
30
+ * Same tier table as the extension's NAV_TIERS (extension/background.js). Both
31
+ * literals must stay in lockstep — verify_nav_waituntil.mjs extracts the
32
+ * extension's by regex and asserts the two agree (cross-side literals are wire
33
+ * protocol: a value only one side knows is a lie on the other).
34
+ */
35
+ const NAV_TIERS = { none: 0, commit: 4_000, domcontentloaded: 4_000, load: 15_000 };
29
36
  /**
30
37
  * Extra time handed to the bridge on top of the navigation budget. Without it a
31
38
  * caller-requested budget longer than the bridge default (60s) would be beaten by
@@ -344,6 +351,28 @@ async function savePdf(controller, payload, requestedPath) {
344
351
  ...(payload.tabUrl === undefined ? {} : { url: payload.tabUrl }),
345
352
  };
346
353
  }
354
+ /** Write one MHTML archive payload to `path` (absolute or under `controller.shotsDir`).
355
+ * Mirrors savePdf's contract; MHTML is text, so it is written verbatim as UTF-8. */
356
+ async function saveMhtml(controller, payload, requestedPath) {
357
+ const dir = controller.shotsDir;
358
+ if (dir === undefined)
359
+ throw new Error('browser-bridge is not configured yet');
360
+ let file;
361
+ if (requestedPath && path.isAbsolute(requestedPath)) {
362
+ file = requestedPath;
363
+ await mkdir(path.dirname(file), { recursive: true });
364
+ }
365
+ else {
366
+ await mkdir(dir, { recursive: true });
367
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
368
+ const random = Math.random().toString(36).slice(2, 6);
369
+ const name = requestedPath ? path.basename(requestedPath) : `${stamp}-${random}.mhtml`;
370
+ file = path.join(dir, name);
371
+ }
372
+ const buffer = Buffer.from(payload.mhtml, 'utf8');
373
+ await writeFile(file, buffer);
374
+ return { file, bytes: buffer.length, tabId: payload.tabId };
375
+ }
347
376
  /** Register every `browser_*` tool; each is a thin adapter over one extension command. */
348
377
  function applyBrowserTools(ctx, controller) {
349
378
  ctx.systemPrompt.section({
@@ -367,7 +396,8 @@ function applyBrowserTools(ctx, controller) {
367
396
  parameters: {
368
397
  url: { type: 'string', required: true, description: 'Absolute URL to open in the tab.' },
369
398
  tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
370
- timeoutMs: { type: 'number', description: `How long to wait for the page load to settle: defaults to 15000, capped at ${NAV_MAX_MS}. Raise it for slow pages instead of retrying; drop it to 3000-5000 when you do not need the page to finish loading and will read/wait afterwards.` },
399
+ waitUntil: { type: 'string', description: 'When the navigation counts as arrived: "load" (default) waits for the page load event (15s budget), "domcontentloaded" returns once the DOM is parsed (async components may still be mounting — follow with browser_wait if an element is missing), "commit" once the new document\'s address lands, "none" returns immediately with no arrival check at all.' },
400
+ timeoutMs: { type: 'number', description: `How long to wait for the page load to settle: defaults to the waitUntil tier (load 15000, commit/domcontentloaded 4000), capped at ${NAV_MAX_MS}. An explicit value always wins over the tier.` },
371
401
  },
372
402
  output: {
373
403
  schema: {
@@ -380,6 +410,9 @@ function applyBrowserTools(ctx, controller) {
380
410
  pendingUrl: { type: 'string' },
381
411
  loadPending: { type: 'boolean' },
382
412
  siteUnreachable: { type: 'boolean' },
413
+ waitUntil: { type: 'string' },
414
+ waitedMs: { type: 'number' },
415
+ readyState: { type: 'string' },
383
416
  },
384
417
  },
385
418
  render: (_args, value) => {
@@ -404,14 +437,23 @@ function applyBrowserTools(ctx, controller) {
404
437
  isConcurrencySafe: () => false,
405
438
  presentCall: args => ({ card: 'generic', title: `Open ${args.url}`, kind: 'other' }),
406
439
  async execute(args, exec) {
407
- const params = { url: args.url };
440
+ const waitUntil = args.waitUntil === undefined ? 'load' : String(args.waitUntil);
441
+ if (!(waitUntil in NAV_TIERS)) {
442
+ throw new Error(`browser_navigate: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
443
+ }
444
+ const params = { url: args.url, waitUntil };
408
445
  if (args.tabId !== undefined)
409
446
  params.tabId = args.tabId;
410
- const budgetMs = typeof args.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)
411
- ? Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)))
412
- : 15_000;
413
- params.timeoutMs = budgetMs;
414
- const raw = await controller.execute('nav', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
447
+ // Explicit timeoutMs always wins over the tier; 'none' waits 0 and sends
448
+ // no budget at all. The bridge gets budget+2s so the extension's own
449
+ // "navigation did not arrive" always reports before the bridge's ceiling.
450
+ let budgetMs = NAV_TIERS[waitUntil];
451
+ if (typeof args.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)) {
452
+ budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
453
+ }
454
+ if (waitUntil !== 'none')
455
+ params.timeoutMs = budgetMs;
456
+ const raw = await controller.execute('nav', params, exec.signal, waitUntil === 'none' ? undefined : budgetMs + BRIDGE_MARGIN_MS);
415
457
  // The declared schema is closed, so the fields describing a navigation that did
416
458
  // not arrive have to be carried over on purpose. The old shape check
417
459
  // (`siteUnreachable === true`) also missed the object the extension really
@@ -429,6 +471,12 @@ function applyBrowserTools(ctx, controller) {
429
471
  || (typeof raw.siteUnreachable === 'object' && raw.siteUnreachable !== null);
430
472
  if (unreachable)
431
473
  out.siteUnreachable = true;
474
+ if (typeof raw.waitUntil === 'string')
475
+ out.waitUntil = raw.waitUntil;
476
+ if (typeof raw.waitedMs === 'number')
477
+ out.waitedMs = raw.waitedMs;
478
+ if (typeof raw.readyState === 'string' && raw.readyState.length > 0)
479
+ out.readyState = raw.readyState;
432
480
  return out;
433
481
  },
434
482
  }));
@@ -836,6 +884,8 @@ function applyBrowserTools(ctx, controller) {
836
884
  tabIds: { type: 'array', items: { type: 'number' }, description: 'For close: every tab to close in one call. The result reports each id (closed / alreadyGone / failed) instead of stopping at the first failure.' },
837
885
  active: { type: 'boolean', description: 'Whether a newly opened tab becomes active; defaults to true.' },
838
886
  group: { type: 'boolean', description: 'For open: put the new tab in the DSH tab group. Defaults to true; pass false to keep it loose.' },
887
+ waitUntil: { type: 'string', description: 'For open: when the new tab counts as arrived — same tiers as browser_navigate (load default / domcontentloaded / commit / none).' },
888
+ timeoutMs: { type: 'number', description: 'For open: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000).' },
839
889
  windowId: { type: 'number', description: 'For ungroup: only dissolve that window’s DSH group; omit to clear every window.' },
840
890
  },
841
891
  output: {
@@ -850,12 +900,24 @@ function applyBrowserTools(ctx, controller) {
850
900
  case 'open': {
851
901
  if (typeof args.url !== 'string' || args.url.length === 0)
852
902
  throw new Error('open requires url');
853
- const params = { url: args.url };
903
+ const waitUntil = args.waitUntil === undefined ? 'load' : String(args.waitUntil);
904
+ if (!(waitUntil in NAV_TIERS)) {
905
+ throw new Error(`browser_tabs open: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
906
+ }
907
+ const params = { url: args.url, waitUntil };
854
908
  if (args.active !== undefined)
855
909
  params.active = args.active;
856
910
  if (args.group !== undefined)
857
911
  params.group = args.group;
858
- return await controller.execute('tabs.open', params, exec.signal);
912
+ if (waitUntil === 'none') {
913
+ return await controller.execute('tabs.open', params, exec.signal);
914
+ }
915
+ let budgetMs = NAV_TIERS[waitUntil];
916
+ if (typeof args.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)) {
917
+ budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
918
+ }
919
+ params.timeoutMs = budgetMs;
920
+ return await controller.execute('tabs.open', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
859
921
  }
860
922
  case 'close': {
861
923
  const ids = closeTabIds(args);
@@ -1211,6 +1273,37 @@ function applyBrowserTools(ctx, controller) {
1211
1273
  return await savePdf(controller, payload, typeof args.path === 'string' ? args.path : undefined);
1212
1274
  },
1213
1275
  }));
1276
+ ctx.tools.register(defineTool({
1277
+ name: 'browser_archive',
1278
+ 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.',
1279
+ parameters: {
1280
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
1281
+ path: { type: 'string', description: 'Absolute path. Omit to save under the configured shotsDir with a timestamped .mhtml name.' },
1282
+ },
1283
+ output: {
1284
+ schema: {
1285
+ type: 'object',
1286
+ additionalProperties: false,
1287
+ properties: {
1288
+ file: { type: 'string', required: true },
1289
+ bytes: { type: 'number', required: true },
1290
+ tabId: { type: 'number', required: true },
1291
+ },
1292
+ },
1293
+ render: (_args, value) => [{
1294
+ type: 'text',
1295
+ text: `MHTML archive written to ${value.file} (${(value.bytes / 1024).toFixed(1)} KB)`,
1296
+ }],
1297
+ },
1298
+ presentCall: args => ({ card: 'generic', title: `Save page archive${args.path ? ' → ' + args.path : ''}`, kind: 'other' }),
1299
+ async execute(args, exec) {
1300
+ const params = {};
1301
+ if (args.tabId !== undefined)
1302
+ params.tabId = args.tabId;
1303
+ const payload = await controller.execute('archive', params, exec.signal);
1304
+ return await saveMhtml(controller, payload, typeof args.path === 'string' ? args.path : undefined);
1305
+ },
1306
+ }));
1214
1307
  ctx.tools.register(defineTool({
1215
1308
  name: 'browser_emulate',
1216
1309
  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.',
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` - 23 个 `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.3.0",
4
+ "version": "1.5.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"
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"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public",