Identity-aware runtime for parallel coding agents -- persistent browser pool + lease broker over CDP
Project description
escarp
Escarp leases OS windows, not just CDP endpoints. A single broker daemon
owns a pool of persistent Chrome for Testing windows, binds each one to a
stable OS-level window identity (kCGWindowNumber on macOS), and hands those
identities — along with their CDP transport URLs — to coding agents over MCP.
The mental model:
- CDP is the automation transport (deterministic, no visible cursor, concurrent-safe).
- CUA is the visible interaction transport (real cursor, OS overlays, native dialogs).
- The OS window identity is the common anchor. Whether your agent drives via CDP or CUA, both ultimately act on the same persistent OS window. Escarp's job is to make that window addressable and verifiable.
Install
pip install escarp
Requirements: Python 3.11+ and a Chrome for Testing binary on disk. On macOS you also get OS-window identity binding for free (via PyObjC).
npx @puppeteer/browsers install chrome@stable
Quick start
escarp launch-pool # spawn N detached CfT windows (default 4)
escarp daemon & # discover them, broker leases, run reaper
escarp setup codex # or: setup claude-code
Look at the pool:
escarp window 0
# slot 0
# os_window_id: 2290
# owner_pid: 9267
# app: Google Chrome for Testing
# bounds: x=40 y=80 w=760 h=580
# cdp_port: 9222
# cdp_window_id: 1373420132
# cdp_target_id: 0B24D11F2E538119159DC810FD2C4914
# verified_alive: true
# verified_app: true
# verified_bounds: true
# verified_key: false
# note: not key/frontmost; window 2300 (pid 9282, ...) is.
Every field after verified_* is queried live — not from cached state.
Lease + drive a window:
escarp acquire --holder me --focus --prompt
# ... drive via Codex Desktop / Playwright / chrome-devtools-mcp ...
escarp release --mine
Commands
| Command | Purpose |
|---|---|
escarp launch-pool |
Spawn N detached Chrome for Testing windows. One-shot; chromes outlive this command. Idempotent (skips slots already listening). |
escarp daemon |
Discover live chromes, calibrate each to an OS-window identity, broker leases on 127.0.0.1:7878, run the reaper. Does NOT own chrome lifecycles. |
escarp window <slot> |
Print and actively verify a slot's OS-window identity. The v1.1 primitive. Returns os_window_id, owner_pid, bounds, plus verified_alive/verified_app/verified_bounds/verified_key — every check queried against the live OS at the moment of the call. Supports --json and --verify-key (exit nonzero if not key/frontmost). |
escarp acquire --holder X [--focus] [--prompt] |
Lease a slot. Persists token to ~/.escarp/leases.json. |
escarp release {--mine | --slot N | --holder NAME | --token T} |
Token-free release for humans. |
escarp focus <slot> |
Best-effort helper that uses the OS-window identity to bring a slot's window forward. CDP Page.bringToFront + osascript activate + AX raise by geometric match, with post-focus verification against os_window_id. Reports success only when the identity check confirms the right window is key. |
escarp setup codex |
Idempotent: preflight (CfT, daemon, pool, MCP path, codex CLI), register escarp-mcp, smoke test. |
escarp setup claude-code |
Same shape, for Claude Code. |
The MCP shim
Register the bundled MCP server:
escarp setup codex # or: escarp setup claude-code
The model gets three tools that return structured identity, not text:
// escarp_acquire returns:
{
"slot": 1,
"os_window_id": 2300,
"owner_pid": 9282,
"bounds": {"x": 820, "y": 80, "width": 760, "height": 580},
"cdp_port": 9223,
"cdp_window_id": 783471201,
"cdp_target_id": "...",
"cdp_ws_url": "ws://127.0.0.1:9223/devtools/browser/...",
"dev_port": null,
"expires_at": 1780174370,
"auto_heartbeat_interval_s": 60.0
}
escarp_status returns the same shape per slot. Auto-heartbeat keeps the
lease alive while the shim is running.
CDP vs CUA — pick the right transport
Both transports act on the same persistent OS window. Pick by what you need:
| Property | CUA (Codex Desktop) | CDP (Playwright / chrome-devtools-mcp) |
|---|---|---|
| Visible OS cursor | ✅ moves on screen | ❌ no cursor movement |
| Native OS overlays (file pickers, downloads, password manager, permission sheets) | ✅ fully supported | ❌ DOM only |
| Determinism | screenshot + AX tree per turn | deterministic CDP commands |
| Concurrent multi-window | ❌ one CUA-controlled window per app bundle — see below | ✅ true parallel agents |
| Window targeting | by app + frontmost (escarp owns making the right window frontmost) | by leased cdp_ws_url (window-addressable directly) |
| Best for | end-user-facing tasks, demos, anything with native UI | dev automation, parallel test runs, headless |
Why "one CUA per app bundle": Codex CUA's addressing model is per-app — it
operates on the key window of an app per turn. Two CUA sessions against two
CfT windows would both resolve to "the CfT app → its frontmost window." The
research/cua_targeting.md report has the
static-analysis evidence.
Escarp's job for native CUA work is therefore: make the right persistent
window the OS-foreground key window, then have the agent act on it. The
identity primitive (escarp window <slot>) is how you verify the handoff
succeeded — without it, focus is just narrative.
HTTP API
| Verb | Body | Returns |
|---|---|---|
GET /status |
-- | Pool snapshot incl. os_window_id, owner_pid, bounds, cdp_window_id per slot |
POST /acquire |
{"holder": str, "slot"?: int, "dev_port"?: int} |
Lease record with full identity payload |
POST /heartbeat |
{"lease_token": str} |
Refreshed lease |
POST /release |
{"lease_token": str} |
Lease in state: free |
GET /reaped |
-- | Last 50 TTL-expired reclamations (debug) |
Architecture
Control plane: slot allocator with kernel-flock atomicity, OS-window
calibration (Chromium Browser.setWindowBounds to a unique per-slot
rectangle, then CGWindowListCopyWindowInfo to bind by bounds), lease broker
with TTL + reaper, HTTP API on 7878, MCP shim. Data plane: Codex CUA via
OS Accessibility, or Playwright / Chrome DevTools MCP / any CDP client over
the leased cdp_ws_url. Escarp provisions and points; it does not proxy
clicks.
The persistence contract is the load-bearing trick: chromes are launched
detached (start_new_session=True) and reparent to launchd/init. The daemon
discovers them by /json/version; it never owns their lifecycle. Kill the
daemon, chromes stay up. Kill an agent mid-task, the reaper reclaims its
lease within one sweep interval (default 2 s). On every release boundary the
broker creates a fresh about:blank tab and closes the rest — no state
inherits across holders.
See V2_PLAN.md for the v0→v2 design notes and
research/cua_targeting.md for the CUA
addressing analysis.
Demos
# Two holders, two browsers, asyncio.gather lockstep concurrency (CDP).
# Proves the lease/concurrency model on the CDP transport.
uv run python scripts/demo_two_holders_concurrent.py
# Lease-boundary reset: drive to YouTube, release, watch the tab snap back.
uv run python scripts/demo_reset_on_release.py
Single-window native-CUA flow:
escarp acquire --holder cua-demo --focus --prompt
# paste the printed preamble into Codex Desktop, append a task
# verify the bridge:
escarp window <slot> --verify-key # exits 0 iff the right OS window is key
escarp release --mine
Configuration
| Env var | Default | What |
|---|---|---|
ESCARP_POOL_SIZE |
4 |
Number of browser slots |
ESCARP_CDP_BASE |
9222 |
cdp port for slot 0; slot N uses base+N |
ESCARP_API_PORT |
7878 |
Broker HTTP API port (bind-and-shift on collision) |
ESCARP_LEASE_TTL_S |
60 |
Lease expiry; reaper reclaims past this |
ESCARP_CFT_BINARY |
autodetect | Path to Chrome for Testing binary |
ESCARP_BROKER_URL |
http://127.0.0.1:7878 |
Where the MCP shim looks for the broker |
ESCARP_LEASES_FILE |
~/.escarp/leases.json |
Local cache of lease tokens for escarp release --mine |
Per-slot resource derivation
slot s -> frontend = 3000 + s*10
backend = 8000 + s*10
postgres = 5432 + s*10
cdp_port = 9222 + s
user_data = ~/.escarp/profiles/<tier>/slot-<s>
os_window_id = <calibrated at daemon startup>
Status
v1.1.0.
Claims that hold:
- Each slot has a stable, actively-verifiable OS-window identity (macOS today).
escarp window <slot>queries it live. - Two agents on different slots drive their own leased CfTs over CDP without colliding.
- Killing an agent returns its browser within one reaper interval.
- Pool exhaustion returns a structured 409, not a hang.
- No lockfiles outside the broker's single source of truth.
Claims that do NOT hold (and aren't claimed):
- Two concurrent native Codex CUA agents on two CfT windows. Not supported today — CUA addresses by app, not window. Use CDP for concurrent multi-agent work; use CUA for single-window high-fidelity work.
Not in 1.1: delegated and supervised identity tiers, cross-machine pooling, Linux/Windows OS-window calibration.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file escarp-1.1.0.tar.gz.
File metadata
- Download URL: escarp-1.1.0.tar.gz
- Upload date:
- Size: 46.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4099e6a09a34c015842f13e0e705af519885baa8d566389354d3fc10f30b9a8a
|
|
| MD5 |
9c797c3e16bc77d327a43d0522a02a54
|
|
| BLAKE2b-256 |
4d49482fd60b625ced66bd6169446f720bc21fd00b71c92e6823a2ba3299e8d9
|
Provenance
The following attestation bundles were made for escarp-1.1.0.tar.gz:
Publisher:
publish.yml on ddavidgao/escarp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
escarp-1.1.0.tar.gz -
Subject digest:
4099e6a09a34c015842f13e0e705af519885baa8d566389354d3fc10f30b9a8a - Sigstore transparency entry: 1676455048
- Sigstore integration time:
-
Permalink:
ddavidgao/escarp@87e947cd5ce9e82f5c5ed69b7cb2bb61fea6bd17 -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/ddavidgao
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@87e947cd5ce9e82f5c5ed69b7cb2bb61fea6bd17 -
Trigger Event:
push
-
Statement type:
File details
Details for the file escarp-1.1.0-py3-none-any.whl.
File metadata
- Download URL: escarp-1.1.0-py3-none-any.whl
- Upload date:
- Size: 51.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1aa443beff3f3c777ebedc01d95a77c0afb55a3a1d12535ae141bdba2a03077b
|
|
| MD5 |
46145f0d0eb2c0cba6abc814c71362fb
|
|
| BLAKE2b-256 |
70f111b38dd10af7d84128ac762bcbd6d33ca51124b48e2a52a34dff394f8ba2
|
Provenance
The following attestation bundles were made for escarp-1.1.0-py3-none-any.whl:
Publisher:
publish.yml on ddavidgao/escarp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
escarp-1.1.0-py3-none-any.whl -
Subject digest:
1aa443beff3f3c777ebedc01d95a77c0afb55a3a1d12535ae141bdba2a03077b - Sigstore transparency entry: 1676455074
- Sigstore integration time:
-
Permalink:
ddavidgao/escarp@87e947cd5ce9e82f5c5ed69b7cb2bb61fea6bd17 -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/ddavidgao
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@87e947cd5ce9e82f5c5ed69b7cb2bb61fea6bd17 -
Trigger Event:
push
-
Statement type: