项目文件夹

文件
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 行
34 KiB
Markdown

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 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