17 KiB
Claude Session Protocol (Local + Remote)
This document explains how Claude launcher paths emit the unified session protocol (content.type = "session"), and how duplicate messages are avoided across session changes/restarts.
Scope
- Local launcher path:
claudeLocalLauncher+sessionScanner(file-based JSONL ingestion) - Remote launcher path:
claudeRemoteLauncher+ SDK stream +OutgoingMessageQueue - Shared protocol sender:
ApiSessionClient.sendClaudeSessionMessage()
Key Components
packages/happy-cli/src/api/apiSession.tssendClaudeSessionMessage(...)maps Claude records into session envelopes and sends them.closeClaudeSessionTurn(status)emitsturn-endwithcompleted|failed|cancelled.
packages/happy-cli/src/claude/utils/sessionProtocolMapper.ts- Claude RawJSONLines -> session-protocol envelopes.
- Maintains
currentTurnIdstate.
packages/happy-cli/src/claude/utils/sessionScanner.ts- Local-mode file scanner with message de-duplication.
packages/happy-cli/src/claude/utils/OutgoingMessageQueue.ts- Remote-mode ordered outgoing queue.
Local Launcher Flow
Sequence
sequenceDiagram
participant Claude as Claude Local Process
participant Hook as SessionStart Hook
participant Scanner as SessionScanner
participant API as ApiSessionClient
participant Mapper as Claude Session Mapper
participant WS as WebSocket
Claude->>Hook: session discovered
Hook->>Scanner: onNewSession(sessionId)
Scanner->>Scanner: read JSONL and dedupe
Scanner->>API: sendClaudeSessionMessage(rawJsonLine)
API->>Mapper: mapClaudeLogMessageToSessionEnvelopes(...)
Mapper-->>API: envelopes and turn state
API->>WS: sendSessionProtocolMessage(envelope...)
Local turn-close behavior
- Abort/switch:
turn-end(status="cancelled") - Normal process exit:
turn-end(status="completed") - Non-zero launcher exit/error:
turn-end(status="failed")
Implemented in:
packages/happy-cli/src/claude/claudeLocalLauncher.ts
Remote Launcher Flow
Sequence
sequenceDiagram
participant SDK as Claude SDK Stream
participant Conv as SDKToLogConverter
participant Queue as OutgoingMessageQueue
participant API as ApiSessionClient
participant Mapper as Claude Session Mapper
participant WS as WebSocket
SDK->>Conv: sdk message
Conv->>Queue: enqueue(logMessage)
Queue->>Queue: preserve strict order
Queue->>API: sendClaudeSessionMessage(logMessage)
API->>Mapper: mapClaudeLogMessageToSessionEnvelopes(...)
Mapper-->>API: envelopes and turn state
API->>WS: sendSessionProtocolMessage(envelope...)
Note over Queue: top-level tool calls can be delayed and released early
Remote turn-close behavior
- On remote
onReady:turn-end(status="completed") - On abort:
turn-end(status="cancelled") - On launcher error:
turn-end(status="failed")
Implemented in:
packages/happy-cli/src/claude/claudeRemoteLauncher.ts
Mapping Rules (Claude -> Session Protocol)
| Claude raw message | Session envelopes |
|---|---|
assistant text block |
agent:text |
assistant thinking block |
agent:text with thinking: true |
assistant tool_use block (non-Task) |
agent:tool-call-start |
assistant tool_use block (Task) |
no parent tool-call envelope; registers provider->session subagent mapping and flushes buffered subagent messages |
user tool_result block (non-Task) |
agent:tool-call-end |
user tool_result block (Task parent result) |
agent:stop for the subagent (no parent tool-call-end) |
user plain string (non-sidechain) |
turn-end(completed) (if open), then emit both user:text (legacy) and session:text (migration shadow copy) |
user plain string (sidechain) |
agent:start (once) then agent:text (subagent set to session cuid2) |
system |
ignored for protocol output |
summary |
ignored for protocol output (metadata update only) |
Notes:
- Turn starts lazily on first agent output in a turn (
turn-startenvelope created when needed). turnvalues in emitted protocol envelopes are cuid2 (examples below usetAshorthand for readability).- Sidechain linking uses
subagenton envelopes, andsubagentis always a session cuid2. - Mapper tracks
providerSubagentToSessionSubagentso provider tool ids (Claudetoolu_*) never leak into protocol envelopes. - If
parent_tool_use_idis missing (common in local/non-SDK logs), mapper infers provider subagent id from:parentUuidancestry (uuid -> providerSubagentpropagation), orTasktool prompt matching (sidechain root prompt to pending Task tool id).
Concrete Examples
Example 1: Remote normal turn (no sidechain)
Input SDK messages:
{ "type": "assistant", "message": { "role": "assistant", "content": [ { "type": "text", "text": "I will inspect auth files." } ] } }
{ "type": "assistant", "message": { "role": "assistant", "content": [ { "type": "tool_use", "id": "toolu_1", "name": "Bash", "input": { "command": "rg auth src" } } ] } }
{ "type": "user", "message": { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_1", "content": "src/auth/index.ts" } ] } }
Emitted session envelopes (simplified):
{ "role": "agent", "turn": "tA", "ev": { "t": "turn-start" } }
{ "role": "agent", "turn": "tA", "ev": { "t": "text", "text": "I will inspect auth files." } }
{ "role": "agent", "turn": "tA", "ev": { "t": "tool-call-start", "call": "toolu_1", "name": "Bash", "title": "Bash call", "description": "Bash call", "args": { "command": "rg auth src" } } }
{ "role": "agent", "turn": "tA", "ev": { "t": "tool-call-end", "call": "toolu_1" } }
{ "role": "agent", "turn": "tA", "ev": { "t": "turn-end", "status": "completed" } }
Example 2: Sidechain message uses subagent
Input SDK message (from Task child context):
{
"type": "assistant",
"parent_tool_use_id": "toolu_task_1",
"message": {
"role": "assistant",
"content": [ { "type": "text", "text": "Subagent: found 3 files." } ]
}
}
Session envelope:
{
"role": "agent",
"turn": "tA",
"subagent": "d6a2s8ydz2lh6ry5od3r2n6n",
"ev": { "t": "text", "text": "Subagent: found 3 files." }
}
Where d6a2s8ydz2lh6ry5od3r2n6n is an adapter-generated cuid2 mapped from provider id toolu_task_1.
App normalization result (conceptual):
{
"role": "agent",
"isSidechain": true,
"content": [ { "type": "text", "text": "Subagent: found 3 files.", "parentUUID": "d6a2s8ydz2lh6ry5od3r2n6n" } ]
}
Example 3: Orphan sidechain child arrives before parent tool call
Arrival order:
- Child raw Claude message first:
{ "type": "assistant", "parent_tool_use_id": "toolu_late", "message": { "role": "assistant", "content": [ { "type": "text", "text": "child before parent" } ] } }
- Parent
Tasktool-use arrives later:
{ "type": "assistant", "message": { "role": "assistant", "content": [ { "type": "tool_use", "id": "toolu_late", "name": "Task", "input": { "prompt": "Inspect auth flow" } } ] } }
CLI mapper behavior:
- Buffers the first message in
bufferedSubagentMessages["toolu_late"](keyed by provider subagent id; no envelope emitted yet). - When
Tasktool_use.id = "toolu_late"is observed, mapper creates/usesproviderSubagentToSessionSubagent["toolu_late"] = "<cuid2>". - Mapper immediately replays buffered entries and emits envelopes with
subagent = <cuid2>. - No parent
tool-call-startenvelope is emitted forTask.
Example 4: Local restart dedupe (same JSONL line not resent)
Session file contains (already processed before restart):
{ "type": "assistant", "uuid": "a-100", "message": { "role": "assistant", "content": [ { "type": "text", "text": "existing line" } ] } }
After restart, scanner loads existing file and seeds:
processedMessageKeys += "a-100"
When the file is re-read and still contains uuid = "a-100", scanner skips it and does not call sendClaudeSessionMessage(...) again.
Example 5: Remote abort while sidechain tool is running
State before abort:
- Ongoing tool call map contains sidechain tool
toolu_sc_1withparentToolCallId = "toolu_task_1".
On abort/finalize:
- Converter emits interrupted tool result with parent linkage:
{
"type": "user",
"isSidechain": true,
"parent_tool_use_id": "toolu_task_1",
"message": { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_sc_1", "is_error": true, "content": "[Request interrupted by user for tool use]" } ] }
}
- Mapper emits:
{ "role": "agent", "turn": "tA", "subagent": "d6a2s8ydz2lh6ry5od3r2n6n", "ev": { "t": "tool-call-end", "call": "toolu_sc_1" } }
- Launcher closes turn:
{ "role": "agent", "turn": "tA", "ev": { "t": "turn-end", "status": "cancelled" } }
When the parent Task tool result is observed in the main turn, mapper emits:
{ "role": "agent", "turn": "tA", "subagent": "d6a2s8ydz2lh6ry5od3r2n6n", "ev": { "t": "stop" } }
Sidechain Deep Dive
Sidechain logic spans three layers:
- Claude launcher output shaping (local scanner or remote SDK converter)
- CLI session-protocol mapping (subagent resolution + orphan buffering)
- App-side linking/tracing into Task children
1) How sidechain IDs are produced in CLI
Remote launcher
- SDK messages can include
parent_tool_use_id. SDKToLogConverterpreservesparent_tool_use_idand computesparentUuidchain for sidechain message lineage.- For
Tasktool calls, remote launcher inserts a synthetic sidechain root viaconvertSidechainUserMessage(toolUseId, prompt)so the sidechain stream has an explicit root prompt. - For interrupted tools on restart/abort, it generates sidechain-aware tool results with optional
parent_tool_use_id.
Relevant files:
packages/happy-cli/src/claude/claudeRemoteLauncher.tspackages/happy-cli/src/claude/utils/sdkToLogConverter.ts
Local launcher
- Sidechain semantics come from JSONL files read by
sessionScanner. - Scanner forwards unseen
RawJSONLinesrecords only. sendClaudeSessionMessage()applies the same mapper as remote.
Relevant files:
packages/happy-cli/src/claude/claudeLocalLauncher.tspackages/happy-cli/src/claude/utils/sessionScanner.ts
2) How sidechains become session protocol (CLI-owned orphan handling)
mapClaudeLogMessageToSessionEnvelopes() extracts subagent linkage:
- Reads
parent_tool_use_id(or camel variant) when provided - Otherwise infers provider subagent via:
parentUuid-> previously known provider subagent id- sidechain root prompt -> pending
Tasktool call id
- Maps provider subagent id to session cuid2 via
providerSubagentToSessionSubagent - Emits agent envelopes with:
turn = currentTurnIdsubagent = mapped session cuid2
- For
Taskparent tool calls:- does not emit
tool-call-start/tool-call-end - emits only subagent stream lifecycle/content (
start,text,stop) for that mapped subagent cuid2
- does not emit
- Tracks subagent ownership in CLI state:
providerSubagentToSessionSubagent: provider id -> session cuid2bufferedSubagentMessages: raw sidechain messages waiting for provider subagent mapping
- If a sidechain message resolves provider subagent
Xbut mapping forXdoes not exist yet:- message is buffered in CLI
- nothing is emitted to server yet
- When a later
Tasktool_usehasid = X:- mapper creates/looks up mapped session cuid2 for
X - mapper flushes buffered messages for
Xin arrival order and emits envelopes with that cuid2
- mapper creates/looks up mapped session cuid2 for
This is the main protocol-level sidechain key.
flowchart LR
A[Raw Claude message] --> B{Has parent tool id}
B -- yes --> C[Resolve provider subagent]
B -- no --> D{Has parent UUID ancestry}
D -- yes --> E[Inherit provider subagent]
D -- no --> F{Matches pending Task prompt}
F -- yes --> G[Resolve provider subagent from Task id]
F -- no --> H[No subagent]
C --> I{Has provider to session mapping}
E --> I
G --> I
H --> M[Emit envelope without subagent]
I -- no --> J[Buffer by provider subagent in CLI]
I -- yes --> K[Emit envelope with subagent cuid2]
K --> L[App receives parentUUID as cuid2]
M --> L
Relevant file:
packages/happy-cli/src/claude/utils/sessionProtocolMapper.ts
3) How app links sidechain messages to Task tool calls
When content.type = "session" is normalized:
parentUUID = envelope.subagent ?? nullisSidechain = (parentUUID !== null)
Then reducer tracer resolves ownership:
- Tool-call message indexed:
toolCallToMessageId[toolCallId] = parentMessageId - Legacy sidechain message with
parentUUID = toolCallIdstill maps directly viatoolCallToMessageId - Session-protocol sidechain message uses
parentUUID = subagent cuid2; app treats this as sidechain context identity, not provider tool id - Legacy prompt-based root matching (
sidechaincontent + Task prompt) is still supported - App orphan buffering remains a fallback for legacy/history data, but source-of-truth orphan handling for new Claude session-protocol streams is CLI buffering.
sequenceDiagram
participant Child as Sidechain raw message
participant Mapper as CLI sessionProtocolMapper
participant Parent as Parent Task tool use
Child->>Mapper: arrives first
Mapper->>Mapper: buffer by provider subagent id
Parent->>Mapper: Task tool use with same id
Mapper->>Mapper: create provider to session cuid2 mapping
Mapper->>Mapper: flush buffered messages
Mapper-->>Mapper: emit children with mapped subagent cuid2
Relevant files:
packages/happy-app/sources/sync/typesRaw.tspackages/happy-app/sources/sync/reducer/reducerTracer.tspackages/happy-app/sources/sync/reducer/reducer.spec.ts(subagent-sidechain tests)
Duplicate Handling and Restarts
Local launcher: robust dedupe from scanner state
sessionScanner prevents duplicates by design:
- On startup with existing
sessionId, it marks all existing messages as processed before watching. - It de-duplicates globally with
processedMessageKeys:user/assistant/system: key =uuidsummary: key =summary:<leafUuid>:<summary>
- On session changes (
onNewSession), it:- skips same/already-pending/already-finished session IDs,
- keeps watching old sessions too (Claude can still append there),
- processes all watched sessions but only sends unseen keys.
This avoids replay duplicates during resume/fork/restart scenarios where files overlap.
Sidechain-specific effect:
- Sidechain records are deduped by the same scanner keys (
uuid-based for user/assistant/system). - Re-reads from both old and new session files do not re-emit already processed sidechain entries.
onNewSessionguards prevent re-activating already-finished/already-pending sessions.
Remote launcher: ordered streaming, not file replay
Remote mode does not replay from disk. It streams SDK messages live:
OutgoingMessageQueuepreserves strict send order by incremental queue id.- Delayed top-level tool-call messages are released when needed (permission request/tool result) to avoid out-of-order artifacts.
- On each launcher cycle finalization:
- outstanding tool calls receive synthetic interrupted tool results,
- queue is flushed before continuing.
- Parent-chain reset happens only when session ID actually changes (
previousSessionIdcheck), reducing restart-related churn.
Sidechain-specific effect:
- Top-level tool-call starts may be delayed briefly; sidechain tool-calls are sent immediately (no top-level delay path).
- On launcher finalization, interrupted sidechain tool calls get synthetic tool_result events, then queue flush ensures delivery order.
Turn-state safety across restarts
ApiSessionClientkeeps Claude protocol turn state (currentTurnId).ApiSessionClientalso preserves mapper sidechain state (providerSubagentToSessionSubagent,bufferedSubagentMessages, prompt/uuid linkage maps) during a launcher cycle.- Launchers explicitly close turns on completion/abort/failure via
closeClaudeSessionTurn(...). - This prevents stale open turns when loops restart.
- App tracer still has orphan maps as defensive fallback, but CLI mapper is responsible for first-pass orphan resolution for new session-protocol traffic.
Mermaid: Turn Lifecycle
stateDiagram
[*] --> Idle
Idle --> ActiveTurn: first agent output
ActiveTurn --> ActiveTurn: text/tool-call events
ActiveTurn --> Idle: completed
ActiveTurn --> Idle: failed
ActiveTurn --> Idle: cancelled
Operational Notes
- Summary messages are still used for metadata/title updates, but not emitted as session events.
- In local mode, scanner dedupe is the primary protection against duplicate delivery after restarts.
- In remote mode, correctness is based on ordered live delivery and controlled launcher lifecycle boundaries.