#!/usr/bin/env bash # Bring up the headless display, then hand over to the command. # # Xvfb and openbox are started HERE, as children of PID 1 (tini), rather than by # the toolkit scripts. That is the fix for the long-standing "Xvfb and the # emulator die on their own every few minutes" note: nothing owned those # processes, so nothing kept them alive, and a run could sit for 300 s in front # of a visible MAIN MENU reporting "no main menu" because the display had gone. # A container-lifetime display makes that failure mode impossible. set -euo pipefail log() { printf '[entrypoint] %s\n' "$*" >&2; } DISPLAY="${DISPLAY:-:98}" GEOM="${SCREEN_GEOMETRY:-1280x720x24}" export DISPLAY # ── Display ────────────────────────────────────────────────────────────────── if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then rm -f "/tmp/.X${DISPLAY#:}-lock" "/tmp/.X11-unix/X${DISPLAY#:}" 2>/dev/null || true # GLX and RANDR are both required: Canary's window is GTK+OpenGL even when the # graphics backend is Vulkan, and xwininfo-based screen oracles need RANDR. Xvfb "$DISPLAY" -screen 0 "$GEOM" -ac -nolisten tcp \ +extension GLX +extension RANDR >/tmp/xvfb.log 2>&1 & for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break sleep 0.2 done fi if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then log "FATAL: no display on $DISPLAY — see /tmp/xvfb.log" exit 1 fi # A window manager is not cosmetic here: without one the emulator window is # never mapped at a known position, and every pixel oracle reads the wrong rows. if ! pgrep -x openbox >/dev/null 2>&1; then openbox >/tmp/openbox.log 2>&1 & sleep 0.5 fi log "display $DISPLAY ready ($GEOM)" # ── Vulkan ─────────────────────────────────────────────────────────────────── # Prefer the real GPU when /dev/dri was passed through; fall back to lavapipe, # which is slow but correct and needs no host device. LVP=$(ls /usr/share/vulkan/icd.d/lvp_icd*.json 2>/dev/null | head -1) if [ "${SYLPH_VULKAN:-auto}" = "sw" ] || [ ! -e /dev/dri/renderD128 ]; then [ -n "$LVP" ] && export VK_ICD_FILENAMES="$LVP" else unset LIBGL_ALWAYS_SOFTWARE fi # Report what Vulkan ACTUALLY enumerated, not what we asked for. Announcing # "host GPU via /dev/dri" because the device node exists is how an agent ends up # believing it has hardware while running llvmpipe — an NVIDIA card needs the # NVIDIA Container Toolkit, and /dev/dri alone does nothing for it. if command -v vulkaninfo >/dev/null 2>&1; then vkdev=$(vulkaninfo --summary 2>/dev/null | awk -F= '/deviceName/{gsub(/^ +| +$/,"",$2); print $2; exit}') case "${vkdev:-}" in "") log "Vulkan: NO DEVICE — vulkaninfo enumerated nothing" ;; llvmpipe*|lavapipe*) log "Vulkan: $vkdev (SOFTWARE — correct but slow)" ;; *) log "Vulkan: $vkdev (hardware)" ;; esac fi # ── Build parallelism ──────────────────────────────────────────────────────── # Bounded by MEMORY, not just cores. A full-parallel build of this tree has # OOM-killed the host outright, and inside a half-the-box container the ceiling # is lower still. ~1.5 GiB per C++ TU is the rule of thumb that has held. # nproc reports the HOST's core count: --cpus is a CFS quota, not a mask. Using # it would oversubscribe a half-the-box container by exactly 2x, so read the # quota the cgroup actually grants. cpus=$(nproc) if [ -r /sys/fs/cgroup/cpu.max ]; then read -r _q _p < /sys/fs/cgroup/cpu.max || true if [ "${_q:-max}" != max ] && [ "${_p:-0}" -gt 0 ]; then cpus=$(( (_q + _p - 1) / _p )) [ "$cpus" -lt 1 ] && cpus=1 fi fi mem_gib=$(awk '/MemAvailable/{printf "%d", $2/1048576}' /proc/meminfo) # MemAvailable is the HOST's too under cgroup v2; prefer the container's cap. if [ -r /sys/fs/cgroup/memory.max ]; then _m=$(cat /sys/fs/cgroup/memory.max) [ "$_m" != max ] && mem_gib=$(( _m / 1073741824 )) fi [ "${mem_gib:-0}" -lt 1 ] && mem_gib=1 # 🔴 THIS CONSTANT WAS WRONG, AND IT COST A RUN. `mem_gib * 2 / 3` assumes # ~1.5 GB per job. On 2026-09-01 a raw `cargo test --release -p sylpheed-formats` # ran 4 jobs in a 6 GB container and was OOM-killed mid-task. Release-mode rustc # on this workspace needs closer to 2 GB, so the divisor is 2, not 3/2. # # ⚠️ And the kill reported `OOMKilled: true` with **ExitCode 0**, so it read as a # clean exit — which is why the restart policy is `unless-stopped` rather than # `on-failure`. by_mem=$(( mem_gib / 2 )) [ "$by_mem" -lt 1 ] && by_mem=1 jobs=$(( cpus < by_mem ? cpus : by_mem )) # An EXPLICIT cap from the launcher wins. Without this the launcher's # `-e CARGO_BUILD_JOBS=...` was computed, exported over, and silently discarded # — the guardrail was set and then removed three lines later. if [ -n "${CARGO_BUILD_JOBS:-}" ] && [ "${CARGO_BUILD_JOBS}" -ge 1 ] 2>/dev/null; then jobs="$CARGO_BUILD_JOBS" _why=" (explicit, from the launcher)" fi export SYLPH_JOBS="$jobs" CARGO_BUILD_JOBS="$jobs" CMAKE_BUILD_PARALLEL_LEVEL="$jobs" log "build parallelism: $jobs${_why:-} (cpus=$cpus, mem=${mem_gib}GiB avail)" mkdir -p "$HOME/shots" "$HOME/logs" # ── The repository, cloned into THIS AGENT'S OWN volume ───────────────────── # Not a bind mount of a human's working tree. That arrangement bit this project # three times: an agent's `git config --local` captured a human's commits, a # credential helper leaked a container-only path onto the host, and a `git add # -A` swept an agent's in-flight files into somebody else's commit. Separate # checkouts make all three impossible rather than discouraged. # # Cloned ONCE. Never auto-pulled: pulling under a running agent moves files out # from under whatever it is mid-edit, which is the same class of bug again. if ! git -C /work rev-parse --verify HEAD >/dev/null 2>&1; then # Checks for a usable HEAD, not merely a .git directory. A clone interrupted # partway -- the container stopped while it ran, which has happened -- leaves # a .git with no commits, and a presence check would then skip the retry # forever and hand the agent an empty repository. echo "[entrypoint] cloning ${SYLPH_REPO_URL:-https://git.mc02.dev/fabi/Sylpheed.git} into /work" _tmp=$(mktemp -d) if git clone --quiet "${SYLPH_REPO_URL:-https://git.mc02.dev/fabi/Sylpheed.git}" "$_tmp/r"; then find /work -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true mv "$_tmp/r"/.[!.]* "$_tmp/r"/* /work/ 2>/dev/null || true echo "[entrypoint] /work at $(git -C /work rev-parse --short HEAD) on $(git -C /work rev-parse --abbrev-ref HEAD)" else echo "[entrypoint] clone FAILED -- the agent has no repository" >&2 fi rm -rf "$_tmp" else echo "[entrypoint] /work at $(git -C /work rev-parse --short HEAD) on $(git -C /work rev-parse --abbrev-ref HEAD)" fi # The shared exchange, for transient files that must not enter git history. mkdir -p /exchange/files 2>/dev/null || true # ── Claude Code config ─────────────────────────────────────────────────────── # ── Claude state: this agent's own, seeded once from the host ─────────────── # Isolated per agent. Both working directories are /work, and Claude Code keys # its per-project state off the working directory -- so a SHARED ~/.claude put # two independent agents in the same projects/-work/ directory, which undoes the # point of giving them separate checkouts. # # Seeded rather than shared because credentials live in .credentials.json and a # token refresh must be able to write. Copying once means each agent refreshes # its own token and neither can corrupt the host's. # Re-seed whenever the HOST's credentials are newer than ours, not only when # ours are missing. The missing-only guard meant an expired token could never be # replaced: the file existed, so the copy was skipped, and restarting the # container changed nothing. A human re-logging in on the host is exactly the # recovery path, and it has to reach here. # # Newer-wins rather than always-copy, because the container refreshes its own # token during a run and that copy may legitimately be the fresher one. # 🔴 A LONG-LIVED TOKEN WINS, AND THE SEEDING MUST NOT FIGHT IT. # # With CLAUDE_CODE_OAUTH_TOKEN set, copying the host's rotating credential file # in would re-create the exact collision the token exists to remove: three # clients on one rotating refresh token, the losers of a rotation race getting # their stored tokens CLEARED to empty strings and parking at "Login expired". # Measured 2026-09-04 -- see the launcher. if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then log "auth: using the long-lived token from the environment; not seeding OAuth" elif [ -d "$HOME/.claude.seed" ] && \ { [ ! -s "$HOME/.claude/.credentials.json" ] || \ [ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then mkdir -p "$HOME/.claude" cp -a "$HOME/.claude.seed/.credentials.json" "$HOME/.claude/" 2>/dev/null || true for f in settings.json CLAUDE.md; do [ -e "$HOME/.claude.seed/$f" ] && cp -a "$HOME/.claude.seed/$f" "$HOME/.claude/" 2>/dev/null || true done echo "[entrypoint] refreshed ~/.claude credentials from the host" fi # Seed ~/.claude.json from the host's read-only copy, then stamp onboarding as # complete. Claude Code re-runs its first-run wizard whenever # lastOnboardingVersion differs from the installed version — so a container with # a newer Claude than the host stops on the theme picker, with no error and no # log line, and an unattended agent sits there forever. if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true fi # Nothing is restored into `~/.claude.json` on purpose. Resuming is done by # SESSION ID off the transcript instead — see the resume block below for why # the index is useless for this. # `credential.helper=store` rewrites this file by rename-over-target, which # fails with EBUSY on a bind mount -- reported as `fatal: unable to write # credential store`, while the push itself succeeds. A fatal line that is # routinely wrong teaches the reader to ignore the one that is real. if [ -f "$HOME/.git-credentials.host" ]; then cp "$HOME/.git-credentials.host" "$HOME/.git-credentials" 2>/dev/null || true chmod 600 "$HOME/.git-credentials" 2>/dev/null || true fi CLAUDE_VER=$(claude --version 2>/dev/null | grep -oE '^[0-9][0-9.]*' || echo 0.0.0) python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" \ "$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true chmod 600 "$HOME/.claude.json" 2>/dev/null || true # ── Claude Code ────────────────────────────────────────────────────────────── if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then # Drop the image's default CMD first, or `claude` is handed the literal string # "bash" as its prompt and answers a question nobody asked. if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then set -- fi # ── Resume across a restart ──────────────────────────────────────────────── # # The container restarts automatically now, and a restart that opens a BLANK # session throws away everything the agent knew. That is not hypothetical: on # 2026-09-01 an OOM kill ended a run mid-task with a 2.7 MB transcript and two # files uncommitted in the volume. # # 🔴 RESUME BY SESSION ID, NOT BY `--continue`. Measured 2026-09-01: # # `--continue` resolves through `~/.claude.json`'s per-project `history` and # `lastSessionId`. Those are written at a GRACEFUL SHUTDOWN — mid-session the # live file has `history: None`, `lastSessionId: None`. A container that is # OOM-killed or `docker rm -f`ed never writes them, which is exactly the case # this feature exists for. So `--continue` answered "No conversation found to # continue" with 33 MB of perfectly good transcripts in the volume beside it, # and persisting `.claude.json` did not help because the fields were never # populated in the first place. # # The TRANSCRIPTS are durable and are named by session id, so read the id off # the newest one for this working directory. Claude Code has not started yet # at this point, so the newest is the previous run's. # # The /loop prompt is still passed, so the loop is RE-ARMED rather than merely # restored — a resumed conversation with no wake-up scheduled answers once and # stops, which looks like resuming and is not. SYLPH_STAMP="$HOME/.claude/.sylph-last-start" SYLPH_RESUME=0 SYLPH_SESSION="" SYLPH_PROJ="$HOME/.claude/projects/$(printf '%s' "$PWD" | sed 's#/#-#g')" if [ -d "$SYLPH_PROJ" ]; then _newest=$(ls -1t "$SYLPH_PROJ"/*.jsonl 2>/dev/null | head -1) if [ -n "$_newest" ]; then SYLPH_SESSION=$(basename "$_newest" .jsonl) SYLPH_RESUME=1 fi fi # 🔴 A POISONED TRANSCRIPT MUST NOT CRASH-LOOP. If the last start was under # two minutes ago we are already in a restart loop, and continuing back into # whatever killed us is the one thing guaranteed not to help. Start fresh and # say so, rather than burning tokens on the same death repeatedly. if [ "$SYLPH_RESUME" = "1" ] && [ -f "$SYLPH_STAMP" ]; then _last=$(cat "$SYLPH_STAMP" 2>/dev/null || echo 0) _now=$(date +%s) if [ $((_now - _last)) -lt 120 ]; then SYLPH_RESUME=0 log "restarted <120s after the last start — restart loop suspected;" log " starting a FRESH session rather than continuing into the same death" fi fi mkdir -p "$HOME/.claude" 2>/dev/null || true date +%s > "$SYLPH_STAMP" 2>/dev/null || true if [ "$SYLPH_RESUME" = "1" ] && [ "$#" -eq 1 ]; then set -- "$1 ⚠️ YOU WERE RESTARTED, and this session was resumed — your context is intact, but the process that was running when it died is gone. Before anything else: 1. \`git -C /work status\`. Whatever you had in progress is still in the tree, UNCOMMITTED. Commit it and \`push-work\` before starting anything new. 2. Any build, test or capture you had running did NOT finish. Do not read its absence as a result. 3. The likeliest cause is an OOM kill — this container is capped at 6 GB. \`CARGO_BUILD_JOBS\` is now set for you in the environment; do not raise it, and prefer \`build-reborn test\` over a raw \`cargo test --release\`, which bypasses the wrapper's job cap. That is exactly what killed the run on 2026-09-01." log "resuming session ${SYLPH_SESSION%%-*}… with a restart notice" fi [ "$SYLPH_RESUME" = "1" ] && set -- --resume "$SYLPH_SESSION" "$@" # The flag the user asked for. It is refused under root, which is why this # image runs as `agent`. # Remote Control registers the session with your account so you can chat with # the agent from claude.ai or your phone — the point of a detached run being # that you are not sitting in front of it. The name is passed EXPLICITLY: the # flag's value is optional, so a bare `--remote-control` would swallow the # /loop prompt that follows as the session name. if [ "${SYLPH_REMOTE:-1}" != "0" ]; then set -- --remote-control "${SYLPH_REMOTE_NAME:-sylpheed-agent}" "$@" log "Remote Control enabled as '${SYLPH_REMOTE_NAME:-sylpheed-agent}'" fi # claude-autonomous wraps `claude --dangerously-skip-permissions` in a pty and # answers the one-time first-run gates. The Bypass Permissions disclaimer in # particular has no config key that skips it, so unattended it hangs forever. set -- claude-autonomous "$@" log "starting Claude Code with --dangerously-skip-permissions in $(pwd)" fi exec "$@"