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 + +