| date_created | 2026-02-05 |
|---|---|
| author | Human |
| status | validated |
| validation_progress | 27/27 |
| claims_extracted | true |
| last_validated | 2026-02-13 |
| last_validated_by | Claude |
Terminal workspace orchestrator for parallel multi-repo development with AI coding agents.
Claude Code must never know it is running inside a container.
Every architectural decision flows from this constraint. Claude runs pnpm dev. Claude curls localhost:3000. Claude runs npx playwright test. All of these must work exactly as they would on a bare metal machine.
- DO NOT expose Docker to Claude Code — Claude never sees containers, images, or port mappings
- DO NOT require Claude to check environment variables, remember mapped ports, or do anything non-standard
- DO NOT leak container abstractions through error messages, paths, or process names
- ONLY present an environment where
localhost:{port}is the real service, because it is — the command executes inside the container's network namespace - If Claude has to remember anything about the infrastructure, the system has failed
One monorepo worktree = one container = one machine.
- DO NOT split monorepo services into separate containers
- DO NOT use docker-compose per project
- DO NOT create per-service isolation within a worktree
- ONLY create one container per worktree clone
- The developer runs
pnpm devand the monorepo's own tooling (turbo, nx) starts all services inside that single container on their default ports - All services share the same
localhostinside the container — they talk to each other normally
All workspaces run simultaneously. There is no concept of a single "active" workspace.
- DO NOT design around one workspace being active at a time
- DO NOT require stopping one workspace to start another
- DO NOT share state, ports, or resources between workspaces
- 5 repos × 3 worktrees each = 15 simultaneous environments, all with Claude Code sessions active, all running dev servers on default ports, with zero conflicts
- Each container has its own network namespace — 15 containers can all bind
:3000simultaneously
Dual is the orchestration layer between the terminal emulator and the runtime. It does not replace tmux. It does not replace Docker. It replaces how the developer interacts with both.
| Layer | What It Is | Who Owns It |
|---|---|---|
| Terminal Emulator | Ghostty, iTerm, Kitty, Alacritty — provides the rendering surface | User's choice |
| Dual | Workspace switching, clone lifecycle, container lifecycle, command routing, reverse proxy | This project |
| Isolation Layer | Docker — one headless container per worktree, all services inside | Docker (orchestrated by Dual) |
| Reverse Proxy | Dual subprocess — maps *.localhost:{port} to containers |
This project |
| Runtime Backend | tmux/zellij on the host — keeps dev tool panes alive across workspace switches | tmux (orchestrated by Dual) |
| Workspace Processes | Split: dev tools on host (nvim, claude, git) · all services in container (pnpm dev) |
Split ownership |
There are exactly two consumers of a workspace's running services. They access them differently.
Claude Code (and all CLI tools) → Container Network
All runtime commands execute inside the container via dual run → docker exec. Inside the container, localhost:3000 is the real service. 15 Claude sessions can all run pnpm dev on :3000 simultaneously because each container has its own network namespace.
Developer (browser) → Multi-Port Reverse Proxy
Dual runs a lightweight reverse proxy on the host. The proxy listens on all ports that any container is using. Subdomain selects the container: lightfast-feat-auth.localhost:3001 routes to the dual-lightfast-feat-auth container's :3001. *.localhost resolves to 127.0.0.1 natively in all modern browsers — no /etc/hosts, no DNS config, no browser extensions.
Dual maintains a routing table that determines where each command executes.
| Command | Executes On | Why |
|---|---|---|
edit src/app.tsx |
Host | Bind mount — container sees the change via shared filesystem |
git commit -m "fix" |
Host | Uses developer's credentials and SSH keys |
cat package.json |
Host | Read-only file operation |
pnpm dev |
Container | Binds ports — must be in isolated network namespace |
pnpm install |
Container | Writes to node_modules inside container |
curl localhost:3000 |
Container | Needs access to the container's network |
npx playwright test |
Container | Needs both browser runtime and network access |
- Anything
npm/pnpm/node/python/curl→ container - Anything
git/cat/ls/vim/nvim→ host - Configurable per project via workspace config
- The mechanism:
dual run <command>wrapsdocker exec <container-name> <command> - Claude Code's shell is configured so that commands are intercepted and routed — Claude never sees
dual runordocker exec
Every workspace is a full git clone. Each has its own .git/ directory, its own node_modules, its own build artifacts, its own env files.
- DO NOT use
git worktree— shared.gitobject store creates lock contention when 15 sessions are active - DO NOT share refs across workspaces — one bad rebase can corrupt all worktrees
- DO NOT share stash, index, or configs across workspaces
Why not worktrees:
- Shared
.gitobject store creates lock contention with 15 active sessions - Shared refs mean one bad rebase can corrupt all worktrees
- Cannot have two worktrees on the same branch
- Stash, index, and some configs are shared — causes unexpected side effects
- Claude modifying
.gitstate in one worktree can break another
Clone strategy:
- Remote origin →
git clone <url>(standard network clone) - Local path →
git clone --local <path>(uses hardlinks — fast, low disk overhead) - Optional
--depth Nfor large repos where full history is not needed
~/dual-workspaces/
├─ lightfast-platform/ ← project (repo)
│ ├─ main/ ← full clone, own .git, own node_modules
│ ├─ feat__auth/ ← full clone, completely independent
│ └─ fix__memory-leak/ ← full clone
└─ agent-os/ ← project (repo)
├─ main/
└─ v2-rewrite/
- Branch names use double-underscore as separator (
feat/auth→feat__auth) because filesystem paths cannot contain/ - Each clone directory is completely independent — no shared state of any kind
Dual inspects the monorepo and auto-builds a minimal container image.
- DO NOT require Dockerfiles from the developer
- DO NOT include dev tools (nvim, claude, git) in the container — those stay on the host
- ONLY install what's needed to run
pnpm dev
| Detects | Provisions in Container |
|---|---|
.nvmrc / .node-version |
Node.js (exact version) |
pnpm-lock.yaml |
pnpm + turbo/nx |
pyproject.toml |
Python + uv/pip |
docker-compose.yml |
Parses service definitions → postgres, redis, etc. |
playwright.config.* |
Chromium + browser dependencies |
.env / .env.local |
Environment variables |
turbo.json / nx.json |
Monorepo task runner |
- Image is cached per monorepo — all worktrees of the same repo share the same base image
- Rebuilt only when dependencies change (lockfile hash, node version, etc.)
Pattern: dual-{repo}-{branch}
Examples:
dual-lightfast-maindual-lightfast-feat-authdual-agent-os-feat-memory
The worktree directory on the host is bind-mounted into the container:
- File edits on the host (via nvim or Claude) are immediately visible inside the container
- The dev server's hot reload picks up changes instantly
node_modulesand build artifacts live inside the container's filesystem- The source tree is shared; the runtime state is isolated
The proxy does not run on a single port. It binds to every port that any container is using.
- DO NOT use a single gateway port with path-based routing
- DO NOT require the developer to remember port mappings
- DO NOT require any host-side configuration (no
/etc/hosts, no DNS, no browser extensions) - ONLY use subdomain + port to select the target:
{repo}-{branch}.localhost:{port}→ container's:{port}
{repo}-{branch}.localhost:{port}
- Proxy is a Dual subprocess — starts automatically when Dual starts, zero config
- When a container starts and binds ports (
:3000,:3001,:4001), Dual registers those ports with the proxy - Proxy starts listening on any newly registered ports (if not already listening)
- Request arrives at
lightfast-feat-auth.localhost:3001:- Proxy reads the
Hostheader →lightfast-feat-auth.localhost - Proxy reads the port →
:3001 - Proxy looks up the container →
dual-lightfast-feat-auth - Proxy forwards the request to that container's
:3001
- Proxy reads the
- When containers stop, their port registrations are removed. If no other container uses that port, the proxy stops listening on it.
- MUST support HTTP and WebSocket (for hot reload / HMR)
- MUST support SSE (Server-Sent Events) and streaming responses
- MUST update routing table dynamically as containers start and stop
- MUST handle concurrent connections to multiple containers simultaneously
- Implementation: Caddy, Traefik, or a custom ~150 line Go/Node HTTP proxy
For 2 monorepos × 3 worktrees each (6 containers, 18+ services):
lightfast-main.localhost:3000 → www (marketing site)
lightfast-main.localhost:3001 → app (dashboard)
lightfast-main.localhost:4001 → api (backend)
lightfast-main.localhost:3002 → docs
lightfast-feat-auth.localhost:3000 → www
lightfast-feat-auth.localhost:3001 → app
lightfast-feat-auth.localhost:4001 → api
lightfast-feat-auth.localhost:3002 → docs
lightfast-fix-billing.localhost:3000 → www
lightfast-fix-billing.localhost:3001 → app
lightfast-fix-billing.localhost:4001 → api
lightfast-fix-billing.localhost:3002 → docs
agent-os-main.localhost:8080 → runtime (engine)
agent-os-main.localhost:3000 → studio (visual builder)
agent-os-main.localhost:9000 → gateway (API)
agent-os-feat-memory.localhost:8080 → runtime
agent-os-feat-memory.localhost:3000 → studio
agent-os-feat-memory.localhost:9000 → gateway
agent-os-perf-hotpath.localhost:8080 → runtime
agent-os-perf-hotpath.localhost:3000 → studio
agent-os-perf-hotpath.localhost:9000 → gateway
All on default ports inside their containers. All accessible simultaneously from the developer's browser.
The runtime backend is an abstraction over the terminal multiplexer. Dual ships with a tmux backend as the default, but the interface is designed to be swappable.
| Method | Signature | Description |
|---|---|---|
create_session |
(workspace_id, processes[]) |
Create a new runtime session with the given processes. Returns a session handle. |
attach |
(session_handle) |
Connect the current terminal to this session. User sees the processes. |
detach |
(session_handle) |
Disconnect terminal from session. Processes keep running in background. |
destroy |
(session_handle) |
Kill all processes and tear down the session. |
is_alive |
(session_handle) → bool |
Check if the session still has running processes. |
list_sessions |
() → session_handle[] |
Return all managed sessions. |
| Backend | Status | Description |
|---|---|---|
TmuxBackend |
Default | Each session = tmux session. Processes = tmux panes. Attach/detach is native tmux. |
ZellijBackend |
Future | Same model, different multiplexer. |
BasicBackend |
Fallback | Background processes only. No multiplexing or panes. |
If a user doesn't have tmux, Dual still works. They get clone management, workspace switching, container lifecycle, and the reverse proxy — just without pane layouts. The BasicBackend is the floor. Multiplexer backends are progressive enhancements.
- Developer opens terminal emulator
- Runs
dual— it becomes their session - Dual shows fuzzy picker across all projects/workspaces
- Developer selects a workspace
- Dual: creates clone if needed → starts container → creates tmux session → attaches
- Developer lands in their workspace with nvim, claude, and shell running
- Developer presses the meta key
- Current workspace is "frozen" (detached from runtime — processes keep running)
- Dual overlay appears with fuzzy picker
- Shows: project name, branch, session status (● running / ○ stopped)
- Developer types to filter, presses enter to select
- Dual attaches to the new workspace's runtime session
- If no session exists, Dual creates one (clone + container + launch processes)
- Loops back — developer can switch again at any time
The developer never leaves Dual's control. They never run tmux attach. They never run docker exec. They never manage sessions manually.
dual → launch (shows fuzzy picker)
dual open → opens all services for current workspace in browser
dual open lightfast-main → opens all lightfast-main services in browser
dual urls → lists all running workspace URLs
dual urls lightfast-feat-auth → URLs for that specific workspace
lightfast-feat-auth ● running
www lightfast-feat-auth.localhost:3000
app lightfast-feat-auth.localhost:3001
api lightfast-feat-auth.localhost:4001
docs lightfast-feat-auth.localhost:3002
agent-os-feat-memory ● running
studio agent-os-feat-memory.localhost:3000
runtime agent-os-feat-memory.localhost:8080
gateway agent-os-feat-memory.localhost:9000
| State | Description |
|---|---|
| ATTACHED | Runtime session is connected to your terminal. You see and interact with processes. |
| BACKGROUND | Processes still running (Claude is thinking, dev server is serving) but not displayed. |
| STOPPED | No runtime session. Clone exists on disk. Session created on first access. |
| LAZY | Config-only. No clone on disk. Clone + session created on first access. |
- Does NOT replace tmux/zellij — it orchestrates them
- Does NOT replace Docker — it orchestrates it
- Does NOT require Dockerfiles — it auto-generates container images
- Does NOT expose Docker to Claude Code — Claude never knows containers exist
- Does NOT require port remapping awareness from any tool
- Does NOT require
/etc/hostschanges or DNS configuration - Does NOT run dev tools (nvim, claude, git) inside containers — those stay on the host
- Does NOT manage a single "active" workspace — all workspaces run simultaneously
- Workspace config format and discovery
- Full clone management (
git clone --local) - tmux backend (create, attach, detach, destroy)
- Meta-key workspace switcher with fuzzy picker
- Filesystem layout (
~/dual-workspaces/{repo}/{branch}/)
- Container image auto-generation from project files
- Container lifecycle management (create, start, stop, destroy)
- Bind mount configuration
- Command routing (
dual run→docker exec) - Shell integration for transparent command interception
- Multi-port reverse proxy
- Dynamic port registration as containers start/stop
- Subdomain routing (
*.localhost) - WebSocket and SSE support
dual openanddual urlscommands
- TUI with workspace sidebar showing live status
dual urlsin TUI sidebar- Container image caching and rebuild detection
- Zellij backend
BasicBackendfor users without a multiplexer