diff --git a/docs/notes/normalized-event-contract.md b/docs/notes/normalized-event-contract.md new file mode 100644 index 0000000..e4cbad1 --- /dev/null +++ b/docs/notes/normalized-event-contract.md @@ -0,0 +1,345 @@ +# Normalized Event Contract + +## Overview + +The Trail Boss daemon accepts normalized events via the `POST /event/normalized` endpoint. This contract is **harness-agnostic** — any adapter can emit these events to integrate a coding harness with Trail Boss, regardless of the underlying AI provider or session model. + +## Endpoint + +### POST /event/normalized + +**URL:** `http://127.0.0.1:4000/event/normalized` + +**Content-Type:** `application/json` + +**Authentication:** None (localhost-only daemon) + +**Request Body:** A JSON object representing one of the event types below. All events include a `type` discriminator field. + +**Response (Success):** +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "ok": true +} +``` + +**Error Responses:** + +| Status | Body | Description | +|--------|------|-------------| +| 400 | `{"error": "Invalid JSON"}` | Request body is not valid JSON | +| 400 | `{"error": "Invalid or missing event type"}` | Missing or invalid `type` field | +| 500 | `{"error": "Internal server error"}` | Server-side processing error | + +## Event Types + +All events share a common structure with a `type` discriminator. The daemon dispatches based on this field. + +### StuckEvent + +Emitted when a session becomes stuck (blocked on user input). + +**Type Discriminator:** `"stuck"` + +**Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | Yes | Must be `"stuck"` | +| `sessionId` | string | Yes | Unique session identifier (harness-specific) | +| `paneId` | string | Yes | tmux pane identifier (e.g., `%3`) | +| `cwd` | string | Yes | Current working directory of the session | +| `transcriptPath` | string | Yes | Path to the session transcript file | +| `reason` | string | Yes | Either `"stopped"` (hook event) or `"permission"` (tool blocked) | +| `message` | string | Yes | Human-readable context: last assistant message (stopped) or tool operation (permission) | +| `timestamp` | number | Yes | Unix timestamp in milliseconds | + +**Example (stopped):** +```json +{ + "type": "stuck", + "sessionId": "sess_01a2b3c4d5e6f7g8", + "paneId": "%12", + "cwd": "/home/coding/project", + "transcriptPath": "/home/coding/.claude/sessions/sess_01a2b3c4d5e6f7g8/transcript.jsonl", + "reason": "stopped", + "message": "I'm waiting for you to review these changes before proceeding.", + "timestamp": 1719987600000 +} +``` + +**Example (permission):** +```json +{ + "type": "stuck", + "sessionId": "sess_01a2b3c4d5e6f7g8", + "paneId": "%12", + "cwd": "/home/coding/project", + "transcriptPath": "/home/coding/.claude/sessions/sess_01a2b3c4d5e6f7g8/transcript.jsonl", + "reason": "permission", + "message": "[Bash] {\"command\":\"rm -rf /tmp/cache\"}", + "timestamp": 1719987600000 +} +``` + +### UnstuckEvent + +Emitted when a session becomes unstuck (user provided input or the session resumed). + +**Type Discriminator:** `"unstuck"` + +**Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | Yes | Must be `"unstuck"` | +| `sessionId` | string | Yes | Unique session identifier (must match a previously stuck session) | +| `timestamp` | number | Yes | Unix timestamp in milliseconds | + +**Example:** +```json +{ + "type": "unstuck", + "sessionId": "sess_01a2b3c4d5e6f7g8", + "timestamp": 1719987660000 +} +``` + +**Note:** The daemon automatically looks up the `paneId` from the stored session state for cleanup. + +### SessionRegistered + +Emitted when a new session starts. Registers the session metadata with the daemon. + +**Type Discriminator:** `"registered"` + +**Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | Yes | Must be `"registered"` | +| `sessionId` | string | Yes | Unique session identifier (harness-specific) | +| `paneId` | string | Yes | tmux pane identifier (e.g., `%3`) | +| `cwd` | string | Yes | Current working directory of the session | +| `transcriptPath` | string | Yes | Path to the session transcript file | +| `timestamp` | number | Yes | Unix timestamp in milliseconds | + +**Example:** +```json +{ + "type": "registered", + "sessionId": "sess_01a2b3c4d5e6f7g8", + "paneId": "%12", + "cwd": "/home/coding/project", + "transcriptPath": "/home/coding/.claude/sessions/sess_01a2b3c4d5e6f7g8/transcript.jsonl", + "timestamp": 1719987600000 +} +``` + +### SessionEnded + +Emitted when a session terminates. Cleans up session state from the daemon. + +**Type Discriminator:** `"ended"` + +**Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | Yes | Must be `"ended"` | +| `sessionId` | string | Yes | Unique session identifier | +| `timestamp` | number | Yes | Unix timestamp in milliseconds | + +**Example:** +```json +{ + "type": "ended", + "sessionId": "sess_01a2b3c4d5e6f7g8", + "timestamp": 1719988000000 +} +``` + +## Deriving Fields from Harness Context + +This section explains how to derive the normalized event fields from common harness contexts. + +### sessionId + +The session identifier is harness-specific. Common sources: + +| Harness | Session ID Source | +|---------|-------------------| +| Claude Code | Transcript filename or internal session UUID | +| Aider | Thread ID or workspace session key | +| Cursor | VS Code workspace URI + session UUID | +| Custom | Generate a UUID or use thread/conversation ID | + +**Key properties:** +- Must be unique per session (across restarts of the same session) +- Should be stable for the lifetime of the session +- Can be a UUID, hash, or harness-provided ID + +### paneId + +The tmux pane identifier in the form `%`. Derive from: + +**Method 1: Environment variable (recommended)** +```bash +# The TMUX_PANE env var is set automatically in tmux sessions +echo $TMUX_PANE # Outputs: %12 +``` + +**Method 2: tmux command** +```bash +# Get the pane ID of the current tmux session +tmux display -p '#{pane_id}' # Outputs: %12 +``` + +**Method 3: Parsing tmux socket path** +```bash +# Extract from TMUX env var +tmux show-env | grep TMUX_PANE +``` + +### cwd + +The current working directory of the session. + +**Method 1: Direct API (if harness provides it)** +```javascript +// Claude Code example +const cwd = session.cwd; +``` + +**Method 2: Environment variable** +```bash +# In the session's shell context +pwd +``` + +**Method 3: Infer from transcript path** +```bash +# If transcript path is /home/user/.claude/sessions/.../transcript.jsonl +# The session may have been invoked from the project root +``` + +### transcriptPath + +The path to the session transcript file. This is used by the reconcile loop for recovery. + +**Common patterns:** + +| Harness | Transcript Path Pattern | +|---------|------------------------| +| Claude Code | `$HOME/.claude/sessions//transcript.jsonl` | +| Aider | Project directory `.aider.transcript.md` or `.aider.cache//transcript` | +| Cursor | `$HOME/.cursor/sessions//transcript.jsonl` | +| Custom | Adapter-defined location | + +**Claude Code example:** +```javascript +// From hook event payload +const transcriptPath = hookEvent.transcript_path; +// "/home/coding/.claude/sessions/sess_01a2b3c4d5e6f7g8/transcript.jsonl" +``` + +### timestamp + +Unix timestamp in milliseconds. Use the current time when emitting the event. + +```javascript +const timestamp = Date.now(); +``` + +## Adapter Implementation Example + +Here's a minimal adapter skeleton for a hypothetical harness: + +```typescript +interface NormalizedEvent { + type: "stuck" | "unstuck" | "registered" | "ended"; +} + +async function emitEvent(event: NormalizedEvent): Promise { + const response = await fetch("http://127.0.0.1:4000/event/normalized", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Trail Boss error: ${error.error}`); + } +} + +// When session starts +await emitEvent({ + type: "registered", + sessionId: getSessionId(), + paneId: process.env.TMUX_PANE, + cwd: process.cwd(), + transcriptPath: getTranscriptPath(), + timestamp: Date.now(), +}); + +// When session blocks on permission +await emitEvent({ + type: "stuck", + sessionId: getSessionId(), + paneId: process.env.TMUX_PANE, + cwd: process.cwd(), + transcriptPath: getTranscriptPath(), + reason: "permission", + message: `[${toolName}] ${JSON.stringify(toolInput)}`, + timestamp: Date.now(), +}); + +// When user provides input +await emitEvent({ + type: "unstuck", + sessionId: getSessionId(), + timestamp: Date.now(), +}); + +// When session ends +await emitEvent({ + type: "ended", + sessionId: getSessionId(), + timestamp: Date.now(), +}); +``` + +## Validation + +The daemon validates events at runtime: + +1. **JSON validity:** Request body must be parseable JSON +2. **Type discriminator:** The `type` field must be one of: `stuck`, `unstuck`, `registered`, `ended` +3. **Required fields:** Each event type must include its required fields (see table above) + +Additional validation (field formats, sessionId existence) may be added in future versions. + +## Error Handling + +Adapters should handle errors gracefully: + +| Scenario | Recommended Handling | +|----------|---------------------| +| Daemon not running | Log warning, queue event locally, or retry with backoff | +| Invalid event type | Log error, fix adapter code | +| Missing required field | Log error, fix adapter code | +| Network error | Retry with exponential backoff, batch pending events | + +## Versioning + +This contract corresponds to Trail Boss daemon v1.0. Future versions may: + +- Add optional fields to existing events (backward compatible) +- Add new event types (backward compatible) +- Introduce a version header for breaking changes + +For breaking changes, the daemon will version the endpoint (e.g., `/event/normalized/v2`).