chrome-cdp-manager
v2.0.0
Published
Set up and drive a Chrome DevTools Protocol (CDP) instance on macOS, Windows, or Linux through a dedicated launcher (consistent Dock/taskbar/app-menu icon). Works with any Chromium-based browser — Chrome, Edge, Brave, Chromium, Vivaldi, Opera, Arc.
Maintainers
Readme
chrome-cdp-manager
Set up and drive a Chrome DevTools Protocol (CDP) instance on macOS, Windows, or Linux through a dedicated launcher, so launches always go through the same entry point and the Dock/taskbar/app-menu icon stays consistent. Works with any Chromium-based browser — Chrome, Edge, Brave, Chromium, Vivaldi, Opera, Arc (Arc is macOS/Windows only).
- macOS: builds a real
.appbundle (/Applications/Agent.app) with a properInfo.plistand the browser's own icon — headed and headless launches go throughopen -nso both run under the same Dock icon (easy to spot and quit). Setup also creates a small "Google Chrome" wrapper app in~/Applicationsthat opens your normal browser profile viaopen -n, so a regular browser window is always one click away even while the CDP instance runs. - Windows: creates a Start Menu
.lnkshortcut with the CDP flags baked in and a custom icon — headed launches go through the shortcut for a consistent taskbar entry. - Linux: installs a bash launcher under
~/.local/share/chrome-cdp-manager/plus a freedesktop.desktopentry in~/.local/share/applications/so the app menu can show a consistent icon. - The raw CDP client uses Node's built-in
fetchandWebSocket; the package never downloads or bundles a browser.playwright-corepowers the optional Playwright CLI wrapper, while the high-level bridge requires your own compatibleplaywrightinstallation.
Requires Node.js ≥ 22 and a Chromium-based browser installed. On macOS, Apple Silicon (the launcher uses
arch -arm64).Non-Chromium browsers (Firefox, Safari) are not supported — they don't speak the same CDP this tool drives.
Usage
The package is published as chrome-cdp-manager; the command it installs is
chrome-cdp.
# Run without installing — invoke via the package name
npx chrome-cdp-manager setup
# Or install once, then use the `chrome-cdp` command
npm install -g chrome-cdp-manager# Create / repair the launcher and save defaults (port 9222, profile ~/.chrome_cdp_profile)
chrome-cdp setup
# Use a specific browser
chrome-cdp setup --browser edge
# See which browsers are installed
chrome-cdp browsers
# Launch Agent and open a page
chrome-cdp open https://example.com
# Same, headless (no window/Dock/taskbar)
chrome-cdp open https://example.com --headless
# Route all traffic through a proxy (defaults to socks5://127.0.0.1:1080)
chrome-cdp open https://example.com --proxy
chrome-cdp open https://example.com --proxy socks5://127.0.0.1:9050
chrome-cdp open https://example.com --proxy http://10.0.0.1:8080
# Fetch a page's rendered HTML over CDP (headless by default)
chrome-cdp html example.com -o page.html
# Open your NORMAL browser (default profile) — works even while Agent runs
chrome-cdp chrome
chrome-cdp chrome https://example.com
# Inspect state (shows the live port, and the configured one if they differ)
chrome-cdp status
# Quit the running instance
chrome-cdp stopTip: with
npx, run it by package name —npx chrome-cdp-manager <command>— sincenpx chrome-cdpwould try to fetch an unrelatedchrome-cdppackage.
Commands
| Command | Description |
| ------------------ | ----------------------------------------------------------------- |
| setup | Create or repair the launcher (icon + CDP flags) |
| open [url] | Launch via the launcher, optionally open a URL; leaves it running |
| html <url> | Navigate and print/save the page's serialized HTML |
| chrome [url] | Open your normal (non-CDP) browser, even while Agent runs |
| status | Show launcher presence and CDP connection state |
| stop | Ask the running Agent instance to quit |
| browsers | List supported browsers and which are installed |
Headless sessions vs. your normal browser (macOS)
Headed and headless sessions launch through open -n /Applications/Agent.app,
so both run under the Agent Dock icon — one place to find the CDP instance and
quit it (right-click → Quit, or chrome-cdp stop).
A CDP session launched from the full browser binary still registers with macOS LaunchServices as that browser. While it runs, clicking your normal browser's own Dock icon (or launching it via Spotlight/Finder) may silently do nothing: macOS sees the browser as "already running". The profiles never conflict — only the app identity does. Ways out:
- The "Google Chrome" wrapper app —
chrome-cdp setupcreates~/Applications/Google Chrome.app(named and iconed after your configured browser). It opens your normal browser profile withopen -n, which forces a new instance, so it works even while the CDP instance runs. Drag it to the Dock and keep it next to Agent. Both instances run the same signed browser binary, so both can read the "Chrome Safe Storage" key from the macOS Keychain (saved passwords/cookies decrypt normally in each). chrome-cdp chrome— the same thing from the terminal. Manual equivalent:open -n -a "Google Chrome".chrome-cdp stop— quit the CDP instance first. The idle watchdog also auto-quits a forgotten headless instance (--idle-timeout, default 0.5 min).
--headless-shell(a separate chrome-headless-shell binary for headless launches) is still supported on Windows/Linux, but is no longer used on macOS — headless goes through the Agent bundle so it keeps the Dock icon.
If you ever ran
sudo chrome-cdp setup: older versions could leave the bundle and config root-owned, breaking later launches with "Permission denied". Fix once withsudo chown -R "$USER" /Applications/Agent.app ~/.config/chrome-cdp-manager. Current versions hand ownership back automatically after a sudo setup.
Programmatic API
Beyond the CLI, the package exposes a typed ES-module API (Node ≥ 22). It ships generated declarations for both the core and Playwright entry points.
import { launch, CdpClient } from "chrome-cdp-manager";
// Ensure the Agent instance is running (launches it if needed) and get its endpoint.
const { endpoint, cdpPort, config, launched } = await launch({ headless: false });
// Drive it with the built-in zero-dependency raw-CDP client...
// (use `cdpPort`, not your requested port — see "Busy CDP port" above)
const cdp = await CdpClient.connect(cdpPort);Also exported: stopBrowser, getBrowserStatus, probeCdp, openUrl,
getPageHtml, closeBrowser, the config helpers loadConfig / resolveConfig
/ DEFAULTS, the proxy helpers parseProxy / detectProxy (+ the
DEFAULT_PROXY* constants), touchActivity, and the EndpointCollisionError /
ProfileBusyError classes.
Breaking in 2.0 — internal helpers that were previously re-exported are no longer part of the public API:
getLauncher,ensureBrowserRunning,waitForCdp,inspectCdpProcess,detectModeFromCommandLine,commandLineMatchesManager,readProcessCommandLine,findListeningPid,validateConfig,computeDefaults,resolveHeadlessShell,usesHeadlessShell,headlessShellArgs, and the port helpersresolveAvailableCdpPort,canBindPort,portFromWebSocketUrl,readDevToolsActivePort,CDP_PORT_SCAN_LIMIT. Reach the same behaviour throughlaunch(),stopBrowser(), andgetBrowserStatus().
import { getBrowserStatus } from "chrome-cdp-manager";
const status = await getBrowserStatus();
if (status.running) {
console.log(status.headless); // true | false
console.log(status.owner); // "manager" | "foreign"
console.log(status.pid, status.modeSource);
}getBrowserStatus() does not launch or stop the browser. Mode prefers the live
process argv (--headless / headless-shell); owner is "manager" only when the
endpoint matches this profile (session, DevToolsActivePort/pid proof, or argv
--user-data-dir + --remote-debugging-port).
Playwright bridge (optional)
If you want Playwright's high-level page API, opt into the separate entry point.
playwright is an optional peer dependency — no browser binary is installed
by this package.
import { connect } from "chrome-cdp-manager/playwright";
await using session = await connect({ headless: false, match: u => u.includes("bing.com") });
await session.page.goto("https://www.bing.com");
// `await using` detaches the CDP channel on scope exit; the browser keeps running.connect() resolves config, ensures the launcher + a running browser, connects
Playwright over CDP, and returns { browser, context, page, config }. In
zero-install (npx) setups where Playwright can't be resolved automatically,
inject it: connect({ chromium }).
Surviving sleep / dropped connections
The CDP channel is a websocket from your process to Chrome, and it does not
survive a host sleep/suspend — macOS tears the socket down, so on wake every
page/context/browser handle throws Target page, context or browser has
been closed, even though Chrome itself is still running.
connect() handles this for you: by default (autoReconnect: true) it
re-launches Chrome if needed and re-attaches with backoff, refreshing the live
handles behind the session.browser / session.context / session.page
getters. The one call that was in flight when the socket dropped still rejects
(Playwright can't replay it) — so wrap each unit of work in session.run(),
which catches a connection-closed error, reconnects, and retries:
await using session = await connect({ match: u => u.includes("bing.com") });
for (const q of queries) {
// Derive locators INSIDE run(): each attempt gets a fresh, live page.
await session.run(async (page) => {
const box = page.locator('textarea[name="q"]').first();
await box.fill(q);
await page.keyboard.press("Enter");
await page.waitForURL(u => u.searchParams.get("q") === q);
});
}The golden rule: derive locators inside run(), never capture them outside
— a locator (or page) captured before a reconnect points at a dead handle.
Lower-level hooks if you'd rather drive the recovery yourself: session.page
(getter, always the live page), session.whenConnected() (await an in-flight
reconnect), session.reconnect() (force one), and onReconnect(session) (fires
after each re-attach). The exported isConnectionClosedError(err) tells a
dropped socket apart from a genuine automation failure.
Note on the idle watchdog. A headless instance has an auto-quit watchdog that closes it once idle. The watchdog can't see Playwright traffic (it only watches CDP target changes), so a live
connect()session bumps an activity heartbeat (every 15s, and on everyrun()) to keep an actively-driven browser from being closed out from under you. If it is closed anyway — a long idle gap betweenrun()calls, or a sleep past the idle budget —run()simply re-launches it on the next call. To opt out of auto-quit entirely, launch withconfig: { idleTimeoutMinutes: 0 }.
Playwright CLI (playwright-cli)
The package also installs a playwright-cli command that runs
Playwright's MCP CLI client against the Agent
instance managed here. It's a thin wrapper that, before handing off, guarantees:
A headed Agent browser is reachable over CDP. If one is already running on the port, it just attaches to it (no relaunch); otherwise it launches a fresh headed instance.
The environment points Playwright at that browser instead of spawning its own:
PLAYWRIGHT_MCP_CDP_ENDPOINT=http://localhost:9222 # from config (default 9222) PLAYWRIGHT_MCP_ISOLATED=false
Everything after playwright-cli is forwarded verbatim to the Playwright CLI:
# Drive the attached browser
playwright-cli goto https://example.com
playwright-cli click "Sign in"
# Help / version forward straight through (no browser launch)
playwright-cli --helpThe Playwright CLI ships via playwright-core (a dependency of this package, so
it works out of the box — no browser download). The endpoint port follows your
saved config (~/.config/chrome-cdp-manager/config.json).
Common options
| Option | Description |
| ----------------------- | ------------------------------------------------------------- |
| --port <port> | CDP port (default 9222) |
| -p, --profile <dir> | Browser user-data-dir (default ~/.chrome_cdp_profile) |
| -b, --browser <name> | Browser: chrome, edge, brave, chromium, vivaldi, opera, arc |
| --path <path> | Explicit browser executable (overrides --browser) |
| --target <path> | Launcher location (.app / .lnk / Linux script) |
| --proxy [server] | Route traffic through a proxy; omit the value for socks5://127.0.0.1:1080. Accepts a port (1080), host:port, or scheme://host:port (socks5, socks4, http, https) |
| --no-proxy | Disable any configured proxy for this run |
| -t, --timeout <secs> | Startup / load timeout (open, html) |
| --idle-timeout <min> | Auto-quit an idle headless instance after N minutes; 0 disables (default 0.5, i.e. 30 seconds). Persist with setup; override per-run on open / html |
-c, --chrome and --bundle remain as aliases for --path and --target.
Browser, port, profile and launcher path are baked into the launcher and
persisted (~/.config/chrome-cdp-manager/config.json) at setup time so every
command agrees on the same environment. Re-run setup --force after changing
them.
Busy CDP port
If the configured port (default 9222) is already taken — a foreign CDP
browser, a proxy, anything — the launch moves to the next free port above it
(up to 100 ports) instead of failing or attaching to something it does not own:
$ chrome-cdp open https://example.com
Port 9222 busy; launched on CDP port 9223 instead.
DevTools: http://127.0.0.1:9223/jsonThe switch is per launch: neither the saved config nor the launcher is
rewritten, so the next cold start tries 9222 again. Later commands still find
the instance — status, html and stop look at the configured port, the
session sidecar, and Chrome's own DevToolsActivePort file. Programmatic
callers read the live port from the launch result:
const { cdpPort, requestedPort, portSwitched, endpoint } = await launch();
// portSwitched === true → this call launched past a busy requestedPort
// cdpPort !== requestedPort with portSwitched === false
// → an instance was already running over theregetBrowserStatus() mirrors this with config.cdpPort (live), requestedPort
and portChanged. Pass portScanLimit to launch() to widen or narrow the
search.
The free-port check and the browser's own bind are not atomic. If something grabs the port in between, the browser starts without a debugging port and the launch fails with an error saying exactly that — close it and retry.
Proxy
--proxy passes Chrome's --proxy-server flag at launch so traffic routes
through it. The default is a local SOCKS5 proxy on port 1080 (the common
ssh -D / shadowsocks default). The proxy is a per-launch flag — it is not
baked into the launcher, so a normal (non-proxied) launch is never affected by
it. Before launching, the proxy port is probed and a warning is printed if
nothing is listening; status reports the configured proxy and whether it's
reachable. Persist a default proxy with chrome-cdp setup --proxy …, or opt out
of a persisted proxy for a single run with --no-proxy.
Idle auto-shutdown
A headless instance has no window, so it's easy to forget one running in the
background where it competes with your normal Chrome. When a headless instance is
launched, a small detached watchdog quits it once it has been idle — no new tabs,
navigations, or CLI commands — for --idle-timeout minutes (0.5 by default,
i.e. 30 seconds; set 1, 10, fractions like 0.5, etc., or 0 to disable).
Headed instances are never auto-quit —
you manage those directly. Persist a default with
chrome-cdp setup --idle-timeout 10; status shows the effective timeout and
whether a watchdog is currently active.
How the icon stays consistent
macOS — the bundle's executable is a small bash launcher:
exec arch -arm64 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/.chrome_cdp_profile" \
"$@"Headed and headless launches go through open -n /Applications/Agent.app, so
LaunchServices runs the browser under the Agent bundle identity — a consistent
Dock icon every time instead of a generic/duplicated entry, and one icon to
quit whichever mode is running.
Windows — a Start Menu .lnk shortcut (Agent.lnk) is created with the
browser as its target, the CDP flags as its arguments, and the browser's icon.
Because the dedicated --user-data-dir gives the browser its own
AppUserModelID, the Agent window gets its own pinnable taskbar entry,
separate from your everyday browser windows.
Linux — a bash launcher at
~/.local/share/chrome-cdp-manager/Agent pins the same CDP flags and
profile (including the headless relaunch guard). A companion
~/.local/share/applications/chrome-cdp.desktop entry makes it show up in the
app menu with the browser's icon when available.
Development
The source, CLI, scripts, and tests are written in strict TypeScript 7.0. The
published package contains compiled ESM JavaScript and declaration files under
dist/.
npm ci
npm run check # strict type-check, including tests and build scripts
npm test # unit tests
npm run test:coverage # CI coverage thresholds
npm run build # compile ESM + declarations
npm run pack:check # validate the npm package contentsCI runs on Ubuntu and Windows with Node.js 22.
