@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 +2 -1
- package/README.md +3 -2
- package/lib/index.js +301 -36
- package/lib/types/index.js +196 -11
- package/lib/types/sessions.d.ts +33 -0
- package/lib/types/sessions.js +51 -0
- package/llms.txt +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
共
|
|
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
|
|
1409
|
-
const
|
|
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,
|
|
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,
|
|
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: "
|
|
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.
|
|
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
|
|
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
|
-
|
|
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;
|
package/lib/types/index.js
CHANGED
|
@@ -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
|
|
270
|
-
const
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
773
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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` -
|
|
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.
|
|
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",
|