Skip to content

Session Launcher API

URL: https://mkdocs.justinsforge.com/memory/general/reference_session_launcher/

Self-serve "+" button for spawning Claude Code Remote Control sessions without going through home-base. Built 2026-06-12; full report at self-serve-session-launcher-findings-2026-06-12.

Piece Where
Production https://chat.justinsforge.com (Cloudflare Access "Justin Secure Only", app id 1383d9f5-9859-484a-be89-64a53122a104, 730h session; media-server tunnel ingress -> http://192.168.86.50:7365; live 2026-06-12, end-to-end verified via temp Access service token, since revoked)
Service scripts/forge_session_launcher_api.py, FastAPI, port 7365, bind 0.0.0.0 (LAN + tailnet)
Unit systemctl --user status forge-session-launcher (user unit, enabled)
Venv infra/session-launcher/.venv
Auth On chat.justinsforge.com: CF Access only; the API validates the edge-injected Cf-Access-Jwt-Assertion (PyJWT vs CF_ACCESS_TEAM_DOMAIN/CF_ACCESS_AUD in forge/.env), no token paste. Bearer SESSION_LAUNCHER_TOKEN remains the fallback for direct LAN/tailnet hits
UI widget sites/justinsforge.com/chat/ (served by the API at / -> /ui/); originally lived in landing-v2/launcher/ but the hub promote wiped that dir, moved out of the redesign tree
Hub tile "Sessions" pinned tile on the landing SERVICES registry -> chat.justinsforge.com
Hub embed (2026-06-12) justinsforge.com chatbar: "+ New Chat" and "Active Chats (n)" expand an inline panel above the pinned row that pushes the page down (animated grid-template-rows 0fr->1fr; Justin rejected modal and slide drawer first). New Chat auto-clicks the widget's [data-act="new"] sheet; Active Chats clicks [data-act="cancel"] for the list; same button toggles closed. Live-session count badge via GET /chat-api/sessions. nginx on the apex (justinsforge-games.conf) proxies /chat-api/ -> 127.0.0.1:7365/api/ and aliases /chat-assets/ -> the chat UI dir, so the widget runs same-origin (no CORS, no token paste). CF_ACCESS_AUD in forge/.env is now comma-separated (chat app + Forge Hub apex app); the API validates the JWT against the list. Widget JS/CSS stay single-source in sites/justinsforge.com/chat/
Preview http://100.97.43.104:7365/ui/ (tailnet, still works)
Log forge/logs/session-launcher.log

Endpoints (all /api/* require the bearer token)

  • GET /healthz: pool counts + jobs in flight, no auth.
  • GET /api/sessions: workers from /tmp/tmux-1000/spawn-* sockets, sorted by session_activity desc (most recently active first); liveness = pane_current_command in {claude,node}; URL from /tmp/spawn-rc-<name>.log bridge regex; pool spares flagged role=pool-spare; includes pool_by_model counts.
  • POST /api/sessions {model, name?, prompt?} -> {job}. Opus/Fable/Sonnet + no name + no prompt = warm forge_spawn_pool.sh claim <model> (~2s); anything custom or Haiku = cold forge_spawn_session.sh with argv prompt (30-60s). Never types a prompt into a live TUI (spawn-audit race).
  • Session-weight fields (2026-07-06): vanilla, no_mcp, readonly_tools, effort, context_1m. See reference_session_prompt_toggles Session weight section. All force a cold spawn; Console-only.
  • GET /api/dev-slots rows include url (tailnet IP + port); the UI shows a "🔗 Open" button next to Teardown when the slot's dev server is listening.
  • target: "vector" (2026-07-01): queues the job for the Vector pull daemon instead of spawning locally; UI checkbox "Start on Vector". Rejects resume/advanced. Full doc: reference_vector_session_launcher.
  • POST /api/sessions/{name}/bridge: re-activate Remote Control on a live session with an unknown/expired bridge and return the fresh URL (same mechanics as pool activate_remote; needs the session's --debug-file log, so pre-2026-06-11 sessions 409).
  • Advanced Prompt ("advanced": true + a prompt): a headless Sonnet scout (~/.local/bin/claude -p, AVX2-safe binary) researches forge context (MEMORY.md, memory/general/, system-map/, handoffs) and rewrites the raw ask into a curated worker briefing, then the session cold-boots with it via argv (curation makes warm handoff moot; prompt delivery is only safe at boot). Job exposes phase: curating|booting + curated_prompt; curator failure falls back to the raw prompt with a note. Curations logged to logs/session-launcher-curated.log. Tested 2026-06-12: 35s curation, real memory recall in the briefing.
  • UI served with Cache-Control: no-cache + versioned asset URLs (stale iOS-cached launcher.js once hid a whole round of fixes).
  • Last-chat time = max(tmux session_activity, rc-debug-log mtime): tmux never sees Remote Control traffic, the debug log updates every exchange.
  • GET /api/history: all transcripts via scripts/forge_session_list.py; POST /api/sessions {resume: <uuid>} boots claude --resume (additive SPAWN_RESUME env in forge_spawn_session.sh) so the new session carries the full old conversation. UI: "All sessions" month-grouped dropdown with Resume buttons; "Re-link" (in each row's ▾ menu, confirm modal) mints a fresh claude.ai/code link when the old one is archived/dead.
  • POST /api/history/summarize {ids}: lazy one-sentence chat summaries, one Haiku batch call per 40 transcripts, cached forever at data/launcher_history_summaries.json; triggered by expanding a month in the UI.
  • GET /api/jobs/{id}: booting | ready (with url) | ready-no-bridge | error.
  • DELETE /api/sessions/{name}: tmux -L spawn-<name> kill-server + reaps rc log + pool markers. home-base is 403-protected.
  • GET /api/pool: raw pool status passthrough.

Rules baked in

  • Zero spawn logic in the service; it only calls the canonical scripts and parses their stdout (session: / url: lines).
  • Fully async subprocesses, no threads (feedback_no_threads_with_subprocess_run).
  • Exposed ONLY behind Cloudflare Access at chat.justinsforge.com (Security Rule #4; justinsforge.com is never public). The Access app was created BEFORE the DNS/ingress so the hostname was never up unauthenticated.
  • Prompt delivery (2026-07-07): the first prompt is delivered via a FILE, not argv. The API writes it to data/session_prompts/<ts>_<name>.txt and passes SPAWN_PROMPT_FILE=<path> to forge_spawn_session.sh, which builds claude ... -- "$(cat <file>)" so the pane reads it at exec time. Reason: the spawn script types the whole boot command into the pane's bash via tmux send-keys, and tmux REJECTS a command line beyond ~16k chars ("command too long"), so a large inline prompt silently crashed the boot. Direct callers of the script (e.g. /spawn) still pass the prompt as argv $3 and keep the inline path. Prompt cap is 50k chars (_prompt_ok). Prompt files auto-prune after 1 day.
  • Attachments (2026-07-07): up to 6 images per launch. Client sends images: [{data, name}] (data = base64 data-URL; legacy single image/image_name still merged in first). Saved to data/session_uploads/, each appended to the prompt as a Read-tool path reference AFTER curation. Console targets only (paths won't resolve on Vector).
  • First-prompt textarea auto-grows to fit content (JS autogrow, capped 420px then scrolls); #fl-prompt has resize:none.
  • Robustness (2026-07-07): the client api() helper now THROWS the server's reason on any non-2xx (was silently resolving 422/400/500 as a phantom success, which left the launcher spinning forever on a job id that never existed). launch() also guards for a missing job id. Client-side length guard mirrors the 50k cap for instant feedback.
  • Quirk: tmux kill-server leaves the stale socket file; harmless, forge_tmux_socket_sweep.sh reaps it.
  • Quirk: "REMOTE CONTROL FAILED, banner never appeared" under heavy concurrent-session load is bridge-registration lag, NOT a boot/prompt failure; the session is usually alive and usable, the API just missed the bridge URL in time.
  • Quirk: the API raises at boot if the UI dir is missing (fail loud); a parallel site promote once wiped the old UI location and the silent is_dir() skip masked it for a service restart.

[session-launcher_Fable5]