c68b2303a0
Serves a localhost page in the pseudo-skill register with the reference markers as clickable buttons: clicks land in <workdir>/events.jsonl, a persistent Monitor tail wakes the session, the session rewrites page.html, and the browser live-reloads over SSE. Actions per marker: expand / show real code / explain / ask, plus a page-level ask box; anchors become forge deep-links when the project has a reachable remote; incidental findings are surfaced on the page as anchored notes. The port is configured in one place (the PORT constant atop scripts/server.py; --port overrides per run). Mechanics proven live against the aura repo before extraction into this skill.
140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""explore — localhost server behind the /explore skill.
|
|
|
|
Serves <workdir>/page.html, records marker-click events into
|
|
<workdir>/events.jsonl (one JSON object per line), and pushes an SSE
|
|
"reload" event whenever page.html changes on disk. The Claude session
|
|
arms a Monitor (`tail -F -n 0 <workdir>/events.jsonl`) as the
|
|
back-channel; rewriting page.html is the reply channel.
|
|
|
|
Usage: server.py <workdir> [--port N]
|
|
|
|
Stdlib only. Binds to localhost.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
# The single place the default port is configured (--port overrides per run).
|
|
PORT = 8000
|
|
|
|
# SSE tuning: how often to poll the page mtime, and how many idle polls
|
|
# between keep-alive comments (0.5s * 30 = one ping every 15s).
|
|
POLL_S = 0.5
|
|
PINGS_EVERY = 30
|
|
|
|
# Set in main() from the workdir argument.
|
|
PAGE = None
|
|
EVENTS = None
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def do_GET(self):
|
|
if self.path in ("/", "/index.html"):
|
|
self._serve_page()
|
|
elif self.path == "/stream":
|
|
self._serve_stream()
|
|
else:
|
|
self.send_error(404)
|
|
|
|
def do_POST(self):
|
|
if self.path == "/event":
|
|
self._record_event()
|
|
else:
|
|
self.send_error(404)
|
|
|
|
def _serve_page(self):
|
|
# Read fresh from disk every time — Claude rewrites this file.
|
|
try:
|
|
body = PAGE.read_bytes()
|
|
except OSError:
|
|
self.send_error(503, "page.html missing")
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _serve_stream(self):
|
|
# Long-lived SSE connection: emit "reload" whenever page.html
|
|
# changes on disk, keep-alive comments in between.
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
try:
|
|
last = PAGE.stat().st_mtime_ns
|
|
except OSError:
|
|
last = 0
|
|
idle = 0
|
|
try:
|
|
while True:
|
|
time.sleep(POLL_S)
|
|
try:
|
|
cur = PAGE.stat().st_mtime_ns
|
|
except OSError:
|
|
continue # page briefly missing mid-rewrite
|
|
if cur != last:
|
|
last = cur
|
|
self.wfile.write(b"event: reload\ndata: 1\n\n")
|
|
self.wfile.flush()
|
|
idle = 0
|
|
else:
|
|
idle += 1
|
|
if idle >= PINGS_EVERY:
|
|
self.wfile.write(b": ping\n\n")
|
|
self.wfile.flush()
|
|
idle = 0
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
return # client went away — normal
|
|
|
|
def _record_event(self):
|
|
try:
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
except ValueError:
|
|
self.send_error(411)
|
|
return
|
|
raw = self.rfile.read(length)
|
|
try:
|
|
event = json.loads(raw)
|
|
if not isinstance(event, dict):
|
|
raise ValueError("expected a JSON object")
|
|
except (ValueError, UnicodeDecodeError):
|
|
self.send_error(400, "invalid JSON")
|
|
return
|
|
event["ts"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
# Single-line appends in "a" mode are atomic enough here.
|
|
with EVENTS.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(event, ensure_ascii=False) + "\n")
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
|
|
def log_message(self, *args):
|
|
pass # keep stdout clean — it is the background-task log
|
|
|
|
|
|
def main():
|
|
global PAGE, EVENTS
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("workdir", type=Path, help="directory holding page.html and events.jsonl")
|
|
ap.add_argument("--port", type=int, default=PORT, help=f"listen port (default {PORT})")
|
|
a = ap.parse_args()
|
|
workdir = a.workdir.resolve()
|
|
workdir.mkdir(parents=True, exist_ok=True)
|
|
PAGE = workdir / "page.html"
|
|
EVENTS = workdir / "events.jsonl"
|
|
EVENTS.touch()
|
|
srv = ThreadingHTTPServer(("127.0.0.1", a.port), Handler)
|
|
print(f"explore serving {workdir} on http://127.0.0.1:{a.port}", flush=True)
|
|
srv.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|