@vibevibes/mcp 0.1.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/LICENSE +21 -0
- package/README.md +51 -0
- package/bin/cli.js +11 -11
- package/bin/postinstall.js +39 -0
- package/bin/serve.js +41 -0
- package/dist/bundler.d.ts +36 -0
- package/dist/bundler.js +254 -0
- package/dist/index.d.ts +4 -8
- package/dist/index.js +767 -221
- package/dist/protocol.d.ts +85 -0
- package/dist/protocol.js +240 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.js +1947 -0
- package/dist/tick-engine.d.ts +81 -0
- package/dist/tick-engine.js +151 -0
- package/dist/viewer/index.html +689 -0
- package/hooks/logic.js +258 -0
- package/hooks/stop-hook.js +341 -0
- package/package.json +59 -33
package/dist/index.js
CHANGED
|
@@ -1,18 +1,112 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* vibevibes-mcp —
|
|
2
|
+
* vibevibes-mcp — MCP server for agent participation in vibevibes experiences.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
* npx vibevibes-mcp # defaults to http://localhost:4321
|
|
8
|
-
* npx vibevibes-mcp https://xyz.trycloudflare.com # join a shared room
|
|
9
|
-
* VIBEVIBES_SERVER_URL=https://... npx vibevibes-mcp
|
|
10
|
-
*
|
|
11
|
-
* 5 tools: connect, watch, act, memory, screenshot
|
|
7
|
+
* 4 tools: connect, act, look, disconnect
|
|
12
8
|
*/
|
|
13
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
11
|
import { z } from "zod";
|
|
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); });
|
|
16
110
|
// Resolve server URL: CLI arg > env var > localhost default
|
|
17
111
|
const RAW_SERVER_URL = process.argv[2] ||
|
|
18
112
|
process.env.VIBEVIBES_SERVER_URL ||
|
|
@@ -22,31 +116,165 @@ const parsedUrl = new URL(RAW_SERVER_URL);
|
|
|
22
116
|
const ROOM_TOKEN = parsedUrl.searchParams.get("token");
|
|
23
117
|
// Strip the token param from the base URL so it isn't duplicated in request paths
|
|
24
118
|
parsedUrl.searchParams.delete("token");
|
|
25
|
-
|
|
26
|
-
|
|
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. */
|
|
27
123
|
let currentActorId = null;
|
|
28
|
-
let
|
|
29
|
-
|
|
124
|
+
let currentOwner = null;
|
|
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 ─────────────────────────────────────
|
|
157
|
+
/**
|
|
158
|
+
* Ensure the Stop hook is registered in .claude/settings.json.
|
|
159
|
+
* Called on connect. Idempotent — skips if already present.
|
|
160
|
+
* Uses absolute path because hook CWD may differ from project root.
|
|
161
|
+
*/
|
|
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));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Hook registration is best-effort
|
|
188
|
+
}
|
|
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}`;
|
|
231
|
+
}
|
|
30
232
|
// ── Helpers ────────────────────────────────────────────────
|
|
31
233
|
async function fetchJSON(path, opts) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
};
|
|
36
|
-
// Attach room token as Authorization header when available
|
|
37
|
-
if (ROOM_TOKEN) {
|
|
38
|
-
headers["Authorization"] = `Bearer ${ROOM_TOKEN}`;
|
|
39
|
-
}
|
|
40
|
-
const res = await fetch(`${SERVER_URL}${path}`, {
|
|
41
|
-
...opts,
|
|
42
|
-
headers,
|
|
43
|
-
});
|
|
44
|
-
const text = await res.text();
|
|
234
|
+
const timeoutMs = opts?.timeoutMs ?? 30000;
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
45
237
|
try {
|
|
46
|
-
|
|
238
|
+
const headers = {
|
|
239
|
+
"Content-Type": "application/json",
|
|
240
|
+
...(opts?.headers || {}),
|
|
241
|
+
};
|
|
242
|
+
// Attach room token as Authorization header when available
|
|
243
|
+
if (ROOM_TOKEN) {
|
|
244
|
+
headers["Authorization"] = `Bearer ${ROOM_TOKEN}`;
|
|
245
|
+
}
|
|
246
|
+
const res = await fetch(`${SERVER_URL}${path}`, {
|
|
247
|
+
...opts,
|
|
248
|
+
signal: controller.signal,
|
|
249
|
+
headers,
|
|
250
|
+
});
|
|
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
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(text);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new Error(`Server returned non-JSON: ${text.slice(0, 200)}`);
|
|
268
|
+
}
|
|
47
269
|
}
|
|
48
|
-
catch {
|
|
49
|
-
|
|
270
|
+
catch (err) {
|
|
271
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
272
|
+
throw new Error(`Request timed out after ${timeoutMs}ms: ${path}`);
|
|
273
|
+
}
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
clearTimeout(timer);
|
|
50
278
|
}
|
|
51
279
|
}
|
|
52
280
|
function formatToolList(tools) {
|
|
@@ -54,248 +282,542 @@ function formatToolList(tools) {
|
|
|
54
282
|
return "No tools available.";
|
|
55
283
|
return tools
|
|
56
284
|
.map((t) => {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
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"}`)
|
|
60
289
|
.join(", ")
|
|
61
290
|
: "{}";
|
|
62
291
|
return ` ${t.name} (${t.risk || "low"}) — ${t.description}\n input: { ${schema} }`;
|
|
63
292
|
})
|
|
64
293
|
.join("\n");
|
|
65
294
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return join;
|
|
77
|
-
}
|
|
78
|
-
async function ensureConnected() {
|
|
79
|
-
if (connected)
|
|
80
|
-
return;
|
|
81
|
-
await joinRoom();
|
|
295
|
+
/**
|
|
296
|
+
* Format state for agent consumption.
|
|
297
|
+
* If the experience defines observe(), show the curated observation.
|
|
298
|
+
* Otherwise fall back to raw state.
|
|
299
|
+
*/
|
|
300
|
+
function formatState(state, observation) {
|
|
301
|
+
if (observation) {
|
|
302
|
+
return `Observation: ${JSON.stringify(observation, null, 2)}`;
|
|
303
|
+
}
|
|
304
|
+
return `State: ${JSON.stringify(state, null, 2)}`;
|
|
82
305
|
}
|
|
83
306
|
// ── MCP Server ─────────────────────────────────────────────
|
|
84
307
|
const server = new McpServer({
|
|
85
308
|
name: "vibevibes",
|
|
86
|
-
version: "0.
|
|
309
|
+
version: "0.4.0",
|
|
87
310
|
});
|
|
88
311
|
// ── Tool: connect ──────────────────────────────────────────
|
|
89
|
-
server.tool("connect", `Connect to the running experience.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 }) => {
|
|
94
324
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 = [
|
|
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
|
+
: "",
|
|
463
|
+
`Experience: ${state.experienceId || "unknown"}`,
|
|
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") }] };
|
|
109
491
|
}
|
|
110
492
|
catch (err) {
|
|
111
493
|
return {
|
|
112
494
|
content: [{
|
|
113
495
|
type: "text",
|
|
114
|
-
text: `Failed to connect
|
|
496
|
+
text: `Failed to connect.\n\nIs the dev server running? (npm run dev)\n\nError: ${toErrorMessage(err)}`,
|
|
115
497
|
}],
|
|
116
498
|
};
|
|
117
499
|
}
|
|
118
500
|
});
|
|
119
|
-
// ── Tool:
|
|
120
|
-
server.tool("
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
501
|
+
// ── Tool: act ──────────────────────────────────────────────
|
|
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
|
+
])`, {
|
|
516
|
+
toolName: z.string().optional().describe("Tool to call, e.g. 'counter.increment'. Required if batch is not provided."),
|
|
517
|
+
input: z.record(z.unknown()).optional().describe("Tool input parameters (for single call)"),
|
|
518
|
+
batch: z.array(z.object({
|
|
519
|
+
toolName: z.string().describe("Tool to call"),
|
|
520
|
+
input: z.record(z.unknown()).optional().describe("Tool input parameters"),
|
|
521
|
+
})).optional().describe("Array of tool calls to execute sequentially in one round-trip. Each call sees the state from the previous call."),
|
|
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;
|
|
132
525
|
try {
|
|
133
|
-
|
|
526
|
+
slot = resolveIdentity(actorId);
|
|
134
527
|
}
|
|
135
528
|
catch (err) {
|
|
136
|
-
return { content: [{ type: "text", text: `Not connected: ${err.message}` }] };
|
|
137
|
-
}
|
|
138
|
-
const t = Math.min(timeout || 30000, 55000);
|
|
139
|
-
// Check if predicate already matches
|
|
140
|
-
if (predicate) {
|
|
141
529
|
try {
|
|
142
|
-
|
|
143
|
-
const fn = new Function("state", "actorId", `return ${predicate}`);
|
|
144
|
-
if (fn(current.sharedState, currentActorId)) {
|
|
145
|
-
return {
|
|
146
|
-
content: [{
|
|
147
|
-
type: "text",
|
|
148
|
-
text: [
|
|
149
|
-
`Predicate already true: ${predicate}`,
|
|
150
|
-
`State: ${JSON.stringify(current.sharedState, null, 2)}`,
|
|
151
|
-
`Participants: ${current.participants?.join(", ")}`,
|
|
152
|
-
].join("\n"),
|
|
153
|
-
}],
|
|
154
|
-
};
|
|
155
|
-
}
|
|
530
|
+
loadIdentity();
|
|
156
531
|
}
|
|
157
|
-
catch {
|
|
158
|
-
|
|
532
|
+
catch { }
|
|
533
|
+
try {
|
|
534
|
+
slot = resolveIdentity(actorId);
|
|
535
|
+
}
|
|
536
|
+
catch (err2) {
|
|
537
|
+
return { content: [{ type: "text", text: `Not connected: ${toErrorMessage(err2)}` }] };
|
|
159
538
|
}
|
|
160
539
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
540
|
+
const effectiveActorId = slot.actorId;
|
|
541
|
+
// Build the list of calls: either from batch or single toolName+input
|
|
542
|
+
const calls = batch && batch.length > 0
|
|
543
|
+
? batch
|
|
544
|
+
: toolName
|
|
545
|
+
? [{ toolName, input }]
|
|
546
|
+
: [];
|
|
547
|
+
if (calls.length === 0) {
|
|
548
|
+
return { content: [{ type: "text", text: `Provide either toolName or batch.` }] };
|
|
169
549
|
}
|
|
170
|
-
|
|
171
|
-
|
|
550
|
+
// Validate tool names to prevent path traversal
|
|
551
|
+
const SAFE_TOOL_NAME = /^[a-zA-Z0-9_.\-]+$/;
|
|
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
|
+
}
|
|
172
556
|
}
|
|
173
|
-
|
|
174
|
-
|
|
557
|
+
const effectiveOwner = slot.owner || currentOwner || undefined;
|
|
558
|
+
const results = [];
|
|
559
|
+
if (calls.length === 1) {
|
|
560
|
+
const call = calls[0];
|
|
175
561
|
try {
|
|
176
|
-
const
|
|
177
|
-
|
|
562
|
+
const idempotencyKey = `${effectiveActorId}-${call.toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
563
|
+
const result = await fetchJSON(`/tools/${call.toolName}`, {
|
|
564
|
+
method: "POST",
|
|
565
|
+
headers: { "X-Idempotency-Key": idempotencyKey },
|
|
566
|
+
body: JSON.stringify({
|
|
567
|
+
actorId: effectiveActorId || "mcp-client",
|
|
568
|
+
owner: effectiveOwner,
|
|
569
|
+
input: call.input || {},
|
|
570
|
+
}),
|
|
571
|
+
timeoutMs: 30000,
|
|
572
|
+
});
|
|
573
|
+
if (result.error) {
|
|
574
|
+
results.push({ toolName: call.toolName, error: result.error });
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
results.push({ toolName: call.toolName, output: result.output, observation: result.observation });
|
|
578
|
+
}
|
|
178
579
|
}
|
|
179
|
-
catch {
|
|
180
|
-
|
|
580
|
+
catch (err) {
|
|
581
|
+
results.push({ toolName: call.toolName, error: toErrorMessage(err) });
|
|
181
582
|
}
|
|
182
583
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else if (batchResult.error) {
|
|
606
|
+
results.push({ toolName: calls[0].toolName, error: batchResult.error });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
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
|
+
}
|
|
188
633
|
}
|
|
189
634
|
}
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
}
|
|
192
647
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (predicate) {
|
|
196
|
-
parts.push(`Predicate "${predicate}": ${predicateMatched}`);
|
|
648
|
+
if (lastObservation) {
|
|
649
|
+
outputParts.push(`Observation: ${JSON.stringify(lastObservation)}`);
|
|
197
650
|
}
|
|
198
|
-
return { content: [{ type: "text", text:
|
|
651
|
+
return { content: [{ type: "text", text: outputParts.join("\n") }] };
|
|
199
652
|
});
|
|
200
|
-
// ── Tool:
|
|
201
|
-
server.tool("
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}, 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;
|
|
209
663
|
try {
|
|
210
|
-
|
|
664
|
+
slot = resolveIdentity(actorId);
|
|
211
665
|
}
|
|
212
|
-
catch
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
method: "POST",
|
|
217
|
-
body: JSON.stringify({
|
|
218
|
-
actorId: currentActorId || "mcp-client",
|
|
219
|
-
input: input || {},
|
|
220
|
-
}),
|
|
221
|
-
});
|
|
222
|
-
if (result.error) {
|
|
223
|
-
return { content: [{ type: "text", text: `Tool error: ${result.error}` }] };
|
|
224
|
-
}
|
|
225
|
-
const state = await fetchJSON("/state");
|
|
226
|
-
const output = [
|
|
227
|
-
`${toolName} → ${JSON.stringify(result.output)}`,
|
|
228
|
-
`State: ${JSON.stringify(state.sharedState, null, 2)}`,
|
|
229
|
-
].join("\n");
|
|
230
|
-
return { content: [{ type: "text", text: output }] };
|
|
231
|
-
});
|
|
232
|
-
// ── Tool: memory ───────────────────────────────────────────
|
|
233
|
-
server.tool("memory", `Persistent agent memory (per-session). Survives across tool calls.
|
|
234
|
-
|
|
235
|
-
Actions:
|
|
236
|
-
get — Retrieve current memory
|
|
237
|
-
set — Merge updates into memory`, {
|
|
238
|
-
action: z.enum(["get", "set"]).describe("What to do"),
|
|
239
|
-
updates: z.record(z.any()).optional().describe("Key-value pairs to merge (for set)"),
|
|
240
|
-
}, async ({ action, updates }) => {
|
|
241
|
-
const key = currentActorId
|
|
242
|
-
? `local:${currentActorId}`
|
|
243
|
-
: "default";
|
|
244
|
-
if (action === "get") {
|
|
245
|
-
const data = await fetchJSON(`/memory?key=${encodeURIComponent(key)}`);
|
|
246
|
-
return {
|
|
247
|
-
content: [{
|
|
248
|
-
type: "text",
|
|
249
|
-
text: `Memory: ${JSON.stringify(data, null, 2)}`,
|
|
250
|
-
}],
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
if (action === "set") {
|
|
254
|
-
if (!updates || Object.keys(updates).length === 0) {
|
|
255
|
-
return { content: [{ type: "text", text: "No updates provided." }] };
|
|
666
|
+
catch {
|
|
667
|
+
try {
|
|
668
|
+
loadIdentity();
|
|
669
|
+
slot = resolveIdentity(actorId);
|
|
256
670
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
671
|
+
catch (err) {
|
|
672
|
+
return { content: [{ type: "text", text: `Not connected: ${toErrorMessage(err)}` }] };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
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") }] };
|
|
683
|
+
}
|
|
684
|
+
catch (err) {
|
|
685
|
+
return { content: [{ type: "text", text: `Look failed: ${toErrorMessage(err)}` }] };
|
|
262
686
|
}
|
|
263
|
-
return { content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
264
687
|
});
|
|
265
|
-
// ── Tool:
|
|
266
|
-
server.tool("
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 }) => {
|
|
274
698
|
try {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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).` }] };
|
|
279
717
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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 };
|
|
723
|
+
}
|
|
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
|
+
}
|
|
763
|
+
}
|
|
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
|
|
784
|
+
try {
|
|
785
|
+
await fetchJSON("/leave", {
|
|
786
|
+
method: "POST",
|
|
787
|
+
body: JSON.stringify({ actorId: identity.actorId }),
|
|
788
|
+
timeoutMs: 5000,
|
|
789
|
+
});
|
|
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`));
|
|
798
|
+
}
|
|
799
|
+
catch { }
|
|
800
|
+
}
|
|
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;
|
|
291
814
|
}
|
|
292
|
-
const arrayBuffer = await res.arrayBuffer();
|
|
293
|
-
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
|
294
815
|
return {
|
|
295
816
|
content: [{
|
|
296
|
-
type: "
|
|
297
|
-
|
|
298
|
-
|
|
817
|
+
type: "text",
|
|
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.`,
|
|
299
821
|
}],
|
|
300
822
|
};
|
|
301
823
|
}
|
|
@@ -303,7 +825,7 @@ Use this to see what the user sees — inspect paintings, check layouts, read re
|
|
|
303
825
|
return {
|
|
304
826
|
content: [{
|
|
305
827
|
type: "text",
|
|
306
|
-
text: `
|
|
828
|
+
text: `Disconnect failed: ${toErrorMessage(err)}`,
|
|
307
829
|
}],
|
|
308
830
|
};
|
|
309
831
|
}
|
|
@@ -317,3 +839,27 @@ main().catch((err) => {
|
|
|
317
839
|
console.error("MCP server failed:", err);
|
|
318
840
|
process.exit(1);
|
|
319
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); });
|