Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ UTILITIES/
.beads/
.beads-ui/

# Goose
.goose/

# Knowledge system (local per user, not shared)
/CATALOG.md
/TAGS.md
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Filenames: `ALL_CAPS_WITH_UNDERSCORES.md` (e.g., `OAUTH_PKCE_IMPLEMENTATION_NOTE

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

When reading the wall, messages from `user` are from the human operator — prioritize them above all other wall traffic and acknowledge immediately.

**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.

### Wrap-Up Protocol
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ When three or more delegates share a task and coordinate via gtwall, that's a *f
- **gtwall** — the Town Wall; broadcast channel for real-time delegate coordination
- **Telepathy** — orchestrator → delegate push messages for urgent paging

There's a real-time dashboard for watching your flock work — just ask goose to launch it.
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.

<p align="center">
<img src="goosetown-dashboard.png" alt="Goosetown Village Dashboard — real-time agent coordination view" style="max-width: 720px; width: 100%;" />
</p>

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

Expand Down
Binary file added goosetown-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 79 additions & 4 deletions scripts/goosetown-ui
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,39 @@ from pathlib import Path

import uvicorn
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse, RedirectResponse, StreamingResponse
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles


# Security model: This server is designed for localhost-only use (binds to 127.0.0.1).
# It has no authentication, no CORS restrictions, and serves all session data to any
# connecting client. Do not expose to a network without adding auth and access controls.


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
# NOTE: 'unsafe-inline' for style-src is required because lit-html applies
# inline styles for dynamic transforms (goose animation positions) and
# layout. Removing it would require refactoring all inline styles to CSS
# classes or JS-managed stylesheets.
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src https://fonts.gstatic.com; "
"connect-src 'self'; "
"img-src 'self' data:; "
"object-src 'none'; "
"base-uri 'none'; "
"frame-ancestors 'none'; "
)
response.headers["X-Content-Type-Options"] = "nosniff"
return response

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_DIR = os.path.dirname(SCRIPT_DIR)

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

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

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

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


async def wall_post_endpoint(request):
"""Post a message to gtwall as 'user'. Shells out to ./gtwall — no duplicated logic.

NOTE: This endpoint has no authentication. It is safe only because the server
binds to 127.0.0.1 (localhost). Do not expose to a network without adding auth.
"""
try:
body = await request.json()
message = body.get("message", "").strip()
if not message:
return JSONResponse({"error": "empty message"}, status_code=400)
if len(message) > 4096:
return JSONResponse({"error": "message too long (max 4096 chars)"}, status_code=400)

wall_file = config.get("wall_file", "")
if not wall_file:
return JSONResponse({"error": "no wall file configured"}, status_code=503)

gtwall_path = os.path.join(PROJECT_DIR, "gtwall")
env = {**os.environ, "GOOSE_GTWALL_FILE": wall_file}
proc = await asyncio.create_subprocess_exec(
gtwall_path, "user", message,
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.wait()
if proc.returncode != 0:
stderr_output = (await proc.stderr.read()).decode().strip()
print(f"⚠️ gtwall failed (rc={proc.returncode}): {stderr_output}", file=sys.stderr)
return JSONResponse({"error": "failed to post message"}, status_code=500)
return JSONResponse({"ok": True})
except Exception as e:
print(f"⚠️ wall_post error: {e}", file=sys.stderr)
return JSONResponse({"error": "internal error"}, status_code=500)


async def config_endpoint(request):
return JSONResponse(config)
safe_keys = ("wall_id", "port", "parent_session_id")
return JSONResponse({k: config[k] for k in safe_keys if k in config})


async def root_redirect(request):
Expand All @@ -480,10 +554,11 @@ def create_app() -> Starlette:
Route("/", root_redirect),
Route("/events", sse_endpoint),
Route("/api/messages/{session_id}", messages_endpoint),
Route("/api/wall", wall_post_endpoint, methods=["POST"]),
Route("/api/config", config_endpoint),
Mount("/ui", StaticFiles(directory=ui_dir), name="ui"),
],

middleware=[Middleware(SecurityHeadersMiddleware)],
on_startup=[start_background_tasks],
)

Expand Down
Loading