dsh-builtin-browser 0.1.22 → 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 (38) hide show
  1. package/README.en.md +50 -3
  2. package/README.md +51 -4
  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 +28 -1
  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.d.ts +1 -0
  15. package/lib/browser-electron/host-main.js +88 -1
  16. package/lib/browser-electron/provider.d.ts +105 -4
  17. package/lib/browser-electron/provider.js +226 -14
  18. package/lib/browser-electron/remote-host.d.ts +29 -6
  19. package/lib/browser-electron/remote-host.js +114 -24
  20. package/lib/browser-electron/settings-store.d.ts +87 -0
  21. package/lib/browser-electron/settings-store.js +160 -0
  22. package/lib/browser-electron/virtual-cursor.d.ts +42 -0
  23. package/lib/browser-electron/virtual-cursor.js +71 -0
  24. package/lib/index.d.ts +16 -2
  25. package/lib/index.js +18 -2
  26. package/lib/tool-browser/index.js +72 -2
  27. package/package.json +20 -6
  28. package/src/browser/runtime.ts +37 -1
  29. package/src/browser/types.ts +31 -0
  30. package/src/browser-electron/entry.ts +135 -0
  31. package/src/browser-electron/history-store.ts +162 -0
  32. package/src/browser-electron/host-main.ts +83 -1
  33. package/src/browser-electron/provider.ts +267 -19
  34. package/src/browser-electron/remote-host.ts +114 -21
  35. package/src/browser-electron/settings-store.ts +198 -0
  36. package/src/browser-electron/virtual-cursor.ts +82 -0
  37. package/src/index.ts +20 -2
  38. package/src/tool-browser/index.ts +71 -1
@@ -440,12 +440,28 @@ class ElectronChildClient {
440
440
  private readonly token: string,
441
441
  private readonly onExit?: () => void,
442
442
  private readonly onUserAction?: (action: BrowserUserAction) => void,
443
+ /**
444
+ * Called when the human closes a browser window. The session it showed is
445
+ * over: the provider releases it, while history and login state stay on disk
446
+ * so the next open is a clean session rather than a resurrected one.
447
+ */
448
+ private readonly onViewClosed?: (windowId: string) => void,
443
449
  /** Test seam: the executable to spawn instead of the resolved Electron
444
450
  * binary. Absent -> resolveElectronPath() (production behavior). */
445
451
  private readonly executable?: string,
446
452
  ) {
447
453
  const electron = this.executable ?? resolveElectronPath()
454
+ // Record what is about to be spawned — with existence checks — BEFORE the
455
+ // attempt. When Electron cannot load the app entry it exits 1 with EMPTY
456
+ // stderr, and that signature is otherwise indistinguishable from a missing
457
+ // binary: writing both paths (and whether they exist) in advance is what
458
+ // turns the next occurrence into a named cause instead of a guess.
448
459
  process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`)
460
+ appendHostLog(
461
+ `${hostStamp()} spawning: electron=${electron} (exists=${String(existsSync(electron))})`
462
+ + ` hostMain=${this.hostMainPath} (exists=${String(existsSync(this.hostMainPath))})`
463
+ + ` port=${String(this.port)}\n`,
464
+ )
449
465
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
450
466
  // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
451
467
  // break the child. Rebuild the env without either.
@@ -482,15 +498,26 @@ class ElectronChildClient {
482
498
  // A failed spawn (bad/corrupt binary) emits 'error' — without a listener
483
499
  // that would crash the whole DSH process.
484
500
  this.child.on('error', error => {
485
- const line = `spawn error: ${String(error)}`
501
+ const line = `${hostStamp()} spawn error: ${String(error)} electron=${electron} hostMain=${this.hostMainPath}`
486
502
  process.stderr.write(`[dsh-browser host] ${line}\n`)
487
503
  appendHostLog(`${line}\n`)
488
504
  this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
489
505
  })
490
506
  this.child.on('exit', (code, signal) => {
491
507
  // The exit code/signal is the other half of a crash report; without it a
492
- // restart loop is indistinguishable from a clean stop.
493
- const line = `browser host exited (code=${String(code)} signal=${String(signal)})`
508
+ // restart loop is indistinguishable from a clean stop. The timestamp, pid
509
+ // and both paths are what turn a bare `code=1` into a diagnosis: Electron
510
+ // exits 1 with empty stderr precisely when it cannot load the app entry,
511
+ // so the dated `exists=` line written at spawn is the other half.
512
+ const entryExists = existsSync(this.hostMainPath)
513
+ const line = `${hostStamp()} browser host exited (code=${String(code)} signal=${String(signal)})`
514
+ + ` pid=${String(this.child?.pid)} electron=${electron} hostMain=${this.hostMainPath}`
515
+ + ` entryExists=${String(entryExists)}`
516
+ // Comparing this with the `exists=` recorded at spawn is the whole
517
+ // diagnosis: present at spawn and absent now means the installation was
518
+ // replaced underneath a running host, which is exactly how Electron ends
519
+ // up unable to load the app entry and exits 1 with empty stderr.
520
+ + (entryExists ? '' : ' [entry script missing at exit]')
494
521
  appendHostLog(`${line}\n`)
495
522
  this.fail(new Error(`dsh-builtin-browser: ${line}`))
496
523
  })
@@ -566,7 +593,7 @@ class ElectronChildClient {
566
593
  const line = this.buffer.slice(0, nl).trim()
567
594
  this.buffer = this.buffer.slice(nl + 1)
568
595
  if (line === '') continue
569
- let msg: { id?: number; op?: string; token?: string; ok?: boolean; result?: unknown; err?: string; action?: unknown }
596
+ let msg: { id?: number; op?: string; token?: string; ok?: boolean; result?: unknown; err?: string; action?: unknown; windowId?: unknown }
570
597
  try {
571
598
  msg = JSON.parse(line) as typeof msg
572
599
  } catch {
@@ -580,6 +607,14 @@ class ElectronChildClient {
580
607
  this.onUserAction?.(msg.action as BrowserUserAction)
581
608
  continue
582
609
  }
610
+ if (msg.op === 'viewClosed') {
611
+ // Fire-and-forget notification from the child's own UI: the human closed
612
+ // the window, so the interface that session showed is gone. The provider
613
+ // decides what that ends — browsing history and login state survive on
614
+ // disk, only the session itself does not.
615
+ this.onViewClosed?.(typeof msg.windowId === 'string' ? msg.windowId : '')
616
+ continue
617
+ }
583
618
  if (this.awaitingHello) {
584
619
  // The FIRST line must be the child's hello carrying the spawn token.
585
620
  // Anything else (or a wrong token) means a spoofed connection: drop
@@ -657,6 +692,11 @@ class RemoteView implements ElectronViewHandle {
657
692
  })
658
693
  }
659
694
 
695
+ /** Give this view web focus in the child, so keyboard input reaches the page. */
696
+ focus(): Promise<void> {
697
+ return this.client.call<void>('focus', { viewId: this.id })
698
+ }
699
+
660
700
  /** Ask the child to download a URL to a local file (keeps cookies/login). */
661
701
  async download(url: string, savePath: string): Promise<void> {
662
702
  // The child writes the file itself (temp + rename); only a small
@@ -681,6 +721,15 @@ class RemoteView implements ElectronViewHandle {
681
721
  }
682
722
  }
683
723
 
724
+ /**
725
+ * How long a FAILED Electron-binary probe is remembered before the binary
726
+ * search runs again (`available()`). A success is kept for the host's lifetime.
727
+ */
728
+ const PROBE_RETRY_MS = (() => {
729
+ const raw = Number(process.env.DSH_BROWSER_PROBE_RETRY_MS)
730
+ return Number.isFinite(raw) && raw >= 0 ? raw : 30000
731
+ })()
732
+
684
733
  /**
685
734
  * Self-hosted view host: spawns the plugin's Electron child on first use and
686
735
  * keeps it alive until dispose(). Fallback when no desktop shell provides
@@ -693,40 +742,59 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
693
742
  private readonly views = new Map<string, ElectronViewHandle>()
694
743
  private readyPromise: Promise<void> | undefined
695
744
  private disposed = false
696
- /** Cached probe result so `available()` stays cheap after the first call. */
745
+ /** Last probe result. A success is kept for the host's lifetime; a failure
746
+ * is retried after `PROBE_RETRY_MS` (see `available()`). */
697
747
  private electronAvailable: boolean | undefined
748
+ /** Earliest time the next probe may run after a negative one. */
749
+ private nextProbeAt = 0
698
750
  /** Window groups (windowId per view), re-sent on every materialization so
699
751
  * a restarted child still places views in the right windows. */
700
752
  private readonly groups = new Map<string, { windowId: string; label?: string }>()
701
753
  /** The provider's user-action handler; routes toolbar actions into sessions. */
702
754
  private userActionHandler: ((action: BrowserUserAction) => void) | undefined
755
+ /**
756
+ * The provider's handler for a window the human closed. Closing the interface
757
+ * ends the session it showed: history and login state stay on disk, so the
758
+ * next open starts clean instead of resuming a window nobody can see.
759
+ */
760
+ private viewClosedHandler: ((windowId: string) => void) | undefined
703
761
 
704
762
  constructor(
705
763
  private readonly hostMainPath: string,
706
764
  /** Test seam: the executable to spawn instead of the resolved Electron
707
765
  * binary. Absent -> resolveElectronPath() (production behavior). */
708
766
  private readonly spawnExecutable?: string,
767
+ /** Test seam: the binary probe behind `available()`. Absent ->
768
+ * resolveElectronPath(). */
769
+ private readonly probe: () => void = resolveElectronPath,
709
770
  ) {}
710
771
 
711
772
  /**
712
773
  * Cheap usability probe: can we find an Electron binary to spawn? The scan
713
- * is filesystem-only (no network), per the seam's contract, and the result
714
- * is cached for the host's lifetime — a missing binary surfaces as
715
- * `BROWSER_PROVIDER_UNAVAILABLE` at provider selection instead of a
716
- * confusing spawn failure on first use.
774
+ * is filesystem-only (no network), per the seam's contract, so a missing
775
+ * binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
776
+ * instead of a confusing spawn failure on first use.
777
+ *
778
+ * A success is cached for the host's lifetime. A failure is NOT: an install
779
+ * (`npm i electron`, or the postinstall that fetches the binary) can land
780
+ * long after DSH started, and provider selection happens once per process —
781
+ * caching "no" permanently reported "no usable browser provider is
782
+ * registered" until DSH restarted, with no way back short of that. Retrying
783
+ * on a cooldown heals it by itself while keeping the scan off the hot path.
717
784
  */
718
785
  available(): boolean {
719
- if (this.electronAvailable === undefined) {
720
- try {
721
- resolveElectronPath()
722
- this.electronAvailable = true
723
- } catch (error) {
724
- this.electronAvailable = false
725
- // The provider will be reported unavailable, so the detailed
726
- // resolution error (install-electron / ELECTRON_PATH guidance) would
727
- // otherwise never reach the user — surface it on stderr.
728
- process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`)
729
- }
786
+ if (this.electronAvailable === true) return true
787
+ if (Date.now() < this.nextProbeAt) return false
788
+ this.nextProbeAt = Date.now() + PROBE_RETRY_MS
789
+ try {
790
+ this.probe()
791
+ this.electronAvailable = true
792
+ } catch (error) {
793
+ this.electronAvailable = false
794
+ // The provider will be reported unavailable, so the detailed
795
+ // resolution error (install-electron / ELECTRON_PATH guidance) would
796
+ // otherwise never reach the user — surface it on stderr.
797
+ process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`)
730
798
  }
731
799
  return this.electronAvailable
732
800
  }
@@ -799,6 +867,7 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
799
867
  token,
800
868
  () => this.onChildExit(),
801
869
  action => this.userActionHandler?.(action),
870
+ windowId => this.viewClosedHandler?.(windowId),
802
871
  this.spawnExecutable,
803
872
  )
804
873
  if (this.pendingSocket !== undefined) {
@@ -932,6 +1001,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
932
1001
  this.userActionHandler = handler
933
1002
  }
934
1003
 
1004
+ /** Register the provider's handler for windows the human closed. */
1005
+ onViewClosed(handler: (windowId: string) => void): void {
1006
+ this.viewClosedHandler = handler
1007
+ }
1008
+
935
1009
  /** Surface a failed user action to the child's toolbar (address bar etc.). */
936
1010
  notifyUserActionError(windowId: string, message: string): void {
937
1011
  void this.ready()
@@ -1027,6 +1101,10 @@ class DeferredRemoteView implements ElectronViewHandle {
1027
1101
  return this.withRecovery(view => view.capture(opts))
1028
1102
  }
1029
1103
 
1104
+ async focus(): Promise<void> {
1105
+ return this.withRecovery(view => view.focus())
1106
+ }
1107
+
1030
1108
  async flushAuth(): Promise<ExportedCookie[]> {
1031
1109
  return this.withRecovery(view => view.flushAuth())
1032
1110
  }
@@ -1049,6 +1127,16 @@ class DeferredRemoteView implements ElectronViewHandle {
1049
1127
  */
1050
1128
  const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024
1051
1129
 
1130
+ /**
1131
+ * ISO timestamp prefix for host-log lines. This log is the only place a spawn
1132
+ * failure is recorded, and an undated line cannot be lined up with anything else
1133
+ * that happened on the machine — a plugin update replacing the entry script, for
1134
+ * instance — which is precisely the question this log gets asked.
1135
+ */
1136
+ function hostStamp(): string {
1137
+ return new Date().toISOString()
1138
+ }
1139
+
1052
1140
  function appendHostLog(text: string): void {
1053
1141
  const home = process.env.DSH_HOME
1054
1142
  if (home === undefined || home === '') return
@@ -1056,7 +1144,12 @@ function appendHostLog(text: string): void {
1056
1144
  const dir = join(home, 'logs')
1057
1145
  const file = join(dir, 'dsh-builtin-browser-host.log')
1058
1146
  try {
1059
- if (statSync(file).size > HOST_LOG_MAX_BYTES) writeFileSync(file, '')
1147
+ if (statSync(file).size > HOST_LOG_MAX_BYTES) {
1148
+ // Discard the old bytes but leave a dated marker: truncating to empty
1149
+ // makes a rotation look like "nothing ever happened", which is how the
1150
+ // history of a crash loop gets silently swallowed.
1151
+ writeFileSync(file, `${hostStamp()} log rotated: previous content exceeded ${String(HOST_LOG_MAX_BYTES)} bytes and was discarded\n`)
1152
+ }
1060
1153
  } catch { /* first write, or unreadable: just append */ }
1061
1154
  mkdirSync(dir, { recursive: true })
1062
1155
  appendFileSync(file, text)
@@ -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.',