@vibevibes/runtime 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,3738 @@
1
+ /**
2
+ * @vibevibes/runtime — the server engine for vibevibes experiences.
3
+ *
4
+ * Single-experience architecture with tool gate, WebSocket broadcasts, and room spawning.
5
+ *
6
+ * The server loads one experience from the project root (manifest.json for protocol
7
+ * experiences, or src/index.tsx for TypeScript experiences). Multiple rooms can be
8
+ * spawned, all running the same experience.
9
+ *
10
+ * AI agents join rooms via MCP or HTTP.
11
+ */
12
+
13
+ import express from "express";
14
+ import { WebSocketServer, WebSocket } from "ws";
15
+ import http from "http";
16
+ import path from "path";
17
+ import fs from "fs";
18
+ import { fileURLToPath } from "url";
19
+ import { z, ZodError } from "zod";
20
+ import { EventEmitter } from "events";
21
+ import { bundleForServer, bundleForClient, evalServerBundle, validateClientBundle } from "./bundler.js";
22
+ import { zodToJsonSchema } from "zod-to-json-schema";
23
+ import { TickEngine } from "./tick-engine.js";
24
+ import { isProtocolExperience, loadProtocolManifest, createProtocolModule, SubprocessExecutor } from "./protocol.js";
25
+ import type { ExperienceModule, ToolDef, StreamDef, ToolCtx, ParticipantSlot } from "@vibevibes/sdk";
26
+ import { createChatTools } from "@vibevibes/sdk";
27
+
28
+ // ── Server config ─────────────────────────────────────────────
29
+ export interface ServerConfig {
30
+ /** Absolute path to the experience project root (where manifest.json or src/index.tsx lives). */
31
+ projectRoot: string;
32
+ /** Port to listen on. Defaults to 4321 or PORT env var. */
33
+ port?: number;
34
+ }
35
+
36
+ // ── Error formatting ──────────────────────────────────────────
37
+
38
+ /** Subset of zodToJsonSchema output used for error formatting. */
39
+ interface JsonSchemaObject {
40
+ properties?: Record<string, { type?: string }>;
41
+ required?: string[];
42
+ }
43
+
44
+ function formatZodError(err: ZodError, toolName: string, tool?: ToolDef): string {
45
+ const issues = err.issues.map((issue) => {
46
+ const path = issue.path.length > 0 ? `'${issue.path.join(".")}'` : "input";
47
+ const extra: string[] = [];
48
+ const detail = issue as { expected?: string; received?: string };
49
+ if (detail.expected) extra.push(`expected ${detail.expected}`);
50
+ if (detail.received && detail.received !== "undefined") extra.push(`got ${detail.received}`);
51
+ const suffix = extra.length > 0 ? ` (${extra.join(", ")})` : "";
52
+ return ` ${path}: ${issue.message}${suffix}`;
53
+ });
54
+ let msg = `Invalid input for '${toolName}':\n${issues.join("\n")}`;
55
+
56
+ if (tool?.input_schema) {
57
+ try {
58
+ const schema = ((tool.input_schema as any)._jsonSchema || zodToJsonSchema(tool.input_schema)) as JsonSchemaObject;
59
+ const props = schema.properties;
60
+ const req = schema.required || [];
61
+ if (props) {
62
+ const fields = Object.entries(props).map(([k, v]) => {
63
+ const optional = !req.includes(k);
64
+ return `${k}${optional ? "?" : ""}: ${v.type || "any"}`;
65
+ });
66
+ msg += `\n\nExpected schema: { ${fields.join(", ")} }`;
67
+ }
68
+ } catch {}
69
+ }
70
+ if (tool?.description) msg += `\nTool description: ${tool.description}`;
71
+ msg += `\n\nHint: Provide all required fields with correct types.`;
72
+ return msg;
73
+ }
74
+
75
+ function formatHandlerError(err: Error, toolName: string, tool?: ToolDef, input?: unknown): string {
76
+ const message = err.message || String(err);
77
+ let msg = `Tool '${toolName}' failed: ${message}`;
78
+
79
+ if (input !== undefined) {
80
+ try { msg += `\n\nInput provided: ${JSON.stringify(input)}`; } catch {}
81
+ }
82
+ if (tool?.input_schema) {
83
+ try {
84
+ const schema = ((tool.input_schema as any)._jsonSchema || zodToJsonSchema(tool.input_schema)) as JsonSchemaObject;
85
+ const props = schema.properties;
86
+ const req = schema.required || [];
87
+ if (props) {
88
+ const fields = Object.entries(props).map(([k, v]) => {
89
+ const optional = !req.includes(k);
90
+ return `${k}${optional ? "?" : ""}: ${v.type || "any"}`;
91
+ });
92
+ msg += `\nTool expects: { ${fields.join(", ")} }`;
93
+ }
94
+ } catch {}
95
+ }
96
+
97
+ // Pattern-match common errors for specific hints
98
+ if (message.includes("Cannot read properties of undefined") || message.includes("Cannot read property")) {
99
+ msg += `\n\nHint: The handler accessed a property that doesn't exist on the current state. Check that initialState includes all fields your tools read from.`;
100
+ } else if (message.includes("is not a function")) {
101
+ msg += `\n\nHint: Something expected to be a function is not. Check for missing imports or incorrect variable types in the tool handler.`;
102
+ } else if (message.includes("Maximum call stack")) {
103
+ msg += `\n\nHint: Infinite recursion detected. A tool handler or function is calling itself without a base case.`;
104
+ } else {
105
+ msg += `\n\nHint: The tool handler threw an error. Check the handler logic and ensure the current state matches what the handler expects.`;
106
+ }
107
+ return msg;
108
+ }
109
+
110
+ /** Safely extract an error message from an unknown thrown value. */
111
+ function toErrorMessage(err: unknown): string {
112
+ return err instanceof Error ? err.message : String(err);
113
+ }
114
+
115
+ /** Safely extract a string from an Express query parameter value. */
116
+ function queryString(val: unknown): string | undefined {
117
+ return typeof val === "string" ? val : undefined;
118
+ }
119
+
120
+ /** Safely parse an Express query parameter as an integer (returns 0 if absent/invalid). */
121
+ function queryInt(val: unknown, radix = 10): number {
122
+ return typeof val === "string" ? (parseInt(val, radix) || 0) : 0;
123
+ }
124
+
125
+ const __runtimeDir = path.dirname(fileURLToPath(import.meta.url));
126
+
127
+ // PROJECT_ROOT is set by startServer() from ServerConfig.projectRoot.
128
+ // It points to the experience project root (where src/, experiences/, registry live).
129
+ let PROJECT_ROOT = "";
130
+
131
+ // ── Custom error types ───────────────────────────────────────
132
+
133
+ class ToolNotFoundError extends Error {
134
+ constructor(message: string) { super(message); this.name = "ToolNotFoundError"; }
135
+ }
136
+
137
+ class ToolForbiddenError extends Error {
138
+ constructor(message: string) { super(message); this.name = "ToolForbiddenError"; }
139
+ }
140
+
141
+ // ── Types ──────────────────────────────────────────────────
142
+
143
+ interface ToolEvent {
144
+ id: string;
145
+ ts: number;
146
+ actorId: string;
147
+ owner?: string;
148
+ role?: string;
149
+ tool: string;
150
+ input: Record<string, unknown>;
151
+ output?: unknown;
152
+ error?: string;
153
+ observation?: Record<string, unknown>;
154
+ }
155
+
156
+ /** Server-side extension of ToolCtx with runtime-only fields. */
157
+ interface ServerToolCtx extends ToolCtx {
158
+ }
159
+
160
+ /** WebSocket with heartbeat tracking. */
161
+ interface HeartbeatWebSocket extends WebSocket {
162
+ isAlive: boolean;
163
+ }
164
+
165
+ interface RoomLink {
166
+ parentRoomId: string;
167
+ childRoomId: string;
168
+ linkType: "spawned" | "referenced" | "forked";
169
+ metadata?: Record<string, unknown>;
170
+ createdAt: string;
171
+ }
172
+
173
+ /** The full module shape returned by evalServerBundle (ExperienceModule + optional extras from defineExperience). */
174
+ type LoadedModule = ExperienceModule & {
175
+ initialState?: Record<string, unknown> | ((config: Record<string, unknown>) => Record<string, unknown>);
176
+ /** Extra `participants` field (sugar from defineExperience, normalized into manifest.participantSlots). */
177
+ participants?: import("@vibevibes/sdk").ParticipantSlot[];
178
+ /** Extra `agents` field (legacy sugar from defineExperience). */
179
+ agents?: Array<{ role: string; systemPrompt: string; allowedTools?: string[]; autoSpawn?: boolean; maxInstances?: number }>;
180
+ };
181
+
182
+ /** A loaded and cached experience (host or external). */
183
+ interface LoadedExperience {
184
+ module: LoadedModule;
185
+ clientBundle: string; // ESM bundle for the browser
186
+ serverCode: string; // CJS bundle (kept for hot-reload re-eval)
187
+ loadedAt: number;
188
+ sourcePath: string; // Absolute path to the entry file
189
+ }
190
+
191
+ // ── Room class ─────────────────────────────────────────────
192
+
193
+ /** A participant's server-side record (stored in Room.participants). */
194
+ interface ParticipantRecord {
195
+ type: "human" | "ai";
196
+ joinedAt: number;
197
+ owner: string;
198
+ role?: string;
199
+ systemPrompt?: string;
200
+ allowedTools?: string[];
201
+ lastPollAt?: number;
202
+ eventCursor?: number;
203
+ /** Agent operating mode: "behavior" | "manual" | "hybrid". Only for AI participants. */
204
+ agentMode?: string;
205
+ /** Arbitrary metadata from join request (model, team, tags, etc.). */
206
+ metadata?: Record<string, string>;
207
+ }
208
+
209
+ // ── WebSocket message types (discriminated union) ───────────
210
+
211
+ /** Broadcast: participant list changed. */
212
+ type WsPresenceUpdate = {
213
+ type: "presence_update";
214
+ participants: string[];
215
+ participantDetails: Array<{ actorId: string; type: string; role?: string; owner?: string; agentMode?: string; metadata?: Record<string, string> }>;
216
+ };
217
+
218
+ /** Broadcast: shared state changed (via tool, stream, reset, or tick).
219
+ * Supports two modes:
220
+ * - Full state: `state` is present (used on reset, first broadcast, or recovery)
221
+ * - Delta: `delta` + optional `deletedKeys` (only changed top-level keys sent) */
222
+ type WsStateUpdate = {
223
+ type: "shared_state_update";
224
+ roomId: string;
225
+ stateVersion: number;
226
+ changedBy: string;
227
+ /** Full state snapshot (mutually exclusive with delta). */
228
+ state?: Record<string, unknown>;
229
+ /** Changed top-level keys only (mutually exclusive with state). */
230
+ delta?: Record<string, unknown>;
231
+ /** Top-level keys removed from state since last broadcast. */
232
+ deletedKeys?: string[];
233
+ event?: ToolEvent;
234
+ tool?: string;
235
+ observation?: Record<string, unknown>;
236
+ stream?: string;
237
+ tick?: unknown;
238
+ };
239
+
240
+ /** Broadcast: experience code hot-reloaded successfully. */
241
+ type WsExperienceUpdated = {
242
+ type: "experience_updated";
243
+ };
244
+
245
+ /** Broadcast: experience build failed during hot-reload. */
246
+ type WsBuildError = {
247
+ type: "build_error";
248
+ error: string;
249
+ };
250
+
251
+ /** All message types sent via Room.broadcastToAll(). */
252
+ type BroadcastMessage =
253
+ | WsPresenceUpdate
254
+ | WsStateUpdate
255
+ | WsExperienceUpdated
256
+ | WsBuildError;
257
+
258
+ class Room {
259
+ readonly id: string;
260
+ readonly experienceId: string;
261
+ sharedState: Record<string, unknown> = {};
262
+ readonly participants = new Map<string, ParticipantRecord>();
263
+ readonly events: ToolEvent[] = [];
264
+ readonly wsConnections = new Map<WebSocket, string>(); // ws → actorId
265
+ readonly kickedActors = new Set<string>();
266
+ readonly kickedOwners = new Set<string>();
267
+ parentRoomId?: string;
268
+ readonly childRoomIds: string[] = [];
269
+ /** Immutable config set at spawn time. Defines this room's modality/parameters. */
270
+ readonly config: Record<string, unknown>;
271
+ /** Pre-created rooms (from registry) are exempt from empty-room GC. */
272
+ preCreated = false;
273
+ /** Per-room promise chain to serialize tool execution and prevent state interleaving. */
274
+ private _executionQueue: Promise<void> = Promise.resolve();
275
+ /** Monotonically increasing version counter for delta broadcasting. */
276
+ stateVersion = 0;
277
+ /** Previous state snapshot for computing deltas. null = first broadcast sends full state. */
278
+ private _prevState: Record<string, unknown> | null = null;
279
+
280
+ constructor(id: string, experienceId: string, initialState?: Record<string, unknown>, config?: Record<string, unknown>) {
281
+ this.id = id;
282
+ this.experienceId = experienceId;
283
+ this.config = Object.freeze(config || {});
284
+ if (initialState) {
285
+ this.sharedState = initialState;
286
+ }
287
+ }
288
+
289
+ broadcastToAll(message: BroadcastMessage): void {
290
+ const data = JSON.stringify(message);
291
+ for (const ws of this.wsConnections.keys()) {
292
+ if (ws.readyState === WebSocket.OPEN) {
293
+ try { ws.send(data); } catch { /* client disconnected mid-send */ }
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Broadcast a state update using delta compression.
300
+ * Compares current sharedState to _prevState using top-level key reference equality.
301
+ * Sends only changed/deleted keys (delta mode), or full state if no previous snapshot.
302
+ * @param forceFullState - If true, always sends full state (used for reset).
303
+ */
304
+ broadcastStateUpdate(extra: {
305
+ changedBy: string;
306
+ event?: ToolEvent;
307
+ tool?: string;
308
+ observation?: Record<string, unknown>;
309
+ stream?: string;
310
+ tick?: unknown;
311
+ }, forceFullState = false): void {
312
+ this.stateVersion++;
313
+ const prev = this._prevState;
314
+ this._prevState = this.sharedState;
315
+
316
+ // First broadcast or forced full state — send everything
317
+ if (!prev || forceFullState) {
318
+ this.broadcastToAll({
319
+ type: "shared_state_update",
320
+ roomId: this.id,
321
+ stateVersion: this.stateVersion,
322
+ state: this.sharedState,
323
+ ...extra,
324
+ });
325
+ return;
326
+ }
327
+
328
+ // Compute delta: compare top-level keys by reference equality (===)
329
+ const changed: Record<string, unknown> = {};
330
+ const deleted: string[] = [];
331
+ let changeCount = 0;
332
+
333
+ for (const key of Object.keys(this.sharedState)) {
334
+ if (this.sharedState[key] !== prev[key]) {
335
+ changed[key] = this.sharedState[key];
336
+ changeCount++;
337
+ }
338
+ }
339
+ for (const key of Object.keys(prev)) {
340
+ if (!(key in this.sharedState)) {
341
+ deleted.push(key);
342
+ changeCount++;
343
+ }
344
+ }
345
+
346
+ // No state changes — still broadcast if there's an event (tools that don't modify state)
347
+ if (changeCount === 0 && !extra.event) return;
348
+
349
+ if (changeCount === 0) {
350
+ // Event-only broadcast (tool ran but didn't change state)
351
+ this.broadcastToAll({
352
+ type: "shared_state_update",
353
+ roomId: this.id,
354
+ stateVersion: this.stateVersion,
355
+ delta: {},
356
+ ...extra,
357
+ });
358
+ } else {
359
+ this.broadcastToAll({
360
+ type: "shared_state_update",
361
+ roomId: this.id,
362
+ stateVersion: this.stateVersion,
363
+ delta: changed,
364
+ ...(deleted.length > 0 ? { deletedKeys: deleted } : {}),
365
+ ...extra,
366
+ });
367
+ }
368
+ }
369
+
370
+ /** Reset delta tracking (call after room reset to force full state on next broadcast). */
371
+ resetDeltaTracking(): void {
372
+ this._prevState = null;
373
+ }
374
+
375
+ participantList(): string[] {
376
+ return Array.from(this.participants.keys());
377
+ }
378
+
379
+ /** Returns participant objects with actorId, type, owner, agentMode, and metadata (for WebSocket broadcasts and stop hook discovery). */
380
+ participantDetails(): Array<{ actorId: string; type: string; role?: string; owner?: string; agentMode?: string; metadata?: Record<string, string> }> {
381
+ return Array.from(this.participants.entries()).map(([actorId, p]) => {
382
+ const detail: { actorId: string; type: string; role?: string; owner?: string; agentMode?: string; metadata?: Record<string, string> } = {
383
+ actorId,
384
+ type: p.type,
385
+ role: p.role,
386
+ owner: p.owner,
387
+ };
388
+ if (p.agentMode) detail.agentMode = p.agentMode;
389
+ if (p.metadata && Object.keys(p.metadata).length > 0) detail.metadata = p.metadata;
390
+ return detail;
391
+ });
392
+ }
393
+
394
+ appendEvent(event: ToolEvent): void {
395
+ this.events.push(event);
396
+ if (this.events.length > MAX_EVENTS_PER_ROOM) {
397
+ this.events.splice(0, this.events.length - MAX_EVENTS_PER_ROOM);
398
+ }
399
+ }
400
+
401
+ /** Serialize an async operation against this room's tool execution queue.
402
+ * Prevents concurrent tool calls from interleaving on shared state. */
403
+ enqueueExecution<T>(fn: () => Promise<T>): Promise<T> {
404
+ const next = this._executionQueue.then(() => fn());
405
+ // Swallow rejections on the chain so one failed tool doesn't block the next
406
+ this._executionQueue = next.then(() => {}, () => {});
407
+ return next;
408
+ }
409
+ }
410
+
411
+ // ── Constants ─────────────────────────────────────────────
412
+
413
+ const DEFAULT_PORT = 4321;
414
+ const MAX_EVENTS_PER_ROOM = 200;
415
+ const JOIN_EVENT_HISTORY = 20;
416
+ const ROOM_STATE_EVENT_HISTORY = 50;
417
+ const HISTORY_DEFAULT_LIMIT = 50;
418
+ const HISTORY_MAX_LIMIT = 200;
419
+ const DEFAULT_STREAM_RATE_LIMIT = 60;
420
+ const STREAM_RATE_WINDOW_MS = 1000;
421
+ const EVENT_BATCH_DEBOUNCE_MS = 50;
422
+ const DEFAULT_TICK_RATE_MS = 50;
423
+ const MAX_BATCH_CALLS = 10;
424
+ const LONG_POLL_MAX_TIMEOUT_MS = 55000;
425
+ const AGENT_CONTEXT_MAX_TIMEOUT_MS = 10000;
426
+ const WS_MAX_PAYLOAD_BYTES = 1024 * 1024; // 1MB
427
+ const WS_EPHEMERAL_MAX_BYTES = 65536; // 64KB
428
+ const WS_HEARTBEAT_INTERVAL_MS = 30000;
429
+ const ROOM_GC_INTERVAL_MS = 60_000;
430
+ const IDEMPOTENCY_CLEANUP_INTERVAL_MS = 60000;
431
+ const SCREENSHOT_DEFAULT_TIMEOUT_MS = 10000;
432
+ const SCREENSHOT_MAX_TIMEOUT_MS = 30000;
433
+ const HOT_RELOAD_DEBOUNCE_MS = 300;
434
+ const WS_CLOSE_GRACE_MS = 3000; // Grace period before deleting participant on WS close (allows reconnect on refresh)
435
+ const JSON_BODY_LIMIT = "256kb";
436
+ const TOOL_HTTP_TIMEOUT_MS = 30_000;
437
+ const TOOL_REGEX_MAX_LENGTH = 100;
438
+ const ROOM_EVENTS_MAX_LISTENERS = 200;
439
+ const STREAM_RATE_LIMIT_STALE_MS = 5000;
440
+ const STREAM_RATE_LIMIT_CLEANUP_INTERVAL_MS = 10000;
441
+ const BLOB_CACHE_MAX_AGE_SECONDS = 300; // 5 minutes — blobs can be overwritten
442
+
443
+ // ── Default observe (used when experience has no observe function) ─────
444
+
445
+ function defaultObserve(state: Record<string, any>, _event: unknown, _actorId: string): Record<string, any> {
446
+ const result: Record<string, any> = {};
447
+ for (const [k, v] of Object.entries(state)) {
448
+ if (!k.startsWith("_")) result[k] = v;
449
+ }
450
+ const phase = typeof state.phase === "string" ? state.phase : null;
451
+ result.directive = phase ? `Current phase: ${phase}` : "Observe the current state and act accordingly.";
452
+ return result;
453
+ }
454
+
455
+ // ── Global state ──────────────────────────────────────────
456
+
457
+ const DEFAULT_ROOM_ID = "local";
458
+ let PORT = parseInt(process.env.PORT || String(DEFAULT_PORT), 10);
459
+
460
+ let publicUrl: string | null = null;
461
+
462
+ const rooms = new Map<string, Room>();
463
+ const tickEngines = new Map<string, TickEngine>();
464
+ const roomLinks: RoomLink[] = [];
465
+ let _actorCounter = 0;
466
+ const agentMemory = new Map<string, Record<string, unknown>>();
467
+ const roomEmptySince = new Map<string, number>(); // roomId → timestamp when first noticed empty (for GC)
468
+ const roomEvents = new EventEmitter();
469
+ roomEvents.setMaxListeners(ROOM_EVENTS_MAX_LISTENERS);
470
+
471
+ /** Remove all parent-child room links involving the given roomId and update parent's childRoomIds. */
472
+ function cleanupRoomLinks(roomId: string, room: Room): void {
473
+ for (let i = roomLinks.length - 1; i >= 0; i--) {
474
+ if (roomLinks[i].childRoomId === roomId || roomLinks[i].parentRoomId === roomId) {
475
+ roomLinks.splice(i, 1);
476
+ }
477
+ }
478
+ if (room.parentRoomId) {
479
+ const parent = rooms.get(room.parentRoomId);
480
+ if (parent) {
481
+ const idx = parent.childRoomIds.indexOf(roomId);
482
+ if (idx !== -1) parent.childRoomIds.splice(idx, 1);
483
+ }
484
+ }
485
+ }
486
+
487
+ function roomNotFoundError(roomId: string): string {
488
+ return `Room '${roomId}' not found. Use list_rooms to see available rooms.`;
489
+ }
490
+
491
+ /** Set no-cache response headers for dynamic content. */
492
+ function setNoCacheHeaders(res: express.Response): void {
493
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
494
+ res.setHeader("Pragma", "no-cache");
495
+ res.setHeader("Expires", "0");
496
+ }
497
+
498
+ /** Broadcast a presence update to all WebSocket clients in a room. */
499
+ function broadcastPresenceUpdate(room: Room): void {
500
+ room.broadcastToAll({
501
+ type: "presence_update",
502
+ participants: room.participantList(),
503
+ participantDetails: room.participantDetails(),
504
+ });
505
+ }
506
+
507
+ // ── Blob store ──────────────────────────────────────────
508
+ const blobStore = new Map<string, Buffer>();
509
+ const blobMeta = new Map<string, { size: number; createdAt: number; roomId: string }>();
510
+ const MAX_BLOB_SIZE = 10 * 1024 * 1024; // 10MB per blob
511
+ const MAX_TOTAL_BLOBS = 50 * 1024 * 1024; // 50MB total
512
+
513
+ // ── Experience cache (replaces single global `experience`) ──
514
+ const experienceCache = new Map<string, LoadedExperience>();
515
+ const experienceErrors = new Map<string, string>(); // Last build error per experience ID
516
+ let hostExperienceId: string = "";
517
+
518
+ // Hot-reload rebuild gate — bundle endpoints await this during in-progress rebuilds
519
+ let rebuildingResolve: (() => void) | null = null;
520
+ let rebuildingPromise: Promise<void> | null = null;
521
+
522
+ // Spawn rate limiting: max 5 spawns per source room per 5 minutes
523
+ const spawnCounts = new Map<string, { count: number; windowStart: number }>();
524
+ const SPAWN_WINDOW_MS = 5 * 60 * 1000;
525
+ const MAX_SPAWNS_PER_WINDOW = 5;
526
+ const MAX_ROOMS = 100; // Hard cap on total rooms to prevent resource exhaustion
527
+ const pendingSpawns = new Set<string>(); // Room IDs currently being spawned (TOCTOU guard)
528
+ const RESERVED_ROOM_IDS = new Set(["spawn", "config-schema", "local", "library", "rooms", "participants", "events", "memory", "agent-context", "screenshot", "blob", "__proto__", "constructor", "prototype"]);
529
+
530
+ // Stream rate limiting: per actor, per stream, per room
531
+ const streamRateLimits = new Map<string, { count: number; windowStart: number }>();
532
+
533
+ // Periodic cleanup for stream rate limits (every 10 seconds)
534
+ const _streamRateCleanupTimer = setInterval(() => {
535
+ const now = Date.now();
536
+ for (const [key, entry] of streamRateLimits) {
537
+ if (now - entry.windowStart > STREAM_RATE_LIMIT_STALE_MS) streamRateLimits.delete(key);
538
+ }
539
+ }, STREAM_RATE_LIMIT_CLEANUP_INTERVAL_MS);
540
+
541
+ // Periodic cleanup for spawn rate limits (every 5 minutes)
542
+ const _spawnRateCleanupTimer = setInterval(() => {
543
+ const now = Date.now();
544
+ for (const [key, entry] of spawnCounts) {
545
+ if (now - entry.windowStart > SPAWN_WINDOW_MS) spawnCounts.delete(key);
546
+ }
547
+ }, SPAWN_WINDOW_MS);
548
+
549
+ /** Set the public tunnel URL (called from dev.ts when --share is active). */
550
+ export function setPublicUrl(url: string): void {
551
+ publicUrl = url;
552
+ }
553
+
554
+ /** Get the base URL clients should use (tunnel URL if sharing, localhost otherwise). */
555
+ export function getBaseUrl(): string {
556
+ return publicUrl || `http://localhost:${PORT}`;
557
+ }
558
+
559
+ // ── Helpers ────────────────────────────────────────────────
560
+
561
+ const FORBIDDEN_MERGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
562
+
563
+ function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
564
+ const result: Record<string, unknown> = { ...target };
565
+ for (const key of Object.keys(source)) {
566
+ if (FORBIDDEN_MERGE_KEYS.has(key)) continue;
567
+ const sv = source[key];
568
+ const tv = target[key];
569
+ if (sv !== null && typeof sv === "object" && !Array.isArray(sv) &&
570
+ tv !== null && typeof tv === "object" && !Array.isArray(tv)) {
571
+ result[key] = deepMerge(tv as Record<string, unknown>, sv as Record<string, unknown>);
572
+ } else {
573
+ result[key] = sv;
574
+ }
575
+ }
576
+ return result;
577
+ }
578
+
579
+ function assignActorId(username: string, type: "human" | "ai", owner?: string): string {
580
+ // Use owner as prefix when available (agents pass unique owner like "agent-abc123")
581
+ // This ensures two agents get truly distinct actorIds instead of "agent-ai-1" / "agent-ai-2"
582
+ const base = owner || `${username}-${type}`;
583
+ _actorCounter++;
584
+ return `${base}-${_actorCounter}`;
585
+ }
586
+
587
+ /** JSON-safe tool descriptor sent to agents/clients. */
588
+ interface ToolListEntry {
589
+ name: string;
590
+ description: string;
591
+ risk: string;
592
+ input_schema: Record<string, unknown>;
593
+ }
594
+
595
+ function getToolList(mod: LoadedModule, allowedTools?: string[]): ToolListEntry[] {
596
+ if (!mod?.tools) return [];
597
+ let tools: ToolDef[] = mod.tools;
598
+ if (allowedTools) tools = tools.filter((t) => allowedTools.includes(t.name));
599
+ return tools.map((t) => ({
600
+ name: t.name,
601
+ description: t.description,
602
+ risk: t.risk || "low",
603
+ input_schema: (t.input_schema as any)?._jsonSchema
604
+ ? (t.input_schema as any)._jsonSchema as Record<string, unknown>
605
+ : t.input_schema ? zodToJsonSchema(t.input_schema) as Record<string, unknown> : {},
606
+ }));
607
+ }
608
+
609
+ function getRoom(roomId: string): Room | undefined {
610
+ return rooms.get(roomId);
611
+ }
612
+
613
+ function getDefaultRoom(): Room {
614
+ const room = rooms.get(DEFAULT_ROOM_ID);
615
+ if (!room) throw new Error(`Default room '${DEFAULT_ROOM_ID}' not found. Server may still be starting up.`);
616
+ return room;
617
+ }
618
+
619
+ /** Get the loaded experience for a room. Returns undefined if not loaded. */
620
+ function getExperienceForRoom(room: Room): LoadedExperience | undefined {
621
+ return experienceCache.get(room.experienceId);
622
+ }
623
+
624
+ /** Get the host experience module (convenience). */
625
+ function getHostExperience(): LoadedModule | undefined {
626
+ return experienceCache.get(hostExperienceId)?.module;
627
+ }
628
+
629
+ function generateRoomId(): string {
630
+ return `room-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
631
+ }
632
+
633
+ function checkSpawnRate(sourceRoomId: string): boolean {
634
+ const now = Date.now();
635
+ const entry = spawnCounts.get(sourceRoomId);
636
+ if (!entry || now - entry.windowStart > SPAWN_WINDOW_MS) {
637
+ spawnCounts.set(sourceRoomId, { count: 1, windowStart: now });
638
+ return true;
639
+ }
640
+ if (entry.count >= MAX_SPAWNS_PER_WINDOW) {
641
+ return false;
642
+ }
643
+ entry.count++;
644
+ return true;
645
+ }
646
+
647
+ /**
648
+ * Resolve room config against a specific experience's roomConfig definition.
649
+ * Handles: preset names, explicit config objects, defaults, and validation.
650
+ */
651
+ function resolveRoomConfig(
652
+ experienceModule: LoadedModule,
653
+ configInput: Record<string, unknown> | string | undefined,
654
+ ): Record<string, unknown> {
655
+ const roomConfigDef = experienceModule?.roomConfig;
656
+ if (!roomConfigDef) {
657
+ // No config schema defined — shallow-copy (don't hold caller's reference) and reject arrays/null
658
+ return (typeof configInput === "object" && configInput !== null && !Array.isArray(configInput))
659
+ ? { ...configInput } : {};
660
+ }
661
+
662
+ let resolved: Record<string, unknown>;
663
+
664
+ if (typeof configInput === "string") {
665
+ // Preset name
666
+ const preset = roomConfigDef.presets?.[configInput];
667
+ if (!preset) {
668
+ const available = Object.keys(roomConfigDef.presets || {}).join(", ");
669
+ throw new Error(`Unknown config preset '${configInput}'. Available: ${available || "(none)"}`);
670
+ }
671
+ resolved = { ...roomConfigDef.defaults, ...preset };
672
+ } else if (configInput && Object.keys(configInput).length > 0) {
673
+ // Explicit config values merged over defaults
674
+ resolved = { ...roomConfigDef.defaults, ...configInput };
675
+ } else {
676
+ // No config provided — use defaults
677
+ resolved = roomConfigDef.defaults ? { ...roomConfigDef.defaults } : {};
678
+ }
679
+
680
+ // Validate against schema if available
681
+ if (roomConfigDef.schema?.parse) {
682
+ try {
683
+ resolved = roomConfigDef.schema.parse(resolved);
684
+ } catch (err: unknown) {
685
+ if (err instanceof ZodError) {
686
+ const issues = err.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
687
+ throw new Error(`Invalid room config:\n${issues}`);
688
+ }
689
+ throw err;
690
+ }
691
+ }
692
+
693
+ return resolved;
694
+ }
695
+
696
+ /**
697
+ * Resolve the initial state for a room from its experience module.
698
+ * Checks for `initialState` (function or object) on the module.
699
+ * Falls back to empty object if not defined.
700
+ */
701
+ function resolveInitialState(
702
+ experienceModule: LoadedModule,
703
+ config: Record<string, unknown>,
704
+ ): Record<string, unknown> {
705
+ const init = experienceModule?.initialState;
706
+ if (typeof init === "function") {
707
+ try { return init(config) || {}; } catch (err: unknown) {
708
+ console.warn(`[resolveInitialState] initialState(config) threw:`, err);
709
+ return {};
710
+ }
711
+ }
712
+ if (init && typeof init === "object") {
713
+ return { ...init };
714
+ }
715
+ // Auto-generate initial state from stateSchema defaults (Zod .default() values)
716
+ const schema = experienceModule?.stateSchema;
717
+ if (schema && typeof schema.parse === "function") {
718
+ try { return schema.parse({}); } catch { /* schema has required fields without defaults */ }
719
+ }
720
+ return {};
721
+ }
722
+
723
+ // ── Experience discovery ────────────────────────────────────
724
+
725
+ /** Discover the experience entry point in the project root. */
726
+ function discoverEntryPath(): string {
727
+ // Protocol experience: manifest.json in project root
728
+ const manifestPath = path.join(PROJECT_ROOT, "manifest.json");
729
+ if (fs.existsSync(manifestPath)) return manifestPath;
730
+
731
+ // TypeScript experience: src/index.tsx
732
+ const tsxPath = path.join(PROJECT_ROOT, "src", "index.tsx");
733
+ if (fs.existsSync(tsxPath)) return tsxPath;
734
+
735
+ // TypeScript experience: index.tsx in root
736
+ const rootTsx = path.join(PROJECT_ROOT, "index.tsx");
737
+ if (fs.existsSync(rootTsx)) return rootTsx;
738
+
739
+ throw new Error(
740
+ `No experience found in ${PROJECT_ROOT}. ` +
741
+ `Create a manifest.json (protocol) or src/index.tsx (TypeScript).`
742
+ );
743
+ }
744
+
745
+ // ── Experience loading ─────────────────────────────────────
746
+
747
+ /**
748
+ * Load an experience from an arbitrary entry path.
749
+ * Bundles server + client, evals server bundle, caches the result.
750
+ */
751
+ async function loadExperienceFromPath(entryPath: string): Promise<LoadedExperience> {
752
+ // Protocol-based experience (manifest.json + subprocess)
753
+ if (isProtocolExperience(entryPath)) {
754
+ return loadProtocolExperienceFromPath(entryPath);
755
+ }
756
+
757
+ const [sCode, cCode] = await Promise.all([
758
+ bundleForServer(entryPath),
759
+ bundleForClient(entryPath),
760
+ ]);
761
+
762
+ const mod = await evalServerBundle(sCode) as LoadedModule;
763
+
764
+ if (!mod?.manifest || !mod?.tools) {
765
+ throw new Error(`Experience at ${entryPath} missing manifest or tools`);
766
+ }
767
+
768
+ // Auto-inject chat tools (_chat.send, _chat.team, _chat.clear) if not already present
769
+ if (!mod.tools.some((t: ToolDef) => t.name === '_chat.send')) {
770
+ mod.tools.push(...createChatTools(z));
771
+ }
772
+
773
+
774
+ // Validate client bundle for syntax errors and unresolved references
775
+ const clientError = validateClientBundle(cCode);
776
+ if (clientError) {
777
+ throw new Error(`Client bundle validation failed for ${entryPath}: ${clientError}`);
778
+ }
779
+
780
+ const loaded: LoadedExperience = {
781
+ module: mod,
782
+ clientBundle: cCode,
783
+ serverCode: sCode,
784
+ loadedAt: Date.now(),
785
+ sourcePath: entryPath,
786
+ };
787
+
788
+ experienceCache.set(mod.manifest.id, loaded);
789
+ experienceErrors.delete(mod.manifest.id); // Clear any previous build error
790
+ return loaded;
791
+ }
792
+
793
+ // Track protocol executors for cleanup
794
+ const protocolExecutors = new Map<string, SubprocessExecutor>();
795
+
796
+ async function loadProtocolExperienceFromPath(manifestPath: string): Promise<LoadedExperience> {
797
+ const manifestDir = path.dirname(manifestPath);
798
+ const manifest = loadProtocolManifest(manifestPath);
799
+
800
+ // Stop existing executor if reloading
801
+ const existing = protocolExecutors.get(manifest.id);
802
+ if (existing) existing.stop();
803
+
804
+ // Spawn the tool process
805
+ const executor = new SubprocessExecutor(
806
+ manifest.toolProcess.command,
807
+ manifest.toolProcess.args || [],
808
+ manifestDir,
809
+ );
810
+ executor.start();
811
+ protocolExecutors.set(manifest.id, executor);
812
+
813
+ // Send init
814
+ try {
815
+ await executor.send("init", { experienceId: manifest.id }, 5000);
816
+ } catch {
817
+ // init is optional — process may not implement it
818
+ }
819
+
820
+ const mod = createProtocolModule(manifest, executor, manifestDir);
821
+
822
+ // Auto-inject chat tools if not present
823
+ if (!mod.tools.some((t: ToolDef) => t.name === '_chat.send')) {
824
+ mod.tools.push(...createChatTools(z));
825
+ }
826
+
827
+ // For protocol experiences, clientBundle is empty (HTML canvas served separately)
828
+ // and serverCode is the manifest JSON (for reference)
829
+ const loaded: LoadedExperience = {
830
+ module: mod as unknown as LoadedModule,
831
+ clientBundle: "", // No JS bundle — HTML canvas served directly
832
+ serverCode: JSON.stringify(manifest),
833
+ loadedAt: Date.now(),
834
+ sourcePath: manifestPath,
835
+ };
836
+
837
+ experienceCache.set(manifest.id, loaded);
838
+ experienceErrors.delete(manifest.id);
839
+ console.log(`[protocol] Loaded ${manifest.title} (${manifest.id}) — ${manifest.tools.length} tools, process: ${manifest.toolProcess.command}`);
840
+ return loaded;
841
+ }
842
+
843
+ /**
844
+ * Get the loaded experience. Returns cached if available.
845
+ */
846
+ async function getLoadedExperience(): Promise<LoadedExperience> {
847
+ const cached = experienceCache.get(hostExperienceId);
848
+ if (cached) return cached;
849
+ return loadHost();
850
+ }
851
+
852
+ /**
853
+ * Load the experience from the project root.
854
+ */
855
+ async function loadHost(): Promise<LoadedExperience> {
856
+ const entryPath = discoverEntryPath();
857
+ const loaded = await loadExperienceFromPath(entryPath);
858
+ hostExperienceId = loaded.module.manifest.id;
859
+ return loaded;
860
+ }
861
+
862
+ // ── Spawn room (async — can load external experiences) ─────
863
+
864
+ async function spawnRoom(
865
+ sourceRoomId: string,
866
+ opts: { experienceId: string; name?: string; initialState?: Record<string, unknown>; linkBack?: boolean; config?: Record<string, unknown> | string; skipRateLimit?: boolean },
867
+ ): Promise<{ roomId: string; url: string; config: Record<string, unknown> }> {
868
+ if (!opts.skipRateLimit && !checkSpawnRate(sourceRoomId)) {
869
+ throw new Error(`Rate limited: max ${MAX_SPAWNS_PER_WINDOW} spawns per ${SPAWN_WINDOW_MS / 60000} minutes`);
870
+ }
871
+
872
+ let roomId = opts.name || generateRoomId();
873
+ // Sanitize room name: strip path traversal and unsafe characters
874
+ if (opts.name) {
875
+ roomId = roomId.replace(/[^a-zA-Z0-9._\-]/g, "-");
876
+ if (!roomId || roomId.length > 128) throw new Error("Invalid room name");
877
+ }
878
+
879
+ // Reject reserved room IDs that would shadow API route segments or cause prototype pollution
880
+ if (RESERVED_ROOM_IDS.has(roomId)) {
881
+ throw new Error(`Room name '${roomId}' is reserved and cannot be used`);
882
+ }
883
+
884
+ // TOCTOU guard: check both committed and pending rooms atomically (single-threaded JS)
885
+ if (rooms.has(roomId) || pendingSpawns.has(roomId)) {
886
+ throw new Error(`Room '${roomId}' already exists`);
887
+ }
888
+ if (rooms.size + pendingSpawns.size >= MAX_ROOMS) {
889
+ throw new Error(`Room limit reached (${MAX_ROOMS}). Delete unused rooms before spawning new ones.`);
890
+ }
891
+
892
+ // Reserve the room ID before async work to prevent concurrent spawns
893
+ pendingSpawns.add(roomId);
894
+
895
+ let room: Room;
896
+ try {
897
+ // Always use the host experience (single-experience server)
898
+ const targetExperienceLoaded = await getLoadedExperience();
899
+
900
+ // Resolve and validate config against the TARGET experience's schema
901
+ const config = resolveRoomConfig(targetExperienceLoaded.module, opts.config);
902
+
903
+ // Resolve initial state: experience default, merged with explicit initialState, plus linkBack
904
+ const expInitialState = resolveInitialState(targetExperienceLoaded.module, config);
905
+ // Filter FORBIDDEN_MERGE_KEYS from initialState to prevent prototype pollution
906
+ const safeInitialState: Record<string, unknown> = {};
907
+ if (opts.initialState && typeof opts.initialState === "object" && !Array.isArray(opts.initialState)) {
908
+ for (const [k, v] of Object.entries(opts.initialState)) {
909
+ if (!FORBIDDEN_MERGE_KEYS.has(k)) safeInitialState[k] = v;
910
+ }
911
+ }
912
+ const mergedState = { ...expInitialState, ...safeInitialState };
913
+ const initialState = opts.linkBack
914
+ ? { ...mergedState, _parentRoom: sourceRoomId }
915
+ : mergedState;
916
+
917
+ room = new Room(roomId, opts.experienceId, initialState, config);
918
+ room.parentRoomId = sourceRoomId;
919
+ rooms.set(roomId, room);
920
+ pendingSpawns.delete(roomId);
921
+ } catch (err: unknown) {
922
+ pendingSpawns.delete(roomId);
923
+ throw err;
924
+ }
925
+
926
+ // Track parent-child link
927
+ const sourceRoom = rooms.get(sourceRoomId);
928
+ if (sourceRoom) {
929
+ sourceRoom.childRoomIds.push(roomId);
930
+ }
931
+
932
+ // Store RoomLink (include config in metadata)
933
+ // Cap roomLinks to prevent unbounded growth under rapid spawn/GC cycling
934
+ if (roomLinks.length >= 1000) {
935
+ roomLinks.splice(0, roomLinks.length - 999);
936
+ }
937
+ roomLinks.push({
938
+ parentRoomId: sourceRoomId,
939
+ childRoomId: roomId,
940
+ linkType: "spawned",
941
+ metadata: { experienceId: opts.experienceId, config: room.config },
942
+ createdAt: new Date().toISOString(),
943
+ });
944
+
945
+ // Start tick engine if experience uses tick netcode
946
+ const loadedExp = experienceCache.get(opts.experienceId);
947
+ if (loadedExp) maybeStartTickEngine(room, loadedExp);
948
+
949
+ const url = `${getBaseUrl()}/room/${roomId}`;
950
+ return { roomId, url, config: room.config };
951
+ }
952
+
953
+ // ── Tick Engine lifecycle ──────────────────────────────────
954
+
955
+ /** Start a tick engine for a room if its experience uses tick netcode. */
956
+ function maybeStartTickEngine(room: Room, loaded: LoadedExperience): void {
957
+ const manifest = loaded.module?.manifest;
958
+ if (manifest?.netcode !== "tick") return;
959
+
960
+ // Stop existing engine first to prevent ghost intervals on hot-reload
961
+ stopTickEngine(room.id);
962
+
963
+ const tickRateMs = manifest.tickRateMs || DEFAULT_TICK_RATE_MS;
964
+
965
+ const engine = new TickEngine(room, loaded, roomEvents, tickRateMs);
966
+ engine.start();
967
+ tickEngines.set(room.id, engine);
968
+ }
969
+
970
+ /** Stop and remove the tick engine for a room. */
971
+ function stopTickEngine(roomId: string): void {
972
+ const engine = tickEngines.get(roomId);
973
+ if (engine) {
974
+ engine.stop();
975
+ tickEngines.delete(roomId);
976
+ }
977
+ }
978
+
979
+ // ── Express app ────────────────────────────────────────────
980
+
981
+ const app = express();
982
+ app.use(express.json({ limit: JSON_BODY_LIMIT }));
983
+
984
+ // CORS for local development
985
+ app.use((_req, res, next) => {
986
+ res.setHeader("Access-Control-Allow-Origin", "*");
987
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
988
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Idempotency-Key");
989
+ if (_req.method === "OPTIONS") { res.sendStatus(200); return; }
990
+ next();
991
+ });
992
+
993
+ // Serve viewer (no-cache so changes are picked up immediately)
994
+ app.get("/", (_req, res) => {
995
+ setNoCacheHeaders(res);
996
+ res.sendFile(path.join(__runtimeDir, "viewer", "index.html"));
997
+ });
998
+ app.use("/viewer", express.static(path.join(__runtimeDir, "viewer")));
999
+
1000
+ // Serve SDK ESM bundle (for scene engine and other SDK features in browser)
1001
+ app.get("/sdk.js", (_req, res) => {
1002
+ const sdkPath = path.join(PROJECT_ROOT, "node_modules", "@vibevibes", "sdk", "dist", "index.js");
1003
+ if (fs.existsSync(sdkPath)) {
1004
+ res.setHeader("Content-Type", "application/javascript");
1005
+ res.sendFile(sdkPath);
1006
+ } else {
1007
+ res.status(404).send("// SDK not found");
1008
+ }
1009
+ });
1010
+
1011
+ // ── Room state endpoint (flat = default room) ──────────────
1012
+
1013
+ app.get("/state", (req, res) => {
1014
+ const room = getDefaultRoom();
1015
+ const exp = getExperienceForRoom(room);
1016
+ let observation: Record<string, unknown> | undefined;
1017
+ const observeFn = exp?.module?.observe ?? defaultObserve;
1018
+ const observeActorId = typeof req.query.actorId === "string" ? req.query.actorId : "viewer";
1019
+ try { observation = observeFn(room.sharedState, null, observeActorId); } catch (err: unknown) { console.warn(`[observe] Error: ${toErrorMessage(err)}`); }
1020
+ res.json({
1021
+ roomId: room.id,
1022
+ experienceId: exp?.module?.manifest?.id ?? room.experienceId,
1023
+ sharedState: room.sharedState,
1024
+ stateVersion: room.stateVersion,
1025
+ participants: room.participantList(),
1026
+ events: room.events.slice(-ROOM_STATE_EVENT_HISTORY),
1027
+ config: room.config,
1028
+ observation,
1029
+ });
1030
+ });
1031
+
1032
+ // ── Slots endpoint (default room) ───────────────────────────
1033
+ app.get("/slots", (_req, res) => {
1034
+ const room = getDefaultRoom();
1035
+ const exp = getExperienceForRoom(room);
1036
+ const slots = exp?.module?.manifest?.participantSlots || exp?.module?.participants || [];
1037
+ res.json({ slots, participantDetails: room.participantDetails() });
1038
+ });
1039
+
1040
+ // ── Participants endpoint ──────────────────────────────────
1041
+
1042
+ app.get("/participants", (_req, res) => res.json({ participants: getDefaultRoom().participantDetails() }));
1043
+ app.get("/rooms/:roomId/participants", (req, res) => {
1044
+ const room = getRoom(req.params.roomId);
1045
+ if (!room) return res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
1046
+ res.json({ participants: room.participantDetails() });
1047
+ });
1048
+
1049
+ // ── Tools list endpoint (structured tool discovery for agents) ──
1050
+
1051
+ function handleToolsList(room: Room, req: express.Request, res: express.Response): void {
1052
+ const exp = getExperienceForRoom(room);
1053
+ if (!exp) {
1054
+ res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
1055
+ return;
1056
+ }
1057
+
1058
+ // If actorId is provided, filter tools by the participant's allowedTools
1059
+ const actorId = queryString(req.query.actorId);
1060
+ let allowedTools: string[] | undefined;
1061
+ if (actorId) {
1062
+ const participant = room.participants.get(actorId);
1063
+ allowedTools = participant?.allowedTools;
1064
+ }
1065
+
1066
+ const tools = getToolList(exp.module, allowedTools);
1067
+
1068
+ // Also return stream definitions if the experience defines them
1069
+ const streams = exp.module.streams
1070
+ ? exp.module.streams.map((s) => ({
1071
+ name: s.name,
1072
+ description: s.description || "",
1073
+ rateLimit: s.rateLimit || DEFAULT_STREAM_RATE_LIMIT,
1074
+ input_schema: s.input_schema ? zodToJsonSchema(s.input_schema) : {},
1075
+ }))
1076
+ : [];
1077
+
1078
+ res.json({
1079
+ roomId: room.id,
1080
+ experienceId: exp.module.manifest?.id || room.experienceId,
1081
+ tools,
1082
+ streams,
1083
+ toolCount: tools.length,
1084
+ streamCount: streams.length,
1085
+ });
1086
+ }
1087
+
1088
+ app.get("/tools-list", (req, res) => handleToolsList(getDefaultRoom(), req, res));
1089
+ app.get("/rooms/:roomId/tools-list", (req, res) => {
1090
+ const room = getRoom(req.params.roomId);
1091
+ if (!room) return res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
1092
+ handleToolsList(room, req, res);
1093
+ });
1094
+
1095
+ // ── History endpoint (filtered event log) ────────────────────
1096
+
1097
+ function handleHistory(room: Room, req: express.Request, res: express.Response): void {
1098
+ const limit = Math.min(queryInt(req.query.limit) || HISTORY_DEFAULT_LIMIT, HISTORY_MAX_LIMIT);
1099
+ const since = queryInt(req.query.since);
1100
+ const actor = queryString(req.query.actor);
1101
+ const tool = queryString(req.query.tool);
1102
+ const owner = queryString(req.query.owner);
1103
+
1104
+ let events = room.events;
1105
+
1106
+ // Filter by timestamp
1107
+ if (since > 0) {
1108
+ events = events.filter(e => e.ts > since);
1109
+ }
1110
+
1111
+ // Filter by actor
1112
+ if (actor) {
1113
+ events = events.filter(e => e.actorId === actor);
1114
+ }
1115
+
1116
+ // Filter by owner
1117
+ if (owner) {
1118
+ events = events.filter(e => e.owner === owner);
1119
+ }
1120
+
1121
+ // Filter by tool name — only allow safe literal characters (no regex metacharacters)
1122
+ // This prevents user-controlled ReDoS via crafted patterns
1123
+ if (tool) {
1124
+ const SAFE_TOOL_REGEX = /^[a-zA-Z0-9._:\-]+$/;
1125
+ if (tool.length <= TOOL_REGEX_MAX_LENGTH && SAFE_TOOL_REGEX.test(tool)) {
1126
+ // Safe literal pattern — escape dots (regex metachar) for exact matching
1127
+ try {
1128
+ const escaped = tool.replace(/\./g, '\\.');
1129
+ const toolRegex = new RegExp('^' + escaped + '$');
1130
+ events = events.filter(e => toolRegex.test(e.tool));
1131
+ } catch {
1132
+ events = events.filter(e => e.tool === tool);
1133
+ }
1134
+ } else {
1135
+ events = events.filter(e => e.tool === tool);
1136
+ }
1137
+ }
1138
+
1139
+ // Take last N events
1140
+ const filteredTotal = events.length;
1141
+ const result = events.slice(-limit);
1142
+
1143
+ res.json({
1144
+ roomId: room.id,
1145
+ events: result,
1146
+ total: room.events.length,
1147
+ filtered: filteredTotal,
1148
+ returned: result.length,
1149
+ hasMore: filteredTotal > limit,
1150
+ });
1151
+ }
1152
+
1153
+ app.get("/history", (req, res) => handleHistory(getDefaultRoom(), req, res));
1154
+ app.get("/rooms/:roomId/history", (req, res) => {
1155
+ const room = getRoom(req.params.roomId);
1156
+ if (!room) return res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
1157
+ handleHistory(room, req, res);
1158
+ });
1159
+
1160
+ // ── Who endpoint (rich participant info) ─────────────────────
1161
+
1162
+ function handleWho(room: Room, _req: express.Request, res: express.Response): void {
1163
+ const participants = Array.from(room.participants.entries()).map(([actorId, p]) => {
1164
+ // Find this participant's last action
1165
+ let lastAction: { tool: string; ts: number } | undefined;
1166
+ for (let i = room.events.length - 1; i >= 0; i--) {
1167
+ if (room.events[i].actorId === actorId) {
1168
+ lastAction = { tool: room.events[i].tool, ts: room.events[i].ts };
1169
+ break;
1170
+ }
1171
+ }
1172
+
1173
+ return {
1174
+ actorId,
1175
+ owner: p.owner,
1176
+ type: p.type,
1177
+ role: p.role,
1178
+ joinedAt: p.joinedAt,
1179
+ lastAction,
1180
+ allowedTools: p.allowedTools,
1181
+ };
1182
+ });
1183
+
1184
+ res.json({
1185
+ roomId: room.id,
1186
+ participants,
1187
+ count: participants.length,
1188
+ humans: participants.filter(p => p.type === "human").length,
1189
+ agents: participants.filter(p => p.type === "ai").length,
1190
+ });
1191
+ }
1192
+
1193
+ app.get("/who", (req, res) => handleWho(getDefaultRoom(), req, res));
1194
+ app.get("/rooms/:roomId/who", (req, res) => {
1195
+ const room = getRoom(req.params.roomId);
1196
+ if (!room) return res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
1197
+ handleWho(room, req, res);
1198
+ });
1199
+
1200
+ // ── Join (flat = default room) ─────────────────────────────
1201
+
1202
+ function experienceNotLoadedError(experienceId: string): string {
1203
+ const lastError = experienceErrors.get(experienceId);
1204
+ const hint = lastError
1205
+ ? `\nLast build error: ${lastError}\nFix the source and save to hot-reload.`
1206
+ : `\nCheck that src/index.tsx exists and exports a valid experience.`;
1207
+ return `Experience '${experienceId}' not loaded.${hint}`;
1208
+ }
1209
+
1210
+ function handleJoin(room: Room, req: express.Request, res: express.Response): void {
1211
+ const exp = getExperienceForRoom(room);
1212
+ if (!exp) {
1213
+ res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
1214
+ return;
1215
+ }
1216
+
1217
+ const { username = "user", actorType: rawActorType = "human", owner, role: requestedRole, agentMode: rawAgentMode, metadata: rawMetadata } = req.body;
1218
+ const actorType: "human" | "ai" = rawActorType === "ai" ? "ai" : "human";
1219
+ const resolvedOwner: string = owner || username;
1220
+
1221
+ // Validate agentMode (only for AI participants)
1222
+ const VALID_AGENT_MODES = ["behavior", "manual", "hybrid"];
1223
+ const agentMode: string | undefined = actorType === "ai" && typeof rawAgentMode === "string" && VALID_AGENT_MODES.includes(rawAgentMode) ? rawAgentMode : undefined;
1224
+
1225
+ // Validate metadata (string→string record, max 20 keys, max 200 chars per value)
1226
+ let metadata: Record<string, string> | undefined;
1227
+ if (rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata)) {
1228
+ metadata = {};
1229
+ let keyCount = 0;
1230
+ for (const [k, v] of Object.entries(rawMetadata as Record<string, unknown>)) {
1231
+ if (keyCount >= 20) break;
1232
+ if (typeof k === "string" && typeof v === "string" && k.length <= 50) {
1233
+ metadata[k] = String(v).slice(0, 200);
1234
+ keyCount++;
1235
+ }
1236
+ }
1237
+ if (Object.keys(metadata).length === 0) metadata = undefined;
1238
+ }
1239
+
1240
+ // Reject empty owner — would bypass kick enforcement
1241
+ if (!resolvedOwner) {
1242
+ res.status(400).json({ error: "owner or username required" });
1243
+ return;
1244
+ }
1245
+
1246
+ // Dedup: if same owner already has a participant in this room, reuse the existing entry
1247
+ if (resolvedOwner) {
1248
+ // Check kick status before allowing reconnect
1249
+ if (room.kickedOwners.has(resolvedOwner)) {
1250
+ res.status(403).json({ error: "You have been kicked from this room." });
1251
+ return;
1252
+ }
1253
+
1254
+ for (const [existingId, existingP] of room.participants.entries()) {
1255
+ if (existingP.owner === resolvedOwner) {
1256
+ // Check if this specific actorId was kicked
1257
+ if (room.kickedActors.has(existingId)) {
1258
+ res.status(403).json({ error: "You have been kicked from this room." });
1259
+ return;
1260
+ }
1261
+
1262
+ // Same owner reconnecting — return existing identity (preserves actorId continuity)
1263
+ existingP.joinedAt = Date.now();
1264
+ // Ensure _agentRoles stays current on reconnect
1265
+ if (existingP.type === "ai" && existingP.role) {
1266
+ room.sharedState = {
1267
+ ...room.sharedState,
1268
+ _agentRoles: { ...(room.sharedState._agentRoles as Record<string, string> ?? {}), [existingId]: existingP.role },
1269
+ };
1270
+ }
1271
+ // Clean up stale WS mapping and close the old WebSocket if still alive
1272
+ for (const [ws, wsActor] of room.wsConnections.entries()) {
1273
+ if (wsActor === existingId) {
1274
+ room.wsConnections.delete(ws);
1275
+ try { ws.close(1000, "Replaced by reconnect"); } catch {}
1276
+ break;
1277
+ }
1278
+ }
1279
+
1280
+ // Broadcast presence update
1281
+ broadcastPresenceUpdate(room);
1282
+
1283
+ // Compute observation for the reconnected agent
1284
+ let observation: Record<string, unknown> | undefined;
1285
+ let observeError: string | undefined;
1286
+ const reconnectObserve = exp.module.observe ?? defaultObserve;
1287
+ try { observation = reconnectObserve(room.sharedState, null, existingId); } catch (e: unknown) {
1288
+ console.error(`[observe] Error in ${exp.module.manifest.id}:`, toErrorMessage(e));
1289
+ observeError = toErrorMessage(e);
1290
+ }
1291
+
1292
+ res.json({
1293
+ roomId: room.id,
1294
+ actorId: existingId,
1295
+ owner: resolvedOwner,
1296
+ role: existingP.role,
1297
+ systemPrompt: existingP.systemPrompt,
1298
+ reconnected: true,
1299
+ observation,
1300
+ observeError,
1301
+ tools: getToolList(exp.module, existingP.allowedTools),
1302
+ });
1303
+ return;
1304
+ }
1305
+ }
1306
+ }
1307
+
1308
+ // Unified participant slot matching — works for BOTH humans and AI.
1309
+ // Resolution: (1) manifest.participantSlots, (2) module.participants, (3) legacy agentSlots.
1310
+ const participantSlots: ParticipantSlot[] | undefined =
1311
+ exp.module.manifest?.participantSlots || exp.module.participants;
1312
+ const agentSlots = exp.module.agents || exp.module.manifest?.agentSlots;
1313
+ let slotRole: string | undefined;
1314
+ let slotAllowedTools: string[] | undefined;
1315
+ let actorIdBase: string | undefined;
1316
+ let slotSystemPrompt: string | undefined;
1317
+
1318
+ if (participantSlots?.length) {
1319
+ // Count occupants per role (respecting maxInstances)
1320
+ const roleOccupancy = new Map<string, number>();
1321
+ for (const [, p] of room.participants) {
1322
+ if (p.role) roleOccupancy.set(p.role, (roleOccupancy.get(p.role) || 0) + 1);
1323
+ }
1324
+
1325
+ // Match: (1) requested role → (2) first open slot matching type → (3) "any" slots
1326
+ const typeMatches = (slotType: string | undefined, joinType: string) => {
1327
+ if (!slotType || slotType === "any") return true;
1328
+ return slotType === joinType;
1329
+ };
1330
+ const hasCapacity = (slot: ParticipantSlot) => {
1331
+ const max = slot.maxInstances ?? 1;
1332
+ const current = roleOccupancy.get(slot.role) || 0;
1333
+ return current < max;
1334
+ };
1335
+
1336
+ let matched: ParticipantSlot | undefined;
1337
+ if (requestedRole) {
1338
+ matched = participantSlots.find((s) =>
1339
+ s.role === requestedRole && typeMatches(s.type, actorType) && hasCapacity(s)
1340
+ );
1341
+ }
1342
+ if (!matched) {
1343
+ matched = participantSlots.find((s) =>
1344
+ s.type === actorType && hasCapacity(s)
1345
+ );
1346
+ }
1347
+ if (!matched) {
1348
+ matched = participantSlots.find((s) =>
1349
+ typeMatches(s.type, actorType) && hasCapacity(s)
1350
+ );
1351
+ }
1352
+ // Fallback: if all matching slots are full, use the first type-compatible slot anyway
1353
+ // (matches old behavior where agents overflow to agentSlots[0])
1354
+ if (!matched) {
1355
+ matched = participantSlots.find((s) => typeMatches(s.type, actorType));
1356
+ }
1357
+
1358
+ if (matched) {
1359
+ slotRole = matched.role;
1360
+ slotAllowedTools = matched.allowedTools;
1361
+ slotSystemPrompt = matched.systemPrompt;
1362
+ // actorId uses the owner name, not the role — role lives in participant record
1363
+ }
1364
+ } else if (actorType === "ai" && agentSlots && agentSlots.length > 0) {
1365
+ // Legacy fallback: agentSlots only match AI joins
1366
+ const occupiedRoles = new Set<string>();
1367
+ for (const [, p] of room.participants) {
1368
+ if (p.type === "ai" && p.role) occupiedRoles.add(p.role);
1369
+ }
1370
+ const slot = agentSlots.find((s) => !occupiedRoles.has(s.role)) || agentSlots[0];
1371
+ slotRole = slot.role;
1372
+ slotAllowedTools = slot.allowedTools;
1373
+ slotSystemPrompt = slot.systemPrompt;
1374
+ }
1375
+
1376
+ const actorId = assignActorId(username, actorType, actorIdBase || resolvedOwner);
1377
+ const participant: ParticipantRecord = {
1378
+ type: actorType, joinedAt: Date.now(), owner: resolvedOwner,
1379
+ };
1380
+
1381
+ if (slotRole) {
1382
+ participant.role = slotRole;
1383
+ }
1384
+ if (slotAllowedTools) {
1385
+ participant.allowedTools = slotAllowedTools;
1386
+ }
1387
+ if (slotSystemPrompt) {
1388
+ participant.systemPrompt = slotSystemPrompt;
1389
+ }
1390
+ if (!slotRole && requestedRole) {
1391
+ participant.role = requestedRole;
1392
+ }
1393
+ if (agentMode) {
1394
+ participant.agentMode = agentMode;
1395
+ }
1396
+ if (metadata) {
1397
+ participant.metadata = metadata;
1398
+ }
1399
+
1400
+ room.participants.set(actorId, participant);
1401
+
1402
+ // Auto-populate _agentRoles when an AI participant joins with a role
1403
+ if (actorType === "ai" && participant.role) {
1404
+ room.sharedState = {
1405
+ ...room.sharedState,
1406
+ _agentRoles: { ...(room.sharedState._agentRoles as Record<string, string> ?? {}), [actorId]: participant.role },
1407
+ };
1408
+ }
1409
+
1410
+ // Broadcast presence update
1411
+ broadcastPresenceUpdate(room);
1412
+
1413
+ // Compute observation so agents get a curated view from the start
1414
+ let observation: Record<string, unknown> | undefined;
1415
+ let observeError: string | undefined;
1416
+ const joinObserve = exp.module.observe ?? defaultObserve;
1417
+ try { observation = joinObserve(room.sharedState, null, actorId); } catch (e: unknown) {
1418
+ console.error(`[observe] Error in ${exp.module.manifest.id}:`, toErrorMessage(e));
1419
+ observeError = toErrorMessage(e);
1420
+ }
1421
+
1422
+ res.json({
1423
+ roomId: room.id,
1424
+ actorId,
1425
+ owner: resolvedOwner,
1426
+ experienceId: exp.module.manifest.id,
1427
+ sharedState: room.sharedState,
1428
+ participants: room.participantList(),
1429
+ events: room.events.slice(-JOIN_EVENT_HISTORY),
1430
+ tools: getToolList(exp.module, participant.allowedTools),
1431
+ browserUrl: getBaseUrl(),
1432
+ config: room.config,
1433
+ hasRoomConfig: !!exp.module.roomConfig,
1434
+ observation,
1435
+ role: participant.role,
1436
+ allowedTools: participant.allowedTools,
1437
+ systemPrompt: slotSystemPrompt,
1438
+ });
1439
+ }
1440
+
1441
+ app.post("/join", (req, res) => handleJoin(getDefaultRoom(), req, res));
1442
+
1443
+ // ── Leave (flat = default room) ────────────────────────────
1444
+
1445
+ function handleLeave(room: Room, req: express.Request, res: express.Response): void {
1446
+ const { actorId } = req.body;
1447
+ if (!actorId || typeof actorId !== "string") {
1448
+ res.status(400).json({ error: "actorId required" });
1449
+ return;
1450
+ }
1451
+ if (!room.participants.has(actorId)) {
1452
+ res.status(404).json({ error: `Participant '${actorId}' not found in room '${room.id}'` });
1453
+ return;
1454
+ }
1455
+ room.participants.delete(actorId);
1456
+ // Clean up agent memory for this actor (key must match executeTool's memoryKey format)
1457
+ const exp = getExperienceForRoom(room);
1458
+ const memKey = exp ? `${exp.module.manifest.id}:${actorId}` : `${room.id}:${actorId}`;
1459
+ agentMemory.delete(memKey);
1460
+ // Clean up any associated WebSocket connections
1461
+ for (const [ws, wsActorId] of room.wsConnections.entries()) {
1462
+ if (wsActorId === actorId) {
1463
+ room.wsConnections.delete(ws);
1464
+ try { ws.close(); } catch {}
1465
+ }
1466
+ }
1467
+ broadcastPresenceUpdate(room);
1468
+ res.json({ left: true, actorId });
1469
+ }
1470
+
1471
+ app.post("/leave", (req, res) => handleLeave(getDefaultRoom(), req, res));
1472
+
1473
+ // ── Kick (remove participant from room) ──────────────────────
1474
+
1475
+ interface KickResult {
1476
+ error?: string;
1477
+ kicked?: boolean;
1478
+ actorId?: string;
1479
+ }
1480
+
1481
+ function handleKick(
1482
+ room: Room,
1483
+ kickerActorId: string,
1484
+ targetActorId: string,
1485
+ ): KickResult {
1486
+ if (kickerActorId === targetActorId) return { error: "Cannot kick yourself" };
1487
+
1488
+ const kicker = room.participants.get(kickerActorId);
1489
+ if (!kicker || kicker.type !== "human") return { error: "Only human participants can kick" };
1490
+
1491
+ if (!room.participants.has(targetActorId)) return { error: "Participant not found" };
1492
+
1493
+ // Remove participant and mark as kicked (track both actorId and owner)
1494
+ const targetParticipant = room.participants.get(targetActorId);
1495
+ room.participants.delete(targetActorId);
1496
+ // Cap kicked sets to prevent unbounded growth on long-running rooms
1497
+ if (room.kickedActors.size >= 500) {
1498
+ const oldest = room.kickedActors.values().next().value;
1499
+ if (oldest) room.kickedActors.delete(oldest);
1500
+ }
1501
+ room.kickedActors.add(targetActorId);
1502
+ if (targetParticipant?.owner) {
1503
+ if (room.kickedOwners.size >= 500) {
1504
+ const oldest = room.kickedOwners.values().next().value;
1505
+ if (oldest) room.kickedOwners.delete(oldest);
1506
+ }
1507
+ room.kickedOwners.add(targetParticipant.owner);
1508
+ }
1509
+
1510
+ // Close their WS if connected
1511
+ for (const [targetWs, wsActorId] of room.wsConnections.entries()) {
1512
+ if (wsActorId === targetActorId) {
1513
+ try { targetWs.send(JSON.stringify({ type: "kicked", by: kickerActorId })); targetWs.close(); } catch {}
1514
+ room.wsConnections.delete(targetWs);
1515
+ break;
1516
+ }
1517
+ }
1518
+
1519
+ // Broadcast updated presence
1520
+ broadcastPresenceUpdate(room);
1521
+
1522
+ // Wake up any long-polling agent-context for the kicked agent
1523
+ roomEvents.emit(`room:${room.id}`);
1524
+
1525
+ return { kicked: true, actorId: targetActorId };
1526
+ }
1527
+
1528
+ app.post("/kick", (req, res) => {
1529
+ const result = handleKick(getDefaultRoom(), req.body.kickerActorId, req.body.targetActorId);
1530
+ res.status(result.error ? 400 : 200).json(result);
1531
+ });
1532
+
1533
+ // ── Idempotency cache ────────────────────────────────────────
1534
+
1535
+ const idempotencyCache = new Map<string, { output: unknown; ts: number }>();
1536
+ const IDEMPOTENCY_TTL = 30000; // 30 seconds
1537
+
1538
+ // Cleanup expired entries every 60 seconds
1539
+ const _idempotencyCleanupTimer = setInterval(() => {
1540
+ const now = Date.now();
1541
+ for (const [key, entry] of idempotencyCache) {
1542
+ if (now - entry.ts > IDEMPOTENCY_TTL) idempotencyCache.delete(key);
1543
+ }
1544
+ }, IDEMPOTENCY_CLEANUP_INTERVAL_MS);
1545
+
1546
+ // ── Execute tool (core) ─────────────────────────────────────
1547
+ // Internal function that executes a single tool call and returns the result.
1548
+ // Used by both the single-tool HTTP endpoint and the batch endpoint.
1549
+
1550
+ interface ToolCallResult {
1551
+ tool: string;
1552
+ output?: unknown;
1553
+ observation?: Record<string, unknown>;
1554
+ error?: string;
1555
+ }
1556
+
1557
+ async function executeTool(
1558
+ room: Room,
1559
+ toolName: string,
1560
+ actorId: string,
1561
+ input: Record<string, unknown> = {},
1562
+ owner?: string,
1563
+ expiredFlag?: { value: boolean },
1564
+ ): Promise<ToolCallResult> {
1565
+ const exp = getExperienceForRoom(room);
1566
+ if (!exp) throw new Error(experienceNotLoadedError(room.experienceId));
1567
+
1568
+ // Handle scoped tool calls from embedded experiences
1569
+ let scopeKey: string | undefined;
1570
+ let resolvedToolName = toolName;
1571
+ if (toolName.includes(':')) {
1572
+ const colonIdx = toolName.indexOf(':');
1573
+ scopeKey = toolName.slice(0, colonIdx);
1574
+ resolvedToolName = toolName.slice(colonIdx + 1);
1575
+ // Validate scopeKey against safe identifier pattern and forbidden keys
1576
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(scopeKey) || FORBIDDEN_MERGE_KEYS.has(scopeKey)) {
1577
+ throw new Error(`Invalid scope key in tool name: '${scopeKey}'`);
1578
+ }
1579
+ }
1580
+
1581
+ // Find tool from the room's experience
1582
+ const tool = exp.module.tools.find((t) => t.name === resolvedToolName);
1583
+ if (!tool) {
1584
+ const available = exp.module.tools.map((t) => t.name).join(", ");
1585
+ throw new ToolNotFoundError(`Tool '${resolvedToolName}' not found. Available tools: ${available}`);
1586
+ }
1587
+
1588
+ // Check role-based tool restrictions for AI actors
1589
+ // Check both bare name and fully-scoped name (e.g. "hero.move" and "scope:hero.move")
1590
+ const callingParticipant = room.participants.get(actorId);
1591
+ if (callingParticipant?.allowedTools &&
1592
+ !callingParticipant.allowedTools.includes(resolvedToolName) &&
1593
+ !callingParticipant.allowedTools.includes(toolName)) {
1594
+ const role = callingParticipant.role || "ai";
1595
+ const allowed = callingParticipant.allowedTools.join(", ");
1596
+ throw new ToolForbiddenError(`Tool '${resolvedToolName}' is not allowed for role '${role}'. Allowed tools: ${allowed}`);
1597
+ }
1598
+
1599
+ // Validate input
1600
+ let validatedInput = input;
1601
+ if (tool.input_schema?.parse) {
1602
+ validatedInput = tool.input_schema.parse(input);
1603
+ }
1604
+
1605
+ // Build ToolCtx
1606
+ const participant = room.participants.get(actorId);
1607
+ const resolvedOwner: string = participant?.owner || owner || actorId;
1608
+ const memoryKey = `${exp.module.manifest.id}:${actorId}`;
1609
+ const ctx: ServerToolCtx = {
1610
+ roomId: room.id,
1611
+ actorId,
1612
+ owner: resolvedOwner,
1613
+ get state() { return room.sharedState; },
1614
+ setState: (newState: Record<string, unknown>) => {
1615
+ if (expiredFlag?.value) return; // Tool timed out — discard late writes
1616
+ room.sharedState = newState;
1617
+ // Warn if state is getting large (>1MB serialized)
1618
+ if (process.env.NODE_ENV !== "test") {
1619
+ try {
1620
+ const size = JSON.stringify(newState).length;
1621
+ if (size > 1_000_000) {
1622
+ console.warn(`[room:${room.id}] State size warning: ${(size / 1_000_000).toFixed(1)}MB. Consider pruning old data.`);
1623
+ }
1624
+ } catch {}
1625
+ }
1626
+ },
1627
+ timestamp: Date.now(),
1628
+ memory: agentMemory.get(memoryKey) || {},
1629
+ setMemory: (updates: Record<string, unknown>) => {
1630
+ const current = agentMemory.get(memoryKey) || {};
1631
+ const merged = deepMerge(current, updates);
1632
+ // Cap per-actor memory at 100 keys to prevent unbounded growth
1633
+ const keys = Object.keys(merged);
1634
+ if (keys.length > 100) {
1635
+ for (const k of keys.slice(0, keys.length - 100)) delete merged[k];
1636
+ }
1637
+ agentMemory.set(memoryKey, merged);
1638
+ },
1639
+ roomConfig: room.config,
1640
+ };
1641
+
1642
+ // Scope state for embedded experience tool calls
1643
+ if (scopeKey) {
1644
+ Object.defineProperty(ctx, 'state', {
1645
+ get() { return room.sharedState[scopeKey!] || {}; },
1646
+ configurable: true,
1647
+ });
1648
+ ctx.setState = (newState: Record<string, unknown>) => {
1649
+ if (expiredFlag?.value) return; // Tool timed out — discard late writes
1650
+ room.sharedState = { ...room.sharedState, [scopeKey!]: newState };
1651
+ };
1652
+ }
1653
+
1654
+ // Wire spawnRoom if experience requests the capability
1655
+ const capabilities = exp.module.manifest.requested_capabilities || [];
1656
+ if (capabilities.includes("room.spawn")) {
1657
+ ctx.spawnRoom = async (opts: { experienceId: string; name?: string; initialState?: Record<string, unknown>; linkBack?: boolean; config?: Record<string, unknown> | string }) => {
1658
+ return spawnRoom(room.id, opts);
1659
+ };
1660
+ }
1661
+
1662
+ // Wire blob operations
1663
+ ctx.setBlob = (key: string, data: ArrayBuffer): string => {
1664
+ const buf = Buffer.from(data);
1665
+ if (buf.length > MAX_BLOB_SIZE) throw new Error(`Blob too large (${buf.length} bytes)`);
1666
+ // Enforce total blob size cap (same logic as REST endpoint)
1667
+ let totalSize = 0;
1668
+ for (const [, meta] of blobMeta) totalSize += meta.size;
1669
+ if (totalSize + buf.length > MAX_TOTAL_BLOBS) {
1670
+ const sorted = [...blobMeta.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
1671
+ while (totalSize + buf.length > MAX_TOTAL_BLOBS && sorted.length > 0) {
1672
+ const entry = sorted.shift();
1673
+ if (!entry) break;
1674
+ const [oldKey, oldMeta] = entry;
1675
+ blobStore.delete(oldKey);
1676
+ blobMeta.delete(oldKey);
1677
+ totalSize -= oldMeta.size;
1678
+ }
1679
+ }
1680
+ blobStore.set(key, buf);
1681
+ blobMeta.set(key, { size: buf.length, createdAt: Date.now(), roomId: room.id });
1682
+ return key;
1683
+ };
1684
+ ctx.getBlob = (key: string): ArrayBuffer | undefined => {
1685
+ const buf = blobStore.get(key);
1686
+ return buf ? buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer : undefined;
1687
+ };
1688
+
1689
+ // Execute handler
1690
+ const output = await tool.handler(ctx, validatedInput);
1691
+
1692
+ // Create event
1693
+ const callerRole = callingParticipant?.role;
1694
+ const event: ToolEvent = {
1695
+ id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
1696
+ ts: Date.now(),
1697
+ actorId,
1698
+ owner: ctx.owner,
1699
+ role: callerRole,
1700
+ tool: toolName,
1701
+ input: validatedInput,
1702
+ output,
1703
+ };
1704
+
1705
+ // Compute observation (falls back to defaultObserve when experience has none)
1706
+ let observation: Record<string, unknown> | undefined;
1707
+ const toolObserve = exp.module.observe ?? defaultObserve;
1708
+ try {
1709
+ observation = toolObserve(room.sharedState, event, actorId);
1710
+ } catch (e: unknown) {
1711
+ console.error(`[observe] Error in ${exp.module.manifest.id}:`, toErrorMessage(e));
1712
+ }
1713
+ if (observation) {
1714
+ event.observation = observation;
1715
+ }
1716
+
1717
+ // Append event
1718
+ room.appendEvent(event);
1719
+
1720
+ // Broadcast state update (delta-compressed)
1721
+ room.broadcastStateUpdate({
1722
+ event,
1723
+ changedBy: actorId,
1724
+ tool: toolName,
1725
+ observation,
1726
+ });
1727
+
1728
+ // Emit for long-poll listeners
1729
+ roomEvents.emit(`room:${room.id}`);
1730
+
1731
+ // Notify tick engine if behavior tools were modified (handle scoped tools like "scope:_behavior.set")
1732
+ const baseTool = toolName.includes(':') ? toolName.split(':').pop()! : toolName;
1733
+ if (baseTool.startsWith("_behavior.")) {
1734
+ const engine = tickEngines.get(room.id);
1735
+ if (engine) engine.markDirty();
1736
+ }
1737
+
1738
+ return { tool: toolName, output, observation };
1739
+ }
1740
+
1741
+ // ── Single tool HTTP endpoint ───────────────────────────────
1742
+
1743
+ async function handleTool(room: Room, req: express.Request, res: express.Response): Promise<void> {
1744
+ const exp = getExperienceForRoom(room);
1745
+ if (!exp) { res.status(500).json({ error: experienceNotLoadedError(room.experienceId) }); return; }
1746
+
1747
+ const toolName = req.params.toolName;
1748
+ const { actorId, input: rawInput = {}, owner } = req.body;
1749
+ // Ensure input is a plain object (not array or primitive)
1750
+ const input = rawInput !== null && typeof rawInput === "object" && !Array.isArray(rawInput) ? rawInput : {};
1751
+
1752
+ // Reject requests without actorId (prevents bypassing participant/allowedTools checks)
1753
+ if (!actorId) {
1754
+ res.status(400).json({ error: "actorId is required" });
1755
+ return;
1756
+ }
1757
+
1758
+ // Verify the caller is a participant in this room
1759
+ if (!room.participants.has(actorId)) {
1760
+ res.status(403).json({ error: `Actor '${actorId}' is not a participant in room '${room.id}'. Call /join first.` });
1761
+ return;
1762
+ }
1763
+
1764
+ // Idempotency: return cached result if same key seen recently (room-scoped)
1765
+ // Placed AFTER participant check to prevent kicked/non-participant actors from replaying cached results
1766
+ const rawIdempotencyKey = req.headers["x-idempotency-key"] as string | undefined;
1767
+ // Skip idempotency for excessively long keys (memory amplification prevention)
1768
+ const idempotencyKey = (rawIdempotencyKey && rawIdempotencyKey.length <= 128) ? `${room.id}:${rawIdempotencyKey}` : undefined;
1769
+ if (idempotencyKey) {
1770
+ const cached = idempotencyCache.get(idempotencyKey);
1771
+ if (cached && Date.now() - cached.ts < IDEMPOTENCY_TTL) {
1772
+ res.json({ output: cached.output, cached: true });
1773
+ return;
1774
+ }
1775
+ }
1776
+
1777
+ try {
1778
+ // Serialize tool execution through per-room queue to prevent state interleaving
1779
+ // Wrap in timeout to prevent a hung handler from permanently blocking the room queue
1780
+ // expiredFlag prevents timed-out handlers from mutating state after the queue moves on
1781
+ const expiredFlag = { value: false };
1782
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
1783
+ const result = await room.enqueueExecution(() =>
1784
+ Promise.race([
1785
+ executeTool(room, toolName, actorId, input, owner, expiredFlag),
1786
+ new Promise<never>((_, reject) => {
1787
+ timeoutHandle = setTimeout(() => {
1788
+ expiredFlag.value = true;
1789
+ reject(new Error(`Tool '${toolName}' timed out after ${TOOL_HTTP_TIMEOUT_MS}ms`));
1790
+ }, TOOL_HTTP_TIMEOUT_MS);
1791
+ }),
1792
+ ])
1793
+ );
1794
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
1795
+
1796
+ // Cache for idempotency
1797
+ if (idempotencyKey) {
1798
+ idempotencyCache.set(idempotencyKey, { output: result.output, ts: Date.now() });
1799
+ }
1800
+
1801
+ res.json({ output: result.output, observation: result.observation });
1802
+ } catch (err: unknown) {
1803
+ // Determine HTTP status code based on error type
1804
+ let statusCode = 400;
1805
+ if (err instanceof ToolNotFoundError) statusCode = 404;
1806
+ else if (err instanceof ToolForbiddenError) statusCode = 403;
1807
+
1808
+ // Look up tool for better error formatting (may be undefined if tool not found)
1809
+ const expForError = getExperienceForRoom(room);
1810
+ const toolForError = expForError?.module?.tools?.find((t: ToolDef) => t.name === toolName);
1811
+
1812
+ const errorMsg = err instanceof ZodError
1813
+ ? formatZodError(err, toolName, toolForError)
1814
+ : (err instanceof Error ? formatHandlerError(err, toolName, toolForError, input) : String(err));
1815
+ const resolvedOwner = owner || room.participants.get(actorId)?.owner;
1816
+ const event: ToolEvent = {
1817
+ id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
1818
+ ts: Date.now(),
1819
+ actorId,
1820
+ ...(resolvedOwner ? { owner: resolvedOwner } : {}),
1821
+ tool: toolName,
1822
+ input,
1823
+ error: errorMsg,
1824
+ };
1825
+ room.appendEvent(event);
1826
+ roomEvents.emit(`room:${room.id}`);
1827
+ res.status(statusCode).json({ error: errorMsg });
1828
+ }
1829
+ }
1830
+
1831
+ app.post("/tools/:toolName", (req, res) => handleTool(getDefaultRoom(), req, res));
1832
+
1833
+ // ── Batch tool endpoint ─────────────────────────────────────
1834
+ // Execute multiple tools sequentially in a single HTTP request.
1835
+ // Each tool sees the state left by the previous call, so order matters.
1836
+ // On error, previously successful calls are NOT rolled back.
1837
+ // Returns results for all calls, including errors.
1838
+
1839
+ app.post("/tools-batch", (req, res) => handleBatch(getDefaultRoom(), req, res));
1840
+
1841
+ async function handleBatch(room: Room, req: express.Request, res: express.Response): Promise<void> {
1842
+ const exp = getExperienceForRoom(room);
1843
+ if (!exp) { res.status(500).json({ error: experienceNotLoadedError(room.experienceId) }); return; }
1844
+
1845
+ const { actorId, owner, calls } = req.body;
1846
+
1847
+ if (!actorId) {
1848
+ res.status(400).json({ error: "actorId is required" });
1849
+ return;
1850
+ }
1851
+
1852
+ // Verify the caller is a participant in this room
1853
+ if (actorId && !room.participants.has(actorId)) {
1854
+ res.status(403).json({ error: `Actor '${actorId}' is not a participant in room '${room.id}'. Call /join first.` });
1855
+ return;
1856
+ }
1857
+
1858
+ if (!Array.isArray(calls) || calls.length === 0) {
1859
+ res.status(400).json({ error: "Missing or empty 'calls' array. Expected: [{ tool, input? }, ...]" });
1860
+ return;
1861
+ }
1862
+
1863
+ if (calls.length > MAX_BATCH_CALLS) {
1864
+ res.status(400).json({ error: `Too many calls in batch (${calls.length}). Maximum is ${MAX_BATCH_CALLS}.` });
1865
+ return;
1866
+ }
1867
+
1868
+ // Serialize entire batch through per-room queue so all calls in the batch
1869
+ // are atomic with respect to other concurrent tool calls.
1870
+ // Per-call timeout prevents a hung handler from permanently blocking the room queue.
1871
+ // Aggregate batch timeout prevents total queue hold > 60s.
1872
+ const BATCH_TOTAL_TIMEOUT_MS = 60_000;
1873
+ const batchStart = Date.now();
1874
+ const { results, lastObservation, hasError } = await room.enqueueExecution(async () => {
1875
+ const results: ToolCallResult[] = [];
1876
+ let lastObservation: Record<string, unknown> | undefined;
1877
+ let hasError = false;
1878
+
1879
+ for (const call of calls) {
1880
+ // Check aggregate batch timeout
1881
+ if (Date.now() - batchStart > BATCH_TOTAL_TIMEOUT_MS) {
1882
+ results.push({ tool: call.tool || "?", error: `Batch total timeout exceeded (${BATCH_TOTAL_TIMEOUT_MS}ms)` });
1883
+ hasError = true;
1884
+ continue;
1885
+ }
1886
+ if (!call.tool) {
1887
+ results.push({ tool: "?", error: "Missing 'tool' field in call" });
1888
+ hasError = true;
1889
+ continue;
1890
+ }
1891
+
1892
+ try {
1893
+ const batchExpiredFlag = { value: false };
1894
+ let batchTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
1895
+ const result = await Promise.race([
1896
+ executeTool(room, call.tool, actorId, (call.input !== null && typeof call.input === 'object' && !Array.isArray(call.input)) ? call.input : {}, owner, batchExpiredFlag),
1897
+ new Promise<never>((_, reject) => {
1898
+ batchTimeoutHandle = setTimeout(() => {
1899
+ batchExpiredFlag.value = true;
1900
+ reject(new Error(`Tool '${call.tool}' timed out after ${TOOL_HTTP_TIMEOUT_MS}ms`));
1901
+ }, TOOL_HTTP_TIMEOUT_MS);
1902
+ }),
1903
+ ]);
1904
+ if (batchTimeoutHandle !== undefined) clearTimeout(batchTimeoutHandle);
1905
+ results.push(result);
1906
+ if (result.observation) lastObservation = result.observation;
1907
+ } catch (err: unknown) {
1908
+ const errorMsg = err instanceof ZodError
1909
+ ? formatZodError(err, call.tool)
1910
+ : (err instanceof Error ? err.message : String(err));
1911
+
1912
+ // Log error event
1913
+ const resolvedBatchOwner = owner || room.participants.get(actorId)?.owner;
1914
+ const event: ToolEvent = {
1915
+ id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
1916
+ ts: Date.now(),
1917
+ actorId,
1918
+ ...(resolvedBatchOwner ? { owner: resolvedBatchOwner } : {}),
1919
+ tool: call.tool,
1920
+ input: call.input || {},
1921
+ error: errorMsg,
1922
+ };
1923
+ room.appendEvent(event);
1924
+ roomEvents.emit(`room:${room.id}`);
1925
+
1926
+ results.push({ tool: call.tool, error: errorMsg });
1927
+ hasError = true;
1928
+ }
1929
+ }
1930
+ return { results, lastObservation, hasError };
1931
+ });
1932
+
1933
+ // Return 207 (Multi-Status) if there were mixed results, 200 if all succeeded
1934
+ const statusCode = hasError ? 207 : 200;
1935
+ res.status(statusCode).json({ results, observation: lastObservation });
1936
+ }
1937
+
1938
+ // ── Get events (supports long-poll via ?timeout=N) ──────────
1939
+
1940
+ function handleEvents(room: Room, req: express.Request, res: express.Response): void {
1941
+ const since = queryInt(req.query.since);
1942
+ const timeout = Math.min(queryInt(req.query.timeout), LONG_POLL_MAX_TIMEOUT_MS);
1943
+ const requestingActorId = queryString(req.query.actorId);
1944
+
1945
+ // Helper: compute observation for current state
1946
+ const computeObservation = (events: ToolEvent[]): Record<string, unknown> | undefined => {
1947
+ const exp = getExperienceForRoom(room);
1948
+ if (!requestingActorId) return undefined;
1949
+ const agentObserve = exp?.module?.observe ?? defaultObserve;
1950
+ try {
1951
+ const lastEvent = events.length > 0 ? events[events.length - 1] : null;
1952
+ return agentObserve(room.sharedState, lastEvent, requestingActorId);
1953
+ } catch (e: unknown) {
1954
+ console.error(`[observe] Error in ${exp?.module?.manifest?.id}:`, toErrorMessage(e));
1955
+ return undefined;
1956
+ }
1957
+ };
1958
+
1959
+ const getNewEvents = () => room.events.filter((e) => e.ts > since && e.actorId !== requestingActorId);
1960
+
1961
+ let newEvents = getNewEvents();
1962
+ if (newEvents.length > 0 || timeout === 0) {
1963
+ const observation = computeObservation(newEvents);
1964
+ res.json({
1965
+ events: newEvents,
1966
+ sharedState: room.sharedState,
1967
+ participants: room.participantList(),
1968
+ observation,
1969
+ });
1970
+ return;
1971
+ }
1972
+
1973
+ // Long-poll: wait for event emission or timeout
1974
+ let responded = false;
1975
+
1976
+ let batchTimer: ReturnType<typeof setTimeout> | null = null;
1977
+
1978
+ const respond = () => {
1979
+ if (responded) return;
1980
+ responded = true;
1981
+ clearTimeout(timer);
1982
+ if (batchTimer) { clearTimeout(batchTimer); batchTimer = null; }
1983
+ roomEvents.removeListener(`room:${room.id}`, onEvent);
1984
+ newEvents = getNewEvents();
1985
+ const observation = computeObservation(newEvents);
1986
+ res.json({
1987
+ events: newEvents,
1988
+ sharedState: room.sharedState,
1989
+ participants: room.participantList(),
1990
+ observation,
1991
+ });
1992
+ };
1993
+
1994
+ const timer = setTimeout(respond, timeout);
1995
+
1996
+ const onEvent = () => {
1997
+ if (responded) return;
1998
+ if (batchTimer) return; // Deduplicate rapid events
1999
+ // Small delay to batch rapid events
2000
+ batchTimer = setTimeout(() => {
2001
+ batchTimer = null;
2002
+ if (responded) return;
2003
+ // Only respond if there are actual new tool events.
2004
+ // Streams modify state and emit roomEvents but don't create
2005
+ // event log entries — ignore those wake-ups so the long-poll
2006
+ // keeps waiting for real tool events (like _chat.send).
2007
+ const pending = getNewEvents();
2008
+ if (pending.length > 0) {
2009
+ respond();
2010
+ }
2011
+ }, EVENT_BATCH_DEBOUNCE_MS);
2012
+ };
2013
+
2014
+ roomEvents.on(`room:${room.id}`, onEvent);
2015
+
2016
+ // Cleanup on client disconnect
2017
+ req.on("close", () => {
2018
+ responded = true;
2019
+ clearTimeout(timer);
2020
+ if (batchTimer) clearTimeout(batchTimer);
2021
+ roomEvents.removeListener(`room:${room.id}`, onEvent);
2022
+ });
2023
+ }
2024
+
2025
+ app.get("/events", (req, res) => handleEvents(getDefaultRoom(), req, res));
2026
+
2027
+ // ── Cross-room events (watch all rooms) ────────────────────
2028
+
2029
+ app.get("/events/all", (req, res) => {
2030
+ const since = queryInt(req.query.since);
2031
+ const timeout = Math.min(queryInt(req.query.timeout), LONG_POLL_MAX_TIMEOUT_MS);
2032
+ const requestingActorId = queryString(req.query.actorId);
2033
+
2034
+ const getAllEvents = () => {
2035
+ const allEvents: Array<ToolEvent & { roomId: string }> = [];
2036
+ for (const room of rooms.values()) {
2037
+ for (const e of room.events) {
2038
+ if (e.ts > since) {
2039
+ // Self-filter: skip own events (consistent with /events and /agent-context)
2040
+ if (requestingActorId && e.actorId === requestingActorId) continue;
2041
+ allEvents.push({ ...e, roomId: room.id });
2042
+ }
2043
+ }
2044
+ }
2045
+ allEvents.sort((a, b) => a.ts - b.ts);
2046
+ return allEvents;
2047
+ };
2048
+
2049
+ const getRoomSummaries = () =>
2050
+ Array.from(rooms.values()).map((room) => ({
2051
+ roomId: room.id,
2052
+ experienceId: room.experienceId,
2053
+ participants: room.participantList(),
2054
+ eventCount: room.events.length,
2055
+ }));
2056
+
2057
+ let newEvents = getAllEvents();
2058
+ if (newEvents.length > 0 || timeout === 0) {
2059
+ res.json({ events: newEvents, rooms: getRoomSummaries() });
2060
+ return;
2061
+ }
2062
+
2063
+ // Long-poll: wait for any room event
2064
+ let responded = false;
2065
+
2066
+ let batchTimer: ReturnType<typeof setTimeout> | null = null;
2067
+
2068
+ const respond = () => {
2069
+ if (responded) return;
2070
+ responded = true;
2071
+ clearTimeout(timer);
2072
+ if (batchTimer) { clearTimeout(batchTimer); batchTimer = null; }
2073
+ clearInterval(newRoomCheck);
2074
+ for (const rid of subscribedRoomIds) {
2075
+ roomEvents.removeListener(`room:${rid}`, onEvent);
2076
+ }
2077
+ res.json({ events: getAllEvents(), rooms: getRoomSummaries() });
2078
+ };
2079
+
2080
+ const timer = setTimeout(respond, timeout);
2081
+
2082
+ const onEvent = () => {
2083
+ if (responded) return;
2084
+ if (batchTimer) return;
2085
+ batchTimer = setTimeout(() => {
2086
+ batchTimer = null;
2087
+ if (responded) return;
2088
+ // Only respond if there are actual new tool events — ignore
2089
+ // stream-only wake-ups (streams don't create event log entries).
2090
+ const pending = getAllEvents();
2091
+ if (pending.length > 0) respond();
2092
+ }, EVENT_BATCH_DEBOUNCE_MS);
2093
+ };
2094
+
2095
+ // Listen on all existing rooms + track subscribed IDs for cleanup
2096
+ const subscribedRoomIds = new Set<string>();
2097
+ for (const room of rooms.values()) {
2098
+ roomEvents.on(`room:${room.id}`, onEvent);
2099
+ subscribedRoomIds.add(room.id);
2100
+ }
2101
+
2102
+ // Also check periodically for newly spawned rooms during long-poll
2103
+ const newRoomCheck = setInterval(() => {
2104
+ if (responded) { clearInterval(newRoomCheck); return; }
2105
+ for (const room of rooms.values()) {
2106
+ if (!subscribedRoomIds.has(room.id)) {
2107
+ roomEvents.on(`room:${room.id}`, onEvent);
2108
+ subscribedRoomIds.add(room.id);
2109
+ }
2110
+ }
2111
+ }, 2000);
2112
+
2113
+ req.on("close", () => {
2114
+ responded = true;
2115
+ clearTimeout(timer);
2116
+ if (batchTimer) clearTimeout(batchTimer);
2117
+ clearInterval(newRoomCheck);
2118
+ for (const rid of subscribedRoomIds) {
2119
+ roomEvents.removeListener(`room:${rid}`, onEvent);
2120
+ }
2121
+ });
2122
+ });
2123
+
2124
+ // ── Browser error capture ──────────────────────────────────────
2125
+ // The viewer POSTs browser errors here so the agent can see them
2126
+ // via /agent-context and fix them automatically.
2127
+
2128
+ const roomBrowserErrors: Map<string, { message: string; ts: number }[]> = new Map();
2129
+ const MAX_BROWSER_ERRORS = 20;
2130
+
2131
+ const lastBrowserErrorAt: Map<string, number> = new Map(); // Rate limit per room
2132
+ const BROWSER_ERROR_COOLDOWN_MS = 200; // Min interval between error-triggered wake-ups
2133
+ const MAX_BROWSER_ERROR_MSG_LEN = 500; // Truncate overlong error messages
2134
+
2135
+ app.post("/browser-error", (req, res) => {
2136
+ const { message, roomId: errorRoomId } = req.body || {};
2137
+ if (typeof message === "string" && message.trim()) {
2138
+ // Truncate message to prevent memory bloat
2139
+ const trimmed = message.trim().slice(0, MAX_BROWSER_ERROR_MSG_LEN);
2140
+ const now = Date.now();
2141
+
2142
+ // Scope errors to the room they came from
2143
+ const targetRoom = (typeof errorRoomId === "string" && rooms.has(errorRoomId))
2144
+ ? errorRoomId
2145
+ : getDefaultRoom().id;
2146
+
2147
+ let errors = roomBrowserErrors.get(targetRoom);
2148
+ if (!errors) {
2149
+ errors = [];
2150
+ roomBrowserErrors.set(targetRoom, errors);
2151
+ }
2152
+ errors.push({ message: trimmed, ts: now });
2153
+ if (errors.length > MAX_BROWSER_ERRORS) {
2154
+ errors.splice(0, errors.length - MAX_BROWSER_ERRORS);
2155
+ }
2156
+
2157
+ // Rate-limit wake-ups per room
2158
+ const lastErrorAt = lastBrowserErrorAt.get(targetRoom) || 0;
2159
+ if (now - lastErrorAt >= BROWSER_ERROR_COOLDOWN_MS) {
2160
+ lastBrowserErrorAt.set(targetRoom, now);
2161
+ roomEvents.emit(`room:${targetRoom}`);
2162
+ }
2163
+ }
2164
+ res.json({ ok: true });
2165
+ });
2166
+
2167
+ // ── Agent context (combined events + observe for Stop hook) ──
2168
+
2169
+ app.get("/agent-context", (req, res) => {
2170
+ const rawSince = queryInt(req.query.since);
2171
+ const timeout = Math.min(queryInt(req.query.timeout), AGENT_CONTEXT_MAX_TIMEOUT_MS);
2172
+ const actorId = queryString(req.query.actorId) || "unknown";
2173
+ const requestedRoomId = queryString(req.query.roomId);
2174
+
2175
+ // Find the agent's actual room:
2176
+ // 1. Explicit roomId query param (preferred)
2177
+ // 2. Search all rooms for the actorId
2178
+ // 3. Fall back to default room
2179
+ let agentRoom: Room = getDefaultRoom();
2180
+ if (requestedRoomId) {
2181
+ const r = getRoom(requestedRoomId);
2182
+ if (r) agentRoom = r;
2183
+ } else {
2184
+ for (const room of rooms.values()) {
2185
+ if (room.participants.has(actorId)) {
2186
+ agentRoom = room;
2187
+ break;
2188
+ }
2189
+ }
2190
+ }
2191
+
2192
+ // Resolve owner: explicit query param > participant record from agent's room
2193
+ // Never fall back to actorId.split — that caused multi-agent collisions
2194
+ // where two agents both resolved to the same prefix and filtered each other out
2195
+ const participantEntry = agentRoom.participants.get(actorId);
2196
+ const requestingOwner = queryString(req.query.owner) || participantEntry?.owner;
2197
+
2198
+ // Use server-tracked cursor when client sends since=0 (e.g. stop hook runs
2199
+ // as a fresh process each time and can't persist cursors between invocations).
2200
+ // This makes the server the true source of truth for cursor state.
2201
+ const since = (rawSince === 0 && participantEntry?.eventCursor)
2202
+ ? participantEntry.eventCursor
2203
+ : rawSince;
2204
+
2205
+ // Update lastPollAt for AI heartbeat tracking
2206
+ if (participantEntry) {
2207
+ participantEntry.lastPollAt = Date.now();
2208
+ }
2209
+
2210
+ // Gather events from ALL rooms (not just default) so the agent sees sub-room activity
2211
+ const getAllNewEvents = () => {
2212
+ const allEvents: (ToolEvent & { roomId: string })[] = [];
2213
+ for (const room of rooms.values()) {
2214
+ for (const e of room.events) {
2215
+ // Self-filter: skip events from the same owner
2216
+ // If either side has no owner, don't filter (safe default: show the event)
2217
+ if (requestingOwner && e.owner === requestingOwner) continue;
2218
+ // Skip tick engine events — agents react to observations, not individual ticks
2219
+ if (e.actorId === "_tick-engine" || e.owner === "_system") continue;
2220
+ if (e.ts > since) {
2221
+ allEvents.push({ ...e, roomId: room.id });
2222
+ }
2223
+ }
2224
+ }
2225
+ return allEvents.sort((a, b) => a.ts - b.ts);
2226
+ };
2227
+
2228
+ // Collect all participants and available tools across rooms
2229
+ const getAllParticipants = () => {
2230
+ const all = new Set<string>();
2231
+ for (const room of rooms.values()) {
2232
+ for (const p of room.participantList()) all.add(p);
2233
+ }
2234
+ return [...all];
2235
+ };
2236
+
2237
+ // Resolve the requesting agent's allowed tools from their participant record in their actual room
2238
+ const requestingParticipant = agentRoom.participants.get(actorId);
2239
+ const agentAllowedTools = requestingParticipant?.allowedTools;
2240
+
2241
+ const getAllRoomInfo = () => {
2242
+ const info: Record<string, { experience: string; tools: string[]; participants: string[] }> = {};
2243
+ for (const room of rooms.values()) {
2244
+ const exp = getExperienceForRoom(room);
2245
+ let toolNames = exp?.module?.tools?.map((t: ToolDef) => t.name) || [];
2246
+ if (agentAllowedTools) toolNames = toolNames.filter((n: string) => agentAllowedTools.includes(n));
2247
+ info[room.id] = {
2248
+ experience: exp?.module?.manifest?.id || room.experienceId || "unknown",
2249
+ tools: toolNames,
2250
+ participants: room.participantList(),
2251
+ };
2252
+ }
2253
+ return info;
2254
+ };
2255
+
2256
+ const buildResponse = () => {
2257
+ // Check if agentRoom was deleted during long-poll
2258
+ if (!rooms.has(agentRoom.id)) {
2259
+ return {
2260
+ events: [],
2261
+ observation: { done: true, reason: "room_deleted" },
2262
+ participants: getAllParticipants(),
2263
+ rooms: getAllRoomInfo(),
2264
+ };
2265
+ }
2266
+
2267
+ const events = getAllNewEvents();
2268
+
2269
+ // Check if this agent was kicked — return done:true so the stop hook archives the state file
2270
+ if (agentRoom.kickedActors.has(actorId)) {
2271
+ agentRoom.kickedActors.delete(actorId); // One-shot: clear after notifying
2272
+ return {
2273
+ events: [],
2274
+ observation: { done: true, reason: "kicked" },
2275
+ participants: getAllParticipants(),
2276
+ rooms: getAllRoomInfo(),
2277
+ };
2278
+ }
2279
+
2280
+ // Compute observation from the agent's actual room (not always default)
2281
+ const observeExp = getExperienceForRoom(agentRoom);
2282
+ let observation: Record<string, unknown> | undefined;
2283
+ let observeError: string | undefined;
2284
+ if (observeExp?.module?.observe) {
2285
+ try {
2286
+ const lastEvent = events.length > 0 ? events[events.length - 1] : null;
2287
+ observation = observeExp.module.observe(agentRoom.sharedState, lastEvent, actorId);
2288
+ } catch (e: unknown) {
2289
+ console.error(`[observe] Error in ${observeExp.module.manifest?.id}:`, toErrorMessage(e));
2290
+ observeError = toErrorMessage(e);
2291
+ }
2292
+ }
2293
+
2294
+ // Find the agent's most recent error (own events are filtered from `events`,
2295
+ // so scan raw room events for this actor's errors since the last poll)
2296
+ let lastError: { tool: string; error: string } | undefined;
2297
+ for (const room of rooms.values()) {
2298
+ for (const e of room.events) {
2299
+ if (e.ts > since && e.error && e.actorId === actorId) {
2300
+ lastError = { tool: e.tool, error: e.error };
2301
+ }
2302
+ }
2303
+ }
2304
+
2305
+ // Collect tick engine status across all rooms
2306
+ const tickStatus: Record<string, { enabled: boolean; tickCount: number }> = {};
2307
+ for (const [roomId, engine] of tickEngines) {
2308
+ const status = engine.getStatus();
2309
+ tickStatus[roomId] = {
2310
+ enabled: status.enabled,
2311
+ tickCount: status.tickCount,
2312
+ };
2313
+ }
2314
+
2315
+ // Collect browser errors for this agent's room since last poll
2316
+ const roomErrors = roomBrowserErrors.get(agentRoom.id) || [];
2317
+ const recentBrowserErrors = roomErrors.filter(e => e.ts > since);
2318
+ // Clear reported errors for this room so they don't repeat
2319
+ if (recentBrowserErrors.length > 0) {
2320
+ roomBrowserErrors.set(agentRoom.id, roomErrors.filter(e => e.ts <= since));
2321
+ }
2322
+
2323
+ // Track eventCursor server-side so agents don't need local state files
2324
+ let eventCursor: number | undefined;
2325
+ if (events.length > 0 && participantEntry) {
2326
+ eventCursor = Math.max(
2327
+ participantEntry.eventCursor || 0,
2328
+ ...events.map(e => e.ts)
2329
+ );
2330
+ participantEntry.eventCursor = eventCursor;
2331
+ } else if (participantEntry?.eventCursor) {
2332
+ eventCursor = participantEntry.eventCursor;
2333
+ }
2334
+
2335
+ return {
2336
+ events,
2337
+ observation: observation || {},
2338
+ observeError,
2339
+ lastError,
2340
+ browserErrors: recentBrowserErrors.length > 0 ? recentBrowserErrors : undefined,
2341
+ participants: getAllParticipants(),
2342
+ rooms: getAllRoomInfo(),
2343
+ tickEngines: Object.keys(tickStatus).length > 0 ? tickStatus : undefined,
2344
+ eventCursor,
2345
+ };
2346
+ };
2347
+
2348
+ // If events, browser errors, or kicked status are already available, respond immediately
2349
+ let newEvents = getAllNewEvents();
2350
+ const pendingBrowserErrors = (roomBrowserErrors.get(agentRoom.id) || []).filter(e => e.ts > since);
2351
+ const isKicked = agentRoom.kickedActors.has(actorId);
2352
+ if (newEvents.length > 0 || pendingBrowserErrors.length > 0 || isKicked || timeout === 0) {
2353
+ res.json(buildResponse());
2354
+ return;
2355
+ }
2356
+
2357
+ // Long-poll: wait for events or timeout
2358
+ let responded = false;
2359
+
2360
+ let batchTimer: ReturnType<typeof setTimeout> | null = null;
2361
+
2362
+ const respond = () => {
2363
+ if (responded) return;
2364
+ responded = true;
2365
+ clearTimeout(timer);
2366
+ if (batchTimer) { clearTimeout(batchTimer); batchTimer = null; }
2367
+ clearInterval(newRoomCheck);
2368
+ // Remove listeners from all subscribed rooms (tracked set handles deleted rooms)
2369
+ for (const rid of subscribedRoomIds) {
2370
+ roomEvents.removeListener(`room:${rid}`, onEvent);
2371
+ }
2372
+ res.json(buildResponse());
2373
+ };
2374
+
2375
+ const timer = setTimeout(respond, timeout);
2376
+
2377
+ const onEvent = () => {
2378
+ if (responded) return;
2379
+ if (batchTimer) return;
2380
+ batchTimer = setTimeout(() => {
2381
+ batchTimer = null;
2382
+ if (responded) return;
2383
+ const pending = getAllNewEvents();
2384
+ if (pending.length > 0 || agentRoom.kickedActors.has(actorId)) respond();
2385
+ }, EVENT_BATCH_DEBOUNCE_MS);
2386
+ };
2387
+
2388
+ // Listen on ALL rooms + track subscribed IDs for cleanup
2389
+ const subscribedRoomIds = new Set<string>();
2390
+ for (const room of rooms.values()) {
2391
+ roomEvents.on(`room:${room.id}`, onEvent);
2392
+ subscribedRoomIds.add(room.id);
2393
+ }
2394
+
2395
+ // Check periodically for newly spawned rooms during long-poll
2396
+ const newRoomCheck = setInterval(() => {
2397
+ if (responded) { clearInterval(newRoomCheck); return; }
2398
+ for (const room of rooms.values()) {
2399
+ if (!subscribedRoomIds.has(room.id)) {
2400
+ roomEvents.on(`room:${room.id}`, onEvent);
2401
+ subscribedRoomIds.add(room.id);
2402
+ }
2403
+ }
2404
+ }, 2000);
2405
+
2406
+ req.on("close", () => {
2407
+ responded = true;
2408
+ clearTimeout(timer);
2409
+ if (batchTimer) clearTimeout(batchTimer);
2410
+ clearInterval(newRoomCheck);
2411
+ for (const rid of subscribedRoomIds) {
2412
+ roomEvents.removeListener(`room:${rid}`, onEvent);
2413
+ }
2414
+ });
2415
+ });
2416
+
2417
+ // ── Serve client bundle ────────────────────────────────────
2418
+
2419
+ app.get("/bundle", async (_req, res) => {
2420
+ // Wait for any in-progress hot-reload to complete before serving
2421
+ if (rebuildingPromise) await rebuildingPromise;
2422
+ const host = experienceCache.get(hostExperienceId);
2423
+ res.setHeader("Content-Type", "text/javascript");
2424
+ setNoCacheHeaders(res);
2425
+ res.send(host?.clientBundle || "");
2426
+ });
2427
+
2428
+ // ── Memory endpoints ───────────────────────────────────────
2429
+
2430
+ app.get("/memory", (req, res) => {
2431
+ const key = queryString(req.query.key);
2432
+ if (!key) { res.json({}); return; }
2433
+ res.json(agentMemory.get(key) || {});
2434
+ });
2435
+
2436
+ app.post("/memory", (req, res) => {
2437
+ const { key, updates } = req.body;
2438
+ if (!key) { res.status(400).json({ error: "key required" }); return; }
2439
+ if (!updates || typeof updates !== "object" || Array.isArray(updates)) { res.status(400).json({ error: "updates must be a plain object" }); return; }
2440
+ const current = agentMemory.get(key) || {};
2441
+ const merged = deepMerge(current, updates);
2442
+ // Cap per-key object to 100 keys (same limit as tool-path setMemory)
2443
+ const keys = Object.keys(merged);
2444
+ if (keys.length > 100) {
2445
+ res.status(400).json({ error: `Memory key limit exceeded (${keys.length}/100)` });
2446
+ return;
2447
+ }
2448
+ agentMemory.set(key, merged);
2449
+ res.json({ saved: true });
2450
+ });
2451
+
2452
+ // ── Blob store endpoints ──────────────────────────────────
2453
+ app.get("/blobs/:key", (req, res) => {
2454
+ const blob = blobStore.get(req.params.key);
2455
+ if (!blob) { res.status(404).json({ error: "Blob not found" }); return; }
2456
+ res.setHeader("Content-Type", "application/octet-stream");
2457
+ res.setHeader("Content-Length", String(blob.length));
2458
+ res.setHeader("Cache-Control", `public, max-age=${BLOB_CACHE_MAX_AGE_SECONDS}`);
2459
+ res.send(blob);
2460
+ });
2461
+
2462
+ app.post("/blobs/:key", express.raw({ limit: "10mb", type: "*/*" }), (req, res) => {
2463
+ const key = req.params.key;
2464
+ const { roomId, actorId } = req.query as { roomId?: string; actorId?: string };
2465
+
2466
+ // Validate blob key: safe characters only, no prototype-pollution keys
2467
+ if (!/^[a-zA-Z0-9._\-]+$/.test(key) || key.length > 256) {
2468
+ res.status(400).json({ error: "Invalid blob key format" });
2469
+ return;
2470
+ }
2471
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
2472
+ res.status(400).json({ error: "Reserved blob key name" });
2473
+ return;
2474
+ }
2475
+
2476
+ if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
2477
+ res.status(400).json({ error: "Empty or invalid blob data" });
2478
+ return;
2479
+ }
2480
+
2481
+ if (req.body.length > MAX_BLOB_SIZE) {
2482
+ res.status(413).json({ error: `Blob too large (${req.body.length} bytes, max ${MAX_BLOB_SIZE})` });
2483
+ return;
2484
+ }
2485
+
2486
+ // Check total size
2487
+ let totalSize = 0;
2488
+ for (const [, meta] of blobMeta) totalSize += meta.size;
2489
+ if (totalSize + req.body.length > MAX_TOTAL_BLOBS) {
2490
+ // Garbage collect: remove oldest blobs until we have space
2491
+ const sorted = [...blobMeta.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
2492
+ while (totalSize + req.body.length > MAX_TOTAL_BLOBS && sorted.length > 0) {
2493
+ const [oldKey, oldMeta] = sorted.shift()!;
2494
+ blobStore.delete(oldKey);
2495
+ blobMeta.delete(oldKey);
2496
+ totalSize -= oldMeta.size;
2497
+ }
2498
+ }
2499
+
2500
+ blobStore.set(key, req.body);
2501
+ blobMeta.set(key, { size: req.body.length, createdAt: Date.now(), roomId: roomId || "local" });
2502
+
2503
+ res.json({ key, size: req.body.length });
2504
+ });
2505
+
2506
+ app.delete("/blobs/:key", (req, res) => {
2507
+ const key = req.params.key;
2508
+ // Validate key format (same safe-char check as POST)
2509
+ if (!/^[a-zA-Z0-9_\-.:]+$/.test(key) || key.length > 200) {
2510
+ res.status(400).json({ error: "Invalid blob key" });
2511
+ return;
2512
+ }
2513
+ if (!blobStore.has(key)) {
2514
+ res.status(404).json({ error: "Blob not found" });
2515
+ return;
2516
+ }
2517
+ blobStore.delete(key);
2518
+ blobMeta.delete(key);
2519
+ res.json({ deleted: true });
2520
+ });
2521
+
2522
+ app.get("/blobs", (_req, res) => {
2523
+ const list = [...blobMeta.entries()].map(([key, meta]) => ({ key, ...meta }));
2524
+ res.json(list);
2525
+ });
2526
+
2527
+ // ── Screenshot capture ────────────────────────────────────
2528
+
2529
+ const pendingScreenshots = new Map<string, {
2530
+ resolve: (data: Buffer) => void;
2531
+ reject: (err: Error) => void;
2532
+ timer: NodeJS.Timeout;
2533
+ viewerWs: WebSocket;
2534
+ }>();
2535
+
2536
+ app.get("/screenshot", (req, res) => {
2537
+ // Find an open viewer WebSocket connection (check default room first, then all rooms)
2538
+ let viewerWs: WebSocket | null = null;
2539
+ for (const room of rooms.values()) {
2540
+ for (const [ws] of room.wsConnections) {
2541
+ if (ws.readyState === WebSocket.OPEN) {
2542
+ viewerWs = ws;
2543
+ break;
2544
+ }
2545
+ }
2546
+ if (viewerWs) break;
2547
+ }
2548
+
2549
+ if (!viewerWs) {
2550
+ res.status(503).json({ error: `No browser viewer connected. Open ${getBaseUrl()} first.` });
2551
+ return;
2552
+ }
2553
+
2554
+ const requestId = `ss-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
2555
+ const timeoutMs = Math.min(queryInt(req.query.timeout) || SCREENSHOT_DEFAULT_TIMEOUT_MS, SCREENSHOT_MAX_TIMEOUT_MS);
2556
+
2557
+ const promise = new Promise<Buffer>((resolve, reject) => {
2558
+ const timer = setTimeout(() => {
2559
+ pendingScreenshots.delete(requestId);
2560
+ reject(new Error("Screenshot timeout"));
2561
+ }, timeoutMs);
2562
+
2563
+ pendingScreenshots.set(requestId, { resolve, reject, timer, viewerWs: viewerWs! });
2564
+ });
2565
+
2566
+ // Clean up if HTTP client disconnects before screenshot arrives
2567
+ req.on("close", () => {
2568
+ const p = pendingScreenshots.get(requestId);
2569
+ if (p) {
2570
+ clearTimeout(p.timer);
2571
+ pendingScreenshots.delete(requestId);
2572
+ p.reject(new Error("Client disconnected"));
2573
+ }
2574
+ });
2575
+
2576
+ // Ask the viewer to capture
2577
+ try {
2578
+ viewerWs.send(JSON.stringify({ type: "screenshot_request", id: requestId }));
2579
+ } catch (err) {
2580
+ const p = pendingScreenshots.get(requestId);
2581
+ if (p) {
2582
+ clearTimeout(p.timer);
2583
+ pendingScreenshots.delete(requestId);
2584
+ p.reject(new Error("WebSocket send failed"));
2585
+ }
2586
+ }
2587
+
2588
+ promise
2589
+ .then((pngBuffer) => {
2590
+ res.setHeader("Content-Type", "image/png");
2591
+ res.setHeader("Content-Length", String(pngBuffer.length));
2592
+ res.send(pngBuffer);
2593
+ })
2594
+ .catch((err) => {
2595
+ res.status(500).json({ error: toErrorMessage(err) });
2596
+ });
2597
+ });
2598
+
2599
+ // ── Sync (re-bundle) ──────────────────────────────────────
2600
+
2601
+ app.post("/sync", async (_req, res) => {
2602
+ try {
2603
+ // Stop tick engines for host rooms before reloading (prevents ghost tick loops)
2604
+ for (const room of rooms.values()) {
2605
+ if (room.experienceId === hostExperienceId) {
2606
+ stopTickEngine(room.id);
2607
+ }
2608
+ }
2609
+ await loadHost();
2610
+ const hostLoaded = experienceCache.get(hostExperienceId);
2611
+ // Broadcast and restart tick engines for rooms running the host experience
2612
+ for (const room of rooms.values()) {
2613
+ if (room.experienceId === hostExperienceId) {
2614
+ room.broadcastToAll({ type: "experience_updated" });
2615
+ if (hostLoaded) maybeStartTickEngine(room, hostLoaded);
2616
+ }
2617
+ }
2618
+ const host = getHostExperience();
2619
+ res.json({ synced: true, title: host?.manifest?.title });
2620
+ } catch (err: unknown) {
2621
+ res.status(500).json({ error: toErrorMessage(err) });
2622
+ }
2623
+ });
2624
+
2625
+ // ── Room management routes ─────────────────────────────────
2626
+
2627
+ app.get("/rooms", (_req, res) => {
2628
+ const roomList = Array.from(rooms.values()).map((room) => {
2629
+ const exp = experienceCache.get(room.experienceId);
2630
+ const st = room.sharedState as Record<string, unknown>;
2631
+ const flags: Record<string, unknown> = {};
2632
+ if (st._recruitment) flags._recruitment = st._recruitment;
2633
+ if (st._announcement) flags._announcement = st._announcement;
2634
+ return {
2635
+ roomId: room.id,
2636
+ experienceId: room.experienceId,
2637
+ experienceTitle: exp?.module?.manifest?.title || room.experienceId,
2638
+ participants: room.participantDetails(),
2639
+ participantCount: room.participants.size,
2640
+ eventCount: room.events.length,
2641
+ parentRoomId: room.parentRoomId,
2642
+ childRoomIds: room.childRoomIds,
2643
+ config: room.config,
2644
+ flags,
2645
+ };
2646
+ });
2647
+ res.json(roomList);
2648
+ });
2649
+
2650
+ app.post("/rooms/spawn", async (req, res) => {
2651
+ try {
2652
+ const { experienceId, name, initialState, linkBack, sourceRoomId, config } = req.body;
2653
+ const source = sourceRoomId || DEFAULT_ROOM_ID;
2654
+ // Validate sourceRoomId exists (prevents rate limit bypass via crafted sourceRoomId)
2655
+ if (sourceRoomId && !rooms.has(source)) {
2656
+ res.status(400).json({ error: `Source room '${source}' not found` });
2657
+ return;
2658
+ }
2659
+ // Only the actual library room bypasses rate limiting
2660
+ const skipRateLimit = source === "library" && rooms.has("library");
2661
+ const result = await spawnRoom(source, {
2662
+ experienceId: experienceId || hostExperienceId,
2663
+ name,
2664
+ initialState,
2665
+ linkBack,
2666
+ config,
2667
+ skipRateLimit,
2668
+ });
2669
+ res.json(result);
2670
+ } catch (err: unknown) {
2671
+ res.status(400).json({ error: toErrorMessage(err) });
2672
+ }
2673
+ });
2674
+
2675
+ // ── Room config schema endpoint ────────────────────────────
2676
+ app.get("/rooms/config-schema", async (req, res) => {
2677
+ try {
2678
+ const loaded = await getLoadedExperience();
2679
+ const roomConfigDef = loaded.module?.roomConfig;
2680
+
2681
+ if (!roomConfigDef) {
2682
+ res.json({ hasConfig: false, experienceId: hostExperienceId });
2683
+ return;
2684
+ }
2685
+ const schema = roomConfigDef.schema
2686
+ ? zodToJsonSchema(roomConfigDef.schema)
2687
+ : {};
2688
+ res.json({
2689
+ hasConfig: true,
2690
+ experienceId: hostExperienceId,
2691
+ schema,
2692
+ defaults: roomConfigDef.defaults || {},
2693
+ presets: Object.keys(roomConfigDef.presets || {}),
2694
+ description: roomConfigDef.description || "",
2695
+ });
2696
+ } catch (err: unknown) {
2697
+ res.status(400).json({ error: toErrorMessage(err) });
2698
+ }
2699
+ });
2700
+
2701
+ // ── Tick status endpoint ───────────────────────────────────
2702
+
2703
+ app.get("/rooms/:roomId/tick-status", (req, res) => {
2704
+ const room = getRoom(req.params.roomId);
2705
+ if (!room) {
2706
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
2707
+ return;
2708
+ }
2709
+ const engine = tickEngines.get(room.id);
2710
+ if (!engine) {
2711
+ res.json({ enabled: false, tickRateMs: 0, tickCount: 0, behaviorsTotal: 0, behaviorsEnabled: 0, lastTick: null });
2712
+ return;
2713
+ }
2714
+ res.json(engine.getStatus());
2715
+ });
2716
+
2717
+ // ── Experiences endpoint (discovery for agents) ────────────
2718
+
2719
+ app.get("/experiences", async (_req, res) => {
2720
+ try {
2721
+ const loaded = await getLoadedExperience();
2722
+ res.json([{
2723
+ id: loaded.module.manifest.id,
2724
+ title: loaded.module.manifest.title,
2725
+ description: loaded.module.manifest.description,
2726
+ version: loaded.module.manifest.version,
2727
+ loaded: true,
2728
+ hasRoomConfig: !!loaded.module.roomConfig,
2729
+ category: loaded.module.manifest.category,
2730
+ tags: loaded.module.manifest.tags,
2731
+ }]);
2732
+ } catch (err: unknown) {
2733
+ res.json([]);
2734
+ }
2735
+ });
2736
+
2737
+ app.get("/rooms/:roomId", (req, res) => {
2738
+ const room = getRoom(req.params.roomId);
2739
+ if (!room) {
2740
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
2741
+ return;
2742
+ }
2743
+ const exp = getExperienceForRoom(room);
2744
+ let observation: Record<string, unknown> | undefined;
2745
+ const roomObserve = exp?.module?.observe ?? defaultObserve;
2746
+ const observeActorId = typeof req.query.actorId === "string" ? req.query.actorId : "viewer";
2747
+ try { observation = roomObserve(room.sharedState, null, observeActorId); } catch (err: unknown) { console.warn(`[observe] Error: ${toErrorMessage(err)}`); }
2748
+ res.json({
2749
+ roomId: room.id,
2750
+ experienceId: room.experienceId,
2751
+ sharedState: room.sharedState,
2752
+ stateVersion: room.stateVersion,
2753
+ participants: room.participantList(),
2754
+ events: room.events.slice(-ROOM_STATE_EVENT_HISTORY),
2755
+ parentRoomId: room.parentRoomId,
2756
+ childRoomIds: room.childRoomIds,
2757
+ config: room.config,
2758
+ observation,
2759
+ });
2760
+ });
2761
+
2762
+ app.get("/rooms/:roomId/links", (req, res) => {
2763
+ const roomId = req.params.roomId;
2764
+ const links = roomLinks.filter(
2765
+ (l) => l.parentRoomId === roomId || l.childRoomId === roomId,
2766
+ );
2767
+ res.json(links);
2768
+ });
2769
+
2770
+ // ── Slots (query participant slot definitions + occupancy) ────
2771
+ app.get("/rooms/:roomId/slots", (req, res) => {
2772
+ const room = getRoom(req.params.roomId);
2773
+ if (!room) {
2774
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
2775
+ return;
2776
+ }
2777
+ const exp = getExperienceForRoom(room);
2778
+ const slots = exp?.module?.manifest?.participantSlots || exp?.module?.participants || [];
2779
+ res.json({
2780
+ slots,
2781
+ participantDetails: room.participantDetails(),
2782
+ });
2783
+ });
2784
+
2785
+ app.post("/rooms/:roomId/join", (req, res) => {
2786
+ const room = getRoom(req.params.roomId);
2787
+ if (!room) {
2788
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
2789
+ return;
2790
+ }
2791
+ handleJoin(room, req, res);
2792
+ });
2793
+
2794
+ app.post("/rooms/:roomId/leave", (req, res) => {
2795
+ const room = getRoom(req.params.roomId);
2796
+ if (!room) {
2797
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
2798
+ return;
2799
+ }
2800
+ handleLeave(room, req, res);
2801
+ });
2802
+
2803
+ app.post("/rooms/:roomId/kick", (req, res) => {
2804
+ const room = getRoom(req.params.roomId);
2805
+ if (!room) {
2806
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
2807
+ return;
2808
+ }
2809
+ const result = handleKick(room, req.body.kickerActorId, req.body.targetActorId);
2810
+ res.status(result.error ? 400 : 200).json(result);
2811
+ });
2812
+
2813
+ // ── Reset room to initial state ──────────────────────────────
2814
+ app.post("/rooms/:roomId/reset", (req, res) => {
2815
+ const room = getRoom(req.params.roomId);
2816
+ if (!room) {
2817
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
2818
+ return;
2819
+ }
2820
+ const loaded = getExperienceForRoom(room);
2821
+ if (!loaded) {
2822
+ res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
2823
+ return;
2824
+ }
2825
+ const config = resolveRoomConfig(loaded.module, room.config);
2826
+ const initialState = resolveInitialState(loaded.module, config);
2827
+ room.sharedState = initialState;
2828
+ room.events.length = 0;
2829
+ // Reset all participant event cursors so agents see the reset event
2830
+ for (const [, p] of room.participants) {
2831
+ p.eventCursor = 0;
2832
+ }
2833
+
2834
+ // Append a reset event so stop hooks see it
2835
+ const resetEvent: ToolEvent = {
2836
+ id: `${Date.now()}-system-${Math.random().toString(36).slice(2, 6)}`,
2837
+ ts: Date.now(),
2838
+ actorId: "system",
2839
+ tool: "_reset",
2840
+ input: {},
2841
+ output: { reset: true },
2842
+ };
2843
+ room.appendEvent(resetEvent);
2844
+ roomEvents.emit(`room:${room.id}`);
2845
+
2846
+ // Reset sends full state (not delta) since the entire state changed
2847
+ room.resetDeltaTracking();
2848
+ room.broadcastStateUpdate({
2849
+ changedBy: "system",
2850
+ tool: "_reset",
2851
+ }, true);
2852
+ res.json({ ok: true });
2853
+ });
2854
+
2855
+ // ── Delete room (cleanup spawned rooms) ─────────────────────
2856
+ app.delete("/rooms/:roomId", (req, res) => {
2857
+ const roomId = req.params.roomId;
2858
+ if (roomId === DEFAULT_ROOM_ID) {
2859
+ res.status(400).json({ error: "Cannot delete the default room." });
2860
+ return;
2861
+ }
2862
+ const room = getRoom(roomId);
2863
+ if (!room) {
2864
+ res.status(404).json({ error: roomNotFoundError(roomId) });
2865
+ return;
2866
+ }
2867
+
2868
+ // Stop tick engine
2869
+ stopTickEngine(roomId);
2870
+
2871
+ cleanupRoomLinks(roomId, room);
2872
+
2873
+ // Clean up agent memory entries before clearing participants
2874
+ const expForDelete = getExperienceForRoom(room);
2875
+ const expIdForDelete = expForDelete?.module?.manifest?.id || room.experienceId;
2876
+ for (const [actorId] of room.participants) {
2877
+ agentMemory.delete(`${expIdForDelete}:${actorId}`);
2878
+ }
2879
+
2880
+ // Close all WebSocket connections for this room
2881
+ for (const [ws] of room.wsConnections) {
2882
+ try { ws.close(1001, "Room deleted"); } catch {}
2883
+ }
2884
+ room.wsConnections.clear();
2885
+ room.participants.clear();
2886
+
2887
+ // Clean up blobs associated with this room
2888
+ for (const [key, meta] of blobMeta) {
2889
+ if (meta.roomId === roomId) {
2890
+ blobStore.delete(key);
2891
+ blobMeta.delete(key);
2892
+ }
2893
+ }
2894
+
2895
+ // Wake up any long-poll listeners waiting on this room
2896
+ roomEvents.emit(`room:${roomId}`);
2897
+
2898
+ // Clean up event listeners, GC tracking, and room data
2899
+ roomEvents.removeAllListeners(`room:${roomId}`);
2900
+ rooms.delete(roomId);
2901
+ roomBrowserErrors.delete(roomId);
2902
+ lastBrowserErrorAt.delete(roomId);
2903
+ spawnCounts.delete(roomId);
2904
+ roomEmptySince.delete(roomId);
2905
+
2906
+ res.json({ deleted: true, roomId });
2907
+ });
2908
+
2909
+ app.post("/rooms/:roomId/tools/:toolName", (req, res) => {
2910
+ const room = getRoom(req.params.roomId);
2911
+ if (!room) {
2912
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
2913
+ return;
2914
+ }
2915
+ handleTool(room, req, res);
2916
+ });
2917
+
2918
+ app.post("/rooms/:roomId/tools-batch", (req, res) => {
2919
+ const room = getRoom(req.params.roomId);
2920
+ if (!room) {
2921
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
2922
+ return;
2923
+ }
2924
+ handleBatch(room, req, res);
2925
+ });
2926
+
2927
+ // ── Stream endpoints (REST API for MCP agents) ──────────────
2928
+
2929
+ function handleStreamRequest(room: Room, streamName: string, req: express.Request, res: express.Response): void {
2930
+ const exp = getExperienceForRoom(room);
2931
+ if (!exp?.module?.streams) {
2932
+ res.status(404).json({ error: "No streams defined" });
2933
+ return;
2934
+ }
2935
+
2936
+ const streamDef = exp.module.streams.find((s: StreamDef) => s.name === streamName);
2937
+ if (!streamDef) {
2938
+ res.status(404).json({ error: `Stream '${streamName}' not found` });
2939
+ return;
2940
+ }
2941
+
2942
+ const { actorId, input } = req.body;
2943
+
2944
+ // Require actorId — anonymous stream writes are not allowed
2945
+ if (!actorId) {
2946
+ res.status(400).json({ error: "actorId is required for stream updates" });
2947
+ return;
2948
+ }
2949
+
2950
+ // Verify the caller is a participant
2951
+ if (!room.participants.has(actorId)) {
2952
+ res.status(403).json({ error: `Actor '${actorId}' is not a participant in room '${room.id}'. Call /join first.` });
2953
+ return;
2954
+ }
2955
+
2956
+ // Rate limiting
2957
+ const rateLimitKey = `${room.id}:${actorId}:${streamName}`;
2958
+ const now = Date.now();
2959
+ const rateLimit = streamDef.rateLimit || DEFAULT_STREAM_RATE_LIMIT;
2960
+ const windowMs = STREAM_RATE_WINDOW_MS;
2961
+ if (!streamRateLimits.has(rateLimitKey)) {
2962
+ streamRateLimits.set(rateLimitKey, { count: 0, windowStart: now });
2963
+ }
2964
+ const rl = streamRateLimits.get(rateLimitKey)!;
2965
+ if (now - rl.windowStart > windowMs) {
2966
+ rl.count = 0;
2967
+ rl.windowStart = now;
2968
+ }
2969
+ if (rl.count >= rateLimit) {
2970
+ res.status(429).json({ error: `Rate limited: max ${rateLimit} updates/sec for stream '${streamName}'. Try again shortly.` });
2971
+ return;
2972
+ }
2973
+ rl.count++;
2974
+
2975
+ // Validate input
2976
+ let validatedInput = input;
2977
+ if (streamDef.input_schema?.parse) {
2978
+ try {
2979
+ validatedInput = streamDef.input_schema.parse(input);
2980
+ } catch (err: unknown) {
2981
+ res.status(400).json({ error: toErrorMessage(err) });
2982
+ return;
2983
+ }
2984
+ }
2985
+
2986
+ // Execute merge
2987
+ try {
2988
+ room.sharedState = streamDef.merge(room.sharedState, validatedInput, actorId);
2989
+ } catch (err: unknown) {
2990
+ res.status(400).json({ error: toErrorMessage(err) });
2991
+ return;
2992
+ }
2993
+
2994
+ // Broadcast state update (no event log entry for streams, delta-compressed)
2995
+ room.broadcastStateUpdate({
2996
+ changedBy: actorId,
2997
+ stream: streamName,
2998
+ });
2999
+ roomEvents.emit(`room:${room.id}`);
3000
+
3001
+ res.json({ ok: true });
3002
+ }
3003
+
3004
+ app.post("/streams/:streamName", (req, res) => {
3005
+ handleStreamRequest(getDefaultRoom(), req.params.streamName, req, res);
3006
+ });
3007
+
3008
+ app.post("/rooms/:roomId/streams/:streamName", (req, res) => {
3009
+ const room = getRoom(req.params.roomId);
3010
+ if (!room) {
3011
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
3012
+ return;
3013
+ }
3014
+ handleStreamRequest(room, req.params.streamName, req, res);
3015
+ });
3016
+
3017
+ app.get("/rooms/:roomId/events", (req, res) => {
3018
+ const room = getRoom(req.params.roomId);
3019
+ if (!room) {
3020
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
3021
+ return;
3022
+ }
3023
+ handleEvents(room, req, res);
3024
+ });
3025
+
3026
+ // ── Room-specific bundle (serves the correct experience's client bundle) ──
3027
+ app.get("/rooms/:roomId/bundle", async (req, res) => {
3028
+ // Wait for any in-progress hot-reload to complete before serving
3029
+ if (rebuildingPromise) await rebuildingPromise;
3030
+ const room = getRoom(req.params.roomId);
3031
+ if (!room) {
3032
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
3033
+ return;
3034
+ }
3035
+ const loaded = getExperienceForRoom(room);
3036
+ if (!loaded) {
3037
+ res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
3038
+ return;
3039
+ }
3040
+ res.setHeader("Content-Type", "text/javascript");
3041
+ setNoCacheHeaders(res);
3042
+ res.send(loaded.clientBundle);
3043
+ });
3044
+
3045
+ // ── Protocol experience HTML canvas ───────────────────────
3046
+
3047
+ app.get("/rooms/:roomId/canvas", async (req, res) => {
3048
+ const room = getRoom(req.params.roomId);
3049
+ if (!room) {
3050
+ res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
3051
+ return;
3052
+ }
3053
+ const loaded = getExperienceForRoom(room);
3054
+ if (!loaded) {
3055
+ res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
3056
+ return;
3057
+ }
3058
+ const canvasPath = (loaded.module as any)?._canvasPath;
3059
+ if (!canvasPath) {
3060
+ res.status(404).json({ error: "This experience has no HTML canvas" });
3061
+ return;
3062
+ }
3063
+ res.setHeader("Content-Type", "text/html");
3064
+ setNoCacheHeaders(res);
3065
+ res.sendFile(canvasPath);
3066
+ });
3067
+
3068
+ // ── MCP config (for remote joiners) ───────────────────────
3069
+
3070
+ app.get("/mcp-config", (_req, res) => {
3071
+ const serverUrl = getBaseUrl();
3072
+ res.json({
3073
+ mcpServers: {
3074
+ "vibevibes-remote": {
3075
+ command: "npx",
3076
+ args: ["-y", "@vibevibes/mcp@latest"],
3077
+ env: {
3078
+ VIBEVIBES_SERVER_URL: serverUrl,
3079
+ },
3080
+ },
3081
+ },
3082
+ instructions: [
3083
+ `Add the above to your .mcp.json to join this room.`,
3084
+ `Or run: npx @vibevibes/mcp@latest ${serverUrl}`,
3085
+ ],
3086
+ });
3087
+ });
3088
+
3089
+ // ── Catch-all: serve viewer for path-based room routing (e.g. /room/room-abc123) ──
3090
+ // Must be AFTER all API routes so it doesn't shadow them.
3091
+ app.get("*", (req, res, next) => {
3092
+ // Skip API-like paths and static assets
3093
+ if (req.path.startsWith("/rooms/") || req.path.startsWith("/tools/") ||
3094
+ req.path.startsWith("/viewer/") || req.path.startsWith("/blobs/") ||
3095
+ req.path.startsWith("/streams/") || req.path.endsWith(".js") ||
3096
+ req.path.endsWith(".css") || req.path.endsWith(".map")) {
3097
+ next();
3098
+ return;
3099
+ }
3100
+ // Serve the viewer — client-side JS will extract room ID from the path
3101
+ setNoCacheHeaders(res);
3102
+ res.sendFile(path.join(__runtimeDir, "viewer", "index.html"));
3103
+ });
3104
+
3105
+ // ── Client bundle smoke test ──────────────────────────────────
3106
+ // Fetches the client bundle from the running server and tries to parse it.
3107
+ // Catches SyntaxErrors (like duplicate declarations) before the user sees them.
3108
+
3109
+ async function smokeTestClientBundle(port: number): Promise<void> {
3110
+ try {
3111
+ const res = await fetch(`http://localhost:${port}/bundle`);
3112
+ const bundleCode = await res.text();
3113
+ if (bundleCode) {
3114
+ const error = validateClientBundle(bundleCode);
3115
+ if (error) {
3116
+ console.error(`\n ⚠ SMOKE TEST FAILED — client bundle has errors:`);
3117
+ console.error(` ${error}`);
3118
+ console.error(` The viewer will fail to load. Fix the source and save to hot-reload.\n`);
3119
+ } else {
3120
+ console.log(` Smoke test: client bundle OK`);
3121
+ }
3122
+ }
3123
+ } catch (err: unknown) {
3124
+ console.error(`\n ⚠ SMOKE TEST FAILED — client bundle has errors:`);
3125
+ console.error(` ${toErrorMessage(err)}`);
3126
+ console.error(` The viewer will fail to load. Fix the source and save to hot-reload.\n`);
3127
+ }
3128
+ }
3129
+
3130
+ // ── Start server ───────────────────────────────────────────
3131
+
3132
+ export async function startServer(config?: ServerConfig): Promise<import("http").Server> {
3133
+ // Apply config
3134
+ if (config?.projectRoot) {
3135
+ PROJECT_ROOT = config.projectRoot;
3136
+ }
3137
+ if (config?.port) {
3138
+ PORT = config.port;
3139
+ }
3140
+ if (!PROJECT_ROOT) {
3141
+ throw new Error("@vibevibes/runtime: projectRoot is required. Pass it via ServerConfig.");
3142
+ }
3143
+ await loadHost();
3144
+
3145
+ // Create default room (with default config + initial state from experience)
3146
+ const hostExp = getHostExperience()!;
3147
+ const defaultConfig = resolveRoomConfig(hostExp, undefined);
3148
+ const hostInitialState = resolveInitialState(hostExp, defaultConfig);
3149
+ const defaultRoom = new Room(DEFAULT_ROOM_ID, hostExperienceId, hostInitialState, defaultConfig);
3150
+ rooms.set(DEFAULT_ROOM_ID, defaultRoom);
3151
+
3152
+
3153
+ // Start tick engine for default room if host experience uses tick netcode
3154
+ const hostLoaded = experienceCache.get(hostExperienceId);
3155
+ if (hostLoaded) {
3156
+ maybeStartTickEngine(defaultRoom, hostLoaded);
3157
+ }
3158
+
3159
+ console.log(` Experience: ${hostExperienceId}`);
3160
+
3161
+ const server = http.createServer(app);
3162
+ const wss = new WebSocketServer({ server, maxPayload: WS_MAX_PAYLOAD_BYTES });
3163
+ wss.on("error", (err) => { console.error("[WSS] server error:", err.message); });
3164
+
3165
+ // Grace period timers for WS close → participant deletion (allows page-refresh reconnects)
3166
+ const wsCloseTimers = new Map<string, NodeJS.Timeout>();
3167
+
3168
+ wss.on("connection", (ws) => {
3169
+ // Heartbeat: mark alive on connection and on pong
3170
+ const hbWs = ws as HeartbeatWebSocket;
3171
+ hbWs.isAlive = true;
3172
+ ws.on("pong", () => { hbWs.isAlive = true; });
3173
+ ws.on("error", (err) => { console.error("[WS] connection error:", (err as Error).message); });
3174
+
3175
+ ws.on("message", (data) => {
3176
+ try {
3177
+ const msg = JSON.parse(data.toString());
3178
+
3179
+ if (msg.type === "join") {
3180
+ const username = (msg.username || "viewer").slice(0, 100);
3181
+ const wsOwner: string = msg.owner || username;
3182
+ const roomId = msg.roomId || DEFAULT_ROOM_ID;
3183
+ const room = getRoom(roomId);
3184
+ if (!room) {
3185
+ ws.send(JSON.stringify({ type: "error", error: roomNotFoundError(roomId) }));
3186
+ return;
3187
+ }
3188
+
3189
+ // Reuse actorId if the viewer sends one back (e.g. on refresh)
3190
+ let actorId: string;
3191
+ if (msg.actorId) {
3192
+ // Check kick status BEFORE evicting stale WS or reusing actorId
3193
+ if (room.kickedActors.has(msg.actorId)) {
3194
+ ws.send(JSON.stringify({ type: "error", error: "You have been kicked from this room." }));
3195
+ return;
3196
+ }
3197
+ if (room.kickedOwners.has(wsOwner)) {
3198
+ ws.send(JSON.stringify({ type: "error", error: "You have been kicked from this room." }));
3199
+ return;
3200
+ }
3201
+
3202
+ // Check if the old actorId is held by a stale WS (refresh scenario)
3203
+ let staleWs: WebSocket | null = null;
3204
+ for (const [existingWs, existingId] of room.wsConnections.entries()) {
3205
+ if (existingId === msg.actorId && existingWs !== ws) {
3206
+ staleWs = existingWs;
3207
+ break;
3208
+ }
3209
+ }
3210
+ if (staleWs) {
3211
+ // Evict the stale connection
3212
+ room.wsConnections.delete(staleWs);
3213
+ try { staleWs.close(); } catch {}
3214
+ }
3215
+ // Cancel any pending close grace timer for this actorId
3216
+ const closeTimer = wsCloseTimers.get(msg.actorId);
3217
+ if (closeTimer) { clearTimeout(closeTimer); wsCloseTimers.delete(msg.actorId); }
3218
+ // Re-add participant if it was deleted during grace window
3219
+ if (!room.participants.has(msg.actorId)) {
3220
+ room.participants.set(msg.actorId, { type: "human", joinedAt: Date.now(), owner: wsOwner });
3221
+ }
3222
+ // Reuse the old actorId
3223
+ actorId = msg.actorId;
3224
+ } else {
3225
+ // Block kicked owners before burning an actorId counter slot
3226
+ if (room.kickedOwners.has(wsOwner)) {
3227
+ ws.send(JSON.stringify({ type: "error", error: "You have been kicked from this room." }));
3228
+ return;
3229
+ }
3230
+
3231
+ // Dedup by owner: reuse existing participant if same owner already joined (matches HTTP join logic)
3232
+ let existingActorId: string | undefined;
3233
+ for (const [aid, p] of room.participants) {
3234
+ if (p.owner === wsOwner) { existingActorId = aid; break; }
3235
+ }
3236
+ if (existingActorId) {
3237
+ actorId = existingActorId;
3238
+ // Evict any stale WS for this actorId
3239
+ for (const [existingWs, existingId] of room.wsConnections.entries()) {
3240
+ if (existingId === existingActorId && existingWs !== ws) {
3241
+ room.wsConnections.delete(existingWs);
3242
+ try { existingWs.close(); } catch {}
3243
+ break;
3244
+ }
3245
+ }
3246
+ } else {
3247
+ // Match human join against participant slots (if defined)
3248
+ const exp = getExperienceForRoom(room);
3249
+ const pSlots: ParticipantSlot[] | undefined = exp?.module?.manifest?.participantSlots || exp?.module?.participants;
3250
+ let wsSlotRole: string | undefined;
3251
+
3252
+ if (pSlots?.length) {
3253
+ const roleOccupancy = new Map<string, number>();
3254
+ for (const [, p] of room.participants) {
3255
+ if (p.role) roleOccupancy.set(p.role, (roleOccupancy.get(p.role) || 0) + 1);
3256
+ }
3257
+ const hasCapacity = (slot: ParticipantSlot) => {
3258
+ const max = slot.maxInstances ?? 1;
3259
+ const current = roleOccupancy.get(slot.role) || 0;
3260
+ return current < max;
3261
+ };
3262
+ // Match: requested role → type-specific → any
3263
+ let matched: ParticipantSlot | undefined;
3264
+ if (msg.role) {
3265
+ matched = pSlots.find((s) =>
3266
+ s.role === msg.role && (!s.type || s.type === "human" || s.type === "any") && hasCapacity(s)
3267
+ );
3268
+ }
3269
+ if (!matched) {
3270
+ matched = pSlots.find((s) =>
3271
+ s.type === "human" && hasCapacity(s)
3272
+ );
3273
+ }
3274
+ if (!matched) {
3275
+ matched = pSlots.find((s) =>
3276
+ (!s.type || s.type === "any") && hasCapacity(s)
3277
+ );
3278
+ }
3279
+ // Fallback: if all human-compatible slots are full, use first compatible
3280
+ if (!matched) {
3281
+ matched = pSlots.find((s) => !s.type || s.type === "human" || s.type === "any");
3282
+ }
3283
+ if (matched) {
3284
+ wsSlotRole = matched.role;
3285
+ const base = matched.role.toLowerCase().replace(/\s+/g, "-");
3286
+ actorId = assignActorId(username, "human", base);
3287
+ } else {
3288
+ actorId = assignActorId(username, "human");
3289
+ }
3290
+ } else {
3291
+ actorId = assignActorId(username, "human");
3292
+ }
3293
+ const wsRole = wsSlotRole || msg.role || undefined;
3294
+ room.participants.set(actorId, { type: "human", joinedAt: Date.now(), owner: wsOwner, role: wsRole });
3295
+ } // end: new actorId assignment block
3296
+ }
3297
+ // For reconnect case: if msg.actorId no longer has a participant entry
3298
+ // (e.g., after room reset), reject the stale reconnect. Viewer must do a fresh join.
3299
+ if (msg.actorId && !room.participants.has(msg.actorId)) {
3300
+ ws.send(JSON.stringify({ type: "error", error: "Session expired. Please rejoin the room." }));
3301
+ return;
3302
+ }
3303
+
3304
+ // Track this WS → actorId in the room
3305
+ room.wsConnections.set(ws, actorId);
3306
+
3307
+ // Resolve role from participant entry
3308
+ const resolvedWsRole = room.participants.get(actorId)?.role;
3309
+
3310
+ // Send initial state (includes stateVersion for delta tracking)
3311
+ ws.send(JSON.stringify({
3312
+ type: "joined",
3313
+ roomId: room.id,
3314
+ actorId,
3315
+ role: resolvedWsRole,
3316
+ sharedState: room.sharedState,
3317
+ stateVersion: room.stateVersion,
3318
+ participants: room.participantList(),
3319
+ participantDetails: room.participantDetails(),
3320
+ events: room.events.slice(-JOIN_EVENT_HISTORY),
3321
+ config: room.config,
3322
+ }));
3323
+
3324
+ // Broadcast presence update to others in this room
3325
+ broadcastPresenceUpdate(room);
3326
+ }
3327
+
3328
+ if (msg.type === "ephemeral") {
3329
+ // Relay ephemeral data to all OTHER clients in the same room.
3330
+ // No validation, no persistence — this is the fast path for cursors,
3331
+ // typing indicators, follow mode, and other high-frequency cosmetic data.
3332
+ // Size guard: reject oversized payloads to prevent DoS amplification
3333
+ const ephPayload = JSON.stringify(msg.data);
3334
+ if (ephPayload.length > WS_EPHEMERAL_MAX_BYTES) {
3335
+ ws.send(JSON.stringify({ type: "error", error: "Ephemeral payload too large (max 64KB)" }));
3336
+ return;
3337
+ }
3338
+ for (const room of rooms.values()) {
3339
+ const senderActorId = room.wsConnections.get(ws);
3340
+ if (senderActorId) {
3341
+ const payload = JSON.stringify({
3342
+ type: "ephemeral",
3343
+ actorId: senderActorId,
3344
+ data: msg.data,
3345
+ });
3346
+ for (const [otherWs, otherId] of room.wsConnections.entries()) {
3347
+ if (otherWs !== ws && otherWs.readyState === WebSocket.OPEN) {
3348
+ otherWs.send(payload);
3349
+ }
3350
+ }
3351
+ break;
3352
+ }
3353
+ }
3354
+ }
3355
+
3356
+ if (msg.type === "stream") {
3357
+ // Validate stream name
3358
+ if (typeof msg.name !== "string" || !msg.name) {
3359
+ ws.send(JSON.stringify({ type: "stream_error", error: "stream.name must be a non-empty string" }));
3360
+ return;
3361
+ }
3362
+ // High-frequency continuous state channel
3363
+ // Find which room this WS belongs to
3364
+ for (const room of rooms.values()) {
3365
+ const senderActorId = room.wsConnections.get(ws);
3366
+ if (!senderActorId) continue;
3367
+
3368
+ const exp = getExperienceForRoom(room);
3369
+ if (!exp?.module?.streams) break;
3370
+
3371
+ const streamDef = exp.module.streams.find((s: StreamDef) => s.name === msg.name);
3372
+ if (!streamDef) {
3373
+ ws.send(JSON.stringify({ type: "stream_error", error: `Stream '${msg.name}' not found` }));
3374
+ break;
3375
+ }
3376
+
3377
+ // Rate limiting
3378
+ const rateLimitKey = `${room.id}:${senderActorId}:${msg.name}`;
3379
+ const now = Date.now();
3380
+ const rateLimit = streamDef.rateLimit || DEFAULT_STREAM_RATE_LIMIT;
3381
+ const windowMs = STREAM_RATE_WINDOW_MS;
3382
+ if (!streamRateLimits.has(rateLimitKey)) {
3383
+ streamRateLimits.set(rateLimitKey, { count: 0, windowStart: now });
3384
+ }
3385
+ const rl = streamRateLimits.get(rateLimitKey)!;
3386
+ if (now - rl.windowStart > windowMs) {
3387
+ rl.count = 0;
3388
+ rl.windowStart = now;
3389
+ }
3390
+ if (rl.count >= rateLimit) {
3391
+ ws.send(JSON.stringify({ type: "stream_error", error: `Rate limited: max ${rateLimit}/sec for '${msg.name}'` }));
3392
+ break;
3393
+ }
3394
+ rl.count++;
3395
+
3396
+ // Validate input
3397
+ let validatedInput = msg.input;
3398
+ if (streamDef.input_schema?.parse) {
3399
+ try {
3400
+ validatedInput = streamDef.input_schema.parse(msg.input);
3401
+ } catch (err: unknown) {
3402
+ ws.send(JSON.stringify({ type: "stream_error", error: `Invalid input for stream '${msg.name}': ${toErrorMessage(err)}` }));
3403
+ break;
3404
+ }
3405
+ }
3406
+
3407
+ // Execute merge
3408
+ try {
3409
+ room.sharedState = streamDef.merge(room.sharedState, validatedInput, senderActorId);
3410
+ } catch (err: unknown) {
3411
+ ws.send(JSON.stringify({ type: "stream_error", error: `Stream '${msg.name}' merge failed: ${toErrorMessage(err)}` }));
3412
+ break;
3413
+ }
3414
+
3415
+ // Broadcast state update (no event log entry for streams, delta-compressed)
3416
+ room.broadcastStateUpdate({
3417
+ changedBy: senderActorId,
3418
+ stream: msg.name,
3419
+ });
3420
+ roomEvents.emit(`room:${room.id}`);
3421
+
3422
+ break;
3423
+ }
3424
+ }
3425
+
3426
+ if (msg.type === "kick") {
3427
+ let found = false;
3428
+ for (const room of rooms.values()) {
3429
+ const kickerActorId = room.wsConnections.get(ws);
3430
+ if (!kickerActorId) continue;
3431
+ found = true;
3432
+ const result = handleKick(room, kickerActorId, msg.targetActorId);
3433
+ if (result.error) {
3434
+ console.warn(`[kick] ${kickerActorId} failed to kick ${msg.targetActorId}: ${result.error}`);
3435
+ ws.send(JSON.stringify({ type: "kick_error", error: result.error }));
3436
+ } else {
3437
+ ws.send(JSON.stringify({ type: "kick_success", actorId: msg.targetActorId }));
3438
+ }
3439
+ break;
3440
+ }
3441
+ if (!found) {
3442
+ ws.send(JSON.stringify({ type: "kick_error", error: "You are not in any room" }));
3443
+ }
3444
+ }
3445
+
3446
+ if (msg.type === "screenshot_response") {
3447
+ const pending = pendingScreenshots.get(msg.id);
3448
+ if (pending && pending.viewerWs === ws) {
3449
+ clearTimeout(pending.timer);
3450
+ pendingScreenshots.delete(msg.id);
3451
+
3452
+ if (msg.error) {
3453
+ pending.reject(new Error(msg.error));
3454
+ } else if (msg.dataUrl) {
3455
+ // Validate data URL format before decoding
3456
+ if (typeof msg.dataUrl !== "string" || !msg.dataUrl.startsWith("data:image/png;base64,")) {
3457
+ pending.reject(new Error("Invalid screenshot data URL format"));
3458
+ } else {
3459
+ const base64Data = msg.dataUrl.slice("data:image/png;base64,".length);
3460
+ const buffer = Buffer.from(base64Data, "base64");
3461
+ pending.resolve(buffer);
3462
+ }
3463
+ } else {
3464
+ pending.reject(new Error("Empty screenshot response"));
3465
+ }
3466
+ }
3467
+ }
3468
+ } catch (err: unknown) {
3469
+ // Log unexpected errors (not JSON parse failures) for debugging
3470
+ if (!(err instanceof SyntaxError)) {
3471
+ console.error("[WS] Unexpected handler error:", err instanceof Error ? err.message : String(err));
3472
+ }
3473
+ }
3474
+ });
3475
+
3476
+ ws.on("close", () => {
3477
+ // Clean up participant from whichever room this WS belongs to.
3478
+ // Only delete human participants — AI agents join via HTTP and don't rely on WS for presence.
3479
+ for (const room of rooms.values()) {
3480
+ const actorId = room.wsConnections.get(ws);
3481
+ if (actorId) {
3482
+ room.wsConnections.delete(ws);
3483
+ const participant = room.participants.get(actorId);
3484
+ if (!participant || participant.type === "human") {
3485
+ // Grace period: delay participant deletion to allow page refresh reconnects.
3486
+ // If the same actorId reconnects within the grace period, the deletion is cancelled.
3487
+ const timer = setTimeout(() => {
3488
+ wsCloseTimers.delete(actorId);
3489
+ // Only delete if no new WS reconnected for this actorId
3490
+ let reconnected = false;
3491
+ for (const [, wsActorId] of room.wsConnections) {
3492
+ if (wsActorId === actorId) { reconnected = true; break; }
3493
+ }
3494
+ if (!reconnected) {
3495
+ room.participants.delete(actorId);
3496
+ broadcastPresenceUpdate(room);
3497
+ }
3498
+ }, WS_CLOSE_GRACE_MS);
3499
+ // Cancel any previous timer for this actorId and store the new one
3500
+ const prev = wsCloseTimers.get(actorId);
3501
+ if (prev) clearTimeout(prev);
3502
+ wsCloseTimers.set(actorId, timer);
3503
+ }
3504
+ break;
3505
+ }
3506
+ }
3507
+ });
3508
+ });
3509
+
3510
+ // ── WebSocket heartbeat interval ──────────────────────────
3511
+ const heartbeatInterval = setInterval(() => {
3512
+ for (const ws of wss.clients) {
3513
+ if ((ws as HeartbeatWebSocket).isAlive === false) {
3514
+ // Dead connection — clean up from rooms and terminate
3515
+ for (const room of rooms.values()) {
3516
+ const actorId = room.wsConnections.get(ws);
3517
+ if (actorId) {
3518
+ room.participants.delete(actorId);
3519
+ room.wsConnections.delete(ws);
3520
+ broadcastPresenceUpdate(room);
3521
+ break;
3522
+ }
3523
+ }
3524
+ ws.terminate();
3525
+ continue;
3526
+ }
3527
+ (ws as HeartbeatWebSocket).isAlive = false;
3528
+ ws.ping();
3529
+ }
3530
+ }, WS_HEARTBEAT_INTERVAL_MS);
3531
+
3532
+ // ── AI agent heartbeat sweep ──────────────────────────────
3533
+ // Evict AI participants that haven't polled /agent-context within the timeout.
3534
+ // This cleans up zombie agents after crashes, SIGKILLs, etc.
3535
+ // 5 minutes — generous enough for Claude Code's turn model.
3536
+ const AI_HEARTBEAT_TIMEOUT_MS = 300_000;
3537
+ const aiHeartbeatInterval = setInterval(() => {
3538
+ const now = Date.now();
3539
+ for (const room of rooms.values()) {
3540
+ const toEvict: string[] = [];
3541
+ for (const [actorId, p] of room.participants) {
3542
+ if (p.type !== "ai") continue;
3543
+ // "behavior" agents run autonomously via tick engine — they never poll, so skip heartbeat eviction
3544
+ if (p.agentMode === "behavior") continue;
3545
+ const lastSeen = p.lastPollAt || p.joinedAt;
3546
+ if (now - lastSeen > AI_HEARTBEAT_TIMEOUT_MS) toEvict.push(actorId);
3547
+ }
3548
+ for (const actorId of toEvict) {
3549
+ room.participants.delete(actorId);
3550
+ // Clean up memory entries keyed by this actorId
3551
+ const exp = getExperienceForRoom(room);
3552
+ const expId = exp?.module?.manifest?.id || room.experienceId;
3553
+ agentMemory.delete(`${expId}:${actorId}`);
3554
+ }
3555
+ if (toEvict.length > 0) {
3556
+ broadcastPresenceUpdate(room);
3557
+ // Notify long-pollers that participants changed
3558
+ roomEvents.emit(`room:${room.id}`);
3559
+ }
3560
+ }
3561
+ }, WS_HEARTBEAT_INTERVAL_MS);
3562
+
3563
+ // ── Room garbage collection sweep ──────────────────────────
3564
+ // Auto-delete spawned rooms that have been empty (zero participants) for over 5 minutes.
3565
+ // The default "local" room is never deleted. Rooms with active WebSocket connections
3566
+ // or recent participants are kept alive. This prevents zombie rooms from accumulating.
3567
+ const ROOM_EMPTY_TTL_MS = 5 * 60 * 1000; // 5 minutes
3568
+
3569
+ const roomGcInterval = setInterval(() => {
3570
+ const now = Date.now();
3571
+ for (const [roomId, room] of rooms) {
3572
+ if (roomId === DEFAULT_ROOM_ID || room.preCreated) continue; // Never GC the default or pre-created rooms
3573
+
3574
+ if (room.participants.size === 0 && room.wsConnections.size === 0) {
3575
+ // Room is empty — track when we first noticed
3576
+ if (!roomEmptySince.has(roomId)) {
3577
+ roomEmptySince.set(roomId, now);
3578
+ }
3579
+ const emptySince = roomEmptySince.get(roomId)!;
3580
+ if (now - emptySince >= ROOM_EMPTY_TTL_MS) {
3581
+ // TTL expired — garbage collect this room
3582
+ console.log(`[GC] Deleting empty room '${roomId}' (empty for ${Math.round((now - emptySince) / 1000)}s)`);
3583
+ stopTickEngine(roomId);
3584
+ cleanupRoomLinks(roomId, room);
3585
+
3586
+ // Clean up memory entries only for actors that were in this room.
3587
+ // Per-actor cleanup already happens during heartbeat eviction (line ~2846).
3588
+ // Don't wildcard-delete all memory for the experience — other rooms
3589
+ // running the same experience would lose their agents' memory.
3590
+ const exp = getExperienceForRoom(room);
3591
+ const expId = exp?.module?.manifest?.id || room.experienceId;
3592
+ for (const [actorId] of room.participants) {
3593
+ agentMemory.delete(`${expId}:${actorId}`);
3594
+ }
3595
+
3596
+ // Clean up blobs associated with this room
3597
+ for (const [key, meta] of blobMeta) {
3598
+ if (meta.roomId === roomId) {
3599
+ blobStore.delete(key);
3600
+ blobMeta.delete(key);
3601
+ }
3602
+ }
3603
+
3604
+ // Wake any long-poll listeners before deletion, then clean up event emitter
3605
+ roomEvents.emit(`room:${roomId}`);
3606
+ roomEvents.removeAllListeners(`room:${roomId}`);
3607
+
3608
+ rooms.delete(roomId);
3609
+ roomEmptySince.delete(roomId);
3610
+ roomBrowserErrors.delete(roomId);
3611
+ lastBrowserErrorAt.delete(roomId);
3612
+ spawnCounts.delete(roomId);
3613
+ }
3614
+ } else {
3615
+ // Room has participants — reset the empty timer
3616
+ roomEmptySince.delete(roomId);
3617
+ }
3618
+ }
3619
+ }, ROOM_GC_INTERVAL_MS);
3620
+
3621
+ // Watch src/ and experiences/ directories for changes (host experience only)
3622
+ // Check both PROJECT_ROOT/experiences/ and PROJECT_ROOT/../experiences/ (top-level monorepo)
3623
+ const watchDirs = [
3624
+ path.join(PROJECT_ROOT, "src"),
3625
+ path.join(PROJECT_ROOT, "experiences"),
3626
+ path.resolve(PROJECT_ROOT, "..", "experiences"),
3627
+ ].filter((d) => fs.existsSync(d));
3628
+ let debounceTimer: NodeJS.Timeout | null = null;
3629
+
3630
+ function onSrcChange(filename?: string): void {
3631
+ if (debounceTimer) clearTimeout(debounceTimer);
3632
+ // Signal that a rebuild is starting (so bundle endpoints can wait)
3633
+ if (!rebuildingPromise) {
3634
+ rebuildingPromise = new Promise<void>((resolve) => { rebuildingResolve = resolve; });
3635
+ }
3636
+ debounceTimer = setTimeout(async () => {
3637
+ console.log(`\nFile changed${filename ? ` (${filename})` : ""}, rebuilding...`);
3638
+
3639
+ // Determine which experiences are affected by this file change
3640
+ // Stop tick engines before reloading
3641
+ for (const room of rooms.values()) {
3642
+ stopTickEngine(room.id);
3643
+ }
3644
+
3645
+ try {
3646
+ // Reload the experience
3647
+ await loadHost();
3648
+ const reloaded = experienceCache.get(hostExperienceId);
3649
+ for (const room of rooms.values()) {
3650
+ room.broadcastToAll({ type: "experience_updated" });
3651
+ if (reloaded) maybeStartTickEngine(room, reloaded);
3652
+ }
3653
+ smokeTestClientBundle(PORT);
3654
+
3655
+ console.log("Hot reload complete.");
3656
+ } catch (err: unknown) {
3657
+ experienceErrors.set(hostExperienceId, toErrorMessage(err));
3658
+ console.error("Hot reload failed:", toErrorMessage(err));
3659
+ // Restart tick engines with last-known-good experience so rooms don't stay frozen
3660
+ const lastGood = experienceCache.get(hostExperienceId);
3661
+ for (const room of rooms.values()) {
3662
+ if (room.experienceId === hostExperienceId) {
3663
+ room.broadcastToAll({ type: "build_error", error: toErrorMessage(err) });
3664
+ if (lastGood) maybeStartTickEngine(room, lastGood);
3665
+ }
3666
+ }
3667
+ } finally {
3668
+ // Always resolve the rebuilding promise so bundle endpoints unblock
3669
+ if (rebuildingResolve) { rebuildingResolve(); rebuildingResolve = null; rebuildingPromise = null; }
3670
+ }
3671
+ }, HOT_RELOAD_DEBOUNCE_MS);
3672
+ }
3673
+
3674
+ for (const watchDir of watchDirs) {
3675
+ try {
3676
+ // recursive: true works on Windows and macOS
3677
+ fs.watch(watchDir, { recursive: true }, (_event, filename) => {
3678
+ if (filename && /\.(tsx?|jsx?|css|json)$/.test(filename)) {
3679
+ // Resolve filename against watchDir (fs.watch gives paths relative to watched dir)
3680
+ onSrcChange(path.join(path.relative(PROJECT_ROOT, watchDir), filename));
3681
+ }
3682
+ });
3683
+ } catch {
3684
+ // Fallback for Linux: watch individual directories
3685
+ function watchDirRecursive(dir: string): void {
3686
+ fs.watch(dir, (_event, filename) => {
3687
+ if (filename && /\.(tsx?|jsx?|css|json)$/.test(filename)) {
3688
+ onSrcChange(path.join(path.relative(PROJECT_ROOT, dir), filename));
3689
+ }
3690
+ });
3691
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
3692
+ if (entry.isDirectory()) watchDirRecursive(path.join(dir, entry.name));
3693
+ }
3694
+ }
3695
+ watchDirRecursive(watchDir);
3696
+ }
3697
+ }
3698
+
3699
+ server.listen(PORT, async () => {
3700
+ console.log(`\n vibe-vibe local runtime`);
3701
+ console.log(` ───────────────────────`);
3702
+ console.log(` Viewer: http://localhost:${PORT}`);
3703
+
3704
+ // Verify the client bundle can be parsed without errors
3705
+ smokeTestClientBundle(PORT);
3706
+ if (publicUrl) {
3707
+ const shareUrl = getBaseUrl();
3708
+ console.log(``);
3709
+ console.log(` ┌─────────────────────────────────────────────────┐`);
3710
+ console.log(` │ SHARE WITH FRIENDS: │`);
3711
+ console.log(` │ │`);
3712
+ console.log(` │ ${shareUrl.padEnd(47)} │`);
3713
+ console.log(` │ │`);
3714
+ console.log(` │ Open in browser to join the room. │`);
3715
+ console.log(` │ AI: npx @vibevibes/mcp ${(shareUrl).padEnd(23)} │`);
3716
+ console.log(` └─────────────────────────────────────────────────┘`);
3717
+ }
3718
+ console.log(`\n Watching src/ and experiences/ for changes\n`);
3719
+ });
3720
+
3721
+ // Cleanup all intervals on server close
3722
+ server.on("close", () => {
3723
+ clearInterval(heartbeatInterval);
3724
+ clearInterval(aiHeartbeatInterval);
3725
+ clearInterval(roomGcInterval);
3726
+ clearInterval(_streamRateCleanupTimer);
3727
+ clearInterval(_spawnRateCleanupTimer);
3728
+ clearInterval(_idempotencyCleanupTimer);
3729
+ });
3730
+
3731
+ return server;
3732
+ }
3733
+
3734
+ // Convenience: allow setting PROJECT_ROOT before startServer() is called
3735
+ // (e.g., for registry loading during setup).
3736
+ export function setProjectRoot(root: string): void {
3737
+ PROJECT_ROOT = root;
3738
+ }