@angelky7/dsh-browser-control 1.6.0 → 1.7.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.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.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) |
67
+ | [DSH-Browser-Control-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/DSH-Browser-Control-v1.7.1.zip) | Chrome extension (unzip and load; this release adds the metrics command and emulate throttling — reinstall required) |
68
+ | [dsh-browser-control-plugin-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/dsh-browser-control-plugin-v1.7.1.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.6.0"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.1"
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"
@@ -256,20 +256,21 @@ curl -X POST http://127.0.0.1:9777/api/command \
256
256
  | `browser_dialog` | Read or set the native-dialog policy accept/dismiss/manual, plus the recent dialog log (v1.0.9+) |
257
257
  | `browser_hold` | Put or remove a cancellable take-over overlay on a tab (tells the user, stops mis-clicks); `covered:false` says why it could not cover the page. A soft layer, not a security boundary |
258
258
  | `browser_cookies` | Read/set/remove cookies visible to a tab (`action: get/set/remove`; get takes a `domain` filter, set/remove need a `url` or `domain` anchor, remove re-checks and reports `remaining`). **Cookie values are credentials** — a get puts them fully into the model-visible text; see SECURITY.md |
259
- | `browser_tabs` | Tab management (list/open/close/activate/group/ungroup; see "What agent tabs look like") |
259
+ | `browser_tabs` | Tab management (list/open/close/activate/group/ungroup; v1.7.0+ history back/forward — same arrival judging as navigate, reports unchanged at the history edge) |
260
260
  | `browser_evaluate` | Run arbitrary JS |
261
- | `browser_screenshot` | Capture page screenshot |
261
+ | `browser_screenshot` | Capture page screenshot (v1.7.0+ receipt carries the real pixel size; fullPage captures the whole scrollable page, selector captures one element) |
262
262
  | `browser_console_log` | Captured console entries, listed with their text (v1.0.7+) |
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
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
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+) |
268
- | `browser_emulate` | Switch to a device viewport (mobile / desktop / custom, v1.0.7+) |
268
+ | `browser_emulate` | Switch to a device viewport (mobile / desktop / custom, v1.0.7+); v1.7.1+ adds throttling: `network` slow3g/fast3g/offline/reset + `cpuThrottlingRate` (1–20), the receipt states the applied numbers, `device:"reset"` clears them too |
269
+ | `browser_metrics` | Read performance counters (DOM nodes / JS heap / layout & script durations, Performance.getMetrics, read-only; puts numbers behind "this page is heavy", v1.7.1+) |
269
270
  | `browser_cleanup` | Clean up temp files |
270
271
  | `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
271
272
 
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`.
273
+ 25 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`.
273
274
 
274
275
  ## Architecture
275
276
 
package/README.md CHANGED
@@ -64,8 +64,8 @@ Chrome 扩展(CDP 驱动)
64
64
 
65
65
  | 文件 | 说明 |
66
66
  |---|---|
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) |
67
+ | [DSH-Browser-Control-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/DSH-Browser-Control-v1.7.1.zip) | Chrome 扩展(解压后加载;这一版扩展新增 metrics 命令与 emulate 节流参数,**必须重装**) |
68
+ | [dsh-browser-control-plugin-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/dsh-browser-control-plugin-v1.7.1.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.6.0"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.1"
108
108
 
109
109
  # 本地目录调试(注意:必须显式 file: 前缀)
110
110
  dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
@@ -250,20 +250,21 @@ curl -X POST http://127.0.0.1:9777/api/command \
250
250
  | `browser_dialog` | 读改原生弹窗应答策略 accept/dismiss/manual,并回最近弹窗记录(v1.0.9+) |
251
251
  | `browser_hold` | 在某个标签页上加/撤一层可取消的接管浮层(告知用户 + 防误触);`covered:false` 时说明盖不住的原因。软层,不是安全边界 |
252
252
  | `browser_cookies` | 读/设/删某标签页可见的 cookie(`action: get/set/remove`;get 带 `domain` 过滤,set/remove 需要 `url` 或 `domain` 锚点,remove 复查并回报 `remaining`)。**cookie 的值是凭据**,get 会把值完整放进模型可见文本——见 SECURITY.md |
253
- | `browser_tabs` | 标签页管理(列表/新建/关闭/切换/归组/拆组;见「agent 开的标签长什么样」) |
253
+ | `browser_tabs` | 标签页管理(列表/新建/关闭/切换/归组/拆组;v1.7.0 起支持历史 back/forward——落地判据与 navigate 同一套,历史尽头如实报 unchanged) |
254
254
  | `browser_evaluate` | 执行任意 JS |
255
- | `browser_screenshot` | 截取页面截图 |
255
+ | `browser_screenshot` | 截取页面截图(v1.7.0 起回执带**真实像素尺寸**;fullPage 一张截全页、selector 只截一个元素) |
256
256
  | `browser_console_log` | 抓取页面 console 日志并逐条给出正文(v1.0.7+) |
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
260
  | `browser_archive` | 整页存成单文件 MHTML(正文/图片/样式全内联,可当文本 grep 的离线证据;是快照,内嵌脚本不会跑,v1.5.0+) |
261
- | `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7+) |
261
+ | `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7+);v1.7.1 起还能限速:`network` slow3g/fast3g/offline/reset + `cpuThrottlingRate`(1–20),回执写明生效数值,`device:"reset"` 一并还原 |
262
+ | `browser_metrics` | 读性能计数器(DOM 节点数 / JS 堆 / 布局与脚本耗时,Performance.getMetrics,只读;让「这页面很重」有数字说话,v1.7.1+) |
262
263
  | `browser_windows` | 窗口控制:清单/开窗/改状态与几何/关整窗/跨窗挪标签(唯一能碰窗口几何的工具,v1.6.0+) |
263
264
  | `browser_cleanup` | 清理临时文件 |
264
265
  | `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
265
266
 
266
- 共 24 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
267
+ 共 25 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
267
268
 
268
269
  ## 架构
269
270
 
package/lib/index.js CHANGED
@@ -117,6 +117,8 @@ const WRITE_COMMANDS = /* @__PURE__ */ new Set([
117
117
  "tabs.closeMany",
118
118
  "tabs.activate",
119
119
  "dialog",
120
+ "tabs.back",
121
+ "tabs.forward",
120
122
  "cleanup",
121
123
  "hold",
122
124
  "windows.open"
@@ -876,7 +878,8 @@ const READ_ONLY_ALLOWED = /* @__PURE__ */ new Set([
876
878
  "console.log",
877
879
  "network.log",
878
880
  "windows.list",
879
- "cookies.get"
881
+ "cookies.get",
882
+ "metrics"
880
883
  ]);
881
884
  /**
882
885
  * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
@@ -1200,6 +1203,17 @@ function closeTabIds(args) {
1200
1203
  if (ids.length === 0) throw new Error("close requires tabId or tabIds");
1201
1204
  return ids;
1202
1205
  }
1206
+ /** Actual pixel dimensions straight from the PNG's IHDR chunk (width/height are
1207
+ * big-endian at offsets 16/20 after the 8-byte signature + 4-byte length + 4-byte
1208
+ * type). The receipt should say how big the picture IS — fullPage captures are
1209
+ * often 3-5 viewports tall and the model cannot tell from the byte count. */
1210
+ function pngDimensions(buffer) {
1211
+ if (buffer.length < 24 || buffer.toString("latin1", 12, 16) !== "IHDR") return {};
1212
+ return {
1213
+ width: buffer.readUInt32BE(16),
1214
+ height: buffer.readUInt32BE(20)
1215
+ };
1216
+ }
1203
1217
  /** Write one screenshot payload to the shots directory and return its durable location. */
1204
1218
  async function saveScreenshot(controller, payload) {
1205
1219
  const dir = controller.shotsDir;
@@ -1210,12 +1224,15 @@ async function saveScreenshot(controller, payload) {
1210
1224
  const file = path.join(dir, `${stamp}-${random}.${payload.format === "jpeg" ? "jpg" : "png"}`);
1211
1225
  const buffer = Buffer.from(payload.base64, "base64");
1212
1226
  await writeFile(file, buffer);
1227
+ const dims = payload.format === "png" ? pngDimensions(buffer) : {};
1213
1228
  return {
1214
1229
  file,
1215
1230
  bytes: buffer.length,
1216
1231
  tabId: payload.tabId,
1217
1232
  title: payload.tabTitle,
1218
- url: payload.tabUrl
1233
+ url: payload.tabUrl,
1234
+ ...dims,
1235
+ ...payload.elementRect === void 0 ? {} : { elementRect: payload.elementRect }
1219
1236
  };
1220
1237
  }
1221
1238
  /** Write one PDF payload to `path` (absolute or relative to `controller.shotsDir`)
@@ -2041,12 +2058,12 @@ function applyBrowserTools(ctx, controller) {
2041
2058
  }));
2042
2059
  ctx.tools.register(defineTool({
2043
2060
  name: "browser_tabs",
2044
- description: "List tabs, or open/close/activate/group/ungroup. `close` takes one tab (`tabId`) or several (`tabIds`); the result accounts for every id, so a retry is safe.",
2061
+ description: "List tabs, or open/close/activate/group/ungroup, or go back/forward in the tab's history. `close` takes one tab (`tabId`) or several (`tabIds`); the result accounts for every id, so a retry is safe.",
2045
2062
  parameters: {
2046
2063
  action: {
2047
2064
  type: "string",
2048
2065
  required: true,
2049
- description: "One of: list, open, close, activate, group, ungroup."
2066
+ description: "One of: list, open, close, activate, group, ungroup, back, forward."
2050
2067
  },
2051
2068
  url: {
2052
2069
  type: "string",
@@ -2054,7 +2071,7 @@ function applyBrowserTools(ctx, controller) {
2054
2071
  },
2055
2072
  tabId: {
2056
2073
  type: "number",
2057
- description: "Target tab for close/activate/group."
2074
+ description: "Target tab for close/activate/group/back/forward; defaults to the active tab for back/forward."
2058
2075
  },
2059
2076
  tabIds: {
2060
2077
  type: "array",
@@ -2071,11 +2088,11 @@ function applyBrowserTools(ctx, controller) {
2071
2088
  },
2072
2089
  waitUntil: {
2073
2090
  type: "string",
2074
- description: "For open: when the new tab counts as arrived — same tiers as browser_navigate (load default / domcontentloaded / commit / none)."
2091
+ description: "For open/back/forward: when the navigation counts as arrived — same tiers as browser_navigate (load default / domcontentloaded / commit / none). back/forward report unchanged:true + a reason when the tab is already at the oldest/newest history entry."
2075
2092
  },
2076
2093
  timeoutMs: {
2077
2094
  type: "number",
2078
- description: "For open: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000)."
2095
+ description: "For open/back/forward: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000)."
2079
2096
  },
2080
2097
  windowId: {
2081
2098
  type: "number",
@@ -2121,6 +2138,18 @@ function applyBrowserTools(ctx, controller) {
2121
2138
  return ids.length === 1 ? await controller.execute("tabs.close", { tabId: ids[0] }, exec.signal) : await controller.execute("tabs.closeMany", { tabIds: ids }, exec.signal);
2122
2139
  }
2123
2140
  case "activate": return await controller.execute("tabs.activate", { tabId: requireTabId(args, "activate") }, exec.signal);
2141
+ case "back":
2142
+ case "forward": {
2143
+ const waitUntil = args.waitUntil === void 0 ? "load" : String(args.waitUntil);
2144
+ if (!(waitUntil in NAV_TIERS)) throw new Error(`browser_tabs ${args.action}: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
2145
+ const params = { waitUntil };
2146
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2147
+ let budgetMs = NAV_TIERS[waitUntil];
2148
+ if (typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)) budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
2149
+ params.timeoutMs = budgetMs;
2150
+ if (args.action === "back") return await controller.execute("tabs.back", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
2151
+ return await controller.execute("tabs.forward", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
2152
+ }
2124
2153
  case "group": return await controller.execute("tabs.group", { tabId: requireTabId(args, "group") }, exec.signal);
2125
2154
  case "ungroup": {
2126
2155
  const params = {};
@@ -2326,7 +2355,7 @@ function applyBrowserTools(ctx, controller) {
2326
2355
  }));
2327
2356
  ctx.tools.register(defineTool({
2328
2357
  name: "browser_screenshot",
2329
- description: "Capture the tab as PNG/JPEG, save it under the configured shots directory, and return the absolute file path.",
2358
+ description: "Capture the tab as PNG/JPEG, save it under the configured shots directory, and return the absolute file path plus the image's real pixel size. fullPage captures the whole scrollable page in one shot (sticky/fixed headers may repeat); selector captures one element.",
2330
2359
  parameters: {
2331
2360
  tabId: {
2332
2361
  type: "number",
@@ -2334,7 +2363,11 @@ function applyBrowserTools(ctx, controller) {
2334
2363
  },
2335
2364
  fullPage: {
2336
2365
  type: "boolean",
2337
- description: "Capture beyond the viewport."
2366
+ description: "Capture the entire scrollable page instead of just the viewport. The receipt's width/height show the real size."
2367
+ },
2368
+ selector: {
2369
+ type: "string",
2370
+ description: "CSS selector: capture just that element (scrolled into view); the receipt carries its rect."
2338
2371
  },
2339
2372
  format: {
2340
2373
  type: "string",
@@ -2359,12 +2392,36 @@ function applyBrowserTools(ctx, controller) {
2359
2392
  required: true
2360
2393
  },
2361
2394
  title: { type: "string" },
2362
- url: { type: "string" }
2395
+ url: { type: "string" },
2396
+ width: { type: "number" },
2397
+ height: { type: "number" },
2398
+ elementRect: {
2399
+ type: "object",
2400
+ additionalProperties: false,
2401
+ properties: {
2402
+ x: {
2403
+ type: "number",
2404
+ required: true
2405
+ },
2406
+ y: {
2407
+ type: "number",
2408
+ required: true
2409
+ },
2410
+ w: {
2411
+ type: "number",
2412
+ required: true
2413
+ },
2414
+ h: {
2415
+ type: "number",
2416
+ required: true
2417
+ }
2418
+ }
2419
+ }
2363
2420
  }
2364
2421
  },
2365
2422
  render: (_args, value) => [{
2366
2423
  type: "text",
2367
- text: `Saved ${value.file} (${value.bytes} bytes)`
2424
+ text: `Saved ${value.file} (${value.bytes} bytes${value.width !== void 0 && value.height !== void 0 ? `, ${value.width}×${value.height}` : ""}${value.elementRect !== void 0 ? `, element at ${value.elementRect.x},${value.elementRect.y} size ${value.elementRect.w}×${value.elementRect.h}` : ""})`
2368
2425
  }]
2369
2426
  },
2370
2427
  presentCall: () => ({
@@ -2376,6 +2433,7 @@ function applyBrowserTools(ctx, controller) {
2376
2433
  const params = { format: args.format === "jpeg" ? "jpeg" : "png" };
2377
2434
  if (args.tabId !== void 0) params.tabId = args.tabId;
2378
2435
  if (args.fullPage !== void 0) params.fullPage = args.fullPage;
2436
+ if (args.selector !== void 0) params.selector = args.selector;
2379
2437
  return saveScreenshot(controller, await controller.execute("screenshot", params, exec.signal));
2380
2438
  }
2381
2439
  }));
@@ -2796,7 +2854,7 @@ function applyBrowserTools(ctx, controller) {
2796
2854
  }));
2797
2855
  ctx.tools.register(defineTool({
2798
2856
  name: "browser_emulate",
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.",
2857
+ description: "Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing, and throttle the network or CPU to test slow conditions. `device:\"reset\"` restores the user's actual viewport and clears all throttling. `network` presets (this tool's own numbers): slow3g = 400ms RTT + ~50KB/s both ways, fast3g = 150ms + ~200KB/s down / ~94KB/s up, offline, reset to clear. `cpuThrottlingRate` multiplies CPU cost (1 = off, up to 20). Throttling lives in the debugger session — it is gone when the extension detaches, and device:\"reset\" clears it too. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.",
2800
2858
  parameters: {
2801
2859
  tabId: {
2802
2860
  type: "number",
@@ -2806,6 +2864,14 @@ function applyBrowserTools(ctx, controller) {
2806
2864
  type: "string",
2807
2865
  description: "Preset: desktop | mobile-iphone-13 | mobile-pixel-7 | tablet-ipad | reset. Or pass custom width/height below."
2808
2866
  },
2867
+ network: {
2868
+ type: "string",
2869
+ description: "slow3g | fast3g | offline | reset. The receipt reports the applied latency/throughput in bytes per second."
2870
+ },
2871
+ cpuThrottlingRate: {
2872
+ type: "number",
2873
+ description: "CPU slowdown multiplier: 1 = none (default), 4 = mid-range phone, up to 20."
2874
+ },
2809
2875
  width: {
2810
2876
  type: "number",
2811
2877
  description: "Custom viewport width in CSS px."
@@ -2846,30 +2912,39 @@ function applyBrowserTools(ctx, controller) {
2846
2912
  deviceScaleFactor: { type: "number" },
2847
2913
  isMobile: { type: "boolean" },
2848
2914
  hasTouch: { type: "boolean" },
2849
- userAgent: { type: "string" }
2915
+ userAgent: { type: "string" },
2916
+ network: { type: "string" },
2917
+ latency: { type: "number" },
2918
+ downloadThroughput: { type: "number" },
2919
+ uploadThroughput: { type: "number" },
2920
+ cpuThrottlingRate: { type: "number" }
2850
2921
  }
2851
2922
  },
2852
2923
  render: (_args, value) => {
2853
2924
  if (value.reset) return [{
2854
2925
  type: "text",
2855
- text: `Tab ${value.tabId}: emulation reset to default`
2926
+ text: `Tab ${value.tabId}: emulation reset to default (network unthrottled, CPU rate 1)`
2856
2927
  }];
2857
2928
  const ua = value.userAgent === void 0 ? "" : ` UA="${flattenOneLine(value.userAgent, 80)}"`;
2929
+ const net = value.network === void 0 ? "" : ` network=${value.network}(${value.latency}ms↓${value.downloadThroughput}B/s↑${value.uploadThroughput}B/s)`;
2930
+ const cpu = value.cpuThrottlingRate === void 0 ? "" : ` cpu×${value.cpuThrottlingRate}`;
2858
2931
  return [{
2859
2932
  type: "text",
2860
- text: `Tab ${value.tabId}: ${value.width || "?"}×${value.height || "?"} DPR=${value.deviceScaleFactor ?? "?"} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}`
2933
+ text: `Tab ${value.tabId}: ${value.width || "?"}×${value.height || "?"} DPR=${value.deviceScaleFactor ?? "?"} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}${net}${cpu}`
2861
2934
  }];
2862
2935
  }
2863
2936
  },
2864
2937
  presentCall: (args) => ({
2865
2938
  card: "generic",
2866
- title: `Emulate${args.device ? " " + args.device : " device"}`,
2939
+ title: `Emulate${args.device ? " " + args.device : ""}${args.network ? " " + args.network : ""}${args.cpuThrottlingRate ? " cpu×" + args.cpuThrottlingRate : ""}`,
2867
2940
  kind: "other"
2868
2941
  }),
2869
2942
  async execute(args, exec) {
2870
2943
  const params = {};
2871
2944
  if (args.tabId !== void 0) params.tabId = args.tabId;
2872
2945
  if (typeof args.device === "string") params.device = args.device;
2946
+ if (typeof args.network === "string") params.network = args.network;
2947
+ if (typeof args.cpuThrottlingRate === "number") params.cpuThrottlingRate = args.cpuThrottlingRate;
2873
2948
  if (typeof args.width === "number") params.width = args.width;
2874
2949
  if (typeof args.height === "number") params.height = args.height;
2875
2950
  if (typeof args.deviceScaleFactor === "number") params.deviceScaleFactor = args.deviceScaleFactor;
@@ -2885,10 +2960,81 @@ function applyBrowserTools(ctx, controller) {
2885
2960
  ...typeof raw.deviceScaleFactor === "number" ? { deviceScaleFactor: raw.deviceScaleFactor } : {},
2886
2961
  ...typeof raw.isMobile === "boolean" ? { isMobile: raw.isMobile } : {},
2887
2962
  ...typeof raw.hasTouch === "boolean" ? { hasTouch: raw.hasTouch } : {},
2888
- ...typeof raw.userAgent === "string" && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {}
2963
+ ...typeof raw.userAgent === "string" && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {},
2964
+ ...typeof raw.network === "string" ? { network: raw.network } : {},
2965
+ ...typeof raw.latency === "number" ? { latency: raw.latency } : {},
2966
+ ...typeof raw.downloadThroughput === "number" ? { downloadThroughput: raw.downloadThroughput } : {},
2967
+ ...typeof raw.uploadThroughput === "number" ? { uploadThroughput: raw.uploadThroughput } : {},
2968
+ ...typeof raw.cpuThrottlingRate === "number" ? { cpuThrottlingRate: raw.cpuThrottlingRate } : {}
2889
2969
  };
2890
2970
  }
2891
2971
  }));
2972
+ ctx.tools.register(defineTool({
2973
+ name: "browser_metrics",
2974
+ description: "Read the tab's performance counters (Performance.getMetrics): DOM node count, JS heap usage, layout/style recalc counts, script/task durations. Use it to back \"this page is heavy\" with numbers, before and after an action. Read-only.",
2975
+ parameters: { tabId: {
2976
+ type: "number",
2977
+ description: "Target tab; defaults to the active tab."
2978
+ } },
2979
+ output: {
2980
+ schema: {
2981
+ type: "object",
2982
+ additionalProperties: false,
2983
+ properties: {
2984
+ tabId: {
2985
+ type: "number",
2986
+ required: true
2987
+ },
2988
+ metrics: {
2989
+ type: "object",
2990
+ required: true,
2991
+ additionalProperties: false,
2992
+ properties: {
2993
+ Timestamp: { type: "number" },
2994
+ Documents: { type: "number" },
2995
+ Frames: { type: "number" },
2996
+ JSEventListeners: { type: "number" },
2997
+ Nodes: { type: "number" },
2998
+ LayoutCount: { type: "number" },
2999
+ RecalcStyleCount: { type: "number" },
3000
+ LayoutDuration: { type: "number" },
3001
+ RecalcStyleDuration: { type: "number" },
3002
+ ScriptDuration: { type: "number" },
3003
+ TaskDuration: { type: "number" },
3004
+ JSHeapUsedSize: { type: "number" },
3005
+ JSHeapTotalSize: { type: "number" }
3006
+ }
3007
+ }
3008
+ }
3009
+ },
3010
+ render: (_args, value) => {
3011
+ const m = value.metrics ?? {};
3012
+ const parts = [];
3013
+ if (m.Nodes !== void 0) parts.push(`nodes=${m.Nodes}`);
3014
+ if (m.JSHeapUsedSize !== void 0) {
3015
+ const total = m.JSHeapTotalSize !== void 0 ? `/${(m.JSHeapTotalSize / 1048576).toFixed(1)}MB` : "";
3016
+ parts.push(`heap=${(m.JSHeapUsedSize / 1048576).toFixed(1)}${total}`);
3017
+ }
3018
+ if (m.LayoutCount !== void 0) parts.push(`layouts=${m.LayoutCount}`);
3019
+ if (m.ScriptDuration !== void 0) parts.push(`script=${Number(m.ScriptDuration).toFixed(2)}s`);
3020
+ if (m.TaskDuration !== void 0) parts.push(`tasks=${Number(m.TaskDuration).toFixed(2)}s`);
3021
+ return [{
3022
+ type: "text",
3023
+ text: `Tab ${value.tabId}: ${parts.join(" ") || JSON.stringify(value.metrics)}`
3024
+ }];
3025
+ }
3026
+ },
3027
+ presentCall: () => ({
3028
+ card: "generic",
3029
+ title: "Browser metrics",
3030
+ kind: "other"
3031
+ }),
3032
+ async execute(args, exec) {
3033
+ const params = {};
3034
+ if (args.tabId !== void 0) params.tabId = args.tabId;
3035
+ return await controller.execute("metrics", params, exec.signal);
3036
+ }
3037
+ }));
2892
3038
  }
2893
3039
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
2894
3040
  function apply(ctx, config) {
@@ -41,6 +41,9 @@ export const HOLD_ABORTED_MARKER = 'browser-hold-aborted:';
41
41
  const WRITE_COMMANDS = new Set([
42
42
  'nav', 'click', 'input', 'press', 'scroll', 'eval', 'emulate',
43
43
  'tabs.open', 'tabs.close', 'tabs.closeMany', 'tabs.activate', 'dialog',
44
+ // 历史导航与 nav 同族:超时/掉线后重试可能**多跳一条**历史(第一次 back 其实
45
+ // 已经落了地),报 unknown 才诚实。
46
+ 'tabs.back', 'tabs.forward',
44
47
  // 这条不是扩展命令,而是插件本地命令 `browser_cleanup`:它逐条 rm,中途失败
45
48
  // (Windows 上被占用的截图会 EPERM)时前面的文件已经没了,报 not_started
46
49
  // 就是告诉模型「什么都没发生,重试安全」,而那正好是假的。
@@ -309,6 +309,15 @@ function closeTabIds(args) {
309
309
  throw new Error('close requires tabId or tabIds');
310
310
  return ids;
311
311
  }
312
+ /** Actual pixel dimensions straight from the PNG's IHDR chunk (width/height are
313
+ * big-endian at offsets 16/20 after the 8-byte signature + 4-byte length + 4-byte
314
+ * type). The receipt should say how big the picture IS — fullPage captures are
315
+ * often 3-5 viewports tall and the model cannot tell from the byte count. */
316
+ function pngDimensions(buffer) {
317
+ if (buffer.length < 24 || buffer.toString('latin1', 12, 16) !== 'IHDR')
318
+ return {};
319
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
320
+ }
312
321
  /** Write one screenshot payload to the shots directory and return its durable location. */
313
322
  async function saveScreenshot(controller, payload) {
314
323
  const dir = controller.shotsDir;
@@ -320,12 +329,15 @@ async function saveScreenshot(controller, payload) {
320
329
  const file = path.join(dir, `${stamp}-${random}.${payload.format === 'jpeg' ? 'jpg' : 'png'}`);
321
330
  const buffer = Buffer.from(payload.base64, 'base64');
322
331
  await writeFile(file, buffer);
332
+ const dims = payload.format === 'png' ? pngDimensions(buffer) : {};
323
333
  return {
324
334
  file,
325
335
  bytes: buffer.length,
326
336
  tabId: payload.tabId,
327
337
  title: payload.tabTitle,
328
338
  url: payload.tabUrl,
339
+ ...dims,
340
+ ...(payload.elementRect === undefined ? {} : { elementRect: payload.elementRect }),
329
341
  };
330
342
  }
331
343
  /** Write one PDF payload to `path` (absolute or relative to `controller.shotsDir`)
@@ -881,16 +893,16 @@ function applyBrowserTools(ctx, controller) {
881
893
  }));
882
894
  ctx.tools.register(defineTool({
883
895
  name: 'browser_tabs',
884
- description: 'List tabs, or open/close/activate/group/ungroup. `close` takes one tab (`tabId`) or several (`tabIds`); the result accounts for every id, so a retry is safe.',
896
+ description: 'List tabs, or open/close/activate/group/ungroup, or go back/forward in the tab\'s history. `close` takes one tab (`tabId`) or several (`tabIds`); the result accounts for every id, so a retry is safe.',
885
897
  parameters: {
886
- action: { type: 'string', required: true, description: 'One of: list, open, close, activate, group, ungroup.' },
898
+ action: { type: 'string', required: true, description: 'One of: list, open, close, activate, group, ungroup, back, forward.' },
887
899
  url: { type: 'string', description: 'URL for the open action.' },
888
- tabId: { type: 'number', description: 'Target tab for close/activate/group.' },
900
+ tabId: { type: 'number', description: 'Target tab for close/activate/group/back/forward; defaults to the active tab for back/forward.' },
889
901
  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.' },
890
902
  active: { type: 'boolean', description: 'Whether a newly opened tab becomes active; defaults to true.' },
891
903
  group: { type: 'boolean', description: 'For open: put the new tab in the DSH tab group. Defaults to true; pass false to keep it loose.' },
892
- waitUntil: { type: 'string', description: 'For open: when the new tab counts as arrived — same tiers as browser_navigate (load default / domcontentloaded / commit / none).' },
893
- timeoutMs: { type: 'number', description: 'For open: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000).' },
904
+ waitUntil: { type: 'string', description: 'For open/back/forward: when the navigation counts as arrived — same tiers as browser_navigate (load default / domcontentloaded / commit / none). back/forward report unchanged:true + a reason when the tab is already at the oldest/newest history entry.' },
905
+ timeoutMs: { type: 'number', description: 'For open/back/forward: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000).' },
894
906
  windowId: { type: 'number', description: 'For ungroup: only dissolve that window’s DSH group; omit to clear every window.' },
895
907
  },
896
908
  output: {
@@ -934,6 +946,28 @@ function applyBrowserTools(ctx, controller) {
934
946
  }
935
947
  case 'activate':
936
948
  return await controller.execute('tabs.activate', { tabId: requireTabId(args, 'activate') }, exec.signal);
949
+ case 'back':
950
+ case 'forward': {
951
+ // Same wait-shape as open: tier default load, explicit budget, bridge
952
+ // margin so the reply can outlive the tier without the bridge timing out.
953
+ // 两条各写一个字面量调用点(surface 台对账 execute 字面量,拼名字会瞎)。
954
+ const waitUntil = args.waitUntil === undefined ? 'load' : String(args.waitUntil);
955
+ if (!(waitUntil in NAV_TIERS)) {
956
+ throw new Error(`browser_tabs ${args.action}: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
957
+ }
958
+ const params = { waitUntil };
959
+ if (args.tabId !== undefined)
960
+ params.tabId = args.tabId;
961
+ let budgetMs = NAV_TIERS[waitUntil];
962
+ if (typeof args.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)) {
963
+ budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
964
+ }
965
+ params.timeoutMs = budgetMs;
966
+ if (args.action === 'back') {
967
+ return await controller.execute('tabs.back', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
968
+ }
969
+ return await controller.execute('tabs.forward', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
970
+ }
937
971
  case 'group':
938
972
  return await controller.execute('tabs.group', { tabId: requireTabId(args, 'group') }, exec.signal);
939
973
  case 'ungroup': {
@@ -1078,10 +1112,11 @@ function applyBrowserTools(ctx, controller) {
1078
1112
  }));
1079
1113
  ctx.tools.register(defineTool({
1080
1114
  name: 'browser_screenshot',
1081
- description: 'Capture the tab as PNG/JPEG, save it under the configured shots directory, and return the absolute file path.',
1115
+ description: 'Capture the tab as PNG/JPEG, save it under the configured shots directory, and return the absolute file path plus the image\'s real pixel size. fullPage captures the whole scrollable page in one shot (sticky/fixed headers may repeat); selector captures one element.',
1082
1116
  parameters: {
1083
1117
  tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
1084
- fullPage: { type: 'boolean', description: 'Capture beyond the viewport.' },
1118
+ fullPage: { type: 'boolean', description: 'Capture the entire scrollable page instead of just the viewport. The receipt\'s width/height show the real size.' },
1119
+ selector: { type: 'string', description: 'CSS selector: capture just that element (scrolled into view); the receipt carries its rect.' },
1085
1120
  format: { type: 'string', description: '"png" (default) or "jpeg".' },
1086
1121
  },
1087
1122
  output: {
@@ -1094,9 +1129,24 @@ function applyBrowserTools(ctx, controller) {
1094
1129
  tabId: { type: 'number', required: true },
1095
1130
  title: { type: 'string' },
1096
1131
  url: { type: 'string' },
1132
+ width: { type: 'number' },
1133
+ height: { type: 'number' },
1134
+ elementRect: {
1135
+ type: 'object',
1136
+ additionalProperties: false,
1137
+ properties: {
1138
+ x: { type: 'number', required: true },
1139
+ y: { type: 'number', required: true },
1140
+ w: { type: 'number', required: true },
1141
+ h: { type: 'number', required: true },
1142
+ },
1143
+ },
1097
1144
  },
1098
1145
  },
1099
- render: (_args, value) => [{ type: 'text', text: `Saved ${value.file} (${value.bytes} bytes)` }],
1146
+ render: (_args, value) => [{
1147
+ type: 'text',
1148
+ text: `Saved ${value.file} (${value.bytes} bytes${value.width !== undefined && value.height !== undefined ? `, ${value.width}×${value.height}` : ''}${value.elementRect !== undefined ? `, element at ${value.elementRect.x},${value.elementRect.y} size ${value.elementRect.w}×${value.elementRect.h}` : ''})`,
1149
+ }],
1100
1150
  },
1101
1151
  presentCall: () => ({ card: 'generic', title: 'Browser screenshot', kind: 'other' }),
1102
1152
  async execute(args, exec) {
@@ -1106,6 +1156,8 @@ function applyBrowserTools(ctx, controller) {
1106
1156
  params.tabId = args.tabId;
1107
1157
  if (args.fullPage !== undefined)
1108
1158
  params.fullPage = args.fullPage;
1159
+ if (args.selector !== undefined)
1160
+ params.selector = args.selector;
1109
1161
  const payload = await controller.execute('screenshot', params, exec.signal);
1110
1162
  return saveScreenshot(controller, payload);
1111
1163
  },
@@ -1394,10 +1446,12 @@ function applyBrowserTools(ctx, controller) {
1394
1446
  }));
1395
1447
  ctx.tools.register(defineTool({
1396
1448
  name: 'browser_emulate',
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.',
1449
+ description: 'Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing, and throttle the network or CPU to test slow conditions. `device:"reset"` restores the user\'s actual viewport and clears all throttling. `network` presets (this tool\'s own numbers): slow3g = 400ms RTT + ~50KB/s both ways, fast3g = 150ms + ~200KB/s down / ~94KB/s up, offline, reset to clear. `cpuThrottlingRate` multiplies CPU cost (1 = off, up to 20). Throttling lives in the debugger session — it is gone when the extension detaches, and device:"reset" clears it too. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.',
1398
1450
  parameters: {
1399
1451
  tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
1400
1452
  device: { type: 'string', description: 'Preset: desktop | mobile-iphone-13 | mobile-pixel-7 | tablet-ipad | reset. Or pass custom width/height below.' },
1453
+ network: { type: 'string', description: 'slow3g | fast3g | offline | reset. The receipt reports the applied latency/throughput in bytes per second.' },
1454
+ cpuThrottlingRate: { type: 'number', description: 'CPU slowdown multiplier: 1 = none (default), 4 = mid-range phone, up to 20.' },
1401
1455
  width: { type: 'number', description: 'Custom viewport width in CSS px.' },
1402
1456
  height: { type: 'number', description: 'Custom viewport height in CSS px.' },
1403
1457
  deviceScaleFactor: { type: 'number', description: 'Custom DPR (1 = standard, 2 = retina, 3 = super-retina).' },
@@ -1418,26 +1472,37 @@ function applyBrowserTools(ctx, controller) {
1418
1472
  isMobile: { type: 'boolean' },
1419
1473
  hasTouch: { type: 'boolean' },
1420
1474
  userAgent: { type: 'string' },
1475
+ network: { type: 'string' },
1476
+ latency: { type: 'number' },
1477
+ downloadThroughput: { type: 'number' },
1478
+ uploadThroughput: { type: 'number' },
1479
+ cpuThrottlingRate: { type: 'number' },
1421
1480
  },
1422
1481
  },
1423
1482
  render: (_args, value) => {
1424
1483
  if (value.reset)
1425
- return [{ type: 'text', text: `Tab ${value.tabId}: emulation reset to default` }];
1426
- // 覆盖生效的是哪个 UA 必须写出来:模型只看这句,看不见 value 里的字段。
1484
+ return [{ type: 'text', text: `Tab ${value.tabId}: emulation reset to default (network unthrottled, CPU rate 1)` }];
1485
+ // 覆盖生效的是哪个 UA / 哪档节流必须写出来:模型只看这句,看不见 value 里的字段。
1427
1486
  const ua = value.userAgent === undefined ? '' : ` UA="${flattenOneLine(value.userAgent, 80)}"`;
1487
+ const net = value.network === undefined ? '' : ` network=${value.network}(${value.latency}ms↓${value.downloadThroughput}B/s↑${value.uploadThroughput}B/s)`;
1488
+ const cpu = value.cpuThrottlingRate === undefined ? '' : ` cpu×${value.cpuThrottlingRate}`;
1428
1489
  return [{
1429
1490
  type: 'text',
1430
- text: `Tab ${value.tabId}: ${value.width || '?'}×${value.height || '?'} DPR=${value.deviceScaleFactor ?? '?'} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}`,
1491
+ text: `Tab ${value.tabId}: ${value.width || '?'}×${value.height || '?'} DPR=${value.deviceScaleFactor ?? '?'} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}${net}${cpu}`,
1431
1492
  }];
1432
1493
  },
1433
1494
  },
1434
- presentCall: args => ({ card: 'generic', title: `Emulate${args.device ? ' ' + args.device : ' device'}`, kind: 'other' }),
1495
+ presentCall: args => ({ card: 'generic', title: `Emulate${args.device ? ' ' + args.device : ''}${args.network ? ' ' + args.network : ''}${args.cpuThrottlingRate ? ' cpu×' + args.cpuThrottlingRate : ''}`, kind: 'other' }),
1435
1496
  async execute(args, exec) {
1436
1497
  const params = {};
1437
1498
  if (args.tabId !== undefined)
1438
1499
  params.tabId = args.tabId;
1439
1500
  if (typeof args.device === 'string')
1440
1501
  params.device = args.device;
1502
+ if (typeof args.network === 'string')
1503
+ params.network = args.network;
1504
+ if (typeof args.cpuThrottlingRate === 'number')
1505
+ params.cpuThrottlingRate = args.cpuThrottlingRate;
1441
1506
  if (typeof args.width === 'number')
1442
1507
  params.width = args.width;
1443
1508
  if (typeof args.height === 'number')
@@ -1463,9 +1528,77 @@ function applyBrowserTools(ctx, controller) {
1463
1528
  ...(typeof raw.isMobile === 'boolean' ? { isMobile: raw.isMobile } : {}),
1464
1529
  ...(typeof raw.hasTouch === 'boolean' ? { hasTouch: raw.hasTouch } : {}),
1465
1530
  ...(typeof raw.userAgent === 'string' && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {}),
1531
+ ...(typeof raw.network === 'string' ? { network: raw.network } : {}),
1532
+ ...(typeof raw.latency === 'number' ? { latency: raw.latency } : {}),
1533
+ ...(typeof raw.downloadThroughput === 'number' ? { downloadThroughput: raw.downloadThroughput } : {}),
1534
+ ...(typeof raw.uploadThroughput === 'number' ? { uploadThroughput: raw.uploadThroughput } : {}),
1535
+ ...(typeof raw.cpuThrottlingRate === 'number' ? { cpuThrottlingRate: raw.cpuThrottlingRate } : {}),
1466
1536
  };
1467
1537
  },
1468
1538
  }));
1539
+ // Performance metrics: a pure read of CDP counters — whitelisted under
1540
+ // readOnly, no effect on the page, and the numbers ARE the content.
1541
+ ctx.tools.register(defineTool({
1542
+ name: 'browser_metrics',
1543
+ description: 'Read the tab\'s performance counters (Performance.getMetrics): DOM node count, JS heap usage, layout/style recalc counts, script/task durations. Use it to back "this page is heavy" with numbers, before and after an action. Read-only.',
1544
+ parameters: {
1545
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
1546
+ },
1547
+ output: {
1548
+ schema: {
1549
+ type: 'object',
1550
+ additionalProperties: false,
1551
+ properties: {
1552
+ tabId: { type: 'number', required: true },
1553
+ metrics: {
1554
+ type: 'object',
1555
+ required: true,
1556
+ additionalProperties: false,
1557
+ properties: {
1558
+ Timestamp: { type: 'number' },
1559
+ Documents: { type: 'number' },
1560
+ Frames: { type: 'number' },
1561
+ JSEventListeners: { type: 'number' },
1562
+ Nodes: { type: 'number' },
1563
+ LayoutCount: { type: 'number' },
1564
+ RecalcStyleCount: { type: 'number' },
1565
+ LayoutDuration: { type: 'number' },
1566
+ RecalcStyleDuration: { type: 'number' },
1567
+ ScriptDuration: { type: 'number' },
1568
+ TaskDuration: { type: 'number' },
1569
+ JSHeapUsedSize: { type: 'number' },
1570
+ JSHeapTotalSize: { type: 'number' },
1571
+ },
1572
+ },
1573
+ },
1574
+ },
1575
+ render: (_args, value) => {
1576
+ const m = (value.metrics ?? {});
1577
+ // 数字本身就是产出:关键几项原样进模型可见文本,heap 顺手换算成 MB。
1578
+ const parts = [];
1579
+ if (m.Nodes !== undefined)
1580
+ parts.push(`nodes=${m.Nodes}`);
1581
+ if (m.JSHeapUsedSize !== undefined) {
1582
+ const total = m.JSHeapTotalSize !== undefined ? `/${(m.JSHeapTotalSize / 1_048_576).toFixed(1)}MB` : '';
1583
+ parts.push(`heap=${(m.JSHeapUsedSize / 1_048_576).toFixed(1)}${total}`);
1584
+ }
1585
+ if (m.LayoutCount !== undefined)
1586
+ parts.push(`layouts=${m.LayoutCount}`);
1587
+ if (m.ScriptDuration !== undefined)
1588
+ parts.push(`script=${Number(m.ScriptDuration).toFixed(2)}s`);
1589
+ if (m.TaskDuration !== undefined)
1590
+ parts.push(`tasks=${Number(m.TaskDuration).toFixed(2)}s`);
1591
+ return [{ type: 'text', text: `Tab ${value.tabId}: ${parts.join(' ') || JSON.stringify(value.metrics)}` }];
1592
+ },
1593
+ },
1594
+ presentCall: () => ({ card: 'generic', title: 'Browser metrics', kind: 'other' }),
1595
+ async execute(args, exec) {
1596
+ const params = {};
1597
+ if (args.tabId !== undefined)
1598
+ params.tabId = args.tabId;
1599
+ return await controller.execute('metrics', params, exec.signal);
1600
+ },
1601
+ }));
1469
1602
  }
1470
1603
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
1471
1604
  export function apply(ctx, config) {
@@ -28,6 +28,9 @@ export const READ_ONLY_ALLOWED = new Set([
28
28
  'windows.list',
29
29
  // 读浏览器自己的状态(cookie 是凭据,但 get 不改任何东西;set/remove 不在此列)
30
30
  'cookies.get',
31
+ // 读性能计数器(Performance.getMetrics):纯粹读,重试无损害;节流(emulate
32
+ // 的 network/cpu 参数)改页面加载行为,emulate 整体仍是写命令,不在此列。
33
+ 'metrics',
31
34
  ]);
32
35
  /**
33
36
  * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
package/llms.txt CHANGED
@@ -35,7 +35,7 @@ Chrome 浏览器扩展 + DeepSeek Harness 插件,让 AI Agent 通过 CDP 驱
35
35
 
36
36
  ## 关键文件
37
37
 
38
- - `src/index.ts` - 24 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
38
+ - `src/index.ts` - 25 个 `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.6.0",
4
+ "version": "1.7.1",
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 && node verify/verify_archive.mjs && node verify/verify_windows.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 && node verify/verify_history_nav.mjs && node verify/verify_metrics_throttle.mjs"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public",