@free-lzj/dsh-browser-use 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,743 @@
1
+ import * as McpClient from "@deepseek-ai/dsh-mcp-client";
2
+ import { spawn } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ //#region lib/types/mcp-tabs.js
6
+ /**
7
+ * BrowserSkill's own MCP server, driven from the Host.
8
+ *
9
+ * Why not call the `bsk` CLI: a short-lived `bsk` cannot own a daemon inside a
10
+ * desktop application. It tries to start one, the process inherits Electron's
11
+ * outer Job Object, and `bsk` gives up ("still belongs to a Job Object after
12
+ * breakaway"). A long-lived `bsk mcp` does work — it keeps the daemon as its own
13
+ * child — which is also why the tools this plugin exposes to the model function
14
+ * while a one-shot `bsk tab list` does not.
15
+ *
16
+ * So the mention menu speaks the same protocol the tools do: one `bsk mcp`
17
+ * child, newline-delimited JSON-RPC, tools called by name.
18
+ *
19
+ * @module @free-lzj/dsh-browser-use/mcp-tabs
20
+ */
21
+ /** How long one JSON-RPC round trip may take. */
22
+ const CALL_TIMEOUT_MS = 3e4;
23
+ /** How long the server may take to answer `initialize`. */
24
+ const START_TIMEOUT_MS = 15e3;
25
+ /** The MCP revision this client speaks; the server answers with its own. */
26
+ const PROTOCOL_VERSION = "2024-11-05";
27
+ /** One candidate executable, tried in order. */
28
+ function candidates() {
29
+ const override = process.env["DSH_BSK_PATH"];
30
+ if (override !== void 0 && override !== "") return [override];
31
+ return [
32
+ "bsk",
33
+ join(homedir(), ".local", "bin", "bsk.exe"),
34
+ join(homedir(), ".local", "bin", "bsk")
35
+ ];
36
+ }
37
+ /**
38
+ * Read one raw tab row into the shape the menu uses, or nothing.
39
+ * @param value - one element of the server's `tabs` array.
40
+ * @returns the tab, or undefined when the row cannot be described.
41
+ */
42
+ function readTab(value) {
43
+ if (typeof value !== "object" || value === null) return void 0;
44
+ const row = value;
45
+ const tabId = row.tab_id;
46
+ if (typeof tabId !== "number" || !Number.isFinite(tabId)) return void 0;
47
+ const title = typeof row.title === "string" ? row.title.trim() : "";
48
+ const url = typeof row.url === "string" ? row.url.trim() : "";
49
+ if (title === "" && url === "") return void 0;
50
+ return {
51
+ tabId,
52
+ title: title === "" ? url : title,
53
+ url,
54
+ active: row.active === true
55
+ };
56
+ }
57
+ /**
58
+ * A minimal MCP stdio client, scoped to the two calls the menu needs.
59
+ *
60
+ * Deliberately not `@deepseek-ai/dsh-mcp-client`: that one exists to publish a
61
+ * server's tools to an Agent, and the menu needs a private request/response
62
+ * channel instead. Two children is the price of the two jobs being independent.
63
+ */
64
+ var BskMcpChannel = class {
65
+ #child;
66
+ #buffer = "";
67
+ #seq = 1;
68
+ #pending = /* @__PURE__ */ new Map();
69
+ #session;
70
+ #starting;
71
+ #disposed = false;
72
+ /**
73
+ * Tabs in the user's own windows, most useful first.
74
+ * @param signal - caller cancellation; a cancelled call stops waiting, not the server.
75
+ * @returns the tabs, or none when the browser is unreachable.
76
+ */
77
+ async listUserTabs(signal) {
78
+ const session = await this.#ensureSession(signal);
79
+ const answer = await this.#tool("bsk_tabs", {
80
+ action: "list",
81
+ scope: "user",
82
+ session
83
+ }, signal);
84
+ const tabs = Array.isArray(answer.tabs) ? answer.tabs : [];
85
+ const parsed = [];
86
+ for (const row of tabs) {
87
+ const tab = readTab(row);
88
+ if (tab !== void 0) parsed.push(tab);
89
+ }
90
+ return [...parsed.filter((tab) => tab.active), ...parsed.filter((tab) => !tab.active)];
91
+ }
92
+ /** Stop the child this channel owns. */
93
+ dispose() {
94
+ this.#disposed = true;
95
+ for (const [id, pending] of this.#pending) {
96
+ clearTimeout(pending.timer);
97
+ pending.reject(/* @__PURE__ */ new Error("browser-use: channel disposed"));
98
+ this.#pending.delete(id);
99
+ }
100
+ this.#child?.kill();
101
+ this.#child = void 0;
102
+ }
103
+ /**
104
+ * The session this channel drives, started on first use.
105
+ * @param signal - caller cancellation while waiting for the start.
106
+ * @returns the session id.
107
+ */
108
+ async #ensureSession(signal) {
109
+ if (this.#session !== void 0) return this.#session;
110
+ if (this.#starting === void 0) {
111
+ const starting = this.#tool("bsk_session", {
112
+ action: "start",
113
+ name: "dsh-mention"
114
+ }, signal).then((answer) => {
115
+ if (typeof answer.session_id !== "string" || answer.session_id === "") throw new Error("browser-use: bsk_session start answered without a session_id");
116
+ this.#session = answer.session_id;
117
+ return answer.session_id;
118
+ });
119
+ this.#starting = starting;
120
+ starting.catch(() => {
121
+ if (this.#starting === starting) this.#starting = void 0;
122
+ });
123
+ }
124
+ return this.#starting;
125
+ }
126
+ /**
127
+ * Call one MCP tool and parse its JSON answer.
128
+ * @param name - tool name on the server.
129
+ * @param args - tool arguments.
130
+ * @param signal - caller cancellation.
131
+ * @returns the parsed answer body.
132
+ */
133
+ async #tool(name, args, signal) {
134
+ const message = await this.#request("tools/call", {
135
+ name,
136
+ arguments: args
137
+ }, signal);
138
+ if (message.error !== void 0) throw new Error(`browser-use: ${name} failed: ${JSON.stringify(message.error)}`);
139
+ const text = message.result?.content?.map((part) => typeof part.text === "string" ? part.text : "").join("") ?? "";
140
+ if (text === "") throw new Error(`browser-use: ${name} answered nothing`);
141
+ let body;
142
+ try {
143
+ body = JSON.parse(text);
144
+ } catch {
145
+ throw new Error(`browser-use: ${name} answered non-JSON: ${text.slice(0, 200)}`);
146
+ }
147
+ if (typeof body !== "object" || body === null) throw new Error(`browser-use: ${name} answered ${typeof body}`);
148
+ const record = body;
149
+ if (record["code"] !== null && record["code"] !== void 0) throw new Error(`browser-use: ${name}: ${String(record["message"] ?? record["hint"] ?? record["code"])}`);
150
+ return record;
151
+ }
152
+ /**
153
+ * One JSON-RPC round trip, starting the child when needed.
154
+ * @param method - JSON-RPC method.
155
+ * @param params - method parameters.
156
+ * @param signal - caller cancellation.
157
+ * @returns the whole response message.
158
+ */
159
+ async #request(method, params, signal) {
160
+ if (this.#disposed) throw new Error("browser-use: channel disposed");
161
+ await this.#ensureChild(signal);
162
+ const child = this.#child;
163
+ if (child === void 0 || child.stdin === null) throw new Error("browser-use: bsk mcp is not running");
164
+ const id = this.#seq;
165
+ this.#seq += 1;
166
+ return new Promise((resolve, reject) => {
167
+ const timer = setTimeout(() => {
168
+ this.#pending.delete(id);
169
+ reject(/* @__PURE__ */ new Error(`browser-use: ${method} timed out after ${CALL_TIMEOUT_MS}ms`));
170
+ }, CALL_TIMEOUT_MS);
171
+ const onAbort = () => {
172
+ clearTimeout(timer);
173
+ this.#pending.delete(id);
174
+ reject(/* @__PURE__ */ new Error("browser-use: request cancelled"));
175
+ };
176
+ signal.addEventListener("abort", onAbort, { once: true });
177
+ this.#pending.set(id, {
178
+ resolve: (message) => {
179
+ signal.removeEventListener("abort", onAbort);
180
+ resolve(message);
181
+ },
182
+ reject: (error) => {
183
+ signal.removeEventListener("abort", onAbort);
184
+ reject(error);
185
+ },
186
+ timer
187
+ });
188
+ child.stdin?.write(`${JSON.stringify({
189
+ jsonrpc: "2.0",
190
+ id,
191
+ method,
192
+ params
193
+ })}\n`);
194
+ });
195
+ }
196
+ /**
197
+ * Start the server and complete the handshake, once.
198
+ * @param signal - caller cancellation while starting.
199
+ */
200
+ async #ensureChild(signal) {
201
+ if (this.#child !== void 0) return;
202
+ const child = await this.#spawn(signal);
203
+ this.#child = child;
204
+ child.stdout?.on("data", (chunk) => {
205
+ this.#feed(chunk.toString("utf8"));
206
+ });
207
+ child.on("exit", () => {
208
+ if (this.#child === child) this.#child = void 0;
209
+ this.#rejectAll("browser-use: bsk mcp exited");
210
+ });
211
+ await this.#request("initialize", {
212
+ protocolVersion: PROTOCOL_VERSION,
213
+ capabilities: {},
214
+ clientInfo: {
215
+ name: "dsh-browser-use",
216
+ version: "0"
217
+ }
218
+ }, signal);
219
+ }
220
+ /**
221
+ * Spawn the first candidate executable that exists.
222
+ * @param signal - caller cancellation while spawning.
223
+ * @returns the live child.
224
+ */
225
+ async #spawn(signal) {
226
+ let missing;
227
+ for (const command of candidates()) {
228
+ const child = spawn(command, ["mcp"], {
229
+ stdio: [
230
+ "pipe",
231
+ "pipe",
232
+ "pipe"
233
+ ],
234
+ windowsHide: true
235
+ });
236
+ if (await new Promise((resolve) => {
237
+ const onError = (error) => {
238
+ child.removeListener("spawn", onSpawn);
239
+ missing = error;
240
+ resolve(false);
241
+ };
242
+ const onSpawn = () => {
243
+ child.removeListener("error", onError);
244
+ resolve(true);
245
+ };
246
+ child.once("error", onError);
247
+ child.once("spawn", onSpawn);
248
+ signal.addEventListener("abort", () => resolve(false), { once: true });
249
+ setTimeout(() => resolve(child.pid !== void 0), START_TIMEOUT_MS);
250
+ }) && child.pid !== void 0) return child;
251
+ child.kill();
252
+ }
253
+ throw missing ?? /* @__PURE__ */ new Error("browser-use: could not start `bsk mcp`; is BrowserSkill installed?");
254
+ }
255
+ /**
256
+ * Parse one stdout chunk into responses.
257
+ * @param chunk - the text that arrived.
258
+ */
259
+ #feed(chunk) {
260
+ this.#buffer += chunk;
261
+ for (;;) {
262
+ const at = this.#buffer.indexOf("\n");
263
+ if (at < 0) return;
264
+ const line = this.#buffer.slice(0, at).trim();
265
+ this.#buffer = this.#buffer.slice(at + 1);
266
+ if (line === "") continue;
267
+ let message;
268
+ try {
269
+ message = JSON.parse(line);
270
+ } catch {
271
+ continue;
272
+ }
273
+ const id = message.id;
274
+ if (typeof id !== "number") continue;
275
+ const pending = this.#pending.get(id);
276
+ if (pending === void 0) continue;
277
+ clearTimeout(pending.timer);
278
+ this.#pending.delete(id);
279
+ pending.resolve(message);
280
+ }
281
+ }
282
+ /**
283
+ * Fail every in-flight request.
284
+ * @param message - the failure sentence.
285
+ */
286
+ #rejectAll(message) {
287
+ for (const [id, pending] of this.#pending) {
288
+ clearTimeout(pending.timer);
289
+ pending.reject(new Error(message));
290
+ this.#pending.delete(id);
291
+ }
292
+ }
293
+ };
294
+ //#endregion
295
+ //#region lib/types/browser-mention.js
296
+ /**
297
+ * The browser tab provider: the first, and for now the only, mentionable thing.
298
+ *
299
+ * Everything specific to browsers lives here — the query match, the row text,
300
+ * the ref shape. The registry above and the routes beside it stay ignorant of
301
+ * what a tab is, which is what lets the next provider be a new file alone.
302
+ *
303
+ * @module @free-lzj/dsh-browser-use/browser-mention
304
+ */
305
+ /** The provider id; also the `<provider>:` prefix of every ref it returns. */
306
+ const PROVIDER_ID = "browser";
307
+ /** Section heading the client renders when it has no translation of its own. */
308
+ const PROVIDER_LABEL = "浏览器";
309
+ /**
310
+ * Describe one tab in a menu row.
311
+ *
312
+ * The host alone would make several rows look identical (three `页面设计器`
313
+ * tabs differ only by the page they show), so the path is part of the row when
314
+ * the URL is not merely a site root.
315
+ * @param tab - the tab.
316
+ * @returns the row's second line.
317
+ */
318
+ function describe(tab) {
319
+ let site = tab.url;
320
+ let path = "";
321
+ try {
322
+ const parsed = new URL(tab.url);
323
+ site = parsed.host;
324
+ path = parsed.pathname === "/" ? "" : parsed.pathname;
325
+ } catch {}
326
+ const where = path === "" ? site : `${site}${path}`;
327
+ return tab.active ? `${where} · 当前标签页` : where;
328
+ }
329
+ /**
330
+ * Match one tab against the typed query.
331
+ *
332
+ * Title and URL both match: people search for `jira` as often as for the ticket
333
+ * number in the title.
334
+ * @param tab - the tab.
335
+ * @param needle - lowercase query; empty matches everything.
336
+ * @returns whether the row belongs in the answer.
337
+ */
338
+ function matches(tab, needle) {
339
+ if (needle === "") return true;
340
+ return tab.title.toLowerCase().includes(needle) || tab.url.toLowerCase().includes(needle);
341
+ }
342
+ /**
343
+ * Build the browser provider over one CLI handle.
344
+ * @param cli - the shared `bsk` session.
345
+ * @returns the provider the registry holds.
346
+ */
347
+ function browserMentionProvider(cli) {
348
+ return {
349
+ id: PROVIDER_ID,
350
+ label: PROVIDER_LABEL,
351
+ async candidates(query, signal) {
352
+ const needle = query.trim().toLowerCase();
353
+ const tabs = await cli.listUserTabs(signal);
354
+ if (signal.aborted) return [];
355
+ return tabs.filter((tab) => matches(tab, needle)).map((tab) => ({
356
+ ref: `${PROVIDER_ID}:${tab.tabId}`,
357
+ label: tab.title,
358
+ description: describe(tab)
359
+ }));
360
+ }
361
+ };
362
+ }
363
+ //#endregion
364
+ //#region lib/types/mention.js
365
+ /**
366
+ * The mention-provider seam.
367
+ *
368
+ * `@` itself belongs to the harness: `dsh-client-ui-input-trigger` owns the menu
369
+ * and `dsh-client-ui-reference` registers one source into it for files and
370
+ * sessions. This package registers a second source for things that are neither,
371
+ * and the provider registry below is where "things" are listed. Adding a kind of
372
+ * thing later (an application, a plugin) means writing one provider — nothing in
373
+ * this file or in the routes learns its name.
374
+ *
375
+ * @module @free-lzj/dsh-browser-use/mention
376
+ */
377
+ /**
378
+ * Holds every registered provider, in registration order.
379
+ *
380
+ * A provider that fails is dropped from the answer, the way the harness's own
381
+ * reference source drops a domain that cannot load: the rest of the menu stays
382
+ * usable, and one dead backend never costs the user their `@`.
383
+ */
384
+ var MentionRegistry = class {
385
+ #providers = [];
386
+ /**
387
+ * Add one provider.
388
+ * @param provider - the source of one group of candidates.
389
+ * @returns the disposer removing it; duplicate ids are refused.
390
+ */
391
+ register(provider) {
392
+ if (this.#providers.some((existing) => existing.id === provider.id)) throw new Error(`mention provider "${provider.id}" is already registered`);
393
+ this.#providers.push(provider);
394
+ return () => {
395
+ const at = this.#providers.indexOf(provider);
396
+ if (at >= 0) this.#providers.splice(at, 1);
397
+ };
398
+ }
399
+ /** @returns the registered ids, for diagnostics. */
400
+ ids() {
401
+ return this.#providers.map((provider) => provider.id);
402
+ }
403
+ /**
404
+ * Collect every provider's answer for one query.
405
+ * @param query - text after `@`.
406
+ * @param signal - caller cancellation.
407
+ * @returns the non-empty groups, plus one line per provider that failed.
408
+ */
409
+ async collect(query, signal) {
410
+ const settled = await Promise.all(this.#providers.map(async (provider) => {
411
+ try {
412
+ return {
413
+ provider,
414
+ items: await provider.candidates(query, signal),
415
+ failure: void 0
416
+ };
417
+ } catch (error) {
418
+ return {
419
+ provider,
420
+ items: [],
421
+ failure: `${provider.id}: ${messageOf(error)}`
422
+ };
423
+ }
424
+ }));
425
+ if (signal.aborted) return {
426
+ groups: [],
427
+ failures: []
428
+ };
429
+ const groups = [];
430
+ const failures = [];
431
+ for (const entry of settled) {
432
+ if (entry.failure !== void 0) failures.push(entry.failure);
433
+ if (entry.items.length === 0) continue;
434
+ groups.push({
435
+ id: entry.provider.id,
436
+ label: entry.provider.label,
437
+ items: entry.items
438
+ });
439
+ }
440
+ return {
441
+ groups,
442
+ failures
443
+ };
444
+ }
445
+ };
446
+ /**
447
+ * The sentence an `unknown` failure carries.
448
+ * @param error - whatever was thrown.
449
+ * @returns its message, or the value's own text.
450
+ */
451
+ function messageOf(error) {
452
+ return error instanceof Error ? error.message : String(error);
453
+ }
454
+ //#endregion
455
+ //#region lib/types/protocol.js
456
+ /**
457
+ * Shapes and routes both halves agree on.
458
+ *
459
+ * The browser half never imports the Host half's runtime values — only these
460
+ * types and the small amount of shared protocol they carry — so this module must
461
+ * stay free of `node:*` imports and of anything that only exists on one side.
462
+ *
463
+ * @module @free-lzj/dsh-browser-use/protocol
464
+ */
465
+ /** Where the browser half asks for mention candidates. */
466
+ const MENTIONS_PATH = "/dsh-browser-use/mentions";
467
+ //#endregion
468
+ //#region lib/types/routes.js
469
+ /**
470
+ * The HTTP surface the browser's `@` menu reads.
471
+ *
472
+ * One read-only route. `dsh-host-webserver` adds no middleware, body parsing or
473
+ * origin policy of its own, so the same-origin guard is stated here by hand, and
474
+ * the method is pinned: a menu lookup has no business writing anything.
475
+ *
476
+ * @module @free-lzj/dsh-browser-use/routes
477
+ */
478
+ /**
479
+ * Whether a request came from the page this server sent, so a document on
480
+ * another origin cannot enumerate the user's tabs through their browser.
481
+ * @param req - the incoming request.
482
+ * @returns true when the request comes from this server's own page.
483
+ */
484
+ function isSameOrigin(req) {
485
+ const site = req.headers["sec-fetch-site"];
486
+ if (typeof site === "string" && site !== "same-origin" && site !== "none") return false;
487
+ const origin = req.headers.origin;
488
+ if (typeof origin !== "string") return true;
489
+ try {
490
+ return new URL(origin).host === req.headers.host;
491
+ } catch {
492
+ return false;
493
+ }
494
+ }
495
+ /**
496
+ * Answer one request with a JSON document.
497
+ * @param res - the response to write.
498
+ * @param status - HTTP status.
499
+ * @param body - the document.
500
+ */
501
+ function respond(res, status, body) {
502
+ const text = JSON.stringify(body);
503
+ res.writeHead(status, {
504
+ "content-type": "application/json; charset=utf-8",
505
+ "content-length": Buffer.byteLength(text),
506
+ "cache-control": "no-store"
507
+ });
508
+ res.end(text);
509
+ }
510
+ /**
511
+ * Mount the mention route.
512
+ * @param ctx - the Host plugin context.
513
+ * @param registry - providers to collect from.
514
+ */
515
+ function registerRoutes(ctx, registry) {
516
+ ctx.inject(["webServer"], (inner) => {
517
+ inner.effect(() => inner.webServer.register({
518
+ kind: "exact",
519
+ path: MENTIONS_PATH,
520
+ handler: (req, res) => handleMentions(ctx, registry, req, res)
521
+ }), "browser-use.mentions-route");
522
+ });
523
+ }
524
+ /**
525
+ * Answer one candidate request.
526
+ * @param ctx - the Host plugin context, for logging.
527
+ * @param registry - providers to collect from.
528
+ * @param req - the incoming request.
529
+ * @param res - the response to write.
530
+ */
531
+ async function handleMentions(ctx, registry, req, res) {
532
+ if (!isSameOrigin(req)) {
533
+ respond(res, 403, { error: "cross-origin request refused" });
534
+ return;
535
+ }
536
+ if (req.method !== "GET") {
537
+ res.setHeader("allow", "GET");
538
+ respond(res, 405, { error: "method not allowed" });
539
+ return;
540
+ }
541
+ const query = new URL(req.url ?? "/", "http://localhost").searchParams.get("q") ?? "";
542
+ const abort = new AbortController();
543
+ res.on("close", () => {
544
+ abort.abort();
545
+ });
546
+ try {
547
+ const collected = await registry.collect(query, abort.signal);
548
+ for (const failure of collected.failures) ctx.logger.warn(`browser-use: provider failed: ${failure}`);
549
+ if (abort.signal.aborted) return;
550
+ respond(res, 200, {
551
+ groups: collected.groups,
552
+ ...collected.failures.length === 0 ? {} : { failures: collected.failures }
553
+ });
554
+ } catch (error) {
555
+ const message = error instanceof Error ? error.message : String(error);
556
+ ctx.logger.warn(`browser-use: mention lookup failed: ${message}`);
557
+ respond(res, 400, { error: message });
558
+ }
559
+ }
560
+ //#endregion
561
+ //#region lib/types/skill.js
562
+ /**
563
+ * The browser skill, shipped inside the plugin.
564
+ *
565
+ * BrowserSkill's own MCP server already delivers a one-paragraph workflow in its
566
+ * `initialize` instructions, but that text is a reminder, not a procedure: it
567
+ * cannot say when to stop, what to do when refs go stale, or where the boundary
568
+ * between the user's tabs and ours sits. Those rules live here so installing
569
+ * this plugin is enough — the separate `browser-skill` agent skill is not needed.
570
+ *
571
+ * The body is written for this plugin against BrowserSkill's public CLI
572
+ * contract; it is not a copy of upstream skill text.
573
+ *
574
+ * @module @free-lzj/dsh-browser-use/skill
575
+ */
576
+ /** Kebab-case skill id; also the name the model calls. */
577
+ const SKILL_NAME = "browser-use";
578
+ /** The skill this plugin registers, so a separate skill install is unnecessary. */
579
+ const BROWSER_SKILL = {
580
+ name: SKILL_NAME,
581
+ description: "操作用户已登录的真实浏览器(Chrome / Edge):打开页面、读页面、点填、多标签、上传下载、控制台与网络诊断。用 @ 提及用户开着的标签页,或用 bsk_tabs 浏览。",
582
+ whenToUse: "用户要你去看一个网页、在他开着的浏览器里操作(登录态已就绪)、复现页面问题、抓取页面数据、验证部署页面,或 @ 了一个标签页时。需要新建独立浏览器、无登录态或纯本地 HTML 渲染时不要用。",
583
+ content: `# 操作用户自己的浏览器
584
+
585
+ 你驱动的是**用户那个已经登录的浏览器**(Chrome 或 Edge),不是无痕窗口。它带着用户真实的 cookie 和登录态——所以你不必、也不应该去登录任何东西。
586
+
587
+ ## 先看有哪些工具
588
+
589
+ 工具前缀 \`mcp__browser-skill__\`,按域分成六个:
590
+
591
+ - \`bsk_session\` — \`start\` / \`stop\` / \`sync\`
592
+ - \`bsk_page\` — \`navigate\` / \`reload\` / 前进后退 / 等待导航或元素
593
+ - \`bsk_inspect\` — \`observe\` / \`snapshot\` / \`get-html\` / \`console\` / \`network\` / \`extract\`
594
+ - \`bsk_interact\` — 点击、输入、按键、下拉、悬停、拖拽、滚轮、焦点、上传
595
+ - \`bsk_tabs\` — 列出 / 新建 / 选中 / 关闭 / 借用 / 归还
596
+ - \`bsk_assist\` — 请求用户帮忙、处理原生对话框
597
+
598
+ 每个工具的 \`action\` 参数决定具体动作,其余参数在工具 schema 里。
599
+
600
+ ## 一个任务的样子
601
+
602
+ 1. \`bsk_session\` 用 \`start\` 起一个会话(拿到 \`session_id\`,后续每次调用都带上它)。用户用 \`@\` 提过的标签页,直接用 \`bsk_tabs\` 的 \`borrow\` 借过来——\`@[标题](dsh-browser:数字)\` 里那串数字就是 tab id。
603
+ 2. \`bsk_session\` 的 \`sync\` 拿当前页面的语义结构(VOM 文本,控件带 \`@eN\` 引用)和 \`context_id\`。
604
+ 3. 要操作控件时,\`bsk_interact\` 用最新一次观察里的 \`@eN\`,并把 \`context_id\` 一起带上——协作保护要求"这次动作基于哪次观察"。
605
+ 4. 每次有意义的页面变化后重新 \`sync\`,再继续。
606
+ 5. **目标达成即 \`bsk_session\` \`stop\`**。别指望空闲超时替你收尾;停掉会话也会把借来的标签页还回去。
607
+
608
+ ## 观察的层次
609
+
610
+ 默认用 \`observe\`(语义结构,够日常交互)。不够时按这个顺序升级,不要一上来就抓 HTML:
611
+
612
+ 1. \`observe\` — 常规理解页面
613
+ 2. \`observe\` 带 \`probe-hover\` — 该出现却没出现的控件,通常是 hover 才显形的菜单
614
+ 3. \`snapshot\` — 需要更严格的无障碍树时
615
+ 4. \`get-html\` — 精确标记或隐藏元数据
616
+ 5. \`console\` / \`network\` — 调试证据,只读
617
+ 6. \`extract\` — 已加载的表格/列表,带列定义
618
+ 7. \`screenshot\` — 只有布局、样式、canvas、图片这类观察文本表达不了的东西才用它
619
+
620
+ ## 两个会咬人的边界
621
+
622
+ **用户标签页**:\`bsk_tabs\` 列出的 \`scope: user\` 是用户自己的标签页,\`scope: agent\` 是我们在 Agent Window 里开的。默认只动 agent 的那几个;要动用户的,先 \`borrow\`,用完立刻 \`return\`。别凭空编 tab id,也别在一次不相关的任务里一直占着它。
623
+
624
+ **引用会失效**:导航、重新渲染、大面积 DOM 变化都会让 \`@eN\` 作废。拿不准就重新 \`sync\` 一次——这比猜一个旧引用去点错地方便宜得多。
625
+
626
+ ## 需要人来做的事
627
+
628
+ 登录、验证码、短信码、支付确认、第三方授权——这些不该由你硬试。用 \`bsk_assist\` 的 \`request_help\` 把请求和页面位置说清楚,等用户做完再继续。连试两次没有进展就转人工,不要反复重放同一个失败动作。
629
+
630
+ ## 失败时怎么走
631
+
632
+ - 引用失效:重新 \`sync\`,重试一次原动作。
633
+ - 找不到控件:先确认是不是 hover 才显形,再降级到 \`snapshot\` 或 \`get-html\`。
634
+ - 超时:先看当前页面状态,再决定要不要等更久;不要连续加长超时。
635
+ - 输入框的值和你要的不一样(页面做了格式化):看实际值是否可接受,别盲目重填。
636
+ - 无法恢复:说清卡在哪,然后 \`stop\` 会话。
637
+
638
+ ## 不要做的事
639
+
640
+ - 不要把 cookie、token、密码从页面里读出来。
641
+ - 不要在用户没要求时操作他的标签页。
642
+ - 不要在任务结束后留着会话和借用的标签页。`,
643
+ source: "bundled"
644
+ };
645
+ //#endregion
646
+ //#region lib/types/index.js
647
+ /**
648
+ * Browser use for DSH — Host half.
649
+ *
650
+ * Two halves of one idea, and they are deliberately different in kind:
651
+ *
652
+ * - **Mentioning** is ours. Nothing in the harness lists what a person has open,
653
+ * so this plugin contributes that: a provider registry, one read-only route,
654
+ * and a browser provider on top of `bsk tab list`.
655
+ * - **Operating** is BrowserSkill's. Its CLI already ships an MCP server with the
656
+ * full action set, and the harness already knows how to give one MCP server to
657
+ * one Session — `mountSessionMcp` is what the official Chrome DevTools and
658
+ * Playwright providers use. Rebuilding those tools here would mean shipping a
659
+ * copy that drifts from upstream on every release.
660
+ *
661
+ * The skill body travels with the plugin for the same reason: installing this is
662
+ * meant to be enough, so the separate `browser-skill` agent skill is not needed.
663
+ *
664
+ * @module @free-lzj/dsh-browser-use
665
+ */
666
+ /**
667
+ * MCP server name; every tool this plugin contributes is `mcp__<name>__*`.
668
+ *
669
+ * Not `bsk` and not `browser`: the name is a namespace the model reads on every
670
+ * call, so it should say whose tools these are and never collide with a server
671
+ * the person wired up themselves.
672
+ */
673
+ const MCP_SERVER_NAME = "browser-skill";
674
+ /**
675
+ * The `bsk` executable.
676
+ *
677
+ * An override exists because a machine can have a stale global install next to
678
+ * the one the extension pairs with, and the wrong one fails as an opaque
679
+ * transport error rather than as "wrong binary".
680
+ */
681
+ function bskCommand() {
682
+ return process.env["DSH_BSK_PATH"] ?? "bsk";
683
+ }
684
+ /**
685
+ * Mount BrowserSkill's MCP server once, for the whole Host.
686
+ *
687
+ * The harness's experimental browser-use runtime would mount one server per
688
+ * Session, but it ships outside the dsh installation and needs a provider
689
+ * registry (`ctx.browserUse`) that not every profile carries. This plugin mounts
690
+ * one server instead: every Agent sees `mcp__browser-skill__*`, and the cost is
691
+ * that two Sessions driving the browser at once share a single BrowserSkill
692
+ * session — and therefore one Agent Window. For one person in one conversation
693
+ * that is the honest trade; a per-Session server would need the runtime package.
694
+ * @param ctx - the Host plugin context.
695
+ */
696
+ function mountBrowserTools(ctx) {
697
+ ctx.inject(["tools", "systemPrompt"], (inner) => {
698
+ try {
699
+ inner.plugin(McpClient, McpClient.Config({
700
+ transport: "stdio",
701
+ serverName: MCP_SERVER_NAME,
702
+ command: bskCommand(),
703
+ args: ["mcp"],
704
+ failOnStartupError: false,
705
+ reconnect: { enabled: false }
706
+ }));
707
+ inner.logger.info(`browser-use: browser tools mounted as mcp__${MCP_SERVER_NAME}__*`);
708
+ } catch (error) {
709
+ const message = error instanceof Error ? error.message : String(error);
710
+ inner.logger.warn(`browser-use: could not mount the browser MCP server: ${message}`);
711
+ }
712
+ });
713
+ }
714
+ /**
715
+ * Register the skill body that makes a separate `browser-skill` install unnecessary.
716
+ * @param ctx - the Host plugin context.
717
+ */
718
+ function registerSkill(ctx) {
719
+ ctx.inject(["skills"], (inner) => {
720
+ inner.effect(() => inner.skills.register(BROWSER_SKILL), "browser-use: bundled skill");
721
+ inner.logger.info(`browser-use: skill "${BROWSER_SKILL.name}" registered`);
722
+ });
723
+ }
724
+ /**
725
+ * Mount the mention route, the provider, the bundled skill, and the browser tools.
726
+ * @param ctx - the Host plugin context.
727
+ */
728
+ function apply(ctx) {
729
+ const tabs = new BskMcpChannel();
730
+ const registry = new MentionRegistry();
731
+ ctx.effect(() => registry.register(browserMentionProvider(tabs)), "browser-use: browser provider");
732
+ registerRoutes(ctx, registry);
733
+ ctx.effect(() => () => {
734
+ tabs.dispose();
735
+ }, "browser-use: bsk mcp channel");
736
+ mountBrowserTools(ctx);
737
+ registerSkill(ctx);
738
+ ctx.logger.info("browser-use: mounted");
739
+ }
740
+ //#endregion
741
+ export { BROWSER_SKILL, BskMcpChannel, MCP_SERVER_NAME, MENTIONS_PATH, MentionRegistry, SKILL_NAME, apply, browserMentionProvider, isSameOrigin, mountBrowserTools, registerRoutes, registerSkill, respond };
742
+
743
+ //# sourceMappingURL=index.js.map