@vibevibes/mcp 0.2.0 → 0.3.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
@@ -1,21 +1,112 @@
1
1
  /**
2
- * vibevibes-mcp — standalone MCP server for joining vibevibes experiences.
2
+ * vibevibes-mcp — MCP server for agent participation in vibevibes experiences.
3
3
  *
4
- * Works with both local dev servers and remote shared tunnels.
5
- * Identity is read from the state file written by setup.js MCP never calls /join.
4
+ * The `connect` tool is the single entry point: it joins the server, writes
5
+ * the state file (for the stop hook to poll), and returns room info.
6
6
  *
7
- * Usage:
8
- * npx vibevibes-mcp # defaults to http://localhost:4321
9
- * npx vibevibes-mcp https://xyz.trycloudflare.com # join a shared room
10
- * VIBEVIBES_SERVER_URL=https://... npx vibevibes-mcp
11
- *
12
- * 11 tools: connect, act, stream, spawn_room, list_rooms, list_experiences, room_config_schema, memory, screenshot, blob_set, blob_get
7
+ * 4 tools: connect, act, look, disconnect
13
8
  */
14
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
11
  import { z } from "zod";
17
- import { readFileSync, existsSync } from "node:fs";
18
- import { resolve } from "node:path";
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
13
+ import { resolve, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ /** Extract error message from unknown catch value. */
17
+ function toErrorMessage(err) {
18
+ return err instanceof Error ? err.message : String(err);
19
+ }
20
+ // ── Server auto-start ──────────────────────────────────────
21
+ let serverStarted = false;
22
+ /**
23
+ * Check if the server is reachable. If not, start it in a child process.
24
+ * The server loads the experience from the given project root.
25
+ */
26
+ async function ensureServerRunning(projectRoot) {
27
+ if (serverStarted)
28
+ return;
29
+ // Check if server is already running
30
+ try {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), 2000);
33
+ const res = await fetch(`${SERVER_URL}/state`, { signal: controller.signal });
34
+ clearTimeout(timer);
35
+ if (res.ok) {
36
+ serverStarted = true;
37
+ return;
38
+ }
39
+ }
40
+ catch {
41
+ // Server not running — start it
42
+ }
43
+ // Find the server entry point (in mcp/dist/ or mcp/src/)
44
+ const serverScript = resolve(MCP_ROOT, "dist/server.js");
45
+ const serverTs = resolve(MCP_ROOT, "src/server.ts");
46
+ // Start server as a child process using a small bootstrap script
47
+ const bootstrapCode = `
48
+ import("${serverScript.replace(/\\/g, "/")}").then(m => {
49
+ m.startServer({ projectRoot: "${projectRoot.replace(/\\/g, "/")}" });
50
+ }).catch(e => {
51
+ // Fallback: try tsx for .ts source
52
+ import("${serverTs.replace(/\\/g, "/")}").then(m => {
53
+ m.startServer({ projectRoot: "${projectRoot.replace(/\\/g, "/")}" });
54
+ }).catch(e2 => {
55
+ console.error("Failed to start server:", e2.message);
56
+ process.exit(1);
57
+ });
58
+ });
59
+ `;
60
+ const child = spawn(process.execPath, ["--input-type=module", "-e", bootstrapCode], {
61
+ stdio: ["pipe", "pipe", "pipe"],
62
+ detached: true,
63
+ env: { ...process.env, PORT: new URL(SERVER_URL).port || "4321" },
64
+ });
65
+ child.unref();
66
+ // Store child for cleanup
67
+ serverChild = child;
68
+ child.stderr?.on("data", (chunk) => {
69
+ const msg = chunk.toString().trim();
70
+ if (msg)
71
+ console.error(`[server] ${msg}`);
72
+ });
73
+ // Wait for server to become available (up to 15 seconds)
74
+ const deadline = Date.now() + 15000;
75
+ while (Date.now() < deadline) {
76
+ await new Promise((r) => setTimeout(r, 500));
77
+ try {
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), 1500);
80
+ const res = await fetch(`${SERVER_URL}/state`, { signal: controller.signal });
81
+ clearTimeout(timer);
82
+ if (res.ok) {
83
+ serverStarted = true;
84
+ return;
85
+ }
86
+ }
87
+ catch {
88
+ // Keep waiting
89
+ }
90
+ }
91
+ throw new Error(`Server failed to start within 15 seconds at ${SERVER_URL}`);
92
+ }
93
+ let serverChild = null;
94
+ // Cleanup server on exit
95
+ process.on("exit", () => { if (serverChild)
96
+ try {
97
+ serverChild.kill();
98
+ }
99
+ catch { } });
100
+ process.on("SIGINT", () => { if (serverChild)
101
+ try {
102
+ serverChild.kill();
103
+ }
104
+ catch { } process.exit(0); });
105
+ process.on("SIGTERM", () => { if (serverChild)
106
+ try {
107
+ serverChild.kill();
108
+ }
109
+ catch { } process.exit(0); });
19
110
  // Resolve server URL: CLI arg > env var > localhost default
20
111
  const RAW_SERVER_URL = process.argv[2] ||
21
112
  process.env.VIBEVIBES_SERVER_URL ||
@@ -25,27 +116,118 @@ const parsedUrl = new URL(RAW_SERVER_URL);
25
116
  const ROOM_TOKEN = parsedUrl.searchParams.get("token");
26
117
  // Strip the token param from the base URL so it isn't duplicated in request paths
27
118
  parsedUrl.searchParams.delete("token");
28
- const SERVER_URL = parsedUrl.toString().replace(/\/$/, ""); // remove trailing slash
29
- // ── State ──────────────────────────────────────────────────
119
+ let SERVER_URL = parsedUrl.toString().replace(/\/$/, ""); // remove trailing slash
120
+ /** All active connections in this MCP session. Multiple subagents can each connect independently. */
121
+ const connections = new Map(); // actorId → slot
122
+ /** The most recently connected identity — used as default when only one connection exists. */
30
123
  let currentActorId = null;
31
124
  let currentOwner = null;
32
- const STATE_FILE = resolve(process.cwd(), ".claude/vibevibes-agent.local.json");
125
+ /** Resolve the effective identity for a tool call. If actorId is provided, use that connection.
126
+ * If only one connection exists, use it. If multiple exist and no actorId given, error. */
127
+ function resolveIdentity(actorId) {
128
+ if (actorId) {
129
+ const slot = connections.get(actorId);
130
+ if (!slot)
131
+ throw new Error(`Unknown actorId '${actorId}'. Connected actors: ${[...connections.keys()].join(", ") || "none"}`);
132
+ return slot;
133
+ }
134
+ if (connections.size === 1) {
135
+ return connections.values().next().value;
136
+ }
137
+ if (connections.size > 1) {
138
+ throw new Error(`Multiple agents connected. You MUST pass actorId to identify yourself.\n` +
139
+ `Connected actors: ${[...connections.keys()].join(", ")}`);
140
+ }
141
+ // No connections — fall back to currentActorId for backward compat (e.g. loadIdentity)
142
+ if (currentActorId && currentOwner) {
143
+ return { actorId: currentActorId, owner: currentOwner };
144
+ }
145
+ throw new Error("Not connected. Call the connect tool first.");
146
+ }
147
+ // Derive paths from this module's location.
148
+ const __filename = fileURLToPath(import.meta.url);
149
+ const __dirname = dirname(__filename);
150
+ // mcp/src/ or mcp/dist/ → MCP package root is ..
151
+ const MCP_ROOT = resolve(__dirname, "..");
152
+ // Project root = cwd (where the experience lives)
153
+ const PROJECT_ROOT = process.cwd();
154
+ const AGENTS_DIR = resolve(PROJECT_ROOT, ".claude/vibevibes-agents");
155
+ const SETTINGS_PATH = resolve(PROJECT_ROOT, ".claude/settings.json");
156
+ // ── Dynamic Hook Registration ─────────────────────────────────────
33
157
  /**
34
- * Load identity from the state file written by setup.js.
35
- * Setup.js is the sole identity authority MCP never calls /join.
158
+ * Ensure the Stop hook is registered in .claude/settings.json.
159
+ * Called on connect. Idempotentskips if already present.
160
+ * Uses absolute path because hook CWD may differ from project root.
36
161
  */
37
- function loadIdentity() {
38
- if (!existsSync(STATE_FILE)) {
39
- throw new Error("Not in agent mode. Use /vibevibes-join first.");
162
+ function ensureStopHook() {
163
+ try {
164
+ const hookScript = resolve(MCP_ROOT, "hooks/stop-hook.js");
165
+ const command = `node "${hookScript}"`;
166
+ let settings = {};
167
+ if (existsSync(SETTINGS_PATH)) {
168
+ settings = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
169
+ }
170
+ const hooks = (settings.hooks ?? {});
171
+ const stopHooks = (hooks.Stop ?? []);
172
+ const alreadyRegistered = stopHooks.some((entry) => entry.hooks?.some((h) => h.type === "command" && h.command === command));
173
+ if (alreadyRegistered)
174
+ return;
175
+ stopHooks.push({
176
+ hooks: [{ type: "command", command, timeout: 60 }],
177
+ });
178
+ hooks.Stop = stopHooks;
179
+ settings.hooks = hooks;
180
+ // Ensure directory exists
181
+ const settingsDir = dirname(SETTINGS_PATH);
182
+ if (!existsSync(settingsDir))
183
+ mkdirSync(settingsDir, { recursive: true });
184
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
40
185
  }
41
- const raw = readFileSync(STATE_FILE, "utf-8");
42
- const state = JSON.parse(raw);
43
- if (!state.active || !state.actorId) {
44
- throw new Error("Agent loop not active. Use /vibevibes-join first.");
186
+ catch {
187
+ // Hook registration is best-effort
45
188
  }
46
- currentActorId = state.actorId;
47
- currentOwner = state.owner || state.actorId.split("-")[0];
48
- return { actorId: state.actorId, serverUrl: state.serverUrl, owner: currentOwner };
189
+ }
190
+ function removeStopHook() {
191
+ try {
192
+ if (!existsSync(SETTINGS_PATH))
193
+ return;
194
+ const hookScript = resolve(MCP_ROOT, "hooks/stop-hook.js");
195
+ const command = `node "${hookScript}"`;
196
+ const settings = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
197
+ const hooks = (settings.hooks ?? {});
198
+ const stopHooks = (hooks.Stop ?? []);
199
+ hooks.Stop = stopHooks.filter((entry) => !entry.hooks?.some((h) => h.type === "command" && h.command === command));
200
+ settings.hooks = hooks;
201
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
202
+ }
203
+ catch {
204
+ // Best-effort cleanup
205
+ }
206
+ }
207
+ function loadIdentity() {
208
+ if (currentOwner && currentActorId) {
209
+ return { actorId: currentActorId, serverUrl: SERVER_URL, owner: currentOwner };
210
+ }
211
+ throw new Error("Not connected. Call the connect tool first.");
212
+ }
213
+ // ── Name Generator ─────────────────────────────────────────
214
+ const ADJECTIVES = [
215
+ "swift", "brave", "calm", "bold", "keen",
216
+ "wild", "sly", "warm", "cool", "bright",
217
+ "quick", "sharp", "wise", "kind", "fair",
218
+ "proud", "free", "dark", "lost", "glad",
219
+ ];
220
+ const ANIMALS = [
221
+ "fox", "wolf", "bear", "hawk", "lynx",
222
+ "deer", "owl", "crow", "hare", "pike",
223
+ "dove", "wren", "newt", "moth", "orca",
224
+ "lion", "toad", "crab", "swan", "mole",
225
+ ];
226
+ function generateName() {
227
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
228
+ const animal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)];
229
+ const suffix = Math.random().toString(36).slice(2, 6);
230
+ return `${adj}-${animal}-${suffix}`;
49
231
  }
50
232
  // ── Helpers ────────────────────────────────────────────────
51
233
  async function fetchJSON(path, opts) {
@@ -67,6 +249,17 @@ async function fetchJSON(path, opts) {
67
249
  headers,
68
250
  });
69
251
  const text = await res.text();
252
+ if (!res.ok) {
253
+ // Try to parse error JSON, fall back to status text
254
+ let message = `HTTP ${res.status}: ${res.statusText}`;
255
+ try {
256
+ const j = JSON.parse(text);
257
+ if (j.error)
258
+ message = `HTTP ${res.status}: ${j.error}`;
259
+ }
260
+ catch { }
261
+ throw new Error(message);
262
+ }
70
263
  try {
71
264
  return JSON.parse(text);
72
265
  }
@@ -75,7 +268,7 @@ async function fetchJSON(path, opts) {
75
268
  }
76
269
  }
77
270
  catch (err) {
78
- if (err.name === "AbortError") {
271
+ if (err instanceof Error && err.name === "AbortError") {
79
272
  throw new Error(`Request timed out after ${timeoutMs}ms: ${path}`);
80
273
  }
81
274
  throw err;
@@ -89,9 +282,10 @@ function formatToolList(tools) {
89
282
  return "No tools available.";
90
283
  return tools
91
284
  .map((t) => {
92
- const schema = t.input_schema?.properties
93
- ? Object.entries(t.input_schema.properties)
94
- .map(([k, v]) => `${k}: ${v.type || "any"}`)
285
+ const props = t.input_schema?.properties;
286
+ const schema = props && typeof props === "object"
287
+ ? Object.entries(props)
288
+ .map(([k, v]) => `${k}: ${typeof v.type === "string" ? v.type : "any"}`)
95
289
  .join(", ")
96
290
  : "{}";
97
291
  return ` ${t.name} (${t.risk || "low"}) — ${t.description}\n input: { ${schema} }`;
@@ -115,77 +309,235 @@ const server = new McpServer({
115
309
  version: "0.4.0",
116
310
  });
117
311
  // ── Tool: connect ──────────────────────────────────────────
118
- server.tool("connect", `Connect to the running experience.
119
-
120
- Returns: available tools, current state, participants, and the browser URL.
121
-
122
- Identity is read from the state file written by /vibevibes-join. MCP never calls /join — setup.js owns identity.
123
-
124
- Call this first, then use act to interact. The stop hook keeps you present.`, {}, async () => {
312
+ server.tool("connect", `Connect to the running experience.
313
+
314
+ Joins the server as an AI participant, creates a session file for the stop hook,
315
+ and returns available tools, current state, participants, and the browser URL.
316
+
317
+ Call this first, then use act to interact. The stop hook keeps you present.`, {
318
+ url: z.string().optional().describe("Server URL to connect to. Defaults to the MCP server's configured URL."),
319
+ role: z.string().optional().describe("Preferred participant slot role to request (e.g. 'Blue Tank', 'Red ADC'). Server assigns the first available slot if omitted."),
320
+ subscription: z.enum(["all", "phase"]).optional().describe("Event subscription mode. 'all' (default) wakes on every event. 'phase' wakes only on phase changes — use for orchestrator agents."),
321
+ agentMode: z.enum(["behavior", "manual", "hybrid"]).optional().describe("Agent operating mode. 'behavior': autonomous via tick engine (no stop hook). 'manual': all decisions via act(). 'hybrid' (default): both behaviors and act()."),
322
+ metadata: z.record(z.string()).optional().describe("Arbitrary metadata (model, team, tags). Flows to server and viewer. E.g. { model: 'haiku', team: 'blue' }"),
323
+ }, async ({ url, role: requestedRole, subscription, agentMode, metadata }) => {
125
324
  try {
126
- const identity = loadIdentity();
127
- // GET /state to fetch tools, state, participants — no POST /join
128
- const state = await fetchJSON("/state", { timeoutMs: 10000 });
129
- const output = [
325
+ if (url) {
326
+ SERVER_URL = url.replace(/\/$/, "");
327
+ }
328
+ // Auto-start server if not running
329
+ await ensureServerRunning(PROJECT_ROOT);
330
+ // Check if already connected (same process re-calling connect)
331
+ let identity = null;
332
+ if (currentOwner && existsSync(AGENTS_DIR)) {
333
+ const agentFiles = readdirSync(AGENTS_DIR).filter(f => f.endsWith(".json"));
334
+ for (const file of agentFiles) {
335
+ try {
336
+ const session = JSON.parse(readFileSync(resolve(AGENTS_DIR, file), "utf-8"));
337
+ if (session.owner !== currentOwner)
338
+ continue;
339
+ // Verify the agent still exists on the server
340
+ let networkError = false;
341
+ try {
342
+ const controller = new AbortController();
343
+ const timer = setTimeout(() => controller.abort(), 3000);
344
+ const verifyHeaders = {};
345
+ if (ROOM_TOKEN)
346
+ verifyHeaders["Authorization"] = `Bearer ${ROOM_TOKEN}`;
347
+ const verifyRes = await fetch(`${SERVER_URL}/participants`, { signal: controller.signal, headers: verifyHeaders });
348
+ clearTimeout(timer);
349
+ if (verifyRes.ok) {
350
+ const data = await verifyRes.json();
351
+ const alive = data.participants?.some((p) => p.actorId === session.actorId);
352
+ if (alive) {
353
+ identity = { actorId: session.actorId, owner: session.owner, id: session.owner };
354
+ currentActorId = session.actorId;
355
+ currentOwner = session.owner;
356
+ break;
357
+ }
358
+ }
359
+ }
360
+ catch {
361
+ networkError = true;
362
+ }
363
+ if (!identity && !networkError) {
364
+ try {
365
+ unlinkSync(resolve(AGENTS_DIR, file));
366
+ }
367
+ catch { }
368
+ }
369
+ }
370
+ catch {
371
+ try {
372
+ unlinkSync(resolve(AGENTS_DIR, file));
373
+ }
374
+ catch { }
375
+ }
376
+ }
377
+ }
378
+ let joinData = null;
379
+ if (!identity) {
380
+ const id = generateName();
381
+ const joinBody = { username: id, actorType: "ai", owner: id };
382
+ if (requestedRole)
383
+ joinBody.role = requestedRole;
384
+ if (agentMode)
385
+ joinBody.agentMode = agentMode;
386
+ if (metadata && Object.keys(metadata).length > 0)
387
+ joinBody.metadata = metadata;
388
+ joinData = await fetchJSON("/join", {
389
+ method: "POST",
390
+ body: JSON.stringify(joinBody),
391
+ timeoutMs: 10000,
392
+ });
393
+ if (joinData.error) {
394
+ throw new Error(joinData.error);
395
+ }
396
+ // Write agent file
397
+ if (!existsSync(AGENTS_DIR)) {
398
+ mkdirSync(AGENTS_DIR, { recursive: true });
399
+ }
400
+ const sessionData = {
401
+ serverUrl: SERVER_URL,
402
+ owner: id,
403
+ actorId: joinData.actorId,
404
+ role: joinData.role || undefined,
405
+ };
406
+ if (subscription && subscription !== "all") {
407
+ sessionData.subscription = subscription;
408
+ }
409
+ if (agentMode) {
410
+ sessionData.agentMode = agentMode;
411
+ }
412
+ writeFileSync(resolve(AGENTS_DIR, `${id}.json`), JSON.stringify(sessionData, null, 2));
413
+ identity = { actorId: joinData.actorId, owner: id, id };
414
+ currentActorId = joinData.actorId;
415
+ currentOwner = id;
416
+ connections.set(joinData.actorId, {
417
+ actorId: joinData.actorId,
418
+ owner: id,
419
+ });
420
+ }
421
+ // Fetch state
422
+ const wroteNewFile = joinData !== null;
423
+ let state;
424
+ try {
425
+ state = await fetchJSON("/state", { timeoutMs: 10000 });
426
+ }
427
+ catch (stateErr) {
428
+ if (wroteNewFile && identity?.owner) {
429
+ try {
430
+ unlinkSync(resolve(AGENTS_DIR, `${identity.owner}.json`));
431
+ }
432
+ catch { }
433
+ }
434
+ throw stateErr;
435
+ }
436
+ // Fetch slot definitions to detect unfilled autoSpawn AI slots
437
+ let unfilledSlots = [];
438
+ try {
439
+ const slotsData = await fetchJSON("/slots", { timeoutMs: 5000 });
440
+ if (slotsData?.slots) {
441
+ const currentParticipants = slotsData.participantDetails || [];
442
+ for (const slot of slotsData.slots) {
443
+ if (slot.type !== "ai" && slot.type !== "any")
444
+ continue;
445
+ if (!slot.autoSpawn)
446
+ continue;
447
+ const max = slot.maxInstances ?? 1;
448
+ const filled = currentParticipants.filter(p => p.role === slot.role && p.type === "ai").length;
449
+ if (filled < max) {
450
+ unfilledSlots.push({ role: slot.role, autoSpawn: slot.autoSpawn, maxInstances: max });
451
+ }
452
+ }
453
+ }
454
+ }
455
+ catch {
456
+ // Slots endpoint not available — skip silently
457
+ }
458
+ const outputParts = [
130
459
  `Connected as ${identity.actorId}`,
460
+ connections.size > 1
461
+ ? `IMPORTANT: Multiple agents connected (${connections.size}). Pass actorId="${identity.actorId}" in all act/look calls.`
462
+ : "",
131
463
  `Experience: ${state.experienceId || "unknown"}`,
132
- `Browser: ${state.browserUrl || SERVER_URL}`,
133
- `Server: ${SERVER_URL}`,
134
- ``,
135
- formatState(state.sharedState, state.observation),
136
- `Participants: ${state.participants?.join(", ")}`,
137
- `Room Config: ${JSON.stringify(state.config || {}, null, 2)}`,
138
- ...(state.hasRoomConfig ? [`(This experience supports configurable rooms — use spawn_room to create configured sub-rooms)`] : []),
139
- ``,
140
- `Tools:`,
141
- formatToolList(state.tools),
142
- ``,
143
- `You are now a live participant. The stop hook keeps you present — use act to interact.`,
144
- ].join("\n");
145
- return { content: [{ type: "text", text: output }] };
464
+ ];
465
+ const role = joinData?.role;
466
+ const systemPrompt = joinData?.systemPrompt;
467
+ if (role)
468
+ outputParts.push(`Role: ${role}`);
469
+ outputParts.push(`Browser: ${state.browserUrl || SERVER_URL}`, `Server: ${SERVER_URL}`, ``);
470
+ if (systemPrompt) {
471
+ outputParts.push(`Your instructions:`, systemPrompt, ``);
472
+ }
473
+ outputParts.push(formatState(state.sharedState, joinData?.observation || state.observation), `Participants: ${state.participants?.join(", ")}`, ``, `Tools:`, formatToolList(joinData?.tools || state.tools || []), ``);
474
+ // Surface unfilled autoSpawn slots
475
+ if (unfilledSlots.length > 0) {
476
+ 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.")`, ``);
477
+ }
478
+ // Mode-specific instructions
479
+ if (agentMode === "behavior") {
480
+ outputParts.push(`BEHAVIOR MODE:`, ` You are a behavior-only agent. All actions run autonomously via the tick engine.`, ` No stop hook — you will NOT receive wake-up events.`, ` Set up behaviors now, then disconnect. The tick engine runs them automatically.`, ``);
481
+ }
482
+ else if (agentMode === "manual") {
483
+ outputParts.push(`MANUAL MODE:`, ` You are a manual agent. Use act() for all decisions. No behaviors.`, ` The stop hook keeps you present — use act to interact.`, ` COMMUNICATE: Use _chat.send to share strategy. Use _chat.team for team-only coordination.`, ``);
484
+ }
485
+ else {
486
+ outputParts.push(`FAST BRAIN / SLOW BRAIN:`, ` Fast brain: Register behaviors via _behavior.set for reactive, per-tick actions that run automatically.`, ` Slow brain: Use act() for strategic decisions and adapting to new situations.`, ` Set up your fast brain FIRST, then use your slow brain to observe and adapt.`, ` COMMUNICATE: Use _chat.send to share strategy. Use _chat.team for team-only coordination.`, ``, `You are now a live participant. The stop hook keeps you present — use act to interact.`);
487
+ }
488
+ // Register the stop hook so Claude Code wakes us on events
489
+ ensureStopHook();
490
+ return { content: [{ type: "text", text: outputParts.filter(Boolean).join("\n") }] };
146
491
  }
147
492
  catch (err) {
148
493
  return {
149
494
  content: [{
150
495
  type: "text",
151
- text: `Failed to connect to ${SERVER_URL}.\n\nIs the dev server running? (npm run dev)\nHave you run /vibevibes-join?\n\nError: ${err.message}`,
496
+ text: `Failed to connect.\n\nIs the dev server running? (npm run dev)\n\nError: ${toErrorMessage(err)}`,
152
497
  }],
153
498
  };
154
499
  }
155
500
  });
156
501
  // ── Tool: act ──────────────────────────────────────────────
157
- server.tool("act", `Execute a tool to mutate shared state. All state changes go through tools.
158
-
159
- Supports batching: pass a "batch" array to execute multiple tool calls sequentially in one round-trip.
160
- Each call sees the state left by the previous call, so order matters.
161
-
162
- Single call:
163
- act(toolName="counter.increment", input={amount: 2})
164
-
165
- Batch call (multiple tools in one round-trip):
166
- act(batch=[
167
- {toolName: "counter.increment", input: {amount: 2}},
168
- {toolName: "counter.increment", input: {amount: 3}},
169
- {toolName: "phase.set", input: {phase: "playing"}}
170
- ])
171
-
172
- Use roomId to interact with a specific room (e.g. a cross-experience spawned room).
173
- Defaults to the "local" (host) room if omitted.`, {
502
+ server.tool("act", `Execute a tool to mutate shared state. All state changes go through tools.
503
+
504
+ Supports batching: pass a "batch" array to execute multiple tool calls sequentially in one round-trip.
505
+ Each call sees the state left by the previous call, so order matters.
506
+
507
+ Single call:
508
+ act(toolName="counter.increment", input={amount: 2})
509
+
510
+ Batch call (multiple tools in one round-trip):
511
+ act(batch=[
512
+ {toolName: "counter.increment", input: {amount: 2}},
513
+ {toolName: "counter.increment", input: {amount: 3}},
514
+ {toolName: "phase.set", input: {phase: "playing"}}
515
+ ])`, {
174
516
  toolName: z.string().optional().describe("Tool to call, e.g. 'counter.increment'. Required if batch is not provided."),
175
- input: z.record(z.any()).optional().describe("Tool input parameters (for single call)"),
517
+ input: z.record(z.unknown()).optional().describe("Tool input parameters (for single call)"),
176
518
  batch: z.array(z.object({
177
519
  toolName: z.string().describe("Tool to call"),
178
- input: z.record(z.any()).optional().describe("Tool input parameters"),
520
+ input: z.record(z.unknown()).optional().describe("Tool input parameters"),
179
521
  })).optional().describe("Array of tool calls to execute sequentially in one round-trip. Each call sees the state from the previous call."),
180
- roomId: z.string().optional().describe("Target room ID (defaults to 'local')"),
181
- }, async ({ toolName, input, batch, roomId }) => {
522
+ actorId: z.string().optional().describe("Your actorId from the connect response. Required when multiple agents are connected in the same session."),
523
+ }, async ({ toolName, input, batch, actorId }) => {
524
+ let slot;
182
525
  try {
183
- loadIdentity();
526
+ slot = resolveIdentity(actorId);
184
527
  }
185
528
  catch (err) {
186
- return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
529
+ try {
530
+ loadIdentity();
531
+ }
532
+ catch { }
533
+ try {
534
+ slot = resolveIdentity(actorId);
535
+ }
536
+ catch (err2) {
537
+ return { content: [{ type: "text", text: `Not connected: ${toErrorMessage(err2)}` }] };
538
+ }
187
539
  }
188
- const targetRoom = roomId || "local";
540
+ const effectiveActorId = slot.actorId;
189
541
  // Build the list of calls: either from batch or single toolName+input
190
542
  const calls = batch && batch.length > 0
191
543
  ? batch
@@ -195,19 +547,25 @@ Defaults to the "local" (host) room if omitted.`, {
195
547
  if (calls.length === 0) {
196
548
  return { content: [{ type: "text", text: `Provide either toolName or batch.` }] };
197
549
  }
198
- const results = [];
550
+ // Validate tool names to prevent path traversal
551
+ const SAFE_TOOL_NAME = /^[a-zA-Z0-9_.\-]+$/;
199
552
  for (const call of calls) {
553
+ if (!SAFE_TOOL_NAME.test(call.toolName)) {
554
+ return { content: [{ type: "text", text: `Invalid tool name: "${call.toolName}". Tool names may only contain letters, digits, dots, hyphens, and underscores.` }] };
555
+ }
556
+ }
557
+ const effectiveOwner = slot.owner || currentOwner || undefined;
558
+ const results = [];
559
+ if (calls.length === 1) {
560
+ const call = calls[0];
200
561
  try {
201
- const idempotencyKey = `${currentActorId}-${call.toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
202
- const toolPath = targetRoom === "local"
203
- ? `/tools/${call.toolName}`
204
- : `/rooms/${targetRoom}/tools/${call.toolName}`;
205
- const result = await fetchJSON(toolPath, {
562
+ const idempotencyKey = `${effectiveActorId}-${call.toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
563
+ const result = await fetchJSON(`/tools/${call.toolName}`, {
206
564
  method: "POST",
207
565
  headers: { "X-Idempotency-Key": idempotencyKey },
208
566
  body: JSON.stringify({
209
- actorId: currentActorId || "mcp-client",
210
- owner: currentOwner || undefined,
567
+ actorId: effectiveActorId || "mcp-client",
568
+ owner: effectiveOwner,
211
569
  input: call.input || {},
212
570
  }),
213
571
  timeoutMs: 30000,
@@ -216,423 +574,260 @@ Defaults to the "local" (host) room if omitted.`, {
216
574
  results.push({ toolName: call.toolName, error: result.error });
217
575
  }
218
576
  else {
219
- results.push({ toolName: call.toolName, output: result.output });
577
+ results.push({ toolName: call.toolName, output: result.output, observation: result.observation });
220
578
  }
221
579
  }
222
580
  catch (err) {
223
- results.push({ toolName: call.toolName, error: err.message });
581
+ results.push({ toolName: call.toolName, error: toErrorMessage(err) });
224
582
  }
225
583
  }
226
- // Fetch final state after all calls
227
- try {
228
- const statePath = targetRoom === "local" ? "/state" : `/rooms/${targetRoom}`;
229
- const state = await fetchJSON(statePath, { timeoutMs: 5000 });
230
- const stateObj = state.sharedState || state;
231
- const outputParts = [];
232
- if (targetRoom !== "local")
233
- outputParts.push(`[Room: ${targetRoom}]`);
234
- for (const r of results) {
235
- if (r.error) {
236
- outputParts.push(`${r.toolName} → ERROR: ${r.error}`);
584
+ else {
585
+ try {
586
+ const batchResult = await fetchJSON(`/tools-batch`, {
587
+ method: "POST",
588
+ body: JSON.stringify({
589
+ actorId: effectiveActorId || "mcp-client",
590
+ owner: effectiveOwner,
591
+ calls: calls.map(c => ({ tool: c.toolName, input: c.input || {} })),
592
+ }),
593
+ timeoutMs: 30000,
594
+ });
595
+ if (batchResult.results && Array.isArray(batchResult.results)) {
596
+ for (const r of batchResult.results) {
597
+ if (r.error) {
598
+ results.push({ toolName: r.tool, error: r.error });
599
+ }
600
+ else {
601
+ results.push({ toolName: r.tool, output: r.output, observation: r.observation });
602
+ }
603
+ }
237
604
  }
238
- else {
239
- outputParts.push(`${r.toolName} ${JSON.stringify(r.output)}`);
605
+ else if (batchResult.error) {
606
+ results.push({ toolName: calls[0].toolName, error: batchResult.error });
240
607
  }
241
608
  }
242
- outputParts.push(formatState(stateObj, state.observation));
243
- return { content: [{ type: "text", text: outputParts.join("\n") }] };
244
- }
245
- catch (err) {
246
- // Still return tool results even if state fetch fails
247
- const outputParts = results.map(r => r.error ? `${r.toolName} → ERROR: ${r.error}` : `${r.toolName} → ${JSON.stringify(r.output)}`);
248
- outputParts.push(`(State fetch failed: ${err.message})`);
249
- return { content: [{ type: "text", text: outputParts.join("\n") }] };
250
- }
251
- });
252
- // ── Tool: stream ───────────────────────────────────────────
253
- server.tool("stream", `Send a continuous state update via stream channel.
254
-
255
- Streams are high-frequency state mutations (brush strokes, sliders, cursors) that bypass the full tool pipeline.
256
- They validate input, merge into state, and broadcast — but don't create event log entries.
257
-
258
- Use this instead of act for high-frequency updates.`, {
259
- name: z.string().describe("Stream name, e.g. 'brush.stroke'"),
260
- input: z.record(z.any()).optional().describe("Stream input parameters"),
261
- roomId: z.string().optional().describe("Target room ID (defaults to 'local')"),
262
- }, async ({ name, input, roomId }) => {
263
- try {
264
- loadIdentity();
265
- }
266
- catch (err) {
267
- return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
268
- }
269
- const targetRoom = roomId || "local";
270
- try {
271
- const streamPath = targetRoom === "local"
272
- ? `/streams/${name}`
273
- : `/rooms/${targetRoom}/streams/${name}`;
274
- const result = await fetchJSON(streamPath, {
275
- method: "POST",
276
- body: JSON.stringify({ actorId: currentActorId, input: input || {} }),
277
- timeoutMs: 10000,
278
- });
279
- if (result.error) {
280
- return { content: [{ type: "text", text: `Stream error: ${result.error}` }] };
609
+ catch (_batchErr) {
610
+ // Fallback: if batch endpoint fails, run calls individually
611
+ for (const call of calls) {
612
+ try {
613
+ const result = await fetchJSON(`/tools/${call.toolName}`, {
614
+ method: "POST",
615
+ body: JSON.stringify({
616
+ actorId: effectiveActorId || "mcp-client",
617
+ owner: effectiveOwner,
618
+ input: call.input || {},
619
+ }),
620
+ timeoutMs: 30000,
621
+ });
622
+ if (result.error) {
623
+ results.push({ toolName: call.toolName, error: result.error });
624
+ }
625
+ else {
626
+ results.push({ toolName: call.toolName, output: result.output, observation: result.observation });
627
+ }
628
+ }
629
+ catch (e) {
630
+ results.push({ toolName: call.toolName, error: toErrorMessage(e) });
631
+ }
632
+ }
281
633
  }
282
- return { content: [{ type: "text", text: `Stream ${name} sent.` }] };
283
- }
284
- catch (err) {
285
- return { content: [{ type: "text", text: `Stream failed: ${err.message}` }] };
286
634
  }
287
- });
288
- // ── Tool: spawn_room ──────────────────────────────────────
289
- server.tool("spawn_room", `Spawn a new configured room (sub-room).
290
-
291
- Each room is an instance of the experience with its own config.
292
- Think of it as: experience = game engine, config = level parameters.
293
-
294
- You can pass:
295
- - experienceId: which experience to run in the new room (defaults to current experience)
296
- - config: an object of config values (validated against the experience's schema)
297
- - config: a preset name string (e.g. "boss-fight") to use predefined configs
298
-
299
- Use list_experiences to discover available experiences.
300
- Use room_config_schema with an experienceId to discover config fields before spawning.
301
-
302
- Returns the new room's ID, URL, and resolved config.`, {
303
- experienceId: z.string().optional().describe("Experience to run in the new room (defaults to host experience). Use list_experiences to see available options."),
304
- name: z.string().optional().describe("Room name (auto-generated if omitted)"),
305
- config: z.union([z.record(z.any()), z.string()]).optional().describe("Config values object or preset name string"),
306
- initialState: z.record(z.any()).optional().describe("Initial shared state for the new room"),
307
- linkBack: z.boolean().optional().describe("Store parent roomId in child state"),
308
- sourceRoomId: z.string().optional().describe("Parent room ID (defaults to 'local')"),
309
- }, async ({ experienceId, name, config, initialState, linkBack, sourceRoomId }) => {
310
- try {
311
- loadIdentity();
312
- }
313
- catch (err) {
314
- return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
315
- }
316
- try {
317
- const result = await fetchJSON("/rooms/spawn", {
318
- method: "POST",
319
- body: JSON.stringify({
320
- experienceId,
321
- name,
322
- config,
323
- initialState,
324
- linkBack: linkBack ?? true,
325
- sourceRoomId: sourceRoomId || "local",
326
- }),
327
- timeoutMs: 30000, // External experiences need time to bundle
328
- });
329
- if (result.error) {
330
- const hint = result.error.includes("not found")
331
- ? `\nUse list_experiences to see available experience IDs.`
332
- : "";
333
- return { content: [{ type: "text", text: `Spawn failed: ${result.error}${hint}` }] };
334
- }
335
- const output = [
336
- `Room spawned successfully!`,
337
- experienceId ? ` Experience: ${experienceId}` : "",
338
- ` Room ID: ${result.roomId}`,
339
- ` URL: ${result.url}`,
340
- ` Config: ${JSON.stringify(result.config, null, 2)}`,
341
- ``,
342
- `The room is live. Participants can join at the URL above.`,
343
- `Use act(roomId="${result.roomId}") to interact with this room.`,
344
- ].filter(Boolean).join("\n");
345
- return { content: [{ type: "text", text: output }] };
635
+ // Build compact response
636
+ const outputParts = [];
637
+ let lastObservation = null;
638
+ for (const r of results) {
639
+ if (r.error) {
640
+ outputParts.push(`${r.toolName} ERROR: ${r.error}`);
641
+ }
642
+ else {
643
+ outputParts.push(`${r.toolName} ${JSON.stringify(r.output)}`);
644
+ if (r.observation)
645
+ lastObservation = r.observation;
646
+ }
346
647
  }
347
- catch (err) {
348
- return {
349
- content: [{
350
- type: "text",
351
- text: `Failed to spawn room: ${err.message}`,
352
- }],
353
- };
648
+ if (lastObservation) {
649
+ outputParts.push(`Observation: ${JSON.stringify(lastObservation)}`);
354
650
  }
651
+ return { content: [{ type: "text", text: outputParts.join("\n") }] };
355
652
  });
356
- // ── Tool: list_rooms ──────────────────────────────────────
357
- server.tool("list_rooms", `List all active rooms and their configs.
358
-
359
- Returns room IDs, participant counts, configs, and parent/child relationships.
360
- Use this to discover what rooms exist and how they're configured.`, {}, async () => {
653
+ // ── Tool: look ─────────────────────────────────────────────
654
+ server.tool("look", `Observe the current state without taking any action.
655
+
656
+ Returns the observation (or raw state), participants, and available tools.
657
+ Read-only no mutations, no events created.
658
+
659
+ Use this to orient yourself before deciding whether to act.`, {
660
+ actorId: z.string().optional().describe("Your actorId from connect. Required when multiple agents connected."),
661
+ }, async ({ actorId }) => {
662
+ let slot;
361
663
  try {
362
- loadIdentity();
664
+ slot = resolveIdentity(actorId);
363
665
  }
364
- catch (err) {
365
- return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
666
+ catch {
667
+ try {
668
+ loadIdentity();
669
+ slot = resolveIdentity(actorId);
670
+ }
671
+ catch (err) {
672
+ return { content: [{ type: "text", text: `Not connected: ${toErrorMessage(err)}` }] };
673
+ }
366
674
  }
367
675
  try {
368
- const rooms = await fetchJSON("/rooms", { timeoutMs: 5000 });
369
- if (!Array.isArray(rooms) || rooms.length === 0) {
370
- return { content: [{ type: "text", text: "No rooms found." }] };
371
- }
372
- const lines = rooms.map((r) => {
373
- const configStr = Object.keys(r.config || {}).length > 0
374
- ? `config: ${JSON.stringify(r.config)}`
375
- : "config: (default)";
376
- const parentStr = r.parentRoomId ? ` ← parent: ${r.parentRoomId}` : "";
377
- const childStr = r.childRoomIds?.length > 0 ? ` → children: [${r.childRoomIds.join(", ")}]` : "";
378
- return ` ${r.roomId} (${r.participants?.length || 0} participants, ${r.eventCount || 0} events) ${configStr}${parentStr}${childStr}`;
379
- });
380
- return {
381
- content: [{
382
- type: "text",
383
- text: `${rooms.length} room(s):\n${lines.join("\n")}`,
384
- }],
385
- };
676
+ const actorParam = slot.actorId ? `?actorId=${encodeURIComponent(slot.actorId)}` : "";
677
+ const state = await fetchJSON(`/state${actorParam}`, { timeoutMs: 10000 });
678
+ const outputParts = [];
679
+ outputParts.push(`Experience: ${state.experienceId || "unknown"}`);
680
+ outputParts.push(formatState(state.sharedState, state.observation));
681
+ outputParts.push(`Participants: ${(state.participants || []).join(", ")}`);
682
+ return { content: [{ type: "text", text: outputParts.join("\n") }] };
386
683
  }
387
684
  catch (err) {
388
- return {
389
- content: [{
390
- type: "text",
391
- text: `Failed to list rooms: ${err.message}`,
392
- }],
393
- };
685
+ return { content: [{ type: "text", text: `Look failed: ${toErrorMessage(err)}` }] };
394
686
  }
395
687
  });
396
- // ── Tool: room_config_schema ──────────────────────────────
397
- server.tool("room_config_schema", `Get the room configuration schema for an experience.
398
-
399
- Shows what config values a room accepts, their types, defaults, and available presets.
400
- Use this before spawn_room to understand what config options are available.
401
-
402
- Pass experienceId to inspect an external experience's config (loads it on demand).`, {
403
- experienceId: z.string().optional().describe("Experience ID to inspect (defaults to host experience)"),
404
- }, async ({ experienceId }) => {
405
- try {
406
- loadIdentity();
407
- }
408
- catch (err) {
409
- return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
410
- }
688
+ // ── Tool: disconnect ────────────────────────────────────────
689
+ server.tool("disconnect", `Disconnect from the current experience.
690
+
691
+ Removes the agent as a participant, deletes the session file, and cleans up.
692
+ After disconnecting, the stop hook will no longer fire.
693
+ If the experience has locked agent participation (canDisconnect: false in observe()),
694
+ the disconnect will be rejected unless force=true is passed.`, {
695
+ force: z.boolean().optional().describe("Force disconnect even if the experience has locked participation"),
696
+ actorId: z.string().optional().describe("Disconnect a specific agent by actorId. Defaults to most recent connection. Use 'all' to disconnect all agents."),
697
+ }, async ({ force, actorId: disconnectActorId }) => {
411
698
  try {
412
- const queryParam = experienceId ? `?experienceId=${encodeURIComponent(experienceId)}` : "";
413
- const schema = await fetchJSON(`/rooms/config-schema${queryParam}`, { timeoutMs: 15000 });
414
- if (!schema.hasConfig) {
415
- return {
416
- content: [{
417
- type: "text",
418
- text: `${experienceId ? `Experience "${experienceId}"` : "This experience"} does not define a room config schema. Rooms are spawned with default settings.`,
419
- }],
420
- };
699
+ // Handle "disconnect all"
700
+ if (disconnectActorId === "all") {
701
+ const allSlots = [...connections.values()];
702
+ for (const s of allSlots) {
703
+ try {
704
+ await fetchJSON("/leave", { method: "POST", body: JSON.stringify({ actorId: s.actorId }), timeoutMs: 5000 });
705
+ }
706
+ catch { }
707
+ try {
708
+ unlinkSync(resolve(AGENTS_DIR, `${s.owner}.json`));
709
+ }
710
+ catch { }
711
+ connections.delete(s.actorId);
712
+ }
713
+ currentActorId = null;
714
+ currentOwner = null;
715
+ removeStopHook();
716
+ return { content: [{ type: "text", text: `Disconnected all ${allSlots.length} agent(s).` }] };
421
717
  }
422
- const output = [
423
- `Room Config Schema${experienceId ? ` (${experienceId})` : ""}:`,
424
- schema.description ? ` ${schema.description}` : "",
425
- ``,
426
- `Schema: ${JSON.stringify(schema.schema, null, 2)}`,
427
- ``,
428
- `Defaults: ${JSON.stringify(schema.defaults, null, 2)}`,
429
- ``,
430
- `Presets: ${schema.presets.length > 0 ? schema.presets.join(", ") : "(none)"}`,
431
- ``,
432
- `Use spawn_room with config values or a preset name to create a configured room.`,
433
- ].filter(Boolean).join("\n");
434
- return { content: [{ type: "text", text: output }] };
435
- }
436
- catch (err) {
437
- return {
438
- content: [{
439
- type: "text",
440
- text: `Failed to get config schema: ${err.message}`,
441
- }],
442
- };
443
- }
444
- });
445
- // ── Tool: list_experiences ─────────────────────────────────
446
- server.tool("list_experiences", `List available experiences that can be used to spawn rooms.
447
-
448
- Shows the host experience and any external experiences registered in vibevibes.registry.json.
449
- Use this to discover what experiences are available for cross-experience room spawning.
450
-
451
- Each experience can be used as an experienceId in spawn_room.`, {}, async () => {
452
- try {
453
- loadIdentity();
454
- }
455
- catch (err) {
456
- return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
457
- }
458
- try {
459
- const experiences = await fetchJSON("/experiences", { timeoutMs: 10000 });
460
- if (!Array.isArray(experiences) || experiences.length === 0) {
461
- return { content: [{ type: "text", text: "No experiences found." }] };
462
- }
463
- const lines = experiences.map((exp) => {
464
- const tag = exp.source === "host" ? "[HOST]" : "[REGISTRY]";
465
- const loaded = exp.loaded ? "" : " (not yet loaded — will bundle on first spawn)";
466
- const config = exp.hasRoomConfig ? " [configurable]" : "";
467
- return ` ${tag} ${exp.id} v${exp.version || "?"}${config}${loaded}\n ${exp.title || ""} — ${exp.description || ""}`;
468
- });
469
- const output = [
470
- `${experiences.length} experience(s) available:`,
471
- ...lines,
472
- ``,
473
- `Use spawn_room(experienceId="...") to create a room running any of these.`,
474
- `Use room_config_schema(experienceId="...") to inspect config options before spawning.`,
475
- ].join("\n");
476
- return { content: [{ type: "text", text: output }] };
477
- }
478
- catch (err) {
479
- return {
480
- content: [{
481
- type: "text",
482
- text: `Failed to list experiences: ${err.message}`,
483
- }],
484
- };
485
- }
486
- });
487
- // ── Tool: memory ───────────────────────────────────────────
488
- server.tool("memory", `Persistent agent memory (per-session). Survives across tool calls.
489
-
490
- Actions:
491
- get — Retrieve current memory
492
- set — Merge updates into memory`, {
493
- action: z.enum(["get", "set"]).describe("What to do"),
494
- updates: z.record(z.any()).optional().describe("Key-value pairs to merge (for set)"),
495
- }, async ({ action, updates }) => {
496
- const key = currentActorId
497
- ? `local:${currentActorId}`
498
- : "default";
499
- if (action === "get") {
500
- const data = await fetchJSON(`/memory?key=${encodeURIComponent(key)}`, { timeoutMs: 5000 });
501
- return {
502
- content: [{
503
- type: "text",
504
- text: `Memory: ${JSON.stringify(data, null, 2)}`,
505
- }],
506
- };
507
- }
508
- if (action === "set") {
509
- if (!updates || Object.keys(updates).length === 0) {
510
- return { content: [{ type: "text", text: "No updates provided." }] };
718
+ // Resolve which identity to disconnect
719
+ let identity;
720
+ if (disconnectActorId && connections.has(disconnectActorId)) {
721
+ const s = connections.get(disconnectActorId);
722
+ identity = { actorId: s.actorId, serverUrl: SERVER_URL, owner: s.owner };
511
723
  }
512
- await fetchJSON("/memory", {
513
- method: "POST",
514
- body: JSON.stringify({ key, updates }),
515
- timeoutMs: 5000,
516
- });
517
- return { content: [{ type: "text", text: `Memory updated: ${JSON.stringify(updates)}` }] };
518
- }
519
- return { content: [{ type: "text", text: `Unknown action: ${action}` }] };
520
- });
521
- // ── Tool: screenshot ──────────────────────────────────────
522
- server.tool("screenshot", `Capture a screenshot of the experience as seen in the browser.
523
-
524
- Returns the current visual state as a PNG image.
525
- Requires the browser viewer to be open.
526
-
527
- Use this to see what the user sees — inspect paintings, check layouts, read rendered text, etc.`, {
528
- timeout: z.number().optional().describe("Max wait ms (default 10000)"),
529
- }, async ({ timeout }) => {
530
- try {
531
- const t = Math.min(timeout || 10000, 30000);
532
- const screenshotHeaders = {};
533
- if (ROOM_TOKEN) {
534
- screenshotHeaders["Authorization"] = `Bearer ${ROOM_TOKEN}`;
724
+ else {
725
+ try {
726
+ identity = loadIdentity();
727
+ }
728
+ catch {
729
+ // Fallback: scan agent files on disk (handles MCP process restart)
730
+ const cleaned = [];
731
+ if (existsSync(AGENTS_DIR)) {
732
+ for (const file of readdirSync(AGENTS_DIR).filter(f => f.endsWith(".json"))) {
733
+ try {
734
+ const agent = JSON.parse(readFileSync(resolve(AGENTS_DIR, file), "utf-8"));
735
+ try {
736
+ const sUrl = agent.serverUrl || SERVER_URL;
737
+ await fetch(`${sUrl}/leave`, {
738
+ method: "POST",
739
+ headers: { "Content-Type": "application/json" },
740
+ body: JSON.stringify({ actorId: agent.actorId }),
741
+ signal: AbortSignal.timeout(3000),
742
+ });
743
+ }
744
+ catch { }
745
+ unlinkSync(resolve(AGENTS_DIR, file));
746
+ cleaned.push(agent.owner || file);
747
+ }
748
+ catch {
749
+ try {
750
+ unlinkSync(resolve(AGENTS_DIR, file));
751
+ }
752
+ catch { }
753
+ cleaned.push(file);
754
+ }
755
+ }
756
+ }
757
+ removeStopHook();
758
+ if (cleaned.length > 0) {
759
+ return { content: [{ type: "text", text: `Recovered from stale state. Cleaned up ${cleaned.length} agent(s): ${cleaned.join(", ")}` }] };
760
+ }
761
+ return { content: [{ type: "text", text: `Not connected. No agent files found to clean up.` }] };
762
+ }
535
763
  }
536
- const controller = new AbortController();
537
- const timer = setTimeout(() => controller.abort(), t + 5000);
764
+ // Check canDisconnect via agent-context
765
+ if (!force) {
766
+ try {
767
+ const ownerParam = identity.owner ? `&owner=${encodeURIComponent(identity.owner)}` : "";
768
+ const ctxUrl = `/agent-context?timeout=0&actorId=${encodeURIComponent(identity.actorId)}${ownerParam}`;
769
+ const ctx = await fetchJSON(ctxUrl, { timeoutMs: 10000 });
770
+ if (ctx?.observation?.canDisconnect === false) {
771
+ return {
772
+ content: [{
773
+ type: "text",
774
+ text: `The experience has locked agent participation (canDisconnect: false). The experience controls when agents can leave. Use force=true to override.`,
775
+ }],
776
+ };
777
+ }
778
+ }
779
+ catch {
780
+ // If we can't check, allow disconnect (server may be down)
781
+ }
782
+ }
783
+ // POST /leave to remove participant from server
538
784
  try {
539
- const res = await fetch(`${SERVER_URL}/screenshot?timeout=${t}`, {
540
- headers: screenshotHeaders,
541
- signal: controller.signal,
785
+ await fetchJSON("/leave", {
786
+ method: "POST",
787
+ body: JSON.stringify({ actorId: identity.actorId }),
788
+ timeoutMs: 5000,
542
789
  });
543
- if (!res.ok) {
544
- const err = await res.json().catch(() => ({ error: res.statusText }));
545
- return {
546
- content: [{
547
- type: "text",
548
- text: `Screenshot failed: ${err.error || "Unknown error"}`,
549
- }],
550
- };
790
+ }
791
+ catch {
792
+ // Server may already be down — continue with local cleanup
793
+ }
794
+ // Delete own agent file
795
+ if (identity.owner) {
796
+ try {
797
+ unlinkSync(resolve(AGENTS_DIR, `${identity.owner}.json`));
551
798
  }
552
- const arrayBuffer = await res.arrayBuffer();
553
- const base64 = Buffer.from(arrayBuffer).toString("base64");
554
- return {
555
- content: [{
556
- type: "image",
557
- data: base64,
558
- mimeType: "image/png",
559
- }],
560
- };
799
+ catch { }
561
800
  }
562
- finally {
563
- clearTimeout(timer);
801
+ // Remove from connections map
802
+ connections.delete(identity.actorId);
803
+ // Clean up if no connections remain
804
+ if (connections.size === 0) {
805
+ removeStopHook();
806
+ currentActorId = null;
807
+ currentOwner = null;
808
+ }
809
+ else {
810
+ // Update current to the last remaining connection
811
+ const last = [...connections.values()].pop();
812
+ currentActorId = last.actorId;
813
+ currentOwner = last.owner;
564
814
  }
565
- }
566
- catch (err) {
567
815
  return {
568
816
  content: [{
569
817
  type: "text",
570
- text: `Screenshot failed: ${err.message}. Is the dev server running? Is the browser open?`,
818
+ text: connections.size === 0
819
+ ? `Disconnected from experience. You are no longer a participant.`
820
+ : `Disconnected ${identity.actorId}. ${connections.size} agent(s) still connected.`,
571
821
  }],
572
822
  };
573
823
  }
574
- });
575
- // ── Tool: blob_set ─────────────────────────────────────────
576
- server.tool("blob_set", `Store a binary blob on the server. Returns the blob key.
577
-
578
- Data is sent as base64-encoded string. Use this for pixel buffers, audio data, etc.
579
- The blob is stored server-side and can be referenced in state by key.`, {
580
- key: z.string().describe("Blob key/ID"),
581
- data: z.string().describe("Base64-encoded binary data"),
582
- roomId: z.string().optional().describe("Room ID (defaults to 'local')"),
583
- }, async ({ key, data, roomId }) => {
584
- try {
585
- loadIdentity();
586
- const binaryData = Buffer.from(data, 'base64');
587
- const headers = {
588
- "Content-Type": "application/octet-stream",
589
- };
590
- if (ROOM_TOKEN)
591
- headers["Authorization"] = `Bearer ${ROOM_TOKEN}`;
592
- const controller = new AbortController();
593
- const timer = setTimeout(() => controller.abort(), 30000);
594
- try {
595
- const res = await fetch(`${SERVER_URL}/blobs/${encodeURIComponent(key)}?actorId=${encodeURIComponent(currentActorId || '')}`, { method: "POST", headers, body: binaryData, signal: controller.signal });
596
- const result = await res.json();
597
- if (result.error)
598
- return { content: [{ type: "text", text: `Blob error: ${result.error}` }] };
599
- return { content: [{ type: "text", text: `Blob stored: key=${key}, size=${result.size} bytes` }] };
600
- }
601
- finally {
602
- clearTimeout(timer);
603
- }
604
- }
605
824
  catch (err) {
606
- return { content: [{ type: "text", text: `Blob set failed: ${err.message}` }] };
607
- }
608
- });
609
- // ── Tool: blob_get ─────────────────────────────────────────
610
- server.tool("blob_get", `Retrieve a binary blob from the server by key.
611
-
612
- Returns base64-encoded data. Use this to read pixel buffers, audio data, etc.`, {
613
- key: z.string().describe("Blob key/ID to retrieve"),
614
- }, async ({ key }) => {
615
- try {
616
- loadIdentity();
617
- const headers = {};
618
- if (ROOM_TOKEN)
619
- headers["Authorization"] = `Bearer ${ROOM_TOKEN}`;
620
- const controller = new AbortController();
621
- const timer = setTimeout(() => controller.abort(), 30000);
622
- try {
623
- const res = await fetch(`${SERVER_URL}/blobs/${encodeURIComponent(key)}`, { headers, signal: controller.signal });
624
- if (!res.ok)
625
- return { content: [{ type: "text", text: `Blob not found: ${key}` }] };
626
- const buf = await res.arrayBuffer();
627
- const base64 = Buffer.from(buf).toString('base64');
628
- return { content: [{ type: "text", text: `Blob ${key} (${buf.byteLength} bytes):\n${base64}` }] };
629
- }
630
- finally {
631
- clearTimeout(timer);
632
- }
633
- }
634
- catch (err) {
635
- return { content: [{ type: "text", text: `Blob get failed: ${err.message}` }] };
825
+ return {
826
+ content: [{
827
+ type: "text",
828
+ text: `Disconnect failed: ${toErrorMessage(err)}`,
829
+ }],
830
+ };
636
831
  }
637
832
  });
638
833
  // ── Start ──────────────────────────────────────────────────
@@ -644,3 +839,27 @@ main().catch((err) => {
644
839
  console.error("MCP server failed:", err);
645
840
  process.exit(1);
646
841
  });
842
+ // ── Exit handler: best-effort cleanup on process exit ──────
843
+ let cleaned = false;
844
+ function cleanup() {
845
+ if (cleaned)
846
+ return;
847
+ cleaned = true;
848
+ if (currentActorId) {
849
+ try {
850
+ const leaveUrl = `${SERVER_URL}/leave`;
851
+ const script = `fetch(process.argv[1],{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({actorId:process.argv[2]})}).catch(()=>{})`;
852
+ spawnSync("node", ["-e", script, leaveUrl, currentActorId], { timeout: 3000, stdio: "ignore" });
853
+ }
854
+ catch { }
855
+ }
856
+ if (currentOwner) {
857
+ try {
858
+ unlinkSync(resolve(AGENTS_DIR, `${currentOwner}.json`));
859
+ }
860
+ catch { }
861
+ }
862
+ removeStopHook();
863
+ }
864
+ process.on("SIGINT", () => { cleanup(); process.exit(0); });
865
+ process.on("SIGTERM", () => { cleanup(); process.exit(0); });