merge the Godot port's history into the monorepo

Brought in with a subtree merge rather than a copy, so the port's 31 commits
survive as history rather than arriving as one anonymous import. Landed under
godot-import/ and moved into the final layout in the next commit, which keeps
git's rename detection able to follow each file across the move.
This commit is contained in:
MechaCat02
2026-08-29 11:32:01 +02:00
39 changed files with 6363 additions and 0 deletions

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"]

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

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"

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())

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
godot-import/docker/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