#!/usr/bin/env bash # Launcher for the Godot port agent. # # ./sylph-port build build the image # ./sylph-port shell interactive shell # ./sylph-port loose [task] detached, self-running on a fixed interval # ./sylph-port logs -f follow it # ./sylph-port attach chat with it (Ctrl-P Ctrl-Q to leave it running) # ./sylph-port remote a link to chat with it from anywhere # ./sylph-port stop stop it # # Env: # SYLPH_PORT_CPUS / SYLPH_PORT_MEM_GB override the cap (default 3 / 4) # SYLPH_PORT_REPO repo to mount at /work (default: this script's parent) # SYLPH_DISC extracted disc root # SYLPH_GIT_CREDENTIALS file with `https://:@host` for push-work # SYLPH_GITEA_TOKEN this agent's own Gitea token file # (default: $HOME/.sylph-gitea-token-port) # SYLPH_LOOP_INTERVAL fixed loop cadence (default 45m) # # ── Two hard-won constraints ──────────────────────────────────────────────── # # 1. THIS REPO IS ITS OWN CLONE. It is deliberately NOT the tree the RE agent # or a human is working in. Sharing a working tree between two writers means # files change under whoever is mid-edit, and a `git add -A` by one sweeps up # the other's work. That happened; do not re-create it. # # 2. IDENTITY GOES IN THE ENVIRONMENT, NOT `.git/config`. Writing `[user]` into # a repo's config captures every commit made in that tree, including a # human's. GIT_AUTHOR_*/GIT_COMMITTER_* apply to this container's commits and # nobody else's. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # The repo to mount at /work. Overridable so this script can be run from a # worktree -- a human editing on `main` must not repoint the agent's checkout. # The checkout is now a volume the container clones into, so this is only used # to locate things that live BESIDE the repository -- the disc, chiefly. Three # levels up from docker/port/ is the workspace root. WORKSPACE="$(cd "$HERE/../../.." && pwd)" IMAGE="${SYLPH_PORT_IMAGE:-sylpheed-port:latest}" NAME="${SYLPH_PORT_NAME:-sylpheed-port}" # Half of what the RE container takes. That container builds a C++ emulator and # drives it; this one converts assets and runs Godot. Two full-size containers # do not fit on a 12-core / 15 GB box beside a desktop -- memory is the binding # constraint, and an over-committed build has crashed this machine before. CPUS="${SYLPH_PORT_CPUS:-3}" MEM_GB="${SYLPH_PORT_MEM_GB:-4}" DISC="${SYLPH_DISC:-$(cd "$WORKSPACE/sylph_extract" 2>/dev/null && pwd || true)}" docker_args() { local _out=( --name "$NAME" --hostname sylph-port --cpus "$CPUS" --memory "${MEM_GB}g" --memory-swap "${MEM_GB}g" # no swap escape hatch: a swapping build # thrashes the whole host --pids-limit 2048 -v "sylpheed-port-repo:/work" -v "sylpheed-port-target:/sylph-home/port/target-container" # CARGO_HOME on a volume, not the container overlay: without it the pinned # decoder source is re-fetched from the network on every fresh container. -v "sylpheed-port-cargo:/sylph-home/port/.cargo" -v "sylpheed-port-claude:/sylph-home/port/.claude" -v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/port/.claude.seed:ro" -v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro" # 🔴 EVIDENCE IS SHARED, NOT COMMITTED (issue #49). Each agent works in its # OWN clone, so a gitignored capture written in one container is invisible to # the other and to the human -- git used to be the transport and no longer is. # One host directory, bind-mounted into both agents, is: all three see the # same files live, every `docs/re/captures/...` citation resolves everywhere, # and nothing can reach git history. Read-write on purpose -- showing each # other a screenshot is the point. -v "${SYLPH_CAPTURES:-$WORKSPACE/Sylpheed/docs/re/captures}:/work/docs/re/captures" -v "sylpheed-exchange:/exchange" -e "PROJECT_DIR=/work" # Same guardrail as the decoder, added the same day and for its reason: the # decoder was OOM-killed mid-task by a RAW `cargo test --release`, which # never reaches `build-export`/`build-reference-cli` and so never saw their # CARGO_BUILD_JOBS. This container is smaller (4 GB, 3 CPUs), so the same # bypass is at least as easy to hit here. -e "CARGO_BUILD_JOBS=${SYLPH_PORT_JOBS:-2}" -e "SYLPH_EXCHANGE=/exchange" -e "SYLPH_AGENT=port" -e "SYLPH_REPO_URL=https://git.mc02.dev/fabi/Sylpheed.git" ) if [ -n "$DISC" ] && [ -d "$DISC" ]; then _out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc") else echo "==> NOTE: no extracted disc found; the exporter has nothing to read." >&2 echo " Set SYLPH_DISC to the directory holding dat/ and hidden/." >&2 fi # Commits are attributed to the port agent, via the environment so that # nothing is written into the repository's config. See constraint 2 above. _out+=( -e "GIT_AUTHOR_NAME=Sylpheed port agent" -e "GIT_AUTHOR_EMAIL=port-agent@localhost" -e "GIT_COMMITTER_NAME=Sylpheed port agent" -e "GIT_COMMITTER_EMAIL=port-agent@localhost" ) # Mounted as `.host` and copied to a writable file by the entrypoint, exactly # like .claude.json. `credential.helper=store` REWRITES its file after a # successful auth -- it writes a temp file and renames over the target, and # renaming onto a bind-mount point gives EBUSY, which surfaces as # `fatal: unable to write credential store: Device or resource busy`. # # The push still succeeds, which is the actual danger: a `fatal:` line that is # routinely wrong teaches the reader to ignore the one that is real. Mounting # rw would also silence it, but then the container can clobber the host's # credential file; copying cannot. # ── Claude auth ── # See the decoder's launcher for the full note. Short version: the OAuth # credential file holds a refresh token that ROTATES ON USE, three clients were # seeded from one copy, and the loser of a rotation race has its tokens CLEARED # to empty strings by Claude Code and parks at "Login expired". A long-lived # `claude setup-token` credential passed in the environment has nothing to # rotate and no file to empty. Same subscription, not API billing. # Inert until the file exists. # Pass through: set SYLPH_OWN_LOGIN=1 when this container has run # `claude auth login` itself, so the entrypoint never copies the host's # rotating credentials over its own grant. Remote Control needs a real # login -- the long-lived token does not carry the sessions scope. [ -n "${SYLPH_OWN_LOGIN:-}" ] && _out+=(-e "SYLPH_OWN_LOGIN=$SYLPH_OWN_LOGIN") local claudetok="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}" if [ -f "$claudetok" ]; then _out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$claudetok")") echo "==> auth: long-lived token from $claudetok (no rotating credential file)" >&2 fi local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}" if [ -f "$gitcred" ]; then _out+=(-v "$gitcred:/sylph-home/port/.git-credentials.host:ro") else echo "==> NOTE: no git credentials at $gitcred — the agent cannot push," >&2 echo " so its work dies with the container." >&2 fi # ── Gitea ── # This agent's OWN token, for its OWN Gitea account — not the push credential # and not the human's. Three reasons it is separate: `~/.sylph-git-credentials` # is scoped `write:repository` and every issue endpoint REFUSES it; a pull # request the agent authored is one a human can approve, which is the entire # review gate; and revoking one agent then touches neither the other nor you. # # Mounted read-only and passed to the MCP server BY PATH — see the entrypoint # for why the value must not go through the environment. # Inert until the file exists: the container still runs, with no issues. local giteatok="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-port}" if [ -f "$giteatok" ]; then _out+=( -v "$giteatok:/sylph-home/port/.sylph-gitea-token:ro" -e "GITEA_TOKEN_FILE=/sylph-home/port/.sylph-gitea-token" ) else echo "==> NOTE: no Gitea token at $giteatok — this agent cannot read its" >&2 echo " notifications, open an issue or open a pull request. Generate one" >&2 echo " while logged in AS sylph-port: Settings -> Applications, scopes" >&2 echo " write:repository, write:issue, write:notification, read:user." >&2 fi [ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") # ── GPU ── # This block did not exist until 2026-09-01, and its absence was reported as # a symptom rather than a cause: **the port agent reported low FPS.** Godot 4 # renders through Vulkan, and with nothing passed through it was falling back # to lavapipe — software Vulkan, which is correct and slow. The decoder's # launcher had this logic; this one never did, so the container that actually # runs a renderer was the one without a GPU. # # Three distinct cases, and conflating them is how you end up believing you # have hardware Vulkan while 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 Godot falls back to lavapipe (software — correct," >&2 echo " slow, and the reason for any low-FPS report). To enable it:" >&2 echo " curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \\" >&2 echo " | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg" >&2 echo " curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \\" >&2 echo " | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \\" >&2 echo " | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list" >&2 echo " sudo apt update && sudo apt install -y 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 printf '%s\n' "${_out[@]}" } mapfile -t ARGS < <(docker_args) case "${1:-}" in build) exec docker build -t "$IMAGE" \ --build-arg "AGENT_UID=$(id -u)" --build-arg "AGENT_GID=$(id -g)" "$HERE" ;; shell) TTY=(-i); [ -t 0 ] && TTY=(-it) exec docker run --rm "${TTY[@]}" "${ARGS[@]}" "$IMAGE" bash ;; loose) shift TASK="${1:-}" if [ -z "$TASK" ]; then if [ -f "$HERE/../../docs/agents/port-loop.md" ]; then TASK="$(cat "$HERE/../../docs/agents/port-loop.md")" else TASK="Work the milestones in docs/MISSION.md." fi fi # A FIXED interval, not self-pacing: the one thing an agent deep in a # milestone reliably forgets is the bookkeeping after it, and a forgotten # wake-up silently ends the loop. INTERVAL="${SYLPH_LOOP_INTERVAL-45m}" echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}" echo "==> repo: own clone in volume sylpheed-port-repo -> /work" # `unless-stopped`, NOT `on-failure`: an OOM kill on this setup reports # `OOMKilled: true` with **ExitCode 0**, so `on-failure` would read a memory # kill as a clean finish and leave the agent down. Restarting into the same # death is handled in the entrypoint, which refuses to `--continue` when the # last start was under two minutes ago. docker run -d -i -t --restart unless-stopped "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \ "/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null echo echo " running detached as '$NAME'." echo " ./sylph-port remote link to chat with it from anywhere" echo " ./sylph-port logs -f follow it" echo " ./sylph-port attach chat with it locally" echo " ./sylph-port stop stop it" ;; logs) shift; exec docker logs "$@" "$NAME" ;; attach) exec docker attach "$NAME" ;; stop) exec docker rm -f "$NAME" ;; remote) echo "waiting for the session to register" >&2 for _ in $(seq 60); do # Read the container LOG, not the session transcript. The transcript # records every command run inside the container -- including this # lookup -- so grepping it matched our own pattern string back. url=$(docker logs "$NAME" 2>&1 \ | grep -aoE 'https://claude\.ai/code/session_[A-Za-z0-9]+' \ | tail -1 || true) [ -n "$url" ] && { echo "$url"; exit 0; } sleep 2 done echo "no session link yet — try ./sylph-port logs -f" >&2 exit 1 ;; *) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//' ;; esac