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.
156 lines
6.9 KiB
Plaintext
Executable File
156 lines
6.9 KiB
Plaintext
Executable File
#!/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
|