@lyrolab/worker-env
v1.4.0
Published
Isolated, self-supervised dev environments for coding agents, described by worker.config.mjs
Readme
@lyrolab/worker-env
Private, isolated dev environments for coding agents. One idempotent command starts every service of a checkout on its own ports and browser origin, under a detached supervisor; another stops it.
Node built-ins only, no runtime dependencies. Requires Node 22 or later on macOS or Linux.
Commands
npm install --save-dev @lyrolab/worker-env
npx worker up # install, start the env in the background, print its URLs
npx worker status # print the env of this checkout (--json for the runtime record)
npx worker down # stop the env and wait for teardown
npx worker verify -- pnpm test # run a heavy command once a host-wide slot is freeThe commands find worker.config.mjs in the current directory or its nearest parent; that directory is the checkout.
worker upruns theinstallsteps, starts a detached supervisor, waits until every service answers its health path, prints the URLs and exits 0. When the checkout already has a live env, it prints that env and exits 0 without installing. A failed install, setup step or service start exits 1 with the error and leaves nothing running.worker upfirst collects envs whose supervisor died.worker statusexits 0 when the env runs, 1 otherwise. It shows the time left before the env stops for idleness;--jsonadds it asidleStopInMs(nullwhile connections are open).worker downstops the env and exits 0, also when none runs.
Each checkout runs at most one env; separate checkouts (clones or worktrees) run side by side.
How an env runs
- The env id is
<name>_worker_<12 hex>, new on every start. - The supervisor creates the configured resources on shared containers, then runs the
setupsteps, then starts the services. Teardown stops the services, then drops the resources. - Every service gets an OS-assigned port on
127.0.0.1and runs in a process group of its own. Any service exit, failed setup step, health timeout,worker downor idle stop stops every group. - The service port the browser and agents use belongs to the supervisor, which relays every TCP connection, WebSockets included, to a private port the service listens on (
${port}). - A service with
browser: truegets the originhttp://<id>.<name>.localhost:<port>. Browsers resolve*.localhostto loopback and treat it as a secure context, so cookies and storage never mix with another env or the owner env. Other services' origin is their loopback URL. - State lives in
.worker/at the checkout root, which ignores itself in git:runtime.json: id, supervisor pid, status (starting,running,stopping,stopped,failed), the error that stopped the env,stoppedBy: "idle"after an idle stop, the idle timeout andidleSince(when the last connection closed,nullwhile one is open), and each service's port, private port, URL, origin, pid and log.<service>.log,install.log,setup.log,supervisor.log.dev.lock: held by the live supervisor.
Configuration
worker.config.mjs default-exports an object:
export default {
name: "my-app",
install: ["pnpm install --frozen-lockfile --prefer-offline"],
resources: {
db: {
type: "postgres",
container: "my-app-postgres",
url: "postgres://postgres:postgres@localhost:5432",
},
cache: { type: "redis", container: "my-app-redis" },
},
setup: [
{
command: "node_modules/.bin/prisma",
args: ["migrate", "deploy"],
cwd: "packages/api",
env: { DATABASE_URL: "${db.url}" },
},
],
services: {
api: {
cwd: "packages/api",
command: "node_modules/.bin/tsx",
args: ["watch", "src/main.ts"],
env: {
PORT: "${port}",
FRONTEND_URL: "${web.origin}",
DATABASE_URL: "${db.url}",
REDIS_URL: "redis://localhost:6379",
REDIS_PREFIX: "${cache.prefix}",
},
health: "/health",
},
web: {
cwd: "packages/web",
command: "node_modules/.bin/vite",
args: ["--host", "127.0.0.1", "--port", "${port}", "--strictPort"],
env: { VITE_API_URL: "${api.url}" },
browser: true,
},
},
}| Key | Type | Description |
| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| name | string, required | Lowercase letters, digits and -, starting with a letter, at most 43 characters. Prefix of the env id and part of browser hostnames. |
| install | step[] | Run by worker up before starting, from the checkout root, logged to .worker/install.log. Skipped when the env already runs. |
| resources | object | Resource name (lowercase letters, digits, -, distinct from service names) to resource. |
| setup | step[] | Run by the supervisor in order once ports are assigned, before services start, logged to .worker/setup.log. |
| services | object, required | Service name (lowercase letters, digits, -) to service. Services start together. |
| startTimeoutMs | integer | Time every service has to answer its health path. Default 180000. |
| idleTimeoutMs | integer | Time without any open connection on the service ports after which the env stops. Default 1800000 (30 minutes). |
A step is either a string, run through sh -c, or an object:
| Key | Type | Description |
| --------- | ---------------- | ----------------------------------------------------- |
| command | string, required | Executable, run without a shell. |
| args | string[] | Arguments. |
| cwd | string | Directory relative to the checkout root. Default .. |
| env | object | Variables added to the inherited environment. |
A service is a step object with two more keys:
| Key | Type | Description |
| --------- | ------- | ------------------------------------------------------------------- |
| health | string | Path that answers below 500 once the service is ready. Default /. |
| browser | boolean | Gives the service a *.localhost origin. Default false. |
A service must listen on 127.0.0.1 at ${port}, its private port; ${<service>.port} is the supervisor's port in front of it. Start servers through their binary (node_modules/.bin/vite) rather than a package-manager script: pnpm runs scripts in a process group of its own, which teardown cannot reach.
Interpolation
command, args, cwd, env values and health accept ${variable}:
| Variable | Value | Available in |
| ----------------------- | ------------------------------------------------------ | ------------------------ |
| ${id} | Env id | setup, services |
| ${name} | Config name | setup, services |
| ${root} | Absolute checkout root | install, setup, services |
| ${<resource>.<value>} | A value of that resource | setup, services |
| ${<service>.port} | That service's port, fronted by the supervisor | setup, services |
| ${<service>.url} | http://127.0.0.1:<port> | setup, services |
| ${<service>.origin} | Browser origin when browser: true, its URL otherwise | setup, services |
| ${port} | Private port the service itself listens on | services |
| ${url}, ${origin} | ${<service>.url} and .origin of the service itself | services |
An unknown variable fails validation. Shell variables in string steps use the $NAME form.
Resources
Each resource is a database or prefix of the env on a container shared with the checkout that owns it. The supervisor creates it before setup and drops it on teardown. worker-env never starts, stops or recreates containers: a container that is not running fails worker up with a message naming it. Creation and teardown refuse any name outside the env id pattern, so resources of the owner env stay untouched.
The owner of each resource is recorded as { host, pid, root, groups }: host name, supervisor pid, checkout root and service process groups.
| type | Keys | Created on the shared container | Owner record | Values |
| ----------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------- | ----------------- |
| postgres | container (required), url (required), user (postgres) | Database <id> through docker exec <container> psql; dropped WITH (FORCE). Postgres 13 or later. | Database comment | database, url |
| mongo | container (required), url (required) | Database <id> through docker exec <container> mongosh, which must connect without credentials. | _worker_owner collection | database, url |
| redis | container (required) | Key prefix <id>; teardown deletes every key matching <id>* through docker exec <container> redis-cli. | Key <id>:_worker_owner | prefix |
| typesense | url (required), apiKey (required) | Collection prefix <id>_; teardown deletes collections and aliases named <id>_* through the HTTP API. | Collection <id>__worker_owner | prefix |
containeris the name of the running Docker container.urlofpostgresandmongois the server URL as services reach it;${<resource>.url}is that URL with the env's database as its path, keeping credentials and query.urloftypesenseis the API base URL, also used to check that Typesense answers before creating anything.${<resource>.database}is the env id;${<resource>.prefix}is<id>for Redis and<id>_for Typesense. Every key, queue and collection the app writes must start with its prefix, or it leaks across envs and survives teardown.
Idle stop and collection
The supervisor counts the connections open on every service port. Once none has been open for idleTimeoutMs, it stops the env and drops its resources, as worker down does. An open browser tab keeps the env alive through its HMR WebSocket until the tab closes, and so does a service that keeps a connection open to another service's URL. WORKER_ENV_IDLE_TIMEOUT_MS overrides the timeout for the supervisor worker up starts.
Every worker up first collects envs whose supervisor died without tearing down (kill -9, reboot, deleted checkout), for the configured resources and the config name:
- An env id is collected when every owner recorded on its resources names this host and a supervisor that no longer runs. Envs of live supervisors and of other hosts stay, and so does a resource without a readable owner record, whose supervisor may still be starting.
- The recorded process groups are stopped first, each only while its leader still runs from inside the recorded checkout. Services of this checkout's last env are stopped the same way when its supervisor is gone.
- Its resources are then dropped from every configured resource, under the same id pattern check as teardown, so the owner env is never touched.
Heavy-command slots
worker verify [--wait SECONDS] -- COMMAND [ARG...]Agents sharing a host run tests, typecheck, lint, builds, installs and worker up through worker verify, so only a few heavy commands load the host at once. It takes the first free slot of a host-wide pool, runs COMMAND and releases the slot when COMMAND exits. It needs no worker.config.mjs and works the same on macOS and Linux.
COMMANDruns without a shell, in the current directory, with the arguments, environment and standard streams ofworker verifyunchanged. Run compound commands throughsh -c.--waitis how long to wait for a slot, 300 seconds by default;--wait 0tries every slot once. When no slot frees up in time,COMMANDnever starts andworker verifyexits 75.- Otherwise it exits with the status of
COMMAND, 75 included, so use the output to tell a busy pool from a command that exited 75. A command that is not found exits 127; one killed by a signal exits 128 plus the signal number. - SIGINT, SIGTERM and SIGHUP reach
COMMAND, whether sent to the process group (Ctrl-C) or toworker verifyalone;worker verifywaits forCOMMANDto exit, releases the slot and dies of the same signal. - A slot is a kernel file lock held by
worker verifyitself, never byCOMMANDor its descendants: processesCOMMANDleaves running in the background hold no slot, and the kernel releases the slot whenworker verifydies, even by SIGKILL. Killingworker verifyalone with SIGKILL therefore letsCOMMANDrun on without a slot. - Usage errors and unusable lock paths exit 2 without running
COMMAND. There is no option to run without a slot.
| Variable | Default | Description |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| WORKER_VERIFY_SLOTS | 3 | Size of the pool. |
| WORKER_VERIFY_LOCK_DIR | $XDG_CACHE_HOME/worker-env/verify, else ~/Library/Caches/worker-env/verify on macOS and ~/.cache/worker-env/verify on Linux | Absolute directory of the slot files. |
Every process using the same directory shares the pool. Slot files are heavy-verification.lock, heavy-verification.1.lock, heavy-verification.2.lock, …; never delete or replace them to clear contention, since a new file escapes the lock held on the old one. On macOS the lock is taken by open(2) with O_EXLOCK; on Linux, which has no such flag, by util-linux flock(1), held in a session of its own and released when worker verify closes its stdin. Both are flock(2) locks, so worker verify and flock heavy-verification.lock COMMAND exclude each other on the same files.
Agent setup
npx worker init # write .claude/rules/worker-env.md
npx worker init --check # exit 1 when that file differs from the installed versionworker init writes the agent rules shipped with the installed version to .claude/rules/worker-env.md at the checkout root, replacing the whole file; running it again changes nothing. The rules tell agents to prove changes with tests and typecheck first, start an env only to see the UI, QA a flow end to end or reproduce runtime-only behavior, and never clean up. Commit the file and run worker init --check in CI so it follows package upgrades. Edits to the file fail the check; put project-specific rules elsewhere.
Claude Code loads .claude/rules/ on its own. Add one line to the agent instructions every agent reads (AGENTS.md, or CLAUDE.md where no AGENTS.md exists):
Before running the app, read and follow `.claude/rules/worker-env.md`.Stop the env when a Claude Code session ends with a SessionEnd hook in .claude/settings.json:
{
"hooks": {
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "cd \"$CLAUDE_PROJECT_DIR\" && pnpm worker down"
}
]
}
]
}
}Development
npm test # node --test, against the fixture app in fixture/Resource integration tests run against throwaway containers named by environment variables and are skipped when they are unset:
docker run -d --name test-postgres -e POSTGRES_PASSWORD=postgres postgres:16
docker run -d --name test-mongo mongo:7
docker run -d --name test-redis redis:7
docker run -d --name test-typesense -p 8108:8108 -e TYPESENSE_API_KEY=test-key -e TYPESENSE_DATA_DIR=/tmp typesense/typesense:27.1
WORKER_ENV_TEST_POSTGRES=test-postgres WORKER_ENV_TEST_MONGO=test-mongo WORKER_ENV_TEST_REDIS=test-redis \
WORKER_ENV_TEST_TYPESENSE_URL=http://localhost:8108 WORKER_ENV_TEST_TYPESENSE_API_KEY=test-key npm testReleases are published to npm by semantic-release from conventional commits on main.
