@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/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
+ });