@vibevibes/mcp 0.5.0 → 0.7.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/index.js CHANGED
@@ -423,28 +423,6 @@ Call this first, then use act to interact. The stop hook keeps you present.`, {
423
423
  }
424
424
  throw stateErr;
425
425
  }
426
- // Fetch slot definitions to detect unfilled autoSpawn AI slots
427
- let unfilledSlots = [];
428
- try {
429
- const slotsData = await fetchJSON("/slots", { timeoutMs: 5000 });
430
- if (slotsData?.slots) {
431
- const currentParticipants = slotsData.participantDetails || [];
432
- for (const slot of slotsData.slots) {
433
- if (slot.type !== "ai" && slot.type !== "any")
434
- continue;
435
- if (!slot.autoSpawn)
436
- continue;
437
- const max = slot.maxInstances ?? 1;
438
- const filled = currentParticipants.filter(p => p.role === slot.role && p.type === "ai").length;
439
- if (filled < max) {
440
- unfilledSlots.push({ role: slot.role, autoSpawn: slot.autoSpawn, maxInstances: max });
441
- }
442
- }
443
- }
444
- }
445
- catch {
446
- // Slots endpoint not available — skip silently
447
- }
448
426
  const outputParts = [
449
427
  `Connected as ${identity.actorId}`,
450
428
  connections.size > 1
@@ -461,10 +439,6 @@ Call this first, then use act to interact. The stop hook keeps you present.`, {
461
439
  outputParts.push(`Your instructions:`, systemPrompt, ``);
462
440
  }
463
441
  outputParts.push(formatState(state.sharedState, joinData?.observation || state.observation), `Participants: ${state.participants?.join(", ")}`, ``, `Tools:`, formatToolList(joinData?.tools || state.tools || []), ``);
464
- // Surface unfilled autoSpawn slots
465
- if (unfilledSlots.length > 0) {
466
- outputParts.push(`UNFILLED AI SLOTS (autoSpawn):`, ...unfilledSlots.map(s => ` - ${s.role} (max ${s.maxInstances ?? 1})`), ``, `These roles need AI agents. Spawn independent teammates — each gets its own MCP session and identity.`, ` Agent(subagent_type="general-purpose", model="sonnet", run_in_background=true,`, ` prompt="Call connect with role='ROLE'. Then play your role.")`, ``);
467
- }
468
442
  outputParts.push(`Use act() to interact. The stop hook keeps you present.`);
469
443
  // Register the stop hook so Claude Code wakes us on events
470
444
  ensureStopHook();
package/dist/server.js CHANGED
@@ -15,8 +15,6 @@ import { ZodError } from "zod";
15
15
  import { EventEmitter } from "events";
16
16
  import { bundleForServer, bundleForClient, evalServerBundle, validateClientBundle } from "./bundler.js";
17
17
  import { zodToJsonSchema } from "zod-to-json-schema";
18
- import { TickEngine } from "./tick-engine.js";
19
- import { isProtocolExperience, loadProtocolManifest, createProtocolModule, SubprocessExecutor } from "./protocol.js";
20
18
  function formatZodError(err, toolName, tool) {
21
19
  const issues = err.issues.map((issue) => {
22
20
  const path = issue.path.length > 0 ? `'${issue.path.join(".")}'` : "input";
@@ -217,14 +215,8 @@ const DEFAULT_PORT = 4321;
217
215
  const MAX_EVENTS = 200;
218
216
  const JOIN_EVENT_HISTORY = 20;
219
217
  const ROOM_STATE_EVENT_HISTORY = 50;
220
- const HISTORY_DEFAULT_LIMIT = 50;
221
- const HISTORY_MAX_LIMIT = 200;
222
- const DEFAULT_STREAM_RATE_LIMIT = 60;
223
- const STREAM_RATE_WINDOW_MS = 1000;
224
218
  const EVENT_BATCH_DEBOUNCE_MS = 50;
225
- const DEFAULT_TICK_RATE_MS = 50;
226
219
  const MAX_BATCH_CALLS = 10;
227
- const LONG_POLL_MAX_TIMEOUT_MS = 55000;
228
220
  const AGENT_CONTEXT_MAX_TIMEOUT_MS = 10000;
229
221
  const WS_MAX_PAYLOAD_BYTES = 1024 * 1024;
230
222
  const WS_EPHEMERAL_MAX_BYTES = 65536;
@@ -233,10 +225,7 @@ const HOT_RELOAD_DEBOUNCE_MS = 300;
233
225
  const WS_CLOSE_GRACE_MS = 3000;
234
226
  const JSON_BODY_LIMIT = "256kb";
235
227
  const TOOL_HTTP_TIMEOUT_MS = 30_000;
236
- const TOOL_REGEX_MAX_LENGTH = 100;
237
228
  const ROOM_EVENTS_MAX_LISTENERS = 200;
238
- const STREAM_RATE_LIMIT_STALE_MS = 5000;
239
- const STREAM_RATE_LIMIT_CLEANUP_INTERVAL_MS = 10000;
240
229
  const IDEMPOTENCY_CLEANUP_INTERVAL_MS = 60000;
241
230
  // ── Default observe ────────────────────────────────────────
242
231
  function defaultObserve(state, _event, _actorId) {
@@ -253,7 +242,6 @@ function defaultObserve(state, _event, _actorId) {
253
242
  let PORT = parseInt(process.env.PORT || String(DEFAULT_PORT), 10);
254
243
  let publicUrl = null;
255
244
  let room;
256
- let tickEngine = null;
257
245
  let _actorCounter = 0;
258
246
  const roomEvents = new EventEmitter();
259
247
  roomEvents.setMaxListeners(ROOM_EVENTS_MAX_LISTENERS);
@@ -263,15 +251,6 @@ let experienceError = null;
263
251
  // Hot-reload rebuild gate
264
252
  let rebuildingResolve = null;
265
253
  let rebuildingPromise = null;
266
- // Stream rate limiting
267
- const streamRateLimits = new Map();
268
- const _streamRateCleanupTimer = setInterval(() => {
269
- const now = Date.now();
270
- for (const [key, entry] of streamRateLimits) {
271
- if (now - entry.windowStart > STREAM_RATE_LIMIT_STALE_MS)
272
- streamRateLimits.delete(key);
273
- }
274
- }, STREAM_RATE_LIMIT_CLEANUP_INTERVAL_MS);
275
254
  export function setPublicUrl(url) {
276
255
  publicUrl = url;
277
256
  }
@@ -345,9 +324,6 @@ function experienceNotLoadedError() {
345
324
  }
346
325
  // ── Experience discovery & loading ──────────────────────────
347
326
  function discoverEntryPath() {
348
- const manifestPath = path.join(PROJECT_ROOT, "manifest.json");
349
- if (fs.existsSync(manifestPath))
350
- return manifestPath;
351
327
  const tsxPath = path.join(PROJECT_ROOT, "src", "index.tsx");
352
328
  if (fs.existsSync(tsxPath))
353
329
  return tsxPath;
@@ -355,15 +331,10 @@ function discoverEntryPath() {
355
331
  if (fs.existsSync(rootTsx))
356
332
  return rootTsx;
357
333
  throw new Error(`No experience found in ${PROJECT_ROOT}. ` +
358
- `Create a manifest.json (protocol) or src/index.tsx (TypeScript).`);
334
+ `Create src/index.tsx (TypeScript).`);
359
335
  }
360
- const protocolExecutors = new Map();
361
336
  async function loadExperience() {
362
337
  const entryPath = discoverEntryPath();
363
- if (isProtocolExperience(entryPath)) {
364
- await loadProtocolExperience(entryPath);
365
- return;
366
- }
367
338
  const [sCode, cCode] = await Promise.all([
368
339
  bundleForServer(entryPath),
369
340
  bundleForClient(entryPath),
@@ -385,48 +356,6 @@ async function loadExperience() {
385
356
  };
386
357
  experienceError = null;
387
358
  }
388
- async function loadProtocolExperience(manifestPath) {
389
- const manifestDir = path.dirname(manifestPath);
390
- const manifest = loadProtocolManifest(manifestPath);
391
- const existing = protocolExecutors.get(manifest.id);
392
- if (existing)
393
- existing.stop();
394
- const executor = new SubprocessExecutor(manifest.toolProcess.command, manifest.toolProcess.args || [], manifestDir);
395
- executor.start();
396
- protocolExecutors.set(manifest.id, executor);
397
- try {
398
- await executor.send("init", { experienceId: manifest.id }, 5000);
399
- }
400
- catch { }
401
- const mod = createProtocolModule(manifest, executor, manifestDir);
402
- loadedExperience = {
403
- module: mod,
404
- clientBundle: "",
405
- serverCode: JSON.stringify(manifest),
406
- loadedAt: Date.now(),
407
- sourcePath: manifestPath,
408
- };
409
- experienceError = null;
410
- console.log(`[protocol] Loaded ${manifest.title} (${manifest.id}) — ${manifest.tools.length} tools`);
411
- }
412
- // ── Tick Engine lifecycle ──────────────────────────────────
413
- function maybeStartTickEngine() {
414
- if (!loadedExperience)
415
- return;
416
- const manifest = loadedExperience.module?.manifest;
417
- if (manifest?.netcode !== "tick")
418
- return;
419
- stopTickEngine();
420
- const tickRateMs = manifest.tickRateMs || DEFAULT_TICK_RATE_MS;
421
- tickEngine = new TickEngine(room, loadedExperience, roomEvents, tickRateMs);
422
- tickEngine.start();
423
- }
424
- function stopTickEngine() {
425
- if (tickEngine) {
426
- tickEngine.stop();
427
- tickEngine = null;
428
- }
429
- }
430
359
  // ── Express app ────────────────────────────────────────────
431
360
  const app = express();
432
361
  app.use(express.json({ limit: JSON_BODY_LIMIT }));
@@ -475,12 +404,6 @@ app.get("/state", (req, res) => {
475
404
  observation,
476
405
  });
477
406
  });
478
- // ── Slots endpoint ──────────────────────────────────────────
479
- app.get("/slots", (_req, res) => {
480
- const mod = getModule();
481
- const slots = mod?.manifest?.participantSlots || mod?.participants || [];
482
- res.json({ slots, participantDetails: room.participantDetails() });
483
- });
484
407
  // ── Participants endpoint ──────────────────────────────────
485
408
  app.get("/participants", (_req, res) => res.json({ participants: room.participantDetails() }));
486
409
  // ── Tools list endpoint ────────────────────────────────────
@@ -497,79 +420,10 @@ app.get("/tools-list", (req, res) => {
497
420
  allowedTools = participant?.allowedTools;
498
421
  }
499
422
  const tools = getToolList(mod, allowedTools);
500
- const streams = mod.streams
501
- ? mod.streams.map((s) => ({
502
- name: s.name,
503
- description: s.description || "",
504
- rateLimit: s.rateLimit || DEFAULT_STREAM_RATE_LIMIT,
505
- input_schema: s.input_schema ? zodToJsonSchema(s.input_schema) : {},
506
- }))
507
- : [];
508
423
  res.json({
509
424
  experienceId: mod.manifest?.id,
510
425
  tools,
511
- streams,
512
426
  toolCount: tools.length,
513
- streamCount: streams.length,
514
- });
515
- });
516
- // ── History endpoint ───────────────────────────────────────
517
- app.get("/history", (req, res) => {
518
- const limit = Math.min(queryInt(req.query.limit) || HISTORY_DEFAULT_LIMIT, HISTORY_MAX_LIMIT);
519
- const since = queryInt(req.query.since);
520
- const actor = queryString(req.query.actor);
521
- const tool = queryString(req.query.tool);
522
- const owner = queryString(req.query.owner);
523
- let events = room.events;
524
- if (since > 0)
525
- events = events.filter(e => e.ts > since);
526
- if (actor)
527
- events = events.filter(e => e.actorId === actor);
528
- if (owner)
529
- events = events.filter(e => e.owner === owner);
530
- if (tool) {
531
- const SAFE_TOOL_REGEX = /^[a-zA-Z0-9._:\-]+$/;
532
- if (tool.length <= TOOL_REGEX_MAX_LENGTH && SAFE_TOOL_REGEX.test(tool)) {
533
- try {
534
- const escaped = tool.replace(/\./g, '\\.');
535
- const toolRegex = new RegExp('^' + escaped + '$');
536
- events = events.filter(e => toolRegex.test(e.tool));
537
- }
538
- catch {
539
- events = events.filter(e => e.tool === tool);
540
- }
541
- }
542
- else {
543
- events = events.filter(e => e.tool === tool);
544
- }
545
- }
546
- const filteredTotal = events.length;
547
- const result = events.slice(-limit);
548
- res.json({
549
- events: result,
550
- total: room.events.length,
551
- filtered: filteredTotal,
552
- returned: result.length,
553
- hasMore: filteredTotal > limit,
554
- });
555
- });
556
- // ── Who endpoint ───────────────────────────────────────────
557
- app.get("/who", (_req, res) => {
558
- const participants = Array.from(room.participants.entries()).map(([actorId, p]) => {
559
- let lastAction;
560
- for (let i = room.events.length - 1; i >= 0; i--) {
561
- if (room.events[i].actorId === actorId) {
562
- lastAction = { tool: room.events[i].tool, ts: room.events[i].ts };
563
- break;
564
- }
565
- }
566
- return { actorId, owner: p.owner, type: p.type, role: p.role, joinedAt: p.joinedAt, lastAction, allowedTools: p.allowedTools };
567
- });
568
- res.json({
569
- participants,
570
- count: participants.length,
571
- humans: participants.filter(p => p.type === "human").length,
572
- agents: participants.filter(p => p.type === "ai").length,
573
427
  });
574
428
  });
575
429
  // ── Join ───────────────────────────────────────────────────
@@ -774,53 +628,6 @@ app.post("/leave", (req, res) => {
774
628
  broadcastPresenceUpdate();
775
629
  res.json({ left: true, actorId });
776
630
  });
777
- // ── Kick ───────────────────────────────────────────────────
778
- app.post("/kick", (req, res) => {
779
- const { kickerActorId, targetActorId } = req.body;
780
- if (kickerActorId === targetActorId) {
781
- res.status(400).json({ error: "Cannot kick yourself" });
782
- return;
783
- }
784
- const kicker = room.participants.get(kickerActorId);
785
- if (!kicker || kicker.type !== "human") {
786
- res.status(400).json({ error: "Only human participants can kick" });
787
- return;
788
- }
789
- if (!room.participants.has(targetActorId)) {
790
- res.status(400).json({ error: "Participant not found" });
791
- return;
792
- }
793
- const targetParticipant = room.participants.get(targetActorId);
794
- room.participants.delete(targetActorId);
795
- if (room.kickedActors.size >= 500) {
796
- const oldest = room.kickedActors.values().next().value;
797
- if (oldest)
798
- room.kickedActors.delete(oldest);
799
- }
800
- room.kickedActors.add(targetActorId);
801
- if (targetParticipant?.owner) {
802
- if (room.kickedOwners.size >= 500) {
803
- const oldest = room.kickedOwners.values().next().value;
804
- if (oldest)
805
- room.kickedOwners.delete(oldest);
806
- }
807
- room.kickedOwners.add(targetParticipant.owner);
808
- }
809
- for (const [targetWs, wsActorId] of room.wsConnections.entries()) {
810
- if (wsActorId === targetActorId) {
811
- try {
812
- targetWs.send(JSON.stringify({ type: "kicked", by: kickerActorId }));
813
- targetWs.close();
814
- }
815
- catch { }
816
- room.wsConnections.delete(targetWs);
817
- break;
818
- }
819
- }
820
- broadcastPresenceUpdate();
821
- roomEvents.emit("room");
822
- res.json({ kicked: true, actorId: targetActorId });
823
- });
824
631
  // ── Idempotency cache ────────────────────────────────────────
825
632
  const idempotencyCache = new Map();
826
633
  const IDEMPOTENCY_TTL = 30000;
@@ -1075,80 +882,6 @@ app.post("/tools-batch", async (req, res) => {
1075
882
  });
1076
883
  res.status(hasError ? 207 : 200).json({ results, observation: lastObservation });
1077
884
  });
1078
- // ── Events (supports long-poll via ?timeout=N) ──────────────
1079
- app.get("/events", (req, res) => {
1080
- const since = queryInt(req.query.since);
1081
- const timeout = Math.min(queryInt(req.query.timeout), LONG_POLL_MAX_TIMEOUT_MS);
1082
- const requestingActorId = queryString(req.query.actorId);
1083
- const computeObservation = (events) => {
1084
- const mod = getModule();
1085
- if (!requestingActorId)
1086
- return undefined;
1087
- const agentObserve = mod?.observe ?? defaultObserve;
1088
- try {
1089
- const lastEvent = events.length > 0 ? events[events.length - 1] : null;
1090
- return agentObserve(room.sharedState, lastEvent, requestingActorId);
1091
- }
1092
- catch (e) {
1093
- console.error(`[observe] Error:`, toErrorMessage(e));
1094
- return undefined;
1095
- }
1096
- };
1097
- const getNewEvents = () => room.events.filter((e) => e.ts > since && e.actorId !== requestingActorId);
1098
- let newEvents = getNewEvents();
1099
- if (newEvents.length > 0 || timeout === 0) {
1100
- res.json({
1101
- events: newEvents,
1102
- sharedState: room.sharedState,
1103
- participants: room.participantList(),
1104
- observation: computeObservation(newEvents),
1105
- });
1106
- return;
1107
- }
1108
- let responded = false;
1109
- let batchTimer = null;
1110
- const respond = () => {
1111
- if (responded)
1112
- return;
1113
- responded = true;
1114
- clearTimeout(timer);
1115
- if (batchTimer) {
1116
- clearTimeout(batchTimer);
1117
- batchTimer = null;
1118
- }
1119
- roomEvents.removeListener("room", onEvent);
1120
- newEvents = getNewEvents();
1121
- res.json({
1122
- events: newEvents,
1123
- sharedState: room.sharedState,
1124
- participants: room.participantList(),
1125
- observation: computeObservation(newEvents),
1126
- });
1127
- };
1128
- const timer = setTimeout(respond, timeout);
1129
- const onEvent = () => {
1130
- if (responded)
1131
- return;
1132
- if (batchTimer)
1133
- return;
1134
- batchTimer = setTimeout(() => {
1135
- batchTimer = null;
1136
- if (responded)
1137
- return;
1138
- const pending = getNewEvents();
1139
- if (pending.length > 0)
1140
- respond();
1141
- }, EVENT_BATCH_DEBOUNCE_MS);
1142
- };
1143
- roomEvents.on("room", onEvent);
1144
- req.on("close", () => {
1145
- responded = true;
1146
- clearTimeout(timer);
1147
- if (batchTimer)
1148
- clearTimeout(batchTimer);
1149
- roomEvents.removeListener("room", onEvent);
1150
- });
1151
- });
1152
885
  // ── Browser error capture ──────────────────────────────────
1153
886
  const browserErrors = [];
1154
887
  const MAX_BROWSER_ERRORS = 20;
@@ -1186,8 +919,6 @@ app.get("/agent-context", (req, res) => {
1186
919
  return room.events.filter(e => {
1187
920
  if (requestingOwner && e.owner === requestingOwner)
1188
921
  return false;
1189
- if (e.actorId === "_tick-engine" || e.owner === "_system")
1190
- return false;
1191
922
  return e.ts > since;
1192
923
  }).sort((a, b) => a.ts - b.ts);
1193
924
  };
@@ -1239,7 +970,6 @@ app.get("/agent-context", (req, res) => {
1239
970
  lastError,
1240
971
  browserErrors: recentBrowserErrors.length > 0 ? recentBrowserErrors : undefined,
1241
972
  participants: room.participantList(),
1242
- tickEngine: tickEngine ? { enabled: true, tickCount: tickEngine.getStatus().tickCount } : undefined,
1243
973
  eventCursor,
1244
974
  };
1245
975
  };
@@ -1296,165 +1026,10 @@ app.get("/bundle", async (_req, res) => {
1296
1026
  setNoCacheHeaders(res);
1297
1027
  res.send(loadedExperience?.clientBundle || "");
1298
1028
  });
1299
- // ── Stream endpoints ───────────────────────────────────────
1300
- app.post("/streams/:streamName", (req, res) => {
1301
- const mod = getModule();
1302
- if (!mod?.streams) {
1303
- res.status(404).json({ error: "No streams defined" });
1304
- return;
1305
- }
1306
- const streamDef = mod.streams.find((s) => s.name === req.params.streamName);
1307
- if (!streamDef) {
1308
- res.status(404).json({ error: `Stream '${req.params.streamName}' not found` });
1309
- return;
1310
- }
1311
- const { actorId, input } = req.body;
1312
- if (!actorId) {
1313
- res.status(400).json({ error: "actorId is required for stream updates" });
1314
- return;
1315
- }
1316
- if (!room.participants.has(actorId)) {
1317
- res.status(403).json({ error: `Actor '${actorId}' is not a participant. Call /join first.` });
1318
- return;
1319
- }
1320
- const rateLimitKey = `${actorId}:${req.params.streamName}`;
1321
- const now = Date.now();
1322
- const rateLimit = streamDef.rateLimit || DEFAULT_STREAM_RATE_LIMIT;
1323
- if (!streamRateLimits.has(rateLimitKey)) {
1324
- streamRateLimits.set(rateLimitKey, { count: 0, windowStart: now });
1325
- }
1326
- const rl = streamRateLimits.get(rateLimitKey);
1327
- if (now - rl.windowStart > STREAM_RATE_WINDOW_MS) {
1328
- rl.count = 0;
1329
- rl.windowStart = now;
1330
- }
1331
- if (rl.count >= rateLimit) {
1332
- res.status(429).json({ error: `Rate limited: max ${rateLimit} updates/sec for stream '${req.params.streamName}'.` });
1333
- return;
1334
- }
1335
- rl.count++;
1336
- let validatedInput = input;
1337
- if (streamDef.input_schema?.parse) {
1338
- try {
1339
- validatedInput = streamDef.input_schema.parse(input);
1340
- }
1341
- catch (err) {
1342
- res.status(400).json({ error: toErrorMessage(err) });
1343
- return;
1344
- }
1345
- }
1346
- try {
1347
- room.sharedState = streamDef.merge(room.sharedState, validatedInput, actorId);
1348
- }
1349
- catch (err) {
1350
- res.status(400).json({ error: toErrorMessage(err) });
1351
- return;
1352
- }
1353
- room.broadcastStateUpdate({ changedBy: actorId, stream: req.params.streamName });
1354
- roomEvents.emit("room");
1355
- res.json({ ok: true });
1356
- });
1357
- // ── Reset ──────────────────────────────────────────────────
1358
- app.post("/reset", (_req, res) => {
1359
- const mod = getModule();
1360
- if (!mod) {
1361
- res.status(500).json({ error: experienceNotLoadedError() });
1362
- return;
1363
- }
1364
- room.sharedState = resolveInitialState(mod);
1365
- room.events.length = 0;
1366
- for (const [, p] of room.participants) {
1367
- p.eventCursor = 0;
1368
- }
1369
- const resetEvent = {
1370
- id: `${Date.now()}-system-${Math.random().toString(36).slice(2, 6)}`,
1371
- ts: Date.now(),
1372
- actorId: "system",
1373
- tool: "_reset",
1374
- input: {},
1375
- output: { reset: true },
1376
- };
1377
- room.appendEvent(resetEvent);
1378
- roomEvents.emit("room");
1379
- room.resetDeltaTracking();
1380
- room.broadcastStateUpdate({ changedBy: "system", tool: "_reset" }, true);
1381
- res.json({ ok: true });
1382
- });
1383
- // ── Sync (re-bundle) ──────────────────────────────────────
1384
- app.post("/sync", async (_req, res) => {
1385
- try {
1386
- stopTickEngine();
1387
- await loadExperience();
1388
- room.broadcastToAll({ type: "experience_updated" });
1389
- maybeStartTickEngine();
1390
- const mod = getModule();
1391
- res.json({ synced: true, title: mod?.manifest?.title });
1392
- }
1393
- catch (err) {
1394
- res.status(500).json({ error: toErrorMessage(err) });
1395
- }
1396
- });
1397
- // ── Experiences endpoint ───────────────────────────────────
1398
- app.get("/experiences", async (_req, res) => {
1399
- const mod = getModule();
1400
- if (!mod) {
1401
- res.json([]);
1402
- return;
1403
- }
1404
- res.json([{
1405
- id: mod.manifest.id,
1406
- title: mod.manifest.title,
1407
- description: mod.manifest.description,
1408
- version: mod.manifest.version,
1409
- loaded: true,
1410
- category: mod.manifest.category,
1411
- tags: mod.manifest.tags,
1412
- }]);
1413
- });
1414
- // ── Tick status ────────────────────────────────────────────
1415
- app.get("/tick-status", (_req, res) => {
1416
- if (!tickEngine) {
1417
- res.json({ enabled: false, tickRateMs: 0, tickCount: 0, behaviorsTotal: 0, behaviorsEnabled: 0, lastTick: null });
1418
- return;
1419
- }
1420
- res.json(tickEngine.getStatus());
1421
- });
1422
- // ── MCP config ─────────────────────────────────────────────
1423
- app.get("/mcp-config", (_req, res) => {
1424
- const serverUrl = getBaseUrl();
1425
- res.json({
1426
- mcpServers: {
1427
- "vibevibes-remote": {
1428
- command: "npx",
1429
- args: ["-y", "@vibevibes/mcp@latest"],
1430
- env: { VIBEVIBES_SERVER_URL: serverUrl },
1431
- },
1432
- },
1433
- instructions: [
1434
- `Add the above to your .mcp.json to join this room.`,
1435
- `Or run: npx @vibevibes/mcp@latest ${serverUrl}`,
1436
- ],
1437
- });
1438
- });
1439
- // ── Protocol experience HTML canvas ───────────────────────
1440
- app.get("/canvas", async (_req, res) => {
1441
- if (!loadedExperience) {
1442
- res.status(500).json({ error: experienceNotLoadedError() });
1443
- return;
1444
- }
1445
- const canvasPath = loadedExperience.module?._canvasPath;
1446
- if (!canvasPath) {
1447
- res.status(404).json({ error: "This experience has no HTML canvas" });
1448
- return;
1449
- }
1450
- res.setHeader("Content-Type", "text/html");
1451
- setNoCacheHeaders(res);
1452
- res.sendFile(canvasPath);
1453
- });
1454
1029
  // ── Catch-all: serve viewer ─────────────────────────────────
1455
1030
  app.get("*", (req, res, next) => {
1456
1031
  if (req.path.startsWith("/tools/") || req.path.startsWith("/viewer/") ||
1457
- req.path.startsWith("/streams/") || req.path.endsWith(".js") ||
1032
+ req.path.endsWith(".js") ||
1458
1033
  req.path.endsWith(".css") || req.path.endsWith(".map")) {
1459
1034
  next();
1460
1035
  return;
@@ -1497,7 +1072,6 @@ export async function startServer(config) {
1497
1072
  const mod = getModule();
1498
1073
  const initialState = resolveInitialState(mod);
1499
1074
  room = new Room(mod.manifest.id, initialState);
1500
- maybeStartTickEngine();
1501
1075
  console.log(` Experience: ${mod.manifest.id}`);
1502
1076
  const server = http.createServer(app);
1503
1077
  const wss = new WebSocketServer({ server, maxPayload: WS_MAX_PAYLOAD_BYTES });
@@ -1645,109 +1219,6 @@ export async function startServer(config) {
1645
1219
  }
1646
1220
  }
1647
1221
  }
1648
- if (msg.type === "stream") {
1649
- if (typeof msg.name !== "string" || !msg.name) {
1650
- ws.send(JSON.stringify({ type: "stream_error", error: "stream.name must be a non-empty string" }));
1651
- return;
1652
- }
1653
- const senderActorId = room.wsConnections.get(ws);
1654
- if (!senderActorId)
1655
- return;
1656
- const mod = getModule();
1657
- if (!mod?.streams)
1658
- return;
1659
- const streamDef = mod.streams.find((s) => s.name === msg.name);
1660
- if (!streamDef) {
1661
- ws.send(JSON.stringify({ type: "stream_error", error: `Stream '${msg.name}' not found` }));
1662
- return;
1663
- }
1664
- const rateLimitKey = `${senderActorId}:${msg.name}`;
1665
- const now = Date.now();
1666
- const rateLimit = streamDef.rateLimit || DEFAULT_STREAM_RATE_LIMIT;
1667
- if (!streamRateLimits.has(rateLimitKey)) {
1668
- streamRateLimits.set(rateLimitKey, { count: 0, windowStart: now });
1669
- }
1670
- const rl = streamRateLimits.get(rateLimitKey);
1671
- if (now - rl.windowStart > STREAM_RATE_WINDOW_MS) {
1672
- rl.count = 0;
1673
- rl.windowStart = now;
1674
- }
1675
- if (rl.count >= rateLimit) {
1676
- ws.send(JSON.stringify({ type: "stream_error", error: `Rate limited: max ${rateLimit}/sec for '${msg.name}'` }));
1677
- return;
1678
- }
1679
- rl.count++;
1680
- let validatedInput = msg.input;
1681
- if (streamDef.input_schema?.parse) {
1682
- try {
1683
- validatedInput = streamDef.input_schema.parse(msg.input);
1684
- }
1685
- catch (err) {
1686
- ws.send(JSON.stringify({ type: "stream_error", error: `Invalid input for stream '${msg.name}': ${toErrorMessage(err)}` }));
1687
- return;
1688
- }
1689
- }
1690
- try {
1691
- room.sharedState = streamDef.merge(room.sharedState, validatedInput, senderActorId);
1692
- }
1693
- catch (err) {
1694
- ws.send(JSON.stringify({ type: "stream_error", error: `Stream '${msg.name}' merge failed: ${toErrorMessage(err)}` }));
1695
- return;
1696
- }
1697
- room.broadcastStateUpdate({ changedBy: senderActorId, stream: msg.name });
1698
- roomEvents.emit("room");
1699
- }
1700
- if (msg.type === "kick") {
1701
- const kickerActorId = room.wsConnections.get(ws);
1702
- if (!kickerActorId) {
1703
- ws.send(JSON.stringify({ type: "kick_error", error: "You are not in the room" }));
1704
- return;
1705
- }
1706
- // Reuse kick logic inline
1707
- if (kickerActorId === msg.targetActorId) {
1708
- ws.send(JSON.stringify({ type: "kick_error", error: "Cannot kick yourself" }));
1709
- return;
1710
- }
1711
- const kicker = room.participants.get(kickerActorId);
1712
- if (!kicker || kicker.type !== "human") {
1713
- ws.send(JSON.stringify({ type: "kick_error", error: "Only human participants can kick" }));
1714
- return;
1715
- }
1716
- if (!room.participants.has(msg.targetActorId)) {
1717
- ws.send(JSON.stringify({ type: "kick_error", error: "Participant not found" }));
1718
- return;
1719
- }
1720
- const targetP = room.participants.get(msg.targetActorId);
1721
- room.participants.delete(msg.targetActorId);
1722
- if (room.kickedActors.size >= 500) {
1723
- const oldest = room.kickedActors.values().next().value;
1724
- if (oldest)
1725
- room.kickedActors.delete(oldest);
1726
- }
1727
- room.kickedActors.add(msg.targetActorId);
1728
- if (targetP?.owner) {
1729
- if (room.kickedOwners.size >= 500) {
1730
- const oldest = room.kickedOwners.values().next().value;
1731
- if (oldest)
1732
- room.kickedOwners.delete(oldest);
1733
- }
1734
- room.kickedOwners.add(targetP.owner);
1735
- }
1736
- for (const [targetWs, wsActorId] of room.wsConnections.entries()) {
1737
- if (wsActorId === msg.targetActorId) {
1738
- try {
1739
- targetWs.send(JSON.stringify({ type: "kicked", by: kickerActorId }));
1740
- targetWs.close();
1741
- }
1742
- catch { }
1743
- room.wsConnections.delete(targetWs);
1744
- break;
1745
- }
1746
- }
1747
- broadcastPresenceUpdate();
1748
- roomEvents.emit("room");
1749
- ws.send(JSON.stringify({ type: "kick_success", actorId: msg.targetActorId }));
1750
- }
1751
1222
  }
1752
1223
  catch (err) {
1753
1224
  if (!(err instanceof SyntaxError)) {
@@ -1835,11 +1306,9 @@ export async function startServer(config) {
1835
1306
  }
1836
1307
  debounceTimer = setTimeout(async () => {
1837
1308
  console.log(`\nFile changed${filename ? ` (${filename})` : ""}, rebuilding...`);
1838
- stopTickEngine();
1839
1309
  try {
1840
1310
  await loadExperience();
1841
1311
  room.broadcastToAll({ type: "experience_updated" });
1842
- maybeStartTickEngine();
1843
1312
  smokeTestClientBundle(PORT);
1844
1313
  console.log("Hot reload complete.");
1845
1314
  }
@@ -1847,7 +1316,6 @@ export async function startServer(config) {
1847
1316
  experienceError = toErrorMessage(err);
1848
1317
  console.error("Hot reload failed:", toErrorMessage(err));
1849
1318
  room.broadcastToAll({ type: "build_error", error: toErrorMessage(err) });
1850
- maybeStartTickEngine();
1851
1319
  }
1852
1320
  finally {
1853
1321
  if (rebuildingResolve) {
@@ -1903,7 +1371,6 @@ export async function startServer(config) {
1903
1371
  server.on("close", () => {
1904
1372
  clearInterval(heartbeatInterval);
1905
1373
  clearInterval(aiHeartbeatInterval);
1906
- clearInterval(_streamRateCleanupTimer);
1907
1374
  clearInterval(_idempotencyCleanupTimer);
1908
1375
  });
1909
1376
  return server;
package/hooks/logic.js CHANGED
@@ -92,8 +92,7 @@ export function formatPrompt(ctx) {
92
92
  }
93
93
  // ── 3. Events (what happened since last wake-up) ──────
94
94
  if (ctx.events && ctx.events.length > 0) {
95
- const visibleEvents = ctx.events.filter((e) => e.actorId !== "_tick-engine" &&
96
- e.owner !== "_system");
95
+ const visibleEvents = ctx.events;
97
96
  if (visibleEvents.length > 0) {
98
97
  for (const e of visibleEvents) {
99
98
  const actor = e.role || e.owner || (e.actorId ? e.actorId.split("-")[0] : "?");
@@ -130,9 +129,7 @@ export function formatPrompt(ctx) {
130
129
  export function makeDecision(ctx, iteration) {
131
130
  if (ctx === null)
132
131
  return null;
133
- const realEvents = ctx.events?.filter((e) => e.actorId !== "_tick-engine" &&
134
- e.owner !== "_system") || [];
135
- const hasEvents = realEvents.length > 0;
132
+ const hasEvents = (ctx.events?.length || 0) > 0;
136
133
  const hasError = !!ctx.lastError;
137
134
  const hasBrowserErrors = ctx.browserErrors != null && ctx.browserErrors.length > 0;
138
135
  const hasObserveError = !!ctx.observeError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibevibes/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server + runtime engine for vibevibes experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,85 +0,0 @@
1
- /**
2
- * Protocol experience support.
3
- *
4
- * Loads experiences defined as manifest.json + subprocess tool handler.
5
- * The subprocess communicates via newline-delimited JSON over stdin/stdout.
6
- */
7
- import type { ExperienceModule } from "@vibevibes/sdk";
8
- export interface ProtocolManifest {
9
- id: string;
10
- version: string;
11
- title: string;
12
- description?: string;
13
- initialState?: Record<string, any>;
14
- tools: ProtocolToolDef[];
15
- toolProcess: {
16
- command: string;
17
- args?: string[];
18
- };
19
- agents?: Array<{
20
- role: string;
21
- systemPrompt: string;
22
- allowedTools?: string[];
23
- autoSpawn?: boolean;
24
- maxInstances?: number;
25
- }>;
26
- observe?: {
27
- exclude?: string[];
28
- include?: string[];
29
- };
30
- canvas?: string;
31
- netcode?: "default" | "tick";
32
- tickRateMs?: number;
33
- streams?: Array<{
34
- name: string;
35
- input_schema: Record<string, any>;
36
- rateLimit?: number;
37
- }>;
38
- }
39
- interface ProtocolToolDef {
40
- name: string;
41
- description: string;
42
- input_schema: Record<string, any>;
43
- risk?: "low" | "medium" | "high";
44
- }
45
- interface RpcRequest {
46
- id: string;
47
- method: "tool" | "observe" | "stream" | "init" | "ping";
48
- params: Record<string, any>;
49
- }
50
- interface RpcResponse {
51
- id: string;
52
- result?: Record<string, any>;
53
- error?: {
54
- message: string;
55
- code?: string;
56
- };
57
- }
58
- export declare class SubprocessExecutor {
59
- private proc;
60
- private pending;
61
- private buffer;
62
- private seq;
63
- private command;
64
- private args;
65
- private cwd;
66
- private restarting;
67
- constructor(command: string, args: string[], cwd: string);
68
- start(): void;
69
- stop(): void;
70
- private processBuffer;
71
- send(method: RpcRequest["method"], params: Record<string, any>, timeoutMs?: number): Promise<RpcResponse>;
72
- get running(): boolean;
73
- }
74
- export declare function isProtocolExperience(entryPath: string): boolean;
75
- export declare function loadProtocolManifest(manifestPath: string): ProtocolManifest;
76
- /**
77
- * Create a synthetic ExperienceModule from a protocol manifest.
78
- * Tool handlers proxy calls to the subprocess executor.
79
- */
80
- export declare function createProtocolModule(manifest: ProtocolManifest, executor: SubprocessExecutor, manifestDir: string): ExperienceModule & {
81
- initialState?: Record<string, any>;
82
- _executor: SubprocessExecutor;
83
- _canvasPath?: string;
84
- };
85
- export {};
package/dist/protocol.js DELETED
@@ -1,240 +0,0 @@
1
- /**
2
- * Protocol experience support.
3
- *
4
- * Loads experiences defined as manifest.json + subprocess tool handler.
5
- * The subprocess communicates via newline-delimited JSON over stdin/stdout.
6
- */
7
- import { spawn } from "child_process";
8
- import * as path from "path";
9
- import * as fs from "fs";
10
- // ── Subprocess executor ─────────────────────────────────────────────────
11
- export class SubprocessExecutor {
12
- proc = null;
13
- pending = new Map();
14
- buffer = "";
15
- seq = 0;
16
- command;
17
- args;
18
- cwd;
19
- restarting = false;
20
- constructor(command, args, cwd) {
21
- this.command = command;
22
- this.args = args;
23
- this.cwd = cwd;
24
- }
25
- start() {
26
- if (this.proc)
27
- return;
28
- this.proc = spawn(this.command, this.args, {
29
- cwd: this.cwd,
30
- stdio: ["pipe", "pipe", "pipe"],
31
- env: { ...process.env },
32
- });
33
- this.proc.stdout.on("data", (chunk) => {
34
- this.buffer += chunk.toString();
35
- this.processBuffer();
36
- });
37
- this.proc.stderr.on("data", (chunk) => {
38
- const msg = chunk.toString().trim();
39
- if (msg)
40
- console.error(`[protocol:${this.command}] ${msg}`);
41
- });
42
- this.proc.on("exit", (code) => {
43
- console.warn(`[protocol] Tool process exited with code ${code}`);
44
- this.proc = null;
45
- // Reject all pending requests
46
- for (const [id, p] of this.pending) {
47
- p.reject(new Error(`Tool process exited (code ${code})`));
48
- clearTimeout(p.timer);
49
- }
50
- this.pending.clear();
51
- this.buffer = "";
52
- // Auto-restart after a short delay
53
- if (!this.restarting) {
54
- this.restarting = true;
55
- setTimeout(() => {
56
- this.restarting = false;
57
- try {
58
- this.start();
59
- }
60
- catch { }
61
- }, 1000);
62
- }
63
- });
64
- this.proc.on("error", (err) => {
65
- console.error(`[protocol] Failed to spawn tool process: ${err.message}`);
66
- });
67
- }
68
- stop() {
69
- this.restarting = true; // Prevent auto-restart
70
- if (this.proc) {
71
- this.proc.kill();
72
- this.proc = null;
73
- }
74
- for (const [, p] of this.pending) {
75
- p.reject(new Error("Executor stopped"));
76
- clearTimeout(p.timer);
77
- }
78
- this.pending.clear();
79
- }
80
- processBuffer() {
81
- const lines = this.buffer.split("\n");
82
- this.buffer = lines.pop() || "";
83
- for (const line of lines) {
84
- const trimmed = line.trim();
85
- if (!trimmed)
86
- continue;
87
- try {
88
- const response = JSON.parse(trimmed);
89
- const pending = this.pending.get(response.id);
90
- if (pending) {
91
- clearTimeout(pending.timer);
92
- this.pending.delete(response.id);
93
- pending.resolve(response);
94
- }
95
- }
96
- catch {
97
- console.warn(`[protocol] Invalid JSON from tool process: ${trimmed.slice(0, 200)}`);
98
- }
99
- }
100
- }
101
- async send(method, params, timeoutMs = 10000) {
102
- if (!this.proc?.stdin?.writable) {
103
- throw new Error("Tool process not running");
104
- }
105
- const id = `req-${++this.seq}`;
106
- const request = { id, method, params };
107
- return new Promise((resolve, reject) => {
108
- const timer = setTimeout(() => {
109
- this.pending.delete(id);
110
- reject(new Error(`Tool process timeout after ${timeoutMs}ms`));
111
- }, timeoutMs);
112
- this.pending.set(id, { resolve, reject, timer });
113
- try {
114
- this.proc.stdin.write(JSON.stringify(request) + "\n");
115
- }
116
- catch (err) {
117
- clearTimeout(timer);
118
- this.pending.delete(id);
119
- reject(err instanceof Error ? err : new Error(String(err)));
120
- }
121
- });
122
- }
123
- get running() {
124
- return this.proc !== null && !this.proc.killed;
125
- }
126
- }
127
- // ── Load a protocol experience ──────────────────────────────────────────
128
- export function isProtocolExperience(entryPath) {
129
- return entryPath.endsWith("manifest.json");
130
- }
131
- export function loadProtocolManifest(manifestPath) {
132
- const raw = fs.readFileSync(manifestPath, "utf-8");
133
- const manifest = JSON.parse(raw);
134
- if (!manifest.id)
135
- throw new Error("manifest.json: id is required");
136
- if (!manifest.version)
137
- throw new Error("manifest.json: version is required");
138
- if (!manifest.title)
139
- throw new Error("manifest.json: title is required");
140
- if (!manifest.toolProcess)
141
- throw new Error("manifest.json: toolProcess is required");
142
- if (!Array.isArray(manifest.tools))
143
- throw new Error("manifest.json: tools must be an array");
144
- return manifest;
145
- }
146
- /**
147
- * Create a synthetic ExperienceModule from a protocol manifest.
148
- * Tool handlers proxy calls to the subprocess executor.
149
- */
150
- export function createProtocolModule(manifest, executor, manifestDir) {
151
- // Create tool defs with handlers that proxy to the subprocess
152
- const tools = manifest.tools.map((t) => ({
153
- name: t.name,
154
- description: t.description,
155
- // Use a plain object as input_schema with a parse method for compatibility
156
- input_schema: createJsonSchemaValidator(t.input_schema),
157
- risk: (t.risk || "low"),
158
- capabilities_required: ["state.write"],
159
- handler: async (ctx, input) => {
160
- const response = await executor.send("tool", {
161
- tool: t.name,
162
- input,
163
- state: ctx.state,
164
- actorId: ctx.actorId,
165
- roomId: ctx.roomId,
166
- timestamp: ctx.timestamp,
167
- });
168
- if (response.error) {
169
- throw new Error(response.error.message);
170
- }
171
- if (response.result?.state) {
172
- ctx.setState(response.result.state);
173
- }
174
- return response.result?.output ?? {};
175
- },
176
- }));
177
- // Build observe function if manifest specifies observation config
178
- let observe;
179
- if (manifest.observe) {
180
- const { exclude, include } = manifest.observe;
181
- observe = (state, _event, _actorId) => {
182
- if (include) {
183
- const filtered = {};
184
- for (const key of include) {
185
- if (key in state)
186
- filtered[key] = state[key];
187
- }
188
- return filtered;
189
- }
190
- if (exclude) {
191
- const filtered = { ...state };
192
- for (const key of exclude) {
193
- delete filtered[key];
194
- }
195
- return filtered;
196
- }
197
- return state;
198
- };
199
- }
200
- // Resolve canvas path
201
- const canvasFile = manifest.canvas || "index.html";
202
- const canvasPath = path.resolve(manifestDir, canvasFile);
203
- const hasCanvas = fs.existsSync(canvasPath);
204
- return {
205
- manifest: {
206
- id: manifest.id,
207
- version: manifest.version,
208
- title: manifest.title,
209
- description: manifest.description || "",
210
- requested_capabilities: ["state.write"],
211
- agentSlots: manifest.agents,
212
- participantSlots: manifest.agents?.map((a) => ({ ...a, type: "ai" })),
213
- netcode: manifest.netcode,
214
- tickRateMs: manifest.tickRateMs,
215
- },
216
- Canvas: (() => null), // Protocol experiences use HTML canvas, not React
217
- tools,
218
- observe,
219
- initialState: manifest.initialState ?? {},
220
- _executor: executor,
221
- _canvasPath: hasCanvas ? canvasPath : undefined,
222
- };
223
- }
224
- /**
225
- * Create a minimal Zod-compatible validator from a JSON Schema.
226
- * Only needs .parse() for the server's input validation.
227
- */
228
- function createJsonSchemaValidator(schema) {
229
- return {
230
- parse: (input) => {
231
- // Basic type validation for protocol experiences
232
- if (schema.type === "object" && typeof input !== "object") {
233
- throw new Error("Expected object input");
234
- }
235
- return input;
236
- },
237
- // Expose the raw JSON Schema for tool listing
238
- _jsonSchema: schema,
239
- };
240
- }
@@ -1,81 +0,0 @@
1
- /**
2
- * Tick Engine — server-side tick loop for experiences with netcode: "tick".
3
- *
4
- * Runs at a fixed tick rate, executing tool calls from the tick context.
5
- * The tick engine provides the timing infrastructure; experiences define
6
- * what happens each tick via their tool handlers.
7
- */
8
- import type { ToolCtx, ToolEvent } from "@vibevibes/sdk";
9
- import type { EventEmitter } from "events";
10
- /** ToolEvent extended with observation field (computed server-side after tool execution). */
11
- interface TickToolEvent extends ToolEvent {
12
- observation?: Record<string, unknown>;
13
- }
14
- /** Minimal Room interface — what the tick engine needs from a room. */
15
- export interface TickRoom {
16
- readonly id: string;
17
- readonly experienceId: string;
18
- readonly config: Record<string, unknown>;
19
- sharedState: Record<string, unknown>;
20
- participantList(): string[];
21
- broadcastToAll(message: Record<string, unknown>): void;
22
- broadcastStateUpdate?(extra: {
23
- changedBy: string;
24
- tick?: unknown;
25
- }, forceFullState?: boolean): void;
26
- appendEvent(event: TickToolEvent): void;
27
- enqueueExecution<T>(fn: () => Promise<T>): Promise<T>;
28
- }
29
- /** Minimal experience interface — what the tick engine needs. */
30
- export interface TickExperience {
31
- module: {
32
- manifest: {
33
- id: string;
34
- };
35
- tools: Array<{
36
- name: string;
37
- input_schema?: {
38
- parse?: (input: unknown) => unknown;
39
- };
40
- handler: (ctx: ToolCtx, input: unknown) => Promise<unknown>;
41
- }>;
42
- observe?: (state: Record<string, unknown>, event: TickToolEvent, actorId: string) => Record<string, unknown>;
43
- };
44
- }
45
- export declare class TickEngine {
46
- private room;
47
- private experience;
48
- private roomEvents;
49
- private interval;
50
- private tickCount;
51
- private tickRateMs;
52
- private startedAt;
53
- private _running;
54
- private _stopped;
55
- private _stateSetThisTick;
56
- constructor(room: TickRoom, experience: TickExperience, roomEvents: EventEmitter, tickRateMs?: number);
57
- /** Start the tick loop. Uses self-correcting setTimeout to prevent timing drift. */
58
- start(): void;
59
- /** Schedule the next tick with drift correction. */
60
- private scheduleNext;
61
- /** Stop the tick loop. */
62
- stop(): void;
63
- /** Mark dirty — no-op in simplified engine, kept for API compat. */
64
- markDirty(): void;
65
- /** Get current tick status. */
66
- getStatus(): {
67
- enabled: boolean;
68
- tickRateMs: number;
69
- tickCount: number;
70
- };
71
- /** Execute one tick cycle. */
72
- private tick;
73
- /** Execute a tool call internally (no HTTP round-trip). */
74
- executeTool(toolName: string, input: Record<string, unknown>, callerActorId?: string, expiredFlag?: {
75
- value: boolean;
76
- }): Promise<{
77
- output?: unknown;
78
- error?: string;
79
- }>;
80
- }
81
- export {};
@@ -1,151 +0,0 @@
1
- /**
2
- * Tick Engine — server-side tick loop for experiences with netcode: "tick".
3
- *
4
- * Runs at a fixed tick rate, executing tool calls from the tick context.
5
- * The tick engine provides the timing infrastructure; experiences define
6
- * what happens each tick via their tool handlers.
7
- */
8
- // ── Tick Engine ───────────────────────────────────────────────────────────────
9
- const TICK_ACTOR_ID = "_tick-engine";
10
- const TICK_OWNER = "_system";
11
- export class TickEngine {
12
- room;
13
- experience;
14
- roomEvents;
15
- interval = null;
16
- tickCount = 0;
17
- tickRateMs;
18
- startedAt = 0;
19
- _running = false;
20
- _stopped = false;
21
- _stateSetThisTick = false;
22
- constructor(room, experience, roomEvents, tickRateMs = 50) {
23
- this.room = room;
24
- this.experience = experience;
25
- this.roomEvents = roomEvents;
26
- this.tickRateMs = tickRateMs;
27
- }
28
- /** Start the tick loop. Uses self-correcting setTimeout to prevent timing drift. */
29
- start() {
30
- if (this.interval)
31
- return;
32
- this._stopped = false;
33
- this.tickCount = 0;
34
- this.startedAt = Date.now();
35
- this.scheduleNext();
36
- }
37
- /** Schedule the next tick with drift correction. */
38
- scheduleNext() {
39
- if (this._stopped)
40
- return;
41
- const expected = this.startedAt + (this.tickCount + 1) * this.tickRateMs;
42
- const delay = Math.max(0, expected - Date.now());
43
- this.interval = setTimeout(() => {
44
- this.tick().then(() => this.scheduleNext(), () => this.scheduleNext());
45
- }, delay);
46
- }
47
- /** Stop the tick loop. */
48
- stop() {
49
- this._stopped = true;
50
- this._running = false;
51
- if (this.interval) {
52
- clearTimeout(this.interval);
53
- this.interval = null;
54
- }
55
- }
56
- /** Mark dirty — no-op in simplified engine, kept for API compat. */
57
- markDirty() { }
58
- /** Get current tick status. */
59
- getStatus() {
60
- return {
61
- enabled: this.interval !== null,
62
- tickRateMs: this.tickRateMs,
63
- tickCount: this.tickCount,
64
- };
65
- }
66
- /** Execute one tick cycle. */
67
- async tick() {
68
- if (this._running)
69
- return;
70
- this._running = true;
71
- try {
72
- this.tickCount++;
73
- }
74
- finally {
75
- this._running = false;
76
- }
77
- }
78
- /** Execute a tool call internally (no HTTP round-trip). */
79
- async executeTool(toolName, input, callerActorId, expiredFlag) {
80
- const tool = this.experience.module.tools.find((t) => t.name === toolName);
81
- if (!tool) {
82
- return { error: `Tool '${toolName}' not found` };
83
- }
84
- const actorId = callerActorId || TICK_ACTOR_ID;
85
- const owner = callerActorId || TICK_OWNER;
86
- try {
87
- let validatedInput = input;
88
- if (tool.input_schema?.parse) {
89
- validatedInput = tool.input_schema.parse(input);
90
- }
91
- const self = this;
92
- const ctxBase = {
93
- roomId: this.room.id,
94
- actorId,
95
- owner,
96
- state: null,
97
- setState: (newState) => {
98
- if (expiredFlag?.value)
99
- return;
100
- self.room.sharedState = newState;
101
- self._stateSetThisTick = true;
102
- },
103
- timestamp: Date.now(),
104
- memory: {},
105
- setMemory: () => { },
106
- };
107
- Object.defineProperty(ctxBase, 'state', {
108
- get() { return self.room.sharedState; },
109
- enumerable: true,
110
- configurable: true,
111
- });
112
- const ctx = ctxBase;
113
- const output = await tool.handler(ctx, validatedInput);
114
- const event = {
115
- id: `tick-${this.tickCount}-${toolName}-${Math.random().toString(36).slice(2, 6)}`,
116
- ts: Date.now(),
117
- actorId: TICK_ACTOR_ID,
118
- owner: TICK_OWNER,
119
- tool: toolName,
120
- input: validatedInput,
121
- output,
122
- };
123
- if (this.experience.module.observe) {
124
- try {
125
- event.observation = this.experience.module.observe(this.room.sharedState, event, actorId);
126
- }
127
- catch {
128
- // Don't fail tick if observe throws
129
- }
130
- }
131
- this.room.appendEvent(event);
132
- this.roomEvents.emit(`room:${this.room.id}`);
133
- return { output };
134
- }
135
- catch (err) {
136
- const errorMsg = err instanceof Error ? err.message : String(err);
137
- const event = {
138
- id: `tick-${this.tickCount}-${toolName}-${Math.random().toString(36).slice(2, 6)}`,
139
- ts: Date.now(),
140
- actorId: TICK_ACTOR_ID,
141
- owner: TICK_OWNER,
142
- tool: toolName,
143
- input,
144
- error: errorMsg,
145
- };
146
- this.room.appendEvent(event);
147
- this.roomEvents.emit(`room:${this.room.id}`);
148
- return { error: errorMsg };
149
- }
150
- }
151
- }