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. api at base_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

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
Globalnpx skills add hefgi/ecluse -g
Project-localnpx 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.

ModeWhat ecluse up doesBest for
containerRuns all services in Docker (app + data)Fully containerized stacks, devcontainer repos
hybridRuns data services in Docker, writes env — you start the appRails/Django/Node with a postgres+redis compose file
hostWrites env vars only — starts nothingPure 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.

FlagDescription
--mode container|host|hybridOverride detected mode
--explainShow detection signals
--yesSkip confirmation prompt
--quietSuppress 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.

FlagDescription
--branch <name>Use a specific branch name instead of the slug
--watchStream service logs after startup
--jsonOutput worktree path + env vars as JSON
--reuse-worktreeReuse an existing worktree instead of creating one
--port <name>=<value>Pin a service to a specific port for this session
--quietSuppress step output (implied by --json)

ecluse down

Tears down services, frees the slot, and removes the worktree.

FlagDescription
--keep-volumesPreserve named Docker volumes
--keep-branchKeep the git branch (no-op — branches are never deleted by ecluse)
--keep-worktreeKeep the worktree directory on disk
--quietSuppress step output

ecluse shutdown

Tears down all active sessions at once. Equivalent to running ecluse down on every session.

FlagDescription
--keep-volumesPreserve named Docker volumes
--keep-worktreesKeep worktree directories on disk
--quietSuppress 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).

FlagDescription
--portsPrint the full port allocation table for all slots
--quietSuppress 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

FieldTypeDefaultDescription
modestringcontainer, host, or hybrid
max_slotsinteger8Maximum parallel sessions
prefixstring"ecluse"Prefix for compose project names and volume names
worktree_dirstring".ecluse/worktrees"Directory for git worktrees
strict_portboolfalseFail immediately on port collision instead of searching
port_search_rangeinteger10How many alternatives to try on collision
app_labelstring"ecluse.role"Docker Compose label key used to identify app vs data services in hybrid mode
app_label_valuestring"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).

FieldTypeDescription
namestringService name — becomes ECLUSE_<NAME>_PORT
base_portintegerPort formula: base_port + slot
runstring"docker" to run in a container; omit for native
commandstringShell command ecluse spawns on ecluse up (native services only; managed by your global process_manager setting)
port_envstring or arrayExtra 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"
ValueBehaviour
tmuxCreates a detached tmux session ecluse-<slug> with one window per service. ecluse shell <slug> attaches to it.
nohupSpawns each service as a background process. Logs at .ecluse/logs/<slug>/, PIDs at .ecluse/pids/<slug>/.
noneSpawns 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]

FieldWhen it runsEnv vars
pre_upBefore any infrastructure is createdNone (runs from repo root)
post_upAfter all services are up and .env.ecluse is writtenAll ECLUSE_* + PORT
pre_downBefore services are stoppedAll ECLUSE_* + PORT
post_downAfter all services are stopped and worktree is removedAll 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.

VariableDescription
ECLUSE_SLOTSlot number (integer, 1–max_slots)
ECLUSE_MODEActive mode (container, host, or hybrid)
ECLUSE_SLUGSession name (the slug passed to ecluse up)
PORTAlias for the first native [[services]] entry — framework-compatible
ECLUSE_<NAME>_PORTPer-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:

SlotPORT
13001
23002
33003
83008

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):

HookWorking dirEnv vars
pre_uprepo rootnone
post_upworktree rootall ECLUSE_* + PORT
pre_downworktree rootall ECLUSE_* + PORT
post_downrepo rootall 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+ (rustup or 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

  1. Open an issue first to discuss the approach.
  2. Add a new variant to config::Mode and error::EcluseError as needed.
  3. Implement ModeHandler in a new file under src/modes/.
  4. Register it in modes::get_handler.
  5. Add detection signals in detect.rs if the mode can be auto-detected.
  6. 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 test before 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.