cdpilot 0.9.0 → 0.9.1
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/README.md +12 -15
- package/bin/cdpilot.js +1 -1
- package/package.json +7 -3
- package/src/cdpilot.py +220 -33
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ npx cdpilot <command>
|
|
|
76
76
|
npm i -g cdpilot
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
**Requirements:** Node.js 18
|
|
79
|
+
**Requirements:** Node.js 18+, Python 3.10+, and one of: Brave Browser, Google Chrome, or Chromium. (The Python `websockets` module is auto-installed by the pre-flight wizard on first launch.)
|
|
80
80
|
|
|
81
81
|
### First-time setup
|
|
82
82
|
|
|
@@ -562,20 +562,16 @@ on macOS — spot degradation before your automation silently stalls.
|
|
|
562
562
|
### Scaling & Workstation Use
|
|
563
563
|
|
|
564
564
|
```bash
|
|
565
|
-
CDPILOT_POOL_SIZE=4 cdpilot launch # 4 separate browser processes
|
|
566
565
|
CDPILOT_OFFSCREEN=1 cdpilot launch # headed, but no window on your screen
|
|
567
566
|
```
|
|
568
567
|
|
|
569
|
-
- **Multi-instance pool** (`CDPILOT_POOL_SIZE=N`) starts N independent browser
|
|
570
|
-
processes and dispatches work to the least-loaded one, for `N × per-instance`
|
|
571
|
-
parallelism. Default is `1` — single instance, no change for existing users.
|
|
572
568
|
- **Off-screen mode** (`CDPILOT_OFFSCREEN=1`) keeps the browser headed (real
|
|
573
569
|
rendering, no headless fingerprint) but positions the window where it can't
|
|
574
570
|
steal focus — meant for automating on a workstation you're also using.
|
|
575
|
-
- **
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
571
|
+
- **Multi-instance pool** (`CDPILOT_POOL_SIZE`) — planned, not shipped yet.
|
|
572
|
+
See [Roadmap](#coming-soon).
|
|
573
|
+
- **Docker + Xvfb harness** — used in a separate, private benchmark
|
|
574
|
+
repo (`cdpilot-bench`), not part of this package.
|
|
579
575
|
|
|
580
576
|
## Use with AI Agents
|
|
581
577
|
|
|
@@ -624,7 +620,6 @@ print(result.stdout)
|
|
|
624
620
|
| `BROWSER_SESSION` | Auto | Session identifier |
|
|
625
621
|
| `CDPILOT_MODE` | `regular` | Stealth tier override (`regular`/`stealth`/`undetected`) |
|
|
626
622
|
| `CDPILOT_OFFSCREEN` | `0` | Headed but render off-screen — no window steals focus |
|
|
627
|
-
| `CDPILOT_POOL_SIZE` | `1` | N separate browser processes, least-loaded dispatch |
|
|
628
623
|
|
|
629
624
|
## How It Works
|
|
630
625
|
|
|
@@ -636,7 +631,7 @@ print(result.stdout)
|
|
|
636
631
|
│ │
|
|
637
632
|
│ Zero dependencies │ Isolated profile
|
|
638
633
|
│ Pure HTTP + WebSocket │ Separate from your
|
|
639
|
-
│
|
|
634
|
+
│ single file, stdlib + websockets │ personal browser
|
|
640
635
|
└────────────────────────────────────────┘
|
|
641
636
|
```
|
|
642
637
|
|
|
@@ -694,8 +689,8 @@ The full adaptive layer is bench-neutral vs baseline (30/80 vs 30/80) for Stealt
|
|
|
694
689
|
|
|
695
690
|
| Feature | cdpilot | Puppeteer | Playwright | Selenium |
|
|
696
691
|
|---------|-----------|-----------|------------|----------|
|
|
697
|
-
| Install size |
|
|
698
|
-
| Dependencies | **0** | 50+ | 30+ | Java + drivers |
|
|
692
|
+
| Install size | **one Python file + a small Node launcher, no node_modules** | 400MB+ | 200MB+ | 100MB+ |
|
|
693
|
+
| Dependencies | **0 npm** (Python: `websockets`, auto-installed) | 50+ | 30+ | Java + drivers |
|
|
699
694
|
| Setup time | **instant** | minutes | minutes | painful |
|
|
700
695
|
| AI-agent ready | **yes** | manual | manual | manual |
|
|
701
696
|
| Browser download | **no** | yes (Chromium) | yes (3 browsers) | no |
|
|
@@ -753,7 +748,8 @@ The only browser MCP with built-in test assertions. Here's what we've shipped an
|
|
|
753
748
|
- [x] 70+ CLI commands (navigate, click, fill, screenshot, PDF, console, network, video understanding, friction ladder...)
|
|
754
749
|
- [x] MCP server for AI agent integration (Claude Code, Cursor, etc.)
|
|
755
750
|
- [x] **10 built-in test assertions** — assert, assert-url, assert-title, assert-count, assert-value, assert-attr, assert-visible/hidden, wait-for, check (batch), screenshot-diff
|
|
756
|
-
- [x]
|
|
751
|
+
- [x] **`test` runner + `trace` viewer** — `cdpilot test [--watch]` runs `*.cdpt.js` files; `cdpilot trace open <run>` opens a time-travel trace viewer for a run
|
|
752
|
+
- [x] **Accessibility tree snapshot** (`a11y-snapshot`) — structured text with @ref handles the agent can act on directly, no vision model needed; 1.4–42× smaller than the page's raw HTML on the four pages we measured (2026-09-27). Not always cheaper than a screenshot: link-heavy pages produce more text than a small screenshot costs
|
|
757
753
|
- [x] **Token-efficient screenshots** — element-level crop (13x smaller), JPEG quality control, format selection
|
|
758
754
|
- [x] **Vision fallback** (`describe`) — a11y + screenshot + text in one call
|
|
759
755
|
- [x] **Annotated screenshots** — @N badge overlays on interactive elements
|
|
@@ -780,7 +776,7 @@ The only browser MCP with built-in test assertions. Here's what we've shipped an
|
|
|
780
776
|
- [x] **Three-tier stealth mode** (`mode regular|stealth|undetected`) — single switch over fingerprint surface, with per-host adaptive tier learning
|
|
781
777
|
- [x] **Press-and-hold solver** (`press-hold`) — humanized press → hold → release gesture (Gaussian 3–7s + micro-jitter) for PerimeterX/HUMAN behavioral challenges; `captcha-solve` auto-routes here
|
|
782
778
|
- [x] **Image CAPTCHA + profile warming** — offline Amazon image OCR (optional `amazoncaptcha` lib) + BYOK image-to-text (capsolver/2captcha); `profile warm` ages the profile for reCAPTCHA v3 score
|
|
783
|
-
- [x] **
|
|
779
|
+
- [x] **Off-screen mode** (`CDPILOT_OFFSCREEN`) — headed rendering without stealing window focus
|
|
784
780
|
- [x] **Data extraction** (`extract`) — structured DOM data in text, JSON, or list format
|
|
785
781
|
- [x] **Page observation** (`observe`) — list all interactive elements with available actions
|
|
786
782
|
- [x] **Script runner** (`run`) — execute `.cdp` script files with pass/fail reporting
|
|
@@ -788,6 +784,7 @@ The only browser MCP with built-in test assertions. Here's what we've shipped an
|
|
|
788
784
|
### Coming Soon
|
|
789
785
|
|
|
790
786
|
- [ ] **iframe** support — interact with elements inside iframes (Shadow DOM traversal already shipped in smart commands)
|
|
787
|
+
- [ ] **Multi-instance pool** (`CDPILOT_POOL_SIZE`) — N independent browser processes with least-loaded dispatch
|
|
791
788
|
- [ ] **Session recording & replay** — record browser sessions and replay them deterministically
|
|
792
789
|
- [ ] **Stealth mode** *(Pro)* — human-like mouse/typing, anti-fingerprint, CAPTCHA solving
|
|
793
790
|
- [ ] **cdpilot Cloud** — hosted browser sessions API, REST + WebSocket MCP endpoint
|
package/bin/cdpilot.js
CHANGED
|
@@ -620,7 +620,7 @@ if (cmd === 'status') {
|
|
|
620
620
|
// Delegate to Python
|
|
621
621
|
const python = findPython();
|
|
622
622
|
if (!python) {
|
|
623
|
-
console.error('Error: Python 3.
|
|
623
|
+
console.error('Error: Python 3.10+ required. Install: https://www.python.org/downloads/');
|
|
624
624
|
process.exit(1);
|
|
625
625
|
}
|
|
626
626
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdpilot",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"mcpName": "io.github.mehmetnadir/cdpilot",
|
|
4
5
|
"description": "Zero-dependency browser automation CLI. Raw CDP, 70+ commands, friction ladder, 3-tier stealth, CAPTCHA + press-and-hold solvers, proxy pools, MCP server. No Puppeteer/Playwright/Selenium.",
|
|
5
6
|
"bin": {
|
|
6
7
|
"cdpilot": "./bin/cdpilot.js",
|
|
@@ -48,15 +49,18 @@
|
|
|
48
49
|
"shadow-dom",
|
|
49
50
|
"perimeterx",
|
|
50
51
|
"press-and-hold",
|
|
51
|
-
"progressive-resilience"
|
|
52
|
-
"multi-instance"
|
|
52
|
+
"progressive-resilience"
|
|
53
53
|
],
|
|
54
54
|
"author": "Mehmet Nadir",
|
|
55
55
|
"license": "MIT",
|
|
56
|
+
"homepage": "https://cdpilot.ndr.ist",
|
|
56
57
|
"repository": {
|
|
57
58
|
"type": "git",
|
|
58
59
|
"url": "https://github.com/mehmetnadir/cdpilot"
|
|
59
60
|
},
|
|
61
|
+
"bugs": {
|
|
62
|
+
"url": "https://github.com/mehmetnadir/cdpilot/issues"
|
|
63
|
+
},
|
|
60
64
|
"engines": {
|
|
61
65
|
"node": ">=18.0.0"
|
|
62
66
|
},
|
package/src/cdpilot.py
CHANGED
|
@@ -14,7 +14,7 @@ Environment:
|
|
|
14
14
|
CDPILOT_PROFILE Isolated browser profile directory
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
__version__ = "0.9.
|
|
17
|
+
__version__ = "0.9.1"
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
20
|
import atexit
|
|
@@ -35,13 +35,16 @@ import secrets
|
|
|
35
35
|
import glob
|
|
36
36
|
import datetime
|
|
37
37
|
import concurrent.futures
|
|
38
|
+
import tempfile
|
|
38
39
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
39
40
|
|
|
40
41
|
# ─── Project-Based Multi-Instance Configuration ───
|
|
41
42
|
# Each project directory (cwd) gets its own browser instance with
|
|
42
43
|
# a unique CDP port and isolated profile directory. Zero-config.
|
|
43
44
|
|
|
44
|
-
CDPILOT_HOME
|
|
45
|
+
# CDPILOT_HOME override lets tests (and advanced users) point the registry
|
|
46
|
+
# at an isolated directory instead of the real ~/.cdpilot.
|
|
47
|
+
CDPILOT_HOME = os.environ.get("CDPILOT_HOME") or os.path.expanduser("~/.cdpilot")
|
|
45
48
|
REGISTRY_FILE = os.path.join(CDPILOT_HOME, "registry.json")
|
|
46
49
|
CDPILOT_PORT_RANGE_START = 9222
|
|
47
50
|
CDPILOT_PORT_RANGE_END = 9322
|
|
@@ -65,10 +68,19 @@ def _get_project_id():
|
|
|
65
68
|
|
|
66
69
|
|
|
67
70
|
def _is_port_free(port):
|
|
68
|
-
"""Check if a port is available for binding.
|
|
71
|
+
"""Check if a port is available for binding.
|
|
72
|
+
|
|
73
|
+
On Windows, SO_REUSEADDR lets a socket bind on top of a port that another
|
|
74
|
+
socket is actively listening on, so a probe using it called every port
|
|
75
|
+
free (and registry cleanup pruned live entries). Exclusive use fails when
|
|
76
|
+
anything holds the port. POSIX SO_REUSEADDR only skips TIME_WAIT.
|
|
77
|
+
"""
|
|
69
78
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
70
79
|
try:
|
|
71
|
-
|
|
80
|
+
if os.name == "nt":
|
|
81
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
82
|
+
else:
|
|
83
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
72
84
|
s.bind(("127.0.0.1", port))
|
|
73
85
|
return True
|
|
74
86
|
except OSError:
|
|
@@ -86,10 +98,26 @@ def _load_registry():
|
|
|
86
98
|
|
|
87
99
|
|
|
88
100
|
def _save_registry(projects):
|
|
89
|
-
"""Write the global project registry.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
"""Write the global project registry atomically.
|
|
102
|
+
|
|
103
|
+
Writes to a temp file in the same directory and os.replace()s it into
|
|
104
|
+
place, so a concurrent cdpilot invocation reading registry.json always
|
|
105
|
+
sees either the fully-old or fully-new content — never a torn/partial
|
|
106
|
+
write (os.replace is atomic on both POSIX and Windows).
|
|
107
|
+
"""
|
|
108
|
+
reg_dir = os.path.dirname(REGISTRY_FILE)
|
|
109
|
+
os.makedirs(reg_dir, exist_ok=True)
|
|
110
|
+
fd, tmp_path = tempfile.mkstemp(prefix='.registry-', suffix='.tmp', dir=reg_dir)
|
|
111
|
+
try:
|
|
112
|
+
with os.fdopen(fd, 'w') as f:
|
|
113
|
+
json.dump({"version": 1, "projects": projects}, f, indent=2)
|
|
114
|
+
os.replace(tmp_path, REGISTRY_FILE)
|
|
115
|
+
except BaseException:
|
|
116
|
+
try:
|
|
117
|
+
os.remove(tmp_path)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
raise
|
|
93
121
|
|
|
94
122
|
|
|
95
123
|
def _register_project(project_id, port, profile_dir, pid=None):
|
|
@@ -108,25 +136,83 @@ def _register_project(project_id, port, profile_dir, pid=None):
|
|
|
108
136
|
_save_registry(registry)
|
|
109
137
|
|
|
110
138
|
|
|
139
|
+
def _pid_alive(pid):
|
|
140
|
+
"""True if a process with this pid exists. Never signals it.
|
|
141
|
+
|
|
142
|
+
Sending signal 0 is a harmless existence probe on POSIX, but on Windows
|
|
143
|
+
Python maps every signal other than CTRL_C/CTRL_BREAK to TerminateProcess:
|
|
144
|
+
the "probe" kills the process. Registry pids are stale by nature and may
|
|
145
|
+
have been reused by an unrelated program, so on Windows ask the kernel
|
|
146
|
+
through OpenProcess/GetExitCodeProcess instead. Defined this early because
|
|
147
|
+
it runs at import time (_resolve_project_config -> _allocate_port).
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
pid = int(pid)
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
return False
|
|
153
|
+
if pid <= 0:
|
|
154
|
+
return False
|
|
155
|
+
if os.name == "nt":
|
|
156
|
+
import ctypes
|
|
157
|
+
kernel32 = ctypes.windll.kernel32
|
|
158
|
+
handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
|
|
159
|
+
if not handle:
|
|
160
|
+
return False
|
|
161
|
+
try:
|
|
162
|
+
code = ctypes.c_ulong()
|
|
163
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
164
|
+
return False
|
|
165
|
+
return code.value == 259 # STILL_ACTIVE
|
|
166
|
+
finally:
|
|
167
|
+
kernel32.CloseHandle(handle)
|
|
168
|
+
try:
|
|
169
|
+
os.kill(pid, 0)
|
|
170
|
+
except ProcessLookupError:
|
|
171
|
+
return False
|
|
172
|
+
except PermissionError:
|
|
173
|
+
return True # exists, just owned by another user
|
|
174
|
+
except OSError:
|
|
175
|
+
return False
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_registry_entry_dead(info):
|
|
180
|
+
"""A registry entry is dead only when we can PROVE nothing is using it.
|
|
181
|
+
|
|
182
|
+
Dead = nothing is listening on its port AND (if a pid was recorded)
|
|
183
|
+
that process is no longer alive. If the port is currently bound, we
|
|
184
|
+
never call it dead even if our own bookkeeping (status/pid) looks
|
|
185
|
+
stale — it may be another project's live browser instance, and a
|
|
186
|
+
live entry must never be pruned.
|
|
187
|
+
"""
|
|
188
|
+
port = info.get("port")
|
|
189
|
+
if port and not _is_port_free(port):
|
|
190
|
+
return False
|
|
191
|
+
pid = info.get("pid")
|
|
192
|
+
if pid and _pid_alive(pid):
|
|
193
|
+
return False
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
|
|
111
197
|
def _cleanup_registry():
|
|
112
|
-
"""
|
|
198
|
+
"""Prune dead entries from the registry and return what's left.
|
|
199
|
+
|
|
200
|
+
Called before every port allocation (see _allocate_port) so the
|
|
201
|
+
registry never accumulates stale entries that starve the
|
|
202
|
+
9222-9322 port range — the root cause of the "No free port" failure
|
|
203
|
+
documented in .claude/docs/vaat-denetimi-2026-09-27.md.
|
|
204
|
+
"""
|
|
113
205
|
registry = _load_registry()
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
info["status"] = "stopped"
|
|
120
|
-
info["pid"] = None
|
|
121
|
-
changed = True
|
|
122
|
-
if changed:
|
|
123
|
-
_save_registry(registry)
|
|
124
|
-
return registry
|
|
206
|
+
alive = {pid_key: info for pid_key, info in registry.items()
|
|
207
|
+
if not _is_registry_entry_dead(info)}
|
|
208
|
+
if len(alive) != len(registry):
|
|
209
|
+
_save_registry(alive)
|
|
210
|
+
return alive
|
|
125
211
|
|
|
126
212
|
|
|
127
213
|
def _allocate_port(project_id):
|
|
128
214
|
"""Find a free port for the given project."""
|
|
129
|
-
registry =
|
|
215
|
+
registry = _cleanup_registry()
|
|
130
216
|
|
|
131
217
|
# Reuse existing port if still free
|
|
132
218
|
if project_id in registry:
|
|
@@ -2528,7 +2614,16 @@ def cmd_launch():
|
|
|
2528
2614
|
sys.exit(1)
|
|
2529
2615
|
|
|
2530
2616
|
|
|
2531
|
-
def cmd_tabs():
|
|
2617
|
+
def cmd_tabs(reap=False, max_tabs=1, include_real=False, dry_run=False):
|
|
2618
|
+
if reap:
|
|
2619
|
+
result = _reap_tabs(max_tabs=max_tabs, include_real=include_real,
|
|
2620
|
+
dry_run=dry_run)
|
|
2621
|
+
verb = "would close" if result.get("dry_run") else "closed"
|
|
2622
|
+
print(f"{verb} {len(result['closed'])} tab(s), kept {result['kept']}"
|
|
2623
|
+
f" (blank {result.get('blank', 0)}, real {result.get('real', 0)})")
|
|
2624
|
+
if result.get("reason"):
|
|
2625
|
+
print(f" {result['reason']}")
|
|
2626
|
+
return
|
|
2532
2627
|
tabs = get_tabs()
|
|
2533
2628
|
pages = [t for t in tabs if t.get("type") == "page"]
|
|
2534
2629
|
for i, p in enumerate(pages):
|
|
@@ -4527,6 +4622,87 @@ def _is_chrome_internal_url(url):
|
|
|
4527
4622
|
or u.startswith("vivaldi://"))
|
|
4528
4623
|
|
|
4529
4624
|
|
|
4625
|
+
def _close_target(target_id):
|
|
4626
|
+
"""Close one CDP target. Returns True when the browser accepted it."""
|
|
4627
|
+
try:
|
|
4628
|
+
urllib.request.urlopen(f"{CDP_BASE}/json/close/{target_id}", timeout=2)
|
|
4629
|
+
return True
|
|
4630
|
+
except Exception:
|
|
4631
|
+
return False
|
|
4632
|
+
|
|
4633
|
+
|
|
4634
|
+
def _reap_tabs(max_tabs=1, include_real=False, dry_run=False):
|
|
4635
|
+
"""Enforce a tab budget on the connected browser.
|
|
4636
|
+
|
|
4637
|
+
A long-lived browser accumulates abandoned tabs: every client that opens a
|
|
4638
|
+
target and exits without closing it leaks one, and nothing collects them.
|
|
4639
|
+
Measured on srv21 2026-08-30 — 513 pages, 506 of them about:blank, 12.7 GB
|
|
4640
|
+
across 529 renderer processes, growing ~6/hour for three days on a box with
|
|
4641
|
+
47 GB total.
|
|
4642
|
+
|
|
4643
|
+
Blank tabs go first: they hold nothing a caller could still want. Pages with
|
|
4644
|
+
a real URL are never touched unless include_real is set, because on a shared
|
|
4645
|
+
browser they may belong to somebody else. The session's own tab and the last
|
|
4646
|
+
remaining page always survive.
|
|
4647
|
+
"""
|
|
4648
|
+
tabs = cdp_get("/json", no_cache=True)
|
|
4649
|
+
if not tabs:
|
|
4650
|
+
return {"closed": [], "kept": 0, "reason": "no CDP connection"}
|
|
4651
|
+
pages = [t for t in tabs if t.get("type") == "page"]
|
|
4652
|
+
if len(pages) <= 1:
|
|
4653
|
+
return {"closed": [], "kept": len(pages), "reason": "nothing to reap"}
|
|
4654
|
+
|
|
4655
|
+
session_id = _get_session_window_target_id()
|
|
4656
|
+
real, blank = [], []
|
|
4657
|
+
for t in pages:
|
|
4658
|
+
(blank if _is_chrome_internal_url(t.get("url")) else real).append(t)
|
|
4659
|
+
|
|
4660
|
+
keep_ids = []
|
|
4661
|
+
if session_id and any(t.get("id") == session_id for t in pages):
|
|
4662
|
+
keep_ids.append(session_id)
|
|
4663
|
+
if not include_real:
|
|
4664
|
+
keep_ids += [t["id"] for t in real if t["id"] not in keep_ids]
|
|
4665
|
+
for t in blank:
|
|
4666
|
+
if len(keep_ids) >= max_tabs:
|
|
4667
|
+
break
|
|
4668
|
+
if t["id"] not in keep_ids:
|
|
4669
|
+
keep_ids.append(t["id"])
|
|
4670
|
+
if not keep_ids: # never leave the browser with zero pages
|
|
4671
|
+
keep_ids.append(pages[0]["id"])
|
|
4672
|
+
|
|
4673
|
+
doomed = [t for t in pages if t["id"] not in keep_ids]
|
|
4674
|
+
closed = []
|
|
4675
|
+
for t in doomed:
|
|
4676
|
+
if dry_run or _close_target(t["id"]):
|
|
4677
|
+
closed.append(t["id"])
|
|
4678
|
+
if closed and not dry_run:
|
|
4679
|
+
cdp_cache_invalidate()
|
|
4680
|
+
owned = _load_owned_tabs()
|
|
4681
|
+
if owned & set(closed):
|
|
4682
|
+
_save_owned_tabs(owned - set(closed))
|
|
4683
|
+
return {"closed": closed, "kept": len(keep_ids),
|
|
4684
|
+
"blank": len(blank), "real": len(real), "dry_run": dry_run}
|
|
4685
|
+
|
|
4686
|
+
|
|
4687
|
+
def _enforce_tab_budget():
|
|
4688
|
+
"""Opt-in post-command reaping via CDPILOT_MAX_TABS.
|
|
4689
|
+
|
|
4690
|
+
Off unless the variable is set: silently closing tabs would be hostile on a
|
|
4691
|
+
desktop where the browser is also the user's. On an unattended server it is
|
|
4692
|
+
the difference between one tab and five hundred.
|
|
4693
|
+
"""
|
|
4694
|
+
raw = os.environ.get("CDPILOT_MAX_TABS", "").strip()
|
|
4695
|
+
if not raw.isdigit() or int(raw) < 1:
|
|
4696
|
+
return
|
|
4697
|
+
try:
|
|
4698
|
+
result = _reap_tabs(max_tabs=int(raw))
|
|
4699
|
+
except Exception:
|
|
4700
|
+
return
|
|
4701
|
+
if result.get("closed"):
|
|
4702
|
+
print(f"[cdpilot] tab budget: closed {len(result['closed'])} idle tab(s)",
|
|
4703
|
+
file=sys.stderr)
|
|
4704
|
+
|
|
4705
|
+
|
|
4530
4706
|
async def _browser_close_graceful():
|
|
4531
4707
|
"""Ask the browser to shut down gracefully via the CDP Browser.close
|
|
4532
4708
|
command (cleanest cross-platform path — flushes state, no orphaned procs).
|
|
@@ -7421,9 +7597,13 @@ def _stop_browser_on_port(port):
|
|
|
7421
7597
|
"""Stop the browser process listening on the given port."""
|
|
7422
7598
|
import signal
|
|
7423
7599
|
try:
|
|
7600
|
+
# Bounded: on some hosts/sandboxes `lsof`/`pkill` can hang instead of
|
|
7601
|
+
# erroring (observed while testing the port-registry fix — a stuck
|
|
7602
|
+
# `lsof -ti :<port>` blocked the whole /v1/sessions DELETE request).
|
|
7603
|
+
# A timeout keeps this best-effort call from ever wedging the caller.
|
|
7424
7604
|
result = subprocess.run(
|
|
7425
7605
|
["lsof", "-ti", f":{port}"],
|
|
7426
|
-
capture_output=True, text=True
|
|
7606
|
+
capture_output=True, text=True, timeout=5
|
|
7427
7607
|
)
|
|
7428
7608
|
pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
|
|
7429
7609
|
if pids:
|
|
@@ -7433,7 +7613,7 @@ def _stop_browser_on_port(port):
|
|
|
7433
7613
|
else:
|
|
7434
7614
|
subprocess.run(
|
|
7435
7615
|
["pkill", "-f", f"remote-debugging-port={port}"],
|
|
7436
|
-
capture_output=True, text=True
|
|
7616
|
+
capture_output=True, text=True, timeout=5
|
|
7437
7617
|
)
|
|
7438
7618
|
return True
|
|
7439
7619
|
except Exception:
|
|
@@ -7514,7 +7694,11 @@ def _api_release_session(session_id: str) -> bool:
|
|
|
7514
7694
|
if not sess:
|
|
7515
7695
|
return False
|
|
7516
7696
|
port = sess.get('port')
|
|
7517
|
-
|
|
7697
|
+
# In TEST_MODE, _api_create_session never actually launched a browser on
|
|
7698
|
+
# `port` (see the sibling check there) — nothing is listening, so skip
|
|
7699
|
+
# the lsof/pkill round trip entirely instead of paying its cost for a
|
|
7700
|
+
# no-op every time.
|
|
7701
|
+
if port and os.environ.get('CDPILOT_API_TEST_MODE') != '1':
|
|
7518
7702
|
_stop_browser_on_port(port)
|
|
7519
7703
|
sess['status'] = 'STOPPED'
|
|
7520
7704
|
del _api_session_store[session_id]
|
|
@@ -11999,13 +12183,8 @@ def _watch_clear_state():
|
|
|
11999
12183
|
|
|
12000
12184
|
|
|
12001
12185
|
def _watch_pid_alive(pid):
|
|
12002
|
-
|
|
12003
|
-
|
|
12004
|
-
try:
|
|
12005
|
-
os.kill(int(pid), 0)
|
|
12006
|
-
return True
|
|
12007
|
-
except (OSError, ValueError):
|
|
12008
|
-
return False
|
|
12186
|
+
# os.kill(pid, 0) terminates the process on Windows — see _pid_alive.
|
|
12187
|
+
return _pid_alive(pid)
|
|
12009
12188
|
|
|
12010
12189
|
|
|
12011
12190
|
def _watch_parse_timecode(spec):
|
|
@@ -12777,7 +12956,12 @@ if __name__ == "__main__":
|
|
|
12777
12956
|
|
|
12778
12957
|
sync_cmds = {
|
|
12779
12958
|
'launch': cmd_launch,
|
|
12780
|
-
'tabs': cmd_tabs
|
|
12959
|
+
'tabs': lambda: cmd_tabs(
|
|
12960
|
+
reap='--reap' in args,
|
|
12961
|
+
max_tabs=next((int(a.split('=')[1]) for a in args
|
|
12962
|
+
if a.startswith('--max=') and a.split('=')[1].isdigit()), 1),
|
|
12963
|
+
include_real='--all' in args,
|
|
12964
|
+
dry_run='--dry-run' in args),
|
|
12781
12965
|
'extensions': cmd_extensions,
|
|
12782
12966
|
'stop': cmd_stop,
|
|
12783
12967
|
'version': cmd_version,
|
|
@@ -12857,6 +13041,7 @@ if __name__ == "__main__":
|
|
|
12857
13041
|
|
|
12858
13042
|
if cmd in sync_cmds:
|
|
12859
13043
|
sync_cmds[cmd]()
|
|
13044
|
+
_enforce_tab_budget()
|
|
12860
13045
|
sys.exit(0)
|
|
12861
13046
|
|
|
12862
13047
|
def require_args(n, usage):
|
|
@@ -13001,6 +13186,7 @@ if __name__ == "__main__":
|
|
|
13001
13186
|
if cmd in NO_CONTROL_CMDS:
|
|
13002
13187
|
asyncio.run(async_map[cmd]())
|
|
13003
13188
|
_update_session_timestamp()
|
|
13189
|
+
_enforce_tab_budget()
|
|
13004
13190
|
else:
|
|
13005
13191
|
async def _wrapped():
|
|
13006
13192
|
ws_url = None
|
|
@@ -13021,6 +13207,7 @@ if __name__ == "__main__":
|
|
|
13021
13207
|
await _control_end(ws_url)
|
|
13022
13208
|
asyncio.run(_wrapped())
|
|
13023
13209
|
_update_session_timestamp()
|
|
13210
|
+
_enforce_tab_budget()
|
|
13024
13211
|
else:
|
|
13025
13212
|
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
13026
13213
|
all_cmds = sorted(set(list(sync_cmds.keys()) + list(async_map.keys())))
|