Skip to content

Self-Serve Session Launcher, Findings + Build Report, 2026-06-12

URL: https://mkdocs.justinsforge.com/memory/handoffs/self-serve-session-launcher-findings-2026-06-12/

Worker: session-launcher_Fable5. Brief: self-serve-session-launcher-2026-06-12.

TLDR: built and running. A "+ New Session" button now exists, backed by a small authed API on Console that wraps the existing spawn engine and warm pool. Preview from your phone right now (tailnet): http://100.97.43.104:7365/ui/ (paste the token from forge/.env SESSION_LAUNCHER_TOKEN once; it sticks in localStorage). End-to-end tested: warm claim returned a working URL in 2.0s, cold haiku spawn with a prompt in 4s, kill verified, home-base protected.

A dedicated micro-service, forge-session-launcher, NOT a context-api router. FastAPI, one flat file at scripts/forge_session_launcher_api.py, port 7365, systemd user unit forge-session-launcher.service, own venv at infra/session-launcher/.venv.

Why not context-api, even though it already has FastAPI + bearer auth: - context-api's charter (its README) is explicit: bind 127.0.0.1 + Tailscale, never exposed via Cloudflare Tunnel. The launcher's whole point is eventual exposure on the hub behind Cloudflare Access. Putting a shell-spawning endpoint inside a service whose policy forbids exposure either breaks the policy or the launcher. - Blast-radius isolation: this endpoint boots real shells. Its own unit, token, and log mean a launcher bug can't take down wellness/home context serving, and vice versa. - Single source of truth is preserved where it matters: the service contains zero spawn logic. It subprocess-calls forge_spawn_pool.sh claim and forge_spawn_session.sh <model> <name> [prompt] and parses their documented stdout. Pool, bridge extraction, socket hygiene all stay canonical.

Routing logic (mirrors what /new and /spawn do today): - Opus + no name + no prompt -> forge_spawn_pool.sh claim (~1-2s, pool itself falls back to cold spawn when empty, so the button never dead-ends). - Anything custom (name, prompt, or non-opus model) -> cold forge_spawn_session.sh, prompt delivered via argv (the post-audit safe path). Warm spares cannot take a prompt because argv delivery only works at boot; typing into a live TUI is exactly the audited race, so I refused to add that path.

2. The UX

  • "+" button: big gold full-width button. Tap -> bottom sheet with a 4-way model picker (Opus "instant", Fable 5, Sonnet, Haiku), optional name, optional first-prompt textarea, with an honest hint: instant = warm Opus, anything custom = 30-60s cold boot.
  • Launch -> POST returns a job id, the widget polls every 2s showing "booting... Ns", then renders a green Open link to claude.ai/code/<bridge>. A link, not window.open, so iOS popup blocking never eats it. Ready is only reported once the bridge URL is actually extracted from the spawn debug log (see section 4); a session that boots without a registered bridge shows a distinct "find it on claude.ai/code by name" state instead of a fake URL.
  • Active sessions list: live list from the per-session tmux sockets (/tmp/tmux-1000/spawn-*), liveness via pane_current_command in {claude, node} (pane-text grep is a known false-positive, per the pool's own comments). Each row: alive dot, name, model chip, Open (when a bridge URL is recoverable), Kill (two-tap arm/confirm, no blocking dialogs). Pool spares are hidden from the list (they are inventory, not workers); the header shows a "N warm spares" badge instead. home-base is kill-protected (403).
  • Mobile-first, matches the landing-v2 design tokens (gold/ember on #08080a, Chakra Petch + IBM Plex Mono), thumb-sized targets, safe-area padding.

3. Built this pass vs blocked on Justin

Built and verified: | Piece | Path | |---|---| | API service (spawn/list/kill/jobs/pool, bearer auth) | scripts/forge_session_launcher_api.py | | systemd user unit (enabled, running) | ~/.config/systemd/user/forge-session-launcher.service | | venv + requirements | infra/session-launcher/ | | Widget (drop-in JS/CSS) + standalone page | sites/justinsforge.com/landing-v2/launcher/ | | Integration note for the redesign session | sites/justinsforge.com/landing-v2/launcher/INTEGRATION.md | | Log | forge/logs/session-launcher.log |

Smoke results (real runs, not staged): warm claim ready in 2.0s with working cse_ URL; cold haiku spawn with argv prompt ready in 4s; DELETE killed the session, reaped the rc debug log and pool marker; unauthenticated and wrong-token requests rejected 401/403; /ui/ serves the widget; mobile screenshot at logs/site-screenshots/shot-20260612-071803-mobile.png.

Blocked on Justin (deliberately NOT done, per brief): 1. Public exposure. Production plan: nginx location /api/launcher/ { proxy_pass http://127.0.0.1:7365/api/; } on the hub vhost, with Cloudflare Access in front of justinsforge.com (Security Rule #4). Needs your go-ahead because Access on the apex changes how every hub visit behaves. Alternative if you want the hub to stay Access-free: a separate sessions.justinsforge.com hostname with Access on just that. 2. Hub merge. The widget lands on the redesigned home page when the site-redesign session (or you) drops in the three lines from INTEGRATION.md. I did not edit their index.html; it was being written mid-flight. 3. Optional hardening once exposed: validate the Cf-Access-Jwt-Assertion header in the service so the bearer token becomes a second factor instead of the only gate.

4. Coordination + audit-bug avoidance

  • Hub redesign: all my files are additive under landing-v2/launcher/; zero edits to the redesign's index.html. The CSS reads the hub's :root tokens (--gold, --card, --font-ui, ...) with fallbacks, so it inherits their theme automatically and still renders standalone. INTEGRATION.md in the same folder tells the redesign session exactly how to place it and which API base to set.
  • Spawn-audit bugs: inherited none. No keystroke injection anywhere (prompts go argv-only; warm spares simply refuse prompts). Bridge URLs come from the same per-session debug log (/tmp/spawn-rc-<name>.log, regex v1/code/sessions/(cse|session)_...) the audited fix standardized on, never from the dead ~/.claude/sessions/*.json path. "Ready" is gated on actual bridge registration, with an explicit degraded state when it lags.
  • One discovered quirk: tmux -L <sock> kill-server leaves the stale socket file behind. Harmless (the list endpoint tolerates it, and forge_tmux_socket_sweep.sh already reaps stale sockets + rc logs), noted so nobody chases it later.
  • Concurrency note: the service is fully async (asyncio.create_subprocess_exec), zero threads, honoring feedback_no_threads_with_subprocess_run.

5. Next steps (priority order) + commands

  1. Try the preview from Venus: http://100.97.43.104:7365/ui/ , token from grep SESSION_LAUNCHER_TOKEN ~/forge/.env.
  2. Decide exposure: CF Access on the hub apex + nginx proxy, or a dedicated sessions.justinsforge.com. Say the word and either is a 15-minute wire-up (/cf-add + one nginx block + Access policy).
  3. Hub merge: site-redesign session includes the widget per INTEGRATION.md.
  4. After exposure: add CF Access JWT validation to the service; consider surfacing /sessions kill --cleanup style dead-socket pruning as a "Clear dead" button.
  5. Nice-to-have: idle-time per session in the list (tmux session_activity is already fetched, just not displayed).

Service control: systemctl --user {status|restart} forge-session-launcher.

Addendum: shipped to chat.justinsforge.com same morning, 2026-06-12

Justin said create it at chat.justinsforge.com. Done and end-to-end verified:

  • Cloudflare Access app created FIRST (app 1383d9f5-9859-484a-be89-64a53122a104, name "chat (session launcher)", 730h session, policy cloned from the apex hub: "Justin Secure Only", [email protected] + [email protected]), then DNS + media-server tunnel ingress via forge_cloudflare_cf.py add chat justinsforge.com http://192.168.86.50:7365. The hostname was never up unauthenticated.
  • Verified through the edge: browser-shaped requests 302 to justinsforge.cloudflareaccess.com; bare clients get 403; a throwaway Access service token + temp policy fetched /ui/ through the tunnel (200, widget HTML), then both were deleted. Only the email policy remains.
  • Incident during the wire-up: the hub redesign session promoted landing-v2/ to live landing/ and deleted landing-v2/, taking my launcher/ UI folder with it (it was uncommitted). The API kept running half-alive because the static mount was gated on a silent is_dir(). Fixes: UI moved OUT of the redesign tree to sites/justinsforge.com/chat/ (its own dir, owned by this service), and the API now raises at boot if the UI dir is missing, so a wipe crash-loops the unit loudly instead of 404ing quietly.
  • Hub tile added: pinned "Sessions" tile in the live landing's SERVICES registry (brain group, i-bolt) linking to chat.justinsforge.com. The old INTEGRATION.md embed path is retired; the tile + dedicated hostname is the integration.
  • Root of the domain redirects to the widget (/ 307 /ui/).
  • First visit per browser: paste the API token once (grep SESSION_LAUNCHER_TOKEN ~/forge/.env), it persists in localStorage. CF Access JWT validation in the service remains the follow-up hardening.

Addendum 2: token paste removed, 2026-06-12

Justin flagged the token field as confusing. Fixed by finishing the hardening item early: the API now validates the Cloudflare-injected Cf-Access-Jwt-Assertion (PyJWT against the team JWKS, audience pinned to the chat app). Logging in through Access IS the auth on chat.justinsforge.com; no token paste, ever. The bearer token survives only as the fallback for direct LAN/tailnet access where there is no edge. Verified end-to-end: /api/sessions returned 200 through the edge with zero Authorization header (temp service token, revoked after). Widget copy also fixed: pool badge shows "pool: …" until the first authed response instead of a misleading 0, and the Opus picker now says "warm, ~1s" with a hint explaining the 2-spare warm pool.

Addendum 3: multi-model warm pool + list UX round, 2026-06-12

Per Justin's feedback: (1) forge_spawn_pool.sh generalized to per-model pools, targets in data/spawn_pool/sizes (opus=2, fable=2, sonnet=2; /new unchanged, claim defaults opus); (2) launcher routes Opus/Fable/Sonnet no-name-no-prompt spawns to claim <model> (fable warm claim tested: 2.0s); (3) sessions list sorted by tmux session_activity desc with an "Xm ago" chip; (4) session names are links when a URL is known, and live sessions without one get a Bridge button (POST /api/sessions/{name}/bridge, rebridge mechanics; pre-debug-log sessions 409); (5) warm-spare badge is per-model and follows the selected model in the picker. RAM note: 6 spares ≈ 2.7GB, Console sits at ~2.4GB available with everything up; tune counts in the sizes file if it pinches.

Addendum 4: Advanced Prompt + cache fix, 2026-06-12

Justin reported the round-3 fixes invisible: root cause was iOS Safari serving stale launcher.js. Fixed permanently: /ui responses now send Cache-Control: no-cache, must-revalidate plus versioned asset URLs. New feature shipped same round: Advanced Prompt checkbox; with it on, a headless Sonnet scout (~/.local/bin/claude -p, the AVX2-safe binary) researches forge memory/system-map/handoffs and rewrites the raw ask into a curated briefing (XML-boundary instruction per Doctrine S11), then the worker cold-boots with the curated prompt via argv. Warm spares cannot take the handoff (prompt delivery is only safe at boot; typing into a live TUI stays banned), and curation takes ~35s anyway, so the cold boot is not the bottleneck. Curator failure degrades to the raw prompt with a visible note. Curations log to logs/session-launcher-curated.log. End-to-end test: 40s raw-ask-to-URL, with the curated briefing correctly recalling the 2026-06-08 qBittorrent incident details from memory.

  • Timestamps fixed for real. tmux session_activity never sees Remote Control traffic, so actively driven sessions looked hours idle. Last-chat is now max(tmux activity, mtime of /tmp/spawn-rc-<name>.log); the rc debug log is written on every exchange. Verified: the session Justin was actively chatting in showed 0s and sorted to the top.
  • Bridge renamed Re-link and shown on every live session. Old claude.ai/code links can be archived/dead server-side (Justin hit this on p0final: chat opens archived, unarchive still doesn't deliver messages, because the local bridge connection is gone). Re-link sends /remote-control to the running session and mints a fresh working link.
  • All-sessions history. Collapsible "All sessions" section at the bottom: every Claude Code transcript on Console (via the existing canonical scripts/forge_session_list.py), grouped into collapsible months, first-prompt snippet + date/time per row. GET /api/history.
  • Resume with full context. Each history row has Resume: boots a fresh session with claude --resume <uuid> so the ENTIRE old conversation rides along natively (no lossy summarizing). Implemented as additive SPAWN_RESUME env support in forge_spawn_session.sh; the launcher passes it through. Tested: May transcript to live URL in 8s. This supersedes transcript-scraping for "use an old chat as context"; the Advanced Prompt curator remains for fresh asks.

Addendum 6: history summaries + row menu + confirm modals, 2026-06-12

  • One-sentence summaries on history rows. Transcripts carry no native summary in claude 2.1.17x, so POST /api/history/summarize batch-generates them with a single Haiku call per 40 chats (excerpt = first real ask + last reply, bounded reads), cached forever in data/launcher_history_summaries.json. Generation is lazy: expanding a month summarizes up to 200 of its chats (5 chained calls max), so quota cost tracks what Justin actually opens. Tested: 6 chats in 13s, "Added Peanuts movies to Plex media library" quality, cache hits instant. Automation transcripts (ask starts with <) skip generation and show their snippet.
  • Row UX: each session row is now [Open if URL] + a ▾ pill that expands Re-link / Kill; both actions go through a centered confirm modal (backdrop blur, explains what the action does) instead of the old two-tap arm.

Addendum 7: descriptions everywhere + automation labeling, 2026-06-12

  • Justin asked what the dozens of "You are extracting persistent memory facts..." chats are: they are scripts/forge_memory_auto_capture.py, the auto-memory system (CLAUDE.md S12), one headless run per ended session. History now recognizes automation transcripts by opening-ask pattern (AUTO_PATTERNS in the API), gives them canned labels ("Auto-memory capture run") with zero LLM spend, dims them, and chips them "auto". Of the newest 200 transcripts, 153 were automation.
  • Live session rows now carry a one-sentence description too: the session URL's cse_ id is joined to its transcript via the structured {"type":"bridge-session","bridgeSessionId":...} line (grep over newest 400 transcripts, cached in data/launcher_bridge_map.json, 10-min miss retry). First attempt grepped the bare cse id and mismatched, because transcripts quote other sessions' URLs in prose; the structured needle fixed it (verified: no duplicate summaries).
  • Newest 200 history summaries pre-generated (188/200 have descriptions); the rest fill lazily on month expand.

Addendum 8: deterministic transcript join + Human/Bot filter, 2026-06-12

  • Descriptions are now guaranteed for everything spawned from here on. The spawn engine generates the session uuid itself (claude --session-id, skipped for resumes) and writes data/spawn_transcripts/<name>, so tmux session -> transcript is deterministic at birth (the boot-window snapshot-diff approach failed: automation transcripts churn constantly, the diff was never unambiguous). Verified end-to-end: spawn -> marker -> description on the live row; kill reaps the marker. Pool drained + refilled so all 6 spares carry markers.
  • Existing sessions resolve opportunistically (marker > bridge-session grep > rc-log/transcript mtime correlation within 3s when unique, persisted once found). Old dead sessions without rc logs stay undescribed; nothing on disk can join them.
  • History filter: Human Created / Bot Created / All chips, default Human Created (153 of the newest 200 transcripts are automation, mostly forge_memory_auto_capture.py).

Addendum 9: 100% coverage via pane capture; rc-keepalive postmortem, 2026-06-12

  • Every listed session now has a description (33/33, no duplicates). New fallback: POST /api/sessions/describe captures each undescribed worker's tmux pane scrollback (last 150 lines) and batch-Haikus one sentence per session, cached by name in data/launcher_pane_summaries.json, auto-triggered by the UI once per load. Works for every session with a live pane, no transcript join required.
  • Postmortem on the wrong descriptions + fake "now" timestamps: the rc debug log updates on Remote Control keepalives, not just chat. That made (a) every bridged session sort as "now" and (b) the mtime-correlation join stamp whatever transcript Justin was actively typing in onto every keepalive-fresh session (six sessions briefly claimed the Peanuts chat). Correlation is deleted; activity now prefers the joined transcript's mtime (changes only on real messages) with tmux activity as fallback; markers were re-poisoned once between wipe and service restart by the auto-refreshing UI hitting the old process, wiped again after the restart, clean since.
  • Join order final: spawn-time --session-id marker > transcript bridge-session grep > pane capture for the rest.

Addendum 10: naming round, 2026-06-12

  • Auto-name at spawn: no name + a prompt = Haiku derives a kebab slug from the raw prompt and it becomes the real session name (test: "Check if the Plex transcoder..." -> plex-gpu-transcode-check_Haiku45). Job shows a naming phase. Resume/warm claims unaffected.
  • Rename + Auto-name in the row ▾ menu: tmux/socket/rc-log names are immutable plumbing, so renames live in a display-name layer (data/launcher_display_names.json, POST /api/sessions/{name}/rename, empty label clears). Auto-name (POST .../autoname) Haiku-titles the session from its pane scrollback. Kill reaps the label. Ctrl/Cmd+Enter submits the launch form (earlier this round); hub pinned asset version bumped in lockstep (single-source widget).

Addendum 11: in-page Quick Chat + stutter eliminated, 2026-06-12

  • Quick Chat shipped (stage 2 of claude.ai independence): header "Chat" button opens an inline panel; each message runs a real headless Claude Code turn (claude -p --session-id/--resume, Sonnet, full forge access, per-chat turn lock at POST /api/chat). Continuity verified (remembered value across resumed turns, ~3-4s per simple turn; tool-heavy turns take longer, UI shows a thinking bubble). Chats are normal transcripts: they appear in history and can be resumed into full sessions. Read drawer made inline (slides under the row, themed scrollbar) earlier this round.
  • Stutter root-caused: repaint signature now ignores activity_epoch (active sessions tick it every exchange, forcing rebuilds), background list polling pauses while the launch form / chat / reader are open, and renders preserve reader scroll + chat draft text across the innerHTML swap. Hub "/" search shortcut no longer steals focus while typing in any input.

[session-launcher_Fable5]