diff --git a/CLAUDE.md b/CLAUDE.md index 2e2137b..99cef6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ bun run dev # Watch mode for development bun test # Run all tests bun test # Run specific test (e.g., bun test sparkline) bun run replay:events -- --input ../tui/test-fixtures/hud-events.jsonl # Replay events + bun run profile:events -- ../tui/test-fixtures/hud-events-stress.jsonl # Profile throughput # Manual testing with a FIFO mkfifo /tmp/test.fifo @@ -52,6 +53,8 @@ Claude Code Hooks → capture-event.sh → FIFO → EventReader → React State | SubagentStop | capture-event.sh | Marks agent complete | | SessionEnd | cleanup.sh | Kills process, removes FIFO | +All events sent to the FIFO include `schemaVersion: 1` (see `docs/API.md`). + ### Library Structure (tui/src/lib/) - **types.ts** - All TypeScript interfaces (HudEvent, ToolEntry, TodoItem, ContextHealth, etc.) diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..bb5d8ea --- /dev/null +++ b/docs/API.md @@ -0,0 +1,36 @@ +# API Contracts + +## HUD Event Schema (v1) + +All events written to the HUD FIFO must include `schemaVersion: 1`. The HUD +will ignore events with an unknown schema version. + +### Required fields +- `schemaVersion`: number (current: `1`) +- `event`: string (e.g., `PreToolUse`, `PostToolUse`, `Stop`) +- `session`: string +- `ts`: number (epoch seconds) + +### Optional fields +- `tool`: string or null +- `toolUseId`: string +- `input`: object or null +- `response`: object or null +- `permissionMode`: string +- `transcriptPath`: string +- `cwd`: string +- `prompt`: string + +### Example +```json +{ + "schemaVersion": 1, + "event": "PostToolUse", + "tool": "Read", + "toolUseId": "tool-1", + "input": { "file_path": "README.md" }, + "response": { "duration_ms": 120 }, + "session": "abc123", + "ts": 1700000000 +} +``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e70600c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,27 @@ +# Documentation + +Start here for a map of the project docs. + +## Getting Started + +- `../README.md` for install + quickstart +- `../TROUBLESHOOTING.md` for common setup issues +- `FAQ.md` for common questions + +## Architecture + +- `ARCHITECTURE.md` for the data flow and component map +- `../CLAUDE.md` for contributor-focused technical details +- `adr/` for architecture decisions +- `API.md` for the HUD event contract +- `THREAT_MODEL.md` for local file and event stream risks + +## Development + +- `../CONTRIBUTING.md` for setup, tests, and contribution guidelines +- `CHANGELOG.md` for release notes +- `RELEASING.md` for the release workflow + +## LLM Helpers + +- `LLM.md` for the copy-paste summary block diff --git a/scripts/capture-event.sh b/scripts/capture-event.sh index 9a93c54..43abef9 100755 --- a/scripts/capture-event.sh +++ b/scripts/capture-event.sh @@ -14,15 +14,36 @@ if [[ -z "$SESSION_ID" || ! "$SESSION_ID" =~ ^[a-zA-Z0-9_-]+$ ]]; then exit 0 fi -EVENT_FIFO="$HOME/.claude/hud/events/$SESSION_ID.fifo" HUD_DIR="$HOME/.claude/hud" +EVENT_FIFO="$HUD_DIR/events/$SESSION_ID.fifo" REFRESH_FILE="$HUD_DIR/refresh.json" -# Update refresh.json with transcriptPath when available (for session resume) +mkdir -p "$HUD_DIR/events" + +if [ ! -p "$EVENT_FIFO" ]; then + rm -f "$EVENT_FIFO" + mkfifo "$EVENT_FIFO" 2>/dev/null || true +fi + +# Ensure refresh.json points at this session in case SessionStart didn't fire. +CURRENT_SESSION=$(jq -r '.sessionId // empty' "$REFRESH_FILE" 2>/dev/null) TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty') +if [ -z "$CURRENT_SESSION" ] || [ "$CURRENT_SESSION" != "$SESSION_ID" ]; then + if [ -n "$TRANSCRIPT_PATH" ]; then + cat > "$REFRESH_FILE" << EOF +{"sessionId":"$SESSION_ID","fifoPath":"$EVENT_FIFO","transcriptPath":"$TRANSCRIPT_PATH"} +EOF + else + cat > "$REFRESH_FILE" << EOF +{"sessionId":"$SESSION_ID","fifoPath":"$EVENT_FIFO"} +EOF + fi + CURRENT_SESSION="$SESSION_ID" +fi + +# Update refresh.json with transcriptPath when available (for session resume) if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$REFRESH_FILE" ]; then # Only update if this session matches the current refresh.json session - CURRENT_SESSION=$(jq -r '.sessionId // empty' "$REFRESH_FILE" 2>/dev/null) if [ "$CURRENT_SESSION" = "$SESSION_ID" ]; then jq --arg tp "$TRANSCRIPT_PATH" '.transcriptPath = $tp' "$REFRESH_FILE" > "$REFRESH_FILE.tmp" && mv "$REFRESH_FILE.tmp" "$REFRESH_FILE" fi @@ -30,6 +51,7 @@ fi if [ -p "$EVENT_FIFO" ]; then EVENT_JSON=$(echo "$INPUT" | jq -c '{ + schemaVersion: 1, event: .hook_event_name, tool: .tool_name, toolUseId: .tool_use_id, diff --git a/tui/src/app-smoke.test.tsx b/tui/src/app-smoke.test.tsx index 7baadc2..9d7b8b0 100644 --- a/tui/src/app-smoke.test.tsx +++ b/tui/src/app-smoke.test.tsx @@ -63,6 +63,7 @@ describe('HUD smoke test', () => { const writer = createWriteStream(fifoPath, { encoding: 'utf-8' }); writer.write( `${JSON.stringify({ + schemaVersion: 1, event: 'UserPromptSubmit', session: 'test-session', prompt: 'Smoke test prompt', diff --git a/tui/src/lib/event-parser.test.ts b/tui/src/lib/event-parser.test.ts index d1e8f85..798595e 100644 --- a/tui/src/lib/event-parser.test.ts +++ b/tui/src/lib/event-parser.test.ts @@ -1,23 +1,11 @@ import { describe, it, expect } from 'vitest'; -import type { HudEvent } from './types.js'; - -// Helper function to parse events as the event reader does -function parseEvent(line: string): HudEvent | null { - try { - const parsed = JSON.parse(line) as HudEvent; - if (!parsed.event || !parsed.session) { - return null; - } - return parsed; - } catch { - return null; - } -} +import { parseHudEvent } from './hud-event.js'; describe('Event Parser', () => { describe('parseEvent', () => { it('should parse valid PostToolUse event', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'PostToolUse', tool: 'Read', input: { file_path: '/test.ts' }, @@ -26,7 +14,7 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).not.toBeNull(); expect(result?.event).toBe('PostToolUse'); @@ -35,6 +23,7 @@ describe('Event Parser', () => { it('should parse valid PreToolUse event', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'PreToolUse', tool: 'Write', toolUseId: 'tool-123', @@ -44,7 +33,7 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).not.toBeNull(); expect(result?.event).toBe('PreToolUse'); @@ -53,6 +42,7 @@ describe('Event Parser', () => { it('should parse UserPromptSubmit event', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'UserPromptSubmit', tool: null, input: null, @@ -62,7 +52,7 @@ describe('Event Parser', () => { prompt: 'Help me fix this bug', }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).not.toBeNull(); expect(result?.event).toBe('UserPromptSubmit'); @@ -71,6 +61,7 @@ describe('Event Parser', () => { it('should parse Stop event', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'Stop', tool: null, input: null, @@ -79,7 +70,7 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).not.toBeNull(); expect(result?.event).toBe('Stop'); @@ -87,6 +78,7 @@ describe('Event Parser', () => { it('should parse PreCompact event', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'PreCompact', tool: null, input: null, @@ -95,7 +87,7 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).not.toBeNull(); expect(result?.event).toBe('PreCompact'); @@ -103,6 +95,7 @@ describe('Event Parser', () => { it('should parse event with session info', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'PostToolUse', tool: 'Read', input: { file_path: '/test.ts' }, @@ -114,7 +107,7 @@ describe('Event Parser', () => { transcriptPath: '/tmp/transcript.json', }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).not.toBeNull(); expect(result?.permissionMode).toBe('plan'); @@ -122,40 +115,43 @@ describe('Event Parser', () => { }); it('should return null for malformed JSON', () => { - const result = parseEvent('not valid json'); + const result = parseHudEvent('not valid json'); expect(result).toBeNull(); }); it('should return null for empty line', () => { - const result = parseEvent(''); + const result = parseHudEvent(''); expect(result).toBeNull(); }); it('should return null for missing event field', () => { const line = JSON.stringify({ + schemaVersion: 1, tool: 'Read', session: 'test', ts: 123, }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).toBeNull(); }); it('should return null for missing session field', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'PostToolUse', tool: 'Read', ts: 123, }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).toBeNull(); }); it('should handle very long file paths', () => { const longPath = '/very/long/path/' + 'nested/'.repeat(50) + 'file.ts'; const line = JSON.stringify({ + schemaVersion: 1, event: 'PostToolUse', tool: 'Read', input: { file_path: longPath }, @@ -164,7 +160,7 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).not.toBeNull(); expect(result?.input?.file_path).toBe(longPath); @@ -173,6 +169,7 @@ describe('Event Parser', () => { it('should handle very long response content', () => { const longContent = 'x'.repeat(100000); const line = JSON.stringify({ + schemaVersion: 1, event: 'PostToolUse', tool: 'Read', input: { file_path: '/test.ts' }, @@ -181,7 +178,7 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).not.toBeNull(); expect(result?.response?.content).toBe(longContent); @@ -189,6 +186,7 @@ describe('Event Parser', () => { it('should handle unicode in content', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'PostToolUse', tool: 'Read', input: { file_path: '/test.ts' }, @@ -197,7 +195,7 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).not.toBeNull(); expect(result?.response?.content).toBe('日本語 🎉 émoji'); @@ -205,6 +203,7 @@ describe('Event Parser', () => { it('should handle special characters in paths', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'PostToolUse', tool: 'Read', input: { file_path: '/path with spaces/file (1).ts' }, @@ -213,7 +212,7 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseEvent(line); + const result = parseHudEvent(line); expect(result).not.toBeNull(); expect(result?.input?.file_path).toBe('/path with spaces/file (1).ts'); diff --git a/tui/src/lib/event-reader.test.ts b/tui/src/lib/event-reader.test.ts index d802d07..d7bc4db 100644 --- a/tui/src/lib/event-reader.test.ts +++ b/tui/src/lib/event-reader.test.ts @@ -14,6 +14,7 @@ describe('EventReader', () => { const filePath = path.join(tmpDir, 'events.log'); const lines = [ JSON.stringify({ + schemaVersion: 1, event: 'PostToolUse', tool: 'Read', input: { file_path: '/tmp/test.txt' }, @@ -23,6 +24,7 @@ describe('EventReader', () => { }), 'not json', JSON.stringify({ + schemaVersion: 1, event: 'UserPromptSubmit', tool: null, input: null, @@ -70,6 +72,7 @@ describe('EventReader', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-hud-')); const filePath = path.join(tmpDir, 'events.log'); const event = JSON.stringify({ + schemaVersion: 1, event: 'PostToolUse', tool: 'Read', input: null, @@ -97,6 +100,7 @@ describe('EventReader', () => { fs.writeFileSync( filePath1, JSON.stringify({ + schemaVersion: 1, event: 'Stop', tool: null, input: null, @@ -109,6 +113,7 @@ describe('EventReader', () => { fs.writeFileSync( filePath2, JSON.stringify({ + schemaVersion: 1, event: 'PreCompact', tool: null, input: null, diff --git a/tui/src/lib/hud-event.test.ts b/tui/src/lib/hud-event.test.ts index 1b4251b..bd640c1 100644 --- a/tui/src/lib/hud-event.test.ts +++ b/tui/src/lib/hud-event.test.ts @@ -4,6 +4,7 @@ import { parseHudEvent } from './hud-event.js'; describe('parseHudEvent', () => { it('parses a valid HUD event', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'PreToolUse', tool: 'Read', toolUseId: 'tool-1', @@ -22,6 +23,7 @@ describe('parseHudEvent', () => { it('accepts events without tool or input fields', () => { const line = JSON.stringify({ + schemaVersion: 1, event: 'UserPromptSubmit', session: 's1', ts: 999, @@ -37,6 +39,19 @@ describe('parseHudEvent', () => { it('rejects invalid JSON or missing fields', () => { expect(parseHudEvent('not-json')).toBeNull(); expect(parseHudEvent(JSON.stringify({ event: 'Stop' }))).toBeNull(); + expect( + parseHudEvent( + JSON.stringify({ + schemaVersion: 2, + event: 'Stop', + tool: null, + input: null, + response: null, + session: 's1', + ts: 1, + }), + ), + ).toBeNull(); expect( parseHudEvent( JSON.stringify({ @@ -50,4 +65,16 @@ describe('parseHudEvent', () => { ), ).toBeNull(); }); + + it('rejects missing schemaVersion', () => { + const line = JSON.stringify({ + event: 'Stop', + tool: null, + input: null, + response: null, + session: 's1', + ts: 1, + }); + expect(parseHudEvent(line)).toBeNull(); + }); }); diff --git a/tui/src/lib/hud-event.ts b/tui/src/lib/hud-event.ts index 90f623e..969dc77 100644 --- a/tui/src/lib/hud-event.ts +++ b/tui/src/lib/hud-event.ts @@ -22,6 +22,8 @@ function readRecordOrNull(value: unknown): Record | null | unde return isRecord(value) ? value : undefined; } +export const HUD_EVENT_SCHEMA_VERSION = 1; + export function parseHudEvent(line: string): HudEvent | null { let raw: unknown; try { @@ -38,14 +40,26 @@ export function parseHudEvent(line: string): HudEvent | null { const tool = 'tool' in raw ? readStringOrNull(raw.tool) : null; const input = 'input' in raw ? readRecordOrNull(raw.input) : null; const response = 'response' in raw ? readRecordOrNull(raw.response) : null; + const schemaVersion = readNumber(raw.schemaVersion); - if (!event || !session || ts === undefined || tool === undefined || input === undefined) { + if ( + !schemaVersion || + !event || + !session || + ts === undefined || + tool === undefined || + input === undefined + ) { return null; } if (response === undefined) return null; + if (schemaVersion !== HUD_EVENT_SCHEMA_VERSION) { + return null; + } const parsed: HudEvent = { event, + schemaVersion, tool, toolUseId: readString(raw.toolUseId), input, diff --git a/tui/src/lib/types.ts b/tui/src/lib/types.ts index 01ecadb..b627a63 100644 --- a/tui/src/lib/types.ts +++ b/tui/src/lib/types.ts @@ -1,5 +1,6 @@ type BaseHudEvent = { event: TEvent; + schemaVersion?: number; tool: string | null; toolUseId?: string; input: Record | null; diff --git a/tui/test-fixtures/hud-events.jsonl b/tui/test-fixtures/hud-events.jsonl index 7713b60..531a521 100644 --- a/tui/test-fixtures/hud-events.jsonl +++ b/tui/test-fixtures/hud-events.jsonl @@ -1,5 +1,5 @@ -{"event":"UserPromptSubmit","session":"fixture","ts":1,"prompt":"Hello"} -{"event":"PreToolUse","tool":"Read","toolUseId":"tool-1","input":{"file_path":"README.md"},"response":null,"session":"fixture","ts":2} -{"event":"PostToolUse","tool":"Read","toolUseId":"tool-1","input":null,"response":{"duration_ms":200},"session":"fixture","ts":2} -{"event":"PreToolUse","tool":"TodoWrite","toolUseId":"tool-2","input":{"todos":[{"content":"Ship HUD tests","status":"in_progress"}]},"response":null,"session":"fixture","ts":3} -{"event":"Stop","tool":null,"input":null,"response":null,"session":"fixture","ts":4} +{"schemaVersion":1,"event":"UserPromptSubmit","session":"fixture","ts":1,"prompt":"Hello"} +{"schemaVersion":1,"event":"PreToolUse","tool":"Read","toolUseId":"tool-1","input":{"file_path":"README.md"},"response":null,"session":"fixture","ts":2} +{"schemaVersion":1,"event":"PostToolUse","tool":"Read","toolUseId":"tool-1","input":null,"response":{"duration_ms":200},"session":"fixture","ts":2} +{"schemaVersion":1,"event":"PreToolUse","tool":"TodoWrite","toolUseId":"tool-2","input":{"todos":[{"content":"Ship HUD tests","status":"in_progress"}]},"response":null,"session":"fixture","ts":3} +{"schemaVersion":1,"event":"Stop","tool":null,"input":null,"response":null,"session":"fixture","ts":4}