docs/session-protocol-claude.md
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.
claudeLocalLauncher + sessionScanner (file-based JSONL ingestion)claudeRemoteLauncher + SDK stream + OutgoingMessageQueueApiSessionClient.sendClaudeSessionMessage()packages/happy-cli/src/api/apiSession.ts
sendClaudeSessionMessage(...) maps Claude records into session envelopes and sends them.closeClaudeSessionTurn(status) emits turn-end with completed|failed|cancelled.packages/happy-cli/src/claude/utils/sessionProtocolMapper.ts
currentTurnId state.packages/happy-cli/src/claude/utils/sessionScanner.ts
packages/happy-cli/src/claude/utils/OutgoingMessageQueue.ts
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...)
turn-end(status="cancelled")turn-end(status="completed")turn-end(status="failed")Implemented in:
packages/happy-cli/src/claude/claudeLocalLauncher.tssequenceDiagram
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
onReady: turn-end(status="completed")turn-end(status="cancelled")turn-end(status="failed")Implemented in:
packages/happy-cli/src/claude/claudeRemoteLauncher.ts| 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-start envelope created when needed).turn values in emitted protocol envelopes are cuid2 (examples below use tA shorthand for readability).subagent on envelopes, and subagent is always a session cuid2.providerSubagentToSessionSubagent so provider tool ids (Claude toolu_*) never leak into protocol envelopes.parent_tool_use_id is missing (common in local/non-SDK logs), mapper infers provider subagent id from:
parentUuid ancestry (uuid -> providerSubagent propagation), orTask tool prompt matching (sidechain root prompt to pending Task tool id).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" } }
subagentInput 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" } ]
}
Arrival order:
{ "type": "assistant", "parent_tool_use_id": "toolu_late", "message": { "role": "assistant", "content": [ { "type": "text", "text": "child before parent" } ] } }
Task tool-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:
bufferedSubagentMessages["toolu_late"] (keyed by provider subagent id; no envelope emitted yet).Task tool_use.id = "toolu_late" is observed, mapper creates/uses providerSubagentToSessionSubagent["toolu_late"] = "<cuid2>".subagent = <cuid2>.tool-call-start envelope is emitted for Task.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.
State before abort:
toolu_sc_1 with parentToolCallId = "toolu_task_1".On abort/finalize:
{
"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]" } ] }
}
{ "role": "agent", "turn": "tA", "subagent": "d6a2s8ydz2lh6ry5od3r2n6n", "ev": { "t": "tool-call-end", "call": "toolu_sc_1" } }
{ "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 logic spans three layers:
parent_tool_use_id.SDKToLogConverter preserves parent_tool_use_id and computes parentUuid chain for sidechain message lineage.Task tool calls, remote launcher inserts a synthetic sidechain root via convertSidechainUserMessage(toolUseId, prompt) so the sidechain stream has an explicit root prompt.parent_tool_use_id.Relevant files:
packages/happy-cli/src/claude/claudeRemoteLauncher.tspackages/happy-cli/src/claude/utils/sdkToLogConverter.tssessionScanner.RawJSONLines records only.sendClaudeSessionMessage() applies the same mapper as remote.Relevant files:
packages/happy-cli/src/claude/claudeLocalLauncher.tspackages/happy-cli/src/claude/utils/sessionScanner.tsmapClaudeLogMessageToSessionEnvelopes() extracts subagent linkage:
parent_tool_use_id (or camel variant) when providedparentUuid -> previously known provider subagent idTask tool call idproviderSubagentToSessionSubagentturn = currentTurnIdsubagent = mapped session cuid2Task parent tool calls:
tool-call-start / tool-call-endstart, text, stop) for that mapped subagent cuid2providerSubagentToSessionSubagent: provider id -> session cuid2bufferedSubagentMessages: raw sidechain messages waiting for provider subagent mappingX but mapping for X does not exist yet:
Task tool_use has id = X:
XX in arrival order and emits envelopes with that cuid2This 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.tsWhen content.type = "session" is normalized:
parentUUID = envelope.subagent ?? nullisSidechain = (parentUUID !== null)Then reducer tracer resolves ownership:
toolCallToMessageId[toolCallId] = parentMessageIdparentUUID = toolCallId still maps directly via toolCallToMessageIdparentUUID = subagent cuid2; app treats this as sidechain context identity, not provider tool idsidechain content + Task prompt) is still supportedsequenceDiagram
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)sessionScanner prevents duplicates by design:
sessionId, it marks all existing messages as processed before watching.processedMessageKeys:
user/assistant/system: key = uuidsummary: key = summary:<leafUuid>:<summary>onNewSession), it:
This avoids replay duplicates during resume/fork/restart scenarios where files overlap.
Sidechain-specific effect:
uuid-based for user/assistant/system).onNewSession guards prevent re-activating already-finished/already-pending sessions.Remote mode does not replay from disk. It streams SDK messages live:
OutgoingMessageQueue preserves strict send order by incremental queue id.previousSessionId check), reducing restart-related churn.Sidechain-specific effect:
ApiSessionClient keeps Claude protocol turn state (currentTurnId).ApiSessionClient also preserves mapper sidechain state (providerSubagentToSessionSubagent, bufferedSubagentMessages, prompt/uuid linkage maps) during a launcher cycle.closeClaudeSessionTurn(...).stateDiagram
[*] --> Idle
Idle --> ActiveTurn: first agent output
ActiveTurn --> ActiveTurn: text/tool-call events
ActiveTurn --> Idle: completed
ActiveTurn --> Idle: failed
ActiveTurn --> Idle: cancelled