scaffold the Godot port, as its own repo with its own agent
The port is deliberately separate from the reverse-engineering project: its own repository, its own clone, its own container. Two writers in one working tree means files change under whoever is mid-edit and a `git add -A` by one sweeps up the other's work -- which happened today in the Reborn tree, so this is set up not to repeat it. The wall: Godot never reads a disc format. An offline Rust exporter converts the user's disc into JSON + PNG + Ogg, and the Godot project reads only that. No GDExtension, no Rust in port/. Beyond the practical reason -- Godot cannot read IPFB, RATC, T8aD, XMA or WMV -- there is the design one: modding is a goal, and if the runtime reads the original formats then modding means reverse engineering, whereas if it reads JSON it means opening a file. The decoders come from sylpheed-formats PINNED BY REVISION (8b6dbcf), not vendored and not reimplemented. `sylpheed_formats::media` in particular already owns every case where one playable thing is not one archive entry: entries that span segment files, banks with several sub-waves, and the cutscene voices, which are one continuous XMA stream chunked into VOICE_*.slb entries whose boundaries do NOT match the cues. That last one is the easiest thing in this project to get subtly wrong, so the mission says outright not to re-derive it. docs/MISSION.md is the objective (P0-P7, each gated by an artifact rather than by compiling). docs/BLOCKED.md lists what cannot proceed until the RE agent answers Q1-Q10, and says plainly that none of it may be guessed -- this agent has no emulator and no oracle, so a value it invents is indistinguishable from a decoded one a month later. The container is deliberately small: 3 cpus / 4 GB against the RE container's 6 / 7, and an image with no C++ toolchain, no Vulkan stack and no emulator. Two full-size containers do not fit on this box beside a desktop. Its launcher sets the git identity through GIT_AUTHOR_*/GIT_COMMITTER_* rather than writing [user] into .git/config -- the config route captures every commit made in that tree, including a human's, which is how six of today's commits ended up attributed to the RE agent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
100
docker/Dockerfile
Normal file
100
docker/Dockerfile
Normal 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.3
|
||||
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"]
|
||||
17
docker/bin/build-export
Executable file
17
docker/bin/build-export
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and run the exporter against the disc.
|
||||
#
|
||||
# build-export build only
|
||||
# build-export --run build, then export to ./export
|
||||
#
|
||||
# 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}"
|
||||
exec "$CARGO_TARGET_DIR/release/sylpheed-export" --disc "$disc" --out export "$@"
|
||||
fi
|
||||
72
docker/bin/claude-autonomous
Executable file
72
docker/bin/claude-autonomous
Executable 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
|
||||
67
docker/bin/push-work
Executable file
67
docker/bin/push-work
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/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
|
||||
# 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
|
||||
|
||||
# `store` reads the file we mounted; nothing is written back (it is read-only).
|
||||
git config --local credential.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 push --set-upstream origin "$branch"
|
||||
echo "push-work: pushed $branch"
|
||||
13
docker/bin/screenshot
Executable file
13
docker/bin/screenshot
Executable 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"
|
||||
85
docker/bin/seed-claude-config.py
Executable file
85
docker/bin/seed-claude-config.py
Executable 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())
|
||||
27
docker/entrypoint.sh
Executable file
27
docker/entrypoint.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/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
|
||||
|
||||
exec "$@"
|
||||
160
docker/sylph-port
Executable file
160
docker/sylph-port
Executable file
@@ -0,0 +1,160 @@
|
||||
#!/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_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)"
|
||||
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"
|
||||
-v "sylpheed-port-claude:/sylph-home/port/.claude"
|
||||
-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"
|
||||
)
|
||||
|
||||
local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
|
||||
if [ -f "$gitcred" ]; then
|
||||
_out+=(-v "$gitcred:/sylph-home/port/.git-credentials: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[@]}" "$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
|
||||
url=$(docker exec "$NAME" sh -c \
|
||||
'grep -ho "https://claude.ai/code/session_[A-Za-z0-9]*" \
|
||||
/sylph-home/port/.claude/**/*.jsonl 2>/dev/null | tail -1' 2>/dev/null || 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
|
||||
Reference in New Issue
Block a user