Back to Happy

Claude Session Protocol (Local + Remote)

docs/session-protocol-claude.md

316.7 KB
Original Source

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.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
    • Claude RawJSONLines -> session-protocol envelopes.
    • Maintains currentTurnId state.
  • 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

mermaid
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

mermaid
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 messageSession envelopes
assistant text blockagent:text
assistant thinking blockagent: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)
systemignored for protocol output
summaryignored for protocol output (metadata update only)

Notes:

  • Turn starts lazily on first agent output in a turn (turn-start envelope created when needed).
  • turn values in emitted protocol envelopes are cuid2 (examples below use tA shorthand for readability).
  • Sidechain linking uses subagent on envelopes, and subagent is always a session cuid2.
  • Mapper tracks providerSubagentToSessionSubagent so provider tool ids (Claude toolu_*) never leak into protocol envelopes.
  • If parent_tool_use_id is missing (common in local/non-SDK logs), mapper infers provider subagent id from:
    1. parentUuid ancestry (uuid -> providerSubagent propagation), or
    2. Task tool prompt matching (sidechain root prompt to pending Task tool id).

Concrete Examples

Example 1: Remote normal turn (no sidechain)

Input SDK messages:

json
{ "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):

json
{ "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):

json
{
  "type": "assistant",
  "parent_tool_use_id": "toolu_task_1",
  "message": {
    "role": "assistant",
    "content": [ { "type": "text", "text": "Subagent: found 3 files." } ]
  }
}

Session envelope:

json
{
  "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):

json
{
  "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:

  1. Child raw Claude message first:
json
{ "type": "assistant", "parent_tool_use_id": "toolu_late", "message": { "role": "assistant", "content": [ { "type": "text", "text": "child before parent" } ] } }
  1. Parent Task tool-use arrives later:
json
{ "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 Task tool_use.id = "toolu_late" is observed, mapper creates/uses providerSubagentToSessionSubagent["toolu_late"] = "<cuid2>".
  • Mapper immediately replays buffered entries and emits envelopes with subagent = <cuid2>.
  • No parent tool-call-start envelope is emitted for Task.

Example 4: Local restart dedupe (same JSONL line not resent)

Session file contains (already processed before restart):

json
{ "type": "assistant", "uuid": "a-100", "message": { "role": "assistant", "content": [ { "type": "text", "text": "existing line" } ] } }

After restart, scanner loads existing file and seeds:

text
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_1 with parentToolCallId = "toolu_task_1".

On abort/finalize:

  1. Converter emits interrupted tool result with parent linkage:
json
{
  "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]" } ] }
}
  1. Mapper emits:
json
{ "role": "agent", "turn": "tA", "subagent": "d6a2s8ydz2lh6ry5od3r2n6n", "ev": { "t": "tool-call-end", "call": "toolu_sc_1" } }
  1. Launcher closes turn:
json
{ "role": "agent", "turn": "tA", "ev": { "t": "turn-end", "status": "cancelled" } }

When the parent Task tool result is observed in the main turn, mapper emits:

json
{ "role": "agent", "turn": "tA", "subagent": "d6a2s8ydz2lh6ry5od3r2n6n", "ev": { "t": "stop" } }

Sidechain Deep Dive

Sidechain logic spans three layers:

  1. Claude launcher output shaping (local scanner or remote SDK converter)
  2. CLI session-protocol mapping (subagent resolution + orphan buffering)
  3. 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.
  • SDKToLogConverter preserves parent_tool_use_id and computes parentUuid chain for sidechain message lineage.
  • For Task tool calls, remote launcher inserts a synthetic sidechain root via convertSidechainUserMessage(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.ts
  • packages/happy-cli/src/claude/utils/sdkToLogConverter.ts

Local launcher

  • Sidechain semantics come from JSONL files read by sessionScanner.
  • Scanner forwards unseen RawJSONLines records only.
  • sendClaudeSessionMessage() applies the same mapper as remote.

Relevant files:

  • packages/happy-cli/src/claude/claudeLocalLauncher.ts
  • packages/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 Task tool call id
  • Maps provider subagent id to session cuid2 via providerSubagentToSessionSubagent
  • Emits agent envelopes with:
    • turn = currentTurnId
    • subagent = mapped session cuid2
  • For Task parent 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
  • Tracks subagent ownership in CLI state:
    • providerSubagentToSessionSubagent: provider id -> session cuid2
    • bufferedSubagentMessages: raw sidechain messages waiting for provider subagent mapping
  • If a sidechain message resolves provider subagent X but mapping for X does not exist yet:
    • message is buffered in CLI
    • nothing is emitted to server yet
  • When a later Task tool_use has id = X:
    • mapper creates/looks up mapped session cuid2 for X
    • mapper flushes buffered messages for X in arrival order and emits envelopes with that cuid2

This is the main protocol-level sidechain key.

mermaid
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

When content.type = "session" is normalized:

  • parentUUID = envelope.subagent ?? null
  • isSidechain = (parentUUID !== null)

Then reducer tracer resolves ownership:

  1. Tool-call message indexed: toolCallToMessageId[toolCallId] = parentMessageId
  2. Legacy sidechain message with parentUUID = toolCallId still maps directly via toolCallToMessageId
  3. Session-protocol sidechain message uses parentUUID = subagent cuid2; app treats this as sidechain context identity, not provider tool id
  4. Legacy prompt-based root matching (sidechain content + Task prompt) is still supported
  5. 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.
mermaid
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.ts
  • packages/happy-app/sources/sync/reducer/reducerTracer.ts
  • packages/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:

  1. On startup with existing sessionId, it marks all existing messages as processed before watching.
  2. It de-duplicates globally with processedMessageKeys:
    • user/assistant/system: key = uuid
    • summary: key = summary:<leafUuid>:<summary>
  3. 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.
  • onNewSession guards 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:

  1. OutgoingMessageQueue preserves strict send order by incremental queue id.
  2. Delayed top-level tool-call messages are released when needed (permission request/tool result) to avoid out-of-order artifacts.
  3. On each launcher cycle finalization:
    • outstanding tool calls receive synthetic interrupted tool results,
    • queue is flushed before continuing.
  4. Parent-chain reset happens only when session ID actually changes (previousSessionId check), 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

  • ApiSessionClient keeps Claude protocol turn state (currentTurnId).
  • ApiSessionClient also 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

mermaid
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.