Files
Sylpheed/docker/agent/bin/seed-claude-config.py
MechaCat02 cb6205ccfb docker: turn the agent loose — detached /loop, with the first-run gates handled
`./sylph-agent loose [task]` starts Claude Code detached with
--dangerously-skip-permissions, running /loop on 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. `logs`/`attach`/`stop` to watch and
end it. Runs -d WITHOUT --rm so the transcript survives the container exiting —
for an unattended run that is the only record of what happened.

Two things had to be fixed for an agent to survive being left alone.

MEMORY CONTINUITY. The project is now bind-mounted twice: at /work, and at its
own host path. Claude Code derives its per-project state key from the working
directory, so running at /work handed the agent an empty project instead of the
accumulated one. Verified: a loose run now reports MEMORY=yes and reads back the
same branch and backlog as the host.

FOUR INTERACTIVE GATES, each a silent permanent hang with nobody at the keyboard
-- no error, no log line, just a container that looks healthy and does nothing:

  theme picker      hasCompletedOnboarding + lastOnboardingVersion. Re-fires
                    whenever the container's Claude Code is a different version
                    to the host's, which is the normal case.
  folder trust      projects.<path>.hasTrustDialogAccepted
  bypass disclaimer answered in a pty by bin/claude-autonomous. It has no config
                    key by design -- it wants a person to accept once, and the
                    person did so by launching this.
  fullscreen upsell fullscreenUpsellSeenCount. This one fires MID-SESSION, after
                    the pty wrapper has already handed over, so it cannot be
                    answered the same way.

Config key names were read out of the shipped binary's own strings, not guessed.

The pty wrapper matches SINGLE WORDS. Claude Code draws its UI with
absolute-column escapes between words, so the prompt arrives as
`Yes,\x1b[13GI\x1b[15Gaccept` and a multi-word pattern never matches -- failing
in a way indistinguishable from the wrapper not running at all. It stops
matching once the session is live so nothing later is answered by accident.

~/.claude.json is now mounted read-only at a staging path and copied in, so the
container cannot rewrite the host config. Credentials stay shared read-write in
~/.claude, which is what token refresh and memory continuity need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:10:09 +02:00

86 lines
3.5 KiB
Python
Executable File

#!/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())