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 CHANGED
@@ -76,7 +76,7 @@ npx cdpilot <command>
76
76
  npm i -g cdpilot
77
77
  ```
78
78
 
79
- **Requirements:** Node.js 18+ and one of: Brave Browser, Google Chrome, or Chromium.
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
- - **Docker + Xvfb harness** (`cdpilot-bench/docker/`) runs headed-in-Xvfb so
576
- bench/automation runs never pop a window on the host display. CI-ready. Note
577
- that software rendering (no GPU) lowers anti-bot scores versus native — it's
578
- an isolated environment for reproducibility, not the headline configuration.
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
- │ ~2500 lines, single file │ personal browser
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 | **~50KB** | 400MB+ | 200MB+ | 100MB+ |
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] **Accessibility tree snapshot** (`a11y-snapshot`) — structured data with @ref references, 500x fewer tokens than screenshots
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] **Multi-instance pool + off-screen mode** (`CDPILOT_POOL_SIZE`, `CDPILOT_OFFSCREEN`) — N parallel browser processes; headed rendering without stealing window focus
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.8+ required. Install: https://www.python.org/downloads/');
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.0",
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.0"
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 = os.path.expanduser("~/.cdpilot")
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
- s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
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
- os.makedirs(os.path.dirname(REGISTRY_FILE), exist_ok=True)
91
- with open(REGISTRY_FILE, 'w') as f:
92
- json.dump({"version": 1, "projects": projects}, f, indent=2)
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
- """Update status for dead processes and return cleaned registry."""
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
- changed = False
115
- for pid_key, info in registry.items():
116
- if info.get("status") == "running":
117
- port = info.get("port")
118
- if port and _is_port_free(port):
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 = _load_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
- if port:
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
- if not pid:
12003
- return False
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())))