@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.
- package/CHANGELOG.md +893 -0
- package/LICENSE +142 -0
- package/README.md +354 -2
- package/client.js +1099 -0
- package/cordis.patch.yml +48 -0
- package/icon.svg +17 -0
- package/index.js +1411 -0
- package/install.cmd +43 -0
- package/install.ps1 +311 -0
- package/lib/cdp.mjs +247 -0
- package/lib/engine.mjs +1412 -0
- package/lib/i18n.mjs +141 -0
- package/lib/prefs.mjs +101 -0
- package/lib/prompt.mjs +518 -0
- package/lib/savings.mjs +131 -0
- package/locale/ar.json +98 -0
- package/locale/az.json +98 -0
- package/locale/bg.json +98 -0
- package/locale/bn.json +98 -0
- package/locale/cs.json +98 -0
- package/locale/da.json +98 -0
- package/locale/de.json +98 -0
- package/locale/el.json +98 -0
- package/locale/en.json +98 -0
- package/locale/es.json +98 -0
- package/locale/fa.json +98 -0
- package/locale/fi.json +98 -0
- package/locale/fr.json +98 -0
- package/locale/he.json +98 -0
- package/locale/hi.json +98 -0
- package/locale/hr.json +98 -0
- package/locale/hu.json +98 -0
- package/locale/id.json +98 -0
- package/locale/it.json +98 -0
- package/locale/ja.json +98 -0
- package/locale/ka.json +98 -0
- package/locale/kk.json +98 -0
- package/locale/km.json +98 -0
- package/locale/ko.json +98 -0
- package/locale/lt.json +98 -0
- package/locale/mr.json +98 -0
- package/locale/ms.json +98 -0
- package/locale/my.json +98 -0
- package/locale/ne.json +98 -0
- package/locale/nl.json +98 -0
- package/locale/no.json +98 -0
- package/locale/pl.json +98 -0
- package/locale/pt.json +98 -0
- package/locale/ro.json +98 -0
- package/locale/ru.json +98 -0
- package/locale/si.json +98 -0
- package/locale/sk.json +98 -0
- package/locale/sl.json +98 -0
- package/locale/sr.json +98 -0
- package/locale/sv.json +98 -0
- package/locale/sw.json +98 -0
- package/locale/ta.json +98 -0
- package/locale/te.json +98 -0
- package/locale/th.json +98 -0
- package/locale/tl.json +98 -0
- package/locale/tr.json +98 -0
- package/locale/uk.json +98 -0
- package/locale/ur.json +98 -0
- package/locale/uz.json +98 -0
- package/locale/vi.json +98 -0
- package/locale/zh.json +98 -0
- 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
|
+
}
|