@vibevibes/mcp 0.9.3 → 0.10.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.d.ts CHANGED
@@ -2,6 +2,6 @@
2
2
  * vibevibes MCP server — connects Claude to a running vibevibes experience.
3
3
  *
4
4
  * The dev server (vibevibes-dev from @vibevibes/sdk) must be running first.
5
- * 5 tools: connect, act, look, screenshot, disconnect
5
+ * 4 tools: connect, act, look, disconnect
6
6
  */
7
7
  export {};
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * vibevibes MCP server — connects Claude to a running vibevibes experience.
3
3
  *
4
4
  * The dev server (vibevibes-dev from @vibevibes/sdk) must be running first.
5
- * 5 tools: connect, act, look, screenshot, disconnect
5
+ * 4 tools: connect, act, look, disconnect
6
6
  */
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -583,35 +583,6 @@ Use this to orient yourself before deciding whether to act.`, {
583
583
  return { content: [{ type: "text", text: `Look failed: ${toErrorMessage(err)}` }] };
584
584
  }
585
585
  });
586
- // ── Tool: screenshot ───────────────────────────────────────
587
- server.tool("screenshot", `Capture a screenshot of the experience as seen in the browser.
588
-
589
- Returns the current visual state as a PNG image. Use this to:
590
- - See what the UI looks like
591
- - Debug visual/layout issues
592
- - Verify your changes rendered correctly
593
-
594
- Requires at least one browser viewer to be connected.`, {}, async () => {
595
- try {
596
- const res = await fetchJSON("/screenshot", { timeoutMs: 15000 });
597
- if (res.error) {
598
- return { content: [{ type: "text", text: `Screenshot failed: ${res.error}` }] };
599
- }
600
- if (!res.dataUrl) {
601
- return { content: [{ type: "text", text: "Screenshot returned empty" }] };
602
- }
603
- // dataUrl is "data:image/png;base64,..."
604
- const base64 = res.dataUrl.replace(/^data:image\/\w+;base64,/, "");
605
- return {
606
- content: [
607
- { type: "image", data: base64, mimeType: "image/png" },
608
- ],
609
- };
610
- }
611
- catch (err) {
612
- return { content: [{ type: "text", text: `Screenshot failed: ${toErrorMessage(err)}` }] };
613
- }
614
- });
615
586
  // ── Tool: disconnect ────────────────────────────────────────
616
587
  server.tool("disconnect", `Disconnect from the current experience.
617
588
 
@@ -2,20 +2,15 @@
2
2
  /**
3
3
  * vibevibes Stop Hook
4
4
  *
5
- * Scans .claude/vibevibes-agents/ for agent files, discovers AI agents from
6
- * the server, polls each for events, and returns to Claude when there's activity.
7
- *
8
- * Self-healing: reconciles agent files against server, cleans stale files,
9
- * and exits gracefully when no AI participants remain.
10
- *
11
- * Shipped with @vibevibes/mcp.
5
+ * Checks if the agent is connected to an experience.
6
+ * If connected, checks for events ONCE (no polling, no timeout).
7
+ * Returns immediately so user input is never blocked.
12
8
  */
13
9
  import { readFileSync, writeFileSync, existsSync, unlinkSync, readdirSync } from "node:fs";
14
10
  import { resolve } from "node:path";
15
11
  import { execSync } from "node:child_process";
16
12
  import { makeDecision } from "./logic.js";
17
13
  // ── Helpers ────────────────────────────────────────────────
18
- /** Resolve project root: git root or cwd */
19
14
  function resolveProjectRoot() {
20
15
  try {
21
16
  return execSync("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3000 }).trim();
@@ -26,8 +21,6 @@ function resolveProjectRoot() {
26
21
  }
27
22
  const PROJECT_ROOT = resolveProjectRoot();
28
23
  const AGENTS_DIR = process.env.VIBEVIBES_AGENTS_DIR || resolve(PROJECT_ROOT, ".claude", "vibevibes-agents");
29
- const DEFAULT_TIMEOUT = 5000;
30
- /** Read JSON from stdin (Claude Code passes hook context here) */
31
24
  function readStdin() {
32
25
  return new Promise((res) => {
33
26
  let data = "";
@@ -45,7 +38,6 @@ function readStdin() {
45
38
  res({});
46
39
  });
47
40
  }
48
- /** Load all agent files from AGENTS_DIR. Returns array of session objects. */
49
41
  function loadAgentFiles() {
50
42
  if (!existsSync(AGENTS_DIR))
51
43
  return [];
@@ -55,7 +47,7 @@ function loadAgentFiles() {
55
47
  .map((f) => {
56
48
  try {
57
49
  const data = JSON.parse(readFileSync(resolve(AGENTS_DIR, f), "utf-8"));
58
- data._filename = f; // Track filename for iteration updates
50
+ data._filename = f;
59
51
  return data;
60
52
  }
61
53
  catch {
@@ -68,83 +60,39 @@ function loadAgentFiles() {
68
60
  return [];
69
61
  }
70
62
  }
71
- /** Increment and persist the iteration counter in an agent file */
72
63
  function bumpIteration(agent) {
73
64
  const iter = (agent.iteration || 0) + 1;
74
65
  try {
75
66
  const filePath = resolve(AGENTS_DIR, agent._filename || `${agent.owner}.json`);
76
- // Use in-memory agent data instead of re-reading from disk to avoid
77
- // race conditions with concurrent stop-hook processes.
78
67
  const data = { ...agent };
79
- delete data._filename; // internal field, not persisted
68
+ delete data._filename;
80
69
  data.iteration = iter;
81
70
  writeFileSync(filePath, JSON.stringify(data, null, 2));
82
71
  }
83
- catch {
84
- // Non-fatal — iteration tracking is best-effort
85
- }
72
+ catch { }
86
73
  return iter;
87
74
  }
88
- /** Delete owned agent files and exit.
89
- * If no filenames specified, deletes all agent files (fallback for SIGINT/SIGTERM). */
90
- let _cleaningUp = false;
91
- function cleanupAndExit(ownedFilenames) {
92
- if (_cleaningUp)
93
- process.exit(0);
94
- _cleaningUp = true;
95
- if (existsSync(AGENTS_DIR)) {
96
- try {
97
- if (ownedFilenames && ownedFilenames.length > 0) {
98
- // Only delete agent files owned by this stop-hook invocation
99
- for (const f of ownedFilenames) {
100
- try {
101
- unlinkSync(resolve(AGENTS_DIR, f));
102
- }
103
- catch { /* ignore */ }
104
- }
105
- }
106
- else {
107
- // Fallback: delete all (SIGINT/SIGTERM where ourAgents is unknown)
108
- for (const f of readdirSync(AGENTS_DIR).filter((f) => f.endsWith(".json"))) {
109
- try {
110
- unlinkSync(resolve(AGENTS_DIR, f));
111
- }
112
- catch { /* ignore */ }
113
- }
114
- }
115
- }
116
- catch { /* ignore */ }
117
- }
118
- process.exit(0);
119
- }
120
- /** Delete a specific agent file by owner name */
121
75
  function deleteAgentFile(owner) {
122
76
  try {
123
77
  unlinkSync(resolve(AGENTS_DIR, `${owner}.json`));
124
78
  }
125
- catch { /* ignore */ }
79
+ catch { }
126
80
  }
127
- // Session end cleanup — SIGINT (Ctrl+C), SIGTERM (process kill)
128
- process.on("SIGINT", () => cleanupAndExit());
129
- process.on("SIGTERM", () => cleanupAndExit());
130
81
  // ── Main ───────────────────────────────────────────────────
131
82
  async function main() {
132
83
  await readStdin();
133
- // H1. Read agent files
84
+ // Load agent files — if none, we're not connected, allow exit
134
85
  const agents = loadAgentFiles();
135
86
  if (agents.length === 0) {
136
87
  process.exit(0);
137
88
  }
138
- // Use the first agent file for server/room info (all should be same server)
139
89
  const session = agents[0];
140
- // H2. Reconciliationverify agents exist on server, clean stale files.
90
+ // Quick reconciliationcheck if our agents still exist on server
141
91
  try {
142
- const sUrl = session.serverUrl;
143
- const rId = session.roomId;
144
- const participantsUrl = rId
145
- ? `${sUrl}/rooms/${rId}/participants`
146
- : `${sUrl}/participants`;
147
- const res = await fetch(participantsUrl, { signal: AbortSignal.timeout(3000) });
92
+ const participantsUrl = session.roomId
93
+ ? `${session.serverUrl}/rooms/${session.roomId}/participants`
94
+ : `${session.serverUrl}/participants`;
95
+ const res = await fetch(participantsUrl, { signal: AbortSignal.timeout(2000) });
148
96
  if (res.ok) {
149
97
  const data = await res.json();
150
98
  if (Array.isArray(data?.participants)) {
@@ -155,127 +103,62 @@ async function main() {
155
103
  }
156
104
  }
157
105
  if (!data.participants.some((p) => p.type === "ai")) {
158
- cleanupAndExit();
106
+ // No AI participants on server — clean up and allow exit
107
+ for (const a of agents)
108
+ deleteAgentFile(a.owner);
109
+ process.exit(0);
159
110
  }
160
111
  }
161
112
  }
162
113
  }
163
114
  catch {
164
- // Server unreachable — proceed to poll (don't delete files)
115
+ // Server unreachable — still block to keep agent alive
165
116
  }
166
- // In-memory cursor per agent (not written to disk)
167
- const cursors = new Map(); // actorId → lastEventCursor
168
- // Build the list of OUR agents from agent files.
169
- const ourAgents = agents
170
- .filter(a => existsSync(resolve(AGENTS_DIR, a._filename || `${a.owner}.json`)))
171
- .map((a) => ({
172
- actorId: a.actorId,
173
- owner: a.owner,
174
- role: a.role || undefined,
175
- iteration: a.iteration || 0,
176
- _filename: a._filename,
177
- serverUrl: a.serverUrl || session.serverUrl,
178
- roomId: a.roomId || undefined,
179
- }));
180
- if (ourAgents.length === 0) {
181
- cleanupAndExit(agents.map(a => a._filename || `${a.owner}.json`));
182
- return;
117
+ // Check for events ONCE no polling, no timeout, instant return
118
+ const agent = agents[0];
119
+ if (!agent.actorId) {
120
+ // Connected but no actorId — block exit silently
121
+ process.stdout.write(JSON.stringify({ decision: "block" }));
122
+ process.exit(0);
183
123
  }
184
- // Poll loop — runs until events arrive or no agents remain.
185
- // Wall-clock limit: exit after 55 seconds to stay within Claude Code's hook timeout.
186
- const POLL_DEADLINE_MS = 55_000;
187
- const pollStart = Date.now();
188
- while (true) {
189
- // Wall-clock guard block exit to keep agent alive
190
- const elapsed = Date.now() - pollStart;
191
- if (elapsed > POLL_DEADLINE_MS) {
192
- // Re-check agent files — if deleted by disconnect, allow exit
193
- const currentAgents = loadAgentFiles();
194
- if (currentAgents.length === 0) {
195
- process.exit(0);
196
- }
197
- // Still connected — block exit
198
- process.stdout.write(JSON.stringify({
199
- decision: "block",
200
- reason: "Still connected to the experience. Waiting for activity.",
201
- }));
124
+ try {
125
+ const ownerParam = agent.owner ? `&owner=${encodeURIComponent(agent.owner)}` : "";
126
+ const roomP = agent.roomId ? `&roomId=${encodeURIComponent(agent.roomId)}` : "";
127
+ // timeout=0 means don't long-poll, just return immediately
128
+ const url = `${agent.serverUrl}/agent-context?since=0&actorId=${encodeURIComponent(agent.actorId)}&timeout=0${ownerParam}${roomP}`;
129
+ const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
130
+ if (!res.ok) {
131
+ // Server error block exit silently
132
+ process.stdout.write(JSON.stringify({ decision: "block" }));
202
133
  process.exit(0);
203
134
  }
204
- // Cap poll timeout to remaining time minus buffer, so we don't overshoot the deadline
205
- const remainingMs = POLL_DEADLINE_MS - elapsed;
206
- const pollTimeout = Math.min(DEFAULT_TIMEOUT, Math.max(1000, remainingMs - 3000));
207
- // Poll only OUR agents (from agent files) in parallel
208
- const results = await Promise.all(ourAgents.map(async (agent) => {
209
- const ctx = await pollAgent(agent.serverUrl, agent.roomId, agent, cursors, pollTimeout);
210
- return { agent, ctx };
211
- }));
212
- // Update cursors from server responses
213
- for (const { agent, ctx } of results) {
214
- if (ctx?.eventCursor != null && agent.actorId) {
215
- cursors.set(agent.actorId, ctx.eventCursor);
216
- }
217
- }
218
- // Handle done signals — clean up agent file if experience says done
219
- const doneOwners = new Set();
220
- for (const { agent, ctx } of results) {
221
- if (!ctx)
222
- continue;
223
- if (ctx.observation?.done) {
224
- deleteAgentFile(agent.owner);
225
- doneOwners.add(agent.owner);
226
- }
227
- }
228
- // Check if any agent got null context (possibly evicted from server or network blip)
229
- const stillAlive = results.filter((r) => r.ctx !== null);
230
- if (stillAlive.length === 0) {
231
- // Don't delete agent files on transient failure — just exit quietly so retry can succeed
135
+ const ctx = await res.json();
136
+ // Check for done signal
137
+ if (ctx.observation?.done) {
138
+ deleteAgentFile(agent.owner);
232
139
  process.exit(0);
233
- return;
234
140
  }
235
- // Filter to agents with actionable events
236
- let live = results.filter((r) => r.ctx && ((r.ctx.events && r.ctx.events.length > 0) ||
237
- r.ctx.lastError ||
238
- r.ctx.observeError ||
239
- (r.ctx.browserErrors != null && r.ctx.browserErrors.length > 0)));
240
- if (live.length > 0) {
241
- if (live.length > 1) {
242
- process.stderr.write(`[vibevibes] Warning: ${live.length} agent files found but expected 1. Using first agent only.\n`);
243
- }
244
- const { agent, ctx } = live[0];
245
- const iteration = doneOwners.has(agent.owner) ? (agent.iteration || 0) + 1 : bumpIteration(agent);
141
+ // Check for actionable events
142
+ const hasEvents = (ctx.events && ctx.events.length > 0) ||
143
+ ctx.lastError ||
144
+ ctx.observeError ||
145
+ (ctx.browserErrors != null && ctx.browserErrors.length > 0);
146
+ if (hasEvents) {
147
+ const iteration = bumpIteration(agent);
246
148
  const decision = makeDecision(ctx, iteration);
247
149
  if (decision) {
248
150
  process.stdout.write(JSON.stringify(decision));
249
151
  }
250
152
  process.exit(0);
251
153
  }
252
- // No events — loop again (the server's long-poll IS the delay)
253
- }
254
- }
255
- /** Poll a single agent's server for events */
256
- async function pollAgent(serverUrl, roomId, agent, cursors, timeout = DEFAULT_TIMEOUT) {
257
- const { actorId, owner } = agent;
258
- if (!actorId)
259
- return null;
260
- const since = cursors.get(actorId) || 0;
261
- const ownerParam = owner ? `&owner=${encodeURIComponent(owner)}` : "";
262
- const roomP = roomId ? `&roomId=${encodeURIComponent(roomId)}` : "";
263
- const controller = new AbortController();
264
- const timer = setTimeout(() => controller.abort(), timeout + 1000);
265
- try {
266
- const url = `${serverUrl}/agent-context?since=${since}&actorId=${encodeURIComponent(actorId)}&timeout=${timeout}${ownerParam}${roomP}`;
267
- const res = await fetch(url, { signal: controller.signal });
268
- if (!res.ok)
269
- return null;
270
- return await res.json();
154
+ // No events — still connected, block exit silently
155
+ process.stdout.write(JSON.stringify({ decision: "block" }));
156
+ process.exit(0);
271
157
  }
272
158
  catch {
273
- return null;
274
- }
275
- finally {
276
- clearTimeout(timer);
159
+ // Network error — block exit silently
160
+ process.stdout.write(JSON.stringify({ decision: "block" }));
161
+ process.exit(0);
277
162
  }
278
163
  }
279
- main().catch(() => {
280
- process.exit(0);
281
- });
164
+ main().catch(() => process.exit(0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibevibes/mcp",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "description": "MCP server — connects Claude to vibevibes experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",