@vibevibes/mcp 0.2.0 → 0.3.1
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 -21
- package/README.md +69 -201
- 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 -9
- package/dist/index.js +687 -468
- 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/hooks/logic.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision logic for the vibevibes-agent Stop hook.
|
|
3
|
+
* No I/O, no HTTP, no file system — just functions.
|
|
4
|
+
*/
|
|
5
|
+
// ── Helpers ────────────────────────────────────────────────
|
|
6
|
+
/** Format a timestamp as HH:MM:SS */
|
|
7
|
+
function fmtTime(ts) {
|
|
8
|
+
if (ts == null || ts <= 0)
|
|
9
|
+
return "";
|
|
10
|
+
const d = new Date(ts);
|
|
11
|
+
return d.toISOString().slice(11, 19);
|
|
12
|
+
}
|
|
13
|
+
/** Truncate a JSON string intelligently — preserves structure when possible */
|
|
14
|
+
function smartTruncate(input, maxLen = 150) {
|
|
15
|
+
let str;
|
|
16
|
+
try {
|
|
17
|
+
str = JSON.stringify(input) ?? "undefined";
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
str = String(input);
|
|
21
|
+
}
|
|
22
|
+
if (str.length <= maxLen)
|
|
23
|
+
return str;
|
|
24
|
+
// Try to find a natural break point (closing brace/bracket)
|
|
25
|
+
const cutoff = maxLen - 3;
|
|
26
|
+
const slice = str.slice(0, cutoff);
|
|
27
|
+
// If we're in the middle of a string value, close it
|
|
28
|
+
const lastQuote = slice.lastIndexOf('"');
|
|
29
|
+
const lastComma = slice.lastIndexOf(",");
|
|
30
|
+
if (lastComma > lastQuote && lastComma > cutoff - 30) {
|
|
31
|
+
return slice.slice(0, lastComma) + "…}";
|
|
32
|
+
}
|
|
33
|
+
return slice + "…";
|
|
34
|
+
}
|
|
35
|
+
// ── Public API ─────────────────────────────────────────────
|
|
36
|
+
/**
|
|
37
|
+
* Format an agent context into a readable prompt string.
|
|
38
|
+
* This is what Claude sees as the "reason" when the Stop hook blocks exit.
|
|
39
|
+
*
|
|
40
|
+
* Layout priority (most actionable first):
|
|
41
|
+
* 1. Errors (browser, tool, observe)
|
|
42
|
+
* 2. Observation (curated state — the "what matters")
|
|
43
|
+
* 3. Chat messages (human communication)
|
|
44
|
+
* 4. Other events (raw details)
|
|
45
|
+
* 5. Room/tick/participant context
|
|
46
|
+
* 6. Instructions
|
|
47
|
+
*/
|
|
48
|
+
export function formatPrompt(ctx) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
// ── 1. Errors (highest priority) ──────────────────────
|
|
51
|
+
// Browser errors — experience crashed in the viewer
|
|
52
|
+
if (ctx.browserErrors && ctx.browserErrors.length > 0) {
|
|
53
|
+
parts.push(`BROWSER ERROR(S) — the experience crashed in the viewer:`);
|
|
54
|
+
for (const e of ctx.browserErrors) {
|
|
55
|
+
parts.push(` ${e.message}`);
|
|
56
|
+
}
|
|
57
|
+
parts.push(`Fix the experience source code and rebuild if needed.`);
|
|
58
|
+
parts.push("");
|
|
59
|
+
}
|
|
60
|
+
// Last error from the agent's own tool call — show first for immediate feedback
|
|
61
|
+
if (ctx.lastError) {
|
|
62
|
+
parts.push(`Your last action failed:`);
|
|
63
|
+
// Sanitize error strings to prevent prompt injection via newlines
|
|
64
|
+
const safeError = String(ctx.lastError.error || "").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 500);
|
|
65
|
+
const safeTool = String(ctx.lastError.tool || "").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 100);
|
|
66
|
+
parts.push(` ${safeTool} → ${safeError}`);
|
|
67
|
+
parts.push("");
|
|
68
|
+
}
|
|
69
|
+
// Observe function error — the experience's observe() is broken
|
|
70
|
+
if (ctx.observeError) {
|
|
71
|
+
const safeObsError = String(ctx.observeError).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 500);
|
|
72
|
+
parts.push(`Warning: observe() threw an error: ${safeObsError}`);
|
|
73
|
+
parts.push("");
|
|
74
|
+
}
|
|
75
|
+
// ── 2. Observation (curated state — most valuable signal) ──
|
|
76
|
+
if (ctx.observation && Object.keys(ctx.observation).length > 0) {
|
|
77
|
+
let obsStr;
|
|
78
|
+
try {
|
|
79
|
+
obsStr = JSON.stringify(ctx.observation, null, 2);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
obsStr = String(ctx.observation);
|
|
83
|
+
}
|
|
84
|
+
if (!obsStr)
|
|
85
|
+
obsStr = "[unserializable observation]";
|
|
86
|
+
// If observation is small, inline it. If large, indent it as a block.
|
|
87
|
+
if (obsStr.length <= 200) {
|
|
88
|
+
parts.push(`Observation: ${obsStr}`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
parts.push(`Observation:`);
|
|
92
|
+
// Indent each line of the JSON
|
|
93
|
+
for (const line of obsStr.split("\n")) {
|
|
94
|
+
parts.push(` ${line}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
parts.push("");
|
|
98
|
+
}
|
|
99
|
+
// ── 3. Events (what happened since last wake-up) ──────
|
|
100
|
+
if (ctx.events && ctx.events.length > 0) {
|
|
101
|
+
// Filter out noise: _behavior.* (setup), tick engine events (_tick-engine / _system)
|
|
102
|
+
const visibleEvents = ctx.events.filter((e) => !(e.tool || "").startsWith("_behavior.") &&
|
|
103
|
+
e.actorId !== "_tick-engine" &&
|
|
104
|
+
e.owner !== "_system");
|
|
105
|
+
// Separate chat messages from other events for prominent display
|
|
106
|
+
const chatEvents = visibleEvents.filter((e) => e.tool === "_chat.send");
|
|
107
|
+
const otherEvents = visibleEvents.filter((e) => e.tool !== "_chat.send" && e.tool !== "_chat.team");
|
|
108
|
+
if (chatEvents.length > 0) {
|
|
109
|
+
parts.push(`CHAT MESSAGE${chatEvents.length > 1 ? "S" : ""}:`);
|
|
110
|
+
for (const e of chatEvents) {
|
|
111
|
+
const actor = e.role || e.owner || (e.actorId ? e.actorId.split("-")[0] : "unknown");
|
|
112
|
+
const roomTag = e.roomId ? `[${e.roomId}] ` : "";
|
|
113
|
+
const time = fmtTime(e.ts);
|
|
114
|
+
const input = e.input;
|
|
115
|
+
// Sanitize chat messages to prevent prompt injection via newlines, ANSI escapes, control chars
|
|
116
|
+
const safeMsg = (input?.message || "").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 500);
|
|
117
|
+
parts.push(` ${time} ${roomTag}${actor}: "${safeMsg}"`);
|
|
118
|
+
}
|
|
119
|
+
parts.push("");
|
|
120
|
+
}
|
|
121
|
+
// Team chat messages (from _chat.team tool calls)
|
|
122
|
+
const teamChatEvents = visibleEvents.filter((e) => e.tool === "_chat.team");
|
|
123
|
+
if (teamChatEvents.length > 0) {
|
|
124
|
+
// Group by team
|
|
125
|
+
const byTeam = new Map();
|
|
126
|
+
for (const e of teamChatEvents) {
|
|
127
|
+
const input = e.input;
|
|
128
|
+
const team = (input?.team || "unknown").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 50);
|
|
129
|
+
if (!byTeam.has(team))
|
|
130
|
+
byTeam.set(team, []);
|
|
131
|
+
byTeam.get(team).push(e);
|
|
132
|
+
}
|
|
133
|
+
for (const [team, events] of byTeam) {
|
|
134
|
+
parts.push(`TEAM CHAT (${team}):`);
|
|
135
|
+
for (const e of events) {
|
|
136
|
+
const actor = e.role || e.owner || (e.actorId ? e.actorId.split("-")[0] : "unknown");
|
|
137
|
+
const time = fmtTime(e.ts);
|
|
138
|
+
const input = e.input;
|
|
139
|
+
const safeMsg = (input?.message || "").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 500);
|
|
140
|
+
parts.push(` ${time} ${actor}: "${safeMsg}"`);
|
|
141
|
+
}
|
|
142
|
+
parts.push("");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (otherEvents.length > 0) {
|
|
146
|
+
// Detect multi-room activity
|
|
147
|
+
const roomIds = new Set(otherEvents.map((e) => e.roomId).filter(Boolean));
|
|
148
|
+
const multiRoom = roomIds.size > 1;
|
|
149
|
+
const header = multiRoom
|
|
150
|
+
? `${otherEvents.length} event(s) across ${roomIds.size} rooms:`
|
|
151
|
+
: `${otherEvents.length} event(s) since your last action:`;
|
|
152
|
+
parts.push(header);
|
|
153
|
+
for (const e of otherEvents) {
|
|
154
|
+
const actor = e.role || e.owner || (e.actorId ? e.actorId.split("-")[0] : "unknown");
|
|
155
|
+
const inputStr = smartTruncate(e.input);
|
|
156
|
+
const roomTag = e.roomId ? `[${e.roomId}] ` : "";
|
|
157
|
+
const time = fmtTime(e.ts);
|
|
158
|
+
parts.push(` ${time} ${roomTag}[${actor}] ${e.tool}(${inputStr})`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// ── 4. Context (rooms, tick engines, participants) ─────
|
|
163
|
+
// Room info — show what rooms exist and their experiences
|
|
164
|
+
if (ctx.rooms && Object.keys(ctx.rooms).length > 1) {
|
|
165
|
+
parts.push("");
|
|
166
|
+
parts.push(`Rooms:`);
|
|
167
|
+
for (const [roomId, info] of Object.entries(ctx.rooms)) {
|
|
168
|
+
const p = info.participants?.length || 0;
|
|
169
|
+
parts.push(` ${roomId} (${info.experience}, ${p} participant${p !== 1 ? "s" : ""})`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Tick engine status — only show if something interesting
|
|
173
|
+
if (ctx.tickEngines) {
|
|
174
|
+
const activeEngines = Object.entries(ctx.tickEngines).filter(([, status]) => status.enabled && status.behaviorsActive > 0);
|
|
175
|
+
if (activeEngines.length > 0) {
|
|
176
|
+
parts.push("");
|
|
177
|
+
for (const [roomId, status] of activeEngines) {
|
|
178
|
+
parts.push(`Tick engine [${roomId}]: ${status.behaviorsActive} behavior(s) active, ${status.tickCount} ticks`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Participants (deduplicated)
|
|
183
|
+
if (ctx.participants && ctx.participants.length > 0) {
|
|
184
|
+
const unique = [...new Set(ctx.participants)];
|
|
185
|
+
parts.push("");
|
|
186
|
+
parts.push(`Participants: ${unique.join(", ")}`);
|
|
187
|
+
}
|
|
188
|
+
// ── 5. Critical event detection ───────────────────────
|
|
189
|
+
if (ctx.events && ctx.events.length > 0) {
|
|
190
|
+
const criticalPatterns = /kill|destroy|tower|ace|gold|death|victory|defeat|eliminated|reset/i;
|
|
191
|
+
const criticalEvents = ctx.events.filter((e) => {
|
|
192
|
+
const toolStr = e.tool || "";
|
|
193
|
+
const outputStr = typeof e.output === "string" ? e.output : "";
|
|
194
|
+
return criticalPatterns.test(toolStr) || criticalPatterns.test(outputStr);
|
|
195
|
+
});
|
|
196
|
+
if (criticalEvents.length > 0) {
|
|
197
|
+
parts.push("");
|
|
198
|
+
const eventTypes = [...new Set(criticalEvents.map(e => e.tool || "event"))].slice(0, 5).join(", ");
|
|
199
|
+
parts.push(`CRITICAL EVENT DETECTED: ${eventTypes}. Consider adjusting your behavior preset via _behavior.activate to adapt to the new situation.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// ── 6. Instructions ───────────────────────────────────
|
|
203
|
+
parts.push("");
|
|
204
|
+
parts.push("New activity in the experience. Use the act MCP tool with roomId to respond in the right room. If nothing requires a response, you may observe without acting.");
|
|
205
|
+
parts.push("COMMUNICATE: Use _chat.send to share strategy with all players. Use _chat.team for team-only coordination. Good agents communicate intentions before acting.");
|
|
206
|
+
parts.push("IMPORTANT: You are a game participant. ONLY use MCP tools (act, look, stream, memory). NEVER use Bash, rm, or any terminal/file commands.");
|
|
207
|
+
return parts.join("\n");
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Decide whether to block Claude's exit and what to feed back.
|
|
211
|
+
*
|
|
212
|
+
* Returns null to allow exit (no state file = not in agent mode).
|
|
213
|
+
* Returns a StopDecision to block exit and feed context to Claude.
|
|
214
|
+
*/
|
|
215
|
+
export function makeDecision(ctx, iteration, agentState) {
|
|
216
|
+
// No state file — allow normal exit
|
|
217
|
+
if (ctx === null)
|
|
218
|
+
return null;
|
|
219
|
+
// Behavior-only agents never produce stop decisions — they run autonomously via tick engine
|
|
220
|
+
if (agentState?.agentMode === "behavior")
|
|
221
|
+
return null;
|
|
222
|
+
// Only count non-system events (tick engine events are noise)
|
|
223
|
+
const realEvents = ctx.events?.filter((e) => !(e.tool || "").startsWith("_behavior.") &&
|
|
224
|
+
e.actorId !== "_tick-engine" &&
|
|
225
|
+
e.owner !== "_system") || [];
|
|
226
|
+
const hasEvents = realEvents.length > 0;
|
|
227
|
+
const hasError = !!ctx.lastError;
|
|
228
|
+
const hasBrowserErrors = ctx.browserErrors != null && ctx.browserErrors.length > 0;
|
|
229
|
+
const hasObserveError = !!ctx.observeError;
|
|
230
|
+
// Experience signaled completion — allow exit (agent file already deleted by stop-hook)
|
|
231
|
+
if (ctx.observation?.done)
|
|
232
|
+
return null;
|
|
233
|
+
// Nothing to react to — allow exit silently (zero wasted tokens)
|
|
234
|
+
if (!hasEvents && !hasError && !hasBrowserErrors && !hasObserveError)
|
|
235
|
+
return null;
|
|
236
|
+
const reason = formatPrompt(ctx);
|
|
237
|
+
const iter = iteration || 0;
|
|
238
|
+
// Build richer systemMessage with experience + role context
|
|
239
|
+
const mode = agentState?.agentMode || "hybrid";
|
|
240
|
+
const parts = [`vibevibes iteration ${iter}`];
|
|
241
|
+
if (agentState?.roomId)
|
|
242
|
+
parts.push(`Room: ${agentState.roomId}`);
|
|
243
|
+
if (agentState?.role)
|
|
244
|
+
parts.push(`Role: ${agentState.role}`);
|
|
245
|
+
parts.push(`Mode: ${mode}`);
|
|
246
|
+
if (mode === "manual") {
|
|
247
|
+
parts.push(`You are a manual agent. Use act() for all decisions. No behaviors. Communicate strategy via chat.`);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
parts.push(`You are a live participant. Fast brain: _behavior.set for reactive per-tick actions. Slow brain: act() for strategic decisions. Communicate strategy via chat. Adapt behaviors on critical events.`);
|
|
251
|
+
}
|
|
252
|
+
parts.push(`ONLY use MCP tools. NEVER use Bash, rm, or any terminal/file commands.`);
|
|
253
|
+
return {
|
|
254
|
+
decision: "block",
|
|
255
|
+
reason,
|
|
256
|
+
systemMessage: parts.join(" | "),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* vibevibes Stop Hook
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, readdirSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { execSync } from "node:child_process";
|
|
16
|
+
import { makeDecision } from "./logic.js";
|
|
17
|
+
// ── Helpers ────────────────────────────────────────────────
|
|
18
|
+
/** Resolve project root: git root or cwd */
|
|
19
|
+
function resolveProjectRoot() {
|
|
20
|
+
try {
|
|
21
|
+
return execSync("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3000 }).trim();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return process.cwd();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const PROJECT_ROOT = resolveProjectRoot();
|
|
28
|
+
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
|
+
function readStdin() {
|
|
32
|
+
return new Promise((res) => {
|
|
33
|
+
let data = "";
|
|
34
|
+
process.stdin.setEncoding("utf-8");
|
|
35
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
36
|
+
process.stdin.on("end", () => {
|
|
37
|
+
try {
|
|
38
|
+
res(JSON.parse(data));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
res({});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
if (process.stdin.isTTY)
|
|
45
|
+
res({});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Load all agent files from AGENTS_DIR. Returns array of session objects. */
|
|
49
|
+
function loadAgentFiles() {
|
|
50
|
+
if (!existsSync(AGENTS_DIR))
|
|
51
|
+
return [];
|
|
52
|
+
try {
|
|
53
|
+
return readdirSync(AGENTS_DIR)
|
|
54
|
+
.filter((f) => f.endsWith(".json"))
|
|
55
|
+
.map((f) => {
|
|
56
|
+
try {
|
|
57
|
+
const data = JSON.parse(readFileSync(resolve(AGENTS_DIR, f), "utf-8"));
|
|
58
|
+
data._filename = f; // Track filename for iteration updates
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.filter((x) => x !== null);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Increment and persist the iteration counter in an agent file */
|
|
72
|
+
function bumpIteration(agent) {
|
|
73
|
+
const iter = (agent.iteration || 0) + 1;
|
|
74
|
+
try {
|
|
75
|
+
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
|
+
const data = { ...agent };
|
|
79
|
+
delete data._filename; // internal field, not persisted
|
|
80
|
+
data.iteration = iter;
|
|
81
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Non-fatal — iteration tracking is best-effort
|
|
85
|
+
}
|
|
86
|
+
return iter;
|
|
87
|
+
}
|
|
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
|
+
function deleteAgentFile(owner) {
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(resolve(AGENTS_DIR, `${owner}.json`));
|
|
124
|
+
}
|
|
125
|
+
catch { /* ignore */ }
|
|
126
|
+
}
|
|
127
|
+
// Session end cleanup — SIGINT (Ctrl+C), SIGTERM (process kill)
|
|
128
|
+
process.on("SIGINT", () => cleanupAndExit());
|
|
129
|
+
process.on("SIGTERM", () => cleanupAndExit());
|
|
130
|
+
// ── Main ───────────────────────────────────────────────────
|
|
131
|
+
async function main() {
|
|
132
|
+
await readStdin();
|
|
133
|
+
// H1. Read agent files
|
|
134
|
+
const agents = loadAgentFiles();
|
|
135
|
+
if (agents.length === 0) {
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
// Use the first agent file for server/room info (all should be same server)
|
|
139
|
+
const session = agents[0];
|
|
140
|
+
// H2. Reconciliation — verify agents exist on server, clean stale files.
|
|
141
|
+
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) });
|
|
148
|
+
if (res.ok) {
|
|
149
|
+
const data = await res.json();
|
|
150
|
+
if (Array.isArray(data?.participants)) {
|
|
151
|
+
const serverActorIds = new Set(data.participants.map((p) => p.actorId));
|
|
152
|
+
for (const agent of agents) {
|
|
153
|
+
if (agent.actorId && !serverActorIds.has(agent.actorId)) {
|
|
154
|
+
deleteAgentFile(agent.owner);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!data.participants.some((p) => p.type === "ai")) {
|
|
158
|
+
cleanupAndExit();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Server unreachable — proceed to poll (don't delete files)
|
|
165
|
+
}
|
|
166
|
+
// In-memory cursor per agent (not written to disk)
|
|
167
|
+
const cursors = new Map(); // actorId → lastEventCursor
|
|
168
|
+
// Track last-seen phase per agent for subscription: "phase" filtering
|
|
169
|
+
const lastPhase = new Map(); // owner → last phase value
|
|
170
|
+
// Build the list of OUR agents from agent files (not all AI agents on server).
|
|
171
|
+
// Each Claude Code workspace only polls its own agents — prevents cross-terminal
|
|
172
|
+
// observation merging that causes identity confusion (wrong myClaimedHero, wrong role).
|
|
173
|
+
// Reuse the already-loaded agents array — reconciliation may have deleted stale files,
|
|
174
|
+
// so filter to only those whose files still exist on disk.
|
|
175
|
+
const ourAgents = agents
|
|
176
|
+
.filter(a => existsSync(resolve(AGENTS_DIR, a._filename || `${a.owner}.json`)))
|
|
177
|
+
.map((a) => ({
|
|
178
|
+
actorId: a.actorId,
|
|
179
|
+
owner: a.owner,
|
|
180
|
+
role: a.role || undefined,
|
|
181
|
+
iteration: a.iteration || 0,
|
|
182
|
+
_filename: a._filename,
|
|
183
|
+
serverUrl: a.serverUrl || session.serverUrl,
|
|
184
|
+
roomId: a.roomId || undefined,
|
|
185
|
+
subscription: a.subscription || "all",
|
|
186
|
+
agentMode: a.agentMode || undefined,
|
|
187
|
+
lastPhase: a.lastPhase,
|
|
188
|
+
}));
|
|
189
|
+
// Initialize lastPhase from persisted agent files (survives across invocations)
|
|
190
|
+
for (const agent of ourAgents) {
|
|
191
|
+
if (agent.subscription === "phase" && agent.lastPhase !== undefined) {
|
|
192
|
+
lastPhase.set(agent.owner, agent.lastPhase ?? null);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (ourAgents.length === 0) {
|
|
196
|
+
cleanupAndExit(agents.map(a => a._filename || `${a.owner}.json`));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Poll loop — runs until events arrive or no agents remain.
|
|
200
|
+
// Wall-clock limit: exit after 55 seconds to stay within Claude Code's hook timeout.
|
|
201
|
+
const POLL_DEADLINE_MS = 55_000;
|
|
202
|
+
const pollStart = Date.now();
|
|
203
|
+
while (true) {
|
|
204
|
+
// Wall-clock guard — exit cleanly before Claude Code kills us
|
|
205
|
+
const elapsed = Date.now() - pollStart;
|
|
206
|
+
if (elapsed > POLL_DEADLINE_MS) {
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
209
|
+
// Cap poll timeout to remaining time minus buffer, so we don't overshoot the deadline
|
|
210
|
+
const remainingMs = POLL_DEADLINE_MS - elapsed;
|
|
211
|
+
const pollTimeout = Math.min(DEFAULT_TIMEOUT, Math.max(1000, remainingMs - 3000));
|
|
212
|
+
// Poll only OUR agents (from agent files) in parallel
|
|
213
|
+
const results = await Promise.all(ourAgents.map(async (agent) => {
|
|
214
|
+
const ctx = await pollAgent(agent.serverUrl, agent.roomId, agent, cursors, pollTimeout);
|
|
215
|
+
return { agent, ctx };
|
|
216
|
+
}));
|
|
217
|
+
// Update cursors from server responses
|
|
218
|
+
for (const { agent, ctx } of results) {
|
|
219
|
+
if (ctx?.eventCursor != null && agent.actorId) {
|
|
220
|
+
cursors.set(agent.actorId, ctx.eventCursor);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Handle done signals — clean up agent file if experience says done
|
|
224
|
+
const doneOwners = new Set();
|
|
225
|
+
for (const { agent, ctx } of results) {
|
|
226
|
+
if (!ctx)
|
|
227
|
+
continue;
|
|
228
|
+
if (ctx.observation?.done) {
|
|
229
|
+
deleteAgentFile(agent.owner);
|
|
230
|
+
doneOwners.add(agent.owner);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Check if any agent got null context (possibly evicted from server or network blip)
|
|
234
|
+
const stillAlive = results.filter((r) => r.ctx !== null);
|
|
235
|
+
if (stillAlive.length === 0) {
|
|
236
|
+
// Don't delete agent files on transient failure — just exit quietly so retry can succeed
|
|
237
|
+
process.exit(0);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Filter to agents with actionable events
|
|
241
|
+
let live = results.filter((r) => r.ctx && ((r.ctx.events && r.ctx.events.length > 0) ||
|
|
242
|
+
r.ctx.lastError ||
|
|
243
|
+
r.ctx.observeError ||
|
|
244
|
+
(r.ctx.browserErrors != null && r.ctx.browserErrors.length > 0)));
|
|
245
|
+
// Filter out behavior-only agents — they run autonomously via tick engine, never need waking
|
|
246
|
+
live = live.filter((r) => r.agent.agentMode !== "behavior");
|
|
247
|
+
// Apply subscription filtering: "phase" agents only wake on phase changes or errors
|
|
248
|
+
live = live.filter((r) => {
|
|
249
|
+
if (r.agent.subscription !== "phase")
|
|
250
|
+
return true; // "all" passes through
|
|
251
|
+
// Always extract and persist current phase (even on error wakeups)
|
|
252
|
+
const currentPhase = typeof r.ctx?.observation?.phase === "string"
|
|
253
|
+
? r.ctx.observation.phase : null;
|
|
254
|
+
const prevPhase = lastPhase.get(r.agent.owner) ?? null;
|
|
255
|
+
lastPhase.set(r.agent.owner, currentPhase);
|
|
256
|
+
// Always wake on errors
|
|
257
|
+
if (r.ctx?.lastError || r.ctx?.observeError ||
|
|
258
|
+
(r.ctx?.browserErrors != null && r.ctx.browserErrors.length > 0))
|
|
259
|
+
return true;
|
|
260
|
+
// Phase changed — wake
|
|
261
|
+
if (currentPhase !== prevPhase)
|
|
262
|
+
return true;
|
|
263
|
+
// Check for phase-related events from agent's own room only
|
|
264
|
+
const phaseEvents = r.ctx?.events?.filter((e) => {
|
|
265
|
+
if (e.roomId && r.agent.roomId && e.roomId !== r.agent.roomId)
|
|
266
|
+
return false;
|
|
267
|
+
return /phase|start|reset|end|finish/i.test(e.tool || "");
|
|
268
|
+
}) || [];
|
|
269
|
+
if (phaseEvents.length > 0)
|
|
270
|
+
return true;
|
|
271
|
+
return false; // Not a phase change — skip
|
|
272
|
+
});
|
|
273
|
+
// Persist lastPhase for phase-subscription agents across invocations.
|
|
274
|
+
// Update agent.lastPhase in memory (bumpIteration writes it for woken agents).
|
|
275
|
+
// For filtered-out agents, write to disk here so next invocation sees the correct phase.
|
|
276
|
+
const liveOwners = new Set(live.map((r) => r.agent.owner));
|
|
277
|
+
for (const { agent } of results) {
|
|
278
|
+
if (agent.subscription !== "phase")
|
|
279
|
+
continue;
|
|
280
|
+
if (!lastPhase.has(agent.owner))
|
|
281
|
+
continue;
|
|
282
|
+
const phaseVal = lastPhase.get(agent.owner) ?? null;
|
|
283
|
+
agent.lastPhase = phaseVal;
|
|
284
|
+
if (!liveOwners.has(agent.owner)) {
|
|
285
|
+
try {
|
|
286
|
+
const filePath = resolve(AGENTS_DIR, agent._filename || `${agent.owner}.json`);
|
|
287
|
+
const data = { ...agent };
|
|
288
|
+
delete data._filename;
|
|
289
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
290
|
+
}
|
|
291
|
+
catch { /* Non-fatal — best-effort persistence */ }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (live.length > 0) {
|
|
295
|
+
if (live.length > 1) {
|
|
296
|
+
process.stderr.write(`[vibevibes] Warning: ${live.length} agent files found but expected 1. Using first agent only.\n`);
|
|
297
|
+
}
|
|
298
|
+
const { agent, ctx } = live[0];
|
|
299
|
+
const iteration = doneOwners.has(agent.owner) ? (agent.iteration || 0) + 1 : bumpIteration(agent);
|
|
300
|
+
const agentState = {
|
|
301
|
+
roomId: agent.roomId || session.roomId,
|
|
302
|
+
role: agent.role,
|
|
303
|
+
iteration,
|
|
304
|
+
agentMode: agent.agentMode,
|
|
305
|
+
};
|
|
306
|
+
const decision = makeDecision(ctx, iteration, agentState);
|
|
307
|
+
if (decision) {
|
|
308
|
+
process.stdout.write(JSON.stringify(decision));
|
|
309
|
+
}
|
|
310
|
+
process.exit(0);
|
|
311
|
+
}
|
|
312
|
+
// No events — loop again (the server's long-poll IS the delay)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Poll a single agent's server for events */
|
|
316
|
+
async function pollAgent(serverUrl, roomId, agent, cursors, timeout = DEFAULT_TIMEOUT) {
|
|
317
|
+
const { actorId, owner } = agent;
|
|
318
|
+
if (!actorId)
|
|
319
|
+
return null;
|
|
320
|
+
const since = cursors.get(actorId) || 0;
|
|
321
|
+
const ownerParam = owner ? `&owner=${encodeURIComponent(owner)}` : "";
|
|
322
|
+
const roomP = roomId ? `&roomId=${encodeURIComponent(roomId)}` : "";
|
|
323
|
+
const controller = new AbortController();
|
|
324
|
+
const timer = setTimeout(() => controller.abort(), timeout + 1000);
|
|
325
|
+
try {
|
|
326
|
+
const url = `${serverUrl}/agent-context?since=${since}&actorId=${encodeURIComponent(actorId)}&timeout=${timeout}${ownerParam}${roomP}`;
|
|
327
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
328
|
+
if (!res.ok)
|
|
329
|
+
return null;
|
|
330
|
+
return await res.json();
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
main().catch(() => {
|
|
340
|
+
process.exit(0);
|
|
341
|
+
});
|