#!/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 set child_pid [exp_pid] # 🔴 THIS WRAPPER USED TO SWALLOW BOTH THE SIGNAL AND THE EXIT STATUS, and those # two omissions caused most of this project's multi-hour outages. Found # 2026-09-03 by tracing the signal path, after a tooling review predicted exactly # this from the symptoms. # # The path is: tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude # # `spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect # is the process Docker signals, and everything below it depends on expect # passing things along. It did not. # # 1. NO SIGNAL FORWARDING. `docker stop` sent SIGTERM to expect, which died and # took the pty with it. Claude Code never received a SIGTERM, so it never ran # its `SessionEnd` hooks and never wrote `lastSessionId`/`history` to # `~/.claude.json` -- which are written only at a GRACEFUL shutdown. That is # the whole reason `claude --continue` answered "No conversation found to # continue" with 33 MB of transcripts sitting in the volume beside it, and why # we resume by scraping a session id off a transcript filename instead. # # 2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit # ZERO. So when the kernel OOM-killer took the child, expect saw EOF and # reported a clean exit -- `OOMKilled: true` with `ExitCode 0`, which is not # Docker being odd, it is this line. It also meant `--restart on-failure` # would have treated a memory kill as success, which is why the policy had to # be `unless-stopped`. # # Both are fixed here. Signals are forwarded to the child and its real status is # propagated, so a kill reads as 137, a clean stop lets Claude Code shut down # properly, and the exit code means what it says. proc forward {sig} { global child_pid catch { exec kill -$sig $child_pid } } trap { forward TERM } SIGTERM trap { forward INT } SIGINT trap { forward HUP } SIGHUP # 🔴 THIS BLOCK TYPED INTO A LIVE SESSION, and the single-word patterns were why. # # 2026-09-04: both agents stopped, and the decoder said so 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." # # The patterns were the bare substrings `Choose`, `trust` and `accept`. The # /loop PROMPT is echoed into the terminal, and that day's brief contained # "H3, the plate delay, is ACCEPTED" and "Do not choose what jump means". So # expect matched the agent's own instructions and sent `2\r` and `1\r` into a # running session, which then sat waiting for a human to explain them. # # The original comment argued that a multi-word pattern "never matches" because # the gate text wraps. That is true of a LITERAL multi-word string and false of a # whitespace-tolerant regex, which is what these now are: `\s+` spans the wrap. # The terminal is also 200 columns wide (set above), so these lines rarely wrap # at all. # # Two defences, because one is not enough for something that can type: # 1. patterns specific enough that ordinary prose cannot match them # 2. gates are skipped ENTIRELY when resuming -- a resumed session cannot show # a first-run gate, so there is nothing to answer and everything to lose if {[info exists env(SYLPH_SKIP_GATES)] && $env(SYLPH_SKIP_GATES) ne "0"} { send_user "\[claude-autonomous] resuming: first-run gates cannot appear, not watching for them\n" } else { # Shorter than the old 90 s. The gates appear immediately or not at all, and # every extra second is a second in which this can type into a live session. set timeout 25 expect { -re {Choose\s+the\s+text\s+style} { if {!$answered_theme} { set answered_theme 1; send "\r" } exp_continue } -re {Do\s+you\s+trust\s+the\s+files} { if {!$answered_trust} { set answered_trust 1 send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n" send "1\r" } exp_continue } -re {Yes,\s*I\s+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 gate appeared. Stop matching so nothing later in the run can be # answered by accident -- which is exactly what used to happen. } eof { exit } } } # Hand the terminal over for the rest of the run. interact # Propagate the child's REAL exit status. `interact` returns when the child is # gone; `wait` then yields {pid spawnid os_error status}. Without this the script # simply ran off the end and returned 0 -- see the note at `spawn` above for what # that cost. catch wait result set status 0 if {[info exists result] && [llength $result] >= 4} { # os_error_flag (index 2) is -1 for a normal exit; anything else means the # wait itself failed and the status field is not a status. if {[lindex $result 2] == 0} { set status [lindex $result 3] } } exit $status