For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Split the sandbox so REPL runs as a daemonizable service separate from Claude CLI.
Architecture: Two Dockerfiles (repl + claude), docker-compose with four services (postgres, localstack, repl, claude). The sandbox:start command starts infrastructure, sandbox:claude runs Claude ephemerally.
Tech Stack: Docker, docker-compose, Babashka, Alpine Linux, Node.js, JDK 21
Split the JDK/Clojure/nREPL parts from the current Dockerfile into a dedicated repl image.
Files:
sandbox/Dockerfile.replsandbox/entrypoint.sh (will become repl-entrypoint.sh)Step 1: Create Dockerfile.repl
FROM eclipse-temurin:21-jdk-alpine
# System dependencies
RUN apk add --no-cache \
bash curl git \
netcat-openbsd \
rlwrap \
shadow \
python3 py3-pip
# Install AWS CLI (for LocalStack initialization)
RUN pip3 install --break-system-packages awscli
# Install Clojure CLI
RUN curl -L -O https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh \
&& chmod +x linux-install.sh \
&& ./linux-install.sh \
&& rm linux-install.sh
# Install Babashka
RUN curl -sLO https://raw.githubusercontent.com/babashka/babashka/master/install \
&& chmod +x install \
&& ./install --dir /usr/local/bin \
&& rm install
# Create shared group with fixed GID (must match host group)
# and non-root user
RUN addgroup -g 1600 orcha && \
adduser -D -G orcha -h /home/repl repl
WORKDIR /workspace
COPY repl-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Ensure repl owns its home directory
RUN chown -R repl:orcha /home/repl && \
chmod -R g+rw /home/repl
USER repl
ENTRYPOINT ["/entrypoint.sh"]
Step 2: Rename and modify entrypoint.sh to repl-entrypoint.sh
#!/bin/bash
set -eu
# Ensure new files are group-writable (for shared GID access with host)
umask 002
NREPL_PORT="${NREPL_PORT:-7888}"
cd /workspace
# Initialize AWS resources (idempotent)
echo "Initializing AWS resources..."
bb dev:init-aws
echo "Starting nREPL on port $NREPL_PORT..."
# Start nREPL with cider middleware in foreground (not backgrounded)
# -J flag passes system property to JVM for LocalStack endpoint resolution
exec clojure -J-Dlocalstack.endpoint=http://localstack:4566 \
-Sdeps '{:deps {nrepl/nrepl {:mvn/version "1.3.1"}
cider/cider-nrepl {:mvn/version "0.56.0"}
refactor-nrepl/refactor-nrepl {:mvn/version "3.11.0"}}
:aliases {:cider/nrepl {:main-opts ["-m" "nrepl.cmdline"
"--middleware" "[refactor-nrepl.middleware/wrap-refactor,cider.nrepl/cider-middleware]"]}}}' \
-M:dev:test:cider/nrepl \
-p "$NREPL_PORT" \
-b 0.0.0.0
Step 3: Verify syntax
Run: bash -n sandbox/repl-entrypoint.sh
Expected: No output (valid syntax)
Step 4: Commit
git add sandbox/Dockerfile.repl sandbox/repl-entrypoint.sh
git commit -m "feat(sandbox): add Dockerfile.repl and repl-entrypoint.sh"
Create a minimal image with just Node.js, Claude CLI, and clj-nrepl-eval.
Files:
sandbox/Dockerfile.claudesandbox/claude-entrypoint.shStep 1: Create Dockerfile.claude
FROM node:22-alpine
# System dependencies (minimal)
RUN apk add --no-cache \
bash git \
shadow
# Install Babashka (needed for clj-nrepl-eval)
RUN wget -O /usr/local/bin/bb https://github.com/babashka/babashka/releases/download/v1.12.199/babashka-1.12.199-linux-amd64-static \
&& chmod +x /usr/local/bin/bb
# Install bbin
RUN wget -O /usr/local/bin/bbin https://raw.githubusercontent.com/babashka/bbin/v0.2.4/bbin \
&& chmod +x /usr/local/bin/bbin
# Install Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# Create shared group with fixed GID (must match host group)
# and non-root user (claude --dangerously-skip-permissions refuses root)
RUN addgroup -g 1600 orcha && \
adduser -D -G orcha -h /home/claude claude
# Switch to claude user to install bbin tools (paths will be correct)
USER claude
ENV HOME=/home/claude
ENV PATH="/home/claude/.local/bin:${PATH}"
# Install clj-nrepl-eval via bbin (must be done as claude user)
RUN bbin install https://github.com/bhauman/clojure-mcp-light.git \
--as clj-nrepl-eval \
--main-opts '["-m" "clojure-mcp-light.nrepl-eval"]'
# Back to root for final setup
USER root
WORKDIR /workspace
COPY claude-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Ensure claude owns its home directory
RUN chown -R claude:orcha /home/claude && \
chmod -R g+rw /home/claude
USER claude
ENTRYPOINT ["/entrypoint.sh"]
Step 2: Create claude-entrypoint.sh
#!/bin/bash
set -eu
# Ensure new files are group-writable (for shared GID access with host)
umask 002
cd /workspace
exec "$@"
Step 3: Verify syntax
Run: bash -n sandbox/claude-entrypoint.sh
Expected: No output (valid syntax)
Step 4: Commit
git add sandbox/Dockerfile.claude sandbox/claude-entrypoint.sh
git commit -m "feat(sandbox): add Dockerfile.claude and claude-entrypoint.sh"
Add repl service, update claude service to use new Dockerfile.
Files:
sandbox/docker-compose.ymlStep 1: Replace docker-compose.yml
name: orcha-sandbox-${FEATURE_NAME:-default}
services:
postgres:
image: pgvector/pgvector:pg18
environment:
POSTGRES_DB: orcha
POSTGRES_HOST_AUTH_METHOD: trust
volumes:
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d orcha"]
interval: 2s
timeout: 5s
retries: 10
start_period: 5s
localstack:
image: localstack/localstack:4.0
environment:
- SERVICES=s3,sqs,secretsmanager,ssm,kms
- DEFAULT_REGION=eu-central-1
- PERSISTENCE=1
volumes:
- localstack-data:/var/lib/localstack
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
interval: 2s
timeout: 5s
retries: 10
start_period: 10s
repl:
build:
context: .
dockerfile: Dockerfile.repl
depends_on:
postgres:
condition: service_healthy
localstack:
condition: service_healthy
# Disable IPv6 - Docker bridge network doesn't support it, causes timeouts
sysctls:
- net.ipv6.conf.all.disable_ipv6=1
environment:
- ENVIRON_NAME=sandbox
- NREPL_PORT=7888
volumes:
- ${WORKTREE_PATH:-.}:/workspace
- ${HOME}/.m2:/home/repl/.m2
ports:
- "${NREPL_HOST_PORT:-17888}:7888"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 7888"]
interval: 2s
timeout: 5s
retries: 30
start_period: 60s
claude:
build:
context: .
dockerfile: Dockerfile.claude
# Disable IPv6 - Docker bridge network doesn't support it, causes timeouts
sysctls:
- net.ipv6.conf.all.disable_ipv6=1
volumes:
- ${WORKTREE_PATH:-.}:/workspace
# Claude Code config and state
- ${HOME}/.claude.json:/home/claude/.claude.json
- ${HOME}/.claude:/home/claude/.claude
# Sandbox-specific projects dir (isolates conversation history)
- ${WORKTREE_PATH:-.}/.claude-sandbox/projects:/home/claude/.claude/projects
stdin_open: true
tty: true
volumes:
postgres-data:
localstack-data:
Step 2: Verify compose file syntax
Run: cd sandbox && docker-compose config > /dev/null && echo "Valid"
Expected: Valid
Step 3: Commit
git add sandbox/docker-compose.yml
git commit -m "feat(sandbox): split repl and claude services in docker-compose"
Clean up the old combined files.
Files:
sandbox/Dockerfilesandbox/entrypoint.shStep 1: Remove old files
git rm sandbox/Dockerfile sandbox/entrypoint.sh
Step 2: Commit
git commit -m "chore(sandbox): remove old combined Dockerfile and entrypoint"
Update the argument parser to handle the -d daemon flag.
Files:
scripts/sandbox.clj:174-196 (parse-args function)Step 1: Update parse-args to handle -d flag
Replace the parse-args function:
(defn parse-args
"Parse --port, --worktree, -d, and --help flags from args.
Returns {:port n :feature name :worktree bool :daemon bool :help bool :services [...]}"
[args]
(loop [[a & more :as args] args
result {}]
(if (empty? args)
result
(cond
(or (= a "--help") (= a "-h"))
(recur more (assoc result :help true))
(= a "--port")
(recur (rest more) (assoc result :port (parse-long (first more))))
(= a "--worktree")
(recur more (assoc result :worktree true))
(= a "-d")
(recur more (assoc result :daemon true))
(nil? (:feature result))
(recur more (assoc result :feature a))
:else
(recur more (update result :services (fnil conj []) a))))))
Step 2: Commit
git add scripts/sandbox.clj
git commit -m "feat(sandbox): add -d flag to parse-args"
Factor out the worktree setup logic to be reused by both start and claude commands.
Files:
scripts/sandbox.clj (add new function before start)Step 1: Add ensure-worktree! function
Add this function before the start function (around line 335):
(defn ensure-worktree!
"Ensure worktree exists for feature branch. Returns {:worktree-path :abs-orcha-path :port}
or nil if cancelled."
[feature port]
(let [port (or port (feature->port feature))
worktree-path (str (fs/path worktree-base feature))
abs-orcha-path (str (fs/absolutize (fs/path worktree-path "orcha")))]
(if (fs/exists? worktree-path)
(do
(println (str "Reusing existing worktree at " worktree-path))
{:worktree-path worktree-path :abs-orcha-path abs-orcha-path :port port})
(do
(println "Fetching from origin...")
(p/shell {:out :inherit :err :inherit} "git" "fetch" "origin")
(println)
(let [branch-state (classify-branch feature)
decision (confirm-branch feature branch-state)]
(when-let [branch (prepare-branch decision)]
(println (str "\nCreating worktree at " worktree-path "..."))
(fs/create-dirs (fs/parent worktree-path))
(p/shell "git" "worktree" "add" worktree-path branch)
{:worktree-path worktree-path :abs-orcha-path abs-orcha-path :port port}))))))
Step 2: Commit
git add scripts/sandbox.clj
git commit -m "refactor(sandbox): extract ensure-worktree! function"
Update start to use docker-compose up with optional -d flag.
Files:
scripts/sandbox.clj (replace start function)Step 1: Replace the start function
(defn start
"Start sandbox infrastructure (postgres, localstack, repl) for a branch."
[args]
(let [{:keys [feature port daemon help]} (parse-args args)]
(when help
(print-help
(str "Usage: bb sandbox:start <branch-name> [--port PORT] [-d]\n\n"
"Start sandbox infrastructure for a branch.\n\n"
"Launches Docker containers:\n"
" - PostgreSQL database\n"
" - LocalStack (AWS emulation)\n"
" - Clojure nREPL server\n\n"
"Options:\n"
" --port PORT Override nREPL port (default: hash-based 17000-17999)\n"
" -d Run in daemon mode (background)\n\n"
"Branch handling:\n"
" - If branch exists locally or remotely, prompts for confirmation\n"
" - If branch doesn't exist, creates from master\n"
" - If worktree already exists, reuses it\n\n"
"Examples:\n"
" bb sandbox:start my-feature # Foreground, shows logs\n"
" bb sandbox:start my-feature -d # Background (daemon)\n"
" bb sandbox:start bugfix-123 --port 18000")))
(when-not feature
(println "Usage: bb sandbox:start <branch-name> [--port PORT] [-d]")
(System/exit 1))
(when-let [{:keys [worktree-path abs-orcha-path port]} (ensure-worktree! feature port)]
(spit (str (fs/path worktree-path ".nrepl-port")) (str port))
(println (str "nREPL will be exposed on port " port))
(fs/create-dirs (str (fs/path abs-orcha-path ".claude-sandbox" "projects")))
(println "Starting sandbox infrastructure...")
(apply p/shell
{:dir sandbox-dir
:extra-env {"WORKTREE_PATH" abs-orcha-path
"NREPL_HOST_PORT" (str port)
"FEATURE_NAME" feature
"HOME" (System/getenv "HOME")}}
"docker-compose" "up"
(if daemon ["-d" "--wait"] [])))))
Step 2: Commit
git add scripts/sandbox.clj
git commit -m "feat(sandbox): rewrite start to use docker-compose up with -d flag"
Add the new claude function for running Claude CLI.
Files:
scripts/sandbox.clj (add after start function)Step 1: Add claude function
(defn claude
"Start Claude CLI in sandbox for a branch."
[args]
(let [{:keys [feature port help]} (parse-args args)]
(when help
(print-help
(str "Usage: bb sandbox:claude <branch-name> [--port PORT]\n\n"
"Start Claude CLI in a sandbox.\n\n"
"Runs claude --dangerously-skip-permissions in a container\n"
"connected to the sandbox network. The REPL and infrastructure\n"
"don't need to be running (but Claude won't be able to reach them).\n\n"
"Options:\n"
" --port PORT Override nREPL port for .nrepl-port file\n\n"
"Examples:\n"
" bb sandbox:claude my-feature")))
(when-not feature
(println "Usage: bb sandbox:claude <branch-name>")
(System/exit 1))
(when-let [{:keys [worktree-path abs-orcha-path port]} (ensure-worktree! feature port)]
(spit (str (fs/path worktree-path ".nrepl-port")) (str port))
(fs/create-dirs (str (fs/path abs-orcha-path ".claude-sandbox" "projects")))
(println "Starting Claude CLI...")
(p/shell {:dir sandbox-dir
:extra-env {"WORKTREE_PATH" abs-orcha-path
"NREPL_HOST_PORT" (str port)
"FEATURE_NAME" feature
"HOME" (System/getenv "HOME")}}
"docker-compose" "run" "--rm" "claude"
"claude" "--dangerously-skip-permissions"))))
Step 2: Commit
git add scripts/sandbox.clj
git commit -m "feat(sandbox): add claude command"
Add the logs function for tailing daemonized sandbox logs.
Files:
scripts/sandbox.clj (add after stop function)Step 1: Add logs function
(defn logs
"Tail logs from sandbox containers"
[args]
(let [{:keys [feature services help]} (parse-args args)]
(when help
(print-help
(str "Usage: bb sandbox:logs <name> [service...]\n\n"
"Tail logs from sandbox containers.\n\n"
"Services: postgres, localstack, repl\n\n"
"Examples:\n"
" bb sandbox:logs my-feature # All services\n"
" bb sandbox:logs my-feature repl # Just repl")))
(when-not feature
(println "Usage: bb sandbox:logs <name> [service...]")
(System/exit 1))
(apply p/shell
{:dir sandbox-dir
:extra-env {"FEATURE_NAME" feature}}
"docker-compose" "logs" "-f"
(or services []))))
Step 2: Commit
git add scripts/sandbox.clj
git commit -m "feat(sandbox): add logs command"
Change find-claude-container to find-repl-container and update references.
Files:
scripts/sandbox.clj (update expose-related functions)Step 1: Rename find-claude-container to find-repl-container
Replace lines 217-225:
(defn find-repl-container
"Find the repl container ID for a feature, or nil if not running"
[feature]
(let [result (p/shell {:out :string :continue true}
"docker" "ps" "-q" "-f"
(str "name=orcha-sandbox-" feature "-repl"))]
(when (and (zero? (:exit result))
(not (str/blank? (:out result))))
(str/trim (:out result)))))
Step 2: Update expose function to use find-repl-container
In the expose function (around line 260), change:
(let [container-id (find-claude-container feature)]
to:
(let [container-id (find-repl-container feature)]
Step 3: Commit
git add scripts/sandbox.clj
git commit -m "fix(sandbox): update expose to use repl container"
Register the new sandbox:claude and sandbox:logs tasks.
Files:
bb.ednStep 1: Add sandbox:claude task after sandbox:start
sandbox:claude
{:doc "Start Claude CLI in sandbox: bb sandbox:claude <feature-name>"
:requires ([sandbox])
:task (sandbox/claude *command-line-args*)}
Step 2: Add sandbox:logs task after sandbox:stop
sandbox:logs
{:doc "Tail sandbox logs: bb sandbox:logs <feature-name> [service...]"
:requires ([sandbox])
:task (sandbox/logs *command-line-args*)}
Step 3: Update sandbox:start doc string
Change:
{:doc "Start sandboxed Claude session: bb sandbox:start <feature-name> [--port PORT]"
to:
{:doc "Start sandbox infrastructure: bb sandbox:start <feature-name> [--port PORT] [-d]"
Step 4: Commit
git add bb.edn
git commit -m "feat(sandbox): register sandbox:claude and sandbox:logs tasks"
Build both images and verify they work.
Step 1: Build repl image
Run: cd sandbox && docker-compose build repl
Expected: Build completes successfully
Step 2: Build claude image
Run: cd sandbox && docker-compose build claude
Expected: Build completes successfully
Step 3: Test start in daemon mode
Run: bb sandbox:start test-split -d
Expected: Containers start in background, command returns
Step 4: Test logs
Run: bb sandbox:logs test-split repl
Expected: Shows repl logs, nREPL starting
Step 5: Test claude
Run: bb sandbox:claude test-split
Expected: Claude CLI starts, can interact with it
Step 6: Test stop
Run: bb sandbox:stop test-split
Expected: All containers stop
Step 7: Clean up
Run: bb sandbox:clean test-split --worktree
Expected: Containers, volumes, and worktree removed
Step 8: Commit (if any fixes needed)
If any fixes were needed during testing, commit them with appropriate messages.
Mark the design as implemented.
Files:
docs/plans/2026-02-24-sandbox-repl-claude-split-design.mdStep 1: Add implementation status
Add to the top of the design doc after the title:
**Status:** Implemented (2026-02-24)
Step 2: Commit
git add docs/plans/2026-02-24-sandbox-repl-claude-split-design.md
git commit -m "docs: mark sandbox split design as implemented"