| name | ecluse |
| description | Complete reference for ecluse โ ephemeral local environments for coding agents, any stack. Use this skill whenever ecluse is mentioned, a .ecluse.toml file is present in the repo, the user asks about worktree isolation, parallel dev environments, or port/database conflicts between branches. When a .ecluse.toml file is present in the repo, loading this skill before starting work is strongly recommended so you understand the isolation model and avoid port conflicts.
|
| tags | ["ecluse","worktree","isolation","environment","ephemeral","testing","parallelization"] |
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.
Each ecluse up allocates a slot โ an integer that drives every isolated resource: port offset, database name, Docker volume names, and git worktree. Nothing leaks between sessions.
Quick navigation
Getting Started
Prerequisites
- macOS 14+ or Linux (WSL2 works but untested)
- Git repo with at least one commit
- Docker/OrbStack for
container or hybrid mode; nothing extra for host
Install
brew install ecluse/tap/ecluse
ecluse --version
Or from source: cargo install ecluse
Agent quick-start
ecluse init --mode hybrid --yes
ecluse up feat-foo --json
ecluse ls
ecluse down feat-foo --delete-worktree
Important โ use a long timeout for ecluse up and use --delete-worktree for ecluse down.
ecluse up runs post_up hooks synchronously โ a hook that polls until postgres is ready can take 30โ120 seconds. Always invoke with a generous timeout:
Bash({"command": "ecluse up feat-foo --json", "timeout": 300000})
ecluse down prompts interactively before removing a worktree when neither --keep-worktree nor --delete-worktree is passed. In a non-interactive context (agents, CI) this blocks until killed. Always pass one of those flags:
Bash({"command": "ecluse down feat-foo --delete-worktree"})
Bash({"command": "ecluse down feat-foo --keep-worktree"})
Note: ecluse shell spawns an interactive subshell โ agents cannot use it. Use ecluse up --json or ecluse env <slug> to get the worktree path and env, then operate directly.
What ecluse up does
New session:
- Allocates a slot (integer 1โN)
- Creates a git worktree at
.ecluse/worktrees/<slug> on the given branch (branch name preserved; slug is the sanitized form used for paths and Docker names)
- Symlinks
.env and .env.local from the repo root into the worktree by default โ no config needed. To opt out entirely set inherit_env = [] in .ecluse.toml, or pass --no-inherit-env to skip for a single run. To symlink a different set of files: inherit_env = [".env", ".env.staging"]. To make a file a per-worktree copy instead of a symlink (so edits in the worktree don't leak back to the root): inherit_env = [".env", { file = ".env.local", mode = "copy" }] โ copy entries are initialized once and never re-copied on subsequent ecluse up runs.
- Depending on mode: starts containers, writes
.env.ecluse, runs pre_up then post_up hooks if configured
post_up runs in the worktree with all env vars set โ use it for migrations, seeding, etc.
Existing session (idempotent):
- Reuses the existing worktree and slot โ no worktree creation, no slot allocation
- Checks which services are already running; starts only the ones that are down
- Each service decision is logged: "already running โ skipped" / "down โ will start"
- Slug is auto-detected from cwd when inside a worktree
Pass your git branch name directly โ slashes are sanitized to hyphens. When no argument is given, resolution is based on worktree location:
| Location | Behaviour |
|---|
| Inside an ecluse-registered worktree | Reuse stored slug/branch |
| Inside any other git worktree | Read branch from cwd, auto-register (--reuse-worktree implied) |
| Repo root / main worktree | Prompt for a branch name |
ecluse up feat/add-auth
ecluse up feat-add-auth
ecluse up feat-foo
ecluse up
ecluse up
ecluse up --force
ecluse up --skip api
ecluse up --force --skip db
Common first-time failures
- "run
ecluse init first" โ no .ecluse.toml found; run ecluse init from repo root
- "all N slots in use" โ
ecluse ls then ecluse down <slug> to free one
- Docker not running โ
open -a OrbStack or open -a Docker
Agent Workflow
You're in a repo with .ecluse.toml. Use ecluse. Every task gets its own isolated slot โ no port collisions, clean teardown when done.
The canonical loop
ecluse up feat/add-auth --json
cd <worktree_path> && PORT=<port> npm test
cd <worktree_path> && source .env.ecluse && npm test
ecluse down <slug> --delete-worktree
Query an existing session anytime
ecluse ls
ecluse env <slug>
ecluse env
ecluse status <slug>
ecluse status <slug> --json
ecluse status
ecluse whose-pid <pid>
ecluse whose-pid <pid> --json
Sync a manually-started environment
If services were started by hand (not via ecluse up), or state.json was lost, use ecluse sync to make ecluse aware of the running session:
ecluse sync <slug>
ecluse sync
ecluse sync <slug> --json
ecluse sync works by:
- Finding all processes whose cwd is inside the worktree (via
lsof +d)
- Matching each native service in
.ecluse.toml by walking the process tree from its command and finding the descendant that is listening on a port
- Detecting docker services (hybrid mode) via
docker ps, matching by container name containing the slug
- Writing PID files for discovered processes so
ecluse down can kill them
- Writing
.env.ecluse with the actual ports found
- Registering (or updating) the session in
state.json
After sync, ecluse ls, ecluse env, and ecluse down all work normally. If a session already exists for the slug, sync refreshes its port_overrides and PID tracking without changing the slot or branch.
Failure modes:
"no running processes found in worktree" โ start your services first, then sync
"worktree not found for slug" โ either run from inside the worktree, or ensure the worktree exists at .ecluse/worktrees/<slug>
- Unmatched services are reported as warnings (not errors) โ partial sync is still registered
Requirement: native services must have a command field in .ecluse.toml for sync to find them. Docker services are matched by container name.
Hard reset with ecluse flush
Use ecluse flush when sessions are stuck, state.json is corrupted, or you want to wipe all ecluse state and start fresh:
ecluse flush
ecluse flush --yes
Flush does the following, in order:
- Tears down all sessions known to
state.json (same as ecluse shutdown)
- Kills orphaned tmux sessions named
ecluse-*
- Stops orphaned Docker Compose projects matching
<prefix>_* (detected via docker ps)
- Removes all directories under
worktree_dir with git worktree remove --force, then git worktree prune
- Deletes
.ecluse/pids/, .ecluse/logs/, .ecluse/overlays/
- Resets
state.json to {"version": 1, "sessions": []}
Steps 1โ5 are best-effort: failures are logged and ignored. The only hard failure is step 6 (cannot reset state.json).
Docker volumes are not removed. Run docker volume prune separately if you also want data volumes gone.
After flush, ecluse ls returns "no active sessions" and all slots are free.
Environment variables
All vars are in the JSON from ecluse up --json or ecluse env <slug>.
| Variable | Example | Description |
|---|
PORT | 3001 | Alias for the first native [[services]] entry โ never hardcode 3000 |
ECLUSE_<NAME>_PORT | ECLUSE_API_PORT=3001 | Per-service port: base_port + slot |
<port_env> | NODE_INSPECT_PORT=9230 | Custom var from extra_ports[].port_env: base_port + slot. Also published as a hostโcontainer binding in docker overlays. |
ECLUSE_<NAME>_DEBUG_PORT | ECLUSE_API_DEBUG_PORT=9230 | Deprecated โ use extra_ports instead. Emitted when debug_port is set in .ecluse.toml. |
ECLUSE_SLOT | 1 | Slot number |
ECLUSE_SLUG | feat-auth | Session slug |
ECLUSE_MODE | hybrid | container, host, or hybrid |
No DATABASE_URL or REDIS_URL are set automatically. Data service ports are exposed as ECLUSE_<SERVICE>_PORT (e.g. ECLUSE_POSTGRES_PORT=5433). Construct connection strings in your post_up hook or app config using that port. This keeps ecluse engine-agnostic.
Parallel sessions
ecluse up feat-auth --json
ecluse up feat-cache --json
ecluse ls
Each session is a separate git branch and worktree. They don't interfere.
Common failures
- "all slots in use" โ
ecluse ls to find stale sessions, ecluse down <slug> --delete-worktree the oldest
ecluse down hangs waiting for input โ always pass --delete-worktree (or --keep-worktree) when running non-interactively; without either flag the command prompts the user before removing the worktree
- Port in use โ first determine who owns it (see "Killing services safely" below). Never run a blind
lsof -ti TCP:<port> \| xargs kill โ that's how parallel agents kill each other.
Killing services safely โ never raw-kill ecluse-allocated ports
Never kill a process on an ecluse-allocated port without checking ownership first.
The canonical fix for misbehaving services is ecluse down + ecluse up, not kill:
ecluse down <your-slug> --keep-worktree
ecluse up <your-slug> --reuse-worktree
This tears down only your services and respawns them with the right env. Idempotent, safe under parallel sessions, never touches another agent's work. Reach for this 95% of the time.
If raw kill is truly unavoidable, always verify ownership first:
ecluse whose-pid <pid>
Never run lsof -ti TCP:<port> | xargs kill blind. Every PID gets checked individually with whose-pid first.
Why this matters: in parallel sessions, the process on the port "next to yours" is almost always another agent's working service, not your own stale leftover. Kill it and you derail a sibling session. This has happened in production: three parallel agents killed each other's services seven times in seven minutes, each one thinking it was "cleaning up rogue processes" from the others' worktrees.
Caveat โ services spawned outside ecluse: if you ran task ..., make ..., npm run ..., or similar from a tmux/Bash shell instead of letting ecluse spawn via command = ... in .ecluse.toml, ecluse may not know about those PIDs. Run ecluse sync <your-slug> first to register them, then ecluse down --keep-worktree will kill them properly.
Port wiring โ exhaust .ecluse.toml options before touching app code
When a service has a hardcoded port or reads it from a config file, resolve it through .ecluse.toml configuration. Only modify application source code as a last resort.
Resolution order (stop at the first that works):
-
CLI flag in command โ most frameworks accept --port as a CLI argument. Pass the ecluse var directly:
[[services]]
name = "web"
base_port = 3000
command = "vite --port $ECLUSE_WEB_PORT"
-
port_env โ app reads a custom env var name (not PORT). Map the allocated port to that name:
[[services]]
name = "api"
base_port = 4000
port_env = "DJANGO_PORT"
command = "python manage.py runserver 0.0.0.0:$DJANGO_PORT"
Multiple services that each need a distinct var (e.g. a monorepo with three APIs that all read process.env.PORT):
[[services]]
name = "api"
base_port = 4444
port_env = "ECLUSE_API_PORT"
command = "pnpm --filter api dev --port $ECLUSE_API_PORT"
[[services]]
name = "admin-api"
base_port = 4544
port_env = "ECLUSE_ADMIN_API_PORT"
command = "pnpm --filter admin-api dev --port $ECLUSE_ADMIN_API_PORT"
-
Modify app source code โ only if the framework has no --port flag and reads no env var at all (rare). Document why the other options were not viable before making the change.
Do not modify vite.config.ts, next.config.js, config/puma.rb, or similar config files when a CLI flag or port_env can achieve the same result.
Avoid external task runners as service entry points. task, make, npm run and similar runners re-read .env.local and inherit the spawning shell's env โ neither of those is .env.ecluse. Under parallel sessions this causes services to bind to the wrong slot's ports because the spawning shell may carry env from a different worktree. Always put the actual service command directly in [[services]] command = "..." so ecluse spawns it with the correct slot env in one step. If you must use an external runner, run ecluse sync <your-slug> immediately after starting it so the resulting PIDs are tracked.
Choosing a Mode
| Mode | What runs in containers | What runs on host | Best for |
|---|
container | Everything โ app + all services | Nothing | docker compose up is the team's primary dev command |
host | Nothing | Everything | Native-only stacks; no Docker |
hybrid | Data services only (postgres, redis, etc.) | App | Compose data plane, app runs natively for speed |
Decision guide
container โ your docker-compose.yml has build: . on the app service and the team's day-one command is docker compose up.
host โ no compose file at all; dev command is npm run dev, bin/rails server, etc. Uses mise, asdf, rbenv, or similar. Docker absent or too heavy.
hybrid โ compose has only data services (postgres, redis); the README says "run docker compose up -d, then bin/dev". You want per-session database isolation with native app speed. This is the most common choice for Rails, Django, and Node.js apps.
Auto-detection
ecluse init
ecluse init --explain
ecluse init --mode hybrid
Detection runs 20+ signals. Key ones (run ecluse init --explain for the full breakdown):
| Signal | container | host | hybrid |
|---|
Compose has build: . | +3 | 0 | 0 |
| All compose services are data images | โ2 | 0 | +5 |
Service labeled ecluse.role: app | 0 | 0 | +10 |
| No compose file | โ5 | +4 | โ5 |
bin/dev exists | 0 | +3 | +2 |
README: docker compose up then bin/dev | 0 | 0 | +3 |
| Docker not installed | โ10 | 0 | โ10 |
Confidence: gap โฅ 4 = High (auto-accept), 2โ3 = Medium, 0โ1 = Low (full breakdown shown), all โค 0 = --mode required.
Changing modes later
ecluse init --mode <new> overwrites .ecluse.toml. Existing sessions keep their stored mode; ecluse down still works for them.
Edge cases
- Nix flake โ use
nix develop; ecluse doesn't understand flake.nix
- Bazel โ use Bazel's native sandbox
- Monorepo, single compose at root โ the common case; use
[[services]] to allocate one port per native service (see t3-monorepo example)
- Monorepo, each service has its own compose file โ point each
[[services]] block at its own file with compose = "services/foo/docker-compose.yml"; only use separate subdirectory .ecluse.toml files when you need fully independent slot pools and state
Container Mode
Every service โ including the app โ runs in Docker under a unique compose project per session. Ports are offset; volumes are namespaced.
Prerequisites
- Docker/OrbStack running
docker-compose.yml with build: . on the app service
Port allocation
Ports are computed as base_port + slot. With [[services]] name="web" base_port=3000 and [[services]] name="postgres" run="docker" base_port=5432:
| Session | Slot | web โ host | postgres โ host |
|---|
feat-foo | 1 | 3001 | 5433 |
fix-bar | 2 | 3002 | 5434 |
Only the host-side port changes. Container-internal ports stay the same.
Volume namespacing
Named volume db_data becomes db_data_ecluse_feat-foo for slot 1. Bind mounts are unchanged.
How it works
ecluse writes .ecluse/overlays/<slug>.yml โ a compose override that rewrites ports and volume names. Your docker-compose.yml is never modified. Merged at runtime via docker compose -f docker-compose.yml -f .ecluse/overlays/<slug>.yml.
Workflow
ecluse up feat-foo
ecluse down feat-foo
ecluse down feat-foo --keep-volumes
Common pitfalls
- Hardcoded port in app code or config file โ use a CLI flag in
command or port_env in .ecluse.toml before modifying app source; see Port wiring section in Agent Workflow
--watch requires Compose v2.22+ โ pass ecluse up --watch
- Invoking
docker compose directly โ the overlay won't be included; use ecluse up or add -f .ecluse/overlays/<slug>.yml
- Multiple services collide on a shared debugger/auxiliary port โ Node.js
--inspect defaults to 9229, Delve to 2345, debugpy to 5678, etc. When multiple services share a default, the second one fails with EADDRINUSE. Fix: add extra_ports = [{ base_port = 9229, port_env = "NODE_INSPECT_PORT" }] to each conflicting [[services]] block and pass the allocated var in command (e.g. NODE_OPTIONS='--inspect=0.0.0.0:$NODE_INSPECT_PORT', dlv ... --listen=:$NODE_INSPECT_PORT). For docker services the port is also published as a hostโcontainer binding automatically.
Host Mode
No containers. ecluse reserves a port range, writes .env.ecluse, creates the worktree, runs pre_up then post_up hooks. If command is set on a [[services]] entry, ecluse spawns it automatically via your global process_manager (tmux or nohup). Otherwise you start your own dev server.
Prerequisites
- No Docker required
- Host Postgres/MySQL/SQLite already available if your app needs a database
Workflow
ecluse up feat-foo
cd .ecluse/worktrees/feat-foo
source .env.ecluse
npm run dev
Database in host mode
ecluse does not provision databases. Use [hooks] post_up with your app's own tooling:
[[services]]
name = "app"
base_port = 3000
[hooks]
post_up = "npx prisma migrate deploy"
pre_down = "npx prisma migrate reset --force"
Your app's connection string is defined in your .env (not managed by ecluse). The hook runs inside the worktree with all ecluse env vars set.
Teardown
ecluse down feat-foo
Common failures
- "Port 3100 is in use by PID 12345" โ
kill 12345
- App can't find database โ
source .env.ecluse before starting; check hook output for migration errors
Hybrid Mode
Data services (postgres, redis, etc.) run in containers with offset ports and namespaced volumes. App runs on the host. If command is set on the native [[services]] entry, ecluse spawns it automatically โ no manual npm run dev required.
This is the fastest dev loop: isolated data, native app speed, hot reload, native debugger.
Prerequisites
- Docker/OrbStack running
docker-compose.yml with data services
Label your app service
Add ecluse.role: app to any service that should not start in a container:
services:
web:
build: .
labels:
ecluse.role: app
ports: ["3000:3000"]
postgres:
image: postgres:16
redis:
image: redis:7
Workflow
ecluse up feat-foo
cd .ecluse/worktrees/feat-foo
source .env.ecluse
bin/dev
Without the label
If no service has ecluse.role: app, ecluse warns and treats all services as data. Not an error.
Teardown
ecluse down feat-foo
ecluse down feat-foo --keep-volumes
Common failures
- App can't connect to postgres โ
ECLUSE_POSTGRES_PORT is the offset port (e.g. 5532, not 5432). Run source .env.ecluse first and use that port in your connection string.
- Data containers didn't start โ
docker info to verify Docker is running
- Wrong service excluded โ check
ecluse.role: app is only on the app service
Troubleshooting
Port already in use
By default ecluse auto-bumps to a free port if the nominal one is taken โ you'll see a log line like port 3001 in use; using 3009 for service 'api'. No action needed unless you want a specific port.
If strict_port = true is set (or all alternatives are exhausted):
Error: port 3001 is already in use by PID 12345; stop that process first
ecluse whose-pid 12345
kill 12345
lsof -iTCP:3001 -sTCP:LISTEN
ecluse up
Persistent conflict: change base_port in the relevant [[services]] block, or increase port_search_range and run ecluse validate to confirm no overlaps.
Cross-agent port collision (parallel sessions killing each other)
Symptom: in parallel sessions on the same repo, services keep dying with exit 137 / SIGKILL even though no one ran ecluse down. Restarting via task or make lands them on the wrong port. Multiple agents are running kill or lsof -ti | xargs kill against each other's PIDs.
Root cause: an external task runner (task, make, npm run, bin/dev) was used as the service entry point instead of command = "..." in .ecluse.toml. External runners re-read .env.local and inherit the spawning shell's env โ neither knows about .env.ecluse. Under parallel sessions the spawning shell can carry env from a different worktree's source .env.ecluse, so services bind to the wrong slot's ports. Agents then see "a process on a port adjacent to mine" and kill it, believing it's their own stale leftover.
Recovery (do this in each affected session):
ecluse down <your-slug> --keep-worktree
ecluse up <your-slug> --reuse-worktree
ecluse status <your-slug>
Prevention:
- Move the actual service commands into
[[services]] command = "..." so ecluse spawns them with the correct slot env. This is the only real fix.
- If you must use an external runner, run
ecluse sync <your-slug> immediately after starting it so the resulting PIDs are tracked. Then ecluse down --keep-worktree will kill them properly.
- Never run
lsof -ti TCP:<port> | xargs kill blind โ see "Killing services safely". Use ecluse whose-pid to verify ownership before any manual kill.
- Consider
slot_stride = 10 in .ecluse.toml for visually distinct adjacent-slot ports (3010, 3020, 3030 instead of 3001, 3002, 3003). Doesn't prevent the root cause but makes mistakes harder.
Wrong content served on the configured URL after multiple up/down cycles
Symptom: the user navigates to http://localhost:7301 expecting slot 1, but sees slot 4's branch instead. ecluse status reports the slot 1 service as healthy. Restarting only the affected session doesn't fix it โ the wrong content keeps appearing on the configured port.
Root cause (fixed in 0.3.2+): an orphan from a previous session is holding the port. Common cause: pnpm/npm wrapper chains where the actual server is a grandchild (sh โ pnpm โ node โ vite) โ under 0.3.1 and earlier, ecluse down killed only the outer wrapper and the actual server reparented to launchd/init, surviving indefinitely and holding 4-8 ports each. After several up/down cycles these orphans accumulated and silently collided with new sessions.
Detection: ecluse status in 0.3.2+ flags this directly:
SERVICE TYPE PORT STATUS WINDOW
backoffice native 7301 โ wrong owner (PID 81906) backoffice
The wrong owner row means: the stored PID (or its descendants) is NOT what's currently listening on 7301 โ something else is. JSON output gains listener_pid and wrong_owner fields. Exit code is 1 (same as โ down).
Recovery on any version:
ecluse whose-pid <listener-pid>
kill -- -<listener-pid>
ecluse flush --yes
Prevention: upgrade to 0.3.2+. The tmux teardown path now kills the whole process group (TERMโKILL grace), matching what the nohup path already did. ecluse flush also sweeps both the worktree cwd and every configured port to clean up orphans that escaped a previous version's teardown.
Docker not running
open -a OrbStack
open -a Docker
sudo systemctl start docker
docker info
Slot exhaustion
Error: all 8 slots are in use; run ecluse ls to see active sessions
ecluse ls
ecluse down <stale-slug>
Or increase max_slots in .ecluse.toml directly.
Stale state after manual worktree deletion
Run ecluse down <slug> anyway โ handlers skip missing resources and remove the state entry. If that fails, edit .ecluse/state.json directly and remove the stale session object.
Host Postgres unreachable
brew services start postgresql@16
sudo systemctl start postgresql
psql -U postgres -c "SELECT 1"
Lock timeout
Error: timed out waiting for state lock after 10s
ps aux | grep ecluse
rm .ecluse/state.lock
Service can't find secrets from .env / .env.local
ecluse symlinks .env and .env.local from the repo root into each worktree at ecluse up time โ so the files are there on disk. Whether the service actually reads them depends on the framework:
- Auto-loaded (no action needed): Next.js, Vite, Create React App,
docker compose (reads .env from the compose file's directory)
- Must load explicitly: Node.js without a dotenv call, Rails, Django, Go, Rust โ the process only sees what ecluse injects via
command / .env.ecluse
For frameworks that need explicit loading, two options:
- Use a
post_up hook to source the file or run a seed/setup script
- Make the app call
dotenv (or equivalent) at startup to load .env
Note: ECLUSE_* vars and PORT from .env.ecluse are always injected into the spawned process environment by ecluse โ those never need explicit loading.
Per-worktree .env override is being clobbered (or shared across worktrees)
Symptom: the user edits .env.local inside a worktree to set, say, AUTH_ENABLED=false, but every other worktree (and the repo root) now sees the same flip โ or running ecluse up again restores the original value.
Cause: by default inherit_env uses mode = "symlink", so each worktree's .env.local is a symlink back to the root file. Editing it edits the shared root file, and every other worktree sees the change through its own symlink. This is correct for shared secrets (DB passwords, API keys) but wrong for per-worktree feature flags.
Fix: mark the file as mode = "copy" in .ecluse.toml. Copy entries are initialized from the root file once on first ecluse up, then left alone forever after โ worktree edits stay local, root edits don't propagate, and parallel worktrees each get their own independent file.
inherit_env = [
".env",
{ file = ".env.local", mode = "copy" },
]
After the change: existing symlinks for .env.local in already-created worktrees are replaced with a fresh copy on the next ecluse up. If a worktree already has a real .env.local (not a symlink), it is preserved as-is โ ecluse up never re-copies on top of a non-symlinked file.
To skip inheritance entirely for a single run (CI, ephemeral agent sessions): ecluse up <slug> --no-inherit-env.
Not inside a git repository
git init && git add . && git commit -m "init"
ecluse init
Debug output
RUST_LOG=debug ecluse up feat-foo
Error code reference
| Error | Cause | Fix |
|---|
SlugInvalid | Slug doesn't match ^[a-z0-9][a-z0-9-]{0,60}[a-z0-9]$ | Lowercase letters, numbers, hyphens; 2โ62 chars |
SlotsExhausted | All slots in use | ecluse ls then ecluse down <slug> |
SessionNotFound | Slug not in state | Check ecluse ls |
LockTimeout | Another process holds lock | Check processes; remove stale lock |
ConfigMissing | No .ecluse.toml found | ecluse init |
NotAGitRepo | Not in a git repo | git init first |
ComposeFileNotFound | No compose file at repo root | Add compose file or switch mode |
PortInUse | Port bound by another process | kill <pid> then retry |
HookFailed | A hook (pre_up, pre_spawn, post_up, pre_down, post_down) exited non-zero | Check the hook command and its output |
ProcessManagerUnavailable | Configured process_manager binary not installed | Install it or set process_manager = "none" in ~/.config/ecluse/config.toml |
SpawnFailed | Failed to spawn a native service process | Check the command field in .ecluse.toml and the binary's availability |
Concurrency and recovery
ecluse up/down no longer hold the state lock while provisioning or tearing
down โ sessions are reserved with a pending marker instead, so parallel
agents never block on each other's slow image pulls or hooks.
What this means for you:
ecluse ls shows <slug> (pending) while an up/down is in flight
("status": "pending" in ls --json). Other read commands keep working.
- Running
up, env, status, shell, or sync against a pending session
fails with operation in progress. Wait for the owning command, or โ if it
crashed โ run ecluse down <slug> to take the session over and clean it up.
ls warns when a session has been pending for more than 15 minutes; that
means the owning command died and the slot is leaked until you down it.
- If a session is removed (
down, flush) while another command was still
provisioning it, the loser detects the takeover, tears down whatever it
created, and exits non-zero. State never resurrects deleted sessions.
Service identity
Pid files record the process start token alongside the PID. A recycled PID
(same number, different process) is never killed, never attributed by
whose-pid, and reports as down in status. Containers are matched by their
compose project label, never by name substrings.
tmux sessions
Services run as their tmux window's own process. A command that exits within
~1.5 s fails ecluse up with the exit status and last output โ a "ready"
session means the services actually started. Dead panes are kept on screen
(remain-on-exit) so you can attach and read the error; window shell is a
plain shell with the session env loaded. Service commands must be
long-running: a command like echo done is treated as an instant failure
under tmux.
--force and unowned ports
ecluse up --force only kills processes that belong to the session
(verified via pid files / tmux panes). A process squatting the session's port
that ecluse does not own produces a warning naming the PID instead of a kill โ
inspect it with ecluse whose-pid <pid> and kill it manually if intended.
Limits
What ecluse intentionally does not do in v0. These are design decisions, not bugs.
- Ports are checked, not reserved โ ecluse finds a free port at
up time and writes it to .env.ecluse. There is a small window between that check and when your process actually binds. If another process takes the port in between, the value in .env.ecluse will be wrong. Fix: ecluse down feat-foo --keep-worktree then ecluse up feat-foo --reuse-worktree, or pin a specific port with --port name=value.
- No process lifecycle management beyond spawn/kill โ ecluse can spawn native services on
up (via command + process_manager) and kill them on down, but cannot auto-restart a crashed process. If a service dies, ecluse up (idempotent โ slug auto-detected from cwd) starts only the downed services. ecluse up --force kills everything on allocated ports and restarts fresh. ecluse ls and ecluse env warn about dead nohup processes.
command requires the app to expose a port entry point โ 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 spawned process environment โ no separate sourcing step needed. If the port is hardcoded or set in a config file, resolve it via .ecluse.toml first: pass it as a CLI flag (command = "vite --port $ECLUSE_WEB_PORT"), or use port_env to inject it under the var name the app already reads. Modifying app source code is the last resort โ see Port wiring above.
- Mode is set at
init, not re-detected on up โ to change: ecluse init --mode <new>
- Multiple compose files supported via
compose field โ each [[services]] block with run = "docker" can point at its own compose file; services without it fall back to the root compose file. Run ecluse init per subdirectory only when you need fully independent slot pools.
localhost:<port> only โ no public URLs; use cloudflared or ngrok alongside ecluse
- No agent process sandboxing โ container mode isolates services, not the agent's filesystem
ecluse shell is for humans, not agents โ agents use ecluse up --json or ecluse env to get the worktree path and env vars, then operate directly; ecluse shell spawns an interactive subshell which blocks non-interactive execution
- No built-in database management โ ecluse allocates ports and writes env vars; use
[hooks] post_up/pre_down with your app's own tooling (prisma, rails db:create, psql, etc.)
- macOS and Linux only โ WSL2 acceptable but untested; native Windows not supported
- No background daemon โ every ecluse command is a short-lived process
- No Ctrl+C rollback guarantee โ if killed mid-
up, run ecluse down <slug> to clean partial state
- Hooks run shell commands, not arbitrary plugins โ
[hooks] in .ecluse.toml supports five lifecycle points (pre_up, pre_spawn, post_up, pre_down, post_down); each runs a shell command in the worktree with all env vars set; there is no plugin API or event bus beyond these. Hooks execute with the same privileges as the agent โ only run ecluse up/ecluse down in repositories whose .ecluse.toml you trust
- No telemetry โ no network calls except the optional Postgres TCP probe during
init
Configuration reference
.ecluse.toml (written by ecluse init, lives at repo root):
mode = "hybrid"
max_slots = 8
prefix = "ecluse"
worktree_dir = ".ecluse/worktrees"
[[services]]
name = "api"
base_port = 3000
command = "npm run dev"
[[services]]
name = "postgres"
run = "docker"
base_port = 5432
[hooks]
pre_up = "..."
pre_spawn = "..."
post_up = "npx prisma migrate deploy"
pre_down = "npx prisma migrate reset --force"
post_down = "..."
Global config (~/.config/ecluse/config.toml)
Controls how native service processes are spawned. Written by ecluse init:
process_manager = "tmux"
tmux โ creates a detached tmux session ecluse-<slug>; ecluse shell [<slug>] attaches to it
nohup โ background processes, logs at .ecluse/logs/<slug>/, PIDs at .ecluse/pids/<slug>/
none โ spawns nothing (default pre-v0.3 behaviour)
ecluse init auto-detects: tmux if present, otherwise nohup. ecluse validate checks the binary is installed. This is per-machine, not per-repo.
Hooks run as shell commands inside the worktree directory. pre_up runs before any infrastructure exists (env vars not yet available). pre_spawn runs after .env.ecluse is written but before native services are started โ use it to derive env values from allocated ports (e.g. CORE_API_URL=http://localhost:$ECLUSE_API_PORT). post_up, pre_down, and post_down all have the full .env.ecluse set (PORT, ECLUSE_SLUG, ECLUSE_<NAME>_PORT, etc.). ecluse does not manage databases directly โ use post_up for migrations and pre_down for teardown.
Examples
See examples.md for 5 canonical config templates covering host, container, hybrid, multi-service monorepo, and Kubernetes. Each entry links directly to the .ecluse.toml and docker-compose.yml you can read and adapt.
Commands
ecluse init [--mode container|host|hybrid] [--explain] [--yes]
ecluse up [<slug>] [--branch <name>] [--watch] [--json] [--reuse-worktree] [--port <name>=<value>] [--services <name>,...] [--force] [--skip <name>,...]
ecluse env [<slug>]
ecluse down [<slug>] [--keep-volumes] [--keep-branch] [--keep-worktree] [--delete-worktree]
ecluse ls [--json]
ecluse validate [--ports]
ecluse status [<slug>] [--json] [--quiet]
ecluse whose-pid <pid> [--json]
ecluse shell exists but is human-only โ it spawns an interactive subshell that blocks non-interactive execution. Agents must not use it.
ecluse validate checks your .ecluse.toml for port range safety (ensures port_search_range doesn't create overlaps between services) and prints the current config. Pass --ports to see the full port allocation table across all slots.
ecluse status checks whether each service is actually running. For native services it matches running processes by command line; for docker services it queries docker ps. Exits with code 1 if any service is down โ useful in CI pre-flight or as a readiness gate after ecluse up. Use --json for machine-readable output:
ecluse status feat-foo
ecluse status feat-foo --json
ecluse status --quiet
Soft restart โ tear down services without losing the git worktree, then spin up fresh:
ecluse down feat-foo --keep-worktree
ecluse up feat-foo --reuse-worktree
Use this when a service failed to bind after up and you want a fresh start without losing changes in the worktree.
Port override โ pin a service to a specific port for this session:
ecluse up feat-foo --port api=4001 --port postgres=5444
Overrides bypass the auto-bump logic and use the given value directly. The overridden ports are stored in session state and reflected in ecluse env output.