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