Sandbox REPL/Claude Split Implementation Plan

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


Task 1: Create Dockerfile.repl

Split the JDK/Clojure/nREPL parts from the current Dockerfile into a dedicated repl image.

Files:

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"

Task 2: Create Dockerfile.claude

Create a minimal image with just Node.js, Claude CLI, and clj-nrepl-eval.

Files:

Step 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"

Task 3: Update docker-compose.yml

Add repl service, update claude service to use new Dockerfile.

Files:

Step 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"

Task 4: Delete old Dockerfile and entrypoint.sh

Clean up the old combined files.

Files:

Step 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"

Task 5: Add -d flag to parse-args

Update the argument parser to handle the -d daemon flag.

Files:

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"

Task 6: Extract worktree setup into shared function

Factor out the worktree setup logic to be reused by both start and claude commands.

Files:

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"

Task 7: Rewrite start function

Update start to use docker-compose up with optional -d flag.

Files:

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"

Task 8: Add claude command

Add the new claude function for running Claude CLI.

Files:

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"

Task 9: Add logs command

Add the logs function for tailing daemonized sandbox logs.

Files:

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"

Task 10: Update expose to use repl container

Change find-claude-container to find-repl-container and update references.

Files:

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"

Task 11: Add bb.edn tasks

Register the new sandbox:claude and sandbox:logs tasks.

Files:

Step 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"

Task 12: Build and test images

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.


Task 13: Final commit - update design doc status

Mark the design as implemented.

Files:

Step 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"