Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion build-pod/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ Check these environment variables for context about what you're building:
- `APP_NAME` — the human-readable app name (if set)
- `APP_DESCRIPTION` — what the app should do (if set)

If `APP_DESCRIPTION` is set, use it as your starting context. The user expects the app to match this description. If the current state of the app doesn't match, proactively offer to fix it.
If `APP_DESCRIPTION` is set, treat it as helpful background about what the app was originally meant to do.

- **Brand-new app:** you'll receive an initial instruction to build it — follow that and get a working first version into the preview right away.
- **Existing app you're resuming:** the description may be the *original* spec, and the app may have intentionally moved on from it. Don't assume any difference is a mistake, and don't start changing things unprompted. Wait for the user to tell you what they want; only offer to align the app with the description if it seems genuinely relevant.

---

Expand Down
97 changes: 84 additions & 13 deletions build-pod/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,20 +70,33 @@ fi
APP_DIR="/repo/${APP_TEAM:-_new}/${APP_SLUG:-_new}"
mkdir -p "$APP_DIR"

# If this is a new app, create a minimal sus.json.
# If this is a new app, create a minimal sus.json. The absence of sus.json is
# also our "brand-new app" signal (an existing app's clone brings its own down),
# and it's restart-safe: a recreated pod re-clones the now-committed scaffold, so
# NEW_APP reads 0 and we don't re-kick a build on restart.
NEW_APP=0
if [ ! -f "$APP_DIR/sus.json" ]; then
cat > "$APP_DIR/sus.json" <<SUSJSON
{
"name": "${APP_NAME:-New App}",
"description": "${APP_DESCRIPTION:-}",
"owner": "${USER_ID:-anonymous}",
"team": "${APP_TEAM:-_new}",
"created_at": "$(date -u +%Y-%m-%d)",
"visibility": ["default"],
"default_stack": "python+htmx",
"tags": []
NEW_APP=1
# Build the manifest with json.dump, not a heredoc: APP_NAME and especially
# APP_DESCRIPTION are free-form user input (a <textarea>), so a quote or
# newline spliced into hand-written JSON yields an invalid sus.json — and
# catalog.py silently drops apps whose manifest won't parse.
APP_DIR="$APP_DIR" python3 - <<'PYEOF'
import datetime, json, os
data = {
"name": os.environ.get("APP_NAME") or "New App",
"description": os.environ.get("APP_DESCRIPTION", ""),
"owner": os.environ.get("USER_ID") or "anonymous",
"team": os.environ.get("APP_TEAM") or "_new",
"created_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d"),
"visibility": ["default"],
"default_stack": "python+htmx",
"tags": [],
}
SUSJSON
with open(os.path.join(os.environ["APP_DIR"], "sus.json"), "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PYEOF
git add -A 2>/dev/null || true
git commit -m "chore: scaffold ${APP_TEAM:-_new}/${APP_SLUG:-_new}" 2>/dev/null || true
fi
Expand Down Expand Up @@ -257,5 +270,63 @@ with open(path, "w") as f:
json.dump(data, f)
PYEOF

# For a brand-new app with a description, hand Claude an initial prompt so it
# starts building to spec on the first turn — otherwise the form description
# only reaches the session as an env var CLAUDE.md *asks* Claude to read, which
# isn't guaranteed. Gated to NEW_APP: an existing-app "Build" session must NOT
# be auto-kicked — its sus.json description may be stale and the user is about
# to say what they want.
#
# One-shot, and this matters: ttyd spawns a fresh `claude` per *client
# connection*, and the frontend reconnects on its own (terminal-iframe reload on
# a not-ready blip, a full page reload after ~15s, a manual refresh, a second
# tab). If every connection were seeded, a reconnect hours into a session would
# re-run "build the initial version" over the user's work — and autosave would
# commit the clobber. So we drop the prompt in a seed file and let the inner
# shell claim it with an atomic `mv` that succeeds exactly once: only the first
# connection is seeded; every later one falls through to a plain interactive
# session. (NEW_APP alone can't gate this — it's fixed for the pod's lifetime.)
#
# APP_DESCRIPTION is untrusted: it's expanded into the seed file by the heredoc
# (bash does not re-scan an expansion's contents) and reaches claude as a single
# "$(cat …)" word — never spliced into the command string, so it cannot break
# quoting or inject shell.
if [ "${NEW_APP:-0}" = "1" ] && [ -n "${APP_DESCRIPTION:-}" ]; then
SUS_SEED_FILE=/tmp/sus-initial-prompt
cat > "$SUS_SEED_FILE" <<SEEDEOF
Build this app now and show it in the preview pane on the right. Here is what it should do:

${APP_DESCRIPTION}

Create the initial working version straight away, then tell me it's ready.
SEEDEOF
export SUS_SEED_FILE
fi
Comment on lines +294 to +304

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The seed isn't one-shot — it re-fires on every terminal reconnect inside the same pod, which can clobber a built app.

ttyd spawns a fresh instance of its command for each client connection, and NEW_APP is computed once at pod start and stays 1 for the pod's whole lifetime. So the restart-safety argument in the PR description holds for pod recreation (fresh entrypoint → scaffold already committed → NEW_APP=0), but not for a client reconnect against a still-live pod.

That's not hypothetical — the frontend reconnects on its own:

  • build.html:305-309 — a not-ready blip clears → _loadTerminal() re-points the iframe at /terminal/?t=… → new ttyd client → new claude → prompt re-fires.
  • build.html:300-302 — 3 consecutive not-ready polls (~15s: ttyd blip, proxy hiccup, pod-IP re-resolution) → window.location.reload() → same thing.
  • Plus a manual refresh or a second tab. build-pod/CLAUDE.md already documents that a page refresh loses the conversation, i.e. starts a new claude process in the same pod.

Pre-PR, a reconnect meant a fresh empty session (annoying, conversation lost, files untouched). Post-PR, that fresh session is handed "Build this app now … Create the initial working version straight away" against an app the user may have been iterating on for an hour — Claude will plausibly rewrite main.py/templates from the original (possibly already-superseded) description. The 5-minute autosave means the overwrite gets committed too.

Fix: move the decision into the inner shell and consume the seed atomically so only the first connection gets it.

Suggested change
if [ "${NEW_APP:-0}" = "1" ] && [ -n "${APP_DESCRIPTION:-}" ]; then
export SUS_INITIAL_PROMPT="Build this app now and show it in the preview pane on the right. Here is what it should do:
${APP_DESCRIPTION}
Create the initial working version straight away, then tell me it's ready."
exec ttyd --port 8080 --writable --base-path / \
bash -c "cd '$APP_DIR' && exec claude --dangerously-skip-permissions --model '${CLAUDE_MODEL:-opus}' \"\$SUS_INITIAL_PROMPT\""
else
exec ttyd --port 8080 --writable --base-path / \
bash -c "cd '$APP_DIR' && exec claude --dangerously-skip-permissions --model '${CLAUDE_MODEL:-opus}'"
fi
if [ "${NEW_APP:-0}" = "1" ] && [ -n "${APP_DESCRIPTION:-}" ]; then
SEED_FILE=/tmp/sus-initial-prompt
cat > "$SEED_FILE" <<SEEDEOF
Build this app now and show it in the preview pane on the right. Here is what it should do:
${APP_DESCRIPTION}
Create the initial working version straight away, then tell me it's ready.
SEEDEOF
export SEED_FILE
fi
export APP_DIR
export CLAUDE_MODEL="${CLAUDE_MODEL:-opus}"
exec ttyd --port 8080 --writable --base-path / \
bash -c 'cd "$APP_DIR" || exit 1
if [ -n "${SEED_FILE:-}" ] && mv "$SEED_FILE" "$SEED_FILE.used" 2>/dev/null; then
exec claude --dangerously-skip-permissions --model "$CLAUDE_MODEL" "$(cat "$SEED_FILE.used")"
fi
exec claude --dangerously-skip-permissions --model "$CLAUDE_MODEL"'

The mv succeeds exactly once, so connection #2 falls through to the plain interactive session. Quoting safety is preserved: the unquoted <<SEEDEOF heredoc expands ${APP_DESCRIPTION} but bash doesn't re-scan the expansion, and the inner "$(cat …)" stays a single word — same guarantee your current \"\$SUS_INITIAL_PROMPT\" gives.

Fix this →


export APP_DIR
export CLAUDE_MODEL="${CLAUDE_MODEL:-opus}"
# ttyd spawns a fresh `claude` per client connection, so consume the seed with
# an atomic `mv` that succeeds exactly once: the first connection is seeded,
# every later one (reconnect, refresh, second tab) falls through to a plain
# interactive session and never re-runs the build over the user's work.
#
# Deliberately NOT recovered: if an early reconnect kills the very first build
# before it writes a file, the replacement session is plain and the user just
# restates the request. Auto-recovering that (re-arming the seed) needs a
# claimant-liveness protocol whose races aren't worth it for a first-turn
# convenience — a plain session is a fine, non-destructive fallback.
#
# Single-quoted body on purpose: $APP_DIR/$SUS_SEED_FILE/$CLAUDE_MODEL and the
# $(cat …) are evaluated by the inner per-connection shell, not baked in once
# by this shell at exec time. The untrusted description lives only in the seed
# file and reaches claude as a single "$(cat …)" argv word — never spliced into
# the command string, so it can't break quoting or inject shell.
# shellcheck disable=SC2016
exec ttyd --port 8080 --writable --base-path / \
bash -c "cd '$APP_DIR' && exec claude --dangerously-skip-permissions --model '${CLAUDE_MODEL:-opus}'"
bash -c '
cd "$APP_DIR" || exit 1
if [ -n "${SUS_SEED_FILE:-}" ] && mv "$SUS_SEED_FILE" "$SUS_SEED_FILE.used" 2>/dev/null; then
exec claude --dangerously-skip-permissions --model "$CLAUDE_MODEL" "$(cat "$SUS_SEED_FILE.used")"
fi
Comment on lines +327 to +330

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The seed is consumed by the first connection attempted, not the first one that survives — an early reconnect burns it and leaves a half-built app with a Claude that's been told to wait.

The mv is the right primitive for the clobber problem, and it fixes it. But the failure window it opens is the one this PR most cares about: the first ~60s of a brand-new app, while the seeded claude is mid-build.

_startHealthWatch (build.html:290-310) starts polling /status 5s after the terminal first loads. A single not-ready poll sets _reconnecting, and the moment it clears → _loadTerminal() → new iframe → new ttyd client. ttyd SIGHUPs the old client's process, so the building claude dies. The replacement connection finds the seed already renamed, falls through to the plain branch — and build-pod/CLAUDE.md now explicitly tells that session "don't start changing things unprompted, wait for the user." So the user lands on an empty prompt next to an app that got 30 seconds of scaffolding, with nothing indicating a build was ever started.

Pre-PR the same blip cost only the (empty) conversation. Post-PR it costs the build itself, silently.

A cheap re-arm keeps the one-shot guarantee where it matters (don't re-run a build over real work) while surviving a blip — only restore the seed if the app dir is still nothing but the scaffold:

Suggested change
cd "$APP_DIR" || exit 1
if [ -n "${SUS_SEED_FILE:-}" ] && mv "$SUS_SEED_FILE" "$SUS_SEED_FILE.used" 2>/dev/null; then
exec claude --dangerously-skip-permissions --model "$CLAUDE_MODEL" "$(cat "$SUS_SEED_FILE.used")"
fi
cd "$APP_DIR" || exit 1
# Re-arm a seed burned by a connection that died before Claude built
# anything: safe precisely because "nothing but sus.json" means there is
# no user work to clobber.
if [ -f "${SUS_SEED_FILE:-}.used" ] && [ -z "$(ls -A . 2>/dev/null | grep -v '^sus\.json$')" ]; then
mv "$SUS_SEED_FILE.used" "$SUS_SEED_FILE" 2>/dev/null || true
fi
if [ -n "${SUS_SEED_FILE:-}" ] && mv "$SUS_SEED_FILE" "$SUS_SEED_FILE.used" 2>/dev/null; then
exec claude --dangerously-skip-permissions --model "$CLAUDE_MODEL" "$(cat "$SUS_SEED_FILE.used")"
fi

Once Claude has written even one file the re-arm stops firing, so the clobber protection you added is unchanged for every case where it matters. Entirely reasonable to skip this if you'd rather keep the tail simple — the current behaviour is still a net improvement over main, just worth knowing the seed isn't guaranteed to land.

Fix this →

exec claude --dangerously-skip-permissions --model "$CLAUDE_MODEL"
'
Loading