dsh-builtin-browser 0.1.1 → 0.1.3

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/cordis.patch.yml CHANGED
@@ -1,19 +1,17 @@
1
1
  # dsh-builtin-browser bundle patch: mounts the shared-browser capability into a
2
- # profile composition. The browser seam (ctx.browser) is always mounted; the
3
- # Electron provider activates only where the host provides `electronViewHost`
4
- # (the desktop shell or any host that supplies a view host through
5
- # `ctx.get('electronViewHost')`), and the model-facing tools ride the same
6
- # gate so plain `dsh web` (no native view) stays unaffected.
2
+ # profile composition. The browser seam (ctx.browser) is always mounted. The
3
+ # Electron provider and browser_* tools are ALWAYS registered: when the host
4
+ # provides `electronViewHost` (the desktop shell) that embedded view is used,
5
+ # and otherwise the plugin self-hosts its own Electron window, so installing
6
+ # the plugin is enough for the browser to work anywhere.
7
7
  - insert:
8
8
  - id: browser
9
9
  name: dsh-builtin-browser/browser
10
10
 
11
11
  - id: browser-electron
12
12
  name: dsh-builtin-browser/browser-electron
13
- disabled: !!js ctx.get('electronViewHost') === undefined
14
13
  config:
15
14
  viewHost: !!js ctx.get('electronViewHost')
16
15
 
17
16
  - id: tool-browser
18
17
  name: dsh-builtin-browser/tool-browser
19
- disabled: !!js ctx.get('electronViewHost') === undefined
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Electron browser provider plugin entry: registers the Electron-backed
3
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')`).
4
+ * Electron `WebContentsView` objects). When a desktop shell supplies
5
+ * `ctx.electronViewHost`, that host is used (embedded, human-machine shared
6
+ * view). Otherwise the plugin self-hosts: it spawns its own Electron child
7
+ * (`host-main.js`) and drives it over a local TCP JSON-RPC socket, so
8
+ * installing the plugin is enough for `browser_*` tools to work on any
9
+ * surface.
7
10
  * @module dsh-browser/browser-electron
8
11
  */
9
12
  import type { Context } from '@deepseek-ai/cordis';
@@ -12,14 +15,15 @@ import type { BrowserRuntime } from '../browser/runtime.ts';
12
15
  import type { ElectronBrowserViewHost } from './provider.ts';
13
16
  export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.ts';
14
17
  export type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts';
18
+ export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.ts';
15
19
  /** Cordis plugin name used by loader diagnostics. */
16
20
  export declare const name = "browser-electron";
17
21
  /** The browser seam this provider registers into. */
18
22
  export declare const inject: string[];
19
- /** Plugin config: the view host supplied by the desktop shell. */
23
+ /** Plugin config: an optional externally-supplied view host. */
20
24
  export interface Config {
21
- /** The Electron view host supplied by the desktop shell. */
22
- readonly viewHost: ElectronBrowserViewHost;
25
+ /** View host supplied by a desktop shell; absent -> self-host. */
26
+ readonly viewHost?: ElectronBrowserViewHost;
23
27
  /** Allow navigation only to HTTP(S) URLs. Default true. */
24
28
  readonly httpOnly?: boolean;
25
29
  }
@@ -1,23 +1,37 @@
1
1
  /**
2
2
  * Electron browser provider plugin entry: registers the Electron-backed
3
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')`).
4
+ * Electron `WebContentsView` objects). When a desktop shell supplies
5
+ * `ctx.electronViewHost`, that host is used (embedded, human-machine shared
6
+ * view). Otherwise the plugin self-hosts: it spawns its own Electron child
7
+ * (`host-main.js`) and drives it over a local TCP JSON-RPC socket, so
8
+ * installing the plugin is enough for `browser_*` tools to work on any
9
+ * surface.
7
10
  * @module dsh-browser/browser-electron
8
11
  */
9
12
  import z from '@deepseek-ai/schemastery';
10
13
  import { ElectronBrowserProvider } from "./provider.js";
14
+ import { defaultHostMainPath, RemoteElectronViewHost } from "./remote-host.js";
11
15
  export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from "./provider.js";
16
+ export { RemoteElectronViewHost, defaultHostMainPath } from "./remote-host.js";
12
17
  /** Cordis plugin name used by loader diagnostics. */
13
18
  export const name = 'browser-electron';
14
19
  /** The browser seam this provider registers into. */
15
20
  export const inject = ['browser'];
16
21
  export const Config = z.object({
22
+ // Absent on surfaces without a desktop shell; the plugin self-hosts then.
17
23
  viewHost: z.any(),
18
24
  httpOnly: z.boolean().default(true),
19
25
  });
20
26
  /** Register the Electron browser provider with `ctx.browser`. */
21
27
  export function apply(ctx, config) {
22
- ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost, { httpOnly: config.httpOnly }));
28
+ // External host (desktop shell) wins; otherwise self-host. The self-hosted
29
+ // child is disposed with the fiber, mirroring the shell's lifetime.
30
+ const host = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath());
31
+ ctx.effect(() => () => {
32
+ if (config.viewHost === undefined && host instanceof RemoteElectronViewHost) {
33
+ host.dispose();
34
+ }
35
+ });
36
+ ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }));
23
37
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Self-hosted Electron browser host (child side): the Electron main process
3
+ * spawned by {@link RemoteElectronViewHost}. Owns one `BrowserWindow` plus
4
+ * `WebContentsView`s and their `webContents.debugger` (CDP), and answers
5
+ * line-delimited JSON-RPC on stdio.
6
+ *
7
+ * Protocol (one JSON object per line, both directions):
8
+ * <- { id, op: 'ping' } | { id, op: 'createView', viewId } |
9
+ * { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
10
+ * { id, op: 'command', viewId, method, params }
11
+ * -> { id, ok: true, result? } | { id, ok: false, err }
12
+ *
13
+ * The parent never parses stderr, so diagnostics may go there freely.
14
+ * @module dsh-browser/browser-electron/host-main
15
+ */
16
+ export {};
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Self-hosted Electron browser host (child side): the Electron main process
3
+ * spawned by {@link RemoteElectronViewHost}. Owns one `BrowserWindow` plus
4
+ * `WebContentsView`s and their `webContents.debugger` (CDP), and answers
5
+ * line-delimited JSON-RPC on stdio.
6
+ *
7
+ * Protocol (one JSON object per line, both directions):
8
+ * <- { id, op: 'ping' } | { id, op: 'createView', viewId } |
9
+ * { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
10
+ * { id, op: 'command', viewId, method, params }
11
+ * -> { id, ok: true, result? } | { id, ok: false, err }
12
+ *
13
+ * The parent never parses stderr, so diagnostics may go there freely.
14
+ * @module dsh-browser/browser-electron/host-main
15
+ */
16
+ import { app, BrowserWindow, WebContentsView } from 'electron';
17
+ import { createInterface } from 'node:readline';
18
+ import { createConnection } from 'node:net';
19
+ /** CDP protocol version attached to every view's debugger. */
20
+ const CDP_VERSION = '1.3';
21
+ /** Views by the id the parent assigned at createView time. */
22
+ const views = new Map();
23
+ /** The single browser window; created lazily on first createView. */
24
+ let window;
25
+ /** The RPC socket to the parent; set when the connection is established. */
26
+ let rpcSocket;
27
+ /** Reply to the parent over the RPC socket. */
28
+ function reply(id, payload) {
29
+ if (rpcSocket === undefined) {
30
+ process.stderr.write(`[dsh-browser host] reply without socket (id=${id})\n`);
31
+ return;
32
+ }
33
+ rpcSocket.write(JSON.stringify({ id, ...payload }) + '\n');
34
+ }
35
+ /** Handle one command. */
36
+ async function handle(op, msg) {
37
+ try {
38
+ switch (op) {
39
+ case 'ping':
40
+ reply(msg.id, { ok: true });
41
+ return;
42
+ case 'createView': {
43
+ const viewId = msg.viewId;
44
+ if (viewId === undefined)
45
+ throw new Error('createView missing viewId');
46
+ if (window === undefined) {
47
+ window = new BrowserWindow({ width: 1400, height: 900, show: true, title: 'dsh-browser' });
48
+ window.on('closed', () => { window = undefined; });
49
+ }
50
+ const view = new WebContentsView();
51
+ view.setVisible(false);
52
+ window.contentView.addChildView(view);
53
+ const [width, height] = window.getContentSize();
54
+ view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 });
55
+ view.setVisible(true);
56
+ view.webContents.debugger.attach(CDP_VERSION);
57
+ views.set(viewId, { webContentsView: view });
58
+ reply(msg.id, { ok: true });
59
+ return;
60
+ }
61
+ case 'destroyView': {
62
+ const viewId = msg.viewId;
63
+ if (viewId === undefined)
64
+ throw new Error('destroyView missing viewId');
65
+ const entry = views.get(viewId);
66
+ if (entry !== undefined) {
67
+ views.delete(viewId);
68
+ try {
69
+ entry.webContentsView.webContents.debugger.detach();
70
+ }
71
+ catch { /* already detached */ }
72
+ entry.webContentsView.webContents.close();
73
+ window?.contentView.removeChildView(entry.webContentsView);
74
+ }
75
+ reply(msg.id, { ok: true });
76
+ return;
77
+ }
78
+ case 'showView': {
79
+ const viewId = msg.viewId;
80
+ if (viewId === undefined)
81
+ throw new Error('showView missing viewId');
82
+ const entry = views.get(viewId);
83
+ if (entry !== undefined)
84
+ entry.webContentsView.setVisible(true);
85
+ reply(msg.id, { ok: true });
86
+ return;
87
+ }
88
+ case 'command': {
89
+ const viewId = msg.viewId;
90
+ if (viewId === undefined)
91
+ throw new Error('command missing viewId');
92
+ const entry = views.get(viewId);
93
+ if (entry === undefined)
94
+ throw new Error(`command: unknown view ${viewId}`);
95
+ const method = msg.method;
96
+ if (typeof method !== 'string')
97
+ throw new Error('command missing method');
98
+ const result = await entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {});
99
+ reply(msg.id, { ok: true, result });
100
+ return;
101
+ }
102
+ default:
103
+ throw new Error(`unknown op ${op}`);
104
+ }
105
+ }
106
+ catch (error) {
107
+ reply(msg.id, { ok: false, err: String(error) });
108
+ }
109
+ }
110
+ /**
111
+ * Electron entry: connect back to the parent's RPC server (port from
112
+ * `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
113
+ * cleared by the parent so `require('electron')` works; this file is loaded as
114
+ * the app entry so `app` is available immediately.
115
+ */
116
+ void app.whenReady().then(() => {
117
+ const portArg = process.argv.indexOf('--rpc-port');
118
+ const port = portArg >= 0 ? Number(process.argv[portArg + 1]) : NaN;
119
+ if (!Number.isFinite(port)) {
120
+ process.stderr.write('[dsh-browser host] missing --rpc-port\n');
121
+ app.exit(1);
122
+ return;
123
+ }
124
+ const socket = createConnection({ host: '127.0.0.1', port });
125
+ rpcSocket = socket;
126
+ socket.setEncoding('utf8');
127
+ const rl = createInterface({ input: socket });
128
+ rl.on('line', line => {
129
+ const text = line.trim();
130
+ if (text === '')
131
+ return;
132
+ let msg;
133
+ try {
134
+ msg = JSON.parse(text);
135
+ }
136
+ catch {
137
+ return; // non-protocol noise
138
+ }
139
+ if (typeof msg.id !== 'number' || typeof msg.op !== 'string')
140
+ return;
141
+ void handle(msg.op, msg).catch(() => { });
142
+ });
143
+ socket.on('error', error => {
144
+ process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`);
145
+ });
146
+ // Keep the process alive until the parent closes the socket or kills us.
147
+ });
148
+ // Diagnostics go to stderr, which the parent never parses as protocol.
149
+ process.on('uncaughtException', error => {
150
+ process.stderr.write(`[dsh-browser host] uncaught: ${String(error)}\n`);
151
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Self-hosted Electron browser host (parent side): an
3
+ * {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
4
+ * Electron child process (host-main.js) and drives it over line-delimited
5
+ * JSON-RPC on stdio. This is what makes the plugin work on surfaces without a
6
+ * desktop shell's electronViewHost (plain dsh web): installing the plugin is
7
+ * enough — the browser window appears on first use.
8
+ *
9
+ * Protocol (one JSON object per line, both directions):
10
+ * -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
11
+ * { id, op: 'showView', viewId } | { id, op: 'command', viewId, method, params }
12
+ * <- { id, ok: true, result? } | { id, ok: false, err }
13
+ *
14
+ * The child is Electron's main process; host-main.js owns the BrowserWindow,
15
+ * WebContentsViews, and webContents.debugger (CDP).
16
+ * @module dsh-browser/browser-electron/remote-host
17
+ */
18
+ import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts';
19
+ /**
20
+ * Self-hosted view host: spawns the plugin's Electron child on first use and
21
+ * keeps it alive until dispose(). Fallback when no desktop shell provides
22
+ * ctx.electronViewHost.
23
+ */
24
+ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
25
+ private readonly hostMainPath;
26
+ private client;
27
+ private server;
28
+ private pendingSocket;
29
+ private readonly views;
30
+ private readyPromise;
31
+ constructor(hostMainPath: string);
32
+ /** Ensure the child is up and ready (lazy on first use). */
33
+ private ready;
34
+ private start;
35
+ createView(): ElectronViewHandle;
36
+ private ensureView;
37
+ showView(handle: ElectronViewHandle): void;
38
+ destroyView(handle: ElectronViewHandle): void;
39
+ /** Shut the child and the RPC server down. */
40
+ dispose(): void;
41
+ }
42
+ /** Default host-main path relative to this module's build output. */
43
+ export declare function defaultHostMainPath(): string;
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Self-hosted Electron browser host (parent side): an
3
+ * {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
4
+ * Electron child process (host-main.js) and drives it over line-delimited
5
+ * JSON-RPC on stdio. This is what makes the plugin work on surfaces without a
6
+ * desktop shell's electronViewHost (plain dsh web): installing the plugin is
7
+ * enough — the browser window appears on first use.
8
+ *
9
+ * Protocol (one JSON object per line, both directions):
10
+ * -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
11
+ * { id, op: 'showView', viewId } | { id, op: 'command', viewId, method, params }
12
+ * <- { id, ok: true, result? } | { id, ok: false, err }
13
+ *
14
+ * The child is Electron's main process; host-main.js owns the BrowserWindow,
15
+ * WebContentsViews, and webContents.debugger (CDP).
16
+ * @module dsh-browser/browser-electron/remote-host
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import { createRequire } from 'node:module';
20
+ import { existsSync, readdirSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { createServer } from 'node:net';
23
+ import { fileURLToPath } from 'node:url';
24
+ /** How long to wait for the child to signal readiness before failing. */
25
+ const READY_TIMEOUT_MS = 20_000;
26
+ /**
27
+ * Locate the Electron binary, trying, in order:
28
+ * 1. require('electron') from this module (optional peer dependency),
29
+ * 2. require.resolve('electron') against DSH install anchors (global dsh
30
+ * package, npm global root) — desktop-shell environments satisfy this,
31
+ * 3. a pnpm virtual store under common workspace layouts,
32
+ * 4. ELECTRON_PATH (explicit override).
33
+ * @returns the path to the Electron executable.
34
+ */
35
+ function resolveElectronPath() {
36
+ const require = createRequire(import.meta.url);
37
+ // 1. Optional peer dependency.
38
+ try {
39
+ const resolved = require('electron');
40
+ if (typeof resolved === 'string' && resolved.length > 0)
41
+ return resolved;
42
+ }
43
+ catch {
44
+ // continue probing
45
+ }
46
+ // 2. DSH install anchors: the global dsh package and the npm global root
47
+ // both carry electron junctions under pnpm layouts.
48
+ const anchors = [];
49
+ const globalPrefix = process.env.npm_config_prefix ?? process.env.PREFIX;
50
+ if (globalPrefix !== undefined) {
51
+ anchors.push(join(globalPrefix, 'node_modules'));
52
+ anchors.push(join(globalPrefix, 'node_modules', '@deepseek-ai', 'dsh', 'node_modules'));
53
+ }
54
+ if (process.env.DSH_HOME !== undefined) {
55
+ anchors.push(join(process.env.DSH_HOME, 'profiles', 'node_modules'));
56
+ }
57
+ for (const anchor of anchors) {
58
+ try {
59
+ const resolved = require.resolve('electron', { paths: [anchor] });
60
+ if (typeof resolved === 'string' && resolved.length > 0) {
61
+ // electron's index.js is not the binary; find the dist executable
62
+ // next to the package root.
63
+ const exe = electronExeBeside(resolved);
64
+ if (exe !== undefined)
65
+ return exe;
66
+ }
67
+ }
68
+ catch {
69
+ // continue probing
70
+ }
71
+ }
72
+ // 3. pnpm virtual store, walking up from several candidate roots: this
73
+ // module (its real path may differ from where the profile resolves it when
74
+ // installed via a junction/link), the process CWD, and the Node executable
75
+ // dir (DSH CLI typically runs from the workspace root).
76
+ const roots = new Set([
77
+ fileURLToPath(new URL('.', import.meta.url)),
78
+ process.cwd(),
79
+ dirname(process.execPath),
80
+ ]);
81
+ for (const root of roots) {
82
+ let dir = root;
83
+ for (let depth = 0; depth < 8; depth++) {
84
+ const store = join(dir, 'node_modules', '.pnpm');
85
+ if (existsSync(store)) {
86
+ const exe = findElectronExe(store);
87
+ if (exe !== undefined)
88
+ return exe;
89
+ }
90
+ const parent = join(dir, '..');
91
+ if (parent === dir)
92
+ break;
93
+ dir = parent;
94
+ }
95
+ }
96
+ // 4. Explicit override.
97
+ const override = process.env.ELECTRON_PATH;
98
+ if (typeof override === 'string' && override.length > 0 && existsSync(override))
99
+ return override;
100
+ throw new Error('dsh-builtin-browser: cannot locate the Electron binary. Install electron in the host profile ' +
101
+ '(e.g. `dsh plugin --profile web add electron`) or set ELECTRON_PATH to the electron executable.');
102
+ }
103
+ /** From an electron package entry file, find the dist executable beside it. */
104
+ function electronExeBeside(entry) {
105
+ const candidates = [
106
+ join(dirname(entry), 'dist', 'electron.exe'),
107
+ join(dirname(entry), 'dist', 'electron'),
108
+ join(dirname(entry), '..', 'dist', 'electron.exe'),
109
+ join(dirname(entry), '..', 'dist', 'electron'),
110
+ ];
111
+ for (const candidate of candidates) {
112
+ if (existsSync(candidate))
113
+ return candidate;
114
+ }
115
+ return undefined;
116
+ }
117
+ /** dirname without importing node:path's dirname separately. */
118
+ function dirname(p) {
119
+ const i = p.lastIndexOf('/');
120
+ const j = p.lastIndexOf('\\');
121
+ const k = Math.max(i, j);
122
+ return k < 0 ? p : p.slice(0, k);
123
+ }
124
+ /** Search a pnpm virtual store for an Electron dist binary. */
125
+ function findElectronExe(store) {
126
+ const entries = existsSync(store) ? readdirSync(store) : [];
127
+ for (const entry of entries) {
128
+ if (!entry.startsWith('electron@'))
129
+ continue;
130
+ const candidates = [
131
+ join(store, entry, 'node_modules', 'electron', 'dist', 'electron.exe'),
132
+ join(store, entry, 'node_modules', 'electron', 'dist', 'electron'),
133
+ ];
134
+ for (const candidate of candidates) {
135
+ if (existsSync(candidate))
136
+ return candidate;
137
+ }
138
+ }
139
+ return undefined;
140
+ }
141
+ /**
142
+ * Line-delimited JSON-RPC client over a local TCP socket. Electron's main
143
+ * process on Windows does not receive piped stdin, so the parent listens on a
144
+ * loopback port and passes it to the child via `--rpc-port`; the child
145
+ * connects back and speaks the same one-JSON-per-line protocol.
146
+ */
147
+ class ElectronChildClient {
148
+ hostMainPath;
149
+ port;
150
+ child;
151
+ pending = new Map();
152
+ nextId = 1;
153
+ buffer = '';
154
+ socket;
155
+ connected = false;
156
+ outbox = [];
157
+ constructor(hostMainPath, port) {
158
+ this.hostMainPath = hostMainPath;
159
+ this.port = port;
160
+ const electron = resolveElectronPath();
161
+ // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
162
+ // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
163
+ // break the child. Rebuild the env without either.
164
+ const env = { ...process.env };
165
+ delete env.ELECTRON_RUN_AS_NODE;
166
+ delete env.NODE_OPTIONS;
167
+ this.child = spawn(electron, [hostMainPath, '--rpc-port', String(port)], {
168
+ stdio: ['ignore', 'pipe', 'pipe'],
169
+ windowsHide: false,
170
+ env,
171
+ });
172
+ this.child.stderr.setEncoding('utf8');
173
+ this.child.stderr.on('data', chunk => {
174
+ // Diagnostics only; never parse stderr as protocol.
175
+ process.stderr.write(`[dsh-browser host] ${String(chunk)}`);
176
+ });
177
+ this.child.on('exit', (code, signal) => {
178
+ const err = new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`);
179
+ for (const pending of this.pending.values())
180
+ pending.reject(err);
181
+ this.pending.clear();
182
+ });
183
+ }
184
+ /** Accept the child's connection (called by the server). */
185
+ attach(socket) {
186
+ this.socket = socket;
187
+ this.connected = true;
188
+ socket.setEncoding('utf8');
189
+ socket.on('data', chunk => this.onData(chunk));
190
+ socket.on('close', () => {
191
+ this.connected = false;
192
+ const err = new Error('dsh-builtin-browser: browser host connection closed');
193
+ for (const pending of this.pending.values())
194
+ pending.reject(err);
195
+ this.pending.clear();
196
+ });
197
+ // Flush anything queued while disconnected.
198
+ if (this.outbox.length > 0) {
199
+ for (const line of this.outbox)
200
+ socket.write(line + '\n');
201
+ this.outbox = [];
202
+ }
203
+ }
204
+ onData(chunk) {
205
+ this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
206
+ let nl;
207
+ while ((nl = this.buffer.indexOf('\n')) >= 0) {
208
+ const line = this.buffer.slice(0, nl).trim();
209
+ this.buffer = this.buffer.slice(nl + 1);
210
+ if (line === '')
211
+ continue;
212
+ let msg;
213
+ try {
214
+ msg = JSON.parse(line);
215
+ }
216
+ catch {
217
+ // Non-protocol line; ignore.
218
+ continue;
219
+ }
220
+ if (typeof msg.id !== 'number')
221
+ continue;
222
+ const pending = this.pending.get(msg.id);
223
+ if (pending === undefined)
224
+ continue;
225
+ this.pending.delete(msg.id);
226
+ if (msg.ok === true)
227
+ pending.resolve(msg.result);
228
+ else
229
+ pending.reject(new Error(msg.err ?? 'browser host command failed'));
230
+ }
231
+ }
232
+ /** Send one command and await the reply. */
233
+ call(op, payload = {}) {
234
+ const id = this.nextId++;
235
+ const line = JSON.stringify({ id, op, ...payload });
236
+ return new Promise((resolve, reject) => {
237
+ this.pending.set(id, { resolve: resolve, reject });
238
+ if (this.connected && this.socket !== undefined) {
239
+ this.socket.write(line + '\n');
240
+ }
241
+ else {
242
+ // Not connected yet: queue; attach() flushes on the child's arrival.
243
+ this.outbox.push(line);
244
+ }
245
+ });
246
+ }
247
+ /** Terminate the child. */
248
+ kill() {
249
+ try {
250
+ this.socket?.destroy();
251
+ }
252
+ catch { /* already closed */ }
253
+ this.child.kill();
254
+ }
255
+ }
256
+ /** One view in the child: its id, used for every command. */
257
+ class RemoteView {
258
+ id;
259
+ client;
260
+ constructor(id, client) {
261
+ this.id = id;
262
+ this.client = client;
263
+ }
264
+ sendCommand(method, params) {
265
+ return this.client.call('command', {
266
+ viewId: this.id,
267
+ method,
268
+ params: params ?? {},
269
+ });
270
+ }
271
+ }
272
+ /**
273
+ * Self-hosted view host: spawns the plugin's Electron child on first use and
274
+ * keeps it alive until dispose(). Fallback when no desktop shell provides
275
+ * ctx.electronViewHost.
276
+ */
277
+ export class RemoteElectronViewHost {
278
+ hostMainPath;
279
+ client;
280
+ server;
281
+ pendingSocket;
282
+ views = new Map();
283
+ readyPromise;
284
+ constructor(hostMainPath) {
285
+ this.hostMainPath = hostMainPath;
286
+ }
287
+ /** Ensure the child is up and ready (lazy on first use). */
288
+ ready() {
289
+ if (this.readyPromise !== undefined)
290
+ return this.readyPromise;
291
+ this.readyPromise = this.start();
292
+ return this.readyPromise;
293
+ }
294
+ async start() {
295
+ // Listen on an ephemeral loopback port; the child connects back.
296
+ const server = createServer(socket => {
297
+ if (this.client !== undefined)
298
+ this.client.attach(socket);
299
+ else
300
+ this.pendingSocket = socket;
301
+ });
302
+ await new Promise((resolve, reject) => {
303
+ server.once('error', reject);
304
+ server.listen(0, '127.0.0.1', () => resolve());
305
+ });
306
+ const address = server.address();
307
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
308
+ this.server = server;
309
+ this.client = new ElectronChildClient(this.hostMainPath, port);
310
+ if (this.pendingSocket !== undefined) {
311
+ this.client.attach(this.pendingSocket);
312
+ this.pendingSocket = undefined;
313
+ }
314
+ // Wait for the child's connection + readiness ping.
315
+ await withTimeout(this.client.call('ping'), READY_TIMEOUT_MS, 'browser host did not become ready');
316
+ }
317
+ createView() {
318
+ // The seam is synchronous; the provider uses the handle immediately, so
319
+ // commands are deferred until the child is up and the view materialized.
320
+ const id = `view:${Math.random().toString(36).slice(2, 10)}`;
321
+ const view = new DeferredRemoteView(id, () => this.ensureView(id));
322
+ this.views.set(id, view);
323
+ return view;
324
+ }
325
+ async ensureView(id) {
326
+ await this.ready();
327
+ const client = this.client;
328
+ if (client === undefined)
329
+ throw new Error('browser host unavailable');
330
+ await client.call('createView', { viewId: id });
331
+ const view = new RemoteView(id, client);
332
+ this.views.set(id, view);
333
+ return view;
334
+ }
335
+ showView(handle) {
336
+ void this.ready().then(() => this.client?.call('showView', { viewId: handle.id }));
337
+ }
338
+ destroyView(handle) {
339
+ const view = this.views.get(handle.id);
340
+ if (view === undefined)
341
+ return;
342
+ this.views.delete(handle.id);
343
+ void this.ready()
344
+ .then(() => this.client?.call('destroyView', { viewId: handle.id }))
345
+ .catch(() => { });
346
+ }
347
+ /** Shut the child and the RPC server down. */
348
+ dispose() {
349
+ this.client?.kill();
350
+ this.client = undefined;
351
+ this.server?.close();
352
+ this.server = undefined;
353
+ this.readyPromise = undefined;
354
+ this.views.clear();
355
+ }
356
+ }
357
+ /** A view handle that waits for child readiness before issuing commands. */
358
+ class DeferredRemoteView {
359
+ id;
360
+ materialize;
361
+ constructor(id, materialize) {
362
+ this.id = id;
363
+ this.materialize = materialize;
364
+ }
365
+ async sendCommand(method, params) {
366
+ const view = await this.materialize();
367
+ return view.sendCommand(method, params);
368
+ }
369
+ }
370
+ /** Reject a promise if it does not settle within the budget. */
371
+ function withTimeout(promise, ms, message) {
372
+ return new Promise((resolve, reject) => {
373
+ const timer = setTimeout(() => reject(new Error(`${message} (${ms}ms)`)), ms);
374
+ promise.then(value => { clearTimeout(timer); resolve(value); }, error => { clearTimeout(timer); reject(error); });
375
+ });
376
+ }
377
+ /** Default host-main path relative to this module's build output. */
378
+ export function defaultHostMainPath() {
379
+ return fileURLToPath(new URL('./host-main.js', import.meta.url));
380
+ }
@@ -42,7 +42,9 @@ export function apply(ctx, config = {}) {
42
42
  const timeoutMs = config.timeoutMs ?? 60_000;
43
43
  ctx.systemPrompt.section({
44
44
  name: 'tool:browser',
45
- order: 110,
45
+ // Tool guidance band is 100-199; 150 keeps clear of the common 110/120
46
+ // tool sections so ordering does not depend on plugin load sequence.
47
+ order: 150,
46
48
  text: 'Use the browser_* tools to operate a real shared browser the human can see and take over. Locate elements by snapshot reference numbers (browser_snapshot) and drive them with browser_execute (DOM-referenced JS, native setters for framework inputs). browser_screenshot is for visual confirmation, not primary targeting. Keep the human informed of what you are doing on the page.',
47
49
  });
48
50
  ctx.tools.register(defineTool({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Shared real browser capability for DeepSeek Harness: a visible browser the human watches and can take over, driven by the agent over CDP (snapshot/execute/content/tab management); host-side seam, provider, and browser_* tools",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -22,6 +22,9 @@
22
22
  "types": "./lib/tool-browser/index.d.ts",
23
23
  "default": "./lib/tool-browser/index.js"
24
24
  },
25
+ "./host-main": {
26
+ "default": "./lib/browser-electron/host-main.js"
27
+ },
25
28
  "./cordis.patch.yml": "./cordis.patch.yml",
26
29
  "./package.json": "./package.json"
27
30
  },
@@ -63,7 +66,13 @@
63
66
  "peerDependencies": {
64
67
  "@deepseek-ai/cordis": "^4.0.1",
65
68
  "@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
66
- "@deepseek-ai/dsh-tools": "^0.0.1-rc.1"
69
+ "@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
70
+ "electron": ">= 30.0.0"
71
+ },
72
+ "peerDependenciesMeta": {
73
+ "electron": {
74
+ "optional": true
75
+ }
67
76
  },
68
77
  "devDependencies": {
69
78
  "@types/node": "^22.0.0",