Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"$schema": "./node_modules/fallow/schema.json",
// e2e/*.test.ts files are vitest entry points (not reached by static import from any
// other source file), and e2e/fake-driver/index.ts is loaded dynamically by the
// daemon via PITLANE_DRIVERS_MODULE (see docs/CLI.md) -- neither is visible to
// daemon via SIMLOCK_DRIVERS_MODULE (see docs/CLI.md) -- neither is visible to
// fallow's static reachability graph without being declared explicitly.
"entry": ["src/index.ts", "src/cli/main.ts", "src/daemon/main.ts", "e2e/**/*.test.ts"],
"dynamicallyLoaded": ["e2e/fake-driver/index.ts"],
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist/
*.log
.DS_Store
.fallow/
.claude/settings.local.json
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Agent guide

Pitlane is a control plane for iOS simulators and Android emulators that lets
Simlock is a control plane for iOS simulators and Android emulators that lets
parallel coding agents lease devices without fighting over them.

## Rules — read before writing code
Expand Down
48 changes: 24 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
### A control plane so parallel coding agents stop fighting over the same simulator

Pitlane is a CLI-first control plane for iOS simulators and Android
Simlock is a CLI-first control plane for iOS simulators and Android
emulators, built for environments where multiple coding agents run on one
machine at the same time. Agents don't touch `simctl` or `avdmanager`
directly — they ask Pitlane for a device and get one back, booted and
directly — they ask Simlock for a device and get one back, booted and
health-checked, that no other agent will touch until they're done with it.

## Why you'd want this
Expand All @@ -12,10 +12,10 @@ health-checked, that no other agent will touch until they're done with it.
that need a device grab whatever `simctl` / `avdmanager` happens to show
them. Two agents that pick the same one start booting, erasing, and
installing over each other — without either ever knowing the other exists.
Pitlane gives them a single primitive instead: **lease a device**.
Simlock gives them a single primitive instead: **lease a device**.

**You don't provision devices by hand.** If nothing matching is free,
Pitlane provisions one itself, up to a capacity limit it derives from the
Simlock provisions one itself, up to a capacity limit it derives from the
machine's CPU and RAM. Once that limit is reached, further requests block
and wait in a fair queue rather than failing outright, with `--timeout` and
`--no-wait` escape hatches for callers that want different behavior.
Expand All @@ -25,15 +25,15 @@ using is shut down after a short idle period to reclaim RAM, then deleted
after a longer one to reclaim disk — automatically, in tiers.

**A crashed simulator doesn't just quietly cost you a device.** If a leased
device's process dies outside pitlane, Pitlane notices, reboots it under the
device's process dies outside simlock, Simlock notices, reboots it under the
same lease, and tells the holder — it can't restore whatever was running
inside the device when it died, but the lease and its device don't just
vanish.

**It's advisory, not a sandbox.** Pitlane doesn't wrap or intercept
**It's advisory, not a sandbox.** Simlock doesn't wrap or intercept
`simctl` / `avdmanager` — it works because agents are instructed to only use
devices handed to them by a lease. What it _does_ enforce is its own blast
radius: Pitlane only ever shuts down, erases, or deletes devices it created
radius: Simlock only ever shuts down, erases, or deletes devices it created
itself. Everything else on the machine is read-only to it.

**Built for agents first, humans second.** Lease results are one JSON line
Expand All @@ -45,7 +45,7 @@ form instead.
## What it looks like

```sh
pitlane lease --platform ios --device "iPhone 16" --detach
simlock lease --platform ios --device "iPhone 16" --detach
```

```json
Expand All @@ -64,7 +64,7 @@ back an identified, ready-to-use device. Release it explicitly, or let its
TTL expire.

Now say a second agent asks for the same `iPhone 16` a moment later. It's
already leased to the first agent — no problem, Pitlane just provisions
already leased to the first agent — no problem, Simlock just provisions
another one. Progress streams as JSON lines on stderr while it happens, and
the lease result lands on stdout the moment the new device is ready:

Expand Down Expand Up @@ -97,10 +97,10 @@ get the same lease/release workflow as tools, through a local stdio server:
```json
{
"mcpServers": {
"pitlane": {
"command": "pitlane",
"simlock": {
"command": "simlock",
"args": ["mcp"],
"env": { "PITLANE_AGENT_ID": "agent-1" }
"env": { "SIMLOCK_AGENT_ID": "agent-1" }
}
}
}
Expand All @@ -111,28 +111,28 @@ get the same lease/release workflow as tools, through a local stdio server:
```sh
pnpm install
pnpm build
pitlane lease --platform ios --device "iPhone 16" --detach
pitlane status --json
simlock lease --platform ios --device "iPhone 16" --detach
simlock status --json
```

The daemon starts on demand — there's no separate setup step. Use
`pitlane doctor` to reconcile managed state with reality, and
`pitlane nuke --yes --delete-devices` only for an emergency reset of
Pitlane-managed devices.
`simlock doctor` to reconcile managed state with reality, and
`simlock nuke --yes --delete-devices` only for an emergency reset of
Simlock-managed devices.

See [docs/CLI.md](docs/CLI.md) for the full command reference and
[docs/CLI.md#mcp-integration-optional](docs/CLI.md) or the [README section
below](#mcp-integration-optional) for wiring up an MCP client.

## MCP integration (optional)

The CLI remains Pitlane's primary, full operator interface. MCP is a
The CLI remains Simlock's primary, full operator interface. MCP is a
narrower, agent-focused integration: it intentionally exposes neither
status, configuration, events, lease renewal, nor destructive or other
operator commands. Start it with `pitlane mcp` — it reserves stdout for MCP
operator commands. Start it with `simlock mcp` — it reserves stdout for MCP
JSON-RPC, so lease results never mix with protocol framing.

`PITLANE_AGENT_ID` sets the server's stable requester identity. Pitlane
`SIMLOCK_AGENT_ID` sets the server's stable requester identity. Simlock
allows at most one active lease per identity, so give each agent session a
distinct, stable id — run one MCP server process per agent session, each
with its own id.
Expand All @@ -142,26 +142,26 @@ what can be leased), `lease_simulator`, `release_simulator`, and
`lease_status` (cheap, safe to poll after a context compaction to check
whether a device is still held). Full tool contracts, progress reporting,
and lease-loss notifications are documented in
[docs/CLI.md](docs/CLI.md#pitlane-mcp).
[docs/CLI.md](docs/CLI.md#simlock-mcp).

## Documentation

- [docs/ABOUT.md](docs/ABOUT.md) — what the tool is and the problem it solves, in short form
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how the daemon, drivers, and frontends fit together
- [docs/CLI.md](docs/CLI.md) — the full command reference
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — every config key, its default, and how limits interact
- [docs/EVENTS.md](docs/EVENTS.md) — catalog of business events on `pitlane events`
- [docs/EVENTS.md](docs/EVENTS.md) — catalog of business events on `simlock events`
- [docs/known-pitfalls.md](docs/known-pitfalls.md) — accepted gaps and their planned fixes
- [docs/IDEAS.md](docs/IDEAS.md) — post-v1 ideas, not yet built

## Made with ❤️ at Callstack

`pitlane` is an open source project and will always remain free to use. If
`simlock` is an open source project and will always remain free to use. If
you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love]
is a group of React and React Native geeks, contact us at
[hello@callstack.com](mailto:hello@callstack.com) if you need any help with
these or just want to say hi!

Like the project? ⛸️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥

[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=pitlane&utm_term=readme-with-love
[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=simlock&utm_term=readme-with-love
14 changes: 7 additions & 7 deletions docs/ABOUT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Pitlane
# Simlock

Pitlane is a control plane for iOS simulators and Android emulators, built for
Simlock is a control plane for iOS simulators and Android emulators, built for
environments where multiple coding agents run in parallel on one machine.

## The problem
Expand All @@ -11,15 +11,15 @@ and installing over each other — without ever knowing the other exists.

## The solution

Pitlane is a CLI-first control plane (backed by a local daemon) that is the
Simlock is a CLI-first control plane (backed by a local daemon) that is the
same for both platforms and gives agents one primitive: **lease a device**.
An optional local stdio MCP integration exposes the focused lease/release
workflow to compatible agent clients; the CLI remains the full operator
interface.

- `pitlane lease` returns a *ready* device — booted and health-checked — that
- `simlock lease` returns a *ready* device — booted and health-checked — that
no other agent will touch for the duration of the lease.
- If no matching device is free, pitlane **provisions** one, up to a
- If no matching device is free, simlock **provisions** one, up to a
configurable capacity limit derived from the machine's CPU and RAM.
- If the limit is reached, the CLI **blocks and waits** in a fair queue until a
device frees up (with `--timeout` and `--no-wait` escape hatches).
Expand All @@ -28,14 +28,14 @@ interface.

## Key properties

- **Advisory coordination.** Pitlane does not sandbox anything. It works
- **Advisory coordination.** Simlock does not sandbox anything. It works
because agents are instructed to never call `simctl` / `avdmanager` directly
and to only use devices handed to them by a lease.
- **Process-held leases.** The `lease` command stays running in the
background; the open connection to the daemon is the heartbeat. Killing the
process releases the lease. A daemon-side TTL is the backstop for zombies.
- **One lease per agent** (v1).
- **Managed-device registry.** Pitlane only ever shuts down, erases, or
- **Managed-device registry.** Simlock only ever shuts down, erases, or
deletes devices it created itself. Everything else on the machine is
read-only to it.
- **Agent-first output.** CLI lease results are one JSON line on stdout;
Expand Down
34 changes: 17 additions & 17 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
## Topology

```
agent ──spawns──> pitlane CLI ──┐
├─ shared daemon client ──unix socket──> pitlane daemon
agent ──spawns──> simlock CLI ──┐
├─ shared daemon client ──unix socket──> simlock daemon
MCP client ──spawns──> stdio MCP ┘ │
┌───┼─────────────┐
│ core (platform-│
Expand Down Expand Up @@ -76,7 +76,7 @@ reclaim(device) -> ready | shutdown // fresh-state strategy lives here
shutdown(device)
destroy(device)
estimate(op) -> ETA for progress events
listManaged() -> Pitlane-prefixed device/process reality for doctor
listManaged() -> Simlock-prefixed device/process reality for doctor
```

The litmus test for the boundary: adding a third driver (e.g. physical
Expand Down Expand Up @@ -138,7 +138,7 @@ provisioning → ready → leased → reclaiming → ready/shutdown → deleted
ready/shutdown/deleted
```

All transitions go through the core. `pitlane status` reads identically for
All transitions go through the core. `simlock status` reads identically for
iOS and Android because of this.

A warm device is derived inventory, not a state: any registry-managed,
Expand All @@ -160,7 +160,7 @@ against capacity, not grantable" is expressed by adding its own entry into
`quarantined`, not by inventing a second state: the release-time purge
failure (`reclaiming → quarantined`) and the stalled-transition timeout
(`provisioning → quarantined`, both owned by `QuarantineCoordinator`) are its
two entries. The latter fires from `pitlane doctor`'s `stalled-transition`
two entries. The latter fires from `simlock doctor`'s `stalled-transition`
finding — a `provisioning`/`reclaiming` device whose time in that state has
outrun a driver-derived threshold, meaning the driver call meant to resolve
it never did and the registry's view has diverged from the driver's. Safer
Expand Down Expand Up @@ -212,11 +212,11 @@ Conclusions baked into the drivers:
connection. For the CLI, that is the CLI process; for MCP, it is the MCP
server process for that agent session. Connection close = release. The CLI
holder additionally watches its parent through the `ParentWatch` port and
self-terminates if it dies, so a crashed agent's backgrounded `pitlane
self-terminates if it dies, so a crashed agent's backgrounded `simlock
lease` cannot outlive it by getting reparented — see
[known-pitfalls.md](known-pitfalls.md).
- **Detached mode (`--detach`)**: returns a token, daemon enforces a TTL, the
agent must `pitlane renew` periodically.
agent must `simlock renew` periodically.
- **TTL backstop**: even held leases have a long daemon-side TTL for zombie
sockets, machine sleep, etc.
- **Heartbeat-driven sliding TTL, capability-gated**: a held lease's backstop
Expand Down Expand Up @@ -260,7 +260,7 @@ The device is not lost track of while that runs. It is `reclaiming`, so it
still counts as running capacity and is invisible to every grant path
(`AcquisitionPlanner` selects by exact state), and the reclaim holds a
`reclaim` operation claim for its whole duration — which is how
`StartupConverger#recoverInterruptedReclaims` and `pitlane doctor`'s
`StartupConverger#recoverInterruptedReclaims` and `simlock doctor`'s
stalled-transition finding both tell a live purge from an abandoned one. A
waiter queued for exactly that device is granted the moment the purge settles:
the coordinator re-notifies acquisition *after* releasing the claim, because
Expand All @@ -273,7 +273,7 @@ Three things still wait for the purge, deliberately:
records, so a device left mid-reclaim would be skipped by the very reset
meant to take it down. `beginMaintenance` drains in-flight background
reclaims, and the maintenance-authorized release awaits its own inline.
- **A graceful `pitlane daemon stop`.** It drains the in-flight reclaims
- **A graceful `simlock daemon stop`.** It drains the in-flight reclaims
(before disposing timers, so a purge that settles into quarantine still gets
its retry cancelled), leaving the pool in the same settled shape an inline
reclaim used to.
Expand Down Expand Up @@ -326,10 +326,10 @@ nuke interfaces rather than duplicating core decisions in the CLI or server.

`Doctor.reconcile()` already knew a leased device could crash: its
`expectedRunState` maps `leased -> "running"`, so a leased device whose
process an operator kills from outside pitlane produces a
process an operator kills from outside simlock produces a
`foreign-state-change` finding. What was missing was anything that acted on
that finding at the moment it mattered. `reconcile()` only ran at daemon
startup and from an explicit `pitlane doctor`, so a crash between those
startup and from an explicit `simlock doctor`, so a crash between those
points sat undetected indefinitely. And even a `doctor --fix` run that saw it
couldn't repair it: `#fixForeignStateChange` bails on a leased device, the
cleanup reaper filters leased targets centrally before a rule ever runs, and
Expand Down Expand Up @@ -390,7 +390,7 @@ should.

None of this is silent. A reboot resumes the lease, but it cannot resume
whatever the agent had running *inside* the device when it died — a launched
app, a `log stream`, an Appium/XCUITest session, a port forward — pitlane has
app, a `log stream`, an Appium/XCUITest session, a port forward — simlock has
no way to know that state existed, let alone restore it. So the monitor emits
`device.crash-detected` the moment a crash is confirmed and `device.recovered`
once the reboot passes readiness; the daemon pushes both to whichever
Expand Down Expand Up @@ -434,14 +434,14 @@ Reaper triggers are observer subscriptions to `lease.released`,
reaper itself emits `disk.pressure-detected` (edge-triggered, once per
crossing) as a post-commit fact for observers — never as the mechanism that
drives `idle-destroy`'s own behavior. Every successful action emits its rule
and reason in `cleanup.executed`; `pitlane cleanup --dry-run` previews
and reason in `cleanup.executed`; `simlock cleanup --dry-run` previews
proposals.

## Event bus

An in-process, typed event bus carries **past-tense business facts**
(`device.reclaimed`, `lease.expired`). Observers — cleanup triggers,
logging/metrics, `pitlane events --follow` — subscribe to it. Warm-pool
logging/metrics, `simlock events --follow` — subscribe to it. Warm-pool
reclaim/disposition, cleanup execution, startup convergence, eviction, and
nuke remain explicit direct component call chains.

Expand Down Expand Up @@ -534,11 +534,11 @@ destruction, never touching a leased device) is unsafe to have in flight
during shutdown; it just means "stopped" is not instantaneous relative to the
failure being reported. `health` itself does not grow a third state for this:
`running` means convergence finished, not that every backgrounded reclaim it
kicked off has settled — `pitlane status` already reports each device's own
kicked off has settled — `simlock status` already reports each device's own
state (`reclaiming` included), so a separate aggregate would duplicate
information already visible per-device rather than add any.

Operational logging is a separate concern from the event bus: `pitlane events`
Operational logging is a separate concern from the event bus: `simlock events`
carries business facts (lease granted, device cleaned up, …) in an in-memory
ring buffer that resets on restart, while the `Logger` port writes durable,
structured JSON lines — one per record — for startup, socket claim/recovery,
Expand All @@ -549,7 +549,7 @@ module-scoped children (`logger.child("server")`, `.child("connection-host")`,
`.child("driver-discovery")`) to each component so every line is attributable.
The sink tracks bytes written and rotates `daemon.log` to `daemon.log.1`
(replacing any previous generation) once `config.log.rotateBytes` is exceeded,
so growth is bounded and `pitlane daemon logs` reads the rotated generation
so growth is bounded and `simlock daemon logs` reads the rotated generation
before the current file. The one exception is the fatal top-level handler: it
cannot depend on `config.log` having loaded successfully, so it builds its own
logger straight from the default log path at a fixed level, falling back to
Expand Down
Loading
Loading