-
Notifications
You must be signed in to change notification settings - Fork 0
Seed initial build prompt for new apps (not existing-app sessions) #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
00ccb9e
07c47ff
6441d76
77aac40
b61633e
9c80f1e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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
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 |
||||||||||||||||||||||||||||||
| exec claude --dangerously-skip-permissions --model "$CLAUDE_MODEL" | ||||||||||||||||||||||||||||||
| ' | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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.
ttydspawns a fresh instance of its command for each client connection, andNEW_APPis computed once at pod start and stays1for 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 → newclaude→ 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.build-pod/CLAUDE.mdalready documents that a page refresh loses the conversation, i.e. starts a newclaudeprocess 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.
The
mvsucceeds exactly once, so connection #2 falls through to the plain interactive session. Quoting safety is preserved: the unquoted<<SEEDEOFheredoc 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 →