@angelky7/dsh-browser-control 1.7.5 → 1.7.7

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
@@ -270,10 +270,11 @@ curl -X POST http://127.0.0.1:9777/api/command \
270
270
  | `browser_drag` | Drag from/to a selector or viewport point: `auto` synthesizes html5 dragstart→dragover→drop for `draggable="true"` sources (no drop unless accepted), otherwise sends real mouse input (press, interpolated moves, release — sliders/canvas/sortable widgets). **Plugin-side only** over the cdp channel embedded in extension v1.7.2, v1.7.3+ |
271
271
  | `browser_audit` | Read this plugin session's in-memory audit trail — every command sent or refused (redacted params, `code`/`effectState`, duration), capped at 200 entries and wiped on restart; typed text and cookie values are redacted, eval expressions truncated. Plugin-local read, works in read-only mode (v1.7.4+) |
272
272
  | `browser_journal` | Read the **persistent journal across dsh restarts** (`<stateDir>/journal.jsonl`, last 1000 entries, grouped by run) — ask it after a restart "what was I doing in which tab"; same redaction; plugin-local disk read that works with the extension offline (v1.7.5+) |
273
+ | `browser_sessions` | Multi-session isolation: list the dsh sessions sharing this browser and each one's **working tab**; `action:"release"` forgets yours. Calls that omit tabId default to THIS session's working tab (verified live) — concurrent sessions stop fighting over the active tab; explicit tabId always wins. Plugin-local, v1.7.6+ |
273
274
  | `browser_cleanup` | Clean up temp files |
274
275
  | `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
275
276
 
276
- 28 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`.
277
+ 29 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`.
277
278
 
278
279
  ## Architecture
279
280
 
package/README.md CHANGED
@@ -240,7 +240,7 @@ curl -X POST http://127.0.0.1:9777/api/command \
240
240
  |---|---|
241
241
  | `browser_navigate` | 导航到 URL |
242
242
  | `browser_read` | 读取页面文本/HTML,正文直接给模型(`offset`/`limit` 窗口,长文分页) |
243
- | `browser_snapshot` | 页面快照 → 逐条列出 ref 的交互树(`mode:"ax"` 走 Chrome 无障碍树,拿 role + 可访问名) |
243
+ | `browser_snapshot` | 页面快照 → 逐条列出 ref 的交互树(`mode:"ax"` 走 Chrome 无障碍树,拿 role + 可访问名);v1.7.7 起支持 `offset` **分页**:页头写明「第几到第几 / 页面总数 / 下一页 offset」,超出扩展 200 条窗口的部分如实说明用 find/read 补齐 |
244
244
  | `browser_click` | 点击元素(by ref / selector) |
245
245
  | `browser_type` | 在输入框填入文本 |
246
246
  | `browser_press` | 模拟键盘按键(回车、Tab、方向键,以及 "Ctrl+A" / "Shift+ArrowRight" 这类组合键;一次按键只插入一次) |
@@ -263,11 +263,12 @@ curl -X POST http://127.0.0.1:9777/api/command \
263
263
  | `browser_drag` | 拖拽:from/to 用选择器或视口坐标;`auto` 对 `draggable="true"` 的源走 html5 事件合成(dragstart→dragover→drop,未接受就不 drop),其余走真实鼠标输入(按下→插值移动→抬起,滑杆/画布/排序组件用这个)。**纯插件实现**(经 v1.7.2 预埋的 cdp 通道),v1.7.3+ |
264
264
  | `browser_audit` | 读本次插件会话的内存审计账本:发过/被拒的每条命令(参数脱敏、`code`/`effectState`、耗时),最多 200 条、dsh 重启即清;输入的文字与 cookie 值打码、eval 表达式截断。纯本地读,只读档下照常可用(v1.7.4+) |
265
265
  | `browser_journal` | 读**跨 dsh 重启**的持久命令账本(`<stateDir>/journal.jsonl`,最多 1000 条,按运行分组)——重启后先问它「上个会话在哪个标签页干了什么」;同样的脱敏规则;纯本地磁盘读,**扩展离线也能用**(v1.7.5+) |
266
+ | `browser_sessions` | 多 session 隔离:列出共用这个浏览器的 dsh 会话与各自的**工作标签页**;`action:"release"` 释放自己的。省略 tabId 的调用默认落到**本会话**的工作标签页(先核验还开着),并发会话不再互相抢活动页;显式 tabId 永远有效。纯本地,v1.7.6+ |
266
267
  | `browser_windows` | 窗口控制:清单/开窗/改状态与几何/关整窗/跨窗挪标签(唯一能碰窗口几何的工具,v1.6.0+) |
267
268
  | `browser_cleanup` | 清理临时文件 |
268
269
  | `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
269
270
 
270
- 共 28 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
271
+ 共 29 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
271
272
 
272
273
  ## 架构
273
274
 
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import path from "node:path";
@@ -1135,6 +1136,70 @@ async function readJournal(stateDir, filter = {}) {
1135
1136
  };
1136
1137
  }
1137
1138
  //#endregion
1139
+ //#region lib/types/sessions.js
1140
+ var SessionRegistry = class {
1141
+ sessions = /* @__PURE__ */ new Map();
1142
+ /** 记一次活动(无论成败)。 */
1143
+ touch(sessionId) {
1144
+ const now = Date.now();
1145
+ const existing = this.sessions.get(sessionId);
1146
+ if (existing === void 0) {
1147
+ this.sessions.set(sessionId, {
1148
+ sessionId,
1149
+ lastActiveAt: now,
1150
+ calls: 1,
1151
+ createdAt: now
1152
+ });
1153
+ return;
1154
+ }
1155
+ existing.lastActiveAt = now;
1156
+ existing.calls += 1;
1157
+ }
1158
+ /** 把某个标签页记为这个会话的工作标签页。 */
1159
+ adopt(sessionId, tabId) {
1160
+ const state = this.sessions.get(sessionId);
1161
+ if (state === void 0) {
1162
+ this.sessions.set(sessionId, {
1163
+ sessionId,
1164
+ workingTabId: tabId,
1165
+ lastActiveAt: Date.now(),
1166
+ calls: 0,
1167
+ createdAt: Date.now()
1168
+ });
1169
+ return;
1170
+ }
1171
+ state.workingTabId = tabId;
1172
+ state.lastActiveAt = Date.now();
1173
+ }
1174
+ /** 当前记录的工作标签页(不核对是否还开着——核对是控制器的事)。 */
1175
+ workingTab(sessionId) {
1176
+ return this.sessions.get(sessionId)?.workingTabId;
1177
+ }
1178
+ /** 清掉工作标签页;返回被清掉的 id。 */
1179
+ release(sessionId) {
1180
+ const state = this.sessions.get(sessionId);
1181
+ if (state === void 0) return void 0;
1182
+ const previous = state.workingTabId;
1183
+ state.workingTabId = void 0;
1184
+ return previous;
1185
+ }
1186
+ /** 全部会话,最近活跃的在前。 */
1187
+ list() {
1188
+ return [...this.sessions.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt).map((s) => {
1189
+ const info = {
1190
+ sessionId: s.sessionId,
1191
+ lastActiveAt: s.lastActiveAt,
1192
+ calls: s.calls
1193
+ };
1194
+ if (s.workingTabId !== void 0) info.workingTabId = s.workingTabId;
1195
+ return info;
1196
+ });
1197
+ }
1198
+ size() {
1199
+ return this.sessions.size;
1200
+ }
1201
+ };
1202
+ //#endregion
1138
1203
  //#region lib/types/index.js
1139
1204
  /**
1140
1205
  * Browser-bridge plugin: one local WebSocket endpoint the DSH Browser Control
@@ -1170,12 +1235,55 @@ const NAV_TIERS = {
1170
1235
  load: 15e3
1171
1236
  };
1172
1237
  /**
1238
+ * Commands whose params carry no meaningful single tab: link probes, tab
1239
+ * management, window management and the global dialog policy. Everything else
1240
+ * defaults to the calling session's working tab when tabId is omitted
1241
+ * (v1.7.6 isolation). New tab-scoped commands need no registration here —
1242
+ * they inherit the default, which is the safe direction.
1243
+ */
1244
+ const NO_TAB_DEFAULT_COMMANDS = /* @__PURE__ */ new Set([
1245
+ "ping",
1246
+ "browser.info",
1247
+ "tabs.list",
1248
+ "tabs.open",
1249
+ "tabs.group",
1250
+ "tabs.ungroup",
1251
+ "windows.list",
1252
+ "windows.open",
1253
+ "windows.update",
1254
+ "windows.close",
1255
+ "windows.moveTab",
1256
+ "dialog"
1257
+ ]);
1258
+ /**
1173
1259
  * Extra time handed to the bridge on top of the navigation budget. Without it a
1174
1260
  * caller-requested budget longer than the bridge default (60s) would be beaten by
1175
1261
  * the bridge, and the model would get `command_timeout` (effect unknown) instead
1176
1262
  * of a navigation timeout (nothing was touched) - two very different next steps.
1177
1263
  */
1178
1264
  const BRIDGE_MARGIN_MS = 2e3;
1265
+ /**
1266
+ * 每个工具体都跑在调用方 agent 的会话上下文里(exec.agent.id)。控制器用它
1267
+ * 解析「这个会话的工作标签页」。没有 agent 身份的调用(HTTP 面、测试、老宿主)
1268
+ * store 是 undefined,行为与以前完全一致。
1269
+ */
1270
+ const TOOL_SESSION = new AsyncLocalStorage();
1271
+ /**
1272
+ * 包一层宿主的 defineTool:注册处全部走这里,工具体自动带上会话上下文。
1273
+ * 这是 v1.7.6 多 session 隔离能集中实现、而不用改二十多处 execute 的原因。
1274
+ */
1275
+ function defineTool$1(options) {
1276
+ const inner = options.execute;
1277
+ const execute = ((args, exec) => {
1278
+ const agent = exec.agent;
1279
+ const sessionId = typeof agent?.id === "string" && agent.id.length > 0 ? agent.id : void 0;
1280
+ return TOOL_SESSION.run(sessionId, () => inner(args, exec));
1281
+ });
1282
+ return defineTool({
1283
+ ...options,
1284
+ execute
1285
+ });
1286
+ }
1179
1287
  /** Cordis plugin name used by loader diagnostics. */
1180
1288
  const name = "browser-bridge";
1181
1289
  /** The tool registry this plugin contributes `browser_*` tools to. */
@@ -1267,6 +1375,8 @@ var BridgeController = class {
1267
1375
  journalKey = "";
1268
1376
  /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
1269
1377
  audit = new AuditLog();
1378
+ /** 多 session 隔离:每个 dsh 会话的工作标签页(见 sessions.ts)。 */
1379
+ sessions = new SessionRegistry();
1270
1380
  constructor(log) {
1271
1381
  this.log = log;
1272
1382
  }
@@ -1405,16 +1515,19 @@ var BridgeController = class {
1405
1515
  */
1406
1516
  async execute(command, params, signal, timeoutMs) {
1407
1517
  const started = Date.now();
1408
- const summary = summarizeAuditParams(command, params);
1409
- const tabId = typeof params.tabId === "number" ? params.tabId : void 0;
1518
+ const sessionId = TOOL_SESSION.getStore();
1519
+ const resolved = sessionId === void 0 ? params : await this.isolateParams(command, params, sessionId, signal);
1520
+ const summary = summarizeAuditParams(command, resolved);
1521
+ const tabId = typeof resolved.tabId === "number" ? resolved.tabId : void 0;
1410
1522
  try {
1411
- this.policyGate(command, params);
1523
+ this.policyGate(command, resolved);
1412
1524
  const server = this.server;
1413
1525
  if (server === void 0) throw classify(command, new Error(this.lastError ?? "浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)"));
1414
- const result = await server.execute(command, params, timeoutMs === void 0 ? { signal } : {
1526
+ const result = await server.execute(command, resolved, timeoutMs === void 0 ? { signal } : {
1415
1527
  signal,
1416
1528
  timeoutMs
1417
1529
  });
1530
+ if (sessionId !== void 0) this.noteSession(sessionId, resolved, result);
1418
1531
  this.recordAudit({
1419
1532
  command,
1420
1533
  tabId,
@@ -1424,6 +1537,7 @@ var BridgeController = class {
1424
1537
  });
1425
1538
  return result;
1426
1539
  } catch (error) {
1540
+ if (sessionId !== void 0) this.sessions.touch(sessionId);
1427
1541
  const code = error.code;
1428
1542
  const effectState = error.effectState;
1429
1543
  this.recordAudit({
@@ -1440,6 +1554,37 @@ var BridgeController = class {
1440
1554
  }
1441
1555
  }
1442
1556
  /**
1557
+ * Apply per-session tab isolation: when a call omits `tabId`, default it to
1558
+ * the calling session's working tab — but only after asking the extension
1559
+ * whether that tab is still open. A stale id must never fail a call that used
1560
+ * to work; when it is gone we forget it and let the browser's active tab
1561
+ * answer, exactly like before this feature existed.
1562
+ */
1563
+ async isolateParams(command, params, sessionId, signal) {
1564
+ if (typeof params.tabId === "number" || NO_TAB_DEFAULT_COMMANDS.has(command)) return params;
1565
+ const recorded = this.sessions.workingTab(sessionId);
1566
+ if (recorded === void 0) return params;
1567
+ const server = this.server;
1568
+ if (server !== void 0) try {
1569
+ const listing = await server.execute("tabs.list", {}, { signal });
1570
+ if (!(Array.isArray(listing?.tabs) && listing.tabs.some((tab) => tab?.id === recorded))) {
1571
+ this.sessions.release(sessionId);
1572
+ return params;
1573
+ }
1574
+ } catch {}
1575
+ return {
1576
+ ...params,
1577
+ tabId: recorded
1578
+ };
1579
+ }
1580
+ /** Record the call and adopt the tab it actually touched (params or result). */
1581
+ noteSession(sessionId, params, result) {
1582
+ this.sessions.touch(sessionId);
1583
+ if (typeof params.tabId === "number") this.sessions.adopt(sessionId, params.tabId);
1584
+ const resultTab = result?.tabId;
1585
+ if (typeof resultTab === "number") this.sessions.adopt(sessionId, resultTab);
1586
+ }
1587
+ /**
1443
1588
  * Refuse a command the local policy forbids, before anything is sent or deleted.
1444
1589
  *
1445
1590
  * The gate sits above the link and above the filesystem, not inside either: a
@@ -1689,7 +1834,7 @@ function applyBrowserTools(ctx, controller) {
1689
1834
  order: 112,
1690
1835
  text: "The browser_* tools drive the user's real, logged-in browser through the DSH Browser Control extension; they act on the active tab unless a tabId is passed. Prefer browser_snapshot first on unfamiliar pages: it numbers interactive elements, and browser_click/browser_type accept the returned ref instead of guessing CSS selectors. browser_read extracts page text in a window (offset/limit) and reports how much text is left, browser_screenshot saves a PNG/JPEG and returns its file path (view it with an image tool). Calls fail with actionable copy while the bridge is disabled or no browser is connected. When a result is cut to bound token cost it is marked \"[renderedTruncated omittedChars=N limitChars=L]\" — re-issue a narrower query (smaller limit, a pattern filter, or browser_read's offset) instead of assuming the page is shorter than it is."
1691
1836
  });
1692
- ctx.tools.register(defineTool({
1837
+ ctx.tools.register(defineTool$1({
1693
1838
  name: "browser_navigate",
1694
1839
  description: "Navigate a browser tab to a URL and wait for the page load to settle.",
1695
1840
  parameters: {
@@ -1784,7 +1929,7 @@ function applyBrowserTools(ctx, controller) {
1784
1929
  return out;
1785
1930
  }
1786
1931
  }));
1787
- ctx.tools.register(defineTool({
1932
+ ctx.tools.register(defineTool$1({
1788
1933
  name: "browser_read",
1789
1934
  description: "Read the current page: title, URL and the page text itself (or full HTML) in a window you control with offset/limit. The returned text is what you see — no separate fetch step is needed.",
1790
1935
  parameters: {
@@ -1879,7 +2024,7 @@ function applyBrowserTools(ctx, controller) {
1879
2024
  };
1880
2025
  }
1881
2026
  }));
1882
- ctx.tools.register(defineTool({
2027
+ ctx.tools.register(defineTool$1({
1883
2028
  name: "browser_snapshot",
1884
2029
  description: "Inventory the page's interactive elements with stable refs; pass a ref to browser_click/browser_type afterwards. mode:\"ax\" numbers them by Chrome's accessibility tree (role + accessible name), which survives a DOM redesign; the default mode matches CSS selectors.",
1885
2030
  parameters: {
@@ -1887,9 +2032,13 @@ function applyBrowserTools(ctx, controller) {
1887
2032
  type: "number",
1888
2033
  description: "Target tab; defaults to the active tab."
1889
2034
  },
2035
+ offset: {
2036
+ type: "number",
2037
+ description: "Skip this many items into the inventory (paging); default 0. Refs are valid for THIS call only — take the next page in the same breath if you need stable refs."
2038
+ },
1890
2039
  limit: {
1891
2040
  type: "number",
1892
- description: "Max elements returned; defaults to 120, capped at 200."
2041
+ description: "Page size: max items returned by this call; defaults to 120. The extension supplies up to a 200-item window per call."
1893
2042
  },
1894
2043
  mode: {
1895
2044
  type: "string",
@@ -1915,6 +2064,17 @@ function applyBrowserTools(ctx, controller) {
1915
2064
  },
1916
2065
  mode: { type: "string" },
1917
2066
  axFallback: { type: "string" },
2067
+ offset: {
2068
+ type: "number",
2069
+ required: true
2070
+ },
2071
+ total: { type: "number" },
2072
+ returned: { type: "number" },
2073
+ nextOffset: { type: "number" },
2074
+ hasMore: {
2075
+ type: "boolean",
2076
+ required: true
2077
+ },
1918
2078
  items: {
1919
2079
  type: "array",
1920
2080
  required: true,
@@ -1939,9 +2099,13 @@ function applyBrowserTools(ctx, controller) {
1939
2099
  }
1940
2100
  },
1941
2101
  render: (_args, value) => {
2102
+ const shown = value.items.length;
2103
+ const start = value.offset + 1;
2104
+ const end = value.offset + shown;
2105
+ const totalText = value.total === void 0 ? `${value.returned ?? shown} available` : `${value.total} on the page`;
1942
2106
  return [{
1943
2107
  type: "text",
1944
- text: clampRendered([`${value.items.length} interactive elements on ${value.title} (mode ${value.mode ?? "dom"}); click or fill them by ref:` + (value.axFallback === void 0 ? "" : `\n(mode ax fell back to dom: ${value.axFallback})`), ...value.items.map((item) => {
2108
+ text: clampRendered([`${shown === 0 ? `no items at offset ${value.offset}` : `items ${start}–${end} of ${totalText}`}${value.total !== void 0 && value.returned !== void 0 && value.total > value.returned ? ` (this extension returns at most ${value.returned}-item windows — use browser_find/browser_read to reach the rest)` : ""}${value.nextOffset === void 0 ? "" : `; next page: offset ${value.nextOffset}`} (mode ${value.mode ?? "dom"}); click or fill them by ref:` + (value.axFallback === void 0 ? "" : `\n(mode ax fell back to dom: ${value.axFallback})`), ...value.items.map((item) => {
1945
2109
  const name = item.name === void 0 ? "" : ` "${flattenOneLine(item.name, 80)}"`;
1946
2110
  const href = item.href === void 0 ? "" : ` -> ${flattenOneLine(item.href, 100)}`;
1947
2111
  return `[${item.ref}] ${item.role ?? item.tag}${name}${href}`;
@@ -1955,20 +2119,33 @@ function applyBrowserTools(ctx, controller) {
1955
2119
  kind: "other"
1956
2120
  }),
1957
2121
  async execute(args, exec) {
1958
- const params = { limit: Math.min(200, Math.max(1, args.limit ?? 120)) };
2122
+ const offset = Math.max(0, Math.trunc(typeof args.offset === "number" && Number.isFinite(args.offset) ? args.offset : 0));
2123
+ const limit = Math.min(200, Math.max(1, typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.trunc(args.limit) : 120));
2124
+ const params = { limit: 200 };
1959
2125
  if (typeof args.tabId === "number") params.tabId = args.tabId;
1960
2126
  if (typeof args.mode === "string") params.mode = args.mode;
1961
2127
  const raw = await controller.execute("snapshot", params, exec.signal);
1962
2128
  const requestedAx = args.mode === "ax";
1963
2129
  const gotAx = raw.mode === "ax" || raw.ax !== void 0;
1964
2130
  const axFallback = typeof raw.axFallback === "string" && raw.axFallback.length > 0 ? raw.axFallback : requestedAx && !gotAx ? "this extension ignored mode:\"ax\" and returned CSS-DOM matches (AX mode needs DSH Browser Control extension v1.0.11 or newer) — the names below are tag names, not accessible names" : void 0;
2131
+ const window = Array.isArray(raw.items) ? raw.items : [];
2132
+ const returned = window.length;
2133
+ const reportedTotal = typeof raw.total === "number" && Number.isFinite(raw.total) ? Math.trunc(raw.total) : void 0;
2134
+ const total = reportedTotal === void 0 ? void 0 : Math.max(returned, reportedTotal);
2135
+ const page = window.slice(offset, offset + limit);
2136
+ const hasMore = offset + page.length < returned;
1965
2137
  return {
1966
2138
  tabId: raw.tabId,
1967
2139
  title: raw.title ?? "",
1968
2140
  url: raw.url ?? "",
1969
2141
  ...raw.mode === void 0 ? {} : { mode: raw.mode },
1970
2142
  ...axFallback === void 0 ? {} : { axFallback },
1971
- items: (raw.items ?? []).map((item) => ({
2143
+ offset,
2144
+ ...total === void 0 ? {} : { total },
2145
+ returned,
2146
+ hasMore,
2147
+ ...hasMore ? { nextOffset: offset + page.length } : {},
2148
+ items: page.map((item) => ({
1972
2149
  ref: item.ref,
1973
2150
  tag: item.tag,
1974
2151
  ...item.role === void 0 ? {} : { role: item.role },
@@ -1978,7 +2155,7 @@ function applyBrowserTools(ctx, controller) {
1978
2155
  };
1979
2156
  }
1980
2157
  }));
1981
- ctx.tools.register(defineTool({
2158
+ ctx.tools.register(defineTool$1({
1982
2159
  name: "browser_click",
1983
2160
  description: "Click a page element with real mouse events; target it by snapshot ref or CSS selector.",
1984
2161
  parameters: {
@@ -2021,7 +2198,7 @@ function applyBrowserTools(ctx, controller) {
2021
2198
  return await controller.execute("click", params, exec.signal);
2022
2199
  }
2023
2200
  }));
2024
- ctx.tools.register(defineTool({
2201
+ ctx.tools.register(defineTool$1({
2025
2202
  name: "browser_type",
2026
2203
  description: "Fill an input/textarea/select/contentEditable (React-compatible events); optionally press Enter afterwards.",
2027
2204
  parameters: {
@@ -2076,7 +2253,7 @@ function applyBrowserTools(ctx, controller) {
2076
2253
  return filled;
2077
2254
  }
2078
2255
  }));
2079
- ctx.tools.register(defineTool({
2256
+ ctx.tools.register(defineTool$1({
2080
2257
  name: "browser_press",
2081
2258
  description: "Send a real keyboard event to the page: a named key (Enter, Tab, Escape, ArrowDown…), a single character, or a combination such as \"Ctrl+A\" / \"Shift+ArrowRight\".",
2082
2259
  parameters: {
@@ -2117,7 +2294,7 @@ function applyBrowserTools(ctx, controller) {
2117
2294
  return await controller.execute("press", params, exec.signal);
2118
2295
  }
2119
2296
  }));
2120
- ctx.tools.register(defineTool({
2297
+ ctx.tools.register(defineTool$1({
2121
2298
  name: "browser_scroll",
2122
2299
  description: "Scroll the page viewport by a delta and report the resulting position.",
2123
2300
  parameters: {
@@ -2160,7 +2337,7 @@ function applyBrowserTools(ctx, controller) {
2160
2337
  }));
2161
2338
  /** Page-side ceiling of `cmdWait`; the bridge budget adds slack on top. */
2162
2339
  const WAIT_MAX_MS = 12e4;
2163
- ctx.tools.register(defineTool({
2340
+ ctx.tools.register(defineTool$1({
2164
2341
  name: "browser_wait",
2165
2342
  description: "Block until a selector appears, some text shows up in the body, or a page-side predicate turns true. Use it after navigation for SPA routes and lazy-rendered content, which browser_navigate does not wait for.",
2166
2343
  parameters: {
@@ -2217,7 +2394,7 @@ function applyBrowserTools(ctx, controller) {
2217
2394
  return await controller.execute("wait", params, exec.signal, timeoutMs + 1e4);
2218
2395
  }
2219
2396
  }));
2220
- ctx.tools.register(defineTool({
2397
+ ctx.tools.register(defineTool$1({
2221
2398
  name: "browser_find",
2222
2399
  description: "Describe the elements matching a CSS selector — count, tag, id, class, text, href and viewport rect. Read-only; use it to decide what to click and to verify the page state.",
2223
2400
  parameters: {
@@ -2257,7 +2434,7 @@ function applyBrowserTools(ctx, controller) {
2257
2434
  return await controller.execute("find", params, exec.signal);
2258
2435
  }
2259
2436
  }));
2260
- ctx.tools.register(defineTool({
2437
+ ctx.tools.register(defineTool$1({
2261
2438
  name: "browser_hold",
2262
2439
  description: "Put a cancellable take-over overlay on a tab so the user can see that you are working on this page and cannot mis-click. The card title is fixed by the extension; your `note` is the body text only (≤400 chars, newlines flattened). The user double-clicks the page (or presses Escape twice, or holds Escape) to close it — you can keep working. Pressing the card's abort button blocks your click/input/press/nav on that tab until you call action=\"off\" (which counts as confirming you continue). This is a soft layer, not a security boundary: the popup lock is the thing that actually freezes the browser. Returns `covered:false` with a `reason` when the page cannot be covered (browser pages, error pages, CSP-strict frames, PDF viewer, an open native dialog).",
2263
2440
  parameters: {
@@ -2308,7 +2485,7 @@ function applyBrowserTools(ctx, controller) {
2308
2485
  return await controller.execute("hold", params, exec.signal);
2309
2486
  }
2310
2487
  }));
2311
- ctx.tools.register(defineTool({
2488
+ ctx.tools.register(defineTool$1({
2312
2489
  name: "browser_cookies",
2313
2490
  description: "Read, set or remove cookies of a tab (CDP Network.*, no new permission). Cookie values are credentials — a get returns them in full and they land in the model-visible text, so filter by domain and do not point this at a page whose cookies you do not actually need. set/remove need an anchor: url, or domain (with optional path).",
2314
2491
  parameters: {
@@ -2397,7 +2574,7 @@ function applyBrowserTools(ctx, controller) {
2397
2574
  return await controller.execute("cookies.remove", params, exec.signal);
2398
2575
  }
2399
2576
  }));
2400
- ctx.tools.register(defineTool({
2577
+ ctx.tools.register(defineTool$1({
2401
2578
  name: "browser_dialog",
2402
2579
  description: "Read or change how native alert/confirm/prompt dialogs are answered (accept, dismiss, or manual), and get the recent dialog log so you can prove one appeared.",
2403
2580
  parameters: { policy: {
@@ -2430,7 +2607,7 @@ function applyBrowserTools(ctx, controller) {
2430
2607
  return await controller.execute("dialog", params, exec.signal);
2431
2608
  }
2432
2609
  }));
2433
- ctx.tools.register(defineTool({
2610
+ ctx.tools.register(defineTool$1({
2434
2611
  name: "browser_info",
2435
2612
  description: "Report which browser and which extension build is actually attached. Run it first when behaviour looks wrong — a stale extension or the wrong browser profile explains most of it.",
2436
2613
  parameters: {},
@@ -2453,7 +2630,7 @@ function applyBrowserTools(ctx, controller) {
2453
2630
  return await controller.execute("browser.info", {}, exec.signal);
2454
2631
  }
2455
2632
  }));
2456
- ctx.tools.register(defineTool({
2633
+ ctx.tools.register(defineTool$1({
2457
2634
  name: "browser_tabs",
2458
2635
  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.",
2459
2636
  parameters: {
@@ -2561,7 +2738,7 @@ function applyBrowserTools(ctx, controller) {
2561
2738
  }
2562
2739
  }
2563
2740
  }));
2564
- ctx.tools.register(defineTool({
2741
+ ctx.tools.register(defineTool$1({
2565
2742
  name: "browser_windows",
2566
2743
  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.",
2567
2744
  parameters: {
@@ -2696,7 +2873,7 @@ function applyBrowserTools(ctx, controller) {
2696
2873
  }));
2697
2874
  /** Page-side ceiling of the eval race in the extension; the bridge budget adds slack on top. */
2698
2875
  const EVAL_MAX_MS = 12e4;
2699
- ctx.tools.register(defineTool({
2876
+ ctx.tools.register(defineTool$1({
2700
2877
  name: "browser_evaluate",
2701
2878
  description: "Run JavaScript in the page and get the JSON result back as a string. Prefer read-only inspection.",
2702
2879
  parameters: {
@@ -2761,7 +2938,7 @@ function applyBrowserTools(ctx, controller) {
2761
2938
  };
2762
2939
  }
2763
2940
  }));
2764
- ctx.tools.register(defineTool({
2941
+ ctx.tools.register(defineTool$1({
2765
2942
  name: "browser_screenshot",
2766
2943
  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.",
2767
2944
  parameters: {
@@ -2845,7 +3022,7 @@ function applyBrowserTools(ctx, controller) {
2845
3022
  return saveScreenshot(controller, await controller.execute("screenshot", params, exec.signal));
2846
3023
  }
2847
3024
  }));
2848
- ctx.tools.register(defineTool({
3025
+ ctx.tools.register(defineTool$1({
2849
3026
  name: "browser_cleanup",
2850
3027
  description: "Delete generated screenshots and agent scratch files (__-prefixed temp scripts/artifacts) from their top-level directories.",
2851
3028
  parameters: {},
@@ -2883,7 +3060,7 @@ function applyBrowserTools(ctx, controller) {
2883
3060
  };
2884
3061
  }
2885
3062
  }));
2886
- ctx.tools.register(defineTool({
3063
+ ctx.tools.register(defineTool$1({
2887
3064
  name: "browser_console_log",
2888
3065
  description: "Read the captured `console.log/info/warn/error` entries for a tab, with the entry text itself. Set `clear:true` to also empty the buffer so the next call shows only entries recorded after this one. Useful for \"what did the page log after I clicked submit\".",
2889
3066
  parameters: {
@@ -2981,7 +3158,7 @@ function applyBrowserTools(ctx, controller) {
2981
3158
  };
2982
3159
  }
2983
3160
  }));
2984
- ctx.tools.register(defineTool({
3161
+ ctx.tools.register(defineTool$1({
2985
3162
  name: "browser_network_log",
2986
3163
  description: "Read captured HTTP request/response pairs for a tab. `includeStatic:true` adds images / fonts / stylesheets / scripts (filtered by default — they dominate the buffer). `methodPattern` / `urlPattern` / `status` filter server-side results; `clear:true` empties the buffer.",
2987
3164
  parameters: {
@@ -3096,7 +3273,7 @@ function applyBrowserTools(ctx, controller) {
3096
3273
  };
3097
3274
  }
3098
3275
  }));
3099
- ctx.tools.register(defineTool({
3276
+ ctx.tools.register(defineTool$1({
3100
3277
  name: "browser_network_clear",
3101
3278
  description: "Empty the per-tab network capture buffer without returning the rows.",
3102
3279
  parameters: { tabId: {
@@ -3134,7 +3311,7 @@ function applyBrowserTools(ctx, controller) {
3134
3311
  return await controller.execute("network.clear", params, exec.signal);
3135
3312
  }
3136
3313
  }));
3137
- ctx.tools.register(defineTool({
3314
+ ctx.tools.register(defineTool$1({
3138
3315
  name: "browser_pdf",
3139
3316
  description: "Export the current page to a PDF. `path` may be absolute (saved there) or omitted (saved under the configured shotsDir). Returns the absolute path + size; the PDF preserves text (selectable, searchable) and print-media CSS.",
3140
3317
  parameters: {
@@ -3212,7 +3389,7 @@ function applyBrowserTools(ctx, controller) {
3212
3389
  return await savePdf(controller, await controller.execute("pdf", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
3213
3390
  }
3214
3391
  }));
3215
- ctx.tools.register(defineTool({
3392
+ ctx.tools.register(defineTool$1({
3216
3393
  name: "browser_archive",
3217
3394
  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.",
3218
3395
  parameters: {
@@ -3261,7 +3438,7 @@ function applyBrowserTools(ctx, controller) {
3261
3438
  return await saveMhtml(controller, await controller.execute("archive", params, exec.signal), typeof args.path === "string" ? args.path : void 0);
3262
3439
  }
3263
3440
  }));
3264
- ctx.tools.register(defineTool({
3441
+ ctx.tools.register(defineTool$1({
3265
3442
  name: "browser_emulate",
3266
3443
  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.",
3267
3444
  parameters: {
@@ -3378,7 +3555,7 @@ function applyBrowserTools(ctx, controller) {
3378
3555
  };
3379
3556
  }
3380
3557
  }));
3381
- ctx.tools.register(defineTool({
3558
+ ctx.tools.register(defineTool$1({
3382
3559
  name: "browser_metrics",
3383
3560
  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.",
3384
3561
  parameters: { tabId: {
@@ -3448,6 +3625,7 @@ function applyBrowserTools(ctx, controller) {
3448
3625
  registerDragTool(ctx, controller);
3449
3626
  registerAuditTool(ctx, controller);
3450
3627
  registerJournalTool(ctx, controller);
3628
+ registerSessionsTool(ctx, controller);
3451
3629
  }
3452
3630
  /** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
3453
3631
  async function cdpCall(ctrl, tabId, method, params, signal) {
@@ -3564,7 +3742,7 @@ function roundDragPoint(p) {
3564
3742
  return out;
3565
3743
  }
3566
3744
  function registerDragTool(ctx, controller) {
3567
- ctx.tools.register(defineTool({
3745
+ ctx.tools.register(defineTool$1({
3568
3746
  name: "browser_drag",
3569
3747
  description: "Drag a page element or point onto another with real input. mode \"mouse\" sends trusted press→moves→release input via CDP (canvas, sliders, sortable widgets); mode \"html5\" synthesizes dragstart/dragover/drop in the page for [draggable] elements and needs page scripting; \"auto\" (default) picks html5 only when the source declares draggable=\"true\". Endpoints are CSS selectors (top document) or fromX/fromY + toX/toY viewport coordinates. Needs browser extension v1.7.2+ (embedded cdp channel).",
3570
3748
  parameters: {
@@ -3777,7 +3955,7 @@ function registerDragTool(ctx, controller) {
3777
3955
  }));
3778
3956
  }
3779
3957
  function registerAuditTool(ctx, controller) {
3780
- ctx.tools.register(defineTool({
3958
+ ctx.tools.register(defineTool$1({
3781
3959
  name: "browser_audit",
3782
3960
  description: "Read this plugin session's in-memory audit trail: every command sent to the browser extension (and every policy/gate refusal), with redacted params, outcome code and duration. Newest last; capped at 200 entries and wiped when dsh restarts. Values typed into pages and cookie values are redacted; eval expressions are truncated. Plugin-local read — works in read-only mode.",
3783
3961
  parameters: {
@@ -3890,7 +4068,7 @@ function registerAuditTool(ctx, controller) {
3890
4068
  }));
3891
4069
  }
3892
4070
  function registerJournalTool(ctx, controller) {
3893
- ctx.tools.register(defineTool({
4071
+ ctx.tools.register(defineTool$1({
3894
4072
  name: "browser_journal",
3895
4073
  description: "Read the persistent command journal across dsh restarts: every command from previous runs of this plugin (written to <stateDir>/journal.jsonl, last 1000 entries), grouped by run. Same redaction as browser_audit. Plugin-local disk read — works with the extension offline and in read-only mode, so it is the way to recover \"what was I doing\" after a restart.",
3896
4074
  parameters: {
@@ -4013,6 +4191,93 @@ function registerJournalTool(ctx, controller) {
4013
4191
  }
4014
4192
  }));
4015
4193
  }
4194
+ function registerSessionsTool(ctx, controller) {
4195
+ ctx.tools.register(defineTool$1({
4196
+ name: "browser_sessions",
4197
+ description: "List the dsh agent sessions sharing this browser and each one's working tab, or release your own working tab (\"release\"). Isolation: when a browser tool omits tabId, it defaults to THIS session's working tab (verified live against the extension) instead of the browser's active tab — concurrent sessions stop fighting over whatever tab is active. Explicit tabId always wins. Plugin-local; works read-only.",
4198
+ parameters: { action: {
4199
+ type: "string",
4200
+ description: "\"list\" (default) or \"release\" (forget your working tab)."
4201
+ } },
4202
+ output: {
4203
+ schema: {
4204
+ type: "object",
4205
+ additionalProperties: false,
4206
+ properties: {
4207
+ current: { type: "string" },
4208
+ sessions: {
4209
+ type: "array",
4210
+ required: true,
4211
+ items: {
4212
+ type: "object",
4213
+ additionalProperties: false,
4214
+ properties: {
4215
+ sessionId: {
4216
+ type: "string",
4217
+ required: true
4218
+ },
4219
+ workingTabId: { type: "number" },
4220
+ lastActiveAt: {
4221
+ type: "number",
4222
+ required: true
4223
+ },
4224
+ calls: {
4225
+ type: "number",
4226
+ required: true
4227
+ }
4228
+ }
4229
+ }
4230
+ },
4231
+ releasedTabId: { type: "number" }
4232
+ }
4233
+ },
4234
+ render: (_args, value) => {
4235
+ const v = value;
4236
+ const short = (id) => id.length <= 18 ? id : `${id.slice(0, 16)}…`;
4237
+ if (v.releasedTabId !== void 0) return [{
4238
+ type: "text",
4239
+ text: `Sessions: released your working tab ${v.releasedTabId} — later calls without tabId follow the browser's active tab again.`
4240
+ }];
4241
+ if (v.sessions.length === 0) return [{
4242
+ type: "text",
4243
+ text: "Sessions: 0 — no agent-scoped tool calls yet (calls without an agent identity keep the old active-tab behaviour)."
4244
+ }];
4245
+ const lines = v.sessions.map((s) => {
4246
+ const me = s.sessionId === v.current ? "* " : " ";
4247
+ const tab = s.workingTabId === void 0 ? "no working tab" : `tab ${s.workingTabId}`;
4248
+ return `${me}${short(s.sessionId)} — ${tab}, ${s.calls} calls, last ${formatClock(s.lastActiveAt)}`;
4249
+ });
4250
+ return [{
4251
+ type: "text",
4252
+ text: clampRendered(`Sessions: ${v.sessions.length}${v.current === void 0 ? "" : ` (you: ${short(v.current)})`}\n${lines.join("\n")}`)
4253
+ }];
4254
+ }
4255
+ },
4256
+ presentCall: (args) => ({
4257
+ card: "generic",
4258
+ title: args.action === "release" ? "Release session tab" : "Browser sessions",
4259
+ kind: "other"
4260
+ }),
4261
+ async execute(args) {
4262
+ const current = TOOL_SESSION.getStore();
4263
+ if (args.action === "release") {
4264
+ const sessions = controller.sessions.list();
4265
+ if (current === void 0) return { sessions };
4266
+ const released = controller.sessions.release(current);
4267
+ const payload = {
4268
+ current,
4269
+ sessions
4270
+ };
4271
+ if (released !== void 0) payload.releasedTabId = released;
4272
+ return payload;
4273
+ }
4274
+ if (args.action !== void 0 && args.action !== "list") throw new Error(`action must be "list" or "release" — got ${JSON.stringify(args.action)}`);
4275
+ const payload = { sessions: controller.sessions.list() };
4276
+ if (current !== void 0) payload.current = current;
4277
+ return payload;
4278
+ }
4279
+ }));
4280
+ }
4016
4281
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
4017
4282
  function apply(ctx, config) {
4018
4283
  const resolved = config;
@@ -17,16 +17,18 @@
17
17
  * fix, so the model can tell the user what to do instead of hanging.
18
18
  * @module @deepseek-ai/dsh-browser-bridge
19
19
  */
20
+ import { AsyncLocalStorage } from 'node:async_hooks';
20
21
  import { mkdir, writeFile } from 'node:fs/promises';
21
22
  import { homedir } from 'node:os';
22
23
  import path from 'node:path';
23
24
  import z from '@deepseek-ai/schemastery';
24
- import { defineTool } from '@deepseek-ai/dsh-tools';
25
+ import { defineTool as defineHostTool } from '@deepseek-ai/dsh-tools';
25
26
  import { BridgeServer, cleanupArtifacts } from "./server.js";
26
27
  import { BridgeError, classify } from "./errors.js";
27
28
  import { policyRefusal } from "./policy.js";
28
29
  import { AUDIT_MAX_ENTRIES, AuditLog, summarizeAuditParams } from "./audit.js";
29
30
  import { Journal, readJournal } from "./journal.js";
31
+ import { SessionRegistry } from "./sessions.js";
30
32
  /** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
31
33
  const NAV_MAX_MS = 120_000;
32
34
  /**
@@ -36,6 +38,19 @@ const NAV_MAX_MS = 120_000;
36
38
  * protocol: a value only one side knows is a lie on the other).
37
39
  */
38
40
  const NAV_TIERS = { none: 0, commit: 4_000, domcontentloaded: 4_000, load: 15_000 };
41
+ /**
42
+ * Commands whose params carry no meaningful single tab: link probes, tab
43
+ * management, window management and the global dialog policy. Everything else
44
+ * defaults to the calling session's working tab when tabId is omitted
45
+ * (v1.7.6 isolation). New tab-scoped commands need no registration here —
46
+ * they inherit the default, which is the safe direction.
47
+ */
48
+ const NO_TAB_DEFAULT_COMMANDS = new Set([
49
+ 'ping', 'browser.info',
50
+ 'tabs.list', 'tabs.open', 'tabs.group', 'tabs.ungroup',
51
+ 'windows.list', 'windows.open', 'windows.update', 'windows.close', 'windows.moveTab',
52
+ 'dialog',
53
+ ]);
39
54
  /**
40
55
  * Extra time handed to the bridge on top of the navigation budget. Without it a
41
56
  * caller-requested budget longer than the bridge default (60s) would be beaten by
@@ -43,6 +58,31 @@ const NAV_TIERS = { none: 0, commit: 4_000, domcontentloaded: 4_000, load: 15_00
43
58
  * of a navigation timeout (nothing was touched) - two very different next steps.
44
59
  */
45
60
  const BRIDGE_MARGIN_MS = 2_000;
61
+ /**
62
+ * 每个工具体都跑在调用方 agent 的会话上下文里(exec.agent.id)。控制器用它
63
+ * 解析「这个会话的工作标签页」。没有 agent 身份的调用(HTTP 面、测试、老宿主)
64
+ * store 是 undefined,行为与以前完全一致。
65
+ */
66
+ const TOOL_SESSION = new AsyncLocalStorage();
67
+ /**
68
+ * 包一层宿主的 defineTool:注册处全部走这里,工具体自动带上会话上下文。
69
+ * 这是 v1.7.6 多 session 隔离能集中实现、而不用改二十多处 execute 的原因。
70
+ */
71
+ function defineTool(options) {
72
+ // The body is typed through ToolRunContext and cast at both boundaries: letting
73
+ // TS deep-compare the generic option shape through a spread blows its recursion
74
+ // budget ("excessive stack depth comparing InferObject").
75
+ const inner = options.execute;
76
+ const execute = ((args, exec) => {
77
+ const agent = exec.agent;
78
+ const sessionId = typeof agent?.id === 'string' && agent.id.length > 0 ? agent.id : undefined;
79
+ return TOOL_SESSION.run(sessionId, () => inner(args, exec));
80
+ });
81
+ // Call the host helper through a non-generic point: re-instantiating its
82
+ // recursive InferObject types from inside a generic wrapper is what TS chokes on.
83
+ const hostDefine = defineHostTool;
84
+ return hostDefine({ ...options, execute });
85
+ }
46
86
  // Re-exported so the policy tables stay testable from the published bundle
47
87
  // (verify/verify_policy.mjs reconciles them against the extension's COMMANDS).
48
88
  export { LOCAL_DESTRUCTIVE_COMMANDS, PAGE_MUTATING_COMMANDS, READ_ONLY_ALLOWED, SCRIPT_COMMANDS, policyRefusal } from "./policy.js";
@@ -125,6 +165,8 @@ class BridgeController {
125
165
  journalKey = '';
126
166
  /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
127
167
  audit = new AuditLog();
168
+ /** 多 session 隔离:每个 dsh 会话的工作标签页(见 sessions.ts)。 */
169
+ sessions = new SessionRegistry();
128
170
  constructor(log) {
129
171
  this.log = log;
130
172
  }
@@ -266,19 +308,25 @@ class BridgeController {
266
308
  */
267
309
  async execute(command, params, signal, timeoutMs) {
268
310
  const started = Date.now();
269
- const summary = summarizeAuditParams(command, params);
270
- const tabId = typeof params.tabId === 'number' ? params.tabId : undefined;
311
+ const sessionId = TOOL_SESSION.getStore();
312
+ const resolved = sessionId === undefined ? params : await this.isolateParams(command, params, sessionId, signal);
313
+ const summary = summarizeAuditParams(command, resolved);
314
+ const tabId = typeof resolved.tabId === 'number' ? resolved.tabId : undefined;
271
315
  try {
272
- this.policyGate(command, params);
316
+ this.policyGate(command, resolved);
273
317
  const server = this.server;
274
318
  if (server === undefined) {
275
319
  throw classify(command, new Error(this.lastError ?? '浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)'));
276
320
  }
277
- const result = await server.execute(command, params, timeoutMs === undefined ? { signal } : { signal, timeoutMs });
321
+ const result = await server.execute(command, resolved, timeoutMs === undefined ? { signal } : { signal, timeoutMs });
322
+ if (sessionId !== undefined)
323
+ this.noteSession(sessionId, resolved, result);
278
324
  this.recordAudit({ command, tabId, params: summary, ok: true, durationMs: Date.now() - started });
279
325
  return result;
280
326
  }
281
327
  catch (error) {
328
+ if (sessionId !== undefined)
329
+ this.sessions.touch(sessionId);
282
330
  const code = error.code;
283
331
  const effectState = error.effectState;
284
332
  this.recordAudit({
@@ -294,6 +342,42 @@ class BridgeController {
294
342
  throw error;
295
343
  }
296
344
  }
345
+ /**
346
+ * Apply per-session tab isolation: when a call omits `tabId`, default it to
347
+ * the calling session's working tab — but only after asking the extension
348
+ * whether that tab is still open. A stale id must never fail a call that used
349
+ * to work; when it is gone we forget it and let the browser's active tab
350
+ * answer, exactly like before this feature existed.
351
+ */
352
+ async isolateParams(command, params, sessionId, signal) {
353
+ if (typeof params.tabId === 'number' || NO_TAB_DEFAULT_COMMANDS.has(command))
354
+ return params;
355
+ const recorded = this.sessions.workingTab(sessionId);
356
+ if (recorded === undefined)
357
+ return params;
358
+ const server = this.server;
359
+ if (server !== undefined) {
360
+ try {
361
+ const listing = await server.execute('tabs.list', {}, { signal });
362
+ const live = Array.isArray(listing?.tabs) && listing.tabs.some((tab) => tab?.id === recorded);
363
+ if (!live) {
364
+ this.sessions.release(sessionId);
365
+ return params;
366
+ }
367
+ }
368
+ catch { /* 列不出来就按旧行为走活动标签页 */ }
369
+ }
370
+ return { ...params, tabId: recorded };
371
+ }
372
+ /** Record the call and adopt the tab it actually touched (params or result). */
373
+ noteSession(sessionId, params, result) {
374
+ this.sessions.touch(sessionId);
375
+ if (typeof params.tabId === 'number')
376
+ this.sessions.adopt(sessionId, params.tabId);
377
+ const resultTab = result?.tabId;
378
+ if (typeof resultTab === 'number')
379
+ this.sessions.adopt(sessionId, resultTab);
380
+ }
297
381
  /**
298
382
  * Refuse a command the local policy forbids, before anything is sent or deleted.
299
383
  *
@@ -725,7 +809,8 @@ function applyBrowserTools(ctx, controller) {
725
809
  description: 'Inventory the page\'s interactive elements with stable refs; pass a ref to browser_click/browser_type afterwards. mode:"ax" numbers them by Chrome\'s accessibility tree (role + accessible name), which survives a DOM redesign; the default mode matches CSS selectors.',
726
810
  parameters: {
727
811
  tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
728
- limit: { type: 'number', description: 'Max elements returned; defaults to 120, capped at 200.' },
812
+ offset: { type: 'number', description: 'Skip this many items into the inventory (paging); default 0. Refs are valid for THIS call only — take the next page in the same breath if you need stable refs.' },
813
+ limit: { type: 'number', description: 'Page size: max items returned by this call; defaults to 120. The extension supplies up to a 200-item window per call.' },
729
814
  mode: { type: 'string', description: '"ax" for the accessibility tree, "dom" (default) for the selector list. "ax" falls back to "dom" with axFallback set if the page gives no a11y tree.' },
730
815
  },
731
816
  output: {
@@ -738,6 +823,11 @@ function applyBrowserTools(ctx, controller) {
738
823
  url: { type: 'string', required: true },
739
824
  mode: { type: 'string' },
740
825
  axFallback: { type: 'string' },
826
+ offset: { type: 'number', required: true },
827
+ total: { type: 'number' },
828
+ returned: { type: 'number' },
829
+ nextOffset: { type: 'number' },
830
+ hasMore: { type: 'boolean', required: true },
741
831
  items: {
742
832
  type: 'array',
743
833
  required: true,
@@ -756,7 +846,16 @@ function applyBrowserTools(ctx, controller) {
756
846
  },
757
847
  },
758
848
  render: (_args, value) => {
759
- const head = `${value.items.length} interactive elements on ${value.title}`
849
+ const shown = value.items.length;
850
+ const start = value.offset + 1;
851
+ const end = value.offset + shown;
852
+ const totalText = value.total === undefined ? `${value.returned ?? shown} available` : `${value.total} on the page`;
853
+ const range = shown === 0 ? `no items at offset ${value.offset}` : `items ${start}–${end} of ${totalText}`;
854
+ const capped = value.total !== undefined && value.returned !== undefined && value.total > value.returned
855
+ ? ` (this extension returns at most ${value.returned}-item windows — use browser_find/browser_read to reach the rest)`
856
+ : '';
857
+ const next = value.nextOffset === undefined ? '' : `; next page: offset ${value.nextOffset}`;
858
+ const head = `${range}${capped}${next}`
760
859
  + ` (mode ${value.mode ?? 'dom'}); click or fill them by ref:`
761
860
  + (value.axFallback === undefined ? '' : `\n(mode ax fell back to dom: ${value.axFallback})`);
762
861
  const lines = value.items.map(item => {
@@ -769,8 +868,12 @@ function applyBrowserTools(ctx, controller) {
769
868
  },
770
869
  presentCall: () => ({ card: 'generic', title: 'Snapshot browser page', kind: 'other' }),
771
870
  async execute(args, exec) {
772
- const limit = Math.min(200, Math.max(1, args.limit ?? 120));
773
- const params = { limit };
871
+ const offset = Math.max(0, Math.trunc(typeof args.offset === 'number' && Number.isFinite(args.offset) ? args.offset : 0));
872
+ const limit = Math.min(200, Math.max(1, typeof args.limit === 'number' && Number.isFinite(args.limit) ? Math.trunc(args.limit) : 120));
873
+ // Ask for the largest window the extension offers (200) and page in the
874
+ // plugin: older surfaces never learned an offset parameter, and they do
875
+ // report `total`, so paging stays plugin-side (v1.7.7).
876
+ const params = { limit: 200 };
774
877
  if (typeof args.tabId === 'number')
775
878
  params.tabId = args.tabId;
776
879
  if (typeof args.mode === 'string')
@@ -788,13 +891,24 @@ function applyBrowserTools(ctx, controller) {
788
891
  : (requestedAx && !gotAx
789
892
  ? 'this extension ignored mode:"ax" and returned CSS-DOM matches (AX mode needs DSH Browser Control extension v1.0.11 or newer) — the names below are tag names, not accessible names'
790
893
  : undefined);
791
- return {
894
+ const window = Array.isArray(raw.items) ? raw.items : [];
895
+ const returned = window.length;
896
+ const reportedTotal = typeof raw.total === 'number' && Number.isFinite(raw.total) ? Math.trunc(raw.total) : undefined;
897
+ const total = reportedTotal === undefined ? undefined : Math.max(returned, reportedTotal);
898
+ const page = window.slice(offset, offset + limit);
899
+ const hasMore = offset + page.length < returned;
900
+ const receipt = {
792
901
  tabId: raw.tabId,
793
902
  title: raw.title ?? '',
794
903
  url: raw.url ?? '',
795
904
  ...(raw.mode === undefined ? {} : { mode: raw.mode }),
796
905
  ...(axFallback === undefined ? {} : { axFallback }),
797
- items: (raw.items ?? []).map(item => ({
906
+ offset,
907
+ ...(total === undefined ? {} : { total }),
908
+ returned,
909
+ hasMore,
910
+ ...(hasMore ? { nextOffset: offset + page.length } : {}),
911
+ items: page.map(item => ({
798
912
  ref: item.ref,
799
913
  tag: item.tag,
800
914
  ...(item.role === undefined ? {} : { role: item.role }),
@@ -802,6 +916,7 @@ function applyBrowserTools(ctx, controller) {
802
916
  ...(item.href === undefined ? {} : { href: item.href }),
803
917
  })),
804
918
  };
919
+ return receipt;
805
920
  },
806
921
  }));
807
922
  ctx.tools.register(defineTool({
@@ -1783,6 +1898,7 @@ function applyBrowserTools(ctx, controller) {
1783
1898
  registerDragTool(ctx, controller);
1784
1899
  registerAuditTool(ctx, controller);
1785
1900
  registerJournalTool(ctx, controller);
1901
+ registerSessionsTool(ctx, controller);
1786
1902
  }
1787
1903
  /* ------------------- browser_drag: composed plugin-side over the cdp channel ------------------- */
1788
1904
  /** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
@@ -2183,6 +2299,75 @@ function registerJournalTool(ctx, controller) {
2183
2299
  },
2184
2300
  }));
2185
2301
  }
2302
+ function registerSessionsTool(ctx, controller) {
2303
+ ctx.tools.register(defineTool({
2304
+ name: 'browser_sessions',
2305
+ description: 'List the dsh agent sessions sharing this browser and each one\'s working tab, or release your own working tab ("release"). Isolation: when a browser tool omits tabId, it defaults to THIS session\'s working tab (verified live against the extension) instead of the browser\'s active tab — concurrent sessions stop fighting over whatever tab is active. Explicit tabId always wins. Plugin-local; works read-only.',
2306
+ parameters: {
2307
+ action: { type: 'string', description: '"list" (default) or "release" (forget your working tab).' },
2308
+ },
2309
+ output: {
2310
+ schema: {
2311
+ type: 'object',
2312
+ additionalProperties: false,
2313
+ properties: {
2314
+ current: { type: 'string' },
2315
+ sessions: {
2316
+ type: 'array',
2317
+ required: true,
2318
+ items: {
2319
+ type: 'object',
2320
+ additionalProperties: false,
2321
+ properties: {
2322
+ sessionId: { type: 'string', required: true },
2323
+ workingTabId: { type: 'number' },
2324
+ lastActiveAt: { type: 'number', required: true },
2325
+ calls: { type: 'number', required: true },
2326
+ },
2327
+ },
2328
+ },
2329
+ releasedTabId: { type: 'number' },
2330
+ },
2331
+ },
2332
+ render: (_args, value) => {
2333
+ const v = value;
2334
+ const short = (id) => (id.length <= 18 ? id : `${id.slice(0, 16)}…`);
2335
+ if (v.releasedTabId !== undefined) {
2336
+ return [{ type: 'text', text: `Sessions: released your working tab ${v.releasedTabId} — later calls without tabId follow the browser's active tab again.` }];
2337
+ }
2338
+ if (v.sessions.length === 0) {
2339
+ return [{ type: 'text', text: 'Sessions: 0 — no agent-scoped tool calls yet (calls without an agent identity keep the old active-tab behaviour).' }];
2340
+ }
2341
+ const lines = v.sessions.map((s) => {
2342
+ const me = s.sessionId === v.current ? '* ' : ' ';
2343
+ const tab = s.workingTabId === undefined ? 'no working tab' : `tab ${s.workingTabId}`;
2344
+ return `${me}${short(s.sessionId)} — ${tab}, ${s.calls} calls, last ${formatClock(s.lastActiveAt)}`;
2345
+ });
2346
+ return [{ type: 'text', text: clampRendered(`Sessions: ${v.sessions.length}${v.current === undefined ? '' : ` (you: ${short(v.current)})`}\n${lines.join('\n')}`) }];
2347
+ },
2348
+ },
2349
+ presentCall: args => ({ card: 'generic', title: args.action === 'release' ? 'Release session tab' : 'Browser sessions', kind: 'other' }),
2350
+ async execute(args) {
2351
+ const current = TOOL_SESSION.getStore();
2352
+ if (args.action === 'release') {
2353
+ const sessions = controller.sessions.list();
2354
+ if (current === undefined)
2355
+ return { sessions };
2356
+ const released = controller.sessions.release(current);
2357
+ const payload = { current, sessions };
2358
+ if (released !== undefined)
2359
+ payload.releasedTabId = released;
2360
+ return payload;
2361
+ }
2362
+ if (args.action !== undefined && args.action !== 'list')
2363
+ throw new Error(`action must be "list" or "release" — got ${JSON.stringify(args.action)}`);
2364
+ const payload = { sessions: controller.sessions.list() };
2365
+ if (current !== undefined)
2366
+ payload.current = current;
2367
+ return payload;
2368
+ },
2369
+ }));
2370
+ }
2186
2371
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
2187
2372
  export function apply(ctx, config) {
2188
2373
  const resolved = config;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 多 session 隔离(v1.7.6):同一个插件实例、同一个浏览器,可能同时被好几个
3
+ * dsh 会话(agent)驱动。以前它们都默认落到「浏览器当前活动标签页」——谁刚切了
4
+ * 页,谁就把别人的上下文抢走。这一层给每个 agent 记住它自己的工作标签页:
5
+ *
6
+ * - 省略 tabId 的工具调用默认落到**本会话的工作标签页**(先向扩展核对它还开着);
7
+ * - 工作标签页被关掉 → 退回活动标签页,并改记新的;
8
+ * - 没带 agent 身份的调用(HTTP 面、测试、宿主老版本)完全维持旧行为。
9
+ *
10
+ * 这是**建议性隔离**,不是所有权锁:显式传 tabId 永远有效,跨会话交接不需要解锁。
11
+ * 硬性归属(borrow/return + 用户仲裁)留给扩展侧那一版。
12
+ */
13
+ export interface SessionInfo {
14
+ sessionId: string;
15
+ workingTabId?: number;
16
+ /** epoch 毫秒 */
17
+ lastActiveAt: number;
18
+ calls: number;
19
+ }
20
+ export declare class SessionRegistry {
21
+ private readonly sessions;
22
+ /** 记一次活动(无论成败)。 */
23
+ touch(sessionId: string): void;
24
+ /** 把某个标签页记为这个会话的工作标签页。 */
25
+ adopt(sessionId: string, tabId: number): void;
26
+ /** 当前记录的工作标签页(不核对是否还开着——核对是控制器的事)。 */
27
+ workingTab(sessionId: string): number | undefined;
28
+ /** 清掉工作标签页;返回被清掉的 id。 */
29
+ release(sessionId: string): number | undefined;
30
+ /** 全部会话,最近活跃的在前。 */
31
+ list(): SessionInfo[];
32
+ size(): number;
33
+ }
@@ -0,0 +1,51 @@
1
+ export class SessionRegistry {
2
+ sessions = new Map();
3
+ /** 记一次活动(无论成败)。 */
4
+ touch(sessionId) {
5
+ const now = Date.now();
6
+ const existing = this.sessions.get(sessionId);
7
+ if (existing === undefined) {
8
+ this.sessions.set(sessionId, { sessionId, lastActiveAt: now, calls: 1, createdAt: now });
9
+ return;
10
+ }
11
+ existing.lastActiveAt = now;
12
+ existing.calls += 1;
13
+ }
14
+ /** 把某个标签页记为这个会话的工作标签页。 */
15
+ adopt(sessionId, tabId) {
16
+ const state = this.sessions.get(sessionId);
17
+ if (state === undefined) {
18
+ this.sessions.set(sessionId, { sessionId, workingTabId: tabId, lastActiveAt: Date.now(), calls: 0, createdAt: Date.now() });
19
+ return;
20
+ }
21
+ state.workingTabId = tabId;
22
+ state.lastActiveAt = Date.now();
23
+ }
24
+ /** 当前记录的工作标签页(不核对是否还开着——核对是控制器的事)。 */
25
+ workingTab(sessionId) {
26
+ return this.sessions.get(sessionId)?.workingTabId;
27
+ }
28
+ /** 清掉工作标签页;返回被清掉的 id。 */
29
+ release(sessionId) {
30
+ const state = this.sessions.get(sessionId);
31
+ if (state === undefined)
32
+ return undefined;
33
+ const previous = state.workingTabId;
34
+ state.workingTabId = undefined;
35
+ return previous;
36
+ }
37
+ /** 全部会话,最近活跃的在前。 */
38
+ list() {
39
+ return [...this.sessions.values()]
40
+ .sort((a, b) => b.lastActiveAt - a.lastActiveAt)
41
+ .map((s) => {
42
+ const info = { sessionId: s.sessionId, lastActiveAt: s.lastActiveAt, calls: s.calls };
43
+ if (s.workingTabId !== undefined)
44
+ info.workingTabId = s.workingTabId;
45
+ return info;
46
+ });
47
+ }
48
+ size() {
49
+ return this.sessions.size;
50
+ }
51
+ }
package/llms.txt CHANGED
@@ -42,7 +42,7 @@ Chrome 浏览器扩展 + DeepSeek Harness 插件,让 AI Agent 通过 CDP 驱
42
42
 
43
43
  ## 关键文件
44
44
 
45
- - `src/index.ts` - 28 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
45
+ - `src/index.ts` - 29 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
46
46
  - `src/policy.ts` - 三张互不相同的表:只读白名单、脚本命令、插件本地破坏性命令(`browser_cleanup` 不经过 `execute()`,必须自己站闸门)
47
47
  - `src/server.ts` - WebSocket 服务 + HTTP 面(鉴权、来源校验、状态页)
48
48
  - `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.7.5",
4
+ "version": "1.7.7",
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 && node verify/verify_history_nav.mjs && node verify/verify_metrics_throttle.mjs && node verify/verify_drag.mjs && node verify/verify_audit.mjs && node verify/verify_journal.mjs && node verify/verify_compat_matrix.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 && node verify/verify_drag.mjs && node verify/verify_audit.mjs && node verify/verify_journal.mjs && node verify/verify_sessions.mjs && node verify/verify_snapshot_page.mjs && node verify/verify_compat_matrix.mjs"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public",