Both agents stopped, and the decoder diagnosed it itself:
"I received '2' and '1' but I don't have a pending question those would
answer -- I was in the middle of setting up the /loop cron job."
claude-autonomous matched the BARE SUBSTRINGS 'Choose', 'trust' and 'accept' to
answer Claude Code's one-time first-run gates. The /loop prompt is echoed into
the terminal, and that day's briefs contain 'accepted as-is' and 'least
trustworthy' -- so expect matched the agent's OWN INSTRUCTIONS and typed 2\r and
1\r into a running session, which then sat waiting for a human to explain them.
The old comment argued a multi-word pattern 'never matches' because the gate
text wraps. True of a literal string, false of a whitespace-tolerant regex, which
is what these now are: \s+ spans the wrap, and the terminal is 200 columns wide.
Measured, old against new, against the real brief text and a real gate:
{accept} brief 0 gate 1 (case-sensitive; briefs say 'accepted')
{Yes,\s*I\s+accept} brief 0 gate 1
{trust} brief 1 <- the trigger
{Do\s+you\s+trust\s+the\s+files} brief 0
Two defences, because one is not enough for something that can type: patterns
prose cannot match, and gates skipped ENTIRELY on resume (SYLPH_SKIP_GATES) --
a resumed session cannot show a first-run gate, so there is nothing to answer
and everything to lose. Timeout cut 90s -> 25s for the same reason.
Also: SYLPH_OWN_LOGIN. Remote Control stopped registering under the long-lived
token, and the likely reason is scope -- `claude auth login` requests
user:sessions:claude_code and the token's auth status reports no email, org or
subscription. A per-agent `claude auth login` restores Remote Control AND avoids
the rotation collision, because each agent holds its own grant rather than a copy
of one. The flag stops the entrypoint seeding the host's credentials over it.
214 lines
11 KiB
Bash
Executable File
214 lines
11 KiB
Bash
Executable File
#!/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)"
|
|
|
|
|
|
# ── Claude state: this agent's own, seeded once from the host ───────────────
|
|
# Isolated per agent. Both working directories are /work, and Claude Code keys
|
|
# its per-project state off the working directory -- so a SHARED ~/.claude put
|
|
# two independent agents in the same projects/-work/ directory, which undoes the
|
|
# point of giving them separate checkouts.
|
|
#
|
|
# Seeded rather than shared because credentials live in .credentials.json and a
|
|
# token refresh must be able to write. Copying once means each agent refreshes
|
|
# its own token and neither can corrupt the host's.
|
|
# Re-seed whenever the HOST's credentials are newer than ours, not only when
|
|
# ours are missing. The missing-only guard meant an expired token could never be
|
|
# replaced: the file existed, so the copy was skipped, and restarting the
|
|
# container changed nothing. A human re-logging in on the host is exactly the
|
|
# recovery path, and it has to reach here.
|
|
#
|
|
# Newer-wins rather than always-copy, because the container refreshes its own
|
|
# token during a run and that copy may legitimately be the fresher one.
|
|
# 🔴 A LONG-LIVED TOKEN WINS, AND THE SEEDING MUST NOT FIGHT IT. With
|
|
# CLAUDE_CODE_OAUTH_TOKEN set, copying the host's rotating credential file in
|
|
# would re-create the collision the token exists to remove: three clients on one
|
|
# rotating refresh token, and the loser of a rotation race gets its stored tokens
|
|
# CLEARED to empty strings by Claude Code and parks. Measured 2026-09-04.
|
|
# 🔴 PER-AGENT LOGIN: never seed. Set SYLPH_OWN_LOGIN=1 once this container
|
|
# has run `claude auth login` itself. Its grant is its OWN -- copying the
|
|
# host's over it re-creates the rotation collision that empties credentials
|
|
# and parks the session, which is the whole reason per-agent logins exist.
|
|
if [ -n "${SYLPH_OWN_LOGIN:-}" ] && [ "${SYLPH_OWN_LOGIN}" != "0" ]; then
|
|
echo "[entrypoint] auth: this agent has its own login; not seeding from the host"
|
|
elif [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
|
|
echo "[entrypoint] auth: long-lived token from the environment; not seeding OAuth"
|
|
elif [ -d "$HOME/.claude.seed" ] && \
|
|
{ [ ! -s "$HOME/.claude/.credentials.json" ] || \
|
|
[ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then
|
|
mkdir -p "$HOME/.claude"
|
|
cp -a "$HOME/.claude.seed/.credentials.json" "$HOME/.claude/" 2>/dev/null || true
|
|
for f in settings.json CLAUDE.md; do
|
|
[ -e "$HOME/.claude.seed/$f" ] && cp -a "$HOME/.claude.seed/$f" "$HOME/.claude/" 2>/dev/null || true
|
|
done
|
|
echo "[entrypoint] refreshed ~/.claude credentials from the host"
|
|
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
|
|
|
|
# Nothing is restored into `~/.claude.json` on purpose. Resuming is done by
|
|
# SESSION ID off the transcript instead — see the resume block below for why the
|
|
# index cannot serve.
|
|
# 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
|
|
|
|
# ── The repository, cloned into THIS AGENT'S OWN volume ─────────────────────
|
|
# Not a bind mount of a human's working tree. That arrangement bit this project
|
|
# three times: an agent's `git config --local` captured a human's commits, a
|
|
# credential helper leaked a container-only path onto the host, and a `git add
|
|
# -A` swept an agent's in-flight files into somebody else's commit. Separate
|
|
# checkouts make all three impossible rather than discouraged.
|
|
#
|
|
# Cloned ONCE. Never auto-pulled: pulling under a running agent moves files out
|
|
# from under whatever it is mid-edit, which is the same class of bug again.
|
|
if ! git -C /work rev-parse --verify HEAD >/dev/null 2>&1; then
|
|
# Checks for a usable HEAD, not merely a .git directory. A clone interrupted
|
|
# partway -- the container stopped while it ran, which has happened -- leaves
|
|
# a .git with no commits, and a presence check would then skip the retry
|
|
# forever and hand the agent an empty repository.
|
|
echo "[entrypoint] cloning ${SYLPH_REPO_URL:-https://git.mc02.dev/fabi/Sylpheed.git} into /work"
|
|
_tmp=$(mktemp -d)
|
|
if git clone --quiet "${SYLPH_REPO_URL:-https://git.mc02.dev/fabi/Sylpheed.git}" "$_tmp/r"; then
|
|
find /work -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
|
|
mv "$_tmp/r"/.[!.]* "$_tmp/r"/* /work/ 2>/dev/null || true
|
|
echo "[entrypoint] /work at $(git -C /work rev-parse --short HEAD) on $(git -C /work rev-parse --abbrev-ref HEAD)"
|
|
else
|
|
echo "[entrypoint] clone FAILED -- the agent has no repository" >&2
|
|
fi
|
|
rm -rf "$_tmp"
|
|
else
|
|
echo "[entrypoint] /work at $(git -C /work rev-parse --short HEAD) on $(git -C /work rev-parse --abbrev-ref HEAD)"
|
|
fi
|
|
|
|
# The shared exchange, for transient files that must not enter git history.
|
|
mkdir -p /exchange/files 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
|
|
# ── Resume across a restart ────────────────────────────────────────────────
|
|
#
|
|
# The container restarts automatically now, and a restart that opens a BLANK
|
|
# session throws away everything the agent knew.
|
|
#
|
|
# 🔴 RESUME BY SESSION ID, NOT BY `--continue`. Measured on the decoder
|
|
# 2026-09-01: `--continue` resolves through `~/.claude.json`'s per-project
|
|
# `history` / `lastSessionId`, and those are written at a GRACEFUL SHUTDOWN --
|
|
# mid-session the live file has both as `None`. A container that is OOM-killed
|
|
# or `docker rm -f`ed never writes them, which is exactly the case this exists
|
|
# for, so `--continue` answered "No conversation found to continue" with the
|
|
# transcripts sitting in the volume beside it.
|
|
#
|
|
# The TRANSCRIPTS are durable and named by session id. Claude Code has not
|
|
# started yet here, so the newest is the previous run's.
|
|
#
|
|
# The /loop prompt is still passed so the loop is RE-ARMED rather than merely
|
|
# restored -- a resumed conversation with no wake-up scheduled answers once
|
|
# and stops, which looks like resuming and is not.
|
|
SYLPH_STAMP="$HOME/.claude/.sylph-last-start"
|
|
SYLPH_RESUME=0
|
|
SYLPH_SESSION=""
|
|
SYLPH_PROJ="$HOME/.claude/projects/$(printf '%s' "$PWD" | sed 's#/#-#g')"
|
|
if [ -d "$SYLPH_PROJ" ]; then
|
|
_newest=$(ls -1t "$SYLPH_PROJ"/*.jsonl 2>/dev/null | head -1)
|
|
if [ -n "$_newest" ]; then
|
|
SYLPH_SESSION=$(basename "$_newest" .jsonl)
|
|
SYLPH_RESUME=1
|
|
fi
|
|
fi
|
|
# 🔴 A POISONED TRANSCRIPT MUST NOT CRASH-LOOP. Restarted under two minutes
|
|
# after the last start, we are already looping: continuing back into whatever
|
|
# killed us is the one thing guaranteed not to help.
|
|
if [ "$SYLPH_RESUME" = "1" ] && [ -f "$SYLPH_STAMP" ]; then
|
|
_last=$(cat "$SYLPH_STAMP" 2>/dev/null || echo 0)
|
|
_now=$(date +%s)
|
|
if [ $((_now - _last)) -lt 120 ]; then
|
|
SYLPH_RESUME=0
|
|
echo "[entrypoint] restarted <120s after the last start -- restart loop"
|
|
echo "[entrypoint] suspected; starting FRESH rather than continuing"
|
|
fi
|
|
fi
|
|
mkdir -p "$HOME/.claude" 2>/dev/null || true
|
|
date +%s > "$SYLPH_STAMP" 2>/dev/null || true
|
|
|
|
if [ "$SYLPH_RESUME" = "1" ] && [ "$#" -eq 1 ]; then
|
|
set -- "$1
|
|
|
|
⚠️ YOU WERE RESTARTED, and this session was resumed — your context is intact,
|
|
but the process that was running when it died is gone. Before anything else:
|
|
|
|
1. \`git -C /work status\`. Whatever you had in progress is still in the tree,
|
|
UNCOMMITTED. Commit it and \`push-work\` before starting anything new.
|
|
2. Any build, test, export or Godot run you had going did NOT finish. Do not
|
|
read its absence as a result.
|
|
3. The likeliest cause is an OOM kill — this container is capped at 4 GB.
|
|
\`CARGO_BUILD_JOBS\` is now set for you in the environment; do not raise it,
|
|
and prefer \`build-export\` / \`build-reference-cli\` over a raw
|
|
\`cargo build --release\`, which bypasses the wrapper's job cap. That is what
|
|
killed the decoder's run on 2026-09-01."
|
|
echo "[entrypoint] resuming session ${SYLPH_SESSION%%-*}… with a restart notice"
|
|
fi
|
|
# Tell the gate-answering wrapper to stand down: a resumed session cannot
|
|
# show a first-run gate, and on 2026-09-04 its single-word patterns matched
|
|
# the /loop prompt itself and typed "2" and "1" into a live session.
|
|
[ "$SYLPH_RESUME" = "1" ] && export SYLPH_SKIP_GATES=1
|
|
[ "$SYLPH_RESUME" = "1" ] && set -- --resume "$SYLPH_SESSION" "$@"
|
|
|
|
# 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 "$@"
|