Session Controller
The session-level surface: list the user’s live Claude Code sessions, subscribe to one, receive the events Claude Code writes to JSONL as they happen, and (carefully) submit prompts.
What a “session” is
A session is a single Claude Code conversation living in a JSONL file at ~/.claude/projects/<workspace-slug>/<sid>.jsonl. Claude Code owns the file. The extension watches it. The phone sees what Claude Code writes — at the granularity of JSONL line writes, not token-level streaming.
Be precise in copy:
✅ “Assistant / session events appear on the phone as Claude Code writes them to JSONL.” ❌ “Tokens stream in real time.”
Capability
session_subscriptionsClients with this capability must explicitly watch_session to receive session_events. Clients without it fall back to v1 broadcast-all behaviour. Native clients should always negotiate this capability — explicit subscription is doctrine-aligned.
Wire shapes
SessionInfo (sent in sessions snapshot)
{
"id": "a230f370-1634-4bdd-91d2-ffb1992ab916",
"cwd": "/root/projects/agentic-ides",
"startedAt": 1700000000000,
"entrypoint": "claude-code-ide",
"title": "User-provided or AI title (optional)"
}Client → Server
{ "type": "list_sessions", "requestId": "r-1" }
{ "type": "watch_session",
"requestId": "r-2",
"traceId": "t-...",
"sessionId": "<sid>"
}
{ "type": "unwatch_session", "requestId": "r-3", "sessionId": "<sid>" }
{ "type": "new_conversation", "requestId": "r-4" }
{ "type": "prompt",
"requestId": "r-5",
"traceId": "t-...",
"message": "your prompt text",
"sessionId": "<sid>"
}
{ "type": "accept_diff", "requestId": "r-6", "traceId": "t-..." }
{ "type": "reject_diff", "requestId": "r-7", "traceId": "t-..." }Server → Client
{ "type": "sessions", "sessions": [ /* SessionInfo[] */ ], "seqId": N }
{ "type": "session_started",
"sessionId": "<sid>",
"cwd": "/path",
"entrypoint":"claude-code-ide",
"seqId": N
}
{ "type": "session_ended", "sessionId": "<sid>", "seqId": N }
{ "type": "watch_ack",
"requestId": "r-2",
"sessionId": "<sid>",
"watching": true,
"seqId": N
}
{ "type": "session_event",
"sessionId": "<sid>",
"event": {
"type": "assistant" | "user" | "tool_use" | "tool_result" | "thinking" | "ai-title" | "text" | "message",
"data": { ... }, // raw JSONL line — back-compat fallback only
"blocks": [ /* ContentBlock[] */ ] // typed projection — render THIS (see "Typed Tool Blocks" below)
},
"seqId": N,
"traceId": "t-...?"
}
{ "type": "prompt_sent",
"requestId": "r-5",
"message": "<echo of submitted text>",
"seqId": N
}Watch lifecycle (the explicit-subscription model)
1. client → server: list_sessions
2. ← sessions [ ... ]
3. user picks session sid
4. client → server: watch_session { sessionId: sid }
5. ← watch_ack { sessionId: sid, watching: true }
6. ← session_event (replay of last ~200 historical events from JSONL)
7. ← session_event (live, as Claude Code writes each new JSONL line)
8. user picks a different session
9. client → server: unwatch_session { sessionId: sid_old }
10. ← watch_ack { sessionId: sid_old, watching: false }
11. client → server: watch_session { sessionId: sid_new }Important: watch_session triggers a replay of the last 200 events from the JSONL file (handled by MessageHandler.replayJsonl in the extension). Native clients should not re-request these themselves — the server delivers them on subscribe.
What native UI should render
For each session:
- Title (preferring
titlefrom the latestai-titleevent in JSONL → falling back tocwdbasename → falling back to first 8 chars ofid). cwd(full path or basename, your call).- Last activity timestamp (track the highest
seqIdyou’ve seen for this sid). - Active indicator if currently watched.
For the chat view inside a session:
- Render each
session_eventaccording to its innerevent.type:user/text/message→ user-aligned bubble.assistant→ assistant-aligned bubble.thinking→ collapsible “thinking” block.tool_use→ tool block with name + input (collapsed by default once completed).tool_result→ result attached to the matchingtool_use.ai-title→ store it as the session title, don’t render as a chat message.
- Unknown future
event.type→ render as(unknown event type: <type>)placeholder. Do not crash.
The block above describes the legacy raw-event.data path. The preferred render path is the typed projection in event.blocks — see the next section.
Typed Tool Blocks (session_event.event.blocks) — SHIPPED 2026-05-25
Additive to
session_event. Renderblocks; never parseevent.data.
Claude Code writes its JSONL in its own message format (message.content[] arrays, tool_use / tool_result shapes, structuredPatch hunks). Historically each client re-parsed that format. That made a Claude Code format change an App-Store-gated emergency — the exact failure mode the doctrine forbids. Typed Tool Blocks removes it.
The extension now ships a typed projection alongside the preserved raw line:
{
"type": "session_event",
"sessionId": "<sid>",
"event": {
"type": "assistant" | "user" | ...,
"data": { /* raw JSONL line — preserved for back-compat only */ },
"blocks": [ /* ContentBlock[] — the typed projection */ ]
},
"seqId": N
}ContentBlock (open kind and name unions)
type ContentBlock =
| { kind: 'text'; text: string }
| { kind: 'thinking'; text: string }
| { kind: 'tool'; toolUseId: string; name: string; // OPEN union — e.g. 'Read' | 'Edit' | 'Bash' | (string & {})
input: Record<string, unknown> } // from the assistant line
| { kind: 'tool_result'; toolUseId: string; isError: boolean; // from the user line
text?: string;
numLines?: number;
edit?: EditResultInfo }
interface EditResultInfo {
added: number; // count of '+'-prefixed lines across all hunks
removed: number; // count of '-'-prefixed lines across all hunks
hunks: Array<{ oldStart: number; lines: string[] }>; // each line is '+'/'-'/' '-prefixed
}The doctrine (this is the whole point)
tool-projection.tsis the SOLE owner of the raw-JSONL →ContentBlockmapping. It extractsstructuredPatch → added/removed/hunks, liftsnumLines, and de-wraps<tool_use_error>. The native client does the equivalent of nothing — it consumes the already-typed blocks.- Native clients render
blocks. They MUST NOT reach intoevent.data.message.content[], recompute diffs, or know whattool_use/tool_result/structuredPatchlook like. If a future Claude Code release changes its JSONL shape, the extension updates its projection and ships through normal channels — the native binary is untouched. - Correlate
tool_result→toolbytoolUseId— a typed id-match, never a format parse. Atoolblock carriestoolUseId; the matchingtool_resultblock carries the sametoolUseId. Attach the result to its tool row by equality. kindandnameare OPEN unions. Unknownkind→ render a neutral generic block (show whatever text/label you have, never crash). Unknown toolname→ render a generic tool row with the literal name.- Labels are CLIENT presentation, computed from the typed data — not format knowledge.
"Read: foo.tsx","Added 12 lines, removed 3 lines", etc. are derived fromname+input+edit.added/removed. Mirror the PWA’stoolDisplay.ts(e.g.formatEditDelta,findTypedToolResult); do not hardcode Claude Code’s content format to produce them. event.datais the rollout fallback ONLY. While the extension/PWA roll out, asession_eventmay arrive withblocksabsent. In that case (and only that case) fall back to the legacy raw-event.typerendering above. Never extend that fallback with new format knowledge — it exists to bridge the deploy window, not to grow.
What native UI should render from blocks
| Block | Render |
|---|---|
text | message text (user- or assistant-aligned per the enclosing event.type) |
thinking | collapsible “thinking” block |
tool | a tool row: client-computed label from name + input (e.g. Read: foo.tsx), input collapsible |
tool_result | attach to the matching tool row by toolUseId; show text, or a diff from edit.hunks, or Added N, removed M from edit.added/edit.removed; surface isError: true distinctly |
unknown kind | generic neutral block; do not crash |
Liveness — turn_status + interrupt — SHIPPED 2026-05-25
The “is Claude working right now, and can I stop it?” surface. Capability:
turn_status_observe.
JSONL writes are bursty — Claude Code can “crunch” for a long stretch writing no JSONL at all. Without a liveness signal, the phone can’t tell a finished turn from one still thinking. turn_status is that signal; the interrupt pair is the Stop button.
Capability
turn_status_observeNegotiate it to receive turn_status broadcasts. (Read-only observation; the PWA negotiates it unconditionally.)
Server → client: turn_status
{ "type": "turn_status",
"sessionId": "<sid>",
"state": "working" | "idle" | (string & {}), // OPEN union
"since": 1700000000000, // epoch ms the state began — drives the elapsed timer
"seqId": N
}The extension (turn-tracker.ts) marks a session working on a user message — a phone-sent prompt or a user JSONL line — and idle on either:
- an assistant terminal
stop_reason(end_turn, etc.), or - a 120 s backstop — a safety net only, not a short quiet-timeout.
⚠️ The backstop is deliberately long. Claude writes no JSONL while “crunching”, so a short quiet-timeout would flip the indicator to
idlemid-turn. Do not reimplement a shorter client-side timeout to “improve responsiveness” — you would kill the indicator while Claude is still working.since+ the server’sidleevent are the contract.
Native render — the working indicator
While the active session’s latest turn_status is working:
- Show an animated glyph + a client-side rotating verb list (
Working… / Generating… / Stewing… / …). This rotation is pure presentation — invent your own verbs; it carries zero Claude Code format knowledge. - Show elapsed time computed from
since(now − since). - Honor reduced-motion OS settings: drop the animation, keep the static label + elapsed.
- Unknown
state(anything that isn’tworking) → render nothing (the safe default is “not working”). Never crash.
Interrupt — the Stop button (data-driven, server-owned)
Client → server:
{ "type": "interrupt_request", "requestId": "r-9", "traceId": "t-...", "sessionId": "<sid>" }Server → client:
{ "type": "interrupt_ack",
"requestId": "r-9",
"ok": true | false,
"errorCode": "interrupt_unavailable" | "no_active_turn" | "bridge_error" | (string & {}), // OPEN union, present when ok:false
"seqId": N
}Renderer contract:
- Show the Stop button only while the active session is
working. - On tap → send
interrupt_request. - On
interrupt_ackwithok: falseanderrorCode: 'interrupt_unavailable'→ disable the Stop button. That code means this extension build cannot interrupt — its webview control channel isn’t patched. Don’t keep re-offering an action the server just said it can’t do. - Other
ok: falsecodes (no_active_turn,bridge_error, unknown) → surface the failure (toast / inline) and degrade gracefully;errorCodeis an OPEN union, so render unknown codes without crashing. - The client never owns the interrupt mechanism. It sends a request and renders the ack. The extension owns the actual interrupt and degrades gracefully when it can’t perform one. Do not try to “interrupt locally” (e.g. by dropping the socket) — there is no client-side interrupt.
Optimistic prompt echo (PWA reference pattern — optional for natives)
The PWA renders the user’s own prompt bubble immediately on send (before any server round-trip), then dedupes it when the authoritative user session_event echoes back over the wire. This is a UX nicety, not a protocol requirement — a native client MAY render only on the echoed session_event if it prefers strict server-authority. If you do echo optimistically, dedupe against the incoming user event (e.g. by submitted text + a short time window) so the prompt doesn’t appear twice.
TraceId model (and its caveat)
The native client should generateTraceId() (UUID or short base36) for any user-initiated outbound message — prompt, watch_session, open_workspace, etc.
The server inherits the prompt’s traceId for post-prompt session_events via a 10-second TTL per-session map (MessageHandler.sessionTraceIds). For fast replies (< 10 s), the receive-side events carry the same traceId. For slow replies, traceId continuity is lost — that’s expected.
Treat traceId-linkage on session_event as best-effort evidence, not a contract. The canonical correlation in the receive direction is by sessionId.
Send path vs receive path
- Send path is trace-by-traceId:
web.ui.send_prompt→ws.in (prompt)→bridge.send_prompt→prompt_sentresponse. All carry the same traceId. - Receive path is trace-by-sid:
watch.read→session_event(per JSONL write) →ws.outto phone. ThetraceIdMAY inherit from the prompt’s traceId via the 10 s window, but should be considered optional.
Replay across reconnects
If the client disconnects and reconnects with lastSeqId, the server replays buffered messages with seqId > lastSeqId from the ring buffer (200 events deep). For session_events specifically:
- If the buffer covers the gap → seamless.
- If the buffer wrapped (rare; only for very long offline windows) → some events are silently lost. This is a known deferred reliability gap. A future
gap_detectedprimitive may surface it; render it then.
For now, on reconnect, native clients should:
- Replay messages from
lastSeqId. - Re-issue
watch_sessionfor each previously-watched session to ensure subscription state matches. - Optionally call
list_sessionsto reconcile the session list.
Doctrine notes
- JSONL is the source of truth on the read path. The native client renders what arrives; it does not infer what Claude Code “must be doing.”
- Don’t persist
messagesto disk. Per-session messages cap at 500 in memory (PWA precedent) and are rebuilt onwatch_sessionreplay. - Token-level streaming is not a thing here. Don’t promise it in UI copy. Don’t build animations that assume per-char arrival.
- The native client never opens the JSONL files directly. Always go through the protocol.
Reference implementation
- Wire types:
packages/protocol/src/messages.ts—SessionInfo,SessionEventMessage,ContentBlock,EditResultInfo,TurnStatusMessage,InterruptRequestMessage,InterruptAckMessage. - PWA dispatcher:
packages/web/src/core/dispatcher.ts—case 'sessions',case 'session_event',case 'turn_status',case 'interrupt_ack'. - PWA store:
packages/web/src/store/index.ts— session list + per-session messages (eachChatMessagecarriesblocks). - Typed Tool Blocks (extension — SOLE owner of the JSONL→block mapping):
packages/extension/src/tool-projection.ts. - Liveness (extension):
packages/extension/src/turn-tracker.ts—working/idletransitions + the 120 s backstop. - PWA renderers:
packages/web/src/components/MessageRenderer.tsx(typed-block rendering + raw-blob fallback),packages/web/src/components/WorkingIndicator.tsx(animated glyph + rotating verbs + elapsed),packages/web/src/components/ChatView.tsx(Stop button + optimistic echo),packages/web/src/core/toolDisplay.ts(client-side labels —formatEditDelta,findTypedToolResult). - Server side (extension):
packages/extension/src/session-watcher.ts,packages/extension/src/message-handler.ts.