Skip to content

Commit cc2fae4

Browse files
feat(session): REST→PTY bridge for /session/start (§1.5b)
POST /api/session/start now routes through transport=pty by default, calling active.acquire(config, PTYTransport) and returning a ws_url the frontend feeds to /api/session/ws. The legacy tmux+ttyd path is reachable via transport=ttyd in the body or STUDYLOOP_TRANSPORT=ttyd env override (operator kill-switch). Why: closes the §1.5b gap flagged in Amendment #5 — without this bridge, the new WS route was reachable by tests only. Now the full request path works: picker POSTs /session/start → server acquires active session + PTYTransport → returns ws_url → browser opens WS and gets live PTY stream. Transport dispatch: - Body field ``transport: pty|ttyd`` (optional, default pty). - STUDYLOOP_TRANSPORT env wins over body — operator emergency switch per plan §1.9 without touching clients. - On pty path: no tmux check, no ttyd spawn, no tmux metadata in session_state.json. Writes transport=pty into state for clarity. Binary missing (503) now returns a structured payload: {error, agent, binary, install_hint}. Hints cover all five registered agents (claude, codex, gemini, kiro, opencode) so the UI — or an agent orchestrating installs — has a concrete next step. Per-agent "self-heal and retry" is §1.10 follow-up. Adapter shell wrapping: PTYTransport's build_launch_cmd returns ["/bin/sh", "-c", adapter.launch_cmd(...)]. Adapters legitimately use pipes/&& (their launch_cmd returns shell strings), so we execvpe /bin/sh instead of splitting via shlex. Mirrors what tmux send-keys effectively did. Tests: - +5 in test_web_session_start_pty.py: happy path returns ws_url and bypasses tmux, pty-is-default when unset, 409 when active, 503 with install_hint, env=ttyd overrides body=pty (routes to legacy branch and trips the tmux check). - Existing test_web_session.py: 4 legacy guard tests updated to pass transport=ttyd explicitly — they were testing tmux-based semantics that only apply to that branch now. 1985 passed, 145 deselected (was 1980 → +5 net). Ruff + format clean. Plan: Amendment #5 "§1.5b REST→PTY bridge" follow-up is now closed. Amendment #6 (below) documents the contract for §1.7 frontend. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bce78aa commit cc2fae4

3 files changed

Lines changed: 539 additions & 7 deletions

File tree

packages/studyloop/src/studyloop/web/routes/session.py

Lines changed: 249 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,70 @@ class StartSessionRequest(BaseModel):
339339
topic: str
340340
energy: int = Field(default=5, ge=1, le=10)
341341
agent: str | None = None
342+
transport: str | None = Field(
343+
default=None,
344+
description=(
345+
"Session transport: 'pty' (default, new path) or 'ttyd' (legacy). "
346+
"STUDYLOOP_TRANSPORT env var takes precedence over this field — "
347+
"operators can force the legacy path without touching clients."
348+
),
349+
)
350+
351+
352+
_AGENT_INSTALL_HINTS: dict[str, str] = {
353+
"claude": "Install the Claude Code CLI: https://docs.anthropic.com/en/docs/claude-code",
354+
"codex": "Install codex: npm i -g @openai/codex (or see https://github.com/openai/codex).",
355+
"gemini": ("Install the Gemini CLI: https://github.com/google-gemini/gemini-cli#installation"),
356+
"kiro": "Install Kiro CLI: https://kiro.dev/docs/cli",
357+
"opencode": "Install OpenCode: https://opencode.ai/docs/install",
358+
}
359+
360+
361+
def _resolve_transport(body_transport: str | None) -> str:
362+
"""Decide between 'pty' and 'ttyd'. Env var wins for operator kill-switch."""
363+
import os
364+
365+
env_override = os.environ.get("STUDYLOOP_TRANSPORT", "").strip().lower()
366+
if env_override in {"pty", "ttyd"}:
367+
return env_override
368+
if body_transport in {"pty", "ttyd"}:
369+
return body_transport
370+
return "pty"
371+
372+
373+
def _build_pty_transport(config): # type: ignore[no-untyped-def]
374+
"""Return a zero-arg factory that constructs a ``PTYTransport`` for ``config``.
375+
376+
Split out from ``_start_pty_session`` so tests can monkeypatch the
377+
whole factory without spawning a real PTY child. The production
378+
factory wraps the adapter's shell-string ``launch_cmd`` in
379+
``/bin/sh -c``, since ``os.execvpe`` needs argv and our adapters
380+
return shell strings (with pipes, ``&&``, etc).
381+
"""
382+
import shutil as _shutil
383+
from pathlib import Path
384+
385+
from studyloop.agent_launcher import AGENTS
386+
from studyloop.session.transports.pty import PTYTransport
387+
388+
adapter = AGENTS[config.agent]
389+
390+
def _resolve_binary(_agent_name: str) -> str | None:
391+
# PTYTransport uses this to set the child's argv[0]. We pass argv
392+
# directly via build_launch_cmd, so the resolved binary is just
393+
# the shell — it does NOT need to match the agent binary.
394+
return _shutil.which("sh") or "/bin/sh"
395+
396+
def _build_launch_cmd(_config) -> list[str]: # type: ignore[no-untyped-def]
397+
claude_project_key = str(_config.cwd).replace("/", "-").lstrip("-")
398+
is_resuming = (Path.home() / ".claude" / "projects" / claude_project_key).exists()
399+
shell_cmd = adapter.launch_cmd(Path(_config.persona_file), is_resuming)
400+
return ["/bin/sh", "-c", shell_cmd]
401+
402+
return lambda: PTYTransport(
403+
resolve_binary=_resolve_binary,
404+
build_launch_cmd=_build_launch_cmd,
405+
)
342406

343407

344408
class SessionOption(BaseModel):
@@ -355,9 +419,191 @@ class SessionOption(BaseModel):
355419
def start_session(body: StartSessionRequest) -> JSONResponse:
356420
"""Start a new study session from the web UI.
357421
358-
Creates the DB record, tmux environment, and ttyd process.
359-
The session runs headless — the user interacts via the browser
360-
(SSE activity feed + ttyd terminal iframe).
422+
Two transports are supported:
423+
424+
- ``pty`` (default, plan §1.5b) — spawns the agent directly via
425+
``PTYTransport`` + ``active.acquire`` and returns a ``ws_url``
426+
that the browser feeds to ``/api/session/ws``. No tmux, no ttyd.
427+
- ``ttyd`` (legacy, plan §1.9 fallback) — runs the original
428+
tmux+ttyd flow for one deprecation window. Enable explicitly via
429+
``{"transport": "ttyd"}`` in the body or by exporting
430+
``STUDYLOOP_TRANSPORT=ttyd``.
431+
"""
432+
transport = _resolve_transport(body.transport)
433+
if transport == "pty":
434+
return _start_pty_session(body)
435+
return _start_ttyd_session(body)
436+
437+
438+
def _start_pty_session(body: StartSessionRequest) -> JSONResponse:
439+
"""PTY-backed start path — no tmux, no ttyd.
440+
441+
1. Reject if a session is already active (``active.current()``).
442+
2. Resolve agent + check binary. 503 with ``install_hint`` on miss.
443+
3. Persona + DB + session_state writes (shared with legacy).
444+
4. ``active.acquire(config, factory)`` — atomic under asyncio.Lock.
445+
5. Return 201 with ``ws_url`` for the client to open.
446+
"""
447+
import asyncio as _asyncio
448+
import os
449+
import shutil
450+
451+
from studyloop.agent_launcher import AGENTS, detect_agents
452+
from studyloop.session import active as session_active
453+
from studyloop.session.transport import SessionAlreadyActiveError, SessionConfig
454+
455+
# --- Agent resolution ---
456+
agent = body.agent
457+
if agent and agent not in AGENTS:
458+
return JSONResponse({"error": f"Unknown agent: {agent}"}, status_code=400)
459+
if not agent:
460+
available = detect_agents()
461+
if not available:
462+
return JSONResponse(
463+
{"error": "No AI agent found on this machine"},
464+
status_code=503,
465+
)
466+
agent = available[0]
467+
468+
adapter = AGENTS[agent]
469+
if not shutil.which(adapter.binary):
470+
return JSONResponse(
471+
{
472+
"error": f"Agent '{agent}' binary not found: {adapter.binary}",
473+
"agent": agent,
474+
"binary": adapter.binary,
475+
"install_hint": _AGENT_INSTALL_HINTS.get(
476+
agent,
477+
f"Install the {agent!r} CLI and ensure {adapter.binary!r} is on PATH.",
478+
),
479+
},
480+
status_code=503,
481+
)
482+
483+
# --- Topic resolution (optional) ---
484+
topic_config = None
485+
try:
486+
from studyloop.logic.topic_resolver import resolve_topic
487+
from studyloop.settings import load_settings
488+
489+
settings = load_settings()
490+
if settings.topics:
491+
result = resolve_topic(body.topic, settings.topics)
492+
topic_config = result.resolved or (result.matches[0] if result.matches else None)
493+
except Exception:
494+
pass
495+
496+
# --- DB record ---
497+
from studyloop.history import start_study_session
498+
from studyloop.output import energy_to_label
499+
500+
energy_label = energy_to_label(body.energy)
501+
study_id = start_study_session(
502+
body.topic,
503+
energy_label,
504+
topic_slug=topic_config.slug if topic_config else None,
505+
)
506+
if not study_id:
507+
return JSONResponse(
508+
{"error": "Failed to create session record"},
509+
status_code=500,
510+
)
511+
512+
# --- Session dir + persona (no tmux) ---
513+
slug = body.topic.lower().replace(" ", "-")[:20]
514+
short_id = study_id[:8]
515+
session_dir = SESSION_DIR / "sessions" / f"pty-{slug}-{short_id}"
516+
517+
from studyloop.agent_launcher import build_canonical_persona
518+
from studyloop.session.orchestrator import setup_session_dir
519+
520+
setup_session_dir(session_dir, body.topic)
521+
canonical = build_canonical_persona("focus", body.topic, body.energy)
522+
persona_hash = hashlib.sha256(canonical.encode()).hexdigest()[:16]
523+
524+
from studyloop.history.sessions import update_persona_hash
525+
526+
update_persona_hash(study_id, persona_hash)
527+
528+
persona_file = adapter.setup(canonical, session_dir)
529+
if adapter.mcp_setup:
530+
adapter.mcp_setup(session_dir)
531+
532+
# --- Session state (no tmux metadata) ---
533+
_ensure_session_dir()
534+
now = datetime.now(UTC).isoformat()
535+
write_session_state(
536+
{
537+
"study_session_id": study_id,
538+
"topic": body.topic,
539+
"energy": body.energy,
540+
"energy_label": energy_label,
541+
"mode": "focus",
542+
"timer_mode": "energy",
543+
"started_at": now,
544+
"start_time": now,
545+
"paused_at": None,
546+
"total_paused_seconds": 0,
547+
"persona_file": str(persona_file),
548+
"session_dir": str(session_dir),
549+
"agent": agent,
550+
"persona_hash": persona_hash,
551+
"transport": "pty",
552+
}
553+
)
554+
TOPICS_FILE.touch(mode=0o600, exist_ok=True)
555+
PARKING_FILE.touch(mode=0o600, exist_ok=True)
556+
557+
# --- Acquire the active-session singleton ---
558+
config = SessionConfig(
559+
study_session_id=study_id,
560+
agent=agent,
561+
persona_file=str(persona_file),
562+
cwd=str(session_dir),
563+
env=dict(os.environ),
564+
cols=80,
565+
rows=24,
566+
)
567+
factory = _build_pty_transport(config)
568+
569+
try:
570+
_asyncio.run(session_active.acquire(config, factory))
571+
except SessionAlreadyActiveError:
572+
return JSONResponse(
573+
{"error": "A session is already active"},
574+
status_code=409,
575+
)
576+
except FileNotFoundError as exc:
577+
logger.exception("PTY start failed: binary missing")
578+
return JSONResponse(
579+
{"error": f"Agent binary not found: {exc}"},
580+
status_code=503,
581+
)
582+
except OSError:
583+
logger.exception("PTY start failed: fork/exec error")
584+
return JSONResponse(
585+
{"error": "Failed to start agent PTY"},
586+
status_code=500,
587+
)
588+
589+
return JSONResponse(
590+
{
591+
"study_session_id": study_id,
592+
"topic": body.topic,
593+
"energy": body.energy,
594+
"agent": agent,
595+
"transport": "pty",
596+
"ws_url": f"/api/session/ws?study_session_id={study_id}",
597+
},
598+
status_code=201,
599+
)
600+
601+
602+
def _start_ttyd_session(body: StartSessionRequest) -> JSONResponse:
603+
"""Legacy tmux+ttyd start path (plan §1.9 emergency fallback).
604+
605+
Kept as-is to guarantee a deprecation window. New development should
606+
target the PTY path above.
361607
"""
362608
import os
363609
import shutil

packages/studyloop/tests/test_web_session.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,22 +330,28 @@ class TestStartSessionAPI:
330330
"""
331331

332332
def test_start_rejects_active_session(self, client: TestClient) -> None:
333+
"""Legacy-path 409 when a tmux+ttyd session is already live."""
333334
with (
334335
patch("studyloop.tmux.is_tmux_available", return_value=True),
335336
patch("studyloop.web.routes.session.is_session_active", return_value=True),
336337
):
337338
resp = client.post(
338339
"/api/session/start",
339-
json={"topic": "Python", "energy": 5},
340+
json={"topic": "Python", "energy": 5, "transport": "ttyd"},
340341
)
341342
assert resp.status_code == 409
342343
assert "already active" in resp.json()["error"]
343344

344345
def test_start_rejects_no_tmux(self, client: TestClient) -> None:
346+
"""transport=ttyd requires tmux — 503 when it's unavailable.
347+
348+
The default (pty) path no longer consults tmux, so this assertion
349+
is specific to the legacy ttyd opt-in.
350+
"""
345351
with patch("studyloop.tmux.is_tmux_available", return_value=False):
346352
resp = client.post(
347353
"/api/session/start",
348-
json={"topic": "Python", "energy": 5},
354+
json={"topic": "Python", "energy": 5, "transport": "ttyd"},
349355
)
350356
assert resp.status_code == 503
351357
assert "tmux" in resp.json()["error"]
@@ -357,7 +363,7 @@ def test_start_rejects_unknown_agent(self, client: TestClient) -> None:
357363
):
358364
resp = client.post(
359365
"/api/session/start",
360-
json={"topic": "Python", "energy": 5, "agent": "nonexistent"},
366+
json={"topic": "Python", "energy": 5, "agent": "nonexistent", "transport": "ttyd"},
361367
)
362368
assert resp.status_code == 400
363369
assert "Unknown agent" in resp.json()["error"]
@@ -377,7 +383,7 @@ def test_start_rejects_agent_when_binary_missing(self, client: TestClient) -> No
377383
):
378384
resp = client.post(
379385
"/api/session/start",
380-
json={"topic": "Python", "energy": 5, "agent": "gemini"},
386+
json={"topic": "Python", "energy": 5, "agent": "gemini", "transport": "ttyd"},
381387
)
382388
assert resp.status_code == 503
383389
error = resp.json()["error"]

0 commit comments

Comments
 (0)