Files
Sylpheed/docker/agent/sylph-agent
MechaCat02 cb6205ccfb docker: turn the agent loose — detached /loop, with the first-run gates handled
`./sylph-agent loose [task]` starts Claude Code detached with
--dangerously-skip-permissions, running /loop on loop-task.md: work the RE
backlog one item at a time, commit to auto/* branches, never push, record
withdrawn results rather than deleting them. `logs`/`attach`/`stop` to watch and
end it. Runs -d WITHOUT --rm so the transcript survives the container exiting —
for an unattended run that is the only record of what happened.

Two things had to be fixed for an agent to survive being left alone.

MEMORY CONTINUITY. The project is now bind-mounted twice: at /work, and at its
own host path. Claude Code derives its per-project state key from the working
directory, so running at /work handed the agent an empty project instead of the
accumulated one. Verified: a loose run now reports MEMORY=yes and reads back the
same branch and backlog as the host.

FOUR INTERACTIVE GATES, each a silent permanent hang with nobody at the keyboard
-- no error, no log line, just a container that looks healthy and does nothing:

  theme picker      hasCompletedOnboarding + lastOnboardingVersion. Re-fires
                    whenever the container's Claude Code is a different version
                    to the host's, which is the normal case.
  folder trust      projects.<path>.hasTrustDialogAccepted
  bypass disclaimer answered in a pty by bin/claude-autonomous. It has no config
                    key by design -- it wants a person to accept once, and the
                    person did so by launching this.
  fullscreen upsell fullscreenUpsellSeenCount. This one fires MID-SESSION, after
                    the pty wrapper has already handed over, so it cannot be
                    answered the same way.

Config key names were read out of the shipped binary's own strings, not guessed.

The pty wrapper matches SINGLE WORDS. Claude Code draws its UI with
absolute-column escapes between words, so the prompt arrives as
`Yes,\x1b[13GI\x1b[15Gaccept` and a multi-word pattern never matches -- failing
in a way indistinguishable from the wrapper not running at all. It stops
matching once the session is live so nothing later is answered by accident.

~/.claude.json is now mounted read-only at a staging path and copied in, so the
container cannot rewrite the host config. Credentials stay shared read-write in
~/.claude, which is what token refresh and memory continuity need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:10:09 +02:00

240 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Host-side launcher for the Sylpheed RE agent container.
#
# Caps the container at HALF the machine's CPUs and memory, computed at run time
# so it stays half on whatever box it lands on.
#
# ./sylph-agent build build (or rebuild) the image
# ./sylph-agent shell interactive shell in the container
# ./sylph-agent agent [prompt] Claude Code, --dangerously-skip-permissions
# ./sylph-agent loose [task] turn it loose: detached, /loop, self-paced
# ./sylph-agent logs [-f] what the loose agent is doing
# ./sylph-agent attach attach to the loose agent's session
# ./sylph-agent run <cmd...> one-shot command
# ./sylph-agent stop stop it
#
# Environment:
# SYLPH_PROJECT host project root (default: three levels up from this file)
# SYLPH_CLAUDE_HOME host dir mounted as the agent's ~/.claude
# (default: $HOME/.claude — shares auth AND memory with you)
# SYLPH_VULKAN=sw force software Vulkan (lavapipe) even if /dev/dri exists
# SYLPH_CPUS / SYLPH_MEM_GB override the computed half
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGE="${SYLPH_IMAGE:-sylpheed-agent:latest}"
NAME="${SYLPH_NAME:-sylpheed-agent}"
PROJECT="${SYLPH_PROJECT:-$(cd "$HERE/../../.." && pwd)}"
# ── Half the box ─────────────────────────────────────────────────────────────
# LC_ALL=C is required, not tidiness: under a locale with a comma decimal
# separator (de_DE and friends) awk prints "6,0" and docker rejects it as
# --cpus with "failed to parse as a rational number".
HOST_CPUS=$(nproc)
HOST_MEM_KB=$(awk '/MemTotal/{print $2}' /proc/meminfo)
CPUS="${SYLPH_CPUS:-$(LC_ALL=C awk -v c="$HOST_CPUS" 'BEGIN{printf "%.1f", c/2}')}"
MEM_GB="${SYLPH_MEM_GB:-$(LC_ALL=C awk -v m="$HOST_MEM_KB" 'BEGIN{printf "%d", m/1048576/2}')}"
[ "$MEM_GB" -lt 2 ] && MEM_GB=2
# /dev/shm holds the emulator's guest memory (gmem.py reads it there). Docker's
# 64 MB default is far too small for a 512 MB console address space, and the
# failure is an obscure mmap error rather than an out-of-space message. tmpfs
# pages count against the memory cap, so take a third of it and no more.
SHM_GB=$(( MEM_GB / 3 )); [ "$SHM_GB" -lt 1 ] && SHM_GB=1
CLAUDE_JSON="${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}"
if [ ! -f "$CLAUDE_JSON" ]; then
echo "==> WARNING: $CLAUDE_JSON does not exist." >&2
echo " Docker would create a DIRECTORY at that path inside the container," >&2
echo " and Claude Code would fail confusingly. Run \`claude\` once on the" >&2
echo " host first, or set SYLPH_CLAUDE_JSON." >&2
fi
usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
docker_args() {
local -n _out=$1
_out=(
--name "$NAME"
--hostname sylph-agent
# ── the cap ──
--cpus "$CPUS"
--memory "${MEM_GB}g"
--memory-swap "${MEM_GB}g" # no swap escape hatch: a swapping build
# thrashes the whole host, which is the
# failure this cap exists to prevent
--pids-limit 4096
# /dev/shm as an EXEC-capable tmpfs, not --shm-size. Docker's default mounts
# it `noexec`, and xenia maps its JIT code cache out of a shm file at a fixed
# address — so with noexec it dies at startup with "Unable to allocate code
# cache generated code storage / Cannot initalize processor", which reads
# like an address-space clash rather than a mount flag.
--tmpfs "/dev/shm:rw,exec,nosuid,nodev,size=${SHM_GB}g"
# Dynamic RE needs to attach to a live process: without SYS_PTRACE, gdb and
# strace are installed but inert ("Could not attach to process"), and the
# container's whole reason for existing is watching the emulator run.
# Docker's default seccomp profile also blocks calls the JIT and the guest
# memory mapper rely on.
--cap-add SYS_PTRACE
--security-opt seccomp=unconfined
--security-opt apparmor=unconfined
# ── project ──
# Mounted TWICE, at the same path the host uses and at /work. The host path
# is what makes Claude Code's memory carry over: its per-project state key is
# derived from the working directory, so running at /work would give the
# agent an empty `-work` project instead of the accumulated
# `-home-fabi-RE-Project-Sylpheed` one. /work stays because the toolkit
# scripts and every doc here refer to it.
-v "$PROJECT:$PROJECT"
-v "$PROJECT:/work"
-e "PROJECT_DIR=/work"
# ── claude ──
# The state dir is shared read-write: credentials live in
# .claude/.credentials.json, so token refresh needs to write, and this is
# also what carries the project memory across.
-v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/re/.claude"
# ~/.claude.json sits BESIDE that directory and holds `hasCompletedOnboarding`.
# Mounted READ-ONLY at a staging path: the entrypoint copies it to
# ~/.claude.json and stamps onboarding as done. Sharing the file directly
# would (a) re-run the first-run theme wizard whenever the container's
# Claude Code version differs from the host's — a silent hang an unattended
# agent never gets past — and (b) let the container rewrite your host config.
-v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/re/.claude.host.json:ro"
# persistent build caches, so a container restart is not a rebuild
-v sylph-agent-cargo:/sylph-home/re/.cargo
-v sylph-agent-target:/sylph-home/re/target-container
-v sylph-agent-canary-build:/sylph-home/re/canary-build
)
# ── Vulkan SDK ──
# xenia's shader step calls `spirv-opt --canonicalize-ids`, which Ubuntu's
# packaged SPIRV-Tools (v2025.1) does not have — the build then dies ~500
# objects in. The LunarG SDK has it. Mounting the host's copy at the same path
# is cheaper than baking a 200 MB SDK into the image AND guarantees the
# container produces byte-identical shaders to the host build.
SDK="${VULKAN_SDK:-}"
if [ -z "$SDK" ]; then
SDK=$(ls -d "$HOME"/vulkan-sdk/*/x86_64 2>/dev/null | sort -V | tail -1 || true)
fi
if [ -n "$SDK" ] && [ -x "$SDK/bin/spirv-opt" ]; then
_out+=(-v "$SDK:$SDK:ro" -e "VULKAN_SDK=$SDK")
else
echo "==> NOTE: no Vulkan SDK found on the host. Building Canary's shaders" >&2
echo " needs spirv-opt with --canonicalize-ids (LunarG SDK); Ubuntu's" >&2
echo " packaged SPIRV-Tools is too old. Running is unaffected." >&2
fi
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
[ -n "${SYLPH_VULKAN:-}" ] && _out+=(-e "SYLPH_VULKAN=$SYLPH_VULKAN")
[ -n "${SYLPH_ISO:-}" ] && _out+=(-e "SYLPH_ISO=$SYLPH_ISO")
# ── GPU ──
# Three distinct cases, and conflating them is how you end up believing you
# have hardware Vulkan while actually running llvmpipe:
#
# NVIDIA needs the NVIDIA Container Toolkit (`--gpus all`). Passing
# /dev/dri alone does NOT work — Mesa cannot drive an NVIDIA card,
# and the proprietary userspace lives outside the image.
# Mesa (AMD/Intel) works with a plain /dev/dri passthrough plus the
# host's render/video GIDs.
# neither software Vulkan (lavapipe): correct, and slow.
if [ "${SYLPH_VULKAN:-auto}" = "sw" ]; then
_out+=(-e SYLPH_VULKAN=sw)
elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
if docker info --format '{{json .Runtimes}}' 2>/dev/null | grep -q nvidia; then
_out+=(--gpus all)
else
echo "==> NOTE: NVIDIA GPU found but the NVIDIA Container Toolkit is not" >&2
echo " installed, so hardware Vulkan is unavailable and the container" >&2
echo " will use lavapipe (software — correct, slow). To enable it:" >&2
echo " sudo apt install nvidia-container-toolkit \\" >&2
echo " && sudo nvidia-ctk runtime configure --runtime=docker \\" >&2
echo " && sudo systemctl restart docker" >&2
_out+=(-e SYLPH_VULKAN=sw)
fi
elif [ -e /dev/dri/renderD128 ]; then
_out+=(--device /dev/dri)
for g in render video; do
gid=$(getent group "$g" | cut -d: -f3 || true)
[ -n "$gid" ] && _out+=(--group-add "$gid")
done
else
_out+=(-e SYLPH_VULKAN=sw)
fi
}
case "${1:-}" in
build)
shift
echo "==> building $IMAGE (uid $(id -u), gid $(id -g))"
exec docker build -t "$IMAGE" \
--build-arg "AGENT_UID=$(id -u)" --build-arg "AGENT_GID=$(id -g)" \
"$@" "$HERE"
;;
loose)
shift
# "On the loose": detached, self-paced, working the RE backlog until stopped.
#
# -d WITHOUT --rm so the transcript survives the container exiting; that is
# the only record of what an unattended run did. -t because /loop keeps the
# session alive and schedules its own wake-ups — a `-p`/print-mode run would
# answer once and exit, killing the loop on its first iteration.
TASK="${1:-}"
if [ -z "$TASK" ]; then
if [ -f "$HERE/loop-task.md" ]; then
TASK="$(cat "$HERE/loop-task.md")"
else
TASK="Work the RE backlog in Syplheed-Reborn/docs/re/BACKLOG.md."
fi
fi
INTERVAL="${SYLPH_LOOP_INTERVAL:-}" # empty = let the model self-pace
declare -a ARGS; docker_args ARGS
ARGS+=(-e SYLPH_AUTONOMOUS=1 -w "$PROJECT")
echo "==> loose | cpus=$CPUS mem=${MEM_GB}g shm=${SHM_GB}g"
echo "==> project: $PROJECT (mounted at its own path, so memory carries over)"
echo "==> pacing: ${INTERVAL:-self-paced}"
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d -i -t "${ARGS[@]}" "$IMAGE" "/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
echo
echo " running detached as '$NAME'."
echo " ./sylph-agent logs -f follow it"
echo " ./sylph-agent attach attach to the session (Ctrl-P Ctrl-Q to detach)"
echo " ./sylph-agent stop stop it"
echo
echo " It commits to auto/* branches and cannot push — no git credentials"
echo " are mounted, so review its work with: git -C '$PROJECT/Syplheed-Reborn' log --oneline auto/..."
;;
logs) shift; exec docker logs "$@" "$NAME" ;;
attach) exec docker attach "$NAME" ;;
shell|agent|run)
mode=$1; shift
declare -a ARGS; docker_args ARGS
echo "==> $mode | cpus=$CPUS mem=${MEM_GB}g shm=${SHM_GB}g (host: ${HOST_CPUS} cpus, $((HOST_MEM_KB/1048576))g)"
echo "==> project: $PROJECT -> /work"
docker rm -f "$NAME" >/dev/null 2>&1 || true
# Allocate a TTY only when stdin actually is one: `docker run -it` fails
# outright ("cannot attach stdin to a TTY-enabled container") under a
# pipeline or a CI runner, which is exactly where `run` gets used.
TTY=(-i); [ -t 0 ] && TTY=(-it)
case "$mode" in
shell) exec docker run --rm "${TTY[@]}" "${ARGS[@]}" "$IMAGE" bash ;;
agent)
# The flag the user asked for. Refused under root, which is why the
# image runs as an unprivileged `agent` user.
ARGS+=(-e SYLPH_AUTONOMOUS=1)
exec docker run --rm "${TTY[@]}" "${ARGS[@]}" "$IMAGE" "$@"
;;
run) exec docker run --rm "${TTY[@]}" "${ARGS[@]}" "$IMAGE" "$@" ;;
esac
;;
stop) exec docker rm -f "$NAME" ;;
doctor)
declare -a ARGS; docker_args ARGS
exec docker run --rm "${ARGS[@]}" "$IMAGE" sylph-doctor
;;
""|-h|--help) usage 0 ;;
*) echo "unknown command: $1" >&2; usage 2 ;;
esac