docs(normalized-event-contract): add comprehensive harness-agnostic adapter contract specification
Document the POST /event/normalized endpoint contract with: - Complete endpoint specification (URL, method, request/response) - All 4 event types with field descriptions and examples - Derivation guide for sessionId, paneId, cwd, transcriptPath from harness context - Implementation example for adapter authors - Error handling and versioning guidance This enables future adapter authors to integrate any coding harness without reading the daemon source code.
This commit is contained in:
parent
9afd810ef5
commit
4ff41bc74b
1 changed files with 345 additions and 0 deletions
345
docs/notes/normalized-event-contract.md
Normal file
345
docs/notes/normalized-event-contract.md
Normal file
|
|
@ -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 `%<number>`. 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/<sessionId>/transcript.jsonl` |
|
||||
| Aider | Project directory `.aider.transcript.md` or `.aider.cache/<thread>/transcript` |
|
||||
| Cursor | `$HOME/.cursor/sessions/<sessionId>/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<void> {
|
||||
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`).
|
||||
Loading…
Add table
Reference in a new issue