monorepo: one repository for the decoders, the port and the corpus
Some checks failed
CI / Native — macos-latest (push) Has been cancelled
CI / Native — windows-latest (push) Has been cancelled
CI / WASM — Web (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Native — ubuntu-latest (push) Has been cancelled

Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.

The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.

Canary stays separate: it is a fork tracking upstream.

New structure for the long term:

  docs/game/     how the game is NAVIGATED -- menus, modals, prompts, alerts,
                 and in-game flight. Written so nobody rediscovers it. Mostly
                 open questions on purpose; the in-game tutorials are the
                 resource for the flight half.
  docs/port/MODDING.md
                 modding as a constraint on the exporter TODAY, not a later
                 feature: one logical asset in one file (the disc splits nearly
                 everything, and resolving that is the exporter's job), names a
                 person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
                 re-exporting is always safe, provenance in every file.
  data/base + data/mods
                 generated tree and drop-in overrides, both gitignored
  exchange/      transient inter-agent files, deliberately outside history
  docs/agents/   the team protocol

Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.

Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
This commit is contained in:
MechaCat02
2026-08-29 11:34:46 +02:00
parent 8c33f86a20
commit 65cefa74c3
45 changed files with 293 additions and 1523 deletions

100
docker/port/Dockerfile Normal file
View File

@@ -0,0 +1,100 @@
# Autonomous port agent for the Sylpheed Godot menu shell.
#
# DELIBERATELY SMALL. The reverse-engineering container next door is 4.36 GB
# because it builds Xenia Canary and drives it under a software Vulkan stack.
# This agent has no emulator, no oracle and no C++ build: it converts already-
# decoded assets and drives Godot. Keeping it light is what lets both containers
# run on one 12-core / 15 GB box without the memory pressure that has crashed it.
#
# What it needs, and nothing else: Rust (the exporter), Godot 4 (the runtime),
# ffmpeg (the transcode), and a headless display to screenshot Godot for
# comparison against the reference renderer.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
TZ=Etc/UTC
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain for the exporter and for building sylpheed-cli from /reborn
build-essential pkg-config git curl ca-certificates \
libssl-dev \
# Godot 4 needs these even headless; the windowed run needs the X libs
libx11-6 libxcursor1 libxinerama1 libxrandr2 libxi6 libgl1 \
libasound2t64 libpulse0 libfontconfig1 \
# the transcode target (libtheora + libvorbis ship in Ubuntu's ffmpeg)
ffmpeg \
# headless display + the screenshot path, for diffing Godot's output
# against `sylpheed-cli screen render`
xvfb x11-utils openbox imagemagick \
# everyday
python3 jq ripgrep unzip file less nano tini sudo procps \
# expect drives Claude Code's one-time interactive gates
expect \
&& rm -rf /var/lib/apt/lists/*
# ── Godot 4 ──────────────────────────────────────────────────────────────────
# Pinned. An engine version bump changes rendering, and this project compares
# screenshots against a reference renderer — so an upgrade must be a deliberate,
# stated act rather than a silent drift.
ARG GODOT_VERSION=4.7.2
RUN cd /tmp \
&& curl -fsSLO "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable/Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \
&& unzip -q "Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \
&& mv "Godot_v${GODOT_VERSION}-stable_linux.x86_64" /usr/local/bin/godot \
&& chmod +x /usr/local/bin/godot \
&& printf '#!/bin/sh\nexec /usr/local/bin/godot --headless "$@"\n' > /usr/local/bin/godot-headless \
&& chmod +x /usr/local/bin/godot-headless \
&& rm -f "Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip"
# ── Node + Claude Code ───────────────────────────────────────────────────────
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm install -g @anthropic-ai/claude-code \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/*
# ── The agent user ───────────────────────────────────────────────────────────
# NOT root: Claude Code refuses --dangerously-skip-permissions with root
# privileges. Ubuntu 24.04 ships its own `ubuntu` account at uid 1000, so the
# common case — matching a host user who is also 1000 — collides with it.
ARG AGENT_UID=1000
ARG AGENT_GID=1000
RUN if getent passwd "${AGENT_UID}" >/dev/null; then \
userdel -r "$(getent passwd "${AGENT_UID}" | cut -d: -f1)" 2>/dev/null || true; \
fi; \
if getent group "${AGENT_GID}" >/dev/null; then \
groupdel "$(getent group "${AGENT_GID}" | cut -d: -f1)" 2>/dev/null || true; \
fi; \
groupadd -g "${AGENT_GID}" agent \
&& useradd -m -u "${AGENT_UID}" -g "${AGENT_GID}" -s /bin/bash -d /sylph-home/port agent \
&& mkdir -p /sylph-home/port /work /reborn \
&& chown -R "${AGENT_UID}:${AGENT_GID}" /sylph-home \
&& echo 'agent ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/agent
COPY bin/ /usr/local/bin/
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/* /usr/local/bin/entrypoint.sh
USER agent
WORKDIR /work
# CARGO_TARGET_DIR points OUTSIDE the bind-mounted repo so the host and the
# container do not invalidate each other's incremental state on every switch.
ENV RUSTUP_HOME=/sylph-home/port/.rustup \
CARGO_HOME=/sylph-home/port/.cargo \
CARGO_TARGET_DIR=/sylph-home/port/target-container \
PATH=/sylph-home/port/.cargo/bin:/usr/local/bin:/usr/bin:/bin
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --profile minimal --component clippy --component rustfmt
RUN mkdir -p /sylph-home/port/target-container /sylph-home/port/.claude
ENV HOME=/sylph-home/port \
DISPLAY=:97 \
SCREEN_GEOMETRY=1280x720x24 \
PROJECT_DIR=/work
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
CMD ["bash"]

23
docker/port/bin/build-export Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Build and run the exporter against the disc.
#
# build-export build only
# build-export --run build, export to ./export, then validate it
#
# The validate step is not optional politeness: `export` writes a tree and
# `check` is the only thing that says the tree is readable by anything other
# than the program that wrote it. A build that exports and does not check has
# not shown anything.
#
# Jobs are capped: this box runs two agent containers and a desktop, and an
# unbounded parallel build has crashed it. Do not raise this to "use all cores".
set -euo pipefail
cd "${PROJECT_DIR:-/work}"
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}"
cargo build --release -p sylpheed-export
if [ "${1:-}" = "--run" ]; then
shift
disc="${SYLPHEED_DISC:?set SYLPHEED_DISC to the extracted disc root}"
"$CARGO_TARGET_DIR/release/sylpheed-export" export --disc "$disc" --out export "$@"
exec "$CARGO_TARGET_DIR/release/sylpheed-export" check --out export
fi

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Build `sylpheed-cli` from the SAME revision of sylpheed-formats the exporter
# is pinned to, and put it on the persistent target volume.
#
# build-reference-cli -> $CARGO_TARGET_DIR/release/sylpheed-cli
#
# Why not just use /reborn/target/release/sylpheed-cli: that binary is built
# from whatever /reborn's working tree is at, which is a LIVE mount of the other
# agent's checkout and moves under you mid-iteration. `sylpheed-cli screen
# render` is the reference the Godot port is diffed against, so if it runs
# different decoders than the exporter, a pixel disagreement has a free variable
# in it and proves nothing about the port.
#
# The pinned source lives in CARGO_HOME, which is on the container overlay and
# does not survive a fresh container -- cargo re-fetches it. The BINARY goes to
# CARGO_TARGET_DIR, which is a volume, so this is a one-off per image.
#
# Jobs are capped for the same reason as build-export.
set -euo pipefail
cd "${PROJECT_DIR:-/work}"
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}"
rev=$(sed -n 's/.*Syplheed-Reborn\.git", rev = "\([0-9a-f]*\)".*/\1/p' \
crates/sylpheed-export/Cargo.toml | head -1)
[ -n "$rev" ] || { echo "build-reference-cli: no rev pin found in Cargo.toml" >&2; exit 1; }
# The checkout only exists once cargo has fetched it; a fresh container has not.
find_checkout() {
find "${CARGO_HOME:?}/git/checkouts" -maxdepth 2 -type d -name "${rev}*" 2>/dev/null | head -1
}
src=$(find_checkout)
if [ -z "$src" ]; then
echo "build-reference-cli: fetching the pinned decoders ($rev)"
cargo fetch
src=$(find_checkout)
fi
[ -n "$src" ] || { echo "build-reference-cli: no checkout for rev $rev" >&2; exit 1; }
# Build into a target directory KEYED BY THE REVISION.
#
# This is not tidiness. Sharing one target dir across pins silently served a
# stale binary: after the pin moved 8b6dbcf -> 5414db3, cargo reported
# "Finished in 0.13s" and left in place a `sylpheed-cli` built from the OLD
# decoders. `screen list` still worked, so the old check passed, and the
# reference renderer this whole project verifies against was a revision behind
# for three consecutive diff runs. A per-rev tree cannot do that: a new pin has
# no artifacts to reuse.
echo "build-reference-cli: building sylpheed-cli from $rev"
tree="$CARGO_TARGET_DIR/reference-cli/$rev"
CARGO_TARGET_DIR="$tree" cargo build --release --manifest-path "$src/Cargo.toml" -p sylpheed-cli
stable="$CARGO_TARGET_DIR/reference-cli/sylpheed-cli"
mkdir -p "$(dirname "$stable")"
cp -f "$tree/release/sylpheed-cli" "$stable"
"$stable" screen list "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" >/dev/null \
|| { echo "build-reference-cli: built, but 'screen list' failed" >&2; exit 1; }
# And check the binary is actually the pinned code, not merely a working one.
# `screen info` prints each element's resting placement, which is decoder
# output; if this disagrees with what the exporter wrote from the same pin, the
# two halves of the verification are not the same revision and every diff below
# is meaningless. Compare rather than assert a value, so this stays true when
# the pin moves again.
if [ -f "${PROJECT_DIR:-/work}/export/screens/title/main_menu.json" ]; then
cli_rest=$("$stable" screen info "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" --build 5 \
| sed -n 's/.*ptframe1\.t32.*rest (\([0-9]*\),\([0-9]*\)).*/\1,\2/p')
exp_rest=$(python3 -c '
import json,sys
d=json.load(open(sys.argv[1]))
e=next(e for e in d["elements"] if e["id"]=="ptframe1")
print("%d,%d" % tuple(e["rest"]["pos"]))' "${PROJECT_DIR:-/work}/export/screens/title/main_menu.json")
if [ "$cli_rest" != "$exp_rest" ]; then
echo "build-reference-cli: STALE OR MISMATCHED BINARY" >&2
echo " the CLI resolves ptframe1 rest to ($cli_rest) but export/ says ($exp_rest)." >&2
echo " Both should come from rev $rev. Delete $tree and rebuild." >&2
exit 1
fi
fi
echo "build-reference-cli: $stable (rev $rev, agrees with export/ on ptframe1)"

View File

@@ -0,0 +1,72 @@
#!/usr/bin/expect -f
# Start Claude Code for an unattended run, answering the first-run gates.
#
# Claude Code has three one-time interactive prompts, and every one of them is a
# silent, permanent hang for an agent with nobody at the keyboard — no error, no
# log line, just a container that looks healthy and does nothing:
#
# 1. the theme picker (first run, or whenever the installed version is
# newer than lastOnboardingVersion)
# 2. "do you trust this folder?" (per workspace)
# 3. the Bypass Permissions disclaimer (for --dangerously-skip-permissions)
#
# `seed-claude-config.py` pre-sets the config keys for 1 and 2. The disclaimer
# has no such key — it is meant to be accepted by a person once — so it is
# answered here instead. That is the honest reading of `sylph-agent loose`: the
# operator accepted it by choosing to run this, and the container is exactly the
# sandbox the warning asks for.
#
# ── Why the patterns are single words ──
# Claude Code draws its UI with ABSOLUTE COLUMN escapes between words, so the
# prompt arrives on the wire as
#
# 2.\x1b[8GYes,\x1b[13GI\x1b[15Gaccept
#
# A multi-word pattern like {Yes, I accept} therefore never matches, and the
# wrapper sits there looking like it is not running at all. Match one word.
set timeout 90
log_user 1
# Give the pty a wide, tall geometry. A detached `docker run -t` defaults to
# 80x24, and Claude Code hard-wraps to the terminal width — which truncates the
# Remote Control URL to "https://claude.ai/code/session_01…" in the one place
# you need to read it, and makes `docker logs` nearly unusable generally.
set stty_init "rows 50 cols 200"
set answered_theme 0
set answered_trust 0
set answered_bypass 0
spawn -noecho claude --dangerously-skip-permissions {*}$argv
expect {
-re {Choose} {
if {!$answered_theme} { set answered_theme 1; send "\r" }
exp_continue
}
-re {trust} {
if {!$answered_trust} {
set answered_trust 1
send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n"
send "1\r"
}
exp_continue
}
-re {accept} {
if {!$answered_bypass} {
set answered_bypass 1
send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n"
send "2\r"
}
exp_continue
}
timeout {
# No new gate for a while: the session is up (or never had one). Stop
# matching so nothing later in the run can be answered by accident.
}
eof { exit }
}
# Hand the terminal over for the rest of the run.
interact

76
docker/port/bin/push-work Executable file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Push the current topic branch to origin — the ONLY sanctioned way out of the
# container.
#
# Why a wrapper instead of plain `git push`:
#
# * **`main` and shared branches are refused.** The agent commits to
# `auto/<topic>`; a human merges. A token that can push anywhere is one
# confused iteration away from rewriting the consolidated line.
# * **Force-push is refused**, always. Nothing here needs it, and history
# rewriting is the one mistake that cannot be undone by merging.
# * It pushes the CURRENT branch only, by name, so a stray `--all` cannot
# publish another agent's worktree branch mid-experiment.
#
# Credentials come from a file mounted read-only at ~/.git-credentials (see
# `sylph-agent`). They are never printed, never logged, and never passed on a
# command line.
#
# push-work push the current branch, and any annotated tags on it
# push-work --dry-run say what it would do
set -euo pipefail
DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "push-work: not inside a git repository" >&2; exit 1; }
cd "$repo_root"
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" = "HEAD" ]; then
echo "push-work: detached HEAD — check out a branch first" >&2; exit 1
fi
case "$branch" in
auto/*) ;;
*)
echo "push-work: refusing to push '$branch'." >&2
echo " Only auto/* topic branches may leave the container; a human merges" >&2
echo " them into main. Move your work: git switch -c auto/<topic>" >&2
exit 1 ;;
esac
if [ ! -s "$HOME/.git-credentials" ]; then
echo "push-work: no credentials mounted at ~/.git-credentials." >&2
echo " The host must start the container with SYLPH_GIT_CREDENTIALS pointing" >&2
echo " at a file containing one line:" >&2
echo " https://<user>:<token>@git.mc02.dev" >&2
exit 1
fi
# Applied to THIS COMMAND ONLY, via `-c`, never `git config --local`.
#
# Writing it to --local config persists it in the repository, and this repo is a
# bind mount the host also uses -- so the host's git inherited
# `store --file=/sylph-home/port/.git-credentials`, a path that exists only
# inside the container, and every host push then failed with
# `unable to get credential storage lock: No such file or directory`.
#
# A tool that configures a shared repository to suit itself breaks every other
# user of that repository. Keep it to the invocation.
CRED_HELPER="store --file=$HOME/.git-credentials"
ahead=$(git rev-list --count "origin/$branch..$branch" 2>/dev/null || git rev-list --count HEAD)
echo "push-work: $branch — $ahead commit(s) to publish"
if [ "$DRY" = 1 ]; then
echo "push-work: --dry-run, stopping here"
exit 0
fi
# --force-with-lease is deliberately NOT offered. If this is rejected as
# non-fast-forward, someone else moved the branch: fetch and merge, do not
# overwrite.
git -c "credential.helper=$CRED_HELPER" push --follow-tags --set-upstream origin "$branch"
echo "push-work: pushed $branch"

13
docker/port/bin/screenshot Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Capture the current display to a PNG.
#
# screenshot out.png
#
# Used to diff Godot's rendering against `sylpheed-cli screen render`. Captures
# the whole 1280x720 root window, which is exactly the design space the screens
# are authored in, so a capture and a composite are directly comparable without
# cropping or scaling.
set -euo pipefail
out="${1:?usage: screenshot OUT.png}"
import -display "${DISPLAY:-:97}" -window root "$out"
identify -format 'captured %wx%h -> %f\n' "$out"

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Mark Claude Code's onboarding as complete in ~/.claude.json.
Claude Code re-runs its first-run wizard whenever `lastOnboardingVersion` does
not match the installed version. In a terminal that is a one-key prompt; for an
unattended agent it is a silent, permanent hang on the theme picker — no error,
no log line, and the container looks like it started fine.
It also pre-accepts the workspace's trust prompt. That is a SECOND, separate
first-run gate: even past onboarding, Claude Code asks "is this a project you
trust?" per directory, and this repo's settings pre-approve 442 tool permissions
so the prompt is emphatic about it. Unattended, it is another silent hang.
Pre-accepting is safe here precisely because the trust question is being
answered by the person who built and launched the container, for their own
repository — it is not a judgement being made on their behalf about someone
else's code.
Usage: seed-claude-config.py <path to .claude.json> <installed version> [workspace...]
"""
import json
import os
import sys
def main() -> int:
if len(sys.argv) < 3:
print(f"usage: {sys.argv[0]} <config.json> <version> [workspace...]",
file=sys.stderr)
return 2
path, version = sys.argv[1], sys.argv[2]
workspaces = sys.argv[3:]
cfg = {}
if os.path.exists(path) and os.path.getsize(path):
try:
with open(path) as f:
cfg = json.load(f)
except (OSError, ValueError):
# A corrupt or partial config is not worth failing the container
# over — start from empty rather than block the run.
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
cfg["hasCompletedOnboarding"] = True
cfg["lastOnboardingVersion"] = version
cfg.setdefault("theme", "dark")
# The THIRD interactive gate: --dangerously-skip-permissions shows a
# "Bypass Permissions mode / you accept all responsibility" confirmation on
# first use. Key name taken from the shipped binary's own strings, not
# guessed. Accepting it here is the whole point of `sylph-agent loose` —
# the container is the sandbox that warning asks you to provide.
cfg["bypassPermissionsModeAccepted"] = True
# A FOURTH gate, and this one fires mid-session rather than at startup, so
# the pty wrapper has already handed over by then: an upsell asking whether
# to try the fullscreen renderer. It is shown while
# `fullscreenUpsellSeenCount` is below an internal threshold, so park it far
# above. Found by reading the shipped binary's strings, same as the others.
cfg["fullscreenUpsellSeenCount"] = 9999
# An auto-update mid-run would restart the process and lose the loop's
# scheduled wake-up, so pin the version the container was built with.
cfg["autoUpdates"] = False
projects = cfg.setdefault("projects", {})
if not isinstance(projects, dict):
projects = cfg["projects"] = {}
for ws in workspaces:
entry = projects.setdefault(ws, {})
if not isinstance(entry, dict):
entry = projects[ws] = {}
entry["hasTrustDialogAccepted"] = True
entry.setdefault("projectOnboardingSeenCount", 1)
entry["hasClaudeMdExternalIncludesApproved"] = True
entry["hasClaudeMdExternalIncludesWarningShown"] = True
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(cfg, f, indent=2)
os.replace(tmp, path)
return 0
if __name__ == "__main__":
sys.exit(main())

72
docker/port/entrypoint.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Bring up the headless display, then hand over.
#
# Xvfb and openbox are started as children of PID 1 (tini), NOT of the agent's
# shell, so they outlive any single command. The RE container learned this the
# hard way: a display owned by a shell gets reaped when that shell exits, which
# reads as "Xvfb dies on its own every few minutes".
set -euo pipefail
: "${DISPLAY:=:97}"
: "${SCREEN_GEOMETRY:=1280x720x24}"
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
Xvfb "$DISPLAY" -screen 0 "$SCREEN_GEOMETRY" -nolisten tcp &
for _ in $(seq 50); do
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break
sleep 0.1
done
openbox >/dev/null 2>&1 &
fi
echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)"
if [ -d /reborn ]; then
echo "[entrypoint] /reborn mounted read-only — HANDOFF.md is the contract"
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 -- no error, 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
# Same reason as .claude.json above: `credential.helper=store` rewrites this
# file by rename-over-target, which fails with EBUSY on a bind mount. Copy it to
# a writable path; nothing is ever written back to the host's file.
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 ──────────────────────────────────────────────────────────────
# Without this the loop prompt is handed to `exec` as a command, and the whole
# markdown file is tried as a filename: exit 126, "File name too long".
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
# Remote Control registers the session with the account so the agent can be
# reached from claude.ai -- the point of a detached run being that nobody is
# sitting in front of it. The name is passed EXPLICITLY: the flag's value is
# optional, so a bare --remote-control swallows the /loop prompt after it.
if [ "${SYLPH_REMOTE:-1}" != "0" ]; then
set -- --remote-control "${SYLPH_REMOTE_NAME:-sylpheed-port}" "$@"
echo "[entrypoint] Remote Control as '${SYLPH_REMOTE_NAME:-sylpheed-port}'"
fi
# claude-autonomous wraps `claude --dangerously-skip-permissions` in a pty and
# answers the one-time first-run gates. The Bypass Permissions disclaimer has
# no config key that skips it, so unattended it hangs forever.
set -- claude-autonomous "$@"
echo "[entrypoint] starting Claude Code in $(pwd)"
fi
exec "$@"

180
docker/port/sylph-port Executable file
View File

@@ -0,0 +1,180 @@
#!/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_REBORN path to the Syplheed-Reborn checkout (read-only mount)
# SYLPH_DISC extracted disc root
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
# 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.
REPO="${SYLPH_PORT_REPO:-$(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}"
REBORN="${SYLPH_REBORN:-$(cd "$REPO/../Syplheed-Reborn" 2>/dev/null && pwd || true)}"
DISC="${SYLPH_DISC:-$(cd "$REPO/../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 "$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 "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/port/.claude"
-v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro"
-e "PROJECT_DIR=/work"
)
# The RE corpus, READ-ONLY. `docs/port/HANDOFF.md` is the contract, and the
# agent also builds sylpheed-cli from here for the reference renderer. Mounted
# ro so a port iteration cannot edit the other agent's repository.
if [ -n "$REBORN" ] && [ -d "$REBORN" ]; then
_out+=(-v "$REBORN:/reborn:ro")
else
echo "==> NOTE: no Syplheed-Reborn checkout found; the agent cannot read" >&2
echo " HANDOFF.md or build the reference renderer. Set SYLPH_REBORN." >&2
fi
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.
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
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
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 "$REPO/docs/loop-task.md" ]; then
TASK="$(cat "$REPO/docs/loop-task.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: $REPO"
echo "==> reborn: ${REBORN:-<none>} (read-only)"
docker run -d -i -t "${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