dsh-builtin-browser 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,30 @@
1
+ /**
2
+ * Electron browser provider plugin entry: registers the Electron-backed
3
+ * `BrowserProvider` with `ctx.browser`. The provider needs a view host (real
4
+ * Electron `WebContentsView` objects) that only a desktop shell can supply,
5
+ * so the view host arrives through plugin config (the bundle patch reads it
6
+ * from `ctx.get('electronViewHost')`).
7
+ * @module dsh-browser/browser-electron
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis';
10
+ import z from '@deepseek-ai/schemastery';
11
+ import type { BrowserRuntime } from '../browser/runtime.ts';
12
+ import type { ElectronBrowserViewHost } from './provider.ts';
13
+ export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.ts';
14
+ export type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts';
15
+ /** Cordis plugin name used by loader diagnostics. */
16
+ export declare const name = "browser-electron";
17
+ /** The browser seam this provider registers into. */
18
+ export declare const inject: string[];
19
+ /** Plugin config: the view host supplied by the desktop shell. */
20
+ export interface Config {
21
+ /** The Electron view host supplied by the desktop shell. */
22
+ readonly viewHost: ElectronBrowserViewHost;
23
+ /** Allow navigation only to HTTP(S) URLs. Default true. */
24
+ readonly httpOnly?: boolean;
25
+ }
26
+ export declare const Config: z<Config>;
27
+ /** Register the Electron browser provider with `ctx.browser`. */
28
+ export declare function apply(ctx: Context & {
29
+ browser: BrowserRuntime;
30
+ }, config: Config): void;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Electron browser provider plugin entry: registers the Electron-backed
3
+ * `BrowserProvider` with `ctx.browser`. The provider needs a view host (real
4
+ * Electron `WebContentsView` objects) that only a desktop shell can supply,
5
+ * so the view host arrives through plugin config (the bundle patch reads it
6
+ * from `ctx.get('electronViewHost')`).
7
+ * @module dsh-browser/browser-electron
8
+ */
9
+ import z from '@deepseek-ai/schemastery';
10
+ import { ElectronBrowserProvider } from "./provider.js";
11
+ export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from "./provider.js";
12
+ /** Cordis plugin name used by loader diagnostics. */
13
+ export const name = 'browser-electron';
14
+ /** The browser seam this provider registers into. */
15
+ export const inject = ['browser'];
16
+ export const Config = z.object({
17
+ viewHost: z.any(),
18
+ httpOnly: z.boolean().default(true),
19
+ });
20
+ /** Register the Electron browser provider with `ctx.browser`. */
21
+ export function apply(ctx, config) {
22
+ ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost, { httpOnly: config.httpOnly }));
23
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Electron-backed browser provider: `WebContentsView` sessions driven over
3
+ * `webContents.debugger` (CDP). The provider itself does not import Electron 鈥? * it operates through the {@link ElectronBrowserViewHost} seam, which the
4
+ * desktop shell implements with real Electron objects. That keeps this
5
+ * package testable under plain Node and leaves the Electron dependency to the
6
+ * shell that owns the `BrowserWindow`.
7
+ * @module dsh-browser/browser-electron
8
+ */
9
+ import type { BrowserContentRequest, BrowserContentResult, BrowserExecuteRequest, BrowserExecuteResult, BrowserOpenRequest, BrowserProvider, BrowserSessionId, BrowserSnapshotResult, BrowserTab } from '../browser/types.ts';
10
+ /** Stable provider id registered with `ctx.browser`. */
11
+ export declare const ELECTRON_BROWSER_PROVIDER_ID = "electron";
12
+ /**
13
+ * The minimal Electron surface this provider needs. Implemented by the
14
+ * desktop shell with a real `WebContentsView`; a fake implements it in tests.
15
+ */
16
+ export interface ElectronBrowserViewHost {
17
+ /**
18
+ * Create a new browser view and return a handle to its webContents-like
19
+ * surface. The host owns windowing (adding the view to the window, sizing,
20
+ * removal); the provider owns CDP-driven behavior.
21
+ */
22
+ createView(): ElectronViewHandle;
23
+ /**
24
+ * Destroy a view created by this host. Called on session close; idempotent
25
+ * for an already-destroyed view.
26
+ * @param handle - the handle returned by {@link createView}.
27
+ */
28
+ destroyView(handle: ElectronViewHandle): void;
29
+ /**
30
+ * Show one view as the session's visible surface. The host keeps exactly
31
+ * one visible; switching tabs reorders visibility without losing state.
32
+ * Optional: a host without visible-tab switching treats every view as
33
+ * always present (acceptable for headless/probe hosts).
34
+ * @param handle - the handle to make visible.
35
+ */
36
+ showView?(handle: ElectronViewHandle): void;
37
+ }
38
+ /**
39
+ * A CDP-capable view handle. This is the subset of Electron's
40
+ * `WebContents`/`WebContentsView` the provider drives; the shell's real
41
+ * implementation adapts `webContents.debugger` to it.
42
+ */
43
+ export interface ElectronViewHandle {
44
+ /** Unique id of the backing view, used for diagnostics. */
45
+ readonly id: string;
46
+ /**
47
+ * Send one CDP command and resolve with its result. Rejects when the
48
+ * debugger is not attached or the command fails.
49
+ * @param method - CDP method, e.g. `Page.navigate`.
50
+ * @param params - CDP command parameters.
51
+ * @returns the CDP `result` object.
52
+ */
53
+ sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>;
54
+ }
55
+ /** Provider config: navigation admission defaults and snapshot caps. */
56
+ export interface ElectronBrowserProviderConfig {
57
+ /** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
58
+ readonly httpOnly?: boolean;
59
+ /** Maximum snapshot elements before truncation. Default 60. */
60
+ readonly snapshotMaxElements?: number;
61
+ /** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
62
+ readonly contentMaxChars?: number;
63
+ }
64
+ /**
65
+ * CDP method/params for `Page.navigate`, as sent to {@link ElectronViewHandle.sendCommand}.
66
+ */
67
+ export interface CdpNavigateParams {
68
+ readonly url: string;
69
+ }
70
+ /**
71
+ * CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
72
+ */
73
+ export interface CdpMouseParams {
74
+ readonly type: 'mousePressed' | 'mouseReleased';
75
+ readonly x: number;
76
+ readonly y: number;
77
+ readonly button: 'left';
78
+ readonly clickCount: number;
79
+ }
80
+ /** CDP method/params for `Input.insertText`. */
81
+ export interface CdpInsertTextParams {
82
+ readonly text: string;
83
+ }
84
+ /** CDP method/params for `Runtime.evaluate`. */
85
+ export interface CdpEvaluateParams {
86
+ readonly expression: string;
87
+ readonly returnByValue: boolean;
88
+ readonly awaitPromise?: boolean;
89
+ }
90
+ /** CDP method for a full-page screenshot capture. */
91
+ export declare const CDP_PAGE_CAPTURE_SCREENSHOT = "Page.captureScreenshot";
92
+ /** CDP method for runtime evaluation (the execute path). */
93
+ export declare const CDP_RUNTIME_EVALUATE = "Runtime.evaluate";
94
+ /** CDP method for navigation. */
95
+ export declare const CDP_PAGE_NAVIGATE = "Page.navigate";
96
+ /**
97
+ * Browser provider over Electron views. Sessions hold an ordered list of
98
+ * tabs; each tab is one view created by the host. The active tab receives
99
+ * every operation; switching tabs calls the host's optional `showView` and
100
+ * never loses state. Navigation is admitted only for HTTP(S) targets unless
101
+ * {@link ElectronBrowserProviderConfig.httpOnly} is disabled.
102
+ */
103
+ export declare class ElectronBrowserProvider implements BrowserProvider {
104
+ private readonly host;
105
+ readonly id = "electron";
106
+ private readonly sessions;
107
+ private readonly httpOnly;
108
+ private readonly snapshotMaxElements;
109
+ private readonly contentMaxChars;
110
+ constructor(host: ElectronBrowserViewHost, config?: ElectronBrowserProviderConfig);
111
+ /** Usable whenever the host can create views (always in the desktop shell). */
112
+ available(): boolean;
113
+ /**
114
+ * Get the shared browser session: the one the host already owns, or a new
115
+ * one on first use. Shared-browser semantics (Cherry Studio's
116
+ * `getOrCreateWindow` pattern): the host's startup view and every later
117
+ * agent call must land on the SAME session and view, so opening again
118
+ * returns the existing session instead of creating a second view the human
119
+ * cannot see. Closing the session resets this; the next open starts fresh.
120
+ */
121
+ open(): Promise<BrowserSessionId>;
122
+ /** Open a URL in the active tab (default) or a new tab. */
123
+ openUrl(session: BrowserSessionId, request: BrowserOpenRequest, signal?: AbortSignal): Promise<void>;
124
+ /** List the session's tabs with their titles. */
125
+ listTabs(session: BrowserSessionId): Promise<readonly BrowserTab[]>;
126
+ /** Switch to a tab by id, making its view visible. */
127
+ switchTab(session: BrowserSessionId, tabId: string): Promise<void>;
128
+ /** Close one tab; closing the active tab activates the next. */
129
+ closeTab(session: BrowserSessionId, tabId: string): Promise<void>;
130
+ /** Close every tab and reset to one blank tab. */
131
+ reset(session: BrowserSessionId): Promise<void>;
132
+ /** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
133
+ navigate(session: BrowserSessionId, request: {
134
+ readonly url: string;
135
+ }, signal?: AbortSignal): Promise<void>;
136
+ /** Execute JS in the active tab's page context. */
137
+ execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult>;
138
+ /** Produce an AI-friendly snapshot of the active tab. */
139
+ snapshot(session: BrowserSessionId, signal?: AbortSignal): Promise<BrowserSnapshotResult>;
140
+ /** Fetch page content in a requested format. */
141
+ content(session: BrowserSessionId, request: BrowserContentRequest, signal?: AbortSignal): Promise<BrowserContentResult>;
142
+ /** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
143
+ click(session: BrowserSessionId, request: {
144
+ readonly x: number;
145
+ readonly y: number;
146
+ }, signal?: AbortSignal): Promise<void>;
147
+ /** Type into the focused element. */
148
+ type(session: BrowserSessionId, request: {
149
+ readonly text: string;
150
+ }, signal?: AbortSignal): Promise<void>;
151
+ /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
152
+ screenshot(session: BrowserSessionId, request?: {
153
+ readonly fullPage?: boolean;
154
+ }, signal?: AbortSignal): Promise<{
155
+ readonly dataUrl: string;
156
+ }>;
157
+ /** Close the session and destroy all its views. Idempotent. */
158
+ close(session: BrowserSessionId): Promise<void>;
159
+ /** Look up a session or throw the unknown-session error. */
160
+ private session;
161
+ /** The active tab of a session. */
162
+ private activeTab;
163
+ /** Append a fresh tab and make it active. */
164
+ private newTab;
165
+ /** Ask the host to show the active tab's view. */
166
+ private showActive;
167
+ /** Read the current URL of a view through CDP. */
168
+ private currentUrl;
169
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Electron-backed browser provider: `WebContentsView` sessions driven over
3
+ * `webContents.debugger` (CDP). The provider itself does not import Electron 鈥? * it operates through the {@link ElectronBrowserViewHost} seam, which the
4
+ * desktop shell implements with real Electron objects. That keeps this
5
+ * package testable under plain Node and leaves the Electron dependency to the
6
+ * shell that owns the `BrowserWindow`.
7
+ * @module dsh-browser/browser-electron
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { BrowserError } from "../browser/types.js";
11
+ /** Stable provider id registered with `ctx.browser`. */
12
+ export const ELECTRON_BROWSER_PROVIDER_ID = 'electron';
13
+ /** CDP method for a full-page screenshot capture. */
14
+ export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot';
15
+ /** CDP method for runtime evaluation (the execute path). */
16
+ export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate';
17
+ /** CDP method for navigation. */
18
+ export const CDP_PAGE_NAVIGATE = 'Page.navigate';
19
+ /** Cap on content returned by a snapshot fetch to keep the wire bounded. */
20
+ const SNAPSHOT_LABEL_MAX = 120;
21
+ /**
22
+ * Browser provider over Electron views. Sessions hold an ordered list of
23
+ * tabs; each tab is one view created by the host. The active tab receives
24
+ * every operation; switching tabs calls the host's optional `showView` and
25
+ * never loses state. Navigation is admitted only for HTTP(S) targets unless
26
+ * {@link ElectronBrowserProviderConfig.httpOnly} is disabled.
27
+ */
28
+ export class ElectronBrowserProvider {
29
+ host;
30
+ id = ELECTRON_BROWSER_PROVIDER_ID;
31
+ sessions = new Map();
32
+ httpOnly;
33
+ snapshotMaxElements;
34
+ contentMaxChars;
35
+ constructor(host, config = {}) {
36
+ this.host = host;
37
+ this.httpOnly = config.httpOnly ?? true;
38
+ this.snapshotMaxElements = config.snapshotMaxElements ?? 60;
39
+ this.contentMaxChars = config.contentMaxChars ?? 100_000;
40
+ }
41
+ /** Usable whenever the host can create views (always in the desktop shell). */
42
+ available() {
43
+ return true;
44
+ }
45
+ /**
46
+ * Get the shared browser session: the one the host already owns, or a new
47
+ * one on first use. Shared-browser semantics (Cherry Studio's
48
+ * `getOrCreateWindow` pattern): the host's startup view and every later
49
+ * agent call must land on the SAME session and view, so opening again
50
+ * returns the existing session instead of creating a second view the human
51
+ * cannot see. Closing the session resets this; the next open starts fresh.
52
+ */
53
+ open() {
54
+ const [existing] = this.sessions.keys();
55
+ if (existing !== undefined)
56
+ return Promise.resolve(existing);
57
+ const handle = this.host.createView();
58
+ const id = `browser:${randomUUID()}`;
59
+ this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0 });
60
+ return Promise.resolve(id);
61
+ }
62
+ /** Open a URL in the active tab (default) or a new tab. */
63
+ async openUrl(session, request, signal) {
64
+ const s = this.session(session);
65
+ if (request.newTab === true) {
66
+ this.newTab(s);
67
+ }
68
+ await this.navigate(session, { url: request.url }, signal);
69
+ }
70
+ /** List the session's tabs with their titles. */
71
+ async listTabs(session) {
72
+ const s = this.session(session);
73
+ const result = [];
74
+ for (let i = 0; i < s.tabs.length; i++) {
75
+ const tab = s.tabs[i];
76
+ if (tab === undefined)
77
+ continue; // defensive: array can shift under concurrency
78
+ result.push({
79
+ id: tab.id,
80
+ url: await this.currentUrl(tab.handle).catch(() => ''),
81
+ active: i === s.activeIndex,
82
+ });
83
+ }
84
+ return result;
85
+ }
86
+ /** Switch to a tab by id, making its view visible. */
87
+ switchTab(session, tabId) {
88
+ const s = this.session(session);
89
+ const index = s.tabs.findIndex(tab => tab.id === tabId);
90
+ if (index < 0) {
91
+ throw new BrowserError(`browser: tab "${tabId}" is not open in this session`, 'BROWSER_TAB_UNKNOWN');
92
+ }
93
+ s.activeIndex = index;
94
+ this.showActive(s);
95
+ return Promise.resolve();
96
+ }
97
+ /** Close one tab; closing the active tab activates the next. */
98
+ closeTab(session, tabId) {
99
+ const s = this.session(session);
100
+ const index = s.tabs.findIndex(tab => tab.id === tabId);
101
+ if (index < 0)
102
+ return Promise.resolve(); // idempotent
103
+ const removed = s.tabs[index];
104
+ if (removed !== undefined) {
105
+ s.tabs.splice(index, 1);
106
+ this.host.destroyView(removed.handle);
107
+ }
108
+ if (s.tabs.length === 0) {
109
+ // Session keeps one blank tab so it stays usable.
110
+ this.newTab(s);
111
+ }
112
+ else if (index < s.activeIndex) {
113
+ // Closing a tab before the active one shifts the array left; keep the
114
+ // same tab active by decrementing the index.
115
+ s.activeIndex -= 1;
116
+ }
117
+ else if (s.activeIndex >= s.tabs.length) {
118
+ // The active tab itself was closed; activate the last remaining one.
119
+ s.activeIndex = s.tabs.length - 1;
120
+ }
121
+ this.showActive(s);
122
+ return Promise.resolve();
123
+ }
124
+ /** Close every tab and reset to one blank tab. */
125
+ reset(session) {
126
+ const s = this.session(session);
127
+ for (const tab of s.tabs)
128
+ this.host.destroyView(tab.handle);
129
+ s.tabs.length = 0;
130
+ this.newTab(s);
131
+ s.activeIndex = 0;
132
+ this.showActive(s);
133
+ return Promise.resolve();
134
+ }
135
+ /** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
136
+ async navigate(session, request, signal) {
137
+ const { handle } = this.activeTab(this.session(session));
138
+ const url = request.url;
139
+ if (this.httpOnly) {
140
+ let parsed;
141
+ try {
142
+ parsed = new URL(url);
143
+ }
144
+ catch {
145
+ throw new BrowserError(`browser: refusing navigation to unparseable URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
146
+ }
147
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
148
+ throw new BrowserError(`browser: refusing navigation to non-HTTP(S) URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
149
+ }
150
+ }
151
+ signal?.throwIfAborted();
152
+ await handle.sendCommand(CDP_PAGE_NAVIGATE, { url });
153
+ }
154
+ /** Execute JS in the active tab's page context. */
155
+ async execute(session, request, signal) {
156
+ const { handle } = this.activeTab(this.session(session));
157
+ signal?.throwIfAborted();
158
+ try {
159
+ // Wrap the script in a Function so `return` statements are legal and
160
+ // request.args arrive as `arguments[0..n]`. A bare script handed to CDP
161
+ // Runtime.evaluate is an expression context — a leading `return` would
162
+ // be a syntax error, and an object-literal script (`{...}`) would parse
163
+ // as a block. So: if the script already starts with `return`, use it as
164
+ // the body verbatim; otherwise wrap it as `return (expr)` so both
165
+ // expression and object-literal forms evaluate to their value. Args are
166
+ // embedded as a JSON array literal; unserializable members become null.
167
+ const body = /^\s*return\b/.test(request.script)
168
+ ? request.script
169
+ : `return (${request.script})`;
170
+ const hasArgs = request.args !== undefined && request.args.length > 0;
171
+ const expression = hasArgs
172
+ ? `(function(){ const __dshArgs = ${JSON.stringify(request.args)}; return Function(${JSON.stringify(body)}).apply(null, __dshArgs) })()`
173
+ : `(function(){ return Function(${JSON.stringify(body)})() })()`;
174
+ const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
175
+ expression,
176
+ returnByValue: true,
177
+ awaitPromise: true,
178
+ });
179
+ if (result.exceptionDetails !== undefined) {
180
+ const detail = result.exceptionDetails;
181
+ return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' };
182
+ }
183
+ return { ok: true, value: result.result?.value ?? null };
184
+ }
185
+ catch (error) {
186
+ throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error });
187
+ }
188
+ }
189
+ /** Produce an AI-friendly snapshot of the active tab. */
190
+ async snapshot(session, signal) {
191
+ const s = this.session(session);
192
+ const tab = this.activeTab(s);
193
+ signal?.throwIfAborted();
194
+ const script = `(() => {
195
+ const cap = ${String(this.snapshotMaxElements)}
196
+ const url = location.href
197
+ const title = document.title || undefined
198
+ const els = [...document.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [contenteditable="true"]')]
199
+ const out = []
200
+ for (const el of els) {
201
+ if (out.length >= cap) break
202
+ const r = el.getBoundingClientRect()
203
+ const cs = getComputedStyle(el)
204
+ if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
205
+ const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'input'))
206
+ : el.tagName === 'TEXTAREA' ? 'textarea'
207
+ : el.tagName === 'SELECT' ? 'select'
208
+ : el.tagName === 'BUTTON' ? 'button'
209
+ : el.tagName === 'A' ? 'link' : 'other'
210
+ const label = (el.getAttribute('aria-label') || el.placeholder || el.textContent || el.value || el.name || el.id || '').toString().replace(/\\s+/g, ' ').trim().slice(0, ${String(SNAPSHOT_LABEL_MAX)})
211
+ if (!label && kind !== 'link') continue
212
+ out.push({
213
+ ref: out.length + 1,
214
+ kind,
215
+ label,
216
+ selector: el.id ? '#' + el.id : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
217
+ x: Math.round(r.x + r.width / 2),
218
+ y: Math.round(r.y + r.height / 2),
219
+ })
220
+ }
221
+ return { url, title, elements: out, truncated: out.length >= cap }
222
+ })()`;
223
+ const result = await handleSendEvaluate(tab.handle, script);
224
+ if (!result.ok)
225
+ throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED');
226
+ const value = result.value;
227
+ return value;
228
+ }
229
+ /** Fetch page content in a requested format. */
230
+ async content(session, request, signal) {
231
+ const tab = this.activeTab(this.session(session));
232
+ signal?.throwIfAborted();
233
+ const maxChars = request.maxChars ?? this.contentMaxChars;
234
+ const selector = request.selector ?? '';
235
+ const format = request.format;
236
+ const script = `(() => {
237
+ const root = ${selector === '' ? 'document.body' : `document.querySelector(${JSON.stringify(selector)})`}
238
+ if (!root) return { ok: false, reason: 'selector not found' }
239
+ const fmt = ${JSON.stringify(format)}
240
+ let content = ''
241
+ if (fmt === 'txt') content = root.innerText || ''
242
+ else if (fmt === 'html') content = root.outerHTML || ''
243
+ else if (fmt === 'json') content = JSON.stringify(root)
244
+ else {
245
+ // markdown: headings, paragraphs, links, lists (best-effort)
246
+ const parts = []
247
+ const walk = (node) => {
248
+ if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
249
+ if (node.nodeType !== Node.ELEMENT_NODE) return
250
+ const tag = node.tagName.toLowerCase()
251
+ if (tag === 'script' || tag === 'style' || tag === 'noscript') return
252
+ if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4') parts.push('\\n' + '#'.repeat(Number(tag[1])) + ' ' + (node.textContent || '').trim() + '\\n')
253
+ else if (tag === 'a') { const t = (node.textContent || '').trim(); if (t) parts.push('[' + t + '](' + (node.href || '') + ')') }
254
+ else if (tag === 'li') parts.push(' - ' + (node.textContent || '').trim())
255
+ else if (tag === 'p' || tag === 'div' || tag === 'section' || tag === 'article') { const t = (node.textContent || '').trim(); if (t) parts.push(t + '\\n') }
256
+ else { for (const child of node.childNodes) walk(child) }
257
+ }
258
+ if (root.nodeType === Node.TEXT_NODE) walk(root)
259
+ else for (const child of root.childNodes) walk(child)
260
+ // Join without a separator: each part already carries its own trailing
261
+ // newline, so a space join would smear headings/links into run-on text.
262
+ content = parts.join('')
263
+ }
264
+ const truncated = content.length > ${String(maxChars)}
265
+ return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
266
+ })()`;
267
+ // Honor a per-call timeout: content evaluation can hang on a heavy page,
268
+ // so a caller-supplied budget bounds it. AbortSignal.any merges the
269
+ // caller's signal with a timeout.
270
+ const timeoutMs = request.timeoutMs ?? 30_000;
271
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
272
+ const result = signal !== undefined
273
+ ? await handleSendEvaluate(tab.handle, script, AbortSignal.any([signal, timeoutSignal]))
274
+ : await handleSendEvaluate(tab.handle, script, timeoutSignal);
275
+ if (!result.ok)
276
+ throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED');
277
+ const value = result.value;
278
+ if (!value.ok)
279
+ throw new BrowserError(`browser: content fetch failed: ${value.reason ?? 'unknown'}`, 'BROWSER_CONTENT_FAILED');
280
+ return { content: value.content ?? '', truncated: value.truncated ?? false };
281
+ }
282
+ /** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
283
+ async click(session, request, signal) {
284
+ const { handle } = this.activeTab(this.session(session));
285
+ signal?.throwIfAborted();
286
+ await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x: request.x, y: request.y, button: 'left', clickCount: 1 });
287
+ await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x: request.x, y: request.y, button: 'left', clickCount: 1 });
288
+ }
289
+ /** Type into the focused element. */
290
+ async type(session, request, signal) {
291
+ const { handle } = this.activeTab(this.session(session));
292
+ signal?.throwIfAborted();
293
+ await handle.sendCommand('Input.insertText', { text: request.text });
294
+ }
295
+ /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
296
+ async screenshot(session, request, signal) {
297
+ const { handle } = this.activeTab(this.session(session));
298
+ signal?.throwIfAborted();
299
+ const params = {};
300
+ if (request?.fullPage === true) {
301
+ // `captureBeyondViewport` captures the full scrollable content; without
302
+ // a clip this yields the full-page image (CDP default is the viewport).
303
+ params.captureBeyondViewport = true;
304
+ }
305
+ const result = await handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params);
306
+ const data = result.data;
307
+ if (typeof data !== 'string') {
308
+ throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
309
+ }
310
+ return { dataUrl: `data:image/png;base64,${data}` };
311
+ }
312
+ /** Close the session and destroy all its views. Idempotent. */
313
+ close(session) {
314
+ const existing = this.sessions.get(session);
315
+ if (existing !== undefined) {
316
+ this.sessions.delete(session);
317
+ for (const tab of existing.tabs)
318
+ this.host.destroyView(tab.handle);
319
+ }
320
+ return Promise.resolve();
321
+ }
322
+ /** Look up a session or throw the unknown-session error. */
323
+ session(session) {
324
+ const existing = this.sessions.get(session);
325
+ if (existing === undefined) {
326
+ throw new BrowserError(`browser: session "${session}" is not open`, 'BROWSER_SESSION_UNKNOWN');
327
+ }
328
+ return existing;
329
+ }
330
+ /** The active tab of a session. */
331
+ activeTab(s) {
332
+ const tab = s.tabs[s.activeIndex];
333
+ if (tab === undefined)
334
+ throw new BrowserError('browser: session has no active tab', 'BROWSER_TAB_UNKNOWN');
335
+ return tab;
336
+ }
337
+ /** Append a fresh tab and make it active. */
338
+ newTab(s) {
339
+ const handle = this.host.createView();
340
+ s.tabs.push({ id: `tab:${randomUUID()}`, handle });
341
+ s.activeIndex = s.tabs.length - 1;
342
+ this.showActive(s);
343
+ }
344
+ /** Ask the host to show the active tab's view. */
345
+ showActive(s) {
346
+ this.host.showView?.(this.activeTab(s).handle);
347
+ }
348
+ /** Read the current URL of a view through CDP. */
349
+ async currentUrl(handle) {
350
+ const result = await handleSendEvaluate(handle, 'location.href');
351
+ return result.ok && typeof result.value === 'string' ? result.value : '';
352
+ }
353
+ }
354
+ /**
355
+ * Run a `Runtime.evaluate` through a view handle and normalize the result.
356
+ * Shared by execute, snapshot, content, and internal URL reads.
357
+ * @param handle - the view handle to evaluate in.
358
+ * @param expression - the JS expression.
359
+ * @param signal - optional abort signal; a fired signal rejects the call.
360
+ */
361
+ async function handleSendEvaluate(handle, expression, signal) {
362
+ signal?.throwIfAborted();
363
+ const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
364
+ expression,
365
+ returnByValue: true,
366
+ awaitPromise: true,
367
+ });
368
+ if (result.exceptionDetails !== undefined) {
369
+ const detail = result.exceptionDetails;
370
+ return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' };
371
+ }
372
+ return { ok: true, value: result.result?.value ?? null };
373
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * dsh-browser plugin entry: aggregates the shared-browser capability pieces.
3
+ * The cordis.patch.yml rows reference subpath exports:
4
+ * - `dsh-browser/browser` -> the ctx.browser seam (Service Definition)
5
+ * - `dsh-browser/browser-electron` -> the Electron CDP provider
6
+ * - `dsh-browser/tool-browser` -> the model-facing browser_* tools
7
+ * This root entry only re-exports for programmatic use; the loader rows are
8
+ * the composition surface.
9
+ * @module dsh-browser
10
+ */
11
+ export { BrowserError } from './browser/types.ts';
12
+ export type { BrowserClickRequest, BrowserContentFormat, BrowserContentRequest, BrowserContentResult, BrowserExecuteRequest, BrowserExecuteResult, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserScreenshotRequest, BrowserScreenshotResult, BrowserSessionId, BrowserSnapshotElement, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, } from './browser/types.ts';
13
+ export { BrowserRuntime } from './browser/runtime.ts';
14
+ export { ElectronBrowserProvider } from './browser-electron/provider.ts';
15
+ export type { ElectronBrowserViewHost, ElectronViewHandle } from './browser-electron/provider.ts';
package/lib/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * dsh-browser plugin entry: aggregates the shared-browser capability pieces.
3
+ * The cordis.patch.yml rows reference subpath exports:
4
+ * - `dsh-browser/browser` -> the ctx.browser seam (Service Definition)
5
+ * - `dsh-browser/browser-electron` -> the Electron CDP provider
6
+ * - `dsh-browser/tool-browser` -> the model-facing browser_* tools
7
+ * This root entry only re-exports for programmatic use; the loader rows are
8
+ * the composition surface.
9
+ * @module dsh-browser
10
+ */
11
+ export { BrowserError } from "./browser/types.js";
12
+ export { BrowserRuntime } from "./browser/runtime.js";
13
+ export { ElectronBrowserProvider } from "./browser-electron/provider.js";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Model-facing browser tools over `ctx.browser`: `browser_open`,
3
+ * `browser_snapshot`, `browser_execute`, `browser_content`,
4
+ * `browser_screenshot`, and tab management (`browser_list_tabs`,
5
+ * `browser_switch_tab`, `browser_close_tab`, `browser_reset`).
6
+ *
7
+ * The tool layer owns only the model-facing schema, argument validation, and
8
+ * result formatting 鈥?never provider selection or page driving, which belong
9
+ * to the seam. Session lifecycle is owned here at the plugin level: the
10
+ * first `browser_open` (or any tool when no session exists) opens a session;
11
+ * later tools reuse it. Per-agent isolation is a follow-up.
12
+ * @module dsh-browser/tool-browser
13
+ */
14
+ import type { Context } from '@deepseek-ai/cordis';
15
+ import type { BrowserSessionId } from '../browser/types.ts';
16
+ /** Plugin name used by loader diagnostics. */
17
+ export declare const name = "tool-browser";
18
+ /** The tool registry, browser seam, and system-prompt registry this tool layer consumes. */
19
+ export declare const inject: string[];
20
+ /** Plugin config: tool timeouts and session defaults. */
21
+ export interface Config {
22
+ /** Cooperative tool-call budget in ms. Default 60000. */
23
+ readonly timeoutMs?: number;
24
+ /** Whether to offer tab-management tools. Default true. */
25
+ readonly tabTools?: boolean;
26
+ }
27
+ /** Register all browser tools with `ctx.tools`. */
28
+ export declare function apply(ctx: Context, config?: Config): void;
29
+ /** Test hook: clear the plugin-level session (used by tests and on reset). */
30
+ export declare const internals: {
31
+ readonly session: BrowserSessionId | undefined;
32
+ clearSession(): void;
33
+ };