diff --git a/configs/glados_webapp_config.yaml b/configs/glados_webapp_config.yaml
new file mode 100644
index 00000000..81d3e8ea
--- /dev/null
+++ b/configs/glados_webapp_config.yaml
@@ -0,0 +1,65 @@
+# GLaDOS configuration with the webapp observability console enabled.
+#
+# Start it with: `uv run glados webapp --config configs/glados_webapp_config.yaml`
+# Then open http://127.0.0.1:8050/ in a browser.
+#
+# The console is an in-process HTTP server (stdlib only) that streams the
+# engine's live state - minds/slots/subagents/MCP/emotion/audio/lanes and the
+# ObservabilityBus event log - over Server-Sent-Events plus a JSON snapshot API.
+Glados:
+ llm_model: "llama3.2"
+ completion_url: "http://localhost:11434/api/chat"
+ api_key: null # Add your API key here if needed!
+ interruptible: true
+ audio_io: "sounddevice" # local hardware. For a browser-mic setup use "websocket".
+ input_mode: "audio" # audio, text, or both
+ tts_enabled: true
+ asr_muted: false
+ tui_theme: "aperture"
+ asr_engine: "tdt"
+ llm_headers: null # Optional extra headers (e.g., OpenRouter HTTP-Referer, X-Title)
+ wake_word: null
+ voice: "glados"
+ announcement: "All neural network modules are now loaded. System Operational."
+
+ # --- webapp observability console ---------------------------------------
+ # Default OFF. Enable here, or set GLADOS_WEBAPP_ENABLED=1 / _PORT / _HOST
+ # environment variables to switch it on without editing YAML. The demo is
+ # intentionally loopback-only because it has no authentication boundary.
+ webapp:
+ enabled: true
+ host: "127.0.0.1" # Listen address
+ port: 8050 # Listen port; open http://127.0.0.1:8050/
+
+ autonomy:
+ enabled: true
+ tick_interval_s: 10
+ cooldown_s: 20
+ autonomy_parallel_calls: 2 # parallel autonomy LLM workers -> the console's "Autonomy" lane
+ autonomy_queue_max: null
+ coalesce_ticks: true
+ jobs:
+ enabled: true
+ poll_interval_s: 1
+ hacker_news:
+ enabled: false
+ interval_s: 1800
+ top_n: 5
+ min_score: 200
+ weather:
+ enabled: false
+ interval_s: 3600
+ latitude: null
+ longitude: null
+ timezone: "auto"
+ temp_change_c: 4
+ wind_alert_kmh: 40
+
+ mcp_servers:
+ - name: "system_info"
+ transport: "stdio"
+ command: "python"
+ args: ["-m", "glados.mcp.system_info_server"]
+
+ personality_preprompt:
+ - system: "You are GLaDOS, a sarcastic AI assistant."
diff --git a/docs/webapp.md b/docs/webapp.md
new file mode 100644
index 00000000..23ceb99d
--- /dev/null
+++ b/docs/webapp.md
@@ -0,0 +1,145 @@
+# Webapp Console
+
+An in-process "mission control" for the running GLaDOS engine. It streams the
+live parallel state — the two autonomy lanes, subagent contexts, tool state,
+PAD emotion, audio/MCP health — to a browser over HTTP + Server-Sent Events.
+No separate service to run; no new runtime dependencies.
+
+## Decoupled launcher (key design)
+
+The webapp console is **not** part of the core engine. It is started by a
+dedicated CLI command — `glados webapp` — that mirrors how `glados tui` works:
+it loads the config, builds a `Glados` engine, starts the in-process
+`WebappServer` on its own port, then runs the engine loop and shuts the server
+down when the loop exits. The engine itself holds no webapp knowledge.
+
+The observable state (`ObservabilityBus`, `MindRegistry`, `TaskSlotStore`,
+subagent memory, interaction/emotion state) only exists inside the running
+`Glados` object, so the console server runs in the same process and reads those
+objects directly — exactly the pattern the WebSocket audio backend uses. This
+keeps it dependency-light and side-effect free for every engine entry point
+(`start`, `tui`, `say`).
+
+The webapp and the TUI are **mutually exclusive UI options** — you run either
+`glados webapp` or `glados tui`, never both; the core engine stays agnostic to
+which one is attached.
+
+## Running it
+
+The console is **off by default**. Enable it, then start it with the webapp
+launcher:
+
+1) YAML config:
+
+ ```yaml
+ webapp:
+ enabled: true
+ host: 127.0.0.1
+ port: 8050
+ ```
+
+2) Environment variables (no config edit):
+
+ ```bash
+ GLADOS_WEBAPP_ENABLED=1 GLADOS_WEBAPP_PORT=8050 uv run glados webapp
+ ```
+
+Both can be combined with `--config`, `--input-mode`, `--tts-enabled`/
+`--tts-disabled`, and `--asr-muted`/`--asr-unmuted`:
+
+```bash
+uv run glados webapp --config ./configs/glados_webapp_config.yaml
+```
+
+Then open `http://127.0.0.1:8050/`.
+
+> **Dummy fallback.** If the page is opened without a live engine — e.g.
+> `examples/webapp/index.html` or a directly-opened static file — the page
+> detects the missing API and falls back to simulated data so it can be
+> previewed and styled.
+
+## Endpoints
+
+| Method | Path | Purpose |
+| ------ | --------------------- | ------- |
+| GET | `/` | Static console (`static/index.html`). |
+| GET | `/api/snapshot` | Aggregate JSON snapshot (minds, agents, slots, lanes, audio, emotion, MCP, interaction, vision). |
+| GET | `/api/state` | Lightweight state JSON for the live gauges. |
+| GET | `/api/stream` | SSE stream (see contract below). |
+| GET | `/api/minds` | Registered mind statuses. |
+| GET | `/api/minds/{id}` | Single mind status. |
+| GET | `/api/minds/{id}/memory` | That agent's private jsonlines memory entries. |
+| GET | `/api/slots` | Task slots (summary fields). |
+| GET | `/api/slots/{id}` | Full slot including the on-demand report. |
+| GET | `/api/agents` | Subagent statuses (`agent_id, title, running, tick_count, last_tick`). |
+
+The API is deliberately read-only and loopback-only. It does not enable CORS,
+rejects cross-origin browser requests, and accepts only `127.0.0.1` or
+`localhost` as its configured host. Remote control needs a separately designed
+authentication and authorization boundary; this demo does not pretend to
+provide one.
+
+## SSE contract — `/api/stream`
+
+Each connection first replays the last 100 events from the bus, then receives:
+
+- `obs` events — the same shape the TUI `ObsScreen` renders:
+
+ ```json
+ {"timestamp": 1750000000.0, "source": "autonomy", "kind": "slot.update",
+ "level": "info", "message": "weather brief -> done", "meta": {"slot": "s_weather"}}
+ ```
+
+ Real `source`/`kind` combos include `llm.request`, `llm.queue`,
+ `llm.tool_calls`, `autonomy.dispatch`, `autonomy.slot.update`,
+ `subagent.start/stop`, `tool.start/finish/error/timeout`, `tts.*`,
+ `mcp.*`, `vision.update`, `text.user_input`.
+
+- `state` events — every ~0.5 s, mirroring `/api/state`, so gauges, clock, and
+ lane chips stay live without re-sending the whole snapshot.
+
+### Multi-consumer (the important bit)
+
+The TUI's `ObservabilityScreen` consumes the bus via `drain()`, which is a
+*single-consumer* FIFO. The webapp never calls `drain()`. Instead every SSE
+connection registers its own private `subscribe()` queue on
+`ObservabilityBus`, so multiple browsers each get their own copy and never
+steal events from the TUI or from each other. This is fully backward-compatible:
+`drain()` and `snapshot()` keep their existing behavior.
+
+## Failure behavior
+
+Because the webapp is the point of the `glados webapp` launcher, a disabled
+console or a bind failure (port in use) is **fatal for that command**: the
+launcher logs an error and exits rather than running the engine without the
+console. This does not affect other entry points — `tui`, `start`, and `say`
+are completely independent of the webapp and never bind a port.
+
+- Request exceptions return JSON error bodies and never crash a worker thread.
+- Client disconnects free their subscription.
+
+## Lifecycle
+
+The `glados webapp` command orchestrates the whole lifecycle:
+
+1. Load `GladosConfig.from_yaml` and apply CLI overrides.
+2. Refuse to start if `webapp.enabled` is false.
+3. Build `Glados.from_config`, then `WebappServer(engine, host, port).start()`.
+4. Run `engine.run()`; on any exit (`try/finally`), shut the server down.
+
+The core engine stays decoupled and side-effect free. This mirrors the TUI's
+launcher pattern: the same `GLADOS_WEBAPP_*` environment variables live on
+`GladosConfig.webapp`, so the launcher reads one merged config.
+`GladosConfig.webapp` stays a field on the shared config model, but the engine
+never reads it at runtime — the decoupling is at the launcher boundary.
+
+## Development
+
+- Console page: `src/glados/webapp/static/index.html` — single self-contained
+ file (Aperture/GLaDOS theme, no build step). `examples/webapp/index.html` is
+ the standalone mockup it derives from.
+- Server: `src/glados/webapp/server.py`; serializers: `serializers.py`;
+ config: `config.py`.
+- Tests: `tests/test_webapp.py` — bus fan-out, serializers, and an in-process
+ HTTP smoke test against a stub engine.
+- Example config: `configs/glados_webapp_config.yaml`.
diff --git a/examples/webapp/index.html b/examples/webapp/index.html
new file mode 100644
index 00000000..277d9a4a
--- /dev/null
+++ b/examples/webapp/index.html
@@ -0,0 +1,719 @@
+
+
+
+
+
+GLaDOS Core Console — Mockup
+
+
+
+
+
+
+
+
+
GLaDOS
CORE CONSOLE · OBSERVATION DECK
+
+ UP 03:12:48
+ --:--:--
+ SYSTEMS NOMINAL
+
+
+
+
+
+
+
+
+
+
Mission Control
+ watch the brain think, in parallel.
+
autonomy pool 2/4in-flight 0
+
+
+
0
Slots tracked
+
0
Active minds
+
0
Inferences in-flight
+
+
+
+
Dual Lane Orchestration
+
+
+
+
+
Priority Lane
direct user agent · 1 lane
+
0 in-flight
+
+
+
+
+
+
+
Autonomy Lane
pooled brains · 2 workers
+
0 in-flight
+
+
+
+
+
+
+
Brainstem PAD + interaction
+
+
Pleasure
0.0
+
Arousal
0.0
+
Dominance
0.0
+
+
+
●Last user ----
+
●Last spoke ----
+
+
+
+
+
The Wire · live stream
+
+
+
+
+
+
+
Neural Core
all parallel inferences, in-flight and queued.
--
+
+
+
Priority Lane
the direct user agent · always first
--
+
+
+
+
Autonomy Lane
pool of Minds · up to 12 workers
--
+
+
+
+
+
Worker pool autonomy_parallel_calls = 2
+
+
+
+
+
+
+
Minds
independent subagents, each with a private context.
+
+
+
+
+
+
Slots
shared state the main agent consumes.
shared
+
+
+
+
+
+
The Wire
observability bus stream.
+
+
+
+
+
+
+
Vitals
everything else the TUI tracks.
+
+
Audio
+
Interaction
+
Vision
+
MCP servers
+
+
+
+
+
+
+
+
+
+
diff --git a/src/glados/cli.py b/src/glados/cli.py
index 5d142754..8975c781 100644
--- a/src/glados/cli.py
+++ b/src/glados/cli.py
@@ -13,6 +13,7 @@
from .TTS import tts_glados
from .utils import spoken_text_converter as stc
from .utils.resources import resource_path
+from .webapp import WebappServer
# Type aliases for clarity
type FileHash = str
@@ -272,6 +273,54 @@ def tui(
sys.exit()
+def run_webapp(
+ config_path: str | Path | list[str] | list[Path] = "glados_config.yaml",
+ input_mode: str | None = None,
+ tts_enabled: bool | None = None,
+ asr_muted: bool | None = None,
+) -> None:
+ """Run GLaDOS with its browser-based observability console."""
+ from loguru import logger
+
+ glados_config = GladosConfig.from_yaml(config_path)
+ updates: dict[str, object] = {}
+ if input_mode:
+ updates["input_mode"] = input_mode
+ if tts_enabled is not None:
+ updates["tts_enabled"] = tts_enabled
+ if asr_muted is not None:
+ updates["asr_muted"] = asr_muted
+ if updates:
+ glados_config = glados_config.model_copy(update=updates)
+
+ webapp_config = glados_config.webapp
+ if webapp_config is None or not webapp_config.enabled:
+ logger.error(
+ "The webapp console is disabled. Enable webapp.enabled or set "
+ "GLADOS_WEBAPP_ENABLED=1."
+ )
+ raise SystemExit(1)
+
+ glados = Glados.from_config(glados_config)
+ server = WebappServer(glados, host=webapp_config.host, port=webapp_config.port)
+ server.start()
+ if not server.is_running:
+ logger.error(
+ "Webapp console could not bind {}:{} - aborting.",
+ webapp_config.host,
+ webapp_config.port,
+ )
+ glados._graceful_shutdown()
+ raise SystemExit(1)
+
+ try:
+ if glados.announcement:
+ glados.play_announcement()
+ glados.run()
+ finally:
+ server.shutdown()
+
+
def parser_add_config(parser: argparse.ArgumentParser) -> None:
"""
Add the '--config' argument to the given parser.
@@ -389,6 +438,11 @@ def main() -> int:
help="Override TUI theme (aperture, ice, matrix, mono, ember)",
)
+ webapp_parser = subparsers.add_parser(
+ "webapp", help="Start GLaDOS with the browser observability console"
+ )
+ parser_add_common_tui_cli_args(webapp_parser)
+
# Say command
say_parser = subparsers.add_parser("say", help="Make GLaDOS speak text")
say_parser.add_argument("text", type=str, help="Text for GLaDOS to speak")
@@ -419,6 +473,13 @@ def main() -> int:
asr_muted=args.asr_muted,
theme=args.theme,
)
+ elif args.command == "webapp":
+ run_webapp(
+ args.config,
+ input_mode=args.input_mode,
+ tts_enabled=args.tts_enabled,
+ asr_muted=args.asr_muted,
+ )
else:
# Default to start if no command specified
start(DEFAULT_CONFIG)
diff --git a/src/glados/core/engine.py b/src/glados/core/engine.py
index 5aa82884..07d896de 100644
--- a/src/glados/core/engine.py
+++ b/src/glados/core/engine.py
@@ -31,6 +31,7 @@
from ..autonomy.summarization import estimate_tokens
from ..mcp import MCPManager, MCPServerConfig
from ..observability import MindRegistry, ObservabilityBus, trim_message
+from ..webapp.config import WebappConfig
from ..vision import VisionConfig, VisionState
from ..vision.constants import SYSTEM_PROMPT_VISION_HANDLING
from .audio_data import AudioMessage
@@ -124,6 +125,7 @@ class GladosConfig(BaseModel):
vision: VisionConfig | None = None
autonomy: AutonomyConfig | None = None
mcp_servers: list[MCPServerConfig] | None = None
+ webapp: WebappConfig | None = None
@model_validator(mode="after")
def _resolve_api_key_from_env(self) -> "GladosConfig":
@@ -134,6 +136,25 @@ def _resolve_api_key_from_env(self) -> "GladosConfig":
self.api_key = env_key
return self
+ @model_validator(mode="after")
+ def _apply_webapp_env(self) -> "GladosConfig":
+ """Apply optional ``GLADOS_WEBAPP_*`` environment overrides."""
+ flag = os.environ.get("GLADOS_WEBAPP_ENABLED")
+ host = os.environ.get("GLADOS_WEBAPP_HOST")
+ port = os.environ.get("GLADOS_WEBAPP_PORT")
+ if flag is None and host is None and port is None:
+ return self
+
+ webapp = self.webapp or WebappConfig()
+ self.webapp = WebappConfig.model_validate(
+ {
+ "enabled": webapp.enabled if flag is None else flag,
+ "host": host or webapp.host,
+ "port": port or webapp.port,
+ }
+ )
+ return self
+
@classmethod
def from_yaml(cls, paths: str | Path | list[str] | list[Path], key_to_config: tuple[str, ...] = ("Glados",)) -> "GladosConfig":
"""
diff --git a/src/glados/observability/bus.py b/src/glados/observability/bus.py
index 4d3f5096..da6d8def 100644
--- a/src/glados/observability/bus.py
+++ b/src/glados/observability/bus.py
@@ -1,3 +1,5 @@
+"""Thread-safe observability event distribution."""
+
from __future__ import annotations
from collections import deque
@@ -10,10 +12,15 @@
class ObservabilityBus:
- def __init__(self, max_history: int = 500) -> None:
+ """Thread-safe event bus with bounded history and independent consumers."""
+
+ def __init__(self, max_history: int = 500, subscriber_max: int = 100) -> None:
+ """Initialize bounded history and per-consumer queue capacity."""
self._queue: queue.Queue[ObservabilityEvent] = queue.Queue()
self._lock = threading.Lock()
self._history: deque[ObservabilityEvent] = deque(maxlen=max_history)
+ self._subscriber_max = max(1, subscriber_max)
+ self._subscribers: list[queue.Queue[ObservabilityEvent]] = []
def emit(
self,
@@ -23,6 +30,7 @@ def emit(
level: str = "info",
meta: dict[str, Any] | None = None,
) -> ObservabilityEvent:
+ """Construct, publish, and return an observability event."""
event = ObservabilityEvent(
timestamp=time.time(),
source=source,
@@ -35,11 +43,39 @@ def emit(
return event
def publish(self, event: ObservabilityEvent) -> None:
+ """Publish an event to history, the legacy queue, and all subscribers."""
with self._lock:
self._history.append(event)
- self._queue.put(event)
+ for subscriber in self._subscribers:
+ try:
+ subscriber.put_nowait(event)
+ except queue.Full:
+ # A slow subscriber must not block producers. Keep its
+ # newest events by evicting one oldest item.
+ try:
+ subscriber.get_nowait()
+ subscriber.put_nowait(event)
+ except (queue.Empty, queue.Full):
+ pass
+ self._queue.put(event)
+
+ def subscribe(self) -> queue.Queue[ObservabilityEvent]:
+ """Return a bounded queue which receives every future event."""
+ subscriber: queue.Queue[ObservabilityEvent] = queue.Queue(maxsize=self._subscriber_max)
+ with self._lock:
+ self._subscribers.append(subscriber)
+ return subscriber
+
+ def unsubscribe(self, subscriber: queue.Queue[ObservabilityEvent]) -> None:
+ """Remove a queue previously returned by :meth:`subscribe`."""
+ with self._lock:
+ try:
+ self._subscribers.remove(subscriber)
+ except ValueError:
+ pass
def drain(self, max_items: int = 100) -> list[ObservabilityEvent]:
+ """Remove up to ``max_items`` from the legacy single-consumer queue."""
events: list[ObservabilityEvent] = []
for _ in range(max_items):
try:
@@ -49,6 +85,7 @@ def drain(self, max_items: int = 100) -> list[ObservabilityEvent]:
return events
def snapshot(self, limit: int | None = None) -> list[ObservabilityEvent]:
+ """Return a stable copy of recent event history."""
with self._lock:
events = list(self._history)
if limit is None or limit <= 0:
@@ -56,6 +93,7 @@ def snapshot(self, limit: int | None = None) -> list[ObservabilityEvent]:
return events[-limit:]
def clear(self) -> None:
+ """Clear history and the legacy queue."""
with self._lock:
self._history.clear()
try:
diff --git a/src/glados/webapp/__init__.py b/src/glados/webapp/__init__.py
new file mode 100644
index 00000000..c9c8401b
--- /dev/null
+++ b/src/glados/webapp/__init__.py
@@ -0,0 +1,14 @@
+"""GLaDOS webapp observability console.
+
+An in-process web server (stdlib only) that exposes the engine's live state to a
+browser: a snapshot REST API plus a Server-Sent-Events stream built on the
+thread-safe ``ObservabilityBus``. Enable it via ``webapp: {enabled: true}`` in
+the config (or ``GLADOS_WEBAPP_ENABLED=1``) and open http://127.0.0.1:8050/.
+"""
+
+from __future__ import annotations
+
+from .config import WebappConfig
+from .server import WebappServer
+
+__all__ = ["WebappConfig", "WebappServer"]
diff --git a/src/glados/webapp/config.py b/src/glados/webapp/config.py
new file mode 100644
index 00000000..c7a9419a
--- /dev/null
+++ b/src/glados/webapp/config.py
@@ -0,0 +1,21 @@
+"""Configuration for the Glados webapp observability console."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+
+class WebappConfig(BaseModel):
+ """Webapp observability console server configuration."""
+
+ enabled: bool = Field(
+ default=False,
+ description="Serve the webapp observability console (default off).",
+ )
+ host: Literal["127.0.0.1", "localhost"] = Field(
+ default="127.0.0.1",
+ description="Loopback listen address; remote access is intentionally disabled.",
+ )
+ port: int = Field(default=8050, ge=0, le=65535, description="Listen port.")
diff --git a/src/glados/webapp/serializers.py b/src/glados/webapp/serializers.py
new file mode 100644
index 00000000..b6844fa7
--- /dev/null
+++ b/src/glados/webapp/serializers.py
@@ -0,0 +1,249 @@
+"""Map live engine state objects to plain JSON-serializable dicts.
+
+These helpers mirror the TUI's panels: they read the exact same thread-safe
+accessors (MindRegistry, TaskSlotStore, SubagentManager, MCPManager, AudioState,
+InteractionState, queue sizes, ...) and turn them into plain dicts for the
+webapp. No state is duplicated - the console just reads what the engine tracks.
+"""
+# The console intentionally adapts several optional engine components through a
+# runtime-shaped boundary. Concrete protocols for every combination would add
+# coupling without improving safety at this read-only telemetry edge.
+# ruff: noqa: ANN401
+
+from __future__ import annotations
+
+from dataclasses import asdict
+import json
+import time
+from typing import Any, cast
+
+try:
+ import numpy as np
+
+ _HAS_NUMPY = True
+except Exception: # pragma: no cover
+ np = None # type: ignore[assignment]
+ _HAS_NUMPY = False
+
+
+def _plain(value: Any) -> Any:
+ """Recursively coerce numpy scalars/arrays and dataclasses to JSON-safe values."""
+ if _HAS_NUMPY and isinstance(value, np.generic):
+ return value.item()
+ if _HAS_NUMPY and isinstance(value, np.ndarray):
+ return value.tolist()
+ if hasattr(value, "to_dict") and callable(value.to_dict):
+ return _plain(value.to_dict())
+ if hasattr(value, "__dataclass_fields__"):
+ return _plain(asdict(value))
+ if isinstance(value, dict):
+ return {str(k): _plain(v) for k, v in value.items()}
+ if isinstance(value, list | tuple):
+ return [_plain(v) for v in value]
+ return value
+
+
+def to_jsonable(obj: Any) -> Any:
+ """Return a recursively JSON-compatible representation of ``obj``."""
+ return _plain(obj)
+
+
+def dumps(obj: Any) -> str:
+ """Serialize to JSON without choking on dataclasses, numpy, or timestamps."""
+ return json.dumps(obj, default=_json_default)
+
+
+def _json_default(value: Any) -> Any:
+ """Coerce otherwise unsupported values for :func:`json.dumps`."""
+ try:
+ plain = _plain(value)
+ return str(value) if plain is value else plain
+ except Exception: # pragma: no cover
+ return str(value)
+
+
+def serialize_event(event: Any) -> dict[str, Any]:
+ """ObservabilityEvent -> {timestamp, source, kind, level, message, meta}."""
+ return {
+ "timestamp": getattr(event, "timestamp", time.time()),
+ "source": getattr(event, "source", ""),
+ "kind": getattr(event, "kind", ""),
+ "level": getattr(event, "level", "info"),
+ "message": getattr(event, "message", ""),
+ "meta": to_jsonable(getattr(event, "meta", {})),
+ }
+
+
+def serialize_mind(mind: Any) -> dict[str, Any]:
+ """Serialize a registered mind's public status."""
+ return {
+ "mind_id": mind.mind_id,
+ "title": mind.title,
+ "status": mind.status,
+ "summary": mind.summary,
+ "role": mind.role,
+ "updated_at": mind.updated_at,
+ }
+
+
+def serialize_slot(slot: Any) -> dict[str, Any]:
+ """Serialize task-slot summary fields."""
+ return {
+ "slot_id": slot.slot_id,
+ "title": slot.title,
+ "status": slot.status,
+ "summary": slot.summary,
+ "notify_user": slot.notify_user,
+ "importance": slot.importance,
+ "confidence": slot.confidence,
+ "next_run": slot.next_run,
+ "updated_at": slot.updated_at,
+ "has_report": bool(slot.report),
+ }
+
+
+def serialize_slot_full(slot: Any) -> dict[str, Any]:
+ """Serialize a task slot including its report."""
+ data = serialize_slot(slot)
+ data["report"] = slot.report
+ return data
+
+
+def serialize_memory_entry(entry: Any) -> dict[str, Any]:
+ """Serialize a subagent memory entry."""
+ return {
+ "key": entry.key,
+ "value": to_jsonable(entry.value),
+ "created_at": entry.created_at,
+ "shown_at": entry.shown_at,
+ }
+
+
+def serialize_agent(agent_status: Any) -> dict[str, Any]:
+ """Serialize a subagent's runtime status."""
+ return {
+ "agent_id": agent_status.agent_id,
+ "title": agent_status.title,
+ "running": agent_status.running,
+ "tick_count": agent_status.tick_count,
+ "last_tick": agent_status.last_tick,
+ }
+
+
+def _audio_state(engine: Any) -> dict[str, Any]:
+ """Read the current audio meter state from the engine."""
+ audio_state = getattr(engine, "audio_state", None)
+ if audio_state is None:
+ return {"rms": 0.0, "vad_active": False}
+ snap = audio_state.snapshot()
+ return {"rms": float(snap.rms), "vad_active": bool(snap.vad_active)}
+
+
+def _emotion_state(engine: Any) -> dict[str, Any] | None:
+ """Return the optional emotion-agent state."""
+ agent = getattr(engine, "_emotion_agent", None)
+ if agent is None:
+ return None
+ try:
+ return cast(dict[str, Any], to_jsonable(agent.state))
+ except Exception: # pragma: no cover
+ return None
+
+
+def _mcp_summary(engine: Any) -> dict[str, Any]:
+ """Return a failure-tolerant MCP status summary."""
+ manager = getattr(engine, "mcp_manager", None)
+ if manager is None:
+ return {"enabled": False, "servers": []}
+ try:
+ servers = manager.status_snapshot()
+ except Exception: # pragma: no cover
+ servers = []
+ return {"enabled": True, "servers": servers}
+
+
+def build_lanes(engine: Any) -> dict[str, Any]:
+ """Summarize priority and autonomy inference lanes."""
+ return {
+ "enabled": bool(getattr(engine, "autonomy_config", None) and engine.autonomy_config.enabled),
+ "priority": {"queue": int(engine.llm_queue_priority.qsize())},
+ "autonomy": {
+ "queue": int(engine.llm_queue_autonomy.qsize()),
+ "inflight": int(engine._autonomy_inflight.value()),
+ "workers": len(getattr(engine, "autonomy_llm_processors", ())),
+ },
+ }
+
+
+def build_state(engine: Any) -> dict[str, Any]:
+ """Lightweight payload streamed periodically to keep gauges/clock live."""
+ return {
+ "t": time.time(),
+ "interaction": {
+ "seconds_since_user": engine.interaction_state.seconds_since_user(),
+ "seconds_since_assistant": engine.interaction_state.seconds_since_assistant(),
+ },
+ "lanes": build_lanes(engine),
+ "audio": _audio_state(engine),
+ "emotion": _emotion_state(engine),
+ "mcp": _mcp_summary(engine),
+ "speaking": bool(engine.currently_speaking_event.is_set()),
+ }
+
+
+def build_snapshot(engine: Any) -> dict[str, Any]:
+ """Aggregate snapshot for the console's initial paint (GET /api/snapshot)."""
+ vision_state = getattr(engine, "vision_state", None)
+ snapshot: dict[str, Any] = {
+ "t": time.time(),
+ "version": "0.1",
+ "autonomy_enabled": bool(getattr(engine, "autonomy_config", None) and engine.autonomy_config.enabled),
+ "lanes": build_lanes(engine),
+ "audio": _audio_state(engine),
+ "emotion": _emotion_state(engine),
+ "mcp": _mcp_summary(engine),
+ "interaction": {
+ "seconds_since_user": engine.interaction_state.seconds_since_user(),
+ "seconds_since_assistant": engine.interaction_state.seconds_since_assistant(),
+ },
+ "speaking": bool(engine.currently_speaking_event.is_set()),
+ "minds": [_safe(serialize_mind, m) for m in engine.mind_registry.snapshot()],
+ "slots": (
+ [_safe(serialize_slot, s) for s in engine.autonomy_slots.list_slots()]
+ if getattr(engine, "autonomy_slots", None)
+ else []
+ ),
+ "agents": (_safe_agents(engine) if getattr(engine, "subagent_manager", None) else []),
+ "vision": vision_state.snapshot() if vision_state else None,
+ }
+ return snapshot
+
+
+def _safe(serializer: Any, item: Any) -> dict[str, Any]:
+ """Serialize one item without allowing telemetry to break snapshots."""
+ try:
+ return cast(dict[str, Any], serializer(item))
+ except Exception: # pragma: no cover
+ return {}
+
+
+def _safe_agents(engine: Any) -> list[dict[str, Any]]:
+ """Return all subagent statuses, or an empty list if unavailable."""
+ try:
+ return [_safe(serialize_agent, a) for a in engine.subagent_manager.list_agents()]
+ except Exception: # pragma: no cover
+ return []
+
+
+__all__ = [
+ "build_snapshot",
+ "build_state",
+ "dumps",
+ "serialize_agent",
+ "serialize_event",
+ "serialize_memory_entry",
+ "serialize_mind",
+ "serialize_slot",
+ "serialize_slot_full",
+ "to_jsonable",
+]
diff --git a/src/glados/webapp/server.py b/src/glados/webapp/server.py
new file mode 100644
index 00000000..75f108ee
--- /dev/null
+++ b/src/glados/webapp/server.py
@@ -0,0 +1,393 @@
+"""In-process webapp observability console server.
+
+Runs inside the Glados engine process (mirroring the websocket-audio server
+pattern) so it can read live thread-safe state directly. A single stdlib
+:class:`http.server.ThreadingHTTPServer` serves the static console, a JSON
+snapshot API, and a Server-Sent-Events (SSE) stream that pushes observability
+events plus periodic state pings to every connected browser.
+
+Endpoints
+---------
+ GET / static console (``static/index.html``)
+ GET /api/snapshot aggregate JSON snapshot
+ GET /api/state lightweight state JSON
+ GET /api/stream SSE: "obs" events + "state" pings
+ GET /api/minds registered mind statuses
+ GET /api/minds/{id} single mind status
+ GET /api/minds/{id}/memory that agent's jsonlines memory entries
+ GET /api/slots task slots (summary fields)
+ GET /api/slots/{id} full slot incl. on-demand report
+ GET /api/agents registered subagent statuses
+"""
+# The HTTP boundary intentionally accepts the live engine and its optional
+# components structurally; serializers isolate those dynamic reads.
+# ruff: noqa: ANN401
+
+from __future__ import annotations
+
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+import threading
+from typing import Any, cast
+from urllib.parse import unquote, urlparse
+
+from loguru import logger
+
+from .serializers import (
+ build_snapshot,
+ build_state,
+ dumps,
+ serialize_agent,
+ serialize_event,
+ serialize_memory_entry,
+ serialize_mind,
+ serialize_slot,
+ serialize_slot_full,
+)
+
+STATIC_DIR = Path(__file__).resolve().parent / "static"
+
+
+def _serialize_or(serializer: Any, item: Any) -> dict[str, Any]:
+ """Serialize one API item, failing closed to an empty object."""
+ try:
+ return cast(dict[str, Any], serializer(item))
+ except Exception: # pragma: no cover
+ return {}
+
+
+# --------------------------------------------------------------------------- helpers
+
+
+def _content_type(name: str) -> str:
+ """Map a static asset suffix to its HTTP content type."""
+ return {
+ ".html": "text/html; charset=utf-8",
+ ".js": "application/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".ico": "image/x-icon",
+ ".wasm": "application/wasm",
+ }.get(Path(name).suffix.lower(), "application/octet-stream")
+
+
+def _find_mind(engine: Any, mind_id: str) -> Any | None:
+ """Find a mind status by identifier in a registry snapshot."""
+ for mind in engine.mind_registry.snapshot():
+ if mind.mind_id == mind_id:
+ return mind
+ return None
+
+
+def _find_store(engine: Any) -> Any | None:
+ """Return the optional task-slot store."""
+ return getattr(engine, "autonomy_slots", None)
+
+
+def _agent_manager(engine: Any) -> Any | None:
+ """Return the optional subagent manager."""
+ return getattr(engine, "subagent_manager", None)
+
+
+class _EngineHTTPServer(ThreadingHTTPServer):
+ """Threading server carrying the live engine reference to handlers."""
+
+ daemon_threads = True
+
+ def __init__(self, address: tuple[str, int], engine: Any) -> None:
+ """Attach the live engine to a standard threaded HTTP server."""
+ self.engine = engine
+ super().__init__(address, _Handler)
+
+
+class _Handler(BaseHTTPRequestHandler):
+ """Stateless handler; reads the engine reference from ``self.server.engine``."""
+
+ protocol_version = "HTTP/1.1"
+
+ # ------------------------------------------------------------ utilities
+ def _path(self) -> str:
+ """Return the decoded request path without its query string."""
+ return urlparse(self.path).path
+
+ def _json(self, code: int, payload: Any) -> None:
+ """Send one length-delimited JSON response."""
+ body = dumps(payload).encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _text(self, code: int, text: str) -> None:
+ """Send one UTF-8 plain-text response."""
+ body = text.encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _file(self, name: str) -> None:
+ """Serve a file confined to the packaged static directory."""
+ target = (STATIC_DIR / name).resolve()
+ if not target.is_relative_to(STATIC_DIR.resolve()) or not target.is_file():
+ return self._text(404, "Not found")
+ body = target.read_bytes()
+ self.send_response(200)
+ self.send_header("Content-Type", _content_type(name))
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, fmt: str, *args: Any) -> None:
+ """Route request logging through Loguru."""
+ logger.debug("[webapp] {} {}", self.address_string(), fmt % args)
+
+ # --------------------------------------------------------------- do_GET
+ def do_GET(self) -> None: # noqa: N802 - required by BaseHTTPRequestHandler
+ """Serve the console, its assets, or a read-only API endpoint."""
+ if not self._same_origin():
+ return self._json(403, {"error": "cross-origin requests are not allowed"})
+ path = self._path()
+ if path in ("/", "/index.html"):
+ return self._file("index.html")
+ if path.startswith("/static/"):
+ return self._file(path[len("/static/") :])
+ if path.startswith("/api/"):
+ try:
+ return self._route_api(path)
+ except (BrokenPipeError, ConnectionResetError): # pragma: no cover
+ return
+ except OSError: # pragma: no cover
+ return
+ except Exception: # pragma: no cover - keep request failures isolated
+ logger.exception("webapp API request failed")
+ return self._json(500, {"error": "internal server error"})
+ return self._text(404, "Not found")
+
+ def _same_origin(self) -> bool:
+ """Allow direct clients and same-origin browsers, never cross-origin pages."""
+ request_host = self.headers.get("Host", "")
+ hostname = urlparse(f"//{request_host}").hostname
+ if hostname not in {"127.0.0.1", "localhost"}:
+ return False
+ origin = self.headers.get("Origin")
+ if origin is None:
+ return True
+ parsed = urlparse(origin)
+ return parsed.scheme in {"http", "https"} and parsed.netloc.lower() == request_host.lower()
+
+ # -------------------------------------------------------------- routing
+ def _route_api(self, path: str) -> None:
+ """Dispatch a read-only API path."""
+ engine = cast(_EngineHTTPServer, self.server).engine
+ if path == "/api/stream":
+ return self._stream(engine)
+ if path == "/api/snapshot":
+ return self._json(200, build_snapshot(engine))
+ if path == "/api/state":
+ return self._json(200, build_state(engine))
+ if path == "/api/minds":
+ minds = [_serialize_or(serialize_mind, m) for m in engine.mind_registry.snapshot()]
+ return self._json(200, {"minds": minds})
+ if path == "/api/agents":
+ return self._json(200, {"agents": self._agent_list(engine)})
+ if path == "/api/slots":
+ slots = [_serialize_or(serialize_slot, s) for s in self._slots(engine)]
+ return self._json(200, {"slots": slots})
+
+ mind_rest = _sub_path(path, "/api/minds/")
+ if mind_rest is not None:
+ parts = mind_rest.split("/", 1)
+ mind_id = unquote(parts[0])
+ sub = parts[1] if len(parts) > 1 else ""
+ if sub == "memory":
+ entries = self._memory(engine, mind_id)
+ if entries is None:
+ return self._json(404, {"error": "No subagent memory for that mind"})
+ return self._json(200, {"agent_id": mind_id, "memory": entries})
+ if sub:
+ return self._json(404, {"error": "Unknown sub-path"})
+ mind = _find_mind(engine, mind_id)
+ if mind is None:
+ return self._json(404, {"error": "mind not found"})
+ return self._json(200, serialize_mind(mind))
+
+ slot_id = _sub_path(path, "/api/slots/")
+ if slot_id is not None:
+ store = _find_store(engine)
+ slot = store.get_slot(unquote(slot_id)) if store is not None else None
+ if slot is None:
+ return self._json(404, {"error": "slot not found"})
+ return self._json(200, serialize_slot_full(slot))
+
+ self._json(404, {"error": "not found"})
+
+ # --------------------------------------------------------------- SSE
+ def _stream(self, engine: Any) -> None:
+ """SSE stream: replay history then push live events + periodic state.
+
+ Uses a private :meth:`ObservabilityBus.subscribe` queue so each browser
+ gets its own copy of the stream instead of competing over the TUI's
+ single-consumer ``drain()`` queue.
+ """
+ import queue as _queue
+
+ bus = engine.observability_bus
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
+ self.send_header("Cache-Control", "no-cache")
+ self.send_header("Connection", "keep-alive")
+ self.end_headers()
+
+ sub = bus.subscribe()
+ try:
+ # Subscribe before taking history so an event cannot land in the
+ # gap between replay and live delivery. Skip the same event object
+ # if it appears in both history and the private queue.
+ history = bus.snapshot(limit=100)[-100:]
+ replayed = {id(event) for event in history}
+ for event in history:
+ self._send_sse("obs", serialize_event(event))
+ import time as _time
+ last_state_time = _time.time()
+ while not engine.shutdown_event.is_set():
+ try:
+ event = sub.get(timeout=0.5)
+ except _queue.Empty:
+ self._send_sse("state", build_state(engine))
+ last_state_time = _time.time()
+ continue
+ if id(event) in replayed:
+ replayed.remove(id(event))
+ continue
+ self._send_sse("obs", serialize_event(event))
+ # Emit state frame if at least 0.5 seconds have elapsed
+ now = _time.time()
+ if now - last_state_time >= 0.5:
+ self._send_sse("state", build_state(engine))
+ last_state_time = now
+ except (BrokenPipeError, ConnectionResetError, OSError): # pragma: no cover
+ pass
+ except Exception: # pragma: no cover - stream already has HTTP headers
+ logger.exception("webapp event stream failed")
+ finally:
+ bus.unsubscribe(sub)
+
+ def _send_sse(self, event_type: str, payload: Any) -> None:
+ """Write and flush one Server-Sent Event frame."""
+ data = dumps(payload)
+ frame = (f"event: {event_type}\ndata: {data}\n\n").encode()
+ self.wfile.write(frame)
+ self.wfile.flush()
+
+ # -------------------------------------------------------------- helpers
+ def _slots(self, engine: Any) -> list[Any]:
+ """Return task slots without propagating telemetry read failures."""
+ store = _find_store(engine)
+ if store is None:
+ return []
+ try:
+ return cast(list[Any], store.list_slots())
+ except Exception: # pragma: no cover
+ return []
+
+ def _agent_list(self, engine: Any) -> list[dict[str, Any]]:
+ """Return serialized subagent statuses."""
+ manager = _agent_manager(engine)
+ if manager is None:
+ return []
+ try:
+ return [_serialize_or(serialize_agent, a) for a in manager.list_agents()]
+ except Exception: # pragma: no cover
+ return []
+
+ def _memory(self, engine: Any, agent_id: str) -> list[dict[str, Any]] | None:
+ """Return serialized private memory for a known subagent."""
+ manager = _agent_manager(engine)
+ if manager is None:
+ return None
+ try:
+ subagent = manager.get(agent_id)
+ except Exception: # pragma: no cover
+ return None
+ if subagent is None:
+ return None
+ try:
+ entries = subagent.memory.list_all()
+ except Exception: # pragma: no cover
+ return None
+ return [_serialize_or(serialize_memory_entry, e) for e in entries]
+
+
+def _sub_path(path: str, prefix: str) -> str | None:
+ """Extract a non-empty, non-directory suffix beneath an API prefix."""
+ if not path.startswith(prefix):
+ return None
+ rest = path[len(prefix) :]
+ if not rest or rest.endswith("/"):
+ return None
+ return rest
+
+
+class WebappServer:
+ """Lifecycle wrapper: start/stop the console server on a background thread."""
+
+ def __init__(self, engine: Any, host: str = "127.0.0.1", port: int = 8050) -> None:
+ """Configure a loopback-only server without binding it yet."""
+ if host not in {"127.0.0.1", "localhost"}:
+ raise ValueError("The unauthenticated webapp console is loopback-only")
+ self.engine = engine
+ self.host = host
+ self.port = port
+ self._server: _EngineHTTPServer | None = None
+ self._thread: threading.Thread | None = None
+ self.bound_port: int | None = None
+
+ def start(self) -> None:
+ """Bind the socket and start serving on a daemon thread."""
+ if self._server is not None:
+ return
+ try:
+ self._server = _EngineHTTPServer((self.host, self.port), self.engine)
+ self.bound_port = self._server.server_address[1]
+ except OSError as exc:
+ logger.error("webapp: failed to bind {}:{} - {}", self.host, self.port, exc)
+ self._server = None
+ return
+ self._thread = threading.Thread(
+ target=self._server.serve_forever,
+ name="GladosWebappServer",
+ daemon=True,
+ )
+ self._thread.start()
+ logger.success("Webapp console live: http://{}:{}/", self.host, self.bound_port)
+
+ def shutdown(self) -> None:
+ """Stop serving, close the socket, and join the server thread."""
+ server = self._server
+ if server is not None:
+ server.shutdown()
+ server.server_close()
+ self._server = None
+ if self._thread is not None and self._thread.is_alive():
+ self._thread.join(timeout=2.0)
+ self._thread = None
+ self.bound_port = None
+
+ @property
+ def url(self) -> str:
+ """Return the effective console URL, including an ephemeral port."""
+ port = self.bound_port or self.port
+ return f"http://{self.host}:{port}/"
+
+ @property
+ def is_running(self) -> bool:
+ """Report whether a server socket is currently active."""
+ return self._server is not None
+
+
+__all__ = ["WebappServer"]
diff --git a/src/glados/webapp/static/index.html b/src/glados/webapp/static/index.html
new file mode 100644
index 00000000..041ec7ae
--- /dev/null
+++ b/src/glados/webapp/static/index.html
@@ -0,0 +1,706 @@
+
+
+
+
+
+GLaDOS Core Console
+
+
+
+
+
+
+
+
+
GLaDOS
CORE CONSOLE · OBSERVATION DECK
+
+ UP 03:12:48
+ --:--:--
+ SYSTEMS NOMINAL
+
+
+
+
+
+
+
+
+
+
Mission Control
+ watch the brain think, in parallel.
+
autonomy pool 2/4in-flight 0
+
+
+
0
Slots tracked
+
0
Active minds
+
0
Inferences in-flight
+
+
+
+
Dual Lane Orchestration
+
+
+
+
+
Priority Lane
direct user agent · 1 lane
+
0 queued
+
+
+
+
+
+
+
Autonomy Lane
pooled brains · 2 workers
+
0 in-flight
+
+
+
+
+
+
+
Brainstem PAD + interaction
+
+
Pleasure
0.0
+
Arousal
0.0
+
Dominance
0.0
+
+
+
●Last user ----
+
●Last spoke ----
+
+
+
+
+
The Wire · live stream
+
+
+
+
+
+
+
Neural Core
all parallel inferences, in-flight and queued.
--
+
+
+
Priority Lane
the direct user agent · always first
--
+
+
+
+
Autonomy Lane
pool of Minds · up to 12 workers
--
+
+
+
+
+
Worker pool autonomy_parallel_calls = 2
+
+
+
+
+
+
+
Minds
independent subagents, each with a private context.