@vibevibes/mcp 0.9.2 → 0.9.5
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/hooks/stop-hook.js +51 -158
- package/package.json +1 -1
package/hooks/stop-hook.js
CHANGED
|
@@ -2,20 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* vibevibes Stop Hook
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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;
|
|
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;
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
90
|
+
// Quick reconciliation — check if our agents still exist on server
|
|
141
91
|
try {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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,117 +103,62 @@ async function main() {
|
|
|
155
103
|
}
|
|
156
104
|
}
|
|
157
105
|
if (!data.participants.some((p) => p.type === "ai")) {
|
|
158
|
-
|
|
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 —
|
|
115
|
+
// Server unreachable — still block to keep agent alive
|
|
165
116
|
}
|
|
166
|
-
//
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
.
|
|
171
|
-
.
|
|
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 to stay alive
|
|
121
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: "Connected. Waiting." }));
|
|
122
|
+
process.exit(0);
|
|
183
123
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 to stay alive
|
|
132
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: "Connected. Server returned error." }));
|
|
192
133
|
process.exit(0);
|
|
193
134
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const results = await Promise.all(ourAgents.map(async (agent) => {
|
|
199
|
-
const ctx = await pollAgent(agent.serverUrl, agent.roomId, agent, cursors, pollTimeout);
|
|
200
|
-
return { agent, ctx };
|
|
201
|
-
}));
|
|
202
|
-
// Update cursors from server responses
|
|
203
|
-
for (const { agent, ctx } of results) {
|
|
204
|
-
if (ctx?.eventCursor != null && agent.actorId) {
|
|
205
|
-
cursors.set(agent.actorId, ctx.eventCursor);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
// Handle done signals — clean up agent file if experience says done
|
|
209
|
-
const doneOwners = new Set();
|
|
210
|
-
for (const { agent, ctx } of results) {
|
|
211
|
-
if (!ctx)
|
|
212
|
-
continue;
|
|
213
|
-
if (ctx.observation?.done) {
|
|
214
|
-
deleteAgentFile(agent.owner);
|
|
215
|
-
doneOwners.add(agent.owner);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
// Check if any agent got null context (possibly evicted from server or network blip)
|
|
219
|
-
const stillAlive = results.filter((r) => r.ctx !== null);
|
|
220
|
-
if (stillAlive.length === 0) {
|
|
221
|
-
// 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);
|
|
222
139
|
process.exit(0);
|
|
223
|
-
return;
|
|
224
140
|
}
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
(
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
process.stderr.write(`[vibevibes] Warning: ${live.length} agent files found but expected 1. Using first agent only.\n`);
|
|
233
|
-
}
|
|
234
|
-
const { agent, ctx } = live[0];
|
|
235
|
-
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);
|
|
236
148
|
const decision = makeDecision(ctx, iteration);
|
|
237
149
|
if (decision) {
|
|
238
150
|
process.stdout.write(JSON.stringify(decision));
|
|
239
151
|
}
|
|
240
152
|
process.exit(0);
|
|
241
153
|
}
|
|
242
|
-
// No events —
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
/** Poll a single agent's server for events */
|
|
246
|
-
async function pollAgent(serverUrl, roomId, agent, cursors, timeout = DEFAULT_TIMEOUT) {
|
|
247
|
-
const { actorId, owner } = agent;
|
|
248
|
-
if (!actorId)
|
|
249
|
-
return null;
|
|
250
|
-
const since = cursors.get(actorId) || 0;
|
|
251
|
-
const ownerParam = owner ? `&owner=${encodeURIComponent(owner)}` : "";
|
|
252
|
-
const roomP = roomId ? `&roomId=${encodeURIComponent(roomId)}` : "";
|
|
253
|
-
const controller = new AbortController();
|
|
254
|
-
const timer = setTimeout(() => controller.abort(), timeout + 1000);
|
|
255
|
-
try {
|
|
256
|
-
const url = `${serverUrl}/agent-context?since=${since}&actorId=${encodeURIComponent(actorId)}&timeout=${timeout}${ownerParam}${roomP}`;
|
|
257
|
-
const res = await fetch(url, { signal: controller.signal });
|
|
258
|
-
if (!res.ok)
|
|
259
|
-
return null;
|
|
260
|
-
return await res.json();
|
|
154
|
+
// No events — still connected, block exit
|
|
155
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: "Connected. No new events." }));
|
|
156
|
+
process.exit(0);
|
|
261
157
|
}
|
|
262
158
|
catch {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
clearTimeout(timer);
|
|
159
|
+
// Network error — block exit to keep agent alive
|
|
160
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: "Connected. Checking..." }));
|
|
161
|
+
process.exit(0);
|
|
267
162
|
}
|
|
268
163
|
}
|
|
269
|
-
main().catch(() =>
|
|
270
|
-
process.exit(0);
|
|
271
|
-
});
|
|
164
|
+
main().catch(() => process.exit(0));
|