dsh-builtin-browser 0.1.23 → 0.2.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.
Files changed (37) hide show
  1. package/README.en.md +45 -2
  2. package/README.md +46 -3
  3. package/client.js +298 -0
  4. package/cordis.patch.yml +24 -5
  5. package/docs/README.md +1 -1
  6. package/docs/architecture.md +1 -1
  7. package/docs/tool-reference.md +2 -1
  8. package/lib/browser/runtime.d.ts +18 -1
  9. package/lib/browser/runtime.js +19 -0
  10. package/lib/browser/types.d.ts +33 -0
  11. package/lib/browser-electron/entry.js +115 -0
  12. package/lib/browser-electron/history-store.d.ts +71 -0
  13. package/lib/browser-electron/history-store.js +151 -0
  14. package/lib/browser-electron/host-main.js +28 -1
  15. package/lib/browser-electron/provider.d.ts +80 -0
  16. package/lib/browser-electron/provider.js +142 -2
  17. package/lib/browser-electron/remote-host.d.ts +8 -0
  18. package/lib/browser-electron/remote-host.js +64 -6
  19. package/lib/browser-electron/settings-store.d.ts +87 -0
  20. package/lib/browser-electron/settings-store.js +160 -0
  21. package/lib/browser-electron/virtual-cursor.d.ts +42 -0
  22. package/lib/browser-electron/virtual-cursor.js +71 -0
  23. package/lib/index.d.ts +16 -2
  24. package/lib/index.js +18 -2
  25. package/lib/tool-browser/index.js +72 -2
  26. package/package.json +13 -1
  27. package/src/browser/runtime.ts +22 -0
  28. package/src/browser/types.ts +31 -0
  29. package/src/browser-electron/entry.ts +135 -0
  30. package/src/browser-electron/history-store.ts +162 -0
  31. package/src/browser-electron/host-main.ts +26 -1
  32. package/src/browser-electron/provider.ts +166 -2
  33. package/src/browser-electron/remote-host.ts +67 -5
  34. package/src/browser-electron/settings-store.ts +198 -0
  35. package/src/browser-electron/virtual-cursor.ts +82 -0
  36. package/src/index.ts +20 -2
  37. package/src/tool-browser/index.ts +71 -1
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Plugin-owned settings document — the switches the browser settings panel
3
+ * exposes, stored next to the browsing history so both follow the same rules:
4
+ * they survive the browser process and DSH restarts.
5
+ *
6
+ * Every field is optional on disk and falls back to a default, so a hand-edited
7
+ * or older file never breaks startup; an unreadable file is treated as "all
8
+ * defaults" rather than an error.
9
+ * @module dsh-browser/browser-electron/settings-store
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+
16
+ /** Vision/operation strategy the provider prefers when the model can see. */
17
+ export type VisionStrategy = 'auto' | 'nonVisual'
18
+
19
+ /** The full settings document, with every field resolved. */
20
+ export interface BrowserSettings {
21
+ /** Persistent browsing history (visited pages). */
22
+ readonly history: {
23
+ readonly enabled: boolean
24
+ readonly maxEntries: number
25
+ readonly maxAgeDays: number
26
+ }
27
+ /** Login-state retention: cookies and the browser profile. */
28
+ readonly cookies: {
29
+ readonly persist: boolean
30
+ }
31
+ /** Presentation behaviour. */
32
+ readonly ui: {
33
+ /** Auto-expand the side panel once per task, then leave the user alone. */
34
+ readonly autoExpandOnce: boolean
35
+ /** Release the browser when its session ends, instead of waiting for a close. */
36
+ readonly closeWithSession: boolean
37
+ /** Show the synthetic cursor/click feedback while the agent operates. */
38
+ readonly virtualCursor: boolean
39
+ }
40
+ /** Visual vs non-visual operation strategy. */
41
+ readonly vision: {
42
+ readonly strategy: VisionStrategy
43
+ }
44
+ /** Whether the agent may read cookies / export login state. */
45
+ readonly credentials: {
46
+ readonly allowRead: boolean
47
+ }
48
+ }
49
+
50
+ /** Defaults for every switch: the documented out-of-the-box behaviour. */
51
+ export const DEFAULT_SETTINGS: BrowserSettings = {
52
+ history: { enabled: true, maxEntries: 5_000, maxAgeDays: 90 },
53
+ cookies: { persist: true },
54
+ ui: { autoExpandOnce: true, closeWithSession: false, virtualCursor: true },
55
+ vision: { strategy: 'auto' },
56
+ credentials: { allowRead: true },
57
+ }
58
+
59
+ /**
60
+ * Absolute path of the settings file, beside the browser profile and the
61
+ * browsing history.
62
+ * @returns the settings file path.
63
+ */
64
+ export function settingsPath(): string {
65
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
66
+ return join(home, 'dsh-builtin-browser-host', 'settings.json')
67
+ }
68
+
69
+ /** Coerce one boolean field, keeping the default when absent or mistyped. */
70
+ function bool(value: unknown, fallback: boolean): boolean {
71
+ return typeof value === 'boolean' ? value : fallback
72
+ }
73
+
74
+ /** Coerce one positive-integer field, keeping the default when invalid. */
75
+ function count(value: unknown, fallback: number): number {
76
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
77
+ }
78
+
79
+ /** Read one nested section of an unknown document, or an empty object. */
80
+ function section(source: Record<string, unknown>, key: string): Record<string, unknown> {
81
+ const value = source[key]
82
+ return typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
83
+ }
84
+
85
+ /**
86
+ * Resolve a raw settings document (already parsed, shape unknown) into the full
87
+ * document, field by field. Unknown keys are dropped: the file is not a place to
88
+ * smuggle configuration.
89
+ * @param raw - the parsed file contents.
90
+ * @returns the resolved settings.
91
+ */
92
+ export function resolveSettings(raw: unknown): BrowserSettings {
93
+ const source = typeof raw === 'object' && raw !== null ? raw as Record<string, unknown> : {}
94
+ const history = section(source, 'history')
95
+ const cookies = section(source, 'cookies')
96
+ const ui = section(source, 'ui')
97
+ const vision = section(source, 'vision')
98
+ const credentials = section(source, 'credentials')
99
+ return {
100
+ history: {
101
+ enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
102
+ maxEntries: count(history.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
103
+ maxAgeDays: count(history.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
104
+ },
105
+ cookies: { persist: bool(cookies.persist, DEFAULT_SETTINGS.cookies.persist) },
106
+ ui: {
107
+ autoExpandOnce: bool(ui.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
108
+ closeWithSession: bool(ui.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
109
+ virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
110
+ },
111
+ vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
112
+ credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
113
+ }
114
+ }
115
+
116
+ /**
117
+ * File-backed settings with an in-memory snapshot. Reads re-stat the file so an
118
+ * edit from another process (or the settings panel writing through a second
119
+ * code path) is picked up without a restart; writes are atomic enough for a
120
+ * single-writer plugin and never throw at the caller.
121
+ */
122
+ export class SettingsStore {
123
+ private cached: BrowserSettings | undefined
124
+ /** Last observed mtime, used to invalidate the cache on external edits. */
125
+ private cachedMtimeMs = -1
126
+
127
+ /**
128
+ * @param file - settings file path (defaults beside the browser profile).
129
+ */
130
+ constructor(private readonly file: string = settingsPath()) {}
131
+
132
+ /** Path this store reads and writes. */
133
+ path(): string {
134
+ return this.file
135
+ }
136
+
137
+ /**
138
+ * Current settings, re-read when the file changed on disk.
139
+ * @returns the resolved settings document.
140
+ */
141
+ get(): BrowserSettings {
142
+ let mtimeMs = -1
143
+ let text: string | undefined
144
+ try {
145
+ if (existsSync(this.file)) {
146
+ mtimeMs = statSync(this.file).mtimeMs
147
+ text = readFileSync(this.file, 'utf8')
148
+ }
149
+ } catch {
150
+ // Unreadable file: fall through to defaults below.
151
+ mtimeMs = -1
152
+ text = undefined
153
+ }
154
+ if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs) return this.cached
155
+ try {
156
+ this.cached = text === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(text))
157
+ } catch {
158
+ // Malformed JSON: behave as defaults rather than fail startup.
159
+ this.cached = DEFAULT_SETTINGS
160
+ }
161
+ this.cachedMtimeMs = mtimeMs
162
+ return this.cached
163
+ }
164
+
165
+ /**
166
+ * Merge a partial patch into the stored document and persist it.
167
+ * @param patch - the fields to change (unknown keys are ignored).
168
+ * @returns the settings after the merge.
169
+ */
170
+ update(patch: unknown): BrowserSettings {
171
+ const current = this.get()
172
+ const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch))
173
+ try {
174
+ mkdirSync(dirname(this.file), { recursive: true })
175
+ writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`)
176
+ } catch {
177
+ // Keep the merged value in memory even when the write fails.
178
+ }
179
+ this.cached = merged
180
+ this.cachedMtimeMs = Date.now()
181
+ return merged
182
+ }
183
+ }
184
+
185
+ /** One-level-deep merge over the settings sections (patch wins). */
186
+ function deepMerge(base: Record<string, unknown>, patch: unknown): Record<string, unknown> {
187
+ if (typeof patch !== 'object' || patch === null) return base
188
+ const result: Record<string, unknown> = { ...base }
189
+ for (const [key, value] of Object.entries(patch as Record<string, unknown>)) {
190
+ const existing = result[key]
191
+ if (typeof value === 'object' && value !== null && typeof existing === 'object' && existing !== null) {
192
+ result[key] = { ...existing as Record<string, unknown>, ...value as Record<string, unknown> }
193
+ } else {
194
+ result[key] = value
195
+ }
196
+ }
197
+ return result
198
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Synthetic pointer feedback for a human watching the tab: an overlay drawn
3
+ * INSIDE the page, never the real system pointer. Its presence is the "the agent
4
+ * has taken over this tab" signal, so it also covers DOM-level operations that
5
+ * move no pointer at all.
6
+ *
7
+ * Implementation notes that matter:
8
+ * - The overlay is `position: fixed` and positioned from viewport coordinates,
9
+ * which is exactly what the element locator already computes and what
10
+ * `Input.dispatchMouseEvent` consumes.
11
+ * - All styling is inline and the ripple uses the Web Animations API: a
12
+ * `<style>` element would be subject to the page's `style-src` CSP, an
13
+ * injected script is not. Injected page feedback must never be silently
14
+ * dropped by a strict CSP.
15
+ * - Every call is best-effort: feedback is never allowed to fail an operation.
16
+ * @module dsh-browser/browser-electron/virtual-cursor
17
+ */
18
+
19
+ /** What the pointer is doing at the reported point. */
20
+ export type CursorAction = 'move' | 'click'
21
+
22
+ /** Element id of the overlay root, stable so repeat calls update one node. */
23
+ const CURSOR_ID = '__dsh_agent_cursor__'
24
+
25
+ /**
26
+ * The in-page expression that materializes or moves the overlay. Idempotent: the
27
+ * first call creates the nodes, later calls only move them (and pulse).
28
+ * @param x - viewport x in CSS pixels.
29
+ * @param y - viewport y in CSS pixels.
30
+ * @param action - whether to pulse a click ripple at the point.
31
+ * @returns a self-contained expression that evaluates to `true`.
32
+ */
33
+ export function cursorScript(x: number, y: number, action: CursorAction): string {
34
+ return `(() => {
35
+ const id = ${JSON.stringify(CURSOR_ID)}
36
+ let root = document.getElementById(id)
37
+ if (!root) {
38
+ root = document.createElement('div')
39
+ root.id = id
40
+ root.setAttribute('aria-hidden', 'true')
41
+ root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform 120ms ease-out;will-change:transform'
42
+ const svg = '<svg width="22" height="22" viewBox="0 0 22 22" style="position:absolute;left:-2px;top:-2px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))">'
43
+ + '<path d="M4 2 L4 18 L8.4 13.8 L11.2 19.6 L13.8 18.4 L11 12.8 L17 12.6 Z" fill="#ffffff" stroke="#111111" stroke-width="1.4" stroke-linejoin="round"/></svg>'
44
+ const ripple = '<span style="position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid rgba(64,160,255,.9);opacity:0"></span>'
45
+ root.innerHTML = svg + ripple
46
+ ;(document.body || document.documentElement).appendChild(root)
47
+ }
48
+ root.style.transform = 'translate(${x}px, ${y}px)'
49
+ ${action === 'click' ? `const ripple = root.lastElementChild
50
+ if (ripple && typeof ripple.animate === 'function') {
51
+ ripple.animate(
52
+ [{ transform: 'scale(.25)', opacity: 0.95 }, { transform: 'scale(1.6)', opacity: 0 }],
53
+ { duration: 550, easing: 'ease-out' },
54
+ )
55
+ }` : ''}
56
+ return true
57
+ })()`
58
+ }
59
+
60
+ /**
61
+ * Draw (or move) the pointer overlay on one view's page.
62
+ *
63
+ * Fire-and-forget: a page that is mid-navigation, cross-origin-blocked, or
64
+ * otherwise unable to answer must not turn a successful operation into a
65
+ * failure, and the navigation that just committed may still be replacing the
66
+ * document this call would have painted into.
67
+ * @param handle - the view to paint into.
68
+ * @param x - viewport x in CSS pixels.
69
+ * @param y - viewport y in CSS pixels.
70
+ * @param action - whether to pulse a click ripple.
71
+ * @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
72
+ */
73
+ export function paintCursor<TView>(
74
+ handle: TView,
75
+ x: number,
76
+ y: number,
77
+ action: CursorAction,
78
+ evaluate: (handle: TView, expression: string) => Promise<unknown>,
79
+ ): void {
80
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return
81
+ void evaluate(handle, cursorScript(Math.round(x), Math.round(y), action)).catch(() => {})
82
+ }
package/src/index.ts CHANGED
@@ -4,11 +4,29 @@
4
4
  * - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
5
5
  * - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
6
6
  * - `dsh-builtin-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.
7
+ * The capability itself is composed by those rows; this root entry exists for
8
+ * programmatic imports AND because the host's client-module scan identifies a
9
+ * package's browser half through a loader row whose specifier resolves to the
10
+ * PACKAGE ROOT. A row named after a subpath (`dsh-builtin-browser/browser`) is
11
+ * not a package specifier and is skipped by design, which is why the patch also
12
+ * registers a row under the bare package name — see cordis.patch.yml.
9
13
  * @module dsh-builtin-browser
10
14
  */
11
15
 
16
+ /** Plugin identity for the root row (the composition surface is the subpath rows). */
17
+ export const name = 'dsh-builtin-browser'
18
+
19
+ /**
20
+ * Inert application for the root row: the browser seam, the Electron provider and
21
+ * the tools are mounted by the subpath rows, so this one only exists to give the
22
+ * package a loader row named after the package itself. Without it the host's
23
+ * client scan finds no row to resolve `dsh.client` from, and the settings panel
24
+ * never reaches the browser (the plugin works, its client half is invisible).
25
+ */
26
+ export function apply(): void {
27
+ // Intentionally empty: see the module comment above.
28
+ }
29
+
12
30
  export { BrowserError } from './browser/types.js'
13
31
  export type {
14
32
  BrowserA11yNode,
@@ -105,7 +105,12 @@ function taskKey(exec: { agent?: { id?: string } } | undefined): string {
105
105
  */
106
106
  async function ensureSession(browser: NonNullable<Context['browser']>, state: ToolBrowserState, key: string, agent?: ExecAgent): Promise<BrowserSessionId> {
107
107
  const existing = state.sessionsByTask.get(key)
108
- if (existing !== undefined) return existing
108
+ if (existing !== undefined) {
109
+ // The human closing a browser window ends that session, so a cached id can
110
+ // be stale: never hand a dead session back to a tool call.
111
+ if (browser.exists(existing)) return existing
112
+ state.sessionsByTask.delete(key)
113
+ }
109
114
  const pending = state.pendingOpens.get(key)
110
115
  if (pending !== undefined) return pending
111
116
  // The task key rides along as the session label so the window title shows
@@ -1327,6 +1332,71 @@ export function apply(ctx: Context, config: Config = {}): void {
1327
1332
  },
1328
1333
  }))
1329
1334
 
1335
+ ctx.tools.register(defineTool({
1336
+ name: 'browser_visited',
1337
+ description: 'List pages the shared browser has visited, newest first, from the PERSISTENT browsing history: it survives closing the browser and restarting DSH, so it is how you (or the human) find a page again afterwards. Reopen one by passing its url to browser_open. Not to be confused with browser_history, which is only the current session\'s operation log (navigate/click/type) and disappears with the session.',
1338
+ parameters: {
1339
+ limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
1340
+ domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
1341
+ },
1342
+ output: {
1343
+ schema: {
1344
+ type: 'object',
1345
+ additionalProperties: false,
1346
+ properties: {
1347
+ count: { type: 'number', required: true },
1348
+ entries: {
1349
+ type: 'array',
1350
+ required: true,
1351
+ items: {
1352
+ type: 'object',
1353
+ additionalProperties: false,
1354
+ properties: {
1355
+ at: { type: 'number', required: true },
1356
+ url: { type: 'string', required: true },
1357
+ title: { type: 'string' },
1358
+ session: { type: 'string' },
1359
+ },
1360
+ },
1361
+ },
1362
+ },
1363
+ },
1364
+ render: (_args, value) => {
1365
+ const entries = value.entries as Array<{ at: number; url: string; title?: string; session?: string }>
1366
+ if (entries.length === 0) {
1367
+ return [{ type: 'text', text: '(no recorded visits — nothing has been browsed yet, or history recording is turned off in settings)' }]
1368
+ }
1369
+ return [{
1370
+ type: 'text',
1371
+ text: entries.map(entry => {
1372
+ const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0, 19)
1373
+ const title = entry.title !== undefined ? `${entry.title} — ` : ''
1374
+ return `${when} ${title}${entry.url}${entry.session !== undefined ? ` [${entry.session}]` : ''}`
1375
+ }).join('\n'),
1376
+ }]
1377
+ },
1378
+ },
1379
+ timeoutMs,
1380
+ isConcurrencySafe: () => true,
1381
+ async execute(args, _exec) {
1382
+ const browser = ctx.get('browser')
1383
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1384
+ const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30
1385
+ const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)))
1386
+ const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined
1387
+ const entries = browser.visited({ limit, ...domain !== undefined ? { domain } : {} })
1388
+ return {
1389
+ count: entries.length,
1390
+ entries: entries.map(entry => ({
1391
+ at: entry.at,
1392
+ url: entry.url,
1393
+ ...entry.title !== undefined ? { title: entry.title } : {},
1394
+ ...entry.session !== undefined ? { session: entry.session } : {},
1395
+ })),
1396
+ }
1397
+ },
1398
+ }))
1399
+
1330
1400
  ctx.tools.register(defineTool({
1331
1401
  name: 'browser_replay',
1332
1402
  description: 'Replay one recorded browser operation by its history sequence number (from browser_history). Navigate/click/type are re-issued against the current page; execute re-runs its script. The replayed step is appended to history as a new entry.',