@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/README.md +113 -2
- package/cordis.patch.yml +11 -0
- package/lib/client.js +193 -0
- package/lib/index.js +743 -0
- package/lib/types/browser-mention.d.ts +18 -0
- package/lib/types/bsk.d.ts +80 -0
- package/lib/types/client/index.d.ts +41 -0
- package/lib/types/client/locales.d.ts +19 -0
- package/lib/types/index.d.ts +58 -0
- package/lib/types/mcp-tabs.d.ts +35 -0
- package/lib/types/mention.d.ts +65 -0
- package/lib/types/protocol.d.ts +53 -0
- package/lib/types/routes.d.ts +33 -0
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/tools.d.ts +35 -0
- package/package.json +90 -4
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
|