Files
slopus--happy/docs/plans/session-protocol-v2.md
wehub-resource-sync 98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:40:49 +08:00

827 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Session Protocol v2 Design
Status: **DRAFT — under review**
## Context
The current session protocol (`happy-wire/src/sessionProtocol.ts`) was designed to solve a real problem: three different message formats (`output`, `codex`, `acp`) hitting the app, each with different field names and tool call shapes. The v1 protocol unified them into a flat event stream with 7 event types, normalized once in the CLI.
**What v1 got right:**
- Flat event stream — no nesting, single `switch` in the client
- Provider-agnostic — no agent backend leaks into the protocol
- Upload-first media with thumbhash for instant image placeholders
- `invoke` field for subagent tracking (flat stream, grouped by client)
- Turn lifecycle (`turn-start` / `turn-end`)
- Separation of lifecycle events from content events
**What v1 got wrong:**
- Single-letter field names (`t`, `ev`, `call`) — hard to read for humans and AI, negligible bandwidth savings under encryption
- No permissions in the protocol — permissions use a separate agent state + RPC side-channel, invisible in the chat transcript
- `role: "session"` wrapper around inner `role: "user" | "agent"` — unnecessary indirection
- Nested `ev` object — adds a level of nesting for no benefit
**What v1 was missing:**
- Permission request/response as first-class messages (audit trail)
- Parent tracking for nested tool calls (beyond subagents)
- Message consumption / read receipts
Before investing more, we researched the protocol landscape.
## Protocol Landscape (March 2026)
### Relevant protocols
| Protocol | Wire Format | Scope | Permissions | Our relevance |
|---|---|---|---|---|
| **ACP (Zed/JetBrains)** | JSON-RPC 2.0 / stdio | Editor <-> Coding Agent | First-class `request_permission` | **HIGH** — closest to our use case |
| **Pi RPC (pi.dev)** | Custom JSONL / stdin | Host <-> Agent | Extension UI sub-protocol | **MEDIUM** — good immutable stream design |
| **MCP (Anthropic)** | JSON-RPC 2.0 | Host <-> Tool Server | Guidelines only | **LOW** — different layer (tools, not sessions) |
| **A2A v1.0 (Google)** | Protobuf / JSON-RPC+gRPC+HTTP | Agent <-> Agent | `INPUT_REQUIRED` / `AUTH_REQUIRED` states | **LOW** — agent-to-agent, not UI-to-agent |
| **AGNTCY ACP** | REST / OpenAPI | Client <-> Remote Agent | Interrupt/resume mechanism | **LOW** — REST-oriented, LangGraph-specific |
### Key takeaway
These protocols are **complementary layers**, not competitors:
- **MCP** = app-to-tool
- **ACP (Zed)** = editor-to-agent
- **A2A** = agent-to-agent
Happy sits in the **ACP layer** — we're a remote UI controlling coding agents. Zed's ACP is the closest match, but we have unique constraints (remote/encrypted transport, multiple agent backends, mobile UI).
### What we take from each
**From ACP (Zed):** Permission kinds (`allow-once`, `allow-always`, `reject-once`, `reject-always`). Tool call status tracking vocabulary. Session lifecycle patterns.
**From Pi (pi.dev):** Immutable append-only event stream. Separate start/end events (not mutable status updates). Clear distinction between LLM deciding to call a tool vs the tool actually executing.
**From v1 (our own):** Flat stream with `turn` grouping. Parent tracking via a field on each message. Upload-first media with thumbhash. The 7-event-type simplicity target.
## Design Principles
1. **Immutable append-only stream** — messages are never updated, only appended. Start/end are separate events. (Inspired by Pi)
2. **Human-readable field names** — no abbreviations (`type` not `t`, `callId` not `call`, `toolName` not `name`)
3. **Debuggable** — a developer reading raw JSON should immediately understand what each message is
4. **Flat discriminated unions**`type` field at the top level, not nested `ev.t`
5. **Permissions in the stream** — permission requests and responses are messages like anything else, creating a permanent audit trail
6. **Parent tracking** — any message can carry a `parentId` linking it to the tool call that spawned it, supporting subagents, nested tool calls, and future scripted pipelines
7. **Not married to any protocol** — we borrow concepts, not wire formats
8. **Encryption boundary unchanged** — server sees `{ c: "...", t: "encrypted" }`, inner format is our concern
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Server (transport) │
│ Sees: { c: "<encrypted>", t: "encrypted" } │
│ Unchanged — stores/relays opaque blobs │
└─────────────────────────────────────────────────────┘
decrypt/encrypt
┌─────────────────────────────────────────────────────┐
│ Inner envelope (this spec) │
│ { role, type, ... } │
│ Human-readable, immutable, append-only │
└─────────────────────────────────────────────────────┘
CLI mappers
┌─────────────────────────────────────────────────────┐
│ Provider output (Claude SDK, Codex MCP, ACP, etc.) │
│ Each provider has its own format │
│ Mappers convert to our inner envelope │
└─────────────────────────────────────────────────────┘
```
## Message Format
Every message is a JSON object with these common fields:
```typescript
type BaseMessage = {
id: string; // unique message id (cuid2)
time: number; // unix timestamp ms
role: "user" | "agent";
type: string; // discriminator — the event type
turn?: string; // turn id (required for agent messages during a turn)
parentId?: string; // parent tool call id — for nesting (subagents, nested tools, pipelines)
agentId?: string; // subagent identity — stable id for the subagent producing this message
};
```
### `parentId` — hierarchical nesting
Any message can carry `parentId` pointing to the `callId` of the tool call that spawned it. This replaces v1's `invoke` field.
Use cases:
- **Subagent messages**: a `Task` tool call spawns a subagent → all subagent messages carry `parentId: "<task call id>"`
- **Nested tool calls**: a subagent's tool calls carry `parentId` pointing to their parent tool call
- **Scripted pipelines**: step B runs inside step A → step B's messages carry `parentId: "<step A call id>"`
Nesting can be arbitrarily deep. Clients group/indent by walking the `parentId` chain.
### `agentId` — subagent identity
Subagents have their own identity beyond just being children of a tool call. When a tool call spawns a subagent, the subagent's messages carry both `parentId` (which tool call spawned it) and `agentId` (the subagent's own stable identifier).
This matters because:
- A subagent may produce messages across multiple tool calls within the same turn
- The client needs to attribute work to a specific subagent for display (title, collapse state, etc.)
- Future: subagent metadata (name, type, session id) can be looked up by `agentId`
**How this differs from the current system:** The current protocol uses `subagent` as a cuid2 grouping id that the CLI mapper generates. It has no richer identity — no name, no type, no metadata. The v2 `agentId` serves the same grouping purpose but is named to clearly indicate it identifies an agent, not just a parent relationship.
**How ACP (Zed) handles this:** ACP has no subagent concept — it's a single-agent protocol. Tool calls are flat.
**How Pi handles this:** Pi has no subagent concept either. Single agent.
**How A2A handles this:** A2A delegates to other agents via `SendMessage` creating child tasks with `reference_task_ids`. Agents have identity via Agent Cards. This is the closest parallel — but at a much heavier weight.
**Our approach:** Keep it lightweight. `agentId` is a string identifier. The CLI mapper generates it when a subagent is detected. Optional `agentTitle` on `tool-call-start` provides a human-readable label when the tool call spawns a subagent.
---
## Event Types
### 11 event types — one `switch(type)` in the client
| Type | Role | Purpose |
|---|---|---|
| `turn-start` | agent | Agent begins processing |
| `turn-end` | agent | Agent finishes processing |
| `text` | agent or user | Text content (markdown, thinking) |
| `tool-call-start` | agent | Agent begins a tool invocation |
| `tool-call-end` | agent | Tool invocation completes |
| `permission-request` | agent | Agent asks user for approval |
| `permission-response` | user | User responds to permission request |
| `photo` | agent or user | Image with thumbhash for instant placeholder |
| `video` | agent or user | Video with thumbhash + dimensions + duration |
| `file` | agent or user | Generic file attachment by reference |
| `service` | agent | Internal/system messages |
---
### `turn-start`
Marks the beginning of an agent turn (one prompt → response cycle).
```json
{
"id": "msg_abc123",
"time": 1710000000000,
"role": "agent",
"type": "turn-start",
"turn": "turn_xyz789"
}
```
### `turn-end`
Marks the end of an agent turn.
```json
{
"id": "msg_abc124",
"time": 1710000010000,
"role": "agent",
"type": "turn-end",
"turn": "turn_xyz789",
"status": "completed"
}
```
`status`: `"completed"` | `"failed"` | `"cancelled"`
### `text`
Text content. Works for both user prompts and agent output.
```json
{
"id": "msg_abc125",
"time": 1710000000100,
"role": "agent",
"type": "text",
"turn": "turn_xyz789",
"text": "I'll help you fix that bug.",
"thinking": false
}
```
`thinking`: `true` for reasoning/thinking tokens, `false` or omitted for visible output. User messages never set `thinking`.
### `tool-call-start`
Agent begins a tool invocation. This is the LLM's decision to invoke a tool — execution may not start until after permission is granted.
```json
{
"id": "msg_abc126",
"time": 1710000000200,
"role": "agent",
"type": "tool-call-start",
"turn": "turn_xyz789",
"callId": "call_001",
"toolName": "bash",
"title": "Run `ls -la`",
"description": "List files in current directory",
"args": { "command": "ls -la" }
}
```
| Field | Type | Description |
|---|---|---|
| `callId` | string | Unique tool call identifier, matched by `tool-call-end` |
| `toolName` | string | Tool name (e.g. `bash`, `edit`, `grep`) |
| `title` | string | Short human-readable summary (inline markdown) |
| `description` | string | Longer description (inline markdown) |
| `args` | object | Tool input arguments |
### `tool-call-end`
Tool invocation completes. Matches a prior `tool-call-start` by `callId`.
```json
{
"id": "msg_abc127",
"time": 1710000000500,
"role": "agent",
"type": "tool-call-end",
"turn": "turn_xyz789",
"callId": "call_001"
}
```
Optionally carries an `error` field on failure:
```json
{
"id": "msg_abc127",
"time": 1710000000500,
"role": "agent",
"type": "tool-call-end",
"turn": "turn_xyz789",
"callId": "call_001",
"error": "Command exited with code 1"
}
```
### `permission-request`
Agent requests permission to proceed with a tool call. This goes into the message stream (not a side-channel), creating a permanent audit trail. Modeled after ACP's `session/request_permission`.
```json
{
"id": "msg_abc128",
"time": 1710000000300,
"role": "agent",
"type": "permission-request",
"turn": "turn_xyz789",
"callId": "call_001",
"toolName": "bash",
"title": "Run `rm -rf node_modules`",
"description": "Delete node_modules directory",
"args": { "command": "rm -rf node_modules" },
"options": [
{ "id": "allow-once", "label": "Allow once", "kind": "allow-once" },
{ "id": "allow-session", "label": "Allow for session", "kind": "allow-always" },
{ "id": "deny", "label": "Deny", "kind": "reject-once" },
{ "id": "deny-always", "label": "Always deny", "kind": "reject-always" }
]
}
```
Permission kinds (from ACP): `"allow-once"` | `"allow-always"` | `"reject-once"` | `"reject-always"`
**Timing**: A `permission-request` appears in the stream between `tool-call-start` and `tool-call-end`. The tool does not execute until the user responds.
### `permission-response`
User responds to a permission request.
```json
{
"id": "msg_user002",
"time": 1710000000400,
"role": "user",
"type": "permission-response",
"callId": "call_001",
"optionId": "allow-once"
}
```
### `photo`
Image attachment. The image must be uploaded/encrypted first, then referenced. Includes thumbhash for instant placeholder rendering (from v1 design).
```json
{
"id": "msg_abc132",
"time": 1710000000600,
"role": "user",
"type": "photo",
"ref": "media/upload_abc123",
"thumbhash": "3OcRJYB4d3h/iIeHeEh3eIhw+j2w",
"width": 1920,
"height": 1080
}
```
| Field | Type | Description |
|---|---|---|
| `ref` | string | Server upload/media reference ID |
| `thumbhash` | string | Base64-encoded [ThumbHash](https://evanw.github.io/thumbhash/) for instant placeholder |
| `width` | number | Original width in pixels |
| `height` | number | Original height in pixels |
### `video`
Video attachment. The video must be uploaded/encrypted first, then referenced. Includes thumbhash for instant poster frame rendering. See `docs/plans/encrypted-media-v1.md` for the full media pipeline design.
```json
{
"id": "msg_abc134",
"time": 1710000000800,
"role": "agent",
"type": "video",
"turn": "turn_xyz789",
"ref": "media/upload_vid789",
"thumbhash": "3OcRJYB4d3h/iIeHeEh3eIhw+j2w",
"width": 1920,
"height": 1080,
"durationMs": 45000,
"mimeType": "video/mp4",
"size": 104857600
}
```
| Field | Type | Description |
|---|---|---|
| `ref` | string | Server upload/media reference ID |
| `thumbhash` | string | Base64-encoded [ThumbHash](https://evanw.github.io/thumbhash/) for poster frame placeholder |
| `width` | number | Video width in pixels |
| `height` | number | Video height in pixels |
| `durationMs` | number | Video duration in milliseconds |
| `mimeType` | string | MIME type (e.g. `video/mp4`) |
| `size` | number | File size in bytes |
**V1 playback model**: download entire encrypted blob, decrypt locally, write to temp file, hand to native player. See encrypted-media-v1.md for benchmarks and V2 streaming considerations.
### `file`
Generic file attachment. The file must be uploaded/encrypted first, then referenced. Use `photo` or `video` for media with visual preview support.
```json
{
"id": "msg_abc133",
"time": 1710000000700,
"role": "agent",
"type": "file",
"turn": "turn_xyz789",
"ref": "media/upload_def456",
"name": "report.pdf",
"size": 104857600,
"mimeType": "application/pdf"
}
```
| Field | Type | Description |
|---|---|---|
| `ref` | string | Server upload/media reference ID |
| `name` | string | Display filename |
| `size` | number | File size in bytes |
| `mimeType` | string | MIME type |
### `service`
Internal/system messages (not directly from the LLM). Context compaction, session metadata, etc.
```json
{
"id": "msg_abc131",
"time": 1710000000050,
"role": "agent",
"type": "service",
"turn": "turn_xyz789",
"text": "Context window compacted"
}
```
---
## Example Streams
### Simple tool call
```
← { id: "a1", time: 1000, role: "user", type: "text", text: "Find TODOs" }
← { id: "a2", time: 1001, role: "agent", type: "turn-start", turn: "t1" }
← { id: "a3", time: 1002, role: "agent", type: "text", turn: "t1", text: "Searching...", thinking: false }
← { id: "a4", time: 1003, role: "agent", type: "tool-call-start", turn: "t1", callId: "c1", toolName: "grep", title: "Searching for TODO", description: "Searching for `TODO` in project root", args: { "pattern": "TODO" } }
← { id: "a5", time: 1004, role: "agent", type: "tool-call-end", turn: "t1", callId: "c1" }
← { id: "a6", time: 1005, role: "agent", type: "text", turn: "t1", text: "Found 3 TODOs." }
← { id: "a7", time: 1006, role: "agent", type: "turn-end", turn: "t1", status: "completed" }
```
### Tool call with permission
```
← { id: "b1", time: 2000, role: "user", type: "text", text: "Delete node_modules" }
← { id: "b2", time: 2001, role: "agent", type: "turn-start", turn: "t2" }
← { id: "b3", time: 2002, role: "agent", type: "tool-call-start", turn: "t2", callId: "c2", toolName: "bash", title: "Run `rm -rf node_modules`", description: "Delete node_modules directory", args: { "command": "rm -rf node_modules" } }
← { id: "b4", time: 2003, role: "agent", type: "permission-request", turn: "t2", callId: "c2", toolName: "bash", title: "Run `rm -rf node_modules`", args: { "command": "rm -rf node_modules" }, options: [...] }
← { id: "b5", time: 2010, role: "user", type: "permission-response", callId: "c2", optionId: "allow-once" }
← { id: "b6", time: 2011, role: "agent", type: "tool-call-end", turn: "t2", callId: "c2" }
← { id: "b7", time: 2012, role: "agent", type: "text", turn: "t2", text: "Done. Deleted node_modules." }
← { id: "b8", time: 2013, role: "agent", type: "turn-end", turn: "t2", status: "completed" }
```
### Subagent (nested via parentId + agentId)
```
← { id: "c1", time: 3000, role: "agent", type: "tool-call-start", turn: "t1", callId: "task1", toolName: "task", title: "Exploring codebase", description: "Searching for auth implementations", args: { "prompt": "Find auth code" }, agentTitle: "Auth research" }
← { id: "c2", time: 3001, role: "agent", type: "text", turn: "t1", parentId: "task1", agentId: "agent_sub1", text: "Looking at src/auth/..." }
← { id: "c3", time: 3002, role: "agent", type: "tool-call-start", turn: "t1", parentId: "task1", agentId: "agent_sub1", callId: "c3", toolName: "grep", title: "Searching for login", description: "Searching for `login` in src/auth/", args: { "pattern": "login" } }
← { id: "c4", time: 3003, role: "agent", type: "tool-call-end", turn: "t1", parentId: "task1", agentId: "agent_sub1", callId: "c3" }
← { id: "c5", time: 3004, role: "agent", type: "text", turn: "t1", parentId: "task1", agentId: "agent_sub1", text: "Found auth handler." }
← { id: "c6", time: 3005, role: "agent", type: "tool-call-end", turn: "t1", callId: "task1" }
```
- `parentId: "task1"` — these messages are children of the `task1` tool call (nesting)
- `agentId: "agent_sub1"` — these messages come from a specific subagent (identity)
- `agentTitle: "Auth research"` on the `tool-call-start` — human-readable label for the subagent when it spawns
- The `tool-call-end` for `task1` has no `agentId` — it's the parent agent closing the tool call
Nesting can go deeper — a subagent's tool call can itself spawn another subagent with its own `agentId`.
### User sends a photo
```
← { id: "d1", time: 4000, role: "user", type: "photo", ref: "media/up_1", thumbhash: "3OcRJYB4d3h/iIeHeEh3eIhw+j2w", width: 800, height: 600 }
← { id: "d2", time: 4001, role: "user", type: "text", text: "What's in this screenshot?" }
```
---
## How Permissions Change
### Current system (v0 — side-channel)
```
agent needs permission
→ updateAgentState(requests[id]) ← ephemeral side-channel, not in transcript
→ push notification to phone
→ user taps approve in app
→ app sends RPC('permission', {...}) ← separate encrypted RPC, not in transcript
→ CLI resolves pending promise
→ updateAgentState(completedRequests[id])
```
**Problem**: permissions are invisible in the chat. You can't scroll back and see what was approved/denied and when.
### New system (v2 — in the stream)
```
agent needs permission
→ tool-call-start emitted to stream
→ permission-request emitted to stream ← visible, permanent record
→ push notification to phone
→ user taps approve in app
→ permission-response emitted to stream ← visible, permanent record
→ CLI resolves pending promise
→ tool-call-end emitted to stream
```
**Benefit**: the full permission lifecycle is part of the permanent transcript. The RPC side-channel can still exist for the actual real-time delivery mechanism, but the messages are also recorded in the stream for replay/audit.
---
## Full Type Definition (TypeScript)
```typescript
type MessageBase = {
id: string; // cuid2
time: number; // unix timestamp ms
turn?: string; // turn id
parentId?: string; // parent tool call id (for nesting)
agentId?: string; // subagent identity
};
// --- Agent messages ---
type AgentTurnStart = MessageBase & {
role: "agent";
type: "turn-start";
turn: string;
};
type AgentTurnEnd = MessageBase & {
role: "agent";
type: "turn-end";
turn: string;
status: "completed" | "failed" | "cancelled";
};
type AgentTextMessage = MessageBase & {
role: "agent";
type: "text";
turn: string;
text: string;
thinking?: boolean;
};
type AgentToolCallStart = MessageBase & {
role: "agent";
type: "tool-call-start";
turn: string;
callId: string;
toolName: string;
title: string;
description: string;
args: Record<string, unknown>;
agentTitle?: string; // human-readable label when this tool call spawns a subagent
};
type AgentToolCallEnd = MessageBase & {
role: "agent";
type: "tool-call-end";
turn: string;
callId: string;
error?: string;
};
type AgentPermissionRequest = MessageBase & {
role: "agent";
type: "permission-request";
turn: string;
callId: string;
toolName: string;
title: string;
description?: string;
args?: Record<string, unknown>;
options: Array<{
id: string;
label: string;
kind: "allow-once" | "allow-always" | "reject-once" | "reject-always";
}>;
};
type AgentPhotoMessage = MessageBase & {
role: "agent";
type: "photo";
turn: string;
ref: string;
thumbhash: string;
width: number;
height: number;
};
type AgentVideoMessage = MessageBase & {
role: "agent";
type: "video";
turn: string;
ref: string;
thumbhash: string;
width: number;
height: number;
durationMs: number;
mimeType: string;
size: number;
};
type AgentFileMessage = MessageBase & {
role: "agent";
type: "file";
turn: string;
ref: string;
name: string;
size: number;
mimeType: string;
};
type AgentServiceMessage = MessageBase & {
role: "agent";
type: "service";
turn?: string;
text: string;
};
// --- User messages ---
type UserTextMessage = MessageBase & {
role: "user";
type: "text";
text: string;
};
type UserPermissionResponse = MessageBase & {
role: "user";
type: "permission-response";
callId: string;
optionId: string;
};
type UserPhotoMessage = MessageBase & {
role: "user";
type: "photo";
ref: string;
thumbhash: string;
width: number;
height: number;
};
type UserVideoMessage = MessageBase & {
role: "user";
type: "video";
ref: string;
thumbhash: string;
width: number;
height: number;
durationMs: number;
mimeType: string;
size: number;
};
type UserFileMessage = MessageBase & {
role: "user";
type: "file";
ref: string;
name: string;
size: number;
mimeType: string;
};
// --- Unions ---
type AgentMessage =
| AgentTurnStart
| AgentTurnEnd
| AgentTextMessage
| AgentToolCallStart
| AgentToolCallEnd
| AgentPermissionRequest
| AgentPhotoMessage
| AgentVideoMessage
| AgentFileMessage
| AgentServiceMessage;
type UserMessage =
| UserTextMessage
| UserPermissionResponse
| UserPhotoMessage
| UserVideoMessage
| UserFileMessage;
type SessionMessage = AgentMessage | UserMessage;
```
---
## What Stays the Same
- **Outer encrypted envelope**: `{ c: "<base64>", t: "encrypted" }` — server never sees content
- **WebSocket transport**: Socket.IO for real-time, REST for message fetch
- **Update types**: `new-message`, `update-session`, `update-machine` — unchanged
- **Message storage**: server stores opaque encrypted blobs, same as today
- **`messages.ts` types**: `SessionMessageContent`, `SessionMessage`, `Update*` schemas — unchanged
- **RPC mechanism**: still used for real-time permission delivery (and other RPCs), but permission messages are also written to the stream
## What Changes
| Before (v1) | After (v2) | Rationale |
|---|---|---|
| `ev.t` (single letter) | `type` (full word) | Human/AI readability |
| `ev.text` | `text` | Flat, no nesting |
| `ev.call` | `callId` | Descriptive |
| `ev.name` | `toolName` | Descriptive |
| Nested `ev` object | Flat top-level fields | One less level of nesting |
| `role: "session"` wrapper | `role: "agent"` / `role: "user"` directly | No unnecessary indirection |
| Single `tool-call` with mutable `status` | Separate `tool-call-start` / `tool-call-end` | Immutable append-only stream |
| `invoke` field (subagent) | `parentId` + `agentId` fields | `parentId` for nesting, `agentId` for subagent identity |
| No permissions in protocol | `permission-request` / `permission-response` | Audit trail, visible in transcript |
| No media types | `photo` + `video` (with thumbhash) + `file` | First-class media — aligns with encrypted-media-v1.md plan |
| Permissions via agent state + RPC only | Permissions in stream + RPC for delivery | Best of both — permanent record + real-time |
## Design Rules
1. **Immutable stream** — messages are never updated, only appended
2. **Upload-first** — files and photos are uploaded/encrypted to the server, then referenced by `ref`
3. **Every message has identity**`id` (cuid2) + `time` (ms) on every message
4. **11 event types** — simple `switch(type)` in any client
5. **Provider-agnostic** — no agent backend leaks into the protocol
6. **Consistent naming** — all `kebab-case` for types, `camelCase` for fields
7. **Inline markdown**`title` and `description` support `` `code` ``, **bold**, *italic*, [links]
8. **Parent chain**`parentId` enables arbitrary nesting without separate lifecycle events per nesting level
## Migration Path
**Key fact: v1 was never published to any CLI release.** Production CLIs (0.13.0) use the legacy `role: 'agent'` / `role: 'user'` format. v1 only ran in dev environments. This means we have zero backward compatibility obligations for v1 — we can replace it entirely.
### Phase 1: Define v2 types, delete v1
- Replace `sessionProtocol.ts` with `sessionProtocolV2.ts` containing Zod schemas matching the types above
- No need to keep v1 types — they were never shipped
- Update `happy-wire/src/index.ts` to export v2
### Phase 2: Update CLI mappers
- Rewrite `claude/utils/sessionProtocolMapper.ts` to emit v2 format
- Rewrite `codex/utils/sessionProtocolMapper.ts` to emit v2 format
- Rewrite `agent/acp/AcpSessionManager.ts` to emit v2 format
- Add permission-request/response messages to the stream alongside existing RPC flow
### Phase 3: Update app normalization
- Update `typesRaw.ts` to accept v2 inner envelopes
- v2 normalization should be simpler — flatter structure, less transformation
- Keep legacy (`role: 'agent'` / `role: 'user'`) normalization for production CLIs still in the wild
- Can drop v1 normalization entirely (it was only used in dev)
### Phase 4: Permission migration
- App renders permission UI from stream messages instead of (or in addition to) agent state
- RPC still used for real-time delivery, but the message is the source of truth
- Eventually deprecate agent state `requests` / `completedRequests`
## Open Questions
- **Versioning**: should messages carry a `version` field, or do we detect format by shape? (Leaning toward shape detection — the `type` field values are unique enough)
- **Plan messages**: ACP has a `plan` update type (prioritized entries). Do we want this? Could be useful for the manager/conductor workflow.
- **Message consumption**: need read receipts at the protocol level? (See backlog — "message consumption visibility")
- **Streaming deltas vs complete messages**: Pi streams text deltas. We currently send complete text blocks. Should we support deltas for lower latency? (Probably not yet — keep it simple)
- **Permission auto-approval**: when a tool is auto-approved (e.g. `allow-always` from a previous decision), should we still emit `permission-request` + `permission-response` to the stream for the audit trail? Or skip them for noise reduction?
### Attachments as parts vs separate messages — NEEDS DESIGN
The current design models `photo`, `video`, and `file` as standalone messages in the stream. This works fine for agent output (agent produces media, emits a message). But it's awkward for **user input with attachments**:
- User sends "what's in this screenshot?" + an image — that's conceptually ONE message with TWO parts (text + photo)
- If sent as two separate messages (`photo` then `text`), there's no guarantee they arrive/render together
- The agent may see the text before the image, or vice versa
- Batching separate messages atomically is annoying at the transport level
**How others handle this:**
- **Claude API**: messages have `content: Array<TextBlock | ImageBlock | ...>` — multi-part by design
- **A2A**: messages have `parts: Array<Part>` where each part can be text, file, or structured data
- **ACP (Zed)**: prompts have `prompt: Array<TextContent | ResourceContent>` — multi-part
- **Pi**: user messages have `attachments: Array<Attachment>` alongside the text content
- **MCP**: tool results have `content: Array<TextContent | ImageContent | ...>` — multi-part
Every protocol uses a **parts/content array** for this. Our flat "one message = one thing" model doesn't handle "text + attachment sent together" well.
**Options to consider:**
1. **Add a `parts` array** — a user message can carry `parts: [{ type: "text", text: "..." }, { type: "photo", ref: "...", ... }]`. This is the Claude/A2A approach. Clean but means user messages become structurally different from the flat event stream.
2. **Add a `groupId` field** — messages that should be treated as one atomic input share a `groupId`. Transport batches them. Keeps the flat stream but adds coordination complexity.
3. **Keep standalone messages, add ordering guarantees** — the transport ensures messages from the same sender in quick succession are delivered in order. The app groups consecutive user messages visually. Simplest but weakest guarantee.
4. **Hybrid** — agent messages stay flat (one event per message), user messages get a `parts` array. Different shapes for different roles. Ugly but pragmatic.
**Also relevant:** we currently only have **server-hosted media** (`ref` pointing to encrypted upload). We'll want **machine-native files** soon (files on the remote machine, referenced by path). And eventually **app-uploaded files** (user attaches from phone/browser). These are three different `ref` schemes that the `file`/`photo`/`video` types need to support — the `ref` field will need to distinguish between `media/<id>` (our uploads), `machine-file://<path>` (remote machine), etc.
### Evidence from Claude Code session logs
Analysis of the current session's JSONL log (grouped by `message.id` to reconstruct actual API messages):
**Assistant messages are multi-block:**
```
63x tool_use (single tool call)
46x text (just text)
26x text + tool_use (text then tool call)
15x tool_use + tool_use (2 parallel tool calls)
5x text + tool_use + tool_use (text then 2 parallel calls)
3x tool_use × 3 (3 parallel calls)
1x thinking + text + tool_use × 5 (thinking, text, 5 parallel calls)
```
**User/tool-result messages are heavily batched:**
```
4x tool_result × 7
3x tool_result × 4
3x tool_result × 8
2x tool_result × 10
1x tool_result × 18
1x tool_result + text + tool_result (results interleaved with injected text)
```
Claude Code **streams each block as a separate JSONL entry**, but the actual API message groups them by `message.id`. When Claude requests 3 parallel tool calls, that's ONE message with 3 `tool_use` blocks. The results come back as ONE message with 3 `tool_result` blocks.
**Implication:** Our flat "one event per message" model **loses this batching information**. Three separate `tool-call-start` events don't convey that they were requested as a parallel batch. This matters for:
- Display (the UI could show parallel calls side-by-side)
- Semantics (the agent intended these as a group, not sequential)
- User input (text + attachment is one atomic user intent)
This is another argument for the `parts` approach, at least for some message types. But it also suggests a lighter alternative: a `batchId` field that groups events without changing the flat structure.
**Decision: deferred.** This needs more thought. For now, keep the flat model — it works for the streaming display use case. Revisit when user-side attachments ship or when parallel tool call display matters.
## References
- [ACP (Zed) spec](https://agentclientprotocol.com/overview/introduction)
- [ACP Registry](https://zed.dev/acp)
- [Pi coding agent RPC](https://github.com/badlogic/pi-mono)
- [MCP spec (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25)
- [A2A v1.0.0 (2026-03-12)](https://github.com/a2aproject/A2A)
- [AGNTCY Agent Connect Protocol](https://github.com/agntcy/acp-spec)
- [Linux Foundation AAIF](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation)
- [Original v1 session protocol spec](../session-protocol.md) — by Steve, Feb 2026