ecluse
Ephemeral local environments for coding agents — any stack.
Each git worktree gets its own slot — isolated ports, isolated services, isolated data. Works whether your stack runs in Docker, on the host, or a mix. No collisions, clean teardown.
You're running 4 Claude Code sessions in parallel. Each agent finishes its task and wants to verify — run the test suite, spin up the app, hit the endpoints. But port 3000 is taken. Agent 2 kills agent 1's server. Agent 3 waits. The verification loop that was supposed to run in parallel is now sequential. You're paying for 4 agents and getting the throughput of one.
ecluse gives each agent its own slot: isolated ports, its own services, its own infra. All 4 agents spin up, verify, and tear down independently. The full AI verification loop — build, migrate, test, e2e — runs in parallel, without collisions, without waiting.
Create worktree → Spin up env → Do work → Verify → PR → Teardown
ecluse up feat-foo # new worktree, isolated ports, isolated services
ecluse up fix-bar # parallel session, different slot, zero collisions
ecluse down feat-foo # clean teardown, nothing left behind
ecluse is French for "canal lock" — each session gets its own chamber, everything is isolated, nothing leaks between them.
How it works
The central concept is a slot — an integer from 1 to max_slots. Every resource is derived from the slot:
- Per-service port:
base_port + slot(e.g.apiatbase_port=3000, slot 1 → 3001, slot 2 → 3002) - Compose project name:
<prefix>_<slug> - Named volumes:
<volume>_<prefix>_<slug>
Three thin mode implementations share this slot primitive. Mode is selected once at init time and stored in .ecluse.toml.
Install
Homebrew (recommended)
brew install hefgi/tap/ecluse
Cargo
cargo install ecluse
Requires Rust 1.85+.
Agent skill
Install the agent skill so your coding agent knows every command, mode, and workflow:
npx skills add hefgi/ecluse -g
| Command | |
|---|---|
| Global | npx skills add hefgi/ecluse -g |
| Project-local | npx skills add hefgi/ecluse |
Dependencies
For container and hybrid modes, OrbStack is recommended over Docker Desktop on macOS — faster, less memory. Docker Engine works on Linux.
Quick start
cd my-project
ecluse init # detects mode, writes .ecluse.toml
ecluse up feat-foo # creates worktree + slot
ecluse shell feat-foo # drops into worktree with env loaded
npm run dev # PORT, DATABASE_URL, etc. already set
Your app runs on a unique port. Other sessions run in parallel without touching yours. Type exit to leave the session.
Parallel sessions
ecluse up feat-foo # slot 1 — PORT=3001
ecluse up fix-bar # slot 2 — PORT=3002
ecluse up chore-baz # slot 3 — PORT=3003
Each session is fully independent. Tear down in any order:
ecluse down feat-foo
ecluse ls # fix-bar and chore-baz still running
Soft restart
Tear down services without losing your worktree, then spin them up fresh:
ecluse down feat-foo --keep-worktree # services torn down, worktree + branch kept
ecluse up feat-foo --reuse-worktree # new slot, fresh ports, worktree reused
Choosing a mode
ecluse init detects the right mode automatically. You confirm before anything is written.
| Mode | What ecluse up does | Best for |
|---|---|---|
container | Runs all services in Docker (app + data) | Fully containerized stacks, devcontainer repos |
hybrid | Runs data services in Docker, writes env — you start the app | Rails/Django/Node with a postgres+redis compose file |
host | Writes env vars only — starts nothing | Pure native stacks with no Docker |
container
Everything runs in Docker. ecluse generates a compose overlay that maps each service to a slot-specific port and names volumes per-slot. Nothing runs on the host outside of Docker.
Best when your repo already has a docker-compose.yml that runs the full stack.
host
Your app runs natively. ecluse allocates ports and writes them to .env.ecluse. No containers are started. Hooks handle any setup (migrations, seeding). If a command is set on a native [[services]] entry, ecluse spawns it via your configured process_manager (tmux or nohup).
Best when there are no data services or you manage them externally.
hybrid
Data services (Postgres, Redis, etc.) run in containers with per-slot ports. Your app runs natively and connects to them via the ports in .env.ecluse.
Best when you have a compose file for data services but run the app with npm run dev or similar. Add command = "npm run dev" to the native [[services]] entry and ecluse will spawn it automatically. See Hybrid mode setup for how to label your compose file.
Changing modes
Mode is stored in .ecluse.toml and shared by all sessions. To change mode, edit .ecluse.toml and tear down any existing sessions first — running sessions use the mode that was active when they were created.
Commands
ecluse init [--mode container|host|hybrid] [--explain] [--yes] [--quiet]
ecluse up <slug> [--branch <name>] [--watch] [--json] [--reuse-worktree] [--port <name>=<value>] [--quiet]
ecluse down <slug> [--keep-volumes] [--keep-branch] [--keep-worktree] [--quiet]
ecluse ls [--json]
ecluse shell <slug>
ecluse env [<slug>]
ecluse validate [--ports] [--quiet]
ecluse shutdown [--keep-volumes] [--keep-worktrees] [--quiet]
ecluse init
Detects the right mode for your repo and writes .ecluse.toml. Runs interactively — shows the detected mode and asks for confirmation before writing.
| Flag | Description |
|---|---|
--mode container|host|hybrid | Override detected mode |
--explain | Show detection signals |
--yes | Skip confirmation prompt |
--quiet | Suppress step output |
ecluse up
Creates a git worktree, allocates a slot, starts services, and writes .env.ecluse. Returns the worktree path and all env vars.
| Flag | Description |
|---|---|
--branch <name> | Use a specific branch name instead of the slug |
--watch | Stream service logs after startup |
--json | Output worktree path + env vars as JSON |
--reuse-worktree | Reuse an existing worktree instead of creating one |
--port <name>=<value> | Pin a service to a specific port for this session |
--quiet | Suppress step output (implied by --json) |
ecluse down
Tears down services, frees the slot, and removes the worktree.
| Flag | Description |
|---|---|
--keep-volumes | Preserve named Docker volumes |
--keep-branch | Keep the git branch (no-op — branches are never deleted by ecluse) |
--keep-worktree | Keep the worktree directory on disk |
--quiet | Suppress step output |
ecluse shutdown
Tears down all active sessions at once. Equivalent to running ecluse down on every session.
| Flag | Description |
|---|---|
--keep-volumes | Preserve named Docker volumes |
--keep-worktrees | Keep worktree directories on disk |
--quiet | Suppress step output |
ecluse shell
Drops into the worktree with all .env.ecluse variables loaded in the shell environment. Interactive use only.
If the session has a tmux session (i.e. process_manager = "tmux" was set at ecluse up time), this attaches to that session instead of spawning a new shell — you'll see the running service windows directly.
ecluse env
Prints the session's environment variables as JSON. Includes worktree_path and all ECLUSE_* vars.
ecluse ls
Lists active sessions. Use --json for machine-readable output.
ecluse validate
Validates port ranges in .ecluse.toml and checks for gaps or collisions. Use --ports to preview the full port allocation table across all slots. Also checks that the configured process_manager binary is installed (e.g. tmux or nohup).
| Flag | Description |
|---|---|
--ports | Print the full port allocation table for all slots |
--quiet | Suppress step output |
Configuration
.ecluse.toml lives at repo root and is written by ecluse init. All fields are optional except mode.
mode = "hybrid"
max_slots = 8
prefix = "ecluse"
worktree_dir = ".ecluse/worktrees"
# Port collision handling (both optional)
# strict_port = false # default: search for a free port on collision
# port_search_range = 10 # how many alternatives to try (bump by max_slots each time)
# One [[services]] block per service. port = base_port + slot.
# Native services run on the host; docker services run in containers.
# The first native entry also sets the PORT alias for framework compatibility.
# Add command = "..." to have ecluse spawn the process on ecluse up.
[[services]]
name = "api"
base_port = 3000 # slot 1 → ECLUSE_API_PORT=3001 + PORT, slot 2 → 3002
command = "npm run dev" # optional — ecluse spawns this on ecluse up
# port_env = "DJANGO_PORT" # optional — also set DJANGO_PORT to the allocated port
# port_env = ["DJANGO_PORT", "APP_PORT"] # or multiple aliases
[[services]]
name = "postgres"
run = "docker"
base_port = 5432 # slot 1 → ECLUSE_POSTGRES_PORT=5433, slot 2 → 5434
# Optional: lifecycle hooks (see Hooks page for full details)
[hooks]
post_up = "npx prisma migrate deploy"
pre_down = "npx prisma migrate reset --force"
Fields
| Field | Type | Default | Description |
|---|---|---|---|
mode | string | — | container, host, or hybrid |
max_slots | integer | 8 | Maximum parallel sessions |
prefix | string | "ecluse" | Prefix for compose project names and volume names |
worktree_dir | string | ".ecluse/worktrees" | Directory for git worktrees |
strict_port | bool | false | Fail immediately on port collision instead of searching |
port_search_range | integer | 10 | How many alternatives to try on collision |
app_label | string | "ecluse.role" | Docker Compose label key used to identify app vs data services in hybrid mode |
app_label_value | string | "app" | Value of app_label that marks a service as the app (not a data service) |
[[services]]
Each [[services]] block defines one service. Each gets a stable, collision-free port per slot (base_port + slot).
| Field | Type | Description |
|---|---|---|
name | string | Service name — becomes ECLUSE_<NAME>_PORT |
base_port | integer | Port formula: base_port + slot |
run | string | "docker" to run in a container; omit for native |
command | string | Shell command ecluse spawns on ecluse up (native services only; managed by your global process_manager setting) |
port_env | string or array | Extra env var names to set to this service's allocated port — accepts a single string or an array |
The first native (non-docker) service entry also sets PORT for framework compatibility.
Omit [[services]] entirely for single-service projects — ecluse falls back to PORT = 3000 + slot.
Global config (~/.config/ecluse/config.toml)
Controls how ecluse spawns native service processes. Written by ecluse init based on what's installed.
process_manager = "tmux" # "tmux" | "nohup" | "none"
| Value | Behaviour |
|---|---|
tmux | Creates a detached tmux session ecluse-<slug> with one window per service. ecluse shell <slug> attaches to it. |
nohup | Spawns each service as a background process. Logs at .ecluse/logs/<slug>/, PIDs at .ecluse/pids/<slug>/. |
none | Spawns nothing — current behaviour before this was added. |
ecluse init sets this automatically: tmux if available, otherwise nohup. Change it at any time — the setting is per-machine, not per-repo.
Run ecluse validate to check the configured binary is installed.
[hooks]
| Field | When it runs | Env vars |
|---|---|---|
pre_up | Before any infrastructure is created | None (runs from repo root) |
post_up | After all services are up and .env.ecluse is written | All ECLUSE_* + PORT |
pre_down | Before services are stopped | All ECLUSE_* + PORT |
post_down | After all services are stopped and worktree is removed | All ECLUSE_* + PORT |
Use post_up for migrations and seeding, pre_down for teardown that needs a live database. See the Hooks page for full details and examples.
Environment variables
Every session writes a .env.ecluse file in the worktree. ecluse up --json and ecluse env return these as JSON.
| Variable | Description |
|---|---|
ECLUSE_SLOT | Slot number (integer, 1–max_slots) |
ECLUSE_MODE | Active mode (container, host, or hybrid) |
ECLUSE_SLUG | Session name (the slug passed to ecluse up) |
PORT | Alias for the first native [[services]] entry — framework-compatible |
ECLUSE_<NAME>_PORT | Per-service port — one per [[services]] entry |
Example
Config:
[[services]]
name = "api"
base_port = 3000
[[services]]
name = "postgres"
run = "docker"
base_port = 5432
Session feat-foo on slot 2:
ECLUSE_SLOT=2
ECLUSE_MODE=hybrid
ECLUSE_SLUG=feat-foo
PORT=3002
ECLUSE_API_PORT=3002
ECLUSE_POSTGRES_PORT=5434
Using env vars in your app
For most frameworks, PORT is all you need. For multi-service apps, read ECLUSE_<NAME>_PORT directly:
// Node
const port = process.env.PORT;
const dbPort = process.env.ECLUSE_POSTGRES_PORT;
# Python
import os
port = os.environ["PORT"]
db_port = os.environ["ECLUSE_POSTGRES_PORT"]
Port allocation
Formula
Each service gets a port derived from the slot:
port = base_port + slot
With base_port = 3000 and max_slots = 8:
| Slot | PORT |
|---|---|
| 1 | 3001 |
| 2 | 3002 |
| 3 | 3003 |
| … | … |
| 8 | 3008 |
Collision handling
By default, ecluse searches for a free port if the nominal one is taken, trying:
nominal + i × max_slots
This keeps search candidates out of other slots' territory. For example, if slot 1's nominal port 3001 is taken, it tries 3009, 3017, …
Set strict_port = true in .ecluse.toml to fail immediately instead of searching.
port_search_range controls how many alternatives to try (default: 10).
Validation
Run ecluse validate --ports to preview the full port allocation table and check for overlaps:
$ ecluse validate --ports
slot api postgres redis
1 3001 5433 6380
2 3002 5434 6381
3 3003 5435 6382
…
Port override
Pin a specific service to a port for a session (useful when the auto-assigned port conflicts with something ecluse can't detect):
ecluse up feat-foo --port api=4001 --port postgres=5444
Known limitation
Ports are checked, not reserved. ecluse finds a free port at ecluse up time and writes it to .env.ecluse. There is a small window between the check and when your process actually binds — if something else takes the port in between, the port in .env.ecluse will be wrong. The fix:
ecluse down feat-foo --keep-worktree
ecluse up feat-foo --reuse-worktree
Agent workflow
ecluse is designed for coding agents running tasks in parallel. The canonical loop:
# 1. Create session — get worktree path + full env in one call
ecluse up <task-slug> --json # returns JSON with worktree_path and all env vars
# 2. Work in the worktree (path from JSON above)
# Edit files, run commands — env vars are in the JSON output
# 3. Tear down
ecluse down <task-slug>
JSON output
ecluse up --json returns everything the agent needs:
{
"worktree_path": "/path/to/repo/.ecluse/worktrees/feat-foo",
"slot": 2,
"mode": "hybrid",
"slug": "feat-foo",
"env": {
"ECLUSE_SLOT": "2",
"ECLUSE_MODE": "hybrid",
"ECLUSE_SLUG": "feat-foo",
"PORT": "3002",
"ECLUSE_API_PORT": "3002",
"ECLUSE_POSTGRES_PORT": "5434"
}
}
Query an existing session anytime:
ecluse env <task-slug> # same JSON shape
Parallel sessions
Each agent gets its own slot. Sessions never share ports or volumes:
# Agent 1
ecluse up feat-payment --json # slot 1, PORT=3001
# Agent 2
ecluse up feat-auth --json # slot 2, PORT=3002
# Agent 3
ecluse up fix-bug-123 --json # slot 3, PORT=3003
All three run the full verification loop simultaneously — build, migrate, test, e2e — without waiting for each other.
Install the skill
The skill teaches your agent every command, failure mode, and config option. Install it so your agent doesn't have to figure this out from scratch:
npx skills add hefgi/ecluse -g
Hybrid mode setup
In hybrid mode, data services (Postgres, Redis, etc.) run in containers with per-slot ports. Your app runs natively.
Option 1: label in docker-compose.yml
Add ecluse.role: app to your app service:
services:
web:
build: .
labels:
ecluse.role: app
ports: ["3000:3000"]
postgres:
image: postgres:16 # no label = data service = containerized
redis:
image: redis:7
ecluse starts postgres and redis in containers with per-slot ports. Your app (web) is skipped — you run it yourself.
Option 2: explicit run = "docker" in .ecluse.toml
Define which services stay in containers directly in the config:
mode = "hybrid"
[[services]]
name = "api"
base_port = 3000 # native — runs on host
[[services]]
name = "postgres"
run = "docker"
base_port = 5432 # containerized
[[services]]
name = "redis"
run = "docker"
base_port = 6379 # containerized
Either approach works — the label is simpler for single-app stacks. The config approach gives more control for monorepos.
After ecluse up
ecluse up feat-foo starts postgres and redis in containers with per-slot ports, creates the worktree, and writes .env.ecluse:
PORT=3001
ECLUSE_API_PORT=3001
ECLUSE_POSTGRES_PORT=5433
ECLUSE_REDIS_PORT=6380
You start your app with these env vars already set:
ecluse shell feat-foo
npm run dev # connects to ECLUSE_POSTGRES_PORT automatically
Hooks
Hooks run shell commands at lifecycle points. Define them in .ecluse.toml:
[hooks]
pre_up = "echo starting"
post_up = "npx prisma migrate deploy"
pre_down = "npx prisma migrate reset --force"
post_down = "echo done"
Lifecycle order
ecluse up
└─ pre_up → runs from repo root, no env vars yet
└─ [services start, worktree created, .env.ecluse written]
└─ post_up → runs from worktree root, full env available
ecluse down
└─ pre_down → runs from worktree root, full env available (services still running)
└─ [services stopped, worktree removed]
└─ post_down → runs from repo root, env vars still available
pre_up
Runs before any infrastructure is created. Working directory is the repo root. No ECLUSE_* variables are available yet.
Use it for: pre-flight checks, pulling images, anything that must happen before services start.
post_up
Runs after all services are up and .env.ecluse is written. Working directory is the worktree root. All ECLUSE_* variables are available.
Use it for:
- Database migrations
- Seeding
- Any setup your app needs before it can run
pre_down
Runs before services are killed or containers are stopped. Working directory is the worktree root. All ECLUSE_* variables are available.
Use it for:
- Draining connections
- Resetting database state while the database is still running
post_down
Runs after all services are stopped and the worktree is removed. Working directory is the repo root. Env vars from the session are still available.
Use it for: cleanup that should happen after everything is gone (notifications, CI status updates, etc.).
Environment
All hooks receive the session's env vars except pre_up (which runs before anything exists):
| Hook | Working dir | Env vars |
|---|---|---|
pre_up | repo root | none |
post_up | worktree root | all ECLUSE_* + PORT |
pre_down | worktree root | all ECLUSE_* + PORT |
post_down | repo root | all ECLUSE_* + PORT |
Examples
Prisma migrations
[hooks]
post_up = "npx prisma migrate deploy"
pre_down = "npx prisma migrate reset --force"
Rails
[hooks]
post_up = "bundle exec rails db:migrate"
pre_down = "bundle exec rails db:drop"
Multiple commands
[hooks]
post_up = "npx prisma migrate deploy && npx prisma db seed"
Deprecated field names
on_up and on_down still work as aliases for pre_up and pre_down respectively, but are deprecated. Migrate to the four-field form above.
Contributing
Issues and PRs are welcome. Check the open issues for ideas — good first issues are tagged. If you're adding a new isolation mode or provider, open an issue first to discuss the approach.
Prerequisites
- Rust 1.85+ (
rustupor your preferred toolchain manager) - For container and hybrid mode testing: OrbStack (macOS) or Docker Engine (Linux)
Dev workflow
cargo fmt --check && cargo clippy -- -D warnings && cargo test # run before pushing
cargo build
cargo test
cargo clippy -- -D warnings
cargo fmt
Run a specific test module:
cargo test slot::tests
cargo test config::tests
cargo test env::tests
cargo test detect::tests
Project structure
src/
├── main.rs command handlers (init/up/down/ls/shell/env/validate/shutdown)
├── cli.rs clap CLI definitions
├── config.rs .ecluse.toml parsing, Config struct, Mode enum
├── slot.rs slot allocation (first free in 1..max_slots)
├── env.rs .env.ecluse generation from slot + config
├── state.rs state.json persistence with file locking (StateGuard)
├── error.rs EcluseError variants with actionable messages
├── detect.rs mode auto-detection via signal scoring
├── worktree.rs git worktree create/remove wrappers
├── hooks.rs pre_up/post_up/pre_down/post_down lifecycle hook execution
├── compose.rs docker-compose.yml parsing + overlay generation
├── docker.rs Docker CLI wrappers
└── modes/ ModeHandler trait + container/host/hybrid impls
Adding a new isolation mode
- Open an issue first to discuss the approach.
- Add a new variant to
config::Modeanderror::EcluseErroras needed. - Implement
ModeHandlerin a new file undersrc/modes/. - Register it in
modes::get_handler. - Add detection signals in
detect.rsif the mode can be auto-detected. - Add unit tests alongside the new code.
Pull requests
- Keep commits focused — one logical change per commit.
- Run
cargo fmt --check && cargo clippy -- -D warnings && cargo testbefore pushing; CI enforces all three. - For bug fixes: a failing test that passes after the fix is ideal.
- For new features: unit tests are required, integration tests are a bonus.
Known limits
These are intentional v0 limitations, not bugs.
Ports are checked, not reserved
ecluse finds a free port at ecluse up time and writes it to .env.ecluse. There is a small window between the check and when your process actually binds — if something else takes the port in between, the port in .env.ecluse will be wrong.
Fix: tear down and recreate the session, or pin a port manually.
ecluse down feat-foo --keep-worktree
ecluse up feat-foo --reuse-worktree
# or
ecluse up feat-foo --port api=4001
Process management is spawn-and-kill only
For host and hybrid modes, ecluse spawns native services on up (via command + process_manager) and kills them on down. It does not monitor or restart crashed processes. ecluse ls warns about dead nohup-managed processes. For a fresh start use:
ecluse down feat-foo --keep-worktree
ecluse up feat-foo --reuse-worktree
command requires the app to read its port from the environment
ecluse injects the full .env.ecluse contents — PORT, ECLUSE_SLOT, ECLUSE_SLUG, ECLUSE_MODE, all ECLUSE_<NAME>_PORT vars, and any port_env aliases — directly into the environment of the spawned process. There is no separate sourcing step; the same map written to .env.ecluse is passed to the child process before exec. This only fails if the app ignores the environment entirely:
- The port is hardcoded in source code — change the app to read
$PORT. - The port is set in a config file (e.g.
config/puma.rb,vite.config.ts,.env) — ecluse does not modify app config files; update the config to read from the environment instead.
If the app reads a custom env var name, use port_env to inject it:
[[services]]
name = "api"
base_port = 3000
port_env = "DJANGO_PORT" # ecluse sets DJANGO_PORT = allocated port
If the framework accepts a CLI flag, pass the env var through the command directly:
command = "next dev --port $PORT"
command = "bundle exec rails s -p $PORT"
Mode is set at init time
Mode is stored in .ecluse.toml and applies to all sessions. Changing mode requires editing .ecluse.toml and tearing down all existing sessions first.
Multiple compose files require explicit compose fields
ecluse supports multiple compose files via the compose field on each [[services]] block. Services without a compose field fall back to the root compose file. There is no automatic discovery of compose files in subdirectories.
Localhost only
All ports are bound to localhost. No remote or network-accessible port management.
No Ctrl+C rollback
If you interrupt ecluse up mid-flight, partial state may be left behind. Use ecluse down <slug> to clean up.
Platform support
macOS and Linux. Windows is not supported.