From 30f49b641b77571756751cdbfc6879dc3c16625d Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Wed, 25 Mar 2026 15:58:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20add=20file-based=20event=20brid?= =?UTF-8?q?ge=20for=20Plugin=E2=86=92MCP=20communication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Python EventBridge: JSON-lines emitter with 0o600 permissions - TypeScript EventBridgeReader: incremental polling with byte offset tracking - 5 event types: tool_call, session_start, session_end, pattern_detected, rule_suggested - Auto-create ~/.codingbuddy/events/ directory - Session cleanup on demand - 11 Python tests + 10 TypeScript tests Closes #933 --- .../src/shared/event-bridge-reader.spec.ts | 192 ++++++++++++++++++ .../src/shared/event-bridge-reader.ts | 89 ++++++++ .../hooks/lib/event_bridge.py | 70 +++++++ .../tests/test_event_bridge.py | 129 ++++++++++++ 4 files changed, 480 insertions(+) create mode 100644 apps/mcp-server/src/shared/event-bridge-reader.spec.ts create mode 100644 apps/mcp-server/src/shared/event-bridge-reader.ts create mode 100644 packages/claude-code-plugin/hooks/lib/event_bridge.py create mode 100644 packages/claude-code-plugin/tests/test_event_bridge.py diff --git a/apps/mcp-server/src/shared/event-bridge-reader.spec.ts b/apps/mcp-server/src/shared/event-bridge-reader.spec.ts new file mode 100644 index 00000000..3a2e0b3c --- /dev/null +++ b/apps/mcp-server/src/shared/event-bridge-reader.spec.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { EventBridgeReader, EVENT_TYPES } from './event-bridge-reader'; +import type { EventType } from './event-bridge-reader'; + +/** + * Real file system tests — No Mocking Principle (augmented-coding.md) + */ +describe('event-bridge-reader', () => { + let eventsDir: string; + + beforeEach(async () => { + eventsDir = await fs.mkdtemp(join(tmpdir(), 'event-bridge-test-')); + }); + + afterEach(async () => { + await fs.rm(eventsDir, { recursive: true, force: true }); + }); + + describe('EVENT_TYPES', () => { + it('should contain all five required event types', () => { + const required: EventType[] = [ + 'tool_call', + 'session_start', + 'session_end', + 'pattern_detected', + 'rule_suggested', + ]; + for (const t of required) { + expect(EVENT_TYPES).toContain(t); + } + }); + }); + + describe('readNewEvents', () => { + it('should return empty array when file does not exist', async () => { + const reader = new EventBridgeReader('no-session', eventsDir); + const events = await reader.readNewEvents(); + expect(events).toEqual([]); + }); + + it('should return empty array when file is empty', async () => { + const sessionId = 'empty-session'; + await fs.writeFile(join(eventsDir, `${sessionId}.jsonl`), '', 'utf-8'); + + const reader = new EventBridgeReader(sessionId, eventsDir); + const events = await reader.readNewEvents(); + expect(events).toEqual([]); + }); + + it('should parse all JSON lines from the file', async () => { + const sessionId = 'parse-test'; + const lines = [ + JSON.stringify({ + ts: '2026-01-01T00:00:00Z', + type: 'session_start', + session_id: sessionId, + payload: {}, + }), + JSON.stringify({ + ts: '2026-01-01T00:00:01Z', + type: 'tool_call', + session_id: sessionId, + payload: { tool_name: 'Bash', success: true }, + }), + ]; + await fs.writeFile(join(eventsDir, `${sessionId}.jsonl`), lines.join('\n') + '\n', 'utf-8'); + + const reader = new EventBridgeReader(sessionId, eventsDir); + const events = await reader.readNewEvents(); + expect(events).toHaveLength(2); + expect(events[0].type).toBe('session_start'); + expect(events[1].type).toBe('tool_call'); + }); + + it('should only return new events on subsequent reads', async () => { + const sessionId = 'incremental'; + const filePath = join(eventsDir, `${sessionId}.jsonl`); + const line1 = + JSON.stringify({ + ts: '2026-01-01T00:00:00Z', + type: 'session_start', + session_id: sessionId, + payload: {}, + }) + '\n'; + await fs.writeFile(filePath, line1, 'utf-8'); + + const reader = new EventBridgeReader(sessionId, eventsDir); + + // First read: gets line1 + const first = await reader.readNewEvents(); + expect(first).toHaveLength(1); + + // Append line2 + const line2 = + JSON.stringify({ + ts: '2026-01-01T00:00:01Z', + type: 'tool_call', + session_id: sessionId, + payload: { tool_name: 'Read', success: true }, + }) + '\n'; + await fs.appendFile(filePath, line2, 'utf-8'); + + // Second read: only gets line2 + const second = await reader.readNewEvents(); + expect(second).toHaveLength(1); + expect(second[0].type).toBe('tool_call'); + }); + + it('should correctly parse event schema fields', async () => { + const sessionId = 'schema-test'; + const event = { + ts: '2026-03-25T06:00:00+00:00', + type: 'pattern_detected', + session_id: sessionId, + payload: { pattern: 'repeated_bash_fail' }, + }; + await fs.writeFile( + join(eventsDir, `${sessionId}.jsonl`), + JSON.stringify(event) + '\n', + 'utf-8', + ); + + const reader = new EventBridgeReader(sessionId, eventsDir); + const events = await reader.readNewEvents(); + + expect(events[0]).toEqual( + expect.objectContaining({ + ts: '2026-03-25T06:00:00+00:00', + type: 'pattern_detected', + session_id: sessionId, + payload: { pattern: 'repeated_bash_fail' }, + }), + ); + }); + + it('should skip malformed JSON lines gracefully', async () => { + const sessionId = 'malformed'; + const content = + [ + JSON.stringify({ + ts: '2026-01-01T00:00:00Z', + type: 'session_start', + session_id: sessionId, + payload: {}, + }), + 'NOT VALID JSON{{{', + JSON.stringify({ + ts: '2026-01-01T00:00:01Z', + type: 'session_end', + session_id: sessionId, + payload: {}, + }), + ].join('\n') + '\n'; + await fs.writeFile(join(eventsDir, `${sessionId}.jsonl`), content, 'utf-8'); + + const reader = new EventBridgeReader(sessionId, eventsDir); + const events = await reader.readNewEvents(); + expect(events).toHaveLength(2); + expect(events[0].type).toBe('session_start'); + expect(events[1].type).toBe('session_end'); + }); + }); + + describe('cleanup', () => { + it('should remove the session event file', async () => { + const sessionId = 'cleanup-test'; + const filePath = join(eventsDir, `${sessionId}.jsonl`); + await fs.writeFile(filePath, '{}' + '\n', 'utf-8'); + + const reader = new EventBridgeReader(sessionId, eventsDir); + await reader.cleanup(); + + await expect(fs.access(filePath)).rejects.toThrow(); + }); + + it('should not throw when file does not exist', async () => { + const reader = new EventBridgeReader('nonexistent', eventsDir); + await expect(reader.cleanup()).resolves.toBeUndefined(); + }); + }); + + describe('default eventsDir', () => { + it('should default to ~/.codingbuddy/events/', () => { + const reader = new EventBridgeReader('s1'); + const home = process.env.HOME || process.env.USERPROFILE || ''; + expect(reader.eventsDir).toBe(join(home, '.codingbuddy', 'events')); + }); + }); +}); diff --git a/apps/mcp-server/src/shared/event-bridge-reader.ts b/apps/mcp-server/src/shared/event-bridge-reader.ts new file mode 100644 index 00000000..6c3a41f7 --- /dev/null +++ b/apps/mcp-server/src/shared/event-bridge-reader.ts @@ -0,0 +1,89 @@ +/** + * File-based event bridge reader for Plugin(Python) → MCP(TypeScript) communication. + * + * Reads JSON-lines events from ~/.codingbuddy/events/.jsonl, + * tracking file offset to only return new events on subsequent reads. + */ +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +/** All supported event types emitted by the Python plugin. */ +export const EVENT_TYPES = [ + 'tool_call', + 'session_start', + 'session_end', + 'pattern_detected', + 'rule_suggested', +] as const; + +export type EventType = (typeof EVENT_TYPES)[number]; + +/** Schema for a single bridge event (matches Python EventBridge output). */ +export interface BridgeEvent { + ts: string; + type: EventType; + session_id: string; + payload: Record; +} + +/** + * Reads new events from a session's JSONL file, tracking byte offset + * so each call only returns events appended since the last read. + */ +export class EventBridgeReader { + readonly eventsDir: string; + private readonly sessionId: string; + private offset = 0; + + constructor(sessionId: string, eventsDir?: string) { + this.sessionId = sessionId; + this.eventsDir = eventsDir ?? join(homedir(), '.codingbuddy', 'events'); + } + + private get filePath(): string { + return join(this.eventsDir, `${this.sessionId}.jsonl`); + } + + /** + * Read new events appended since the last call. + * Returns an empty array if the file does not exist or has no new content. + * Malformed JSON lines are silently skipped. + */ + async readNewEvents(): Promise { + let content: string; + try { + const buffer = await fs.readFile(this.filePath, 'utf-8'); + content = buffer.slice(this.offset); + this.offset = buffer.length; + } catch { + return []; + } + + if (!content) { + return []; + } + + const events: BridgeEvent[] = []; + const lines = content.split('\n').filter(line => line.trim().length > 0); + + for (const line of lines) { + try { + events.push(JSON.parse(line) as BridgeEvent); + } catch { + // Skip malformed JSON lines + } + } + + return events; + } + + /** Remove the session event file if it exists. */ + async cleanup(): Promise { + try { + await fs.unlink(this.filePath); + } catch { + // Ignore if file doesn't exist + } + } +} diff --git a/packages/claude-code-plugin/hooks/lib/event_bridge.py b/packages/claude-code-plugin/hooks/lib/event_bridge.py new file mode 100644 index 00000000..5c68c65f --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/event_bridge.py @@ -0,0 +1,70 @@ +"""File-based event bridge for Plugin(Python) → MCP(TypeScript) communication. + +Emits events as JSON lines to ~/.codingbuddy/events/.jsonl. +""" +import json +import os +from datetime import datetime, timezone +from typing import Optional + +EVENT_TYPES = ( + "tool_call", + "session_start", + "session_end", + "pattern_detected", + "rule_suggested", +) + + +class EventBridge: + """Append-only JSON-lines event emitter for a single session.""" + + def __init__(self, session_id: str, events_dir: Optional[str] = None): + self.session_id = session_id + self.events_dir = events_dir or os.path.join( + os.path.expanduser("~"), ".codingbuddy", "events" + ) + + @property + def _file_path(self) -> str: + return os.path.join(self.events_dir, f"{self.session_id}.jsonl") + + def emit(self, event_type: str, payload: dict) -> None: + """Append one JSON-line event to the session file. + + Args: + event_type: One of EVENT_TYPES. + payload: Arbitrary dict attached to the event. + + Raises: + ValueError: If event_type is not in EVENT_TYPES. + """ + if event_type not in EVENT_TYPES: + raise ValueError(f"Unknown event type: {event_type}") + + os.makedirs(self.events_dir, exist_ok=True) + + event = { + "ts": datetime.now(timezone.utc).isoformat(), + "type": event_type, + "session_id": self.session_id, + "payload": payload, + } + + # Open with restricted permissions; create if needed + fd = os.open( + self._file_path, + os.O_WRONLY | os.O_CREAT | os.O_APPEND, + 0o600, + ) + try: + os.write(fd, (json.dumps(event) + "\n").encode()) + finally: + os.close(fd) + + def cleanup(self) -> None: + """Remove the session event file if it exists.""" + try: + os.remove(self._file_path) + except FileNotFoundError: + pass diff --git a/packages/claude-code-plugin/tests/test_event_bridge.py b/packages/claude-code-plugin/tests/test_event_bridge.py new file mode 100644 index 00000000..f1700215 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_event_bridge.py @@ -0,0 +1,129 @@ +"""Tests for EventBridge — file-based event emission for Plugin→MCP communication.""" +import json +import os +import stat +import tempfile + +import pytest + +from hooks.lib.event_bridge import EventBridge, EVENT_TYPES + + +@pytest.fixture +def events_dir(): + """Create a temporary directory for event files.""" + d = tempfile.mkdtemp() + yield d + # Cleanup + for f in os.listdir(d): + os.remove(os.path.join(d, f)) + os.rmdir(d) + + +@pytest.fixture +def bridge(events_dir): + """Create an EventBridge instance with a temp events directory.""" + return EventBridge(session_id="test-session-1", events_dir=events_dir) + + +class TestEventTypes: + def test_event_types_contains_required_types(self): + """EVENT_TYPES must include all five required event types.""" + required = {"tool_call", "session_start", "session_end", "pattern_detected", "rule_suggested"} + assert required.issubset(set(EVENT_TYPES)) + + def test_emit_rejects_unknown_event_type(self, bridge): + """emit() should raise ValueError for unknown event types.""" + with pytest.raises(ValueError, match="Unknown event type"): + bridge.emit("unknown_type", {"data": 1}) + + +class TestEmit: + def test_emit_creates_session_file(self, bridge, events_dir): + """First emit should create the session JSONL file.""" + bridge.emit("session_start", {}) + path = os.path.join(events_dir, "test-session-1.jsonl") + assert os.path.exists(path) + + def test_emit_appends_json_line(self, bridge, events_dir): + """Each emit should append exactly one JSON line.""" + bridge.emit("tool_call", {"tool_name": "Bash", "success": True}) + bridge.emit("tool_call", {"tool_name": "Read", "success": True}) + + path = os.path.join(events_dir, "test-session-1.jsonl") + with open(path, "r") as f: + lines = f.readlines() + assert len(lines) == 2 + + def test_emit_writes_valid_json_with_schema(self, bridge, events_dir): + """Each line must be valid JSON with ts, type, session_id, payload.""" + bridge.emit("tool_call", {"tool_name": "Bash", "success": True}) + + path = os.path.join(events_dir, "test-session-1.jsonl") + with open(path, "r") as f: + event = json.loads(f.readline()) + + assert "ts" in event + assert event["type"] == "tool_call" + assert event["session_id"] == "test-session-1" + assert event["payload"] == {"tool_name": "Bash", "success": True} + + def test_emit_timestamp_is_iso8601(self, bridge, events_dir): + """Timestamp must be ISO 8601 format.""" + bridge.emit("session_start", {}) + + path = os.path.join(events_dir, "test-session-1.jsonl") + with open(path, "r") as f: + event = json.loads(f.readline()) + + # ISO 8601 should contain 'T' and end with timezone info or 'Z' + from datetime import datetime + ts = event["ts"] + # Should parse without error + datetime.fromisoformat(ts.replace("Z", "+00:00")) + + +class TestFilePermissions: + def test_file_created_with_0600_permissions(self, bridge, events_dir): + """Event file must be created with 0o600 permissions.""" + bridge.emit("session_start", {}) + + path = os.path.join(events_dir, "test-session-1.jsonl") + file_stat = os.stat(path) + mode = stat.S_IMODE(file_stat.st_mode) + assert mode == 0o600 + + +class TestDirectoryCreation: + def test_auto_creates_events_directory(self): + """EventBridge should create events directory if it doesn't exist.""" + with tempfile.TemporaryDirectory() as tmp: + nested = os.path.join(tmp, "nested", "events") + bridge = EventBridge(session_id="s1", events_dir=nested) + bridge.emit("session_start", {}) + assert os.path.isdir(nested) + # Cleanup + os.remove(os.path.join(nested, "s1.jsonl")) + + +class TestCleanup: + def test_cleanup_removes_session_file(self, bridge, events_dir): + """cleanup() should remove the session's JSONL file.""" + bridge.emit("session_start", {}) + path = os.path.join(events_dir, "test-session-1.jsonl") + assert os.path.exists(path) + + bridge.cleanup() + assert not os.path.exists(path) + + def test_cleanup_noop_when_no_file(self, bridge): + """cleanup() should not raise when no file exists.""" + bridge.cleanup() # Should not raise + + +class TestDefaultEventsDir: + def test_default_events_dir_uses_home(self): + """Default events_dir should be ~/.codingbuddy/events/.""" + bridge = EventBridge(session_id="s1") + expected = os.path.join(os.path.expanduser("~"), ".codingbuddy", "events") + assert bridge.events_dir == expected