dsh-sidebar-browser-control 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.
@@ -0,0 +1,12 @@
1
+ # dsh-sidebar-browser-control —— DSH bundle patch
2
+ #
3
+ # Host half only: the browser control surface (routes + tools + slash command).
4
+ # The Client half is declared through `dsh.client` in package.json and drives
5
+ # the right-hand Sidebar Browser through ctx.sidebarRight.
6
+ #
7
+ # Install (from any workspace):
8
+ # plugin_manager { action: install_bundle, target: "<abs path to this directory>" }
9
+
10
+ - insert:
11
+ - id: sidebar-browser-control
12
+ name: 'dsh-sidebar-browser-control'
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2
+ <rect x="2.5" y="3.5" width="14" height="11" rx="2" />
3
+ <path d="M2.5 7h14" />
4
+ <circle cx="5" cy="5.25" r="0.6" fill="currentColor" stroke="none" />
5
+ <circle cx="7" cy="5.25" r="0.6" fill="currentColor" stroke="none" />
6
+ <path d="M18.5 10.5v3.2l1.6 1.1a.7.7 0 0 1 .2 1l-2.6 3.8a.7.7 0 0 1-1 .15l-2-1.6a.7.7 0 0 1-.12-.95l1.5-2.2" />
7
+ </svg>
package/index.js ADDED
@@ -0,0 +1,561 @@
1
+ /**
2
+ * dsh-sidebar-browser-control — Host half.
3
+ *
4
+ * Gives the agent (and a `/browser` slash command) control over DSH's own
5
+ * built-in browser: the right-hand Sidebar's Browser tab
6
+ * (`@deepseek-ai/dsh-client-ui-sidebar-browser`).
7
+ *
8
+ * Architecture: the browser lives in the Web renderer, so the Host half owns
9
+ * the *operations* and the Client half (client.js) owns the *presentation*.
10
+ * They meet on four loopback routes served by `ctx.webServer`:
11
+ *
12
+ * POST /sidebar-browser-control/hello client announces itself
13
+ * GET /sidebar-browser-control/poll long-poll: Host -> Client commands
14
+ * POST /sidebar-browser-control/result client settles one command
15
+ * GET /sidebar-browser-control/status diagnostics
16
+ *
17
+ * A tool call enqueues a command for the most recently active client, the held
18
+ * long-poll delivers it immediately, and the call resolves when the client
19
+ * settles it — so one operation has exactly one implementation and two callers
20
+ * (tools and the slash command), while neither direction polls on a timer.
21
+ *
22
+ * Only the standard library is imported: a profile-installed bundle resolves
23
+ * outside the DSH installation, so shipped packages are not on its import path.
24
+ */
25
+
26
+ /** Route prefix owned by this plugin. */
27
+ const BASE = '/sidebar-browser-control';
28
+ /** How long one long-poll is held open before answering with no commands. */
29
+ const POLL_HOLD_MS = 10_000;
30
+ /** How long a command waits for the client to settle it. */
31
+ const CALL_TIMEOUT_MS = 30_000;
32
+ /** Largest request body accepted on the control routes. */
33
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
34
+ /** A client that has not spoken for this long stops receiving commands. */
35
+ const CLIENT_IDLE_MS = 120_000;
36
+ /** Requests may only come from the DSH application origin. */
37
+ const APPLICATION_ORIGIN = /^(dsh-app:\/\/app|https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?)$/iu;
38
+
39
+ /** Loader entry name for this plugin. */
40
+ export const name = 'sidebar-browser-control';
41
+ /** Hard dependencies: the tool registry and the HTTP carrier. */
42
+ export const inject = ['tools', 'webServer'];
43
+
44
+ /**
45
+ * Answer one request with JSON.
46
+ * @param res - node response owning the socket.
47
+ * @param status - HTTP status code.
48
+ * @param value - JSON-serializable body.
49
+ */
50
+ function sendJson(res, status, value) {
51
+ const body = JSON.stringify(value ?? null);
52
+ res.writeHead(status, {
53
+ 'content-type': 'application/json; charset=utf-8',
54
+ 'cache-control': 'no-store',
55
+ 'content-length': Buffer.byteLength(body)
56
+ });
57
+ res.end(body);
58
+ }
59
+
60
+ /**
61
+ * Read and parse a JSON request body.
62
+ * @param req - node request.
63
+ * @returns the parsed body, or an empty object for an empty body.
64
+ */
65
+ function readJson(req) {
66
+ return new Promise((resolve, reject) => {
67
+ const chunks = [];
68
+ let size = 0;
69
+ req.on('data', (chunk) => {
70
+ size += chunk.length;
71
+ if (size > MAX_BODY_BYTES) {
72
+ reject(new Error('request body is too large'));
73
+ req.destroy();
74
+ return;
75
+ }
76
+ chunks.push(chunk);
77
+ });
78
+ req.on('end', () => {
79
+ if (chunks.length === 0) {
80
+ resolve({});
81
+ return;
82
+ }
83
+ try {
84
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
85
+ } catch {
86
+ reject(new Error('request body is not JSON'));
87
+ }
88
+ });
89
+ req.on('error', reject);
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Admit one request by its browser-supplied origin signals, so a random page in
95
+ * the user's browser cannot drive the application's own browser panel.
96
+ * @param req - node request.
97
+ * @returns whether the request may proceed.
98
+ */
99
+ function admitted(req) {
100
+ const site = req.headers['sec-fetch-site'];
101
+ if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return false;
102
+ const origin = req.headers.origin;
103
+ if (typeof origin === 'string' && origin.length > 0 && !APPLICATION_ORIGIN.test(origin)) return false;
104
+ return true;
105
+ }
106
+
107
+ /** One connected browser client and its pending long-poll. */
108
+ class Client {
109
+ /** @param id - opaque per-page identity. */
110
+ constructor(id) {
111
+ this.id = id;
112
+ this.platform = 'unknown';
113
+ this.capabilities = [];
114
+ this.href = undefined;
115
+ this.seenAt = Date.now();
116
+ this.focused = false;
117
+ this.queue = [];
118
+ this.waiter = undefined;
119
+ }
120
+ }
121
+
122
+ /** Host-side command router: the single implementation behind every caller. */
123
+ class Pilot {
124
+ constructor() {
125
+ /** @type {Map<string, Client>} */
126
+ this.clients = new Map();
127
+ /** @type {Map<string, {resolve: Function, reject: Function}>} */
128
+ this.pending = new Map();
129
+ this.sequence = 0;
130
+ }
131
+
132
+ /**
133
+ * @param prefix - identifier prefix.
134
+ * @returns a fresh opaque identifier.
135
+ */
136
+ nextId(prefix) {
137
+ this.sequence += 1;
138
+ return `${prefix}${this.sequence}-${Math.random().toString(36).slice(2, 10)}`;
139
+ }
140
+
141
+ /** Forget clients that stopped talking. */
142
+ prune() {
143
+ const cutoff = Date.now() - CLIENT_IDLE_MS;
144
+ for (const [id, client] of this.clients) {
145
+ if (client.seenAt < cutoff && client.waiter === undefined) this.clients.delete(id);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Register or refresh one client page.
151
+ * @param input - client announcement.
152
+ * @returns the client record.
153
+ */
154
+ hello(input) {
155
+ this.prune();
156
+ const claimed = typeof input.clientId === 'string' ? input.clientId : undefined;
157
+ const client = claimed !== undefined && this.clients.has(claimed) ? this.clients.get(claimed) : new Client(this.nextId('client-'));
158
+ client.platform = typeof input.platform === 'string' ? input.platform.slice(0, 32) : client.platform;
159
+ client.capabilities = Array.isArray(input.capabilities) ? input.capabilities.filter((entry) => typeof entry === 'string').slice(0, 32) : [];
160
+ client.href = typeof input.href === 'string' ? input.href.slice(0, 400) : undefined;
161
+ client.seenAt = Date.now();
162
+ this.clients.set(client.id, client);
163
+ return client;
164
+ }
165
+
166
+ /** @returns the client that most deserves the next command: focused first, then most recent. */
167
+ active() {
168
+ this.prune();
169
+ let best;
170
+ for (const client of this.clients.values()) {
171
+ if (best === undefined) {
172
+ best = client;
173
+ continue;
174
+ }
175
+ if (client.focused !== best.focused) {
176
+ if (client.focused === true) best = client;
177
+ continue;
178
+ }
179
+ if (client.seenAt > best.seenAt) best = client;
180
+ }
181
+ return best;
182
+ }
183
+
184
+ /**
185
+ * Hold one long-poll open for a client until a command arrives.
186
+ * @param clientId - announcing client.
187
+ * @param res - node response to keep open.
188
+ * @param focused - whether the page reported window focus.
189
+ * @returns whether the client is known.
190
+ */
191
+ hold(clientId, res, focused) {
192
+ const client = this.clients.get(clientId);
193
+ if (client === undefined) return false;
194
+ client.seenAt = Date.now();
195
+ client.focused = focused === true;
196
+ const waiter = { res, timer: undefined, closed: false, finish: undefined };
197
+ waiter.finish = (value) => {
198
+ if (waiter.closed) return;
199
+ waiter.closed = true;
200
+ if (client.waiter === waiter) client.waiter = undefined;
201
+ clearTimeout(waiter.timer);
202
+ sendJson(res, 200, value);
203
+ };
204
+ res.on('close', () => {
205
+ if (waiter.closed) return;
206
+ waiter.closed = true;
207
+ clearTimeout(waiter.timer);
208
+ if (client.waiter === waiter) client.waiter = undefined;
209
+ });
210
+ waiter.timer = setTimeout(() => waiter.finish({ ok: true, commands: [] }), POLL_HOLD_MS);
211
+ client.waiter = waiter;
212
+ this.flush(client);
213
+ return true;
214
+ }
215
+
216
+ /**
217
+ * Deliver queued commands to a held poll, if both exist.
218
+ * @param client - the client to flush.
219
+ */
220
+ flush(client) {
221
+ const waiter = client.waiter;
222
+ if (waiter === undefined || client.queue.length === 0) return;
223
+ const commands = client.queue.splice(0, client.queue.length);
224
+ waiter.finish({ ok: true, commands });
225
+ }
226
+
227
+ /**
228
+ * Run one operation in the connected browser page.
229
+ * @param op - client operation name.
230
+ * @param args - operation arguments.
231
+ * @param signal - caller cancellation.
232
+ * @returns the operation result.
233
+ */
234
+ async call(op, args, signal) {
235
+ const client = this.active();
236
+ if (client === undefined) {
237
+ throw new Error('DSH 内置浏览器的控制端未连接:请打开 DSH 的 GUI 页面(插件的客户端半随页面加载),然后重试。');
238
+ }
239
+ const id = this.nextId('cmd-');
240
+ client.queue.push({ id, op, args: args ?? {} });
241
+ this.flush(client);
242
+ return await new Promise((resolve, reject) => {
243
+ const timer = setTimeout(() => {
244
+ settle();
245
+ reject(new Error(`内置浏览器在 ${Math.round(CALL_TIMEOUT_MS / 1000)} 秒内没有响应;GUI 可能已关闭、正在刷新,或被模态框阻塞。`));
246
+ }, CALL_TIMEOUT_MS);
247
+ const abort = () => {
248
+ settle();
249
+ reject(new Error('内置浏览器操作已取消。'));
250
+ };
251
+ const settle = () => {
252
+ clearTimeout(timer);
253
+ this.pending.delete(id);
254
+ signal?.removeEventListener('abort', abort);
255
+ };
256
+ if (signal !== undefined) {
257
+ if (signal.aborted) {
258
+ abort();
259
+ return;
260
+ }
261
+ signal.addEventListener('abort', abort, { once: true });
262
+ }
263
+ this.pending.set(id, {
264
+ resolve: (value) => {
265
+ settle();
266
+ resolve(value);
267
+ },
268
+ reject: (error) => {
269
+ settle();
270
+ reject(error);
271
+ }
272
+ });
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Settle one command from a client.
278
+ * @param input - client report.
279
+ * @returns whether a pending call was waiting for it.
280
+ */
281
+ settle(input) {
282
+ const id = typeof input.id === 'string' ? input.id : undefined;
283
+ if (id === undefined) return false;
284
+ const entry = this.pending.get(id);
285
+ if (entry === undefined) return false;
286
+ if (input.ok === true) entry.resolve(input.value ?? null);
287
+ else entry.reject(new Error(typeof input.error === 'string' && input.error.length > 0 ? input.error : '内置浏览器操作失败。'));
288
+ return true;
289
+ }
290
+
291
+ /** @returns a JSON snapshot for diagnostics. */
292
+ status() {
293
+ this.prune();
294
+ return {
295
+ clients: [...this.clients.values()].map((client) => ({
296
+ id: client.id,
297
+ platform: client.platform,
298
+ capabilities: client.capabilities,
299
+ href: client.href,
300
+ held: client.waiter !== undefined,
301
+ queued: client.queue.length,
302
+ idleMs: Date.now() - client.seenAt
303
+ })),
304
+ pendingCalls: this.pending.size
305
+ };
306
+ }
307
+ }
308
+
309
+ /** Render the tab list as tool text. */
310
+ function tabsText(value) {
311
+ const tabs = Array.isArray(value?.tabs) ? value.tabs : [];
312
+ const header = `platform=${value?.platform ?? 'unknown'} page_control=${value?.page_control === true ? 'yes' : 'no'} panel_open=${value?.panel_open === true ? 'yes' : 'no'}`;
313
+ if (tabs.length === 0) return `${header}\n内置浏览器当前没有打开任何标签页。`;
314
+ const lines = tabs.map((tab) => {
315
+ const marks = [tab.active === true ? 'active' : '', tab.on_screen === true ? 'on-screen' : 'other-session'].filter((mark) => mark.length > 0).join(',');
316
+ const address = typeof tab.url === 'string' && tab.url.length > 0 ? ` — ${tab.url}` : '';
317
+ return `- ${tab.tab_id}${marks.length > 0 ? ` [${marks}]` : ''} ${tab.title ?? ''}${address}`;
318
+ });
319
+ return `${header}\n${tabs.length} 个内置浏览器标签页:\n${lines.join('\n')}`;
320
+ }
321
+
322
+ /** Render one control/page result as tool text. */
323
+ function actionText(value) {
324
+ if (value === null || typeof value !== 'object') return String(value ?? '完成');
325
+ const parts = [];
326
+ if (typeof value.action === 'string') parts.push(value.action);
327
+ if (typeof value.tab_id === 'string') parts.push(`tab=${value.tab_id}`);
328
+ if (typeof value.url === 'string' && value.url.length > 0) parts.push(value.url);
329
+ if (typeof value.title === 'string' && value.title.length > 0) parts.push(`title=${value.title}`);
330
+ const head = parts.length > 0 ? parts.join(' ') : '完成';
331
+ const body = [];
332
+ if (typeof value.text === 'string' && value.text.length > 0) body.push(value.text);
333
+ if (typeof value.note === 'string' && value.note.length > 0) body.push(`注意:${value.note}`);
334
+ if (value.value !== undefined && value.value !== null) body.push(JSON.stringify(value.value));
335
+ if (Array.isArray(value.links) && value.links.length > 0) {
336
+ body.push(value.links.map((link) => `- ${link.text ?? ''} ${link.href ?? ''}`).join('\n'));
337
+ }
338
+ if (Array.isArray(value.elements) && value.elements.length > 0) {
339
+ body.push(value.elements.map((element) => `- ${element.selector ?? ''} ${element.text ?? ''}`).join('\n'));
340
+ }
341
+ return body.length === 0 ? head : `${head}\n${body.join('\n')}`;
342
+ }
343
+
344
+ /**
345
+ * Register the browser control surface.
346
+ * @param ctx - the plugin context.
347
+ */
348
+ export function apply(ctx) {
349
+ const pilot = new Pilot();
350
+
351
+ ctx.effect(() => ctx.webServer.register({
352
+ kind: 'exact',
353
+ path: `${BASE}/hello`,
354
+ handler: async (req, res) => {
355
+ if (!admitted(req)) {
356
+ sendJson(res, 403, { ok: false, error: 'origin not allowed' });
357
+ return;
358
+ }
359
+ try {
360
+ const body = await readJson(req);
361
+ const client = pilot.hello(body);
362
+ sendJson(res, 200, { ok: true, clientId: client.id, holdMs: POLL_HOLD_MS });
363
+ } catch (error) {
364
+ sendJson(res, 400, { ok: false, error: String(error?.message ?? error) });
365
+ }
366
+ }
367
+ }), 'sidebar-browser-control.hello');
368
+
369
+ ctx.effect(() => ctx.webServer.register({
370
+ kind: 'exact',
371
+ path: `${BASE}/poll`,
372
+ handler: (req, res) => {
373
+ if (!admitted(req)) {
374
+ sendJson(res, 403, { ok: false, error: 'origin not allowed' });
375
+ return;
376
+ }
377
+ const clientId = new URL(req.url ?? '/', 'http://x').searchParams.get('clientId');
378
+ const focused = new URL(req.url ?? '/', 'http://x').searchParams.get('focus') === '1';
379
+ if (clientId === null || !pilot.hold(clientId, res, focused)) sendJson(res, 410, { ok: false, error: 'unknown-client' });
380
+ }
381
+ }), 'sidebar-browser-control.poll');
382
+
383
+ ctx.effect(() => ctx.webServer.register({
384
+ kind: 'exact',
385
+ path: `${BASE}/result`,
386
+ handler: async (req, res) => {
387
+ if (!admitted(req)) {
388
+ sendJson(res, 403, { ok: false, error: 'origin not allowed' });
389
+ return;
390
+ }
391
+ try {
392
+ const body = await readJson(req);
393
+ if (typeof body.clientId === 'string') {
394
+ const client = pilot.clients.get(body.clientId);
395
+ if (client !== undefined) client.seenAt = Date.now();
396
+ }
397
+ sendJson(res, 200, { ok: true, settled: pilot.settle(body) });
398
+ } catch (error) {
399
+ sendJson(res, 400, { ok: false, error: String(error?.message ?? error) });
400
+ }
401
+ }
402
+ }), 'sidebar-browser-control.result');
403
+
404
+ ctx.effect(() => ctx.webServer.register({
405
+ kind: 'exact',
406
+ path: `${BASE}/status`,
407
+ handler: (req, res) => {
408
+ if (!admitted(req)) {
409
+ sendJson(res, 403, { ok: false, error: 'origin not allowed' });
410
+ return;
411
+ }
412
+ sendJson(res, 200, { ok: true, ...pilot.status() });
413
+ }
414
+ }), 'sidebar-browser-control.status');
415
+
416
+ const sharedOutput = {
417
+ schema: {
418
+ type: 'object',
419
+ properties: {
420
+ action: { type: 'string' },
421
+ tab_id: { type: 'string' },
422
+ url: { type: 'string' },
423
+ title: { type: 'string' },
424
+ text: { type: 'string' },
425
+ note: { type: 'string' },
426
+ platform: { type: 'string' },
427
+ page_control: { type: 'boolean' }
428
+ }
429
+ }
430
+ };
431
+
432
+ ctx.effect(() => ctx.tools.register({
433
+ name: 'browser_tabs',
434
+ description: "List the tabs open in DSH's own built-in browser (the right-hand Sidebar's Browser panel), with each tab's id, title, URL and owning session. Also reports whether page-level control (read/click/type/eval) is available — it is on the Desktop app, not in a plain browser tab. Call this first when you need a tab id for browser_control or browser_page.",
435
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
436
+ output: { ...sharedOutput, render: (_args, value) => [{ type: 'text', text: tabsText(value) }] },
437
+ async execute(_args, exec) {
438
+ return await pilot.call('tabs', {}, exec.signal);
439
+ }
440
+ }), 'sidebar-browser-control.tool.tabs');
441
+
442
+ ctx.effect(() => ctx.tools.register({
443
+ name: 'browser_control',
444
+ description: "Drive DSH's own built-in browser: open a URL in the right-hand Sidebar Browser, navigate an existing tab, go back/forward, reload, close or focus a tab, control the panel (expand/collapse/fullscreen/split/float/dock), or hand the current page to the system browser. Actions apply to the session currently on screen. Use browser_tabs to obtain a tab id; omitting tab_id acts on the active tab.",
445
+ parameters: {
446
+ type: 'object',
447
+ additionalProperties: false,
448
+ properties: {
449
+ action: {
450
+ type: 'string',
451
+ enum: ['open', 'navigate', 'back', 'forward', 'reload', 'close', 'focus', 'expand', 'collapse', 'fullscreen', 'split', 'float', 'dock', 'external'],
452
+ description: 'open: load a URL (a new tab unless tab_id is given, in which case that tab is replaced). navigate: load a URL in the given tab. back/forward/reload: drive the page. close/focus: tab lifecycle. expand/collapse/fullscreen: the Sidebar panel. split/float/dock: pane layout. external: open the current page in the system browser.'
453
+ },
454
+ url: { type: 'string', description: 'HTTP(S) address for open/navigate. A bare host is completed with https://' },
455
+ tab_id: { type: 'string', description: 'Target tab id from browser_tabs; omit for the active tab.' }
456
+ },
457
+ required: ['action']
458
+ },
459
+ output: { ...sharedOutput, render: (_args, value) => [{ type: 'text', text: actionText(value) }] },
460
+ async execute(args, exec) {
461
+ return await pilot.call('control', args, exec.signal);
462
+ }
463
+ }), 'sidebar-browser-control.tool.control');
464
+
465
+ ctx.effect(() => ctx.tools.register({
466
+ name: 'browser_page',
467
+ description: "Read and act on the page inside DSH's built-in browser tab (Desktop app only; browser_tabs reports page_control). text returns the page's visible text, links lists anchors, elements lists interactive elements with a usable selector for click, eval runs JavaScript in the page and returns its JSON value, click clicks a selector or the element containing text, type fills a field (optionally submitting), scroll scrolls, wait waits for a selector or text.",
468
+ parameters: {
469
+ type: 'object',
470
+ additionalProperties: false,
471
+ properties: {
472
+ action: {
473
+ type: 'string',
474
+ enum: ['text', 'links', 'elements', 'eval', 'click', 'type', 'scroll', 'wait'],
475
+ description: 'What to do with the page.'
476
+ },
477
+ tab_id: { type: 'string', description: 'Target tab id from browser_tabs; omit for the active tab.' },
478
+ selector: { type: 'string', description: 'CSS selector for click/type/scroll/wait.' },
479
+ text: { type: 'string', description: 'Text to type, or text to match for click/wait when no selector is given.' },
480
+ code: { type: 'string', description: 'JavaScript for eval. Use an expression, or statements ending in a return.' },
481
+ submit: { type: 'boolean', description: 'type: submit the form afterwards.' },
482
+ ms: { type: 'number', description: 'wait: timeout in milliseconds (default 10000). scroll: pixels to scroll (default one viewport).' },
483
+ max_chars: { type: 'number', description: 'text/links: truncate the returned text (default 8000 characters).' }
484
+ },
485
+ required: ['action']
486
+ },
487
+ output: {
488
+ schema: {
489
+ type: 'object',
490
+ properties: {
491
+ action: { type: 'string' },
492
+ tab_id: { type: 'string' },
493
+ url: { type: 'string' },
494
+ title: { type: 'string' },
495
+ text: { type: 'string' },
496
+ note: { type: 'string' }
497
+ }
498
+ },
499
+ render: (_args, value) => [{ type: 'text', text: actionText(value) }]
500
+ },
501
+ async execute(args, exec) {
502
+ return await pilot.call('page', args, exec.signal);
503
+ }
504
+ }), 'sidebar-browser-control.tool.page');
505
+
506
+ ctx.inject(['commands'], (scope) => {
507
+ scope.effect(() => scope.commands.register({
508
+ name: 'browser',
509
+ description: '驱动 DSH 内置浏览器:/browser open <url> | tabs | back | forward | reload | close | expand | collapse | fullscreen | split | read',
510
+ input: { hint: 'open <url> | tabs | back | forward | reload | close | expand | collapse | fullscreen | split | read' },
511
+ async handler(invocation) {
512
+ try {
513
+ return { kind: 'success', text: await runCommand(pilot, invocation) };
514
+ } catch (error) {
515
+ return { kind: 'error', text: String(error?.message ?? error) };
516
+ }
517
+ }
518
+ }), 'sidebar-browser-control.command');
519
+ });
520
+ }
521
+
522
+ /**
523
+ * Run one `/browser` line against the same operations the tools use.
524
+ * @param pilot - command router.
525
+ * @param invocation - command invocation.
526
+ * @returns human-readable report text.
527
+ */
528
+ async function runCommand(pilot, invocation) {
529
+ const [word, ...rest] = invocation.rawInput.trim().split(/\s+/u);
530
+ const verb = (word ?? '').toLowerCase() === '' ? 'tabs' : word.toLowerCase();
531
+ const argument = rest.join(' ').trim();
532
+ const signal = invocation.signal;
533
+ switch (verb) {
534
+ case 'tabs':
535
+ case 'list':
536
+ return tabsText(await pilot.call('tabs', {}, signal));
537
+ case 'open':
538
+ case 'go': {
539
+ if (argument.length === 0) throw new Error('用法:/browser open <url>');
540
+ return actionText(await pilot.call('control', { action: 'open', url: argument }, signal));
541
+ }
542
+ case 'read':
543
+ return actionText(await pilot.call('page', { action: 'text', ...argument.length > 0 ? { tab_id: argument } : {} }, signal));
544
+ case 'back':
545
+ case 'forward':
546
+ case 'reload':
547
+ case 'close':
548
+ case 'focus':
549
+ case 'expand':
550
+ case 'collapse':
551
+ case 'fullscreen':
552
+ case 'split':
553
+ case 'float':
554
+ case 'dock':
555
+ case 'external':
556
+ case 'navigate':
557
+ return actionText(await pilot.call('control', { action: verb, ...argument.length > 0 ? { tab_id: argument } : {} }, signal));
558
+ default:
559
+ throw new Error(`未知的 /browser 子命令:${verb}(可用:open、tabs、back、forward、reload、close、focus、expand、collapse、fullscreen、split、float、dock、external、read)`);
560
+ }
561
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Sidebar Browser Control",
4
+ "description": "Let the agent and the /browser command drive DSH's built-in sidebar browser: list tabs, open, navigate, back/forward, reload, read the page, run JavaScript, click and type."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "内置浏览器控制",
4
+ "description": "让 Agent 与 /browser 命令驱动 DSH 右侧栏的内置浏览器:列出标签、打开、导航、前进后退、刷新、读取正文、执行 JS、点击与输入。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "dsh-sidebar-browser-control",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Drive DSH's own built-in browser (the right-hand Sidebar's Browser tab) from the agent: list tabs, open, navigate, back/forward, reload, read the page, run JavaScript, click and type. No second Chromium process.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "browser",
10
+ "browser-automation",
11
+ "sidebar",
12
+ "agent-tool",
13
+ "cordis"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "XUFENGCHINA",
17
+ "type": "module",
18
+ "main": "index.js",
19
+ "icon": "./icon.svg",
20
+ "meta": {
21
+ "title": "Sidebar Browser Control",
22
+ "description": "Let the agent and the /browser command drive DSH's built-in sidebar browser: list tabs, open, navigate, back/forward, reload, read the page, run JavaScript, click and type."
23
+ },
24
+ "exports": {
25
+ ".": "./index.js",
26
+ "./client": "./client.js",
27
+ "./cordis.patch.yml": "./cordis.patch.yml",
28
+ "./package.json": "./package.json",
29
+ "./locale/*.json": "./locale/*.json"
30
+ },
31
+ "files": [
32
+ "index.js",
33
+ "client.js",
34
+ "cordis.patch.yml",
35
+ "icon.svg",
36
+ "locale/"
37
+ ],
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/XUFENGCHINA/dsh-sidebar-browser-control.git"
44
+ },
45
+ "homepage": "https://github.com/XUFENGCHINA/dsh-sidebar-browser-control#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/XUFENGCHINA/dsh-sidebar-browser-control/issues"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "scripts": {
53
+ "check": "node --check index.js && node --check client.js",
54
+ "check:release": "node scripts/check-release-ready.mjs",
55
+ "test": "node test/bridge.test.mjs",
56
+ "verify:pack": "node scripts/verify-pack.mjs",
57
+ "verify": "npm run check && npm test && npm run verify:pack",
58
+ "prepublishOnly": "npm run check:release && npm run verify"
59
+ },
60
+ "dsh": {
61
+ "bundle": {
62
+ "patch": "./cordis.patch.yml"
63
+ },
64
+ "client": {
65
+ "platform": "web",
66
+ "immediately": true,
67
+ "inject": [
68
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
69
+ ]
70
+ }
71
+ }
72
+ }