From dc932cc8ea12d53cd1471d08b24a79915404e28b Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:17:34 +0000 Subject: [PATCH 1/2] Initial plan From 864f071947140c6a49837fc7022249af55e11b3b Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:30:43 +0000 Subject: [PATCH 2/2] feat: continue sessions from web UI Co-authored-by: Panacota96 <118935424+Panacota96@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 5 +- src/guild_scroll/session.py | 38 ++++++++-- src/guild_scroll/web/app.py | 121 +++++++++++++++++++++++++++---- src/guild_scroll/web/terminal.py | 20 ++++- tests/test_server.py | 28 +++++++ 6 files changed, 190 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8734bb4..b3cb2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and ### Added - **Web terminal capture** — WebSocket `/ws/session/{name}/terminal` now streams browser keystrokes to the PTY and logs each newline-delimited command as a `CommandEvent` (with cwd, seq, and part) into `session.jsonl`. Live terminal UI added to session pages with REST fallbacks for start/read/write/stop. +- **Continue session (web)** — `POST /api/session/{name}/continue` now starts a joined part with active-terminal guard, returns `{session, part, status}`, updates `parts_count`, and is exposed via a Continue button on session pages. --- diff --git a/README.md b/README.md index e24e12c..a66ad7c 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ gscroll export --format md # structured report, ready to share | **Validation** | `gscroll validate [SESSION] --repair` checks JSONL/assets/parts and patches repairable metadata | | **Replay** | `gscroll replay` via `scriptreplay` with speed control | | **TUI** | Interactive Textual dashboard — session sidebar, phase timeline, command table | -| **Web preview** | `gscroll serve` hosts an HTML viewer + JSON API with full session CRUD (`POST /api/sessions`, `DELETE`, `continue`, `validate`) | +| **Web preview** | `gscroll serve` hosts an HTML viewer + JSON API with session CRUD (create, delete, continue with part tracking, validate) | | **Session auto-detect** | All sub-commands pick up `GUILD_SCROLL_SESSION` automatically | | **Self-update** | `gscroll update` checks GitHub and reinstalls | @@ -366,7 +366,7 @@ Set `GUILD_SCROLL_ALLOW_REMOTE=1` to allow the report server to bind to non-loca | `GET` | `/api/session/{name}` | Fetch session detail (commands, notes, assets) | | `POST` | `/api/sessions` | Create a session scaffold (`{"name": "..."}`) → 201/409/422 | | `DELETE` | `/api/session/{name}` | Delete a session directory → 204/404/400 | -| `POST` | `/api/session/{name}/continue` | Scaffold a new session part → `{"part": N}` | +| `POST` | `/api/session/{name}/continue` | Start a joined session part → `{"session": "...", "part": N, "status": "active"}` (404 if missing, 409 if already active) | | `POST` | `/api/session/{name}/validate` | Validate (and optionally repair with `?repair=true`) → `{valid, errors, warnings, repaired}` | | `POST` | `/api/session/{name}/report` | Render a filtered export (body: `{"format": "md\|html", ...}`) | | `GET` | `/api/session/{name}/download` | Download session export (`?format=md\|html`) | @@ -377,6 +377,7 @@ Set `GUILD_SCROLL_ALLOW_REMOTE=1` to allow the report server to bind to non-loca - Session pages include an **Open Terminal** panel that POSTs `/api/session/{name}/terminal/start` and streams over WebSocket `/ws/session/{name}/terminal`. - Each newline-delimited stdin message is logged to `session.jsonl` as a `CommandEvent` (with cwd, seq, and part) before being forwarded to the PTY; terminal output is mirrored to `terminal.log`. - REST fallbacks are available for polling and automation: `GET /api/session/{name}/terminal/read`, `POST /api/session/{name}/terminal/write`, and `POST /api/session/{name}/terminal/stop`. +- A **Continue Session** action starts a new part from the session page and updates the live terminal to target the new part. ### JSONL Event Types diff --git a/src/guild_scroll/session.py b/src/guild_scroll/session.py index 4892d03..336399b 100644 --- a/src/guild_scroll/session.py +++ b/src/guild_scroll/session.py @@ -59,6 +59,21 @@ def _detect_operator() -> Optional[str]: return None +def next_part_number(parts_dir: Path) -> int: + """Return the next part number based on existing part directories.""" + existing_parts = [p for p in parts_dir.iterdir() if p.is_dir() and p.name.isdigit()] if parts_dir.exists() else [] + return max((int(p.name) for p in existing_parts), default=1) + 1 # part 1 is logs/, so next is 2+ + + +def update_parts_count(sess_dir: Path, parts_count: int) -> None: + """Update the top-level session_meta.parts_count to at least parts_count.""" + log_path = sess_dir / "logs" / SESSION_LOG_NAME + if not log_path.exists(): + return + with JSONLWriter(log_path) as writer: + _patch_session_meta_file(writer._fh, parts_count=parts_count) + + def start_session(raw_name: str, join: bool = False, mode: Optional[str] = None) -> None: """Create the session directory tree, inject hooks, launch script, finalize. @@ -148,8 +163,7 @@ def _start_part(session_name: str, sess_dir: Path) -> None: parts_dir.mkdir(exist_ok=True) # Determine next part number (existing logs/ counts as part 1) - existing_parts = [p for p in parts_dir.iterdir() if p.is_dir() and p.name.isdigit()] if parts_dir.exists() else [] - next_part = len(existing_parts) + 2 # part 1 is logs/, so next is 2+ + next_part = next_part_number(parts_dir) part_logs_dir = parts_dir / str(next_part) / "logs" part_assets_dir = parts_dir / str(next_part) / "assets" @@ -170,12 +184,15 @@ def _start_part(session_name: str, sess_dir: Path) -> None: start_time=iso_timestamp(), hostname=socket.gethostname(), operator=_detect_operator(), + parts_count=next_part, ) part_log = part_logs_dir / SESSION_LOG_NAME hmac_key = load_session_key(sess_dir) with JSONLWriter(part_log, hmac_key=hmac_key) as writer: writer.write(part_meta.to_dict()) + update_parts_count(sess_dir, next_part) + env_part = str(next_part) try: import subprocess as _sp @@ -366,10 +383,15 @@ def _patch_session_meta(log_path: Path, end_time: str, command_count: int) -> No if not log_path.exists(): return with JSONLWriter(log_path) as writer: - _patch_session_meta_file(writer._fh, end_time, command_count) + _patch_session_meta_file(writer._fh, end_time=end_time, command_count=command_count) -def _patch_session_meta_file(fh, end_time: str, command_count: int) -> None: +def _patch_session_meta_file( + fh, + end_time: Optional[str] = None, + command_count: Optional[int] = None, + parts_count: Optional[int] = None, +) -> None: """Patch session_meta in-place using an already-open a+ compatible handle.""" fh.flush() fh.seek(0) @@ -385,8 +407,12 @@ def _patch_session_meta_file(fh, end_time: str, command_count: int) -> None: updated.append(line) continue if record.get("type") == "session_meta": - record["end_time"] = end_time - record["command_count"] = command_count + if end_time is not None: + record["end_time"] = end_time + if command_count is not None: + record["command_count"] = command_count + if parts_count is not None: + record["parts_count"] = max(int(record.get("parts_count") or 1), int(parts_count)) updated.append(json.dumps(record, ensure_ascii=False)) fh.seek(0) fh.truncate() diff --git a/src/guild_scroll/web/app.py b/src/guild_scroll/web/app.py index 032da70..b94c413 100644 --- a/src/guild_scroll/web/app.py +++ b/src/guild_scroll/web/app.py @@ -21,12 +21,14 @@ HOOK_EVENTS_NAME, PARTS_DIR_NAME, ) +from guild_scroll.integrity import load_session_key from guild_scroll.exporters.html import export_html from guild_scroll.exporters.markdown import export_markdown from guild_scroll.exporters.output_extractor import build_command_output_map from guild_scroll.log_schema import NoteEvent, SessionMeta +from guild_scroll.log_writer import JSONLWriter from guild_scroll.search import SearchFilter, search_commands -from guild_scroll.session import list_sessions +from guild_scroll.session import list_sessions, next_part_number, update_parts_count from guild_scroll.session_loader import LoadedSession, load_session from guild_scroll.utils import generate_session_id, iso_timestamp, sanitize_session_name from guild_scroll.validator import repair_session, validate_session @@ -63,6 +65,15 @@ def _is_safe_session_name(name: str) -> bool: return True +def _detect_operator() -> str | None: + """Return operator identity from environment, if available.""" + for key in ("USER", "LOGNAME", "USERNAME"): + value = os.environ.get(key) + if value and value.strip(): + return value.strip() + return None + + def _query_value(params: dict[str, list[str]], key: str) -> str | None: values = params.get(key) if not values: @@ -551,6 +562,8 @@ def _render_session_page( "" ) + default_part = max(session.parts) if session.parts else 1 + return f""" @@ -568,6 +581,8 @@ def _render_session_page( .actions {{ display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0.9rem 0 1rem; }} .action-pill {{ border: 1px solid #36567f; border-radius: 999px; padding: 0.3rem 0.75rem; text-decoration: none; }} .action-pill:hover {{ border-color: #52d0ff; background: #1a2a42; }} +button.action-pill {{ background: transparent; color: #e9efff; cursor: pointer; }} +.action-status {{ color: #9eb8da; min-height: 1.2rem; display: inline-flex; align-items: center; }} .report-frame {{ width: 100%; height: 760px; border: 1px solid #334b70; background: #fff; border-radius: 8px; }} .report-preview {{ background: #0b1020; border: 1px solid #334b70; border-radius: 8px; padding: 1rem; overflow: auto; min-height: 760px; }} .discoveries-panel {{ position: sticky; top: 1rem; border: 1px solid #3d608d; border-radius: 12px; background: linear-gradient(160deg, #13243b, #0f1d31); padding: 0.9rem; box-shadow: inset 0 0 0 1px rgba(96, 142, 193, 0.16); }} @@ -588,7 +603,9 @@ def _render_session_page( .discovery-tags {{ margin-top: 0.25rem; color: #9eb8da; font-size: 0.78rem; word-break: break-word; }} .discovery-empty {{ margin: 0.8rem 0 0; color: #9eb8da; }} .terminal-panel {{ border: 1px solid #36567f; border-radius: 12px; background: linear-gradient(145deg, #101b2c, #0c1626); padding: 0.9rem; margin-bottom: 1rem; }} -.terminal-header {{ display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }} +.terminal-header {{ display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; flex-wrap: wrap; }} +.terminal-controls {{ display: inline-flex; align-items: center; gap: 0.5rem; }} +.terminal-part-label {{ border: 1px solid #3d608d; border-radius: 6px; padding: 0.25rem 0.5rem; color: #c7dcff; font-size: 0.85rem; }} .gs-terminal-btn {{ border: 1px solid #3d608d; background: #0e1a2c; color: #e9efff; padding: 0.4rem 0.8rem; border-radius: 8px; cursor: pointer; }} .gs-terminal-btn:hover {{ border-color: #52d0ff; background: #13243b; }} .terminal-output {{ background: #0b1020; border: 1px solid #334b70; border-radius: 8px; padding: 0.6rem; min-height: 220px; max-height: 320px; overflow: auto; white-space: pre-wrap; }} @@ -601,8 +618,21 @@ def _render_session_page( @@ -686,13 +746,18 @@ def _render_session_page( Markdown preview Download HTML Download Markdown + +

Live Terminal

- +
+ + +

         
@@ -1165,13 +1230,17 @@ def _handle_continue_session(self, raw_name: str) -> None: self._send_json({"error": "Session not found"}, status=404) return + if TERMINALS.any_active(session_name): + self._send_json({"error": "Session already active"}, status=409) + return + parts_dir = sess_dir / PARTS_DIR_NAME parts_dir.mkdir(exist_ok=True) - existing = [p for p in parts_dir.iterdir() if p.is_dir() and p.name.isdigit()] - next_part = max((int(p.name) for p in existing), default=1) + 1 # part 1 is logs/, so next is 2+ + next_part = next_part_number(parts_dir) - part_logs_dir = parts_dir / str(next_part) / "logs" - part_assets_dir = parts_dir / str(next_part) / "assets" + part_root = parts_dir / str(next_part) + part_logs_dir = part_root / "logs" + part_assets_dir = part_root / "assets" for directory in (part_logs_dir, part_assets_dir): directory.mkdir(parents=True, exist_ok=True) @@ -1180,10 +1249,36 @@ def _handle_continue_session(self, raw_name: str) -> None: session_id=generate_session_id(), start_time=iso_timestamp(), hostname=socket.gethostname(), + operator=_detect_operator(), + parts_count=next_part, ) - _write_jsonl_record(part_logs_dir / SESSION_LOG_NAME, part_meta.to_dict()) + hmac_key = load_session_key(sess_dir) + writer = JSONLWriter(part_logs_dir / SESSION_LOG_NAME, hmac_key=hmac_key) + writer.write(part_meta.to_dict()) + writer.close() + + try: + proc = TERMINALS.start(session_name, part=next_part) + except TerminalAlreadyRunning: + shutil.rmtree(str(part_root), ignore_errors=True) + self._send_json({"error": "Session already active"}, status=409) + return + except TerminalNotSupported as exc: + shutil.rmtree(str(part_root), ignore_errors=True) + self._send_json({"error": str(exc)}, status=501) + return + except ShellNotFound as exc: + shutil.rmtree(str(part_root), ignore_errors=True) + self._send_json({"error": str(exc)}, status=500) + return + except FileNotFoundError: + shutil.rmtree(str(part_root), ignore_errors=True) + self._send_json({"error": "Session not found"}, status=404) + return + + update_parts_count(sess_dir, next_part) - self._send_json({"session_name": session_name, "part": next_part}, status=200) + self._send_json({"session": session_name, "part": next_part, "status": "active", "pid": proc.pid}, status=200) def _handle_validate_session(self, raw_name: str, params: dict[str, list[str]]) -> None: session_name = unquote(raw_name) diff --git a/src/guild_scroll/web/terminal.py b/src/guild_scroll/web/terminal.py index d6f58fe..1d04994 100644 --- a/src/guild_scroll/web/terminal.py +++ b/src/guild_scroll/web/terminal.py @@ -12,6 +12,7 @@ from typing import Dict, Optional, Tuple from guild_scroll.config import PARTS_DIR_NAME, SESSION_LOG_NAME, get_sessions_dir +from guild_scroll.hooks import detect_shell from guild_scroll.integrity import load_session_key from guild_scroll.log_schema import CommandEvent from guild_scroll.log_writer import JSONLWriter @@ -59,9 +60,16 @@ def __init__(self, session_name: str, part: int = 1, shell: str = "zsh"): except ImportError as exc: # pragma: no cover - platform specific raise TerminalNotSupported("Terminal not supported on this platform") from exc - shell_path = shutil.which(shell) + shell_candidate = detect_shell() or shell + shell_path = shutil.which(shell_candidate) if not shell_path: - raise ShellNotFound(f"{shell} not found on this system") + for fallback in ("bash", "sh"): + shell_path = shutil.which(fallback) + if shell_path: + shell_candidate = fallback + break + if not shell_path: + raise ShellNotFound(f"{shell_candidate} not found on this system") self.session_name = session_name self.part = part @@ -92,6 +100,7 @@ def __init__(self, session_name: str, part: int = 1, shell: str = "zsh"): env = os.environ.copy() env.setdefault("TERM", "xterm-256color") + env.setdefault("SHELL", shell_candidate) work_dir = tempfile.gettempdir() self._proc = subprocess.Popen( [shell_path], @@ -269,6 +278,13 @@ def start(self, session_name: str, part: int = 1) -> TerminalProcess: self._sessions[key] = process return process + def any_active(self, session_name: str) -> bool: + with self._lock: + for (name, _), proc in self._sessions.items(): + if name == session_name and proc.is_alive(): + return True + return False + def get(self, session_name: str, part: int = 1) -> Optional[TerminalProcess]: with self._lock: proc = self._sessions.get((session_name, part)) diff --git a/tests/test_server.py b/tests/test_server.py index 9110bd3..92b9945 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -334,8 +334,36 @@ def test_continue_session_returns_200_with_part_number(self, isolated_sessions_d status, data = _request(server, "POST", "/api/session/cont-sess/continue") assert status == 200 payload = json.loads(data) + assert payload["session"] == "cont-sess" + assert payload["status"] == "active" assert payload["part"] >= 2 + + status_get, data_get = _request(server, "GET", "/api/session/cont-sess") + assert status_get == 200 + meta = json.loads(data_get)["session"] + assert meta.get("parts_count") >= payload["part"] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + def test_continue_session_conflict_when_active(self, isolated_sessions_dir): + sessions_dir = isolated_sessions_dir / "sessions" + _make_session(sessions_dir, "cont-live") + server, thread = _start_test_server() + part = None + try: + status_first, data_first = _request(server, "POST", "/api/session/cont-live/continue") + assert status_first == 200 + part = json.loads(data_first)["part"] + + status_second, data_second = _request(server, "POST", "/api/session/cont-live/continue") + assert status_second == 409 + payload_second = json.loads(data_second) + assert "active" in payload_second.get("error", "").lower() finally: + if part: + _request(server, "POST", f"/api/session/cont-live/terminal/stop?part={part}") server.shutdown() server.server_close() thread.join(timeout=2)