Skip to content
This repository was archived by the owner on Apr 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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`) |
Expand All @@ -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

Expand Down
38 changes: 32 additions & 6 deletions src/guild_scroll/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
121 changes: 108 additions & 13 deletions src/guild_scroll/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -551,6 +562,8 @@ def _render_session_page(
"</pre>"
)

default_part = max(session.parts) if session.parts else 1

return f"""<!DOCTYPE html>
<html lang="en">
<head>
Expand All @@ -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); }}
Expand All @@ -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; }}
Expand All @@ -601,8 +618,21 @@ def _render_session_page(
</style>
<script>
const gsSessionPath = "{session_name}";
let gsTerminalPart = {default_part};
let gsTerminalSocket = null;

function gsSetTerminalPart(part) {{
gsTerminalPart = part;
const badge = document.getElementById("gs-terminal-part");
if (badge) {{
badge.textContent = "Part " + part;
}}
}}

function gsPartQuery() {{
return `?part=${{encodeURIComponent(gsTerminalPart)}}`;
}}

function gsSetTerminalButton(running) {{
const btn = document.getElementById("gs-terminal-btn");
if (!btn) {{ return; }}
Expand All @@ -618,7 +648,7 @@ def _render_session_page(

async function gsStartTerminal() {{
try {{
const resp = await fetch(`/api/session/${{gsSessionPath}}/terminal/start`, {{ method: "POST" }});
const resp = await fetch(`/api/session/${{gsSessionPath}}/terminal/start${{gsPartQuery()}}`, {{ method: "POST" }});
const payload = await resp.json().catch(() => ({{}}));
if (!resp.ok) {{
const msg = payload.error ? payload.error : "Unable to start terminal.";
Expand All @@ -631,7 +661,7 @@ def _render_session_page(
}}

const wsProto = window.location.protocol === "https:" ? "wss" : "ws";
const wsUrl = wsProto + "://" + window.location.host + "/ws/session/" + gsSessionPath + "/terminal";
const wsUrl = wsProto + "://" + window.location.host + "/ws/session/" + gsSessionPath + "/terminal" + gsPartQuery();
gsTerminalSocket = new WebSocket(wsUrl);
gsTerminalSocket.onmessage = (event) => gsAppendTerminalOutput(event.data || "");
gsTerminalSocket.onclose = () => {{ gsTerminalSocket = null; gsSetTerminalButton(false); }};
Expand All @@ -641,7 +671,7 @@ def _render_session_page(

async function gsStopTerminal() {{
try {{
await fetch(`/api/session/${{gsSessionPath}}/terminal/stop`, {{ method: "POST" }});
await fetch(`/api/session/${{gsSessionPath}}/terminal/stop${{gsPartQuery()}}`, {{ method: "POST" }});
}} catch (_) {{}}
if (gsTerminalSocket) {{
gsTerminalSocket.close();
Expand All @@ -666,14 +696,44 @@ def _render_session_page(
if (gsTerminalSocket && gsTerminalSocket.readyState === WebSocket.OPEN) {{
gsTerminalSocket.send(payload);
}} else {{
fetch(`/api/session/${{gsSessionPath}}/terminal/write`, {{
fetch(`/api/session/${{gsSessionPath}}/terminal/write${{gsPartQuery()}}`, {{
method: "POST",
headers: {{ "Content-Type": "application/json" }},
body: JSON.stringify({{ input: payload }}),
}});
}}
inputEl.value = "";
}}

async function gsContinueSession() {{
const statusEl = document.getElementById("gs-continue-status");
const btn = document.getElementById("gs-continue-btn");
if (statusEl) {{ statusEl.textContent = "Continuing..."; }}
if (btn) {{ btn.disabled = true; }}
try {{
const resp = await fetch(`/api/session/${{gsSessionPath}}/continue`, {{ method: "POST" }});
const payload = await resp.json().catch(() => ({{}}));
if (!resp.ok) {{
const msg = payload.error ? payload.error : "Unable to continue session.";
if (statusEl) {{ statusEl.textContent = msg; }}
return;
}}
const part = Number(payload.part) || 1;
gsSetTerminalPart(part);
if (gsTerminalSocket) {{
await gsStopTerminal();
}}
if (statusEl) {{
statusEl.textContent = `Started part ${{part}}`;
}}
}} catch (err) {{
if (statusEl) {{ statusEl.textContent = "Failed to continue session."; }}
}} finally {{
if (btn) {{ btn.disabled = false; }}
}}
}}

gsSetTerminalPart(gsTerminalPart);
</script>
</head>
<body>
Expand All @@ -686,13 +746,18 @@ def _render_session_page(
<a class="action-pill" href="/session/{session_name}?{md_query}">Markdown preview</a>
<a class="action-pill" href="/api/session/{session_name}/download?{urlencode({'format': 'html', **filter_params})}">Download HTML</a>
<a class="action-pill" href="/api/session/{session_name}/download?{urlencode({'format': 'md', **filter_params})}">Download Markdown</a>
<button type="button" id="gs-continue-btn" class="action-pill" onclick="gsContinueSession()">Continue Session</button>
<span id="gs-continue-status" class="action-status" aria-live="polite"></span>
</div>
</section>

<section class="terminal-panel" aria-label="Live terminal">
<div class="terminal-header">
<h2>Live Terminal</h2>
<button type="button" id="gs-terminal-btn" class="gs-terminal-btn" onclick="gsTerminalToggle()">Open Terminal</button>
<div class="terminal-controls">
<span id="gs-terminal-part" class="terminal-part-label"></span>
<button type="button" id="gs-terminal-btn" class="gs-terminal-btn" onclick="gsTerminalToggle()">Open Terminal</button>
</div>
</div>
<pre id="gs-terminal-output" class="terminal-output" aria-live="polite"></pre>
<div class="terminal-actions">
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down
Loading
Loading