Skip to content

Commit 0bdb322

Browse files
Add village visualization for orchestrator dashboard, user gtwall support (#2)
* 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. * fix: address PR crossfire findings - Remove dead parent_session query param from SSE URL - Math.round → Math.floor for path start tile (prevents tile jump mid-walk) - Add aria-live/role=log to bulletin feed for screen reader announcements - Add aria-expanded to village toggle button - Add aria-label to wall-post textarea - Add viewport meta to editor.html - Fix e.target.role → getAttribute('role') for cross-browser compat - Document CSP unsafe-inline rationale and localhost trust model - Tighten speech bubble positioning (y=-60, closer to goose) - Trailing newlines on HTML files * chore: biome lint fixes and crossfire polish - Sort imports per biome organizeImports (editor.js, village.js) - Remove unused BUILDING_CHARS import from editor.js - Lowercase hex literal 0xFFFF → 0xffff (tiles.js) - Use template literal for string concat (village.js) - Biome check now passes clean (0 errors, 0 warnings) Crossfire: both reviewers APPROVE at 9/10 * docs: mention village in README * docs: add village dashboard screenshot to README * fix: wall post error handling and speech timer comment - Check resp.ok on wall post fetch; keep dialog open on failure - Add comment explaining setTimeout(8100) = SPEECH_DURATION_MS + 100ms buffer Addresses feedback from external PR review.
1 parent 53d4fa6 commit 0bdb322

20 files changed

Lines changed: 2756 additions & 32 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

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,11 @@ When three or more delegates share a task and coordinate via gtwall, that's a *f
8585
- **gtwall** — the Town Wall; broadcast channel for real-time delegate coordination
8686
- **Telepathy** — orchestrator → delegate push messages for urgent paging
8787

88-
There's a real-time dashboard for watching your flock work — just ask goose to launch it.
88+
There's a real-time dashboard for watching your flock work (yes, they're actual geese on a map) — just ask goose to launch it.
89+
90+
<p align="center">
91+
<img src="goosetown-dashboard.png" alt="Goosetown Village Dashboard — real-time agent coordination view" style="max-width: 720px; width: 100%;" />
92+
</p>
8993

9094
Learn more in [AGENTS.md](AGENTS.md).
9195

goosetown-dashboard.png

1.62 MB
Loading

scripts/goosetown-ui

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,39 @@ 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+
# NOTE: 'unsafe-inline' for style-src is required because lit-html applies
28+
# inline styles for dynamic transforms (goose animation positions) and
29+
# layout. Removing it would require refactoring all inline styles to CSS
30+
# classes or JS-managed stylesheets.
31+
response.headers["Content-Security-Policy"] = (
32+
"default-src 'self'; "
33+
"script-src 'self' https://cdn.jsdelivr.net; "
34+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
35+
"font-src https://fonts.gstatic.com; "
36+
"connect-src 'self'; "
37+
"img-src 'self' data:; "
38+
"object-src 'none'; "
39+
"base-uri 'none'; "
40+
"frame-ancestors 'none'; "
41+
)
42+
response.headers["X-Content-Type-Options"] = "nosniff"
43+
return response
44+
1645
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
1746
PROJECT_DIR = os.path.dirname(SCRIPT_DIR)
1847

@@ -22,6 +51,8 @@ WALLS_DIR = os.path.expanduser("~/.goosetown/walls")
2251
CHILD_PATTERN = re.compile(r"Task (\d{8}_\d+) started in background")
2352
GTWALL_ID_PATTERN = re.compile(r"Your gtwall ID is (\S+)")
2453
DELEGATE_NAME_PATTERN = re.compile(r"You are (\S+)\.")
54+
# IMPORTANT: These patterns are duplicated in ui/js/buildings.js (inferRole function).
55+
# If you change patterns here, update the JS version to match.
2556
ROLE_PATTERNS = {
2657
"orchestrator": re.compile(r"orchestrat", re.I),
2758
"researcher": re.compile(r"research", re.I),
@@ -395,6 +426,8 @@ async def sessions_tree_watcher():
395426

396427
# ── SSE Endpoint ────────────────────────────────────────────────────────────
397428
async def sse_endpoint(request):
429+
if len(clients) > 50:
430+
return JSONResponse({"error": "too many connections"}, status_code=503)
398431
queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
399432
clients.append(queue)
400433
last_event_id = request.headers.get("Last-Event-ID")
@@ -438,10 +471,12 @@ async def sse_endpoint(request):
438471
# ── REST ────────────────────────────────────────────────────────────────────
439472
async def messages_endpoint(request):
440473
sid = request.path_params["session_id"]
474+
if not re.match(r'^[a-zA-Z0-9_-]{1,128}$', sid):
475+
return JSONResponse({"error": "invalid session id"}, status_code=400)
441476
before = request.query_params.get("before")
442477

443478
try:
444-
limit = min(int(request.query_params.get("limit", "100")), 500)
479+
limit = max(1, min(int(request.query_params.get("limit", "100")), 500))
445480
with db() as cur:
446481
if before:
447482
cur.execute(
@@ -458,14 +493,53 @@ async def messages_endpoint(request):
458493
)
459494
rows = [{"id": r[0], "role": r[1], "content_json": r[2], "created_timestamp": r[3]} for r in cur.fetchall()]
460495
except Exception as e:
461-
return JSONResponse({"error": str(e)}, status_code=500)
496+
print(f"⚠️ messages_endpoint error: {e}", file=sys.stderr)
497+
return JSONResponse({"error": "internal error"}, status_code=500)
462498

463499
rows.reverse()
464500
return JSONResponse({"session_id": sid, "messages": rows, "has_more": len(rows) == limit})
465501

466502

503+
async def wall_post_endpoint(request):
504+
"""Post a message to gtwall as 'user'. Shells out to ./gtwall — no duplicated logic.
505+
506+
NOTE: This endpoint has no authentication. It is safe only because the server
507+
binds to 127.0.0.1 (localhost). Do not expose to a network without adding auth.
508+
"""
509+
try:
510+
body = await request.json()
511+
message = body.get("message", "").strip()
512+
if not message:
513+
return JSONResponse({"error": "empty message"}, status_code=400)
514+
if len(message) > 4096:
515+
return JSONResponse({"error": "message too long (max 4096 chars)"}, status_code=400)
516+
517+
wall_file = config.get("wall_file", "")
518+
if not wall_file:
519+
return JSONResponse({"error": "no wall file configured"}, status_code=503)
520+
521+
gtwall_path = os.path.join(PROJECT_DIR, "gtwall")
522+
env = {**os.environ, "GOOSE_GTWALL_FILE": wall_file}
523+
proc = await asyncio.create_subprocess_exec(
524+
gtwall_path, "user", message,
525+
env=env,
526+
stdout=asyncio.subprocess.PIPE,
527+
stderr=asyncio.subprocess.PIPE,
528+
)
529+
await proc.wait()
530+
if proc.returncode != 0:
531+
stderr_output = (await proc.stderr.read()).decode().strip()
532+
print(f"⚠️ gtwall failed (rc={proc.returncode}): {stderr_output}", file=sys.stderr)
533+
return JSONResponse({"error": "failed to post message"}, status_code=500)
534+
return JSONResponse({"ok": True})
535+
except Exception as e:
536+
print(f"⚠️ wall_post error: {e}", file=sys.stderr)
537+
return JSONResponse({"error": "internal error"}, status_code=500)
538+
539+
467540
async def config_endpoint(request):
468-
return JSONResponse(config)
541+
safe_keys = ("wall_id", "port", "parent_session_id")
542+
return JSONResponse({k: config[k] for k in safe_keys if k in config})
469543

470544

471545
async def root_redirect(request):
@@ -480,10 +554,11 @@ def create_app() -> Starlette:
480554
Route("/", root_redirect),
481555
Route("/events", sse_endpoint),
482556
Route("/api/messages/{session_id}", messages_endpoint),
557+
Route("/api/wall", wall_post_endpoint, methods=["POST"]),
483558
Route("/api/config", config_endpoint),
484559
Mount("/ui", StaticFiles(directory=ui_dir), name="ui"),
485560
],
486-
561+
middleware=[Middleware(SecurityHeadersMiddleware)],
487562
on_startup=[start_background_tasks],
488563
)
489564

0 commit comments

Comments
 (0)