Remote Control, so a detached run is not a one-way trip: ./sylph-agent remote -> https://claude.ai/code/session_... ./sylph-agent attach -> the container's own terminal `loose` now starts Claude Code with `--remote-control <name>`, registering the session with your account so you can chat with it from claude.ai or a phone. The name is passed EXPLICITLY because the flag's value is optional — a bare `--remote-control` swallows the /loop prompt that follows as the session name. Off with SYLPH_REMOTE=0, renamed with SYLPH_REMOTE_NAME. The pty is forced to 200x50. A detached `docker run -t` gives 80x24, and Claude Code hard-wraps to the terminal width — which truncated the Remote Control URL to ".../session_01..." in the one place you actually need to read it, and made `docker logs` nearly unusable besides. `remote` waits up to six minutes and reports progress: registration lands a minute or two after launch, so answering "not found" immediately would be answering a different question than the one being asked. Verified: a loose run registers and `./sylph-agent remote` prints the full URL. `attach` is NOT verified end to end from here — `docker attach` refuses a piped stdin ("cannot attach stdin to a TTY-enabled container"), which is a property of this harness rather than of the container, so it needs a real terminal to try. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
252 lines
12 KiB
Markdown
252 lines
12 KiB
Markdown
# The RE agent container
|
||
|
||
A container an autonomous Claude Code agent can be turned loose in: it builds
|
||
and runs **both** halves of the project — Xenia Canary as the behaviour oracle
|
||
and Sylpheed Reborn as the port — and carries the dynamic-RE toolkit that drives
|
||
the emulator, reads its guest memory and photographs its screen.
|
||
|
||
```bash
|
||
./sylph-agent build # build the image
|
||
./sylph-agent doctor # prove it can do the four things it exists for
|
||
./sylph-agent shell # poke around
|
||
./sylph-agent agent # Claude Code, --dangerously-skip-permissions
|
||
./sylph-agent loose # turn it loose: detached, /loop, self-paced
|
||
./sylph-agent remote # link to chat with it from anywhere
|
||
./sylph-agent attach # chat with it locally
|
||
./sylph-agent logs -f # watch it
|
||
./sylph-agent stop # stop it
|
||
```
|
||
|
||
## On the loose
|
||
|
||
`./sylph-agent loose` starts Claude Code **detached**, with
|
||
`--dangerously-skip-permissions`, running `/loop` on the task in
|
||
[`loop-task.md`](loop-task.md) — work the RE backlog one item at a time, commit
|
||
to `auto/*` branches, never push, record withdrawn results rather than deleting
|
||
them. Pass your own task as an argument, or set `SYLPH_LOOP_INTERVAL=30m` for a
|
||
fixed cadence instead of letting it self-pace.
|
||
|
||
It runs `-d` **without** `--rm`, so the transcript survives the container
|
||
exiting — for an unattended run that is the only record of what happened.
|
||
|
||
### Talking to it
|
||
|
||
Two channels, both live while it works:
|
||
|
||
* **`./sylph-agent remote`** prints a `https://claude.ai/code/session_…` link.
|
||
`loose` starts Claude Code with `--remote-control`, so the session registers
|
||
with your account and you can chat with it from claude.ai or your phone —
|
||
which is the point of a detached run. Disable with `SYLPH_REMOTE=0`, rename
|
||
with `SYLPH_REMOTE_NAME`.
|
||
|
||
Registration takes a minute or two after launch, so `remote` waits for it
|
||
rather than reporting "not found" to what is really "not yet".
|
||
|
||
* **`./sylph-agent attach`** joins the container's own terminal. Type to talk to
|
||
it; **Ctrl-P Ctrl-Q** detaches and leaves it running. Do not press Ctrl-C —
|
||
that goes to the agent.
|
||
|
||
The pty is forced to 200×50 (`stty_init` in `bin/claude-autonomous`). A detached
|
||
`docker run -t` is 80×24, and Claude Code hard-wraps to the terminal width,
|
||
which truncated the Remote Control URL to `…/session_01…` in the one place you
|
||
need to read it — and made `docker logs` almost unreadable besides.
|
||
|
||
**It cannot push.** No git credentials are mounted, deliberately: a human
|
||
reviews before anything leaves the box. Review with
|
||
`git -C <project>/Syplheed-Reborn log --oneline main..auto/<topic>`.
|
||
|
||
### The four gates
|
||
|
||
Claude Code has four one-time prompts, and each one 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. All four are handled:
|
||
|
||
| gate | how |
|
||
|---|---|
|
||
| theme picker | `hasCompletedOnboarding` + `lastOnboardingVersion` in `~/.claude.json` |
|
||
| "do you trust this folder?" | `projects.<path>.hasTrustDialogAccepted` |
|
||
| Bypass Permissions disclaimer | answered in a pty by [`bin/claude-autonomous`](bin/claude-autonomous) — it has no config key, by design |
|
||
| fullscreen-renderer upsell | `fullscreenUpsellSeenCount`, because it fires *mid-session*, after the pty wrapper has handed over |
|
||
|
||
Config keys were read out of the shipped binary's own strings rather than
|
||
guessed. The pty wrapper matches **single words**: Claude Code draws its UI with
|
||
absolute-column escapes between words, so `Yes, I accept` arrives as
|
||
`Yes,\x1b[13GI\x1b[15Gaccept` and a multi-word pattern never matches — which
|
||
looks exactly like the wrapper not running at all. It stops matching once the
|
||
session is live, so nothing later can be answered by accident.
|
||
|
||
## The resource cap
|
||
|
||
The container gets **half the machine**, computed at launch so it stays half on
|
||
any box:
|
||
|
||
| | how |
|
||
|---|---|
|
||
| CPU | `--cpus $(nproc)/2` |
|
||
| memory | `--memory` = half `MemTotal`, **`--memory-swap` equal to it** |
|
||
| `/dev/shm` | a third of the memory cap, min 1 GiB, mounted **exec** |
|
||
| build jobs | derived *inside* the container from **available memory**, not cores |
|
||
|
||
Two of those deserve a word.
|
||
|
||
**No swap headroom.** `--memory-swap` is set equal to `--memory`, so the
|
||
container cannot swap. That is deliberate: a swapping build thrashes the whole
|
||
host, which is precisely the failure the cap exists to prevent. A build that
|
||
would have swapped gets OOM-killed inside the container instead, and the host
|
||
stays usable.
|
||
|
||
**`/dev/shm` is not incidental.** Xenia backs the guest address space with
|
||
`/dev/shm/xenia_memory_*`, and the whole live-memory toolkit (`gmem.py`,
|
||
`gpoke.py`, `mission_state.py`) reads it from there. Docker's default is 64 MiB,
|
||
which is far too small for a 512 MiB console — and it fails as an obscure mmap
|
||
error rather than an out-of-space message.
|
||
|
||
**Build parallelism is memory-bound.** A full-parallel build of this tree has
|
||
OOM-killed the host outright, so the entrypoint computes jobs from *available
|
||
memory* (≈1.5 GiB per C++ TU) and exports it as `SYLPH_JOBS`, `CARGO_BUILD_JOBS`
|
||
and `CMAKE_BUILD_PARALLEL_LEVEL`. Override with `SYLPH_CPUS` / `SYLPH_MEM_GB`.
|
||
|
||
## Inside
|
||
|
||
| command | what |
|
||
|---|---|
|
||
| `build-canary [Release\|Debug]` | configure + build Canary |
|
||
| `build-reborn [build\|test\|ci]` | build/test Reborn, with the disc env wired up |
|
||
| `run-canary [flags…]` | launch Canary with the settings this title needs |
|
||
| `screenshot [out.png]` | grab the display |
|
||
| `sylph-doctor` | self-check |
|
||
| `tools/re-capture/*` | the RE toolkit, already on `PATH` |
|
||
|
||
Layout: project at `/work`, `HOME=/sylph-home/re`, `DISPLAY=:98` — the values
|
||
`tools/re-capture/*.sh` already assume, so the existing toolkit runs unmodified.
|
||
|
||
Build outputs live **outside** the bind mount (`CARGO_TARGET_DIR`,
|
||
`XENIA_BUILD_DIR`, both named Docker volumes). The host builds the same trees,
|
||
and sharing `target/` or `build/` makes host and container reconfigure and
|
||
relink everything the other just did.
|
||
|
||
## Screenshots
|
||
|
||
Two layers, and the distinction matters:
|
||
|
||
* `/usr/local/bin/screenshot` — raw full-root PNG (ImageMagick, falling back to
|
||
ffmpeg's x11grab, then xwd).
|
||
* `tools/re-capture/bin/screenshot` — **first on `PATH`**, wraps the above and
|
||
crops to the *game surface*.
|
||
|
||
The crop is not cosmetic. Xenia's window is a GTK window whose menu bar pushes
|
||
the 1280×720 game image down ~25 px, and every pixel oracle in the toolkit was
|
||
measured against the bare game image. When that offset was unaccounted for, one
|
||
run sat 300 s in front of a plainly visible MAIN MENU reporting "no main menu".
|
||
The wrapper derives the offset from the window's own height rather than a
|
||
per-display constant.
|
||
|
||
For finding a screen at all, prefer `screen_id.py`, which classifies by
|
||
whole-image statistics instead of named pixels.
|
||
|
||
## Vulkan
|
||
|
||
`mesa-vulkan-drivers` + `vulkan-tools` are installed, so Vulkan works with **no
|
||
host GPU** via lavapipe (software — correct, slow). When the host has
|
||
`/dev/dri`, the launcher passes the device through and adds the host's `render`
|
||
and `video` GIDs, and the entrypoint uses the hardware ICD. Force software with
|
||
`SYLPH_VULKAN=sw`. `vulkaninfo --summary` (or `sylph-doctor`) says which you got — and the
|
||
entrypoint reports the device that **actually enumerated**, not the one it asked
|
||
for, because "I passed `/dev/dri`" and "I have hardware Vulkan" are different
|
||
claims.
|
||
|
||
⚠️ **On an NVIDIA host, `/dev/dri` alone does nothing** — Mesa cannot drive an
|
||
NVIDIA card and the proprietary userspace lives outside the image. You need the
|
||
NVIDIA Container Toolkit; the launcher detects the situation and tells you the
|
||
three commands. Until then Canary runs on lavapipe, which is correct but has not
|
||
been observed to reach a rendered frame in a couple of minutes — everything
|
||
*else* (guest memory, the JIT, the live-memory toolkit) works fine on it.
|
||
|
||
## Input, and why there is no virtual gamepad
|
||
|
||
`run-canary` passes `--hid=file --pad_file=/tmp/xenia_pad.txt`; drive it with
|
||
`tools/re-capture/pad.py`. There is deliberately **no `/dev/uinput`**: input
|
||
devices are not namespaced, so a virtual pad created in a container registers
|
||
with the *host's* input stack and every scripted press leaks onto the user's
|
||
desktop.
|
||
|
||
The trap that wasted a session: 360 menus poll `XamInputGetKeystrokeEx`, not
|
||
`GetState` — with `GetKeystroke` stubbed the pad looks completely dead on a
|
||
title screen while its own log shows the press arriving.
|
||
|
||
## Settings that are requirements, not preferences
|
||
|
||
`run-canary` bakes these in; changing them will cost you an afternoon.
|
||
|
||
* **`--apu=sdl` with `SDL_AUDIODRIVER=dummy`** — and **no `--audio` flag**,
|
||
which is not a cvar here (see below). There is no PulseAudio, so `--apu=nop`
|
||
looks like the safe muted choice. It is not: the log fills with
|
||
`CreateDriver failed for index=0`, the guest never gets past the intro movie,
|
||
and the window stays black for 8+ minutes. SDL against a dummy device is
|
||
silent *and* lets the title advance.
|
||
* **One emulator at a time**, enforced with a lockfile. Two at once perturbs
|
||
both and the box.
|
||
* Stale `/dev/shm/xenia_memory_*` from a killed run is removed at launch —
|
||
otherwise the memory readers find two candidates and pick the dead one.
|
||
|
||
## Claude Code
|
||
|
||
Runs as an unprivileged `agent` user, because `--dangerously-skip-permissions`
|
||
is **refused under root**. `./sylph-agent agent` sets `SYLPH_AUTONOMOUS=1` and
|
||
the entrypoint adds the flag.
|
||
|
||
Auth comes from the host `~/.claude`, bind-mounted read-write (token refresh
|
||
needs to write). **That directory also holds your memory and project state**, so
|
||
the container agent and you share it. Point `SYLPH_CLAUDE_HOME` at a separate
|
||
directory to isolate it, or set `ANTHROPIC_API_KEY` instead.
|
||
|
||
`~/.claude.json` is different: mounted **read-only** at a staging path and
|
||
copied in, so the container cannot rewrite your host config — and so a version
|
||
skew between the container's Claude Code and yours cannot re-trigger onboarding.
|
||
|
||
The project is bind-mounted **twice**, at `/work` and at its own host path. The
|
||
host path is what makes memory carry over: Claude Code derives its per-project
|
||
state key from the working directory, so running at `/work` would hand the agent
|
||
an empty project instead of the accumulated one. Verified — a loose run reports
|
||
`MEMORY=yes` and reads back the same branch and backlog you see.
|
||
|
||
## Host prerequisites
|
||
|
||
* **A Vulkan SDK** (LunarG), for *building* only. Canary's shader step calls
|
||
`spirv-opt --canonicalize-ids`, which Ubuntu's packaged SPIRV-Tools (v2025.1)
|
||
does not have — the build then dies ~500 objects in, and the error you see is
|
||
a Python `TypeError`, not the real message. The launcher mounts the host's SDK
|
||
read-only at its own path and sets `VULKAN_SDK`; that also guarantees the
|
||
container produces byte-identical shaders to a host build.
|
||
* **`nvidia-container-toolkit`**, for hardware Vulkan — see below.
|
||
|
||
## Things that will waste your afternoon
|
||
|
||
Each of these was hit while bringing this container up.
|
||
|
||
* **An unknown xenia flag hangs; it does not error.** `ParseLaunchArguments`
|
||
calls `ShowSimpleMessageBox` *before logging is initialised*, and that SDL
|
||
dialog blocks on `XIfEvent` forever with nobody to click it. The symptom is a
|
||
10×10 window, a completely empty log and no guest memory — which reads like a
|
||
hang deep in the emulator. `--audio` is **not** a cvar in this tree despite
|
||
appearing in the RE notes; `--apu=sdl` is the real one. If Canary appears to
|
||
hang at startup, suspect a typo'd flag first.
|
||
* **`/dev/shm` must be `exec`.** Docker mounts it `noexec`, and xenia maps its
|
||
JIT code cache out of a shm file. With `noexec` it dies at startup with
|
||
"Unable to allocate code cache generated code storage / Cannot initalize
|
||
processor", which reads like an address-space clash. The launcher uses
|
||
`--tmpfs /dev/shm:rw,exec,…` rather than `--shm-size`.
|
||
* **gdb needs root inside the container.** `--cap-add SYS_PTRACE` is passed, but
|
||
the *host's* `kernel.yama.ptrace_scope=1` still blocks attaching to a
|
||
non-descendant. Use `sudo gdb -p <pid>` (passwordless), or launch the target
|
||
under gdb so it is a child.
|
||
* **Named volumes need their mount points to exist in the image**, or Docker
|
||
creates them root-owned and the first write fails obscurely.
|
||
|
||
## Known limitation
|
||
|
||
`build-reborn ci` runs the native legs only. `just ci`'s wasm check does not
|
||
build, for a pre-existing reason unrelated to any change under test: the
|
||
workspace pins `tokio = { features = ["full"] }`, which pulls `mio`, which
|
||
refuses to compile for `wasm32-unknown-unknown`.
|