@operatingtag/dsh-unclesam 0.0.0-stage → 1.0.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 (67) hide show
  1. package/CHANGELOG.md +893 -0
  2. package/LICENSE +142 -0
  3. package/README.md +354 -2
  4. package/client.js +1099 -0
  5. package/cordis.patch.yml +48 -0
  6. package/icon.svg +17 -0
  7. package/index.js +1411 -0
  8. package/install.cmd +43 -0
  9. package/install.ps1 +311 -0
  10. package/lib/cdp.mjs +247 -0
  11. package/lib/engine.mjs +1412 -0
  12. package/lib/i18n.mjs +141 -0
  13. package/lib/prefs.mjs +101 -0
  14. package/lib/prompt.mjs +518 -0
  15. package/lib/savings.mjs +131 -0
  16. package/locale/ar.json +98 -0
  17. package/locale/az.json +98 -0
  18. package/locale/bg.json +98 -0
  19. package/locale/bn.json +98 -0
  20. package/locale/cs.json +98 -0
  21. package/locale/da.json +98 -0
  22. package/locale/de.json +98 -0
  23. package/locale/el.json +98 -0
  24. package/locale/en.json +98 -0
  25. package/locale/es.json +98 -0
  26. package/locale/fa.json +98 -0
  27. package/locale/fi.json +98 -0
  28. package/locale/fr.json +98 -0
  29. package/locale/he.json +98 -0
  30. package/locale/hi.json +98 -0
  31. package/locale/hr.json +98 -0
  32. package/locale/hu.json +98 -0
  33. package/locale/id.json +98 -0
  34. package/locale/it.json +98 -0
  35. package/locale/ja.json +98 -0
  36. package/locale/ka.json +98 -0
  37. package/locale/kk.json +98 -0
  38. package/locale/km.json +98 -0
  39. package/locale/ko.json +98 -0
  40. package/locale/lt.json +98 -0
  41. package/locale/mr.json +98 -0
  42. package/locale/ms.json +98 -0
  43. package/locale/my.json +98 -0
  44. package/locale/ne.json +98 -0
  45. package/locale/nl.json +98 -0
  46. package/locale/no.json +98 -0
  47. package/locale/pl.json +98 -0
  48. package/locale/pt.json +98 -0
  49. package/locale/ro.json +98 -0
  50. package/locale/ru.json +98 -0
  51. package/locale/si.json +98 -0
  52. package/locale/sk.json +98 -0
  53. package/locale/sl.json +98 -0
  54. package/locale/sr.json +98 -0
  55. package/locale/sv.json +98 -0
  56. package/locale/sw.json +98 -0
  57. package/locale/ta.json +98 -0
  58. package/locale/te.json +98 -0
  59. package/locale/th.json +98 -0
  60. package/locale/tl.json +98 -0
  61. package/locale/tr.json +98 -0
  62. package/locale/uk.json +98 -0
  63. package/locale/ur.json +98 -0
  64. package/locale/uz.json +98 -0
  65. package/locale/vi.json +98 -0
  66. package/locale/zh.json +98 -0
  67. package/package.json +73 -4
package/install.cmd ADDED
@@ -0,0 +1,43 @@
1
+ @echo off
2
+ REM Uncle Sam - DeepSeek Harness plugin installer
3
+ REM
4
+ REM Double-click this file. It runs install.ps1 next to it in a visible console,
5
+ REM so the person can read what happened instead of watching a window flash past.
6
+ REM
7
+ REM Nothing here decides anything: all checks, backups and prompts live in
8
+ REM install.ps1, so there is exactly one place to review what gets written.
9
+
10
+ setlocal
11
+ cd /d "%~dp0"
12
+
13
+ if not exist "install.ps1" (
14
+ echo.
15
+ echo install.ps1 is missing.
16
+ echo Keep this file and install.ps1 in the same folder.
17
+ echo.
18
+ pause
19
+ exit /b 1
20
+ )
21
+
22
+ echo.
23
+ echo Uncle Sam - DeepSeek Harness plugin installer
24
+ echo.
25
+ echo This will look for your DeepSeek Harness profile, back it up,
26
+ echo and add the Uncle Sam plugin to it.
27
+ echo.
28
+
29
+ set /p CONFIRM= Continue? [Y/n]
30
+ if /i "%CONFIRM%"=="n" (
31
+ echo Cancelled. Nothing was changed.
32
+ pause
33
+ exit /b 0
34
+ )
35
+
36
+ REM -ExecutionPolicy Bypass applies to this one process only. It does not change
37
+ REM any machine or user setting, which matters: the installer should not need
38
+ REM permanent permission to run scripts just to install a plugin.
39
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
40
+
41
+ echo.
42
+ echo Finished. Read the output above for the result.
43
+ pause
package/install.ps1 ADDED
@@ -0,0 +1,311 @@
1
+ #Requires -Version 5.1
2
+ <#
3
+ Copyright (c) 2026 Operating Tag. All rights reserved.
4
+ @license CC-BY-NC-ND-4.0
5
+ @author Operating Tag (@operatingtag)
6
+
7
+ .SYNOPSIS
8
+ Uncle Sam installer for DeepSeek Harness.
9
+
10
+ .DESCRIPTION
11
+ Installs the Uncle Sam plugin into a DeepSeek Harness profile.
12
+
13
+ It does the same two things the plugin manager does: link the package into the
14
+ profile, and add the row that tells the harness to load it.
15
+
16
+ Design rules, because this edits somebody's working harness:
17
+
18
+ * Nothing is changed until the profile has been found, checked and backed up.
19
+ * -DryRun prints every action and writes nothing at all.
20
+ * Every write is preceded by a backup of the file it is about to touch.
21
+ * If anything looks wrong it stops and says what, rather than guessing.
22
+
23
+ .PARAMETER Profile
24
+ Profile name to install into. Defaults to "desktop".
25
+
26
+ .PARAMETER DryRun
27
+ Show what would happen, change nothing.
28
+
29
+ .PARAMETER Uninstall
30
+ Remove the plugin from the profile instead of adding it.
31
+
32
+ .EXAMPLE
33
+ .\install.ps1 -DryRun
34
+ .\install.ps1
35
+ .\install.ps1 -Uninstall
36
+
37
+ .NOTES
38
+ @file install.ps1
39
+ @version 3.9.11
40
+ @author Operating Tag (@operatingtag)
41
+ @license CC-BY-NC-ND-4.0
42
+ #>
43
+
44
+ [CmdletBinding()]
45
+ param(
46
+ [string]$Profile = 'desktop',
47
+ [switch]$DryRun,
48
+ [switch]$Uninstall
49
+ )
50
+
51
+ $ErrorActionPreference = 'Stop'
52
+
53
+ $PACKAGE_ID = 'dsh-unclesam'
54
+ $PACKAGE_NAME = '@operatingtag/dsh-unclesam'
55
+ $ROOT = Split-Path -Parent $MyInvocation.MyCommand.Path
56
+
57
+ function Write-Step($text) { Write-Host " $text" }
58
+ function Write-Ok($text) { Write-Host " [ok] $text" -ForegroundColor Green }
59
+ function Write-Warn2($text) { Write-Host " [warn] $text" -ForegroundColor Yellow }
60
+ function Write-Fail($text) { Write-Host " [fail] $text" -ForegroundColor Red }
61
+ function Write-Act($text) {
62
+ if ($DryRun) { Write-Host " [dry] $text" -ForegroundColor Cyan }
63
+ else { Write-Host " [do] $text" -ForegroundColor Cyan }
64
+ }
65
+
66
+ Write-Host ''
67
+ Write-Host ' Uncle Sam - DeepSeek Harness plugin installer' -ForegroundColor White
68
+ Write-Host " $PACKAGE_NAME"
69
+ if ($DryRun) { Write-Host ' DRY RUN - nothing will be written' -ForegroundColor Cyan }
70
+ Write-Host ''
71
+
72
+ # ---------------------------------------------------------------- 1. the files
73
+ Write-Step 'Checking the plugin files'
74
+
75
+ $required = @('package.json', 'index.js', 'client.js', 'cordis.patch.yml')
76
+ $missing = $required | Where-Object { -not (Test-Path (Join-Path $ROOT $_)) }
77
+ if ($missing) {
78
+ Write-Fail "this folder is missing: $($missing -join ', ')"
79
+ Write-Step 'Run the installer from inside the uncle-sam folder.'
80
+ exit 1
81
+ }
82
+
83
+ $manifest = Get-Content (Join-Path $ROOT 'package.json') -Raw | ConvertFrom-Json
84
+ if ($manifest.name -ne $PACKAGE_NAME) {
85
+ Write-Fail "package.json says '$($manifest.name)', expected '$PACKAGE_NAME'"
86
+ exit 1
87
+ }
88
+ Write-Ok "plugin files present, version $($manifest.version)"
89
+
90
+ # ---------------------------------------------------------------- 2. node / pnpm
91
+ Write-Step 'Checking node'
92
+
93
+ $node = Get-Command node -ErrorAction SilentlyContinue
94
+ if (-not $node) {
95
+ Write-Fail 'node was not found on PATH.'
96
+ Write-Step 'Install Node.js 22 or newer from https://nodejs.org and run this again.'
97
+ exit 1
98
+ }
99
+ Write-Ok "node $(& node --version)"
100
+
101
+ $pnpm = Get-Command pnpm -ErrorAction SilentlyContinue
102
+ if (-not $pnpm) {
103
+ Write-Warn2 'pnpm was not found - falling back to npm'
104
+ }
105
+
106
+ # ---------------------------------------------------------------- 3. the profile
107
+ Write-Step 'Looking for the DeepSeek Harness profile'
108
+
109
+ $profilesRoot = Join-Path $env:USERPROFILE '.dsh\profiles'
110
+ if (-not (Test-Path $profilesRoot)) {
111
+ Write-Fail "no profiles folder at $profilesRoot"
112
+ Write-Step 'Is DeepSeek Harness installed and has it been started once?'
113
+ exit 1
114
+ }
115
+
116
+ $profileDir = Join-Path $profilesRoot $Profile
117
+ $profileManifest = Join-Path $profileDir 'package.json'
118
+ $profilePatch = Join-Path $profileDir 'cordis.patch.yml'
119
+
120
+ if (-not (Test-Path $profileDir)) {
121
+ Write-Fail "profile '$Profile' does not exist"
122
+ $available = Get-ChildItem $profilesRoot -Directory | Select-Object -ExpandProperty Name
123
+ Write-Step "available profiles: $($available -join ', ')"
124
+ exit 1
125
+ }
126
+ if (-not (Test-Path $profileManifest)) {
127
+ Write-Fail "profile '$Profile' has no package.json - it may be incomplete"
128
+ exit 1
129
+ }
130
+ if (-not (Test-Path $profilePatch)) {
131
+ Write-Fail "profile '$Profile' has no cordis.patch.yml"
132
+ exit 1
133
+ }
134
+ Write-Ok "profile '$Profile' at $profileDir"
135
+
136
+ # ---------------------------------------------------------------- 4. already there?
137
+ $profileJson = Get-Content $profileManifest -Raw | ConvertFrom-Json
138
+ $alreadyLinked = $false
139
+ if ($profileJson.dependencies -and $profileJson.dependencies.PSObject.Properties.Name -contains $PACKAGE_NAME) {
140
+ $alreadyLinked = $true
141
+ }
142
+
143
+ $patchText = Get-Content $profilePatch -Raw
144
+ $alreadyPatched = $patchText -match [regex]::Escape($PACKAGE_NAME)
145
+
146
+ # ---------------------------------------------------------------- uninstall
147
+ if ($Uninstall) {
148
+ Write-Host ''
149
+ Write-Step 'Removing Uncle Sam from the profile'
150
+
151
+ if (-not $alreadyPatched -and -not $alreadyLinked) {
152
+ Write-Ok 'it is not installed here - nothing to do'
153
+ exit 0
154
+ }
155
+
156
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
157
+ $backup = "$profilePatch.$stamp.bak"
158
+ Write-Act "back up cordis.patch.yml -> $(Split-Path -Leaf $backup)"
159
+ if (-not $DryRun) { Copy-Item $profilePatch $backup -Force }
160
+
161
+ # Remove the entry that names this package.
162
+ #
163
+ # The previous version walked forward from the matching line and stopped at the next
164
+ # top-level dash, which removed the name and config but left the "- insert:" header
165
+ # behind - a half-deleted entry, which is worse than none because the YAML still parses
166
+ # and silently does nothing. This finds the whole block first, then cuts it.
167
+ #
168
+ # A block starts at the nearest line above the package name that begins with "- ", and
169
+ # runs until the next line that begins with "- " at the same or shallower indentation.
170
+ $lines = $patchText -split "`r?`n"
171
+ $remove = New-Object 'bool[]' $lines.Count
172
+
173
+ for ($i = 0; $i -lt $lines.Count; $i++) {
174
+ if ($lines[$i] -notmatch [regex]::Escape($PACKAGE_NAME)) { continue }
175
+
176
+ # walk back to the entry header
177
+ $start = $i
178
+ while ($start -gt 0 -and $lines[$start] -notmatch '^-\s') { $start-- }
179
+ if ($lines[$start] -notmatch '^-\s') { $start = $i }
180
+
181
+ # walk forward to the end of that entry
182
+ $end = $i
183
+ for ($j = $i + 1; $j -lt $lines.Count; $j++) {
184
+ if ($lines[$j] -match '^-\s') { break }
185
+ $end = $j
186
+ }
187
+
188
+ # including the comment lines the installer itself added, just above the entry
189
+ $cStart = $start
190
+ while ($cStart -gt 0 -and $lines[$cStart - 1] -match '^\s*#') { $cStart-- }
191
+
192
+ for ($k = $cStart; $k -le $end; $k++) { $remove[$k] = $true }
193
+ }
194
+
195
+ $kept = New-Object System.Collections.Generic.List[string]
196
+ for ($i = 0; $i -lt $lines.Count; $i++) {
197
+ if (-not $remove[$i]) { $kept.Add($lines[$i]) }
198
+ }
199
+
200
+ # collapse any run of blank lines the removal left behind
201
+ $clean = New-Object System.Collections.Generic.List[string]
202
+ $blank = $false
203
+ foreach ($line in $kept) {
204
+ $isBlank = [string]::IsNullOrWhiteSpace($line)
205
+ if ($isBlank -and $blank) { continue }
206
+ $clean.Add($line)
207
+ $blank = $isBlank
208
+ }
209
+
210
+ $removed = ($remove | Where-Object { $_ }).Count
211
+ Write-Act "remove the plugin entry from cordis.patch.yml ($removed line(s))"
212
+ if (-not $DryRun) {
213
+ ($clean -join "`r`n").TrimEnd() + "`r`n" | Set-Content $profilePatch -Encoding UTF8
214
+ }
215
+
216
+ Write-Act "run pnpm remove $PACKAGE_NAME"
217
+ if (-not $DryRun) {
218
+ Push-Location $profileDir
219
+ try {
220
+ if ($pnpm) { & pnpm remove $PACKAGE_NAME 2>&1 | Out-Null }
221
+ else { & npm remove $PACKAGE_NAME 2>&1 | Out-Null }
222
+ } finally { Pop-Location }
223
+ }
224
+
225
+ Write-Host ''
226
+ Write-Ok 'removed. Restart the harness to unload it.'
227
+ exit 0
228
+ }
229
+
230
+ # ---------------------------------------------------------------- 5. install
231
+ Write-Host ''
232
+
233
+ if ($alreadyLinked -and $alreadyPatched) {
234
+ Write-Ok 'already installed in this profile'
235
+ Write-Step 'Restart the harness if you have not since installing.'
236
+ exit 0
237
+ }
238
+
239
+ # Back up before touching anything.
240
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
241
+ $patchBackup = "$profilePatch.$stamp.bak"
242
+ $manifestBackup = "$profileManifest.$stamp.bak"
243
+
244
+ Write-Step 'Backing up the profile'
245
+ Write-Act "cordis.patch.yml -> $(Split-Path -Leaf $patchBackup)"
246
+ Write-Act "package.json -> $(Split-Path -Leaf $manifestBackup)"
247
+ if (-not $DryRun) {
248
+ Copy-Item $profilePatch $patchBackup -Force
249
+ Copy-Item $profileManifest $manifestBackup -Force
250
+ }
251
+
252
+ Write-Host ''
253
+ Write-Step "Installing into profile '$Profile'"
254
+
255
+ # The package link. pnpm is the harness's own package manager, so it is preferred.
256
+ if (-not $alreadyLinked) {
257
+ Write-Act "link the package into the profile"
258
+ if (-not $DryRun) {
259
+ Push-Location $profileDir
260
+ try {
261
+ $spec = "file:$ROOT"
262
+ if ($pnpm) { & pnpm add $spec 2>&1 | Out-Null } else { & npm install $spec 2>&1 | Out-Null }
263
+ if ($LASTEXITCODE -ne 0) {
264
+ Write-Fail 'the package manager refused the install'
265
+ Write-Step "restore from $(Split-Path -Leaf $manifestBackup) if the profile looks wrong"
266
+ exit 1
267
+ }
268
+ } finally { Pop-Location }
269
+ }
270
+ } else {
271
+ Write-Ok 'package already linked'
272
+ }
273
+
274
+ # The profile row. Appended, so nothing already there is rewritten.
275
+ if (-not $alreadyPatched) {
276
+ $block = @"
277
+
278
+ # Uncle Sam - answers through chat.deepseek.com in a real browser instead of the paid API.
279
+ # Added by install.ps1. Remove with install.ps1 -Uninstall.
280
+ - insert:
281
+ - id: $PACKAGE_ID
282
+ name: '$PACKAGE_NAME'
283
+ config:
284
+ port: 9222
285
+ defaultModel: deepseek-chat
286
+ autoMinimise: true
287
+ "@
288
+ Write-Act 'add the plugin row to cordis.patch.yml'
289
+ if (-not $DryRun) {
290
+ Add-Content -Path $profilePatch -Value $block -Encoding UTF8
291
+ }
292
+ } else {
293
+ Write-Ok 'profile row already present'
294
+ }
295
+
296
+ Write-Host ''
297
+ if ($DryRun) {
298
+ Write-Host ' Dry run finished. Nothing was written.' -ForegroundColor Cyan
299
+ Write-Host ' Run again without -DryRun to install.' -ForegroundColor Cyan
300
+ } else {
301
+ Write-Ok 'installed'
302
+ Write-Host ''
303
+ Write-Step 'Next:'
304
+ Write-Step ' 1. Restart DeepSeek Harness'
305
+ Write-Step ' 2. Open Settings -> Uncle Sam'
306
+ Write-Step ' 3. Click "Sign in / open chat"'
307
+ Write-Step ''
308
+ Write-Step "To remove: .\install.ps1 -Uninstall"
309
+ Write-Step "Backups: $patchBackup"
310
+ }
311
+ Write-Host ''
package/lib/cdp.mjs ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * A minimal Chrome DevTools Protocol client with no dependencies.
3
+ *
4
+ * @file cdp.mjs
5
+ * @version 3.9.11
6
+ * @author Operating Tag (@operatingtag)
7
+ * @license CC-BY-NC-ND-4.0
8
+ *
9
+ * WHY THIS IS HAND-WRITTEN
10
+ * Every other route to the browser has a cost: puppeteer-core drags a 3 MB dependency tree into a
11
+ * plugin that otherwise needs nothing, and shelling out to a helper process adds an IPC hop to the
12
+ * hot path of every single token. Node 24 ships a global `WebSocket` and a global `fetch`, which is
13
+ * exactly the two things CDP needs, so the whole client is a couple of hundred lines and the token
14
+ * stream never leaves this process.
15
+ *
16
+ * WHAT CDP ACTUALLY IS
17
+ * A WebSocket to a page, carrying JSON messages: `{id, method, params}` out, `{id, result}` or
18
+ * `{method, params}` back. Requests are matched to replies by `id`; events arrive unasked and are
19
+ * dispatched to listeners. That is the entire protocol surface used here.
20
+ *
21
+ * WHY EVERY CALL HAS A TIMEOUT
22
+ * A CDP socket that half-dies leaves a promise pending forever. In an LLM adapter that becomes a
23
+ * hung model call with no error, which is the worst failure shape: the user watches a spinner and
24
+ * nothing is logged. Every await here is bounded and rejects with a named timeout instead.
25
+ */
26
+
27
+ const DEFAULT_TIMEOUT = 30000;
28
+
29
+ export class CdpTimeout extends Error {
30
+ constructor(method, ms) {
31
+ super(`CDP ${method} timed out after ${ms}ms`);
32
+ this.name = 'CdpTimeout';
33
+ this.method = method;
34
+ }
35
+ }
36
+
37
+ export class Cdp {
38
+ #ws;
39
+ #nextId = 1;
40
+ #pending = new Map();
41
+ #listeners = new Map();
42
+ #closed = false;
43
+
44
+ constructor(ws) {
45
+ this.#ws = ws;
46
+ ws.addEventListener('message', (ev) => this.#onMessage(ev));
47
+ ws.addEventListener('close', () => this.#onClose());
48
+ ws.addEventListener('error', () => this.#onClose());
49
+ }
50
+
51
+ static async #list(port) {
52
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`, {
53
+ signal: AbortSignal.timeout(5000),
54
+ });
55
+ if (!res.ok) throw new Error(`CDP /json/list returned ${res.status}`);
56
+ return res.json();
57
+ }
58
+
59
+ /** Every page target currently exposed on a debugging port. */
60
+ static async targets(port) {
61
+ const all = await Cdp.#list(port);
62
+ return all.filter((t) => t.type === 'page' && t.webSocketDebuggerUrl);
63
+ }
64
+
65
+ /** Find a page whose URL matches, or the first page at all. */
66
+ static async findTarget(port, match) {
67
+ const pages = await Cdp.targets(port);
68
+ if (pages.length === 0) return undefined;
69
+ if (!match) return pages[0];
70
+ return (
71
+ pages.find((p) => String(p.url || '').includes(match)) ??
72
+ pages.find((p) => String(p.url || '').startsWith('http')) ??
73
+ pages[0]
74
+ );
75
+ }
76
+
77
+ static async open(port) {
78
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
79
+ signal: AbortSignal.timeout(5000),
80
+ });
81
+ if (!res.ok) throw new Error(`CDP /json/version returned ${res.status}`);
82
+ return res.json();
83
+ }
84
+
85
+ /** Connect to a target id, or to the browser-level socket when no id is given. */
86
+ static async attach(port, targetId) {
87
+ const url = targetId
88
+ ? `ws://127.0.0.1:${port}/devtools/page/${targetId}`
89
+ : (await Cdp.open(port)).webSocketDebuggerUrl;
90
+ const ws = new WebSocket(url);
91
+ await new Promise((resolve, reject) => {
92
+ const timer = setTimeout(() => reject(new Error('CDP websocket open timed out')), 10000);
93
+ ws.addEventListener('open', () => {
94
+ clearTimeout(timer);
95
+ resolve();
96
+ });
97
+ ws.addEventListener('error', () => {
98
+ clearTimeout(timer);
99
+ reject(new Error(`CDP websocket failed to open: ${url}`));
100
+ });
101
+ });
102
+ return new Cdp(ws);
103
+ }
104
+
105
+ get closed() {
106
+ return this.#closed;
107
+ }
108
+
109
+ #onClose() {
110
+ if (this.#closed) return;
111
+ this.#closed = true;
112
+ for (const [, pending] of this.#pending) {
113
+ clearTimeout(pending.timer);
114
+ pending.reject(new Error('CDP socket closed'));
115
+ }
116
+ this.#pending.clear();
117
+ }
118
+
119
+ #onMessage(ev) {
120
+ let msg;
121
+ try {
122
+ msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data));
123
+ } catch {
124
+ return;
125
+ }
126
+ if (msg.id !== undefined) {
127
+ const pending = this.#pending.get(msg.id);
128
+ if (!pending) return;
129
+ this.#pending.delete(msg.id);
130
+ clearTimeout(pending.timer);
131
+ if (msg.error) pending.reject(new Error(`${pending.method}: ${msg.error.message}`));
132
+ else pending.resolve(msg.result);
133
+ return;
134
+ }
135
+ if (msg.method) {
136
+ const set = this.#listeners.get(msg.method);
137
+ if (set) for (const fn of set) {
138
+ try {
139
+ fn(msg.params);
140
+ } catch {
141
+ /* a listener must never take the socket down */
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ on(method, fn) {
148
+ let set = this.#listeners.get(method);
149
+ if (!set) {
150
+ set = new Set();
151
+ this.#listeners.set(method, set);
152
+ }
153
+ set.add(fn);
154
+ return () => set.delete(fn);
155
+ }
156
+
157
+ send(method, params = {}, timeout = DEFAULT_TIMEOUT) {
158
+ if (this.#closed) return Promise.reject(new Error('CDP socket closed'));
159
+ const id = this.#nextId++;
160
+ return new Promise((resolve, reject) => {
161
+ const timer = setTimeout(() => {
162
+ this.#pending.delete(id);
163
+ reject(new CdpTimeout(method, timeout));
164
+ }, timeout);
165
+ this.#pending.set(id, { resolve, reject, timer, method });
166
+ try {
167
+ this.#ws.send(JSON.stringify({ id, method, params }));
168
+ } catch (err) {
169
+ clearTimeout(timer);
170
+ this.#pending.delete(id);
171
+ reject(err);
172
+ }
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Evaluate an expression in the page and return its JSON value.
178
+ *
179
+ * `returnByValue` is on, so a function returning a plain object arrives as data rather than as an
180
+ * object handle the caller then has to release.
181
+ */
182
+ async evaluate(expression, { awaitPromise = false, timeout = DEFAULT_TIMEOUT } = {}) {
183
+ const res = await this.send(
184
+ 'Runtime.evaluate',
185
+ {
186
+ expression,
187
+ returnByValue: true,
188
+ awaitPromise,
189
+ userGesture: true,
190
+ includeCommandLineAPI: false,
191
+ },
192
+ timeout,
193
+ );
194
+ if (res.exceptionDetails) {
195
+ const d = res.exceptionDetails;
196
+ const text =
197
+ d.exception?.description ?? d.exception?.value ?? d.text ?? 'evaluation threw';
198
+ throw new Error(`page evaluation failed: ${String(text).slice(0, 400)}`);
199
+ }
200
+ return res.result?.value;
201
+ }
202
+
203
+ async navigate(url, timeout = 45000) {
204
+ await this.send('Page.enable');
205
+ await this.send('Page.navigate', { url }, timeout);
206
+ }
207
+
208
+ async bringToFront() {
209
+ try {
210
+ await this.send('Page.bringToFront', {}, 5000);
211
+ } catch {
212
+ /* headless and unfocused targets refuse this; it is only cosmetic */
213
+ }
214
+ }
215
+
216
+ close() {
217
+ try {
218
+ this.#ws.close();
219
+ } catch {
220
+ /* already gone */
221
+ }
222
+ this.#onClose();
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Poll `fn` until it returns a truthy value or the budget runs out.
228
+ *
229
+ * A throwing poll is treated as "not ready yet", not as failure. The first version let the throw
230
+ * escape, so waiting for a browser to open its debug port died on the first `fetch failed` - which is
231
+ * the normal state for the several seconds before the port exists.
232
+ */
233
+ export async function waitFor(fn, { timeout = 20000, interval = 120, label = 'condition' } = {}) {
234
+ const started = Date.now();
235
+ let lastError;
236
+ while (Date.now() - started < timeout) {
237
+ try {
238
+ const value = await fn();
239
+ if (value) return value;
240
+ } catch (err) {
241
+ lastError = err;
242
+ }
243
+ await new Promise((r) => setTimeout(r, interval));
244
+ }
245
+ const detail = lastError ? ` (last error: ${lastError.message})` : '';
246
+ throw new Error(`timed out waiting for ${label} after ${timeout}ms${detail}`);
247
+ }