Skip to content

Commit 666fe35

Browse files
feat(ui): add steampunk village visualization for agent orchestration
Interactive SVG village where AI delegates appear as geese walking between role-specific buildings (library for researchers, forge for generics, factory for workers, etc.) via A* pathfinding with tiered terrain costs. Includes: - Real-time SSE dashboard with delegate registry, wall feed, and workshop - ASCII map system with visual editor (editor.html) - Standalone fullscreen village page (village.html) - Map validator script with pathfinding analysis (scripts/validate-map) - Pub/sub state management, lit-html rendering, zero build step - 3 CDN deps: lit-html, marked, DOMPurify (version-pinned) Security: CSP headers, DOMPurify + lit-html auto-escaping, parameterized SQL, read-only DB, input validation, subprocess_exec (not shell). Accessibility: WCAG AA contrast, ARIA roles/states, keyboard navigation, focus trapping, prefers-reduced-motion support. Crossfire-reviewed to 9/10 by independent models.
1 parent 53d4fa6 commit 666fe35

18 files changed

Lines changed: 2693 additions & 28 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ UTILITIES/
1313
.beads/
1414
.beads-ui/
1515

16+
# Goose
17+
.goose/
18+
1619
# Knowledge system (local per user, not shared)
1720
/CATALOG.md
1821
/TAGS.md

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Filenames: `ALL_CAPS_WITH_UNDERSCORES.md` (e.g., `OAUTH_PKCE_IMPLEMENTATION_NOTE
2929

3030
The **Town Wall** (`gtwall`) is a broadcast communication tool for real-time coordination between delegates. Per-session walls, position-tracked per reader.
3131

32+
When reading the wall, messages from `user` are from the human operator — prioritize them above all other wall traffic and acknowledge immediately.
33+
3234
**Run `./gtwall --usage` as your first action** — it prints the full usage cadence, examples, and rules. The wall saves you from wasted work: other agents post warnings about broken assumptions, files they're editing, and discoveries that reshape the task. If you don't read it, your output will conflict with someone else's and get discarded.
3335

3436
### Wrap-Up Protocol

scripts/goosetown-ui

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,35 @@ from pathlib import Path
99

1010
import uvicorn
1111
from starlette.applications import Starlette
12+
from starlette.middleware import Middleware
13+
from starlette.middleware.base import BaseHTTPMiddleware
1214
from starlette.responses import JSONResponse, RedirectResponse, StreamingResponse
1315
from starlette.routing import Mount, Route
1416
from starlette.staticfiles import StaticFiles
1517

18+
19+
# Security model: This server is designed for localhost-only use (binds to 127.0.0.1).
20+
# It has no authentication, no CORS restrictions, and serves all session data to any
21+
# connecting client. Do not expose to a network without adding auth and access controls.
22+
23+
24+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
25+
async def dispatch(self, request, call_next):
26+
response = await call_next(request)
27+
response.headers["Content-Security-Policy"] = (
28+
"default-src 'self'; "
29+
"script-src 'self' https://cdn.jsdelivr.net; "
30+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
31+
"font-src https://fonts.gstatic.com; "
32+
"connect-src 'self'; "
33+
"img-src 'self' data:; "
34+
"object-src 'none'; "
35+
"base-uri 'none'; "
36+
"frame-ancestors 'none'; "
37+
)
38+
response.headers["X-Content-Type-Options"] = "nosniff"
39+
return response
40+
1641
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
1742
PROJECT_DIR = os.path.dirname(SCRIPT_DIR)
1843

@@ -22,6 +47,8 @@ WALLS_DIR = os.path.expanduser("~/.goosetown/walls")
2247
CHILD_PATTERN = re.compile(r"Task (\d{8}_\d+) started in background")
2348
GTWALL_ID_PATTERN = re.compile(r"Your gtwall ID is (\S+)")
2449
DELEGATE_NAME_PATTERN = re.compile(r"You are (\S+)\.")
50+
# IMPORTANT: These patterns are duplicated in ui/js/buildings.js (inferRole function).
51+
# If you change patterns here, update the JS version to match.
2552
ROLE_PATTERNS = {
2653
"orchestrator": re.compile(r"orchestrat", re.I),
2754
"researcher": re.compile(r"research", re.I),
@@ -395,6 +422,8 @@ async def sessions_tree_watcher():
395422

396423
# ── SSE Endpoint ────────────────────────────────────────────────────────────
397424
async def sse_endpoint(request):
425+
if len(clients) > 50:
426+
return JSONResponse({"error": "too many connections"}, status_code=503)
398427
queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
399428
clients.append(queue)
400429
last_event_id = request.headers.get("Last-Event-ID")
@@ -438,10 +467,12 @@ async def sse_endpoint(request):
438467
# ── REST ────────────────────────────────────────────────────────────────────
439468
async def messages_endpoint(request):
440469
sid = request.path_params["session_id"]
470+
if not re.match(r'^[a-zA-Z0-9_-]{1,128}$', sid):
471+
return JSONResponse({"error": "invalid session id"}, status_code=400)
441472
before = request.query_params.get("before")
442473

443474
try:
444-
limit = min(int(request.query_params.get("limit", "100")), 500)
475+
limit = max(1, min(int(request.query_params.get("limit", "100")), 500))
445476
with db() as cur:
446477
if before:
447478
cur.execute(
@@ -458,14 +489,49 @@ async def messages_endpoint(request):
458489
)
459490
rows = [{"id": r[0], "role": r[1], "content_json": r[2], "created_timestamp": r[3]} for r in cur.fetchall()]
460491
except Exception as e:
461-
return JSONResponse({"error": str(e)}, status_code=500)
492+
print(f"⚠️ messages_endpoint error: {e}", file=sys.stderr)
493+
return JSONResponse({"error": "internal error"}, status_code=500)
462494

463495
rows.reverse()
464496
return JSONResponse({"session_id": sid, "messages": rows, "has_more": len(rows) == limit})
465497

466498

499+
async def wall_post_endpoint(request):
500+
"""Post a message to gtwall. Shells out to ./gtwall — no duplicated logic."""
501+
try:
502+
body = await request.json()
503+
message = body.get("message", "").strip()
504+
if not message:
505+
return JSONResponse({"error": "empty message"}, status_code=400)
506+
if len(message) > 4096:
507+
return JSONResponse({"error": "message too long (max 4096 chars)"}, status_code=400)
508+
509+
wall_file = config.get("wall_file", "")
510+
if not wall_file:
511+
return JSONResponse({"error": "no wall file configured"}, status_code=503)
512+
513+
gtwall_path = os.path.join(PROJECT_DIR, "gtwall")
514+
env = {**os.environ, "GOOSE_GTWALL_FILE": wall_file}
515+
proc = await asyncio.create_subprocess_exec(
516+
gtwall_path, "user", message,
517+
env=env,
518+
stdout=asyncio.subprocess.PIPE,
519+
stderr=asyncio.subprocess.PIPE,
520+
)
521+
await proc.wait()
522+
if proc.returncode != 0:
523+
stderr_output = (await proc.stderr.read()).decode().strip()
524+
print(f"⚠️ gtwall failed (rc={proc.returncode}): {stderr_output}", file=sys.stderr)
525+
return JSONResponse({"error": "failed to post message"}, status_code=500)
526+
return JSONResponse({"ok": True})
527+
except Exception as e:
528+
print(f"⚠️ wall_post error: {e}", file=sys.stderr)
529+
return JSONResponse({"error": "internal error"}, status_code=500)
530+
531+
467532
async def config_endpoint(request):
468-
return JSONResponse(config)
533+
safe_keys = ("wall_id", "port", "parent_session_id")
534+
return JSONResponse({k: config[k] for k in safe_keys if k in config})
469535

470536

471537
async def root_redirect(request):
@@ -480,10 +546,11 @@ def create_app() -> Starlette:
480546
Route("/", root_redirect),
481547
Route("/events", sse_endpoint),
482548
Route("/api/messages/{session_id}", messages_endpoint),
549+
Route("/api/wall", wall_post_endpoint, methods=["POST"]),
483550
Route("/api/config", config_endpoint),
484551
Mount("/ui", StaticFiles(directory=ui_dir), name="ui"),
485552
],
486-
553+
middleware=[Middleware(SecurityHeadersMiddleware)],
487554
on_startup=[start_background_tasks],
488555
)
489556

0 commit comments

Comments
 (0)