1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
FROM node:24-trixie-slim

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean \
    && apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    ca-certificates \
    curl \
    fd-find \
    git \
    jq \
    just \
    ripgrep \
    && ln -sf /usr/bin/fdfind /usr/bin/fd

RUN --mount=type=cache,target=/tmp/downloads \
    curl -fsSL "https://github.com/Wilfred/difftastic/releases/latest/download/difft-aarch64-unknown-linux-gnu.tar.gz" \
      | tar xz -C /usr/local/bin difft \
    && curl -fsSL "https://github.com/facebook/dotslash/releases/latest/download/dotslash-linux-musl.aarch64.tar.gz" \
      | tar xz -C /usr/local/bin dotslash \
    && curl -fsSL "https://github.com/kejadlen/ranger/releases/latest/download/ranger-aarch64-unknown-linux-gnu.tar.gz" \
      | tar xz -C /usr/local/bin ranger

RUN --mount=type=secret,id=github-token,target=/run/secrets/github-token \
    if [ -s /run/secrets/github-token ]; then \
      JJ_VERSION=$(curl -s -H "Authorization: Bearer $(cat /run/secrets/github-token)" \
        https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
    else \
      JJ_VERSION=$(curl -s https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
    fi && \
    curl -fsSL "https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/jj-${JJ_VERSION}-aarch64-unknown-linux-musl.tar.gz" \
      | tar xz --strip-components=0 -C /usr/local/bin ./jj

# pi publishes under @earendil-works. The older @mariozechner scope still
# resolves and still carries the `latest` tag, so it looks current, but it's
# abandoned at 0.73.1 — far enough behind that host extensions fail to load
# against it (no ./compat export in its pi-ai).
RUN --mount=type=cache,target=/root/.npm \
    npm install -g @earendil-works/pi-coding-agent @anthropic-ai/claude-code

# Claude Code refuses --dangerously-skip-permissions when running as root
# unless IS_SANDBOX=1 acknowledges the sandbox. The container is the sandbox.
# Pi ignores this.
ENV IS_SANDBOX=1

# Run Claude in bypass-permissions mode and skip the bypass dialog. Managed
# settings is the highest tier, so this overrides whatever's in the user's
# mounted ~/.claude. defaultMode replaces the --dangerously-skip-permissions
# CLI flag; skipDangerousModePermissionPrompt suppresses the once-per-machine
# acceptance dialog. The container is already a sandbox (see IS_SANDBOX), so
# prompting is redundant.
RUN mkdir -p /etc/claude-code && cat > /etc/claude-code/managed-settings.json <<'EOF'
{
  "permissions": { "defaultMode": "bypassPermissions" },
  "skipDangerousModePermissionPrompt": true
}
EOF

WORKDIR /workspace

# No ENTRYPOINT: one image carries both agents, and the generated compose
# config picks `pi` or `claude` per session.