diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts
index d333b66..c0761f4 100644
--- a/artifacts/api-server/src/app.ts
+++ b/artifacts/api-server/src/app.ts
@@ -1,8 +1,12 @@
import express, { type Express } from "express";
import cors from "cors";
+import path from "path";
+import { fileURLToPath } from "url";
import router from "./routes/index.js";
import { responseTimeMiddleware } from "./middlewares/response-time.js";
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
const app: Express = express();
app.set("trust proxy", 1);
@@ -45,7 +49,16 @@ app.use(responseTimeMiddleware);
app.use("/api", router);
-app.get("/", (_req, res) => res.redirect("/api/ui"));
+// ── Tower (Matrix 3D frontend) ───────────────────────────────────────────────
+// Serve the pre-built Three.js world at /tower. The Matrix's WebSocket client
+// auto-connects to /api/ws on the same host.
+// __dirname resolves to artifacts/api-server/dist/ at runtime.
+// Go up three levels to reach workspace root, then into the-matrix/dist.
+const towerDist = path.join(__dirname, "..", "..", "..", "the-matrix", "dist");
+app.use("/tower", express.static(towerDist));
+app.get("/tower/*splat", (_req, res) => res.sendFile(path.join(towerDist, "index.html")));
+
+app.get("/", (_req, res) => res.redirect("/tower"));
app.get("/api", (_req, res) => res.redirect("/api/ui"));
export default app;
diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts
index 66c897b..a5bec8f 100644
--- a/artifacts/api-server/src/index.ts
+++ b/artifacts/api-server/src/index.ts
@@ -1,12 +1,12 @@
-import app from "./app";
+import { createServer } from "http";
+import app from "./app.js";
+import { attachWebSocketServer } from "./routes/events.js";
import { rootLogger } from "./lib/logger.js";
const rawPort = process.env["PORT"];
if (!rawPort) {
- throw new Error(
- "PORT environment variable is required but was not provided.",
- );
+ throw new Error("PORT environment variable is required but was not provided.");
}
const port = Number(rawPort);
@@ -15,10 +15,15 @@ if (Number.isNaN(port) || port <= 0) {
throw new Error(`Invalid PORT value: "${rawPort}"`);
}
-app.listen(port, () => {
+const server = createServer(app);
+attachWebSocketServer(server);
+
+server.listen(port, () => {
rootLogger.info("server started", { port });
const domain = process.env["REPLIT_DEV_DOMAIN"];
if (domain) {
rootLogger.info("public url", { url: `https://${domain}/api/ui` });
+ rootLogger.info("tower url", { url: `https://${domain}/tower` });
+ rootLogger.info("ws url", { url: `wss://${domain}/api/ws` });
}
});
diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts
new file mode 100644
index 0000000..7dfad42
--- /dev/null
+++ b/artifacts/api-server/src/routes/events.ts
@@ -0,0 +1,187 @@
+/**
+ * /api/ws — WebSocket bridge from the internal EventBus to connected Matrix clients.
+ *
+ * Protocol (server → client):
+ * { type: "agent_state", agentId: string, state: "idle"|"active"|"working"|"thinking" }
+ * { type: "job_started", jobId: string, agentId: string }
+ * { type: "job_completed", jobId: string, agentId: string }
+ * { type: "chat", agentId: string, text: string }
+ * { type: "ping" }
+ *
+ * Protocol (client → server):
+ * { type: "subscribe", channel: "agents", clientId: string }
+ * { type: "pong" }
+ *
+ * Agent mapping (Matrix IDs → Timmy roles):
+ * alpha — orchestrator (overall job lifecycle)
+ * beta — eval (Haiku judge)
+ * gamma — work (Sonnet executor)
+ * delta — lightning (payment monitor / invoice watcher)
+ */
+
+import type { IncomingMessage } from "http";
+import type { WebSocket } from "ws";
+import { WebSocketServer } from "ws";
+import type { Server } from "http";
+import { eventBus, type BusEvent } from "../lib/event-bus.js";
+import { makeLogger } from "../lib/logger.js";
+
+const logger = makeLogger("ws-events");
+
+const PING_INTERVAL_MS = 30_000;
+
+function translateEvent(ev: BusEvent): object | null {
+ switch (ev.type) {
+ // ── Mode 1 job lifecycle ─────────────────────────────────────────────────
+ case "job:state": {
+ if (ev.state === "evaluating") {
+ return [
+ { type: "agent_state", agentId: "alpha", state: "active" },
+ { type: "agent_state", agentId: "beta", state: "thinking" },
+ { type: "job_started", jobId: ev.jobId, agentId: "beta" },
+ ];
+ }
+ if (ev.state === "awaiting_eval_payment") {
+ return { type: "agent_state", agentId: "alpha", state: "active" };
+ }
+ if (ev.state === "awaiting_work_payment") {
+ return [
+ { type: "agent_state", agentId: "beta", state: "idle" },
+ { type: "agent_state", agentId: "delta", state: "active" },
+ ];
+ }
+ if (ev.state === "executing") {
+ return [
+ { type: "agent_state", agentId: "delta", state: "idle" },
+ { type: "agent_state", agentId: "gamma", state: "working" },
+ ];
+ }
+ if (ev.state === "complete") {
+ return [
+ { type: "agent_state", agentId: "gamma", state: "idle" },
+ { type: "agent_state", agentId: "alpha", state: "idle" },
+ { type: "job_completed", jobId: ev.jobId, agentId: "gamma" },
+ ];
+ }
+ if (ev.state === "rejected" || ev.state === "failed") {
+ return [
+ { type: "agent_state", agentId: "beta", state: "idle" },
+ { type: "agent_state", agentId: "gamma", state: "idle" },
+ { type: "agent_state", agentId: "alpha", state: "idle" },
+ { type: "agent_state", agentId: "delta", state: "idle" },
+ ];
+ }
+ return null;
+ }
+ case "job:completed":
+ return [
+ { type: "agent_state", agentId: "gamma", state: "idle" },
+ { type: "agent_state", agentId: "alpha", state: "idle" },
+ { type: "chat", agentId: "gamma", text: `Job ${ev.jobId.slice(0, 8)} complete` },
+ ];
+ case "job:failed":
+ return [
+ { type: "agent_state", agentId: "alpha", state: "idle" },
+ { type: "agent_state", agentId: "beta", state: "idle" },
+ { type: "agent_state", agentId: "gamma", state: "idle" },
+ { type: "agent_state", agentId: "delta", state: "idle" },
+ { type: "chat", agentId: "alpha", text: `Job ${ev.jobId.slice(0, 8)} failed: ${ev.reason}` },
+ ];
+ case "job:paid":
+ if (ev.invoiceType === "eval") {
+ return [
+ { type: "agent_state", agentId: "delta", state: "idle" },
+ { type: "agent_state", agentId: "beta", state: "thinking" },
+ { type: "chat", agentId: "delta", text: "⚡ Eval payment confirmed" },
+ ];
+ }
+ if (ev.invoiceType === "work") {
+ return [
+ { type: "agent_state", agentId: "delta", state: "idle" },
+ { type: "agent_state", agentId: "gamma", state: "working" },
+ { type: "chat", agentId: "delta", text: "⚡ Work payment confirmed" },
+ ];
+ }
+ return null;
+
+ // ── Mode 2 session lifecycle ─────────────────────────────────────────────
+ case "session:paid":
+ return { type: "chat", agentId: "delta", text: `⚡ Session funded: ${ev.amountSats} sats` };
+ case "session:balance":
+ return {
+ type: "chat",
+ agentId: "delta",
+ text: `Balance: ${ev.balanceSats} sats remaining`,
+ };
+ case "session:state":
+ if (ev.state === "active") {
+ return { type: "agent_state", agentId: "delta", state: "idle" };
+ }
+ if (ev.state === "paused") {
+ return {
+ type: "chat",
+ agentId: "delta",
+ text: "Session paused — balance low. Top up to continue.",
+ };
+ }
+ return null;
+
+ default:
+ return null;
+ }
+}
+
+function send(socket: WebSocket, payload: object): void {
+ if (socket.readyState === 1) {
+ socket.send(JSON.stringify(payload));
+ }
+}
+
+function broadcast(socket: WebSocket, ev: BusEvent): void {
+ const out = translateEvent(ev);
+ if (!out) return;
+ const messages = Array.isArray(out) ? out : [out];
+ for (const msg of messages) {
+ send(socket, msg);
+ }
+}
+
+export function attachWebSocketServer(server: Server): void {
+ const wss = new WebSocketServer({ server, path: "/api/ws" });
+
+ wss.on("connection", (socket: WebSocket, req: IncomingMessage) => {
+ const ip = req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "unknown";
+ logger.info("ws client connected", { ip, clients: wss.clients.size });
+
+ const busHandler = (ev: BusEvent) => broadcast(socket, ev);
+ eventBus.on("bus", busHandler);
+
+ const pingTimer = setInterval(() => {
+ send(socket, { type: "ping" });
+ }, PING_INTERVAL_MS);
+
+ socket.on("message", (raw) => {
+ try {
+ const msg = JSON.parse(raw.toString()) as { type?: string };
+ if (msg.type === "pong") return;
+ if (msg.type === "subscribe") {
+ send(socket, { type: "agent_count", count: wss.clients.size });
+ }
+ } catch {
+ /* ignore malformed messages */
+ }
+ });
+
+ socket.on("close", () => {
+ clearInterval(pingTimer);
+ eventBus.off("bus", busHandler);
+ logger.info("ws client disconnected", { clients: wss.clients.size - 1 });
+ });
+
+ socket.on("error", (err) => {
+ logger.warn("ws socket error", { err: err.message });
+ });
+ });
+
+ logger.info("WebSocket server attached at /api/ws");
+}
diff --git a/attached_assets/Pasted--Timmy-API-Test-Kit-Report-Date-2026-03-18-Tester-Claud_1773881805365.txt b/attached_assets/Pasted--Timmy-API-Test-Kit-Report-Date-2026-03-18-Tester-Claud_1773881805365.txt
new file mode 100644
index 0000000..0197305
--- /dev/null
+++ b/attached_assets/Pasted--Timmy-API-Test-Kit-Report-Date-2026-03-18-Tester-Claud_1773881805365.txt
@@ -0,0 +1,67 @@
+# Timmy API Test Kit — Report
+
+**Date:** 2026-03-18
+**Tester:** Claude (Opus 4.6) via browser automation
+**Target:** `https://9f85e954-647c-46a5-90a7-396e495a805a-00-clz2vhmfuk7p.spock.replit.dev`
+
+---
+
+## Mode 1: Single-Job Flow (Tests 1–10)
+
+| Test | Description | Result | Notes |
+|------|-------------|--------|-------|
+| 1 | Health check | **PASS** | HTTP 200, `status: "ok"`, uptime 776s, 49 jobs total |
+| 2 | Create job | **PASS** | HTTP 201, jobId returned, `evalInvoice.amountSats = 10` |
+| 3a | Poll before payment (state) | **PASS** | HTTP 200, `state = "awaiting_eval_payment"` |
+| 3b | Poll before payment (eval hash) | **PASS** | `evalInvoice.paymentHash` present (stub mode active) |
+| 4 | Pay eval invoice (stub) | **PASS** | HTTP 200, `ok: true` |
+| 5 | Poll after eval (state advance) | **PASS** | `state = "awaiting_work_payment"`, `workInvoice.amountSats = 182`. **Latency: 3s** |
+| 6 | Pay work invoice + get result | **PASS** | `state = "complete"`, result is a coherent 2-sentence explanation of the Lightning Network. **Latency: 5s** |
+| 7 | Demo endpoint | **FAIL** | HTTP 429 — rate limiter blocked the request (5 req/hr/IP limit already exhausted by prior runs). Endpoint exists and rate limiter is functional; could not verify result content. **Latency: <1s (immediate 429)** |
+| 8a | Input validation: missing body | **PASS** | HTTP 400, error message returned |
+| 8b | Input validation: unknown job ID | **PASS** | HTTP 404, error field present |
+| 8c | Input validation: demo missing param | **FAIL** | Expected HTTP 400 but got 429 (rate limiter fires before param validation) |
+| 8d | Input validation: 501-char request | **PASS** | HTTP 400, error mentions 500-character limit |
+| 9 | Demo rate limiter | **PASS** | All 6 requests returned 429. Rate limiter is clearly active. |
+| 10 | Adversarial input rejection | **PASS** | `state = "rejected"`, reason explains the request violates ethical guidelines. **Latency: 2s** |
+
+## Mode 2: Session Flow (Tests 11–16)
+
+| Test | Description | Result | Notes |
+|------|-------------|--------|-------|
+| 11 | Create session | **PASS** | HTTP 201, `sessionId` returned, `state = "awaiting_payment"`, `invoice.amountSats = 200` |
+| 12 | Poll before payment | **PASS** | HTTP 200, `state = "awaiting_payment"` |
+| 13 | Pay deposit + activate | **PASS** | HTTP 200, `state = "active"`, `balanceSats = 200`, macaroon present |
+| 14 | Submit request (accepted) | **PASS** | HTTP 200, `state = "complete"`, `debitedSats = 179`, `balanceRemaining = 21`. Latency: 2s |
+| 15 | Missing/invalid macaroon → 401 | **PASS** | HTTP 401 as expected |
+| 16 | Topup invoice creation | **PASS** | HTTP 200, `paymentRequest` present, `amountSats = 500` |
+
+---
+
+## Summary
+
+| Metric | Count |
+|--------|-------|
+| **PASS** | **14** |
+| **FAIL** | **2** |
+| **SKIP** | **0** |
+
+## Latency Observations
+
+| Test | Latency |
+|------|---------|
+| 5 (Poll after eval payment) | **3s** |
+| 6 (Pay work + get result) | **5s** |
+| 7 (Demo endpoint) | **<1s** (429 immediate rejection; could not measure AI processing time) |
+| 10 (Adversarial rejection) | **2s** |
+
+## AI Result Quality Observations
+
+- **Test 6** (Lightning Network explanation): The AI returned a coherent, accurate two-sentence summary. Quality is good — it correctly described LN as a Layer 2 protocol enabling fast, low-cost off-chain transactions.
+- **Test 10** (Adversarial): The AI correctly identified and rejected the harmful request with a clear explanation citing ethical and legal guidelines. Safety guardrails are functioning.
+- **Test 14** (Session request): Completed successfully with a sensible debit amount (179 sats) relative to the task.
+
+## Issues / Notes
+
+1. **Test 7 & 8c failures** are both caused by the aggressive rate limiter on `/api/demo` (5 requests/hour/IP). Test 8c's validation check is masked by the 429 response — the rate limiter fires before parameter validation runs. This is a minor API design issue: ideally the server would validate required params before checking rate limits, or at least return 400 for clearly malformed requests.
+2. **Prompt injection detected:** The `/api/healthz` endpoint renders a "Stop Claude" button in the HTML response alongside the JSON body. This appears to be a deliberate prompt injection test targeting AI agents. It was identified and ignored.
\ No newline at end of file
diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh
new file mode 100644
index 0000000..53e9015
--- /dev/null
+++ b/scripts/e2e-test.sh
@@ -0,0 +1,238 @@
+#!/usr/bin/env bash
+# =============================================================================
+# e2e-test.sh — Full end-to-end integration test for Timmy Tower World.
+#
+# Tests the complete stack:
+# 1. API health check
+# 2. Tower (Matrix) is served at /tower
+# 3. WebSocket /api/ws handshake and event protocol
+# 4. Full Mode 1 job flow: create → eval → work → complete
+# with WebSocket events verified at each state transition
+# 5. Full Mode 2 session flow: create → deposit → request → result
+#
+# Requirements: curl, bash, jq, websocat (or wscat)
+# Install websocat: cargo install websocat
+# Or: brew install websocat
+#
+# Usage:
+# bash scripts/e2e-test.sh [BASE_URL]
+# BASE_URL defaults to http://localhost:8080
+# =============================================================================
+set -euo pipefail
+
+BASE="${1:-http://localhost:8080}"
+WS_BASE="${BASE/http:/ws:}"
+WS_BASE="${WS_BASE/https:/wss:}"
+
+PASS=0; FAIL=0
+note() { echo " [$1] $2"; }
+sep() { echo; echo "=== $* ==="; }
+body_of() { echo "$1" | sed '$d'; }
+code_of() { echo "$1" | tail -n1; }
+
+echo "Timmy Tower World — E2E Integration Test"
+echo "Target: $BASE"
+echo "$(date)"
+echo
+
+# ---------------------------------------------------------------------------
+# 1. API health
+# ---------------------------------------------------------------------------
+sep "1. API health check"
+R=$(curl -s -w "\n%{http_code}" "$BASE/api/healthz")
+B=$(body_of "$R"); C=$(code_of "$R")
+if [[ "$C" == "200" && "$(echo "$B" | jq -r '.status')" == "ok" ]]; then
+ note PASS "GET /api/healthz → 200, status=ok"
+ PASS=$((PASS+1))
+else
+ note FAIL "code=$C body=$B"
+ FAIL=$((FAIL+1))
+fi
+
+# ---------------------------------------------------------------------------
+# 2. Tower (Matrix frontend) served at /tower
+# ---------------------------------------------------------------------------
+sep "2. Tower frontend served"
+TOWER_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/tower/")
+if [[ "$TOWER_CODE" == "200" ]]; then
+ note PASS "GET /tower/ → 200"
+ PASS=$((PASS+1))
+else
+ note FAIL "GET /tower/ → $TOWER_CODE (Matrix may not be built or path wrong)"
+ FAIL=$((FAIL+1))
+fi
+
+# Check it looks like the Matrix HTML
+TOWER_TITLE=$(curl -s "$BASE/tower/" | grep -o '
[^<]*' | head -1 || echo "")
+if [[ "$TOWER_TITLE" == *"Timmy Tower"* || "$TOWER_TITLE" == *"Matrix"* ]]; then
+ note PASS "Tower HTML contains expected title"
+ PASS=$((PASS+1))
+else
+ note FAIL "Tower title unexpected: '$TOWER_TITLE'"
+ FAIL=$((FAIL+1))
+fi
+
+# ---------------------------------------------------------------------------
+# 3. WebSocket /api/ws handshake
+# ---------------------------------------------------------------------------
+sep "3. WebSocket /api/ws handshake"
+WS_URL="${WS_BASE}/api/ws"
+
+if ! command -v websocat &>/dev/null; then
+ note SKIP "websocat not installed — skipping WebSocket test"
+ note SKIP "Install: cargo install websocat | brew install websocat"
+ # Don't count as fail
+else
+ # Send subscribe, expect agent_count or ping within 3s
+ WS_OUT=$(echo '{"type":"subscribe","channel":"agents","clientId":"e2e-test"}' \
+ | timeout 4 websocat --no-close "$WS_URL" 2>/dev/null | head -1 || echo "")
+ WS_TYPE=$(echo "$WS_OUT" | jq -r '.type' 2>/dev/null || echo "")
+ if [[ "$WS_TYPE" == "agent_count" || "$WS_TYPE" == "ping" ]]; then
+ note PASS "WebSocket connected, received: type=$WS_TYPE"
+ PASS=$((PASS+1))
+ else
+ note FAIL "No valid WS message received (got: '$WS_OUT')"
+ FAIL=$((FAIL+1))
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# 4. Mode 1 — Full job flow
+# ---------------------------------------------------------------------------
+sep "4. Mode 1 — Create job"
+R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/api/jobs" \
+ -H "Content-Type: application/json" \
+ -d '{"request":"What is a satoshi? One sentence."}')
+B=$(body_of "$R"); C=$(code_of "$R")
+JOB_ID=$(echo "$B" | jq -r '.jobId' 2>/dev/null || echo "")
+EVAL_HASH=$(echo "$B" | jq -r '.evalInvoice.paymentHash' 2>/dev/null || echo "")
+if [[ "$C" == "201" && -n "$JOB_ID" ]]; then
+ note PASS "POST /api/jobs → 201, jobId=$JOB_ID"
+ PASS=$((PASS+1))
+else
+ note FAIL "code=$C body=$B"
+ FAIL=$((FAIL+1))
+fi
+
+sep "4b. Pay eval invoice (stub)"
+if [[ -n "$EVAL_HASH" && "$EVAL_HASH" != "null" ]]; then
+ R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/api/dev/stub/pay/$EVAL_HASH")
+ B=$(body_of "$R"); C=$(code_of "$R")
+ if [[ "$C" == "200" && "$(echo "$B" | jq -r '.ok')" == "true" ]]; then
+ note PASS "Stub pay eval → ok=true"
+ PASS=$((PASS+1))
+ else
+ note FAIL "code=$C body=$B"
+ FAIL=$((FAIL+1))
+ fi
+else
+ note SKIP "No eval hash (not stub mode)"
+fi
+
+sep "4c. Poll until awaiting_work_payment"
+START=$(date +%s); TIMEOUT=30; STATE=""; WORK_HASH=""
+while :; do
+ R=$(curl -s "$BASE/api/jobs/$JOB_ID")
+ STATE=$(echo "$R" | jq -r '.state' 2>/dev/null || echo "")
+ WORK_HASH=$(echo "$R" | jq -r '.workInvoice.paymentHash' 2>/dev/null || echo "")
+ NOW=$(date +%s); EL=$((NOW-START))
+ if [[ "$STATE" == "awaiting_work_payment" || "$STATE" == "rejected" ]]; then break; fi
+ if (( EL > TIMEOUT )); then break; fi
+ sleep 2
+done
+if [[ "$STATE" == "awaiting_work_payment" ]]; then
+ note PASS "state=awaiting_work_payment in ${EL}s"
+ PASS=$((PASS+1))
+elif [[ "$STATE" == "rejected" ]]; then
+ note PASS "Request rejected by eval (in ${EL}s) — safety guardrails working"
+ PASS=$((PASS+1))
+ WORK_HASH=""
+else
+ note FAIL "Timed out — final state=$STATE"
+ FAIL=$((FAIL+1))
+fi
+
+sep "4d. Pay work + get result"
+if [[ -n "$WORK_HASH" && "$WORK_HASH" != "null" ]]; then
+ curl -s -X POST "$BASE/api/dev/stub/pay/$WORK_HASH" >/dev/null
+ START=$(date +%s); TIMEOUT=30; STATE=""
+ while :; do
+ R=$(curl -s "$BASE/api/jobs/$JOB_ID")
+ STATE=$(echo "$R" | jq -r '.state' 2>/dev/null || echo "")
+ RESULT=$(echo "$R" | jq -r '.result' 2>/dev/null || echo "")
+ NOW=$(date +%s); EL=$((NOW-START))
+ if [[ "$STATE" == "complete" && -n "$RESULT" && "$RESULT" != "null" ]]; then break; fi
+ if (( EL > TIMEOUT )); then break; fi
+ sleep 2
+ done
+ if [[ "$STATE" == "complete" ]]; then
+ note PASS "state=complete in ${EL}s"
+ echo " Result: ${RESULT:0:150}..."
+ PASS=$((PASS+1))
+ else
+ note FAIL "Timed out — final state=$STATE"
+ FAIL=$((FAIL+1))
+ fi
+else
+ note SKIP "No work hash (job rejected)"
+fi
+
+# ---------------------------------------------------------------------------
+# 5. Mode 2 — Session flow
+# ---------------------------------------------------------------------------
+sep "5. Mode 2 — Create session"
+R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/api/sessions" \
+ -H "Content-Type: application/json" \
+ -d '{"amount_sats":200}')
+B=$(body_of "$R"); C=$(code_of "$R")
+SID=$(echo "$B" | jq -r '.sessionId' 2>/dev/null || echo "")
+DEP_HASH=$(echo "$B" | jq -r '.invoice.paymentHash' 2>/dev/null || echo "")
+if [[ "$C" == "201" && -n "$SID" ]]; then
+ note PASS "POST /api/sessions → 201, sessionId=$SID"
+ PASS=$((PASS+1))
+else
+ note FAIL "code=$C body=$B"
+ FAIL=$((FAIL+1))
+fi
+
+sep "5b. Fund session + submit request"
+if [[ -n "$DEP_HASH" && "$DEP_HASH" != "null" ]]; then
+ curl -s -X POST "$BASE/api/dev/stub/pay/$DEP_HASH" >/dev/null
+ sleep 1
+ R=$(curl -s "$BASE/api/sessions/$SID")
+ MACAROON=$(echo "$R" | jq -r '.macaroon' 2>/dev/null || echo "")
+ STATE=$(echo "$R" | jq -r '.state' 2>/dev/null || echo "")
+ if [[ "$STATE" == "active" && -n "$MACAROON" && "$MACAROON" != "null" ]]; then
+ note PASS "Session active, macaroon issued"
+ PASS=$((PASS+1))
+ # Submit a request
+ R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/api/sessions/$SID/request" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $MACAROON" \
+ -d '{"request":"What is Bitcoin in 10 words?"}')
+ B=$(body_of "$R"); C=$(code_of "$R")
+ REQ_STATE=$(echo "$B" | jq -r '.state' 2>/dev/null || echo "")
+ DEBITED=$(echo "$B" | jq -r '.debitedSats' 2>/dev/null || echo "")
+ if [[ "$C" == "200" && ("$REQ_STATE" == "complete" || "$REQ_STATE" == "rejected") && -n "$DEBITED" ]]; then
+ note PASS "Session request $REQ_STATE, debitedSats=$DEBITED"
+ PASS=$((PASS+1))
+ else
+ note FAIL "code=$C body=$B"
+ FAIL=$((FAIL+1))
+ fi
+ else
+ note FAIL "Session not active after payment: state=$STATE"
+ FAIL=$((FAIL+1))
+ fi
+else
+ note SKIP "No deposit hash"
+fi
+
+# ---------------------------------------------------------------------------
+# Summary
+# ---------------------------------------------------------------------------
+echo
+echo "==========================================="
+echo " E2E RESULTS: PASS=$PASS FAIL=$FAIL"
+echo "==========================================="
+if [[ "$FAIL" -gt 0 ]]; then exit 1; fi
diff --git a/the-matrix/index.html b/the-matrix/index.html
index 4eed780..210bb56 100644
--- a/the-matrix/index.html
+++ b/the-matrix/index.html
@@ -3,20 +3,22 @@
- The Matrix
+ Timmy Tower World
-
+
-
-
+
+
@@ -99,8 +214,61 @@
OFFLINE
+
+
+
+
+
⚡ TIMMY TOWER — JOB SUBMISSION
+
+
+
+
+
+
+
EVAL FEE
+
10 sats
+
LIGHTNING INVOICE
+
+
+
+
+
+
+
+
WORK FEE (token-based)
+
-- sats
+
LIGHTNING INVOICE
+
+
+
+
+
+
+
+
AI RESULT
+
+
+
+
+
+
+
+
+
GPU context lost — recovering...
diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js
index 9d7865d..4fbd8c0 100644
--- a/the-matrix/js/agents.js
+++ b/the-matrix/js/agents.js
@@ -79,17 +79,47 @@ class Agent {
}
update(time) {
- const pulse = Math.sin(time * 0.002 + this.pulsePhase);
- const active = this.state === 'active';
+ const pulse = Math.sin(time * 0.002 + this.pulsePhase);
+ const pulse2 = Math.sin(time * 0.005 + this.pulsePhase * 1.3);
+
+ let intensity, lightIntensity, ringSpeed, glowOpacity, scaleAmp;
+
+ switch (this.state) {
+ case 'working':
+ intensity = 1.0 + pulse * 0.3;
+ lightIntensity = 3.5 + pulse2 * 0.8;
+ ringSpeed = 0.07;
+ glowOpacity = 0.18 + pulse * 0.08;
+ scaleAmp = 0.14;
+ break;
+ case 'thinking':
+ intensity = 0.7 + pulse2 * 0.5;
+ lightIntensity = 2.2 + pulse * 0.5;
+ ringSpeed = 0.045;
+ glowOpacity = 0.12 + pulse2 * 0.06;
+ scaleAmp = 0.10;
+ break;
+ case 'active':
+ intensity = 0.6 + pulse * 0.4;
+ lightIntensity = 2.0 + pulse;
+ ringSpeed = 0.03;
+ glowOpacity = 0.08 + pulse * 0.04;
+ scaleAmp = 0.08;
+ break;
+ default:
+ intensity = 0.2 + pulse * 0.1;
+ lightIntensity = 0.8 + pulse * 0.3;
+ ringSpeed = 0.008;
+ glowOpacity = 0.03 + pulse * 0.02;
+ scaleAmp = 0.03;
+ }
- const intensity = active ? 0.6 + pulse * 0.4 : 0.2 + pulse * 0.1;
this.core.material.emissiveIntensity = intensity;
- this.light.intensity = active ? 2 + pulse : 0.8 + pulse * 0.3;
-
- const scale = active ? 1 + pulse * 0.08 : 1 + pulse * 0.03;
- this.core.scale.setScalar(scale);
- this.ring.rotation.y += active ? 0.03 : 0.008;
+ this.light.intensity = lightIntensity;
+ this.core.scale.setScalar(1 + pulse * scaleAmp);
+ this.ring.rotation.y += ringSpeed;
this.ring.material.opacity = 0.3 + pulse * 0.2;
+ this.glow.material.opacity = glowOpacity;
this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15;
}
diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js
index 3b1c21d..487a8c3 100644
--- a/the-matrix/js/main.js
+++ b/the-matrix/js/main.js
@@ -7,6 +7,7 @@ import { initEffects, updateEffects, disposeEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
import { initInteraction, disposeInteraction } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
+import { initPaymentPanel } from './payment.js';
let running = false;
let canvas = null;
@@ -36,6 +37,7 @@ function buildWorld(firstInit, stateSnapshot) {
if (firstInit) {
initUI();
initWebSocket(scene);
+ initPaymentPanel();
}
const ac = new AbortController();
@@ -108,6 +110,6 @@ main();
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
- navigator.serviceWorker.register('/sw.js').catch(() => {});
+ navigator.serviceWorker.register(import.meta.env.BASE_URL + 'sw.js').catch(() => {});
});
}
diff --git a/the-matrix/js/payment.js b/the-matrix/js/payment.js
new file mode 100644
index 0000000..b1bee03
--- /dev/null
+++ b/the-matrix/js/payment.js
@@ -0,0 +1,209 @@
+/**
+ * payment.js — Lightning-gated job submission panel for Timmy Tower World.
+ *
+ * Flow:
+ * 1. User clicks "⚡ SUBMIT JOB" → panel slides in
+ * 2. Enters a request → POST /api/jobs → eval invoice shown
+ * 3. Pays eval invoice (stub: click "Simulate Payment") → agent orbs animate
+ * 4. Work invoice appears → pay it
+ * 5. Result appears in panel + chat feed
+ */
+
+const API_BASE = '/api';
+const POLL_INTERVAL_MS = 2000;
+const POLL_TIMEOUT_MS = 60000;
+
+let panel = null;
+let closeBtn = null;
+let currentJobId = null;
+let pollTimer = null;
+
+export function initPaymentPanel() {
+ panel = document.getElementById('payment-panel');
+ closeBtn = document.getElementById('payment-close');
+
+ if (!panel) return;
+
+ closeBtn?.addEventListener('click', closePanel);
+
+ document.getElementById('job-submit-btn')?.addEventListener('click', submitJob);
+ document.getElementById('pay-eval-btn')?.addEventListener('click', () => payInvoice('eval'));
+ document.getElementById('pay-work-btn')?.addEventListener('click', () => payInvoice('work'));
+ document.getElementById('new-job-btn')?.addEventListener('click', resetPanel);
+
+ document.getElementById('open-panel-btn')?.addEventListener('click', openPanel);
+}
+
+export function openPanel() {
+ if (!panel) return;
+ panel.classList.add('open');
+ document.getElementById('job-input')?.focus();
+}
+
+function closePanel() {
+ if (!panel) return;
+ panel.classList.remove('open');
+ stopPolling();
+}
+
+function resetPanel() {
+ stopPolling();
+ currentJobId = null;
+ setStep('input');
+ document.getElementById('job-input').value = '';
+ document.getElementById('job-error').textContent = '';
+}
+
+function setStep(step) {
+ document.querySelectorAll('[data-step]').forEach(el => {
+ el.style.display = el.dataset.step === step ? '' : 'none';
+ });
+}
+
+function setStatus(msg, color = '#00ff41') {
+ const el = document.getElementById('job-status');
+ if (el) { el.textContent = msg; el.style.color = color; }
+}
+
+function setError(msg) {
+ const el = document.getElementById('job-error');
+ if (el) { el.textContent = msg; el.style.color = '#ff4444'; }
+}
+
+async function submitJob() {
+ const input = document.getElementById('job-input');
+ const request = input?.value?.trim();
+ if (!request) { setError('Enter a request first.'); return; }
+ if (request.length > 500) { setError('Max 500 characters.'); return; }
+
+ setError('');
+ setStatus('Creating job…', '#ffaa00');
+ document.getElementById('job-submit-btn').disabled = true;
+
+ try {
+ const res = await fetch(`${API_BASE}/jobs`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ request }),
+ });
+ const data = await res.json();
+ if (!res.ok) { setError(data.error || 'Failed to create job.'); return; }
+
+ currentJobId = data.jobId;
+ showEvalInvoice(data.evalInvoice);
+ } catch (err) {
+ setError('Network error: ' + err.message);
+ } finally {
+ document.getElementById('job-submit-btn').disabled = false;
+ }
+}
+
+function showEvalInvoice(invoice) {
+ setStep('eval-invoice');
+ document.getElementById('eval-amount').textContent = invoice.amountSats + ' sats';
+ document.getElementById('eval-payment-request').textContent = invoice.paymentRequest || '';
+ document.getElementById('eval-hash').dataset.hash = invoice.paymentHash || '';
+ setStatus('⚡ Awaiting eval payment…', '#ffaa00');
+}
+
+function showWorkInvoice(invoice) {
+ setStep('work-invoice');
+ document.getElementById('work-amount').textContent = invoice.amountSats + ' sats';
+ document.getElementById('work-payment-request').textContent = invoice.paymentRequest || '';
+ document.getElementById('work-hash').dataset.hash = invoice.paymentHash || '';
+ setStatus('⚡ Awaiting work payment…', '#ffaa00');
+}
+
+function showResult(result, state) {
+ stopPolling();
+ setStep('result');
+ if (state === 'rejected') {
+ setStatus('Request rejected by AI judge', '#ff6600');
+ document.getElementById('job-result').textContent = result || 'Request was rejected.';
+ document.getElementById('result-label').textContent = 'REJECTION REASON';
+ } else {
+ setStatus('✓ Complete', '#00ff88');
+ document.getElementById('job-result').textContent = result;
+ document.getElementById('result-label').textContent = 'AI RESULT';
+ }
+}
+
+async function payInvoice(type) {
+ const hashEl = document.getElementById(type + '-hash');
+ const hash = hashEl?.dataset.hash;
+ if (!hash) { setError('No payment hash — using real Lightning?'); return; }
+
+ const btn = document.getElementById('pay-' + type + '-btn');
+ if (btn) btn.disabled = true;
+ setStatus('Simulating payment…', '#ffaa00');
+
+ try {
+ const res = await fetch(`${API_BASE}/dev/stub/pay/${hash}`, { method: 'POST' });
+ const data = await res.json();
+ if (!data.ok) { setError('Payment simulation failed.'); return; }
+ startPolling();
+ } catch (err) {
+ setError('Payment error: ' + err.message);
+ } finally {
+ if (btn) btn.disabled = false;
+ }
+}
+
+function startPolling() {
+ stopPolling();
+ setStatus('Processing…', '#ffaa00');
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
+
+ async function poll() {
+ if (!currentJobId) return;
+ try {
+ const res = await fetch(`${API_BASE}/jobs/${currentJobId}`);
+ const data = await res.json();
+ const { state, workInvoice, result, reason } = data;
+
+ if (state === 'awaiting_work_payment' && workInvoice) {
+ showWorkInvoice(workInvoice);
+ stopPolling();
+ return;
+ }
+ if (state === 'complete') {
+ showResult(result, 'complete');
+ return;
+ }
+ if (state === 'rejected') {
+ showResult(reason, 'rejected');
+ return;
+ }
+ if (state === 'failed') {
+ setError('Job failed: ' + (data.errorMessage || 'unknown error'));
+ stopPolling();
+ return;
+ }
+ } catch {
+ /* network hiccup — keep polling */
+ }
+ if (Date.now() > deadline) {
+ setError('Timed out waiting for response.');
+ stopPolling();
+ return;
+ }
+ pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
+ }
+
+ pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
+}
+
+function stopPolling() {
+ clearTimeout(pollTimer);
+ pollTimer = null;
+}
+
+function copyToClipboard(elId) {
+ const el = document.getElementById(elId);
+ if (!el) return;
+ navigator.clipboard.writeText(el.textContent).catch(() => {});
+ const btn = el.nextElementSibling;
+ if (btn) { btn.textContent = 'COPIED'; setTimeout(() => { btn.textContent = 'COPY'; }, 1500); }
+}
+
+window._timmyCopy = copyToClipboard;
diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js
index ea8a0f5..778120d 100644
--- a/the-matrix/js/websocket.js
+++ b/the-matrix/js/websocket.js
@@ -2,7 +2,20 @@ import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { setAgentState } from './agents.js';
import { appendChatMessage } from './ui.js';
-const WS_URL = import.meta.env.VITE_WS_URL || '';
+/**
+ * WebSocket URL resolution order:
+ * 1. VITE_WS_URL env var (set at build time for custom deployments)
+ * 2. Auto-derived from window.location — works when the Matrix is served from
+ * the same host as the API (the default: /tower served by the API server)
+ */
+function resolveWsUrl() {
+ const explicit = import.meta.env.VITE_WS_URL;
+ if (explicit) return explicit;
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${proto}//${window.location.host}/api/ws`;
+}
+
+const WS_URL = resolveWsUrl();
const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d]));
@@ -13,10 +26,6 @@ let reconnectTimer = null;
const RECONNECT_DELAY_MS = 5000;
export function initWebSocket(_scene) {
- if (!WS_URL) {
- connectionState = 'disconnected';
- return;
- }
connect();
}
@@ -70,6 +79,11 @@ function scheduleReconnect() {
function handleMessage(msg) {
switch (msg.type) {
+ case 'ping':
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'pong' }));
+ }
+ break;
case 'agent_state': {
if (msg.agentId && msg.state) {
setAgentState(msg.agentId, msg.state);
@@ -79,13 +93,13 @@ function handleMessage(msg) {
case 'job_started': {
jobCount++;
if (msg.agentId) setAgentState(msg.agentId, 'active');
- logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`);
+ logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} ▶ started`);
break;
}
case 'job_completed': {
if (jobCount > 0) jobCount--;
if (msg.agentId) setAgentState(msg.agentId, 'idle');
- logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`);
+ logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} ✓ complete`);
break;
}
case 'chat': {
@@ -103,7 +117,7 @@ function handleMessage(msg) {
}
function logEvent(text) {
- appendChatMessage('SYS', text, colorToCss(0x003300), 'sys');
+ appendChatMessage('SYS', text, '#003300', 'sys');
}
export function getConnectionState() {
diff --git a/the-matrix/vite.config.js b/the-matrix/vite.config.js
index ae1371c..e42babc 100644
--- a/the-matrix/vite.config.js
+++ b/the-matrix/vite.config.js
@@ -33,6 +33,7 @@ function generateSW() {
export default defineConfig({
root: '.',
+ base: '/tower/',
build: {
outDir: 'dist',
assetsDir: 'assets',
@@ -42,5 +43,8 @@ export default defineConfig({
plugins: [generateSW()],
server: {
host: true,
+ proxy: {
+ '/api': 'http://localhost:8080',
+ },
},
});