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.