Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
556dde34bThis file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Claude-Code JSONL → web ServerEvent normalizer.
*
* Self-contained port of the hosted runtime’s replay normalizer
* (`the hosted runtime`: transformCCMessageToSessionMessage,
* sessionMessagesToEvents, stripContextSummary/stripSystemBlocks + tool-summary)
* — this package has no dependency outside itself.
*
* Two entry points:
* - messagesToEvents(rawLines) → full SessionEvent[] replay for `session_get`.
* - lineToEvents(rawLine, state) → incremental events for live tailing, PLUS a derived
* turn lifecycle (turn_started / turn_ended) so a prompt typed directly in the terminal
* renders identically to one sent from the web (mirror-all).
*/
import { harnessWebTool } from './harnessWebTools.js'
// ── Event shapes (match the server’s SessionEvent contract SessionEvent + live turn frames) ──────
export interface SubagentSummary {
agentId?: string
agentType?: string
totalTokens?: number
totalDurationMs?: number
totalToolUseCount?: number
}
export type SessionEvent =
| { type: 'user_message'; payload: { content: string; images?: Array<{ media_type: string; data: string }> } }
| { type: 'thinking_delta'; payload: { content: string; thinkingId?: string } }
| { type: 'thinking_title'; payload: { thinkingId?: string; title: string } }
| { type: 'text_delta'; payload: { content: string } }
| { type: 'tool_start'; payload: { id: string; tool: string; input: unknown; parentToolUseId?: string } }
| { type: 'tool_end'; payload: { id: string; tool: string; output: string; isError: boolean; summary: string; parentToolUseId?: string; subagent?: SubagentSummary; durationSeconds?: number } }
| { type: 'context_compact'; payload: { message: string; trigger?: string } }
| { type: 'done'; payload: { result: string } }
/** Live-stream events: replay events + the derived turn lifecycle. */
export type LiveEvent =
| SessionEvent
| { type: 'turn_started'; payload: { userMessage: string } }
| { type: 'turn_ended'; payload: { aborted?: true } } // aborted = killed by an interrupt, no recap
// An ASYNC sub-agent actually finished. `id` is the spawning tool_use id, so it pairs with the
// `tool_start`/`tool_end` of the same sub-agent (see taskNotificationEvent).
| { type: 'subagent_finished'; payload: { id: string; status: string; summary?: string } }
export interface LastTurnText {
userMessage: string
assistantText: string
}
interface RawContentBlock {
type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'image'
text?: string
thinking?: string
id?: string
name?: string
input?: unknown
tool_use_id?: string
content?: string | Array<{ type: string; text?: string }>
is_error?: boolean
source?: { type: string; media_type: string; data: string }
}
interface NormalizedMessage {
type: 'user' | 'assistant'
message?: { role: 'user' | 'assistant'; content: RawContentBlock[] }
toolUseResult?: SubagentSummary & { status?: string; durationSeconds?: number }
/** Raw Anthropic stop_reason from the assistant line (drives turn_ended). */
stopReason?: string | null
}
const MAX_OUTPUT = 2000
const MAX_THINKING = 500
const MAX_DIFF_SIDE = 1500
const PREVIEW_LINES = 8
// ── Text cleanup (ported verbatim) ────────────────────────────────────────────────────────────────
/** Strip the compaction-injected context summary; null if the whole message was summary. */
export function stripContextSummary(text: string): string | null {
const endMarker = '<!-- END CONTEXT SUMMARY -->'
const idx = text.indexOf(endMarker)
if (idx === -1) return text
let remaining = text.slice(idx + endMarker.length)
remaining = remaining.replace(/^\s*---\s*Now,?\s*continuing.*?:\s*/is, '')
remaining = remaining.trim()
return remaining || null
}
/** Strip system-injected blocks not meant for UI display. */
export function stripSystemBlocks(text: string): string {
return text
.replace(/<task-notification>[\s\S]*?<\/task-notification>\s*/g, '')
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, '')
.replace(/<goal-context>[\s\S]*?<\/goal-context>\s*/g, '')
.replace(/<dev-server-context>[\s\S]*?<\/dev-server-context>\s*/g, '')
.replace(/<available-deferred-tools>[\s\S]*?<\/available-deferred-tools>\s*/g, '')
.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>\s*/g, '')
.replace(/<command-name>[\s\S]*?<\/command-name>\s*/g, '')
.replace(/<command-message>[\s\S]*?<\/command-message>\s*/g, '')
.replace(/<command-args>[\s\S]*?<\/command-args>\s*/g, '')
.replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>\s*/g, '')
.replace(/Read the output file to retrieve the result:[^\n]*/g, '')
.trim()
}
/**
* Bash mode (`!command` in Claude Code) writes the command and its output back into the transcript as a
* `type:'user'` line — `<bash-input>…</bash-input>` and the `<bash-stdout>`/`<bash-stderr>` that follow.
* It is the person running a local shell, NOT a prompt to the agent, so it must not open a turn: if it
* did, the per-turn recap would summarise shell mechanics ("paste the command on one line") instead of
* what the agent is actually working on, and the tile would drift off-task with every `!`. Stripped ONLY
* for the turn-detection decision (see `realUserText`), never for display — the transcript still shows
* the commands. A line carrying real prose ALONGSIDE a bash block keeps counting; only a bash-only line
* is skipped.
*/
export function stripBashModeBlocks(text: string): string {
return text
.replace(/<bash-input>[\s\S]*?<\/bash-input>\s*/g, '')
.replace(/<bash-stdout>[\s\S]*?<\/bash-stdout>\s*/g, '')
.replace(/<bash-stderr>[\s\S]*?<\/bash-stderr>\s*/g, '')
.trim()
}
// Built-in CLI commands that only drive the local TUI and never dispatch an agent turn. Surfacing
// them as a user prompt would open a turn nothing ever closes (device tile stuck busy), so they stay
// invisible — same as before. Anything else (a custom `~/.claude/commands/*.md`, `/goal`, `/review`,
// a plugin skill) DOES run a turn and must be visible.
const LOCAL_ONLY_COMMANDS = new Set([
'add-dir', 'agents', 'bug', 'clear', 'compact', 'config', 'context', 'copy', 'cost', 'desktop',
'doctor', 'effort', 'exit', 'export', 'help', 'hooks', 'ide', 'login', 'logout', 'mcp', 'memory',
'model', 'output-style', 'permissions', 'privacy-settings', 'quit', 'release-notes',
'reload-plugins', 'resume', 'skills', 'statusline', 'status', 'terminal-setup', 'todos',
'upgrade', 'vim',
])
/**
* `<command-name>/goal</command-name>…<command-args>x</command-args>` → `/goal x`.
*
* Claude Code records a slash command ONLY as these tags (the expansion it actually sends to the
* model is written as a separate `isMeta` line, which we suppress). `stripSystemBlocks` erases the
* tags, so without this the whole line normalizes to "" → `realUserText` returns null → NO
* `turn_started` is ever emitted for a command prompt. That broke the device's goal mode: the
* injected `/goal <text>` ran fine in the terminal, but `SessionInputController` never saw the turn
* open and reported "The agent did not accept the message" after its submit retries — and the recap
* had no user message to lead with.
*/
function commandPromptText(text: string): string | null {
const name = text.match(/<command-name>\s*([^<]*?)\s*<\/command-name>/)?.[1]
if (!name) return null
const bare = name.replace(/^\//, '').trim()
if (!bare || LOCAL_ONLY_COMMANDS.has(bare.toLowerCase())) return null
const args = text.match(/<command-args>([\s\S]*?)<\/command-args>/)?.[1]?.trim() ?? ''
return args ? `/${bare} ${args}` : `/${bare}`
}
/** Short title heuristic for a thinking block (first 3-8 words of the first line). */
function extractThinkingTitle(thinking: string): string | null {
const trimmed = thinking.trim()
if (!trimmed || trimmed.length < 10) return null
const firstLine = trimmed.split('\n')[0].replace(/^[\s#*\->]+/, '').trim()
if (!firstLine) return null
const words = firstLine.split(/\s+/).slice(0, 8)
if (words.length < 2) return null
let title = words.join(' ').replace(/[.,:;!?]+$/, '')
if (title.length > 60) title = title.slice(0, 57) + '...'
return title
}
/** The short ⎿ label shown after a tool finishes (e.g. "Read 247 lines"). */
function computeToolSummary(toolName: string, rawOutput: string, isError: boolean): string {
if (isError) return rawOutput.split('\n')[0]?.slice(0, 80) || 'Error'
switch (toolName) {
case 'Bash': {
const match = rawOutput.match(/^Exit code (\d+)/)
return match ? `Exit ${match[1]}` : 'Done'
}
case 'Read': {
const lineCount = (rawOutput.match(/^\s*\d+→/gm) || []).length
return lineCount > 0 ? `Read ${lineCount} lines` : 'Read'
}
case 'Write':
case 'WriteFile':
return rawOutput.includes('successfully') ? 'Written' : rawOutput.slice(0, 60)
case 'Edit':
case 'EditFile':
return rawOutput.includes('successfully') ? 'Updated' : rawOutput.slice(0, 60)
case 'Glob': {
const count = rawOutput.trim() ? rawOutput.trim().split('\n').length : 0
return `${count} file${count !== 1 ? 's' : ''}`
}
case 'Grep': {
const count = rawOutput.trim() ? rawOutput.trim().split('\n').length : 0
return `${count} match${count !== 1 ? 'es' : ''}`
}
case 'WebFetch':
case 'WebSearch':
return 'Fetched'
case 'TodoRead':
case 'TodoWrite':
return 'Done'
default:
return rawOutput.split('\n')[0]?.slice(0, 80) || 'Done'
}
}
// ── Raw JSONL line → normalized message (port of transformCCMessageToSessionMessage) ─────────────
function smartWriteInput(input: Record<string, unknown>): Record<string, unknown> {
const raw = input.content as string
const lines = raw.split('\n')
const preview = lines.slice(0, PREVIEW_LINES).join('\n')
return {
...input,
content: undefined,
content_stats: {
lines: lines.length,
chars: raw.length,
preview: preview + (lines.length > PREVIEW_LINES ? '\n…' : ''),
},
}
}
function smartEditInput(input: Record<string, unknown>): Record<string, unknown> {
const truncSide = (s: unknown): unknown => {
if (typeof s !== 'string') return s
if (s.length <= MAX_DIFF_SIDE) return s
const half = Math.floor(MAX_DIFF_SIDE / 2)
return s.slice(0, half) + '\n…\n' + s.slice(s.length - half)
}
return { ...input, old_string: truncSide(input.old_string), new_string: truncSide(input.new_string) }
}
function mapContentItem(item: unknown): RawContentBlock {
const c = (item ?? {}) as Record<string, unknown> // a null content block must not throw on `.type`
const type = (c.type || 'text') as RawContentBlock['type']
if (type === 'image') {
return { type: 'image', source: c.source as RawContentBlock['source'] }
}
let resultContent = c.content as RawContentBlock['content']
if (type === 'tool_result' && typeof resultContent === 'string' && resultContent.length > MAX_OUTPUT) {
resultContent = resultContent.slice(0, MAX_OUTPUT) + '\n…[truncated]'
}
let toolInput = c.input as Record<string, unknown> | undefined
let toolName = c.name as string | undefined
// On a Local model the web tools come from the `harness` MCP server; the transcript should not
// look different for it, so they take the native cards here, at the one raw→normalized point.
const webTool = type === 'tool_use' ? harnessWebTool(toolName, toolInput) : null
if (webTool) {
toolName = webTool.tool
toolInput = webTool.input
}
if (type === 'tool_use' && toolInput) {
if ((toolName === 'Write' || toolName === 'WriteFile') && typeof toolInput.content === 'string' && toolInput.content.length > 500) {
toolInput = smartWriteInput(toolInput)
} else if (
(toolName === 'Edit' || toolName === 'EditFile') &&
(((toolInput.old_string as string)?.length ?? 0) > MAX_DIFF_SIDE || ((toolInput.new_string as string)?.length ?? 0) > MAX_DIFF_SIDE)
) {
toolInput = smartEditInput(toolInput)
}
}
return {
type,
text: c.text as string | undefined,
thinking: c.thinking as string | undefined,
id: c.id as string | undefined,
name: toolName,
input: toolInput,
tool_use_id: c.tool_use_id as string | undefined,
content: resultContent,
is_error: c.is_error as boolean | undefined,
}
}
/** Parse one raw JSONL entry into a normalized user/assistant message (null for other types). */
export function transformLine(rawMessage: Record<string, unknown>): NormalizedMessage | null {
const type = rawMessage.type as string
if (type !== 'user' && type !== 'assistant') return null
const messageObj = rawMessage.message as Record<string, unknown> | undefined
let messageContent: RawContentBlock[] | undefined
if (messageObj) {
const role = messageObj.role as string
const content = messageObj.content
if (role === 'user' || role === 'assistant') {
if (typeof content === 'string') {
messageContent = [{ type: 'text', text: content }]
} else if (Array.isArray(content)) {
messageContent = content.length > 0 ? content.map(mapContentItem) : [{ type: 'text', text: '' }]
} else if (content === null || content === undefined) {
messageContent = [{ type: 'text', text: '' }]
}
}
} else if (type === 'user') {
const directContent = rawMessage.content
if (typeof directContent === 'string') {
messageContent = [{ type: 'text', text: directContent }]
} else if (Array.isArray(directContent)) {
messageContent = directContent.map(mapContentItem)
}
}
let toolUseResult: NormalizedMessage['toolUseResult']
if (rawMessage.tool_use_result || rawMessage.toolUseResult) {
const tr = (rawMessage.tool_use_result || rawMessage.toolUseResult) as Record<string, unknown>
toolUseResult = {
status: (tr.status || 'complete') as string,
agentId: (tr.agentId ?? tr.agent_id) as string | undefined,
agentType: (tr.agentType ?? tr.agent_type) as string | undefined,
totalTokens: tr.totalTokens as number | undefined,
totalDurationMs: tr.totalDurationMs as number | undefined,
totalToolUseCount: tr.totalToolUseCount as number | undefined,
// WebSearch carries its search time here (the CLI's "Did 1 search in Ns" line).
durationSeconds: typeof tr.durationSeconds === 'number' ? tr.durationSeconds : undefined,
}
}
const role = (messageObj?.role || (type === 'user' ? 'user' : 'assistant')) as 'user' | 'assistant'
if (role === 'user' && messageContent) {
for (const block of messageContent) {
if (block.type !== 'text' || !block.text) continue
// A typed slash command survives as `/name args`; everything else is stripped as before.
block.text = commandPromptText(block.text) ?? stripSystemBlocks(block.text)
}
}
if (messageContent === undefined) messageContent = [{ type: 'text', text: '' }]
return {
type: type as 'user' | 'assistant',
message: { role, content: messageContent },
toolUseResult,
stopReason: (messageObj?.stop_reason as string | null | undefined) ?? null,
}
}
// ── Shared per-message event emission ─────────────────────────────────────────────────────────────
// Auto-fix / platform prompts filtered from replay (sent by the platform, not the user).
const SYSTEM_PROMPT_PATTERNS = [
'The dev server is running but the app has an error:',
'Please fix this code issue. Check the page component, routing, and any compile errors.',
'<!-- CONTEXT SUMMARY',
'This session is being continued from a previous conversation',
'<local-command-stdout>Compacted',
]
function isSystemPrompt(content: RawContentBlock[]): boolean {
const text = content.filter((c) => c.type === 'text' && c.text).map((c) => c.text!).join('')
return SYSTEM_PROMPT_PATTERNS.some((p) => text.includes(p))
}
/** A REAL user prompt (not a tool_result echo, not a platform prompt). Returns the cleaned text or null. */
/**
* An ESC interrupt (terminal, or a Stop from the web/device) is written to the transcript as a plain user
* line — `type:'user'`, `userType:'external'`, no `isMeta`, no flag of ANY kind — so its text is the only
* thing distinguishing it from something the user typed. Treating it as a prompt opened a brand-new turn at
* the instant a turn was cancelled, and the device then counted seconds forever on a turn no assistant
* would ever answer. Covers the `for tool use` variant too. A user who literally types this loses one turn
* card; that is the cheaper failure.
*/
const INTERRUPT_MARKER = /^\[Request interrupted by user[^\]]*\]$/
/** The user-authored text of a line, before interrupt markers are judged. null = not a user prompt. */
function userTextRaw(msg: NormalizedMessage): string | null {
if (msg.type !== 'user' || !msg.message) return null
const content = msg.message.content
if (isSystemPrompt(content)) return null
if (content.some((c) => c.type === 'tool_result')) return null
const parts: string[] = []
for (const block of content) {
if (block.type !== 'text' || !block.text) continue
let cleaned = stripContextSummary(block.text)
if (cleaned) cleaned = stripSystemBlocks(cleaned)
if (cleaned && !cleaned.match(/^\[Image: original \d+x\d+/)) parts.push(cleaned)
}
const text = parts.join('\n')
return text || null
}
/**
* An async sub-agent's REAL completion. Claude's `Agent` tool returns in milliseconds with
* "Async agent launched successfully." — that `tool_result` is a launch ack, not a result, and taking it
* for one made the device tick a sub-agent off the moment it started. The actual finish arrives much
* later as a `type:"user"` record the CLI injects into the parent transcript:
*
* <task-notification><task-id>…</task-id><tool-use-id>toolu_018wkW…</tool-use-id>
* <output-file>…</output-file><status>completed</status><summary>Agent "…" finished…
*
* `stripSystemBlocks` erases the whole block, so it correctly starts no turn — but that also meant the
* only signal a sub-agent had finished was being thrown away. Read it here, keyed by tool-use-id.
*
* Takes the RAW record, not a NormalizedMessage: `transformLine` already ran the strip (line ~304), so by
* then the text block is empty and there is nothing left to parse.
*/
function taskNotificationEvent(raw: Record<string, unknown>): LiveEvent | null {
if (raw.type !== 'user') return null
const content = (raw.message as { content?: unknown } | undefined)?.content ?? raw.content
let text = ''
if (typeof content === 'string') text = content
else if (Array.isArray(content)) {
text = content
.map((b) => (typeof b === 'string' ? b : (b as RawContentBlock)?.type === 'text' ? String((b as RawContentBlock).text ?? '') : ''))
.join('\n')
}
if (!text.includes('<task-notification>')) return null
const id = text.match(/<tool-use-id>([^<]+)<\/tool-use-id>/)?.[1]?.trim()
if (!id) return null
const status = text.match(/<status>([^<]+)<\/status>/)?.[1]?.trim() || 'completed'
const summary = text.match(/<summary>([\s\S]*?)(?:<\/summary>|$)/)?.[1]?.trim()
return { type: 'subagent_finished', payload: { id, status, ...(summary ? { summary } : {}) } }
}
/** True for the synthetic line an interrupt leaves behind. It ends a turn; it never starts one. */
function isInterruptLine(msg: NormalizedMessage): boolean {
const text = userTextRaw(msg)
return text !== null && INTERRUPT_MARKER.test(text)
}
// Every caller of this asks "is this a prompt the user made?" — turn slicing for replay and the recap's
// user-message source as well as the live turn lifecycle — and an interrupt marker is not one of those.
function realUserText(msg: NormalizedMessage): string | null {
const text = userTextRaw(msg)
if (text === null || INTERRUPT_MARKER.test(text)) return null
// A `!command` line is not a prompt — skip it so the turn (and its recap) stays anchored to the last
// real ask. Only when nothing but bash blocks remain: a message that also carries prose still counts.
if (!stripBashModeBlocks(text)) return null
return text
}
/**
* Extract the last real user prompt and the assistant text that followed it from
* raw Claude JSONL. This is the recap source of truth: it matches replay parsing
* rules, skips tool_result echoes / platform prompts / compact metadata, and only
* summarizes assistant text blocks.
*/
export function lastTurnTextFromRawLines(rawLines: string[]): LastTurnText | null {
let userMessage = ''
let assistantParts: string[] = []
for (const line of rawLines) {
if (!line.trim()) continue
let raw: Record<string, unknown>
try { raw = JSON.parse(line) as Record<string, unknown> } catch { continue }
const compact = compactEventFromRaw(raw)
if (compact !== undefined) continue
const msg = transformLine(raw)
if (!msg?.message) continue
const userText = realUserText(msg)
if (userText !== null) {
userMessage = userText
assistantParts = []
continue
}
if (msg.type !== 'assistant') continue
const text = msg.message.content
.filter((c) => c.type === 'text' && c.text)
.map((c) => c.text!)
.join('')
.trim()
if (text) assistantParts.push(text)
}
const assistantText = assistantParts.join('\n\n').trim()
return assistantText ? { userMessage, assistantText } : null
}
/** Aggregates computed from a sub-agent's OWN transcript. Async/background agents never get totals
* in the launcher's toolUseResult (it only records `{isAsync, status:'async_launched', agentId}` at
* launch) — the real numbers live in `<session>/subagents/agent-<id>.jsonl`. totalTokens mirrors the
* CLI's definition: input + output + cache_read + cache_creation summed over assistant turns. */
export function subagentStatsFromRawLines(rawLines: string[]): { totalToolUseCount: number; totalDurationMs?: number; totalTokens?: number } {
let toolCount = 0
let tokens = 0
let first: number | undefined
let last: number | undefined
for (const line of rawLines) {
if (!line.trim()) continue
let raw: Record<string, unknown>
try { raw = JSON.parse(line) as Record<string, unknown> } catch { continue }
if (!raw || typeof raw !== 'object') continue
const ts = typeof raw.timestamp === 'string' ? Date.parse(raw.timestamp) : NaN
if (!Number.isNaN(ts)) { if (first === undefined) first = ts; last = ts }
if (raw.type !== 'assistant') continue
const msg = raw.message as { content?: unknown; usage?: Record<string, unknown> } | undefined
if (!msg) continue
if (Array.isArray(msg.content)) {
for (const b of msg.content) if ((b as { type?: string }).type === 'tool_use') toolCount++
}
const u = msg.usage
if (u) {
tokens += (Number(u.input_tokens) || 0) + (Number(u.output_tokens) || 0)
+ (Number(u.cache_read_input_tokens) || 0) + (Number(u.cache_creation_input_tokens) || 0)
}
}
return {
totalToolUseCount: toolCount,
totalDurationMs: first !== undefined && last !== undefined && last > first ? last - first : undefined,
totalTokens: tokens || undefined,
}
}
/** Emit tool_end events for a user message's tool_result blocks. */
function toolResultEvents(msg: NormalizedMessage, toolIdToName: Map<string, string>): SessionEvent[] {
const events: SessionEvent[] = []
if (!msg.message) return events
for (const block of msg.message.content) {
if (block.type !== 'tool_result') continue
const toolUseId = block.tool_use_id || ''
const isError = block.is_error === true
let rawOutput = ''
if (typeof block.content === 'string') {
rawOutput = block.content
} else if (Array.isArray(block.content)) {
rawOutput = block.content.filter((c) => c.type === 'text' && c.text).map((c) => c.text!).join('')
}
const toolName = toolIdToName.get(toolUseId) || ''
const summary = computeToolSummary(toolName, rawOutput, isError)
if (rawOutput.length > MAX_OUTPUT) rawOutput = rawOutput.slice(0, MAX_OUTPUT) + '\n…[truncated]'
const agg = msg.toolUseResult
const subagent = agg?.agentId
? { agentId: agg.agentId, agentType: agg.agentType, totalTokens: agg.totalTokens, totalDurationMs: agg.totalDurationMs, totalToolUseCount: agg.totalToolUseCount }
: undefined
events.push({
type: 'tool_end',
payload: {
id: toolUseId, tool: toolName, output: rawOutput, isError, summary,
...(subagent ? { subagent } : {}),
...(typeof agg?.durationSeconds === 'number' ? { durationSeconds: agg.durationSeconds } : {}),
},
})
}
return events
}
/** Emit thinking/text/tool_start events for an assistant message's blocks. */
function assistantEvents(
msg: NormalizedMessage,
toolIdToName: Map<string, string>,
thinkingIdPrefix: string,
thinkingCounterStart = 0,
): SessionEvent[] {
const events: SessionEvent[] = []
if (!msg.message) return events
let thinkingCounter = thinkingCounterStart
for (const block of msg.message.content) {
if (block.type === 'thinking' && block.thinking) {
const thinkingId = `${thinkingIdPrefix}${thinkingCounter++}`
const thinkingContent = block.thinking.length > MAX_THINKING
? block.thinking.slice(0, MAX_THINKING) + '\n…[truncated]'
: block.thinking
events.push({ type: 'thinking_delta', payload: { content: thinkingContent, thinkingId } })
const title = extractThinkingTitle(block.thinking)
if (title) events.push({ type: 'thinking_title', payload: { thinkingId, title } })
} else if (block.type === 'text' && block.text) {
events.push({ type: 'text_delta', payload: { content: block.text } })
} else if (block.type === 'tool_use') {
const toolId = block.id || ''
const toolName = block.name || ''
toolIdToName.set(toolId, toolName)
let toolInput = block.input
if (toolInput && typeof toolInput === 'object') {
const inputCopy = { ...(toolInput as Record<string, unknown>) }
for (const [key, val] of Object.entries(inputCopy)) {
if (typeof val === 'string' && val.length > MAX_OUTPUT) {
inputCopy[key] = val.slice(0, MAX_OUTPUT) + '\n…[truncated]'
}
}
toolInput = inputCopy
}
events.push({ type: 'tool_start', payload: { id: toolId, tool: toolName, input: toolInput } })
}
}
return events
}
// ── Compaction lines ────────────────────────────────────────────────────────────────────────────
//
// When a tmux claude session compacts (auto or `/compact`), it does an IN-FILE compact: the JSONL
// keeps the SAME sessionId and is append-only (pre-compact history stays on disk), so the byte-offset
// tail streams straight through — there is no dead file to re-point at and no truncation to re-read.
// The compact only injects a few bookkeeping lines we must handle so the stream stays clean:
// • `system` / `subtype:"compact_boundary"` (+ `compactMetadata.trigger`) → one `context_compact`
// indicator (purely a UI hint; the actual stream continuity is already guaranteed by the tail).
// • the injected `isCompactSummary` user line (the big "This session is being continued…" summary)
// and any `isMeta` bookkeeping line → suppressed via the AUTHORITATIVE flags (not a fragile
// English-string match), so the summary is never rendered as a giant fake user turn / new turn.
//
// Returns the events to emit for a compact line (possibly `[]` to suppress it), or `undefined` when
// `raw` is not a compact line and should fall through to the normal user/assistant path.
export function compactEventFromRaw(raw: Record<string, unknown>): SessionEvent[] | undefined {
if (raw.type === 'system' && raw.subtype === 'compact_boundary') {
const meta = raw.compactMetadata as { trigger?: string } | undefined
return [{
type: 'context_compact',
payload: {
message: 'Context was compacted — the previous conversation has been summarized to free up space.',
...(meta?.trigger ? { trigger: meta.trigger } : {}),
},
}]
}
if (raw.isCompactSummary === true) return []
if (raw.isMeta === true) {
// NOT all isMeta user lines are bookkeeping. A `/loop` iteration is a REAL prompt that Claude
// submits on the user's behalf every time the loop fires, and it is written as isMeta — so blanket
// suppression made every iteration after the first invisible: no turn_started, therefore no recap,
// therefore nothing on the device, while the loop kept running perfectly in the terminal.
//
// The discriminator is `promptSource`, which Claude sets authoritatively (same principle as the
// flags above — never an English-string match):
// 'typed' + no isMeta → the human typed it
// 'system' + isMeta → submitted programmatically: a loop iteration. This IS a turn.
// (absent) + isMeta → real bookkeeping: a compact summary, or a slash command's expansion
// text. Still suppressed, so one `/goal` is not three user turns.
if (raw.type === 'user' && raw.promptSource === 'system') return undefined
return []
}
return undefined
}
// ── Full replay (session_get) ─────────────────────────────────────────────────────────────────────
/** Convert a whole session's raw JSONL lines to replay events (same render path as streaming). */
export function messagesToEvents(rawLines: string[]): SessionEvent[] {
const events: SessionEvent[] = []
const toolIdToName = new Map<string, string>()
let thinkingCounter = 0
let inAutoFixSequence = false
for (const line of rawLines) {
if (!line.trim()) continue
let raw: Record<string, unknown>
try { raw = JSON.parse(line) as Record<string, unknown> } catch { continue }
const compact = compactEventFromRaw(raw)
if (compact) { events.push(...compact); continue } // compact boundary → indicator; summary/meta → suppressed
const msg = transformLine(raw)
if (!msg?.message) continue
if (msg.type === 'user') {
const content = msg.message.content
if (isSystemPrompt(content)) { inAutoFixSequence = true; continue }
const hasToolResult = content.some((c) => c.type === 'tool_result')
const hasUserText = content.some((c) => c.type === 'text' && c.text?.trim())
if (inAutoFixSequence && hasToolResult && !hasUserText) continue
if (inAutoFixSequence) inAutoFixSequence = false
if (!hasToolResult) {
const textParts: string[] = []
const images: Array<{ media_type: string; data: string }> = []
for (const block of content) {
if (block.type === 'text' && block.text) {
let cleaned = stripContextSummary(block.text)
if (cleaned) cleaned = stripSystemBlocks(cleaned)
if (cleaned && !cleaned.match(/^\[Image: original \d+x\d+/)) textParts.push(cleaned)
} else if (block.type === 'image' && block.source) {
images.push({ media_type: block.source.media_type, data: block.source.data })
}
}
const textContent = textParts.join('\n')
if (textContent || images.length > 0) {
events.push({ type: 'user_message', payload: { content: textContent, ...(images.length > 0 ? { images } : {}) } })
}
continue
}
events.push(...toolResultEvents(msg, toolIdToName))
continue
}
// assistant
if (inAutoFixSequence) continue
const before = events.length
events.push(...assistantEvents(msg, toolIdToName, 'thinking-hist-', thinkingCounter))
// keep the per-session thinking counter monotonic across messages
thinkingCounter += events.slice(before).filter((e) => e.type === 'thinking_delta').length
}
events.push({ type: 'done', payload: { result: 'success' } })
return events
}
/**
* Turn-snapped pagination window over raw JSONL lines, for `session_get` with `{limit, before}`.
* `before` = uuid of the oldest line the client already holds (exclusive upper bound). Returns the
* line slice to feed `messagesToEvents` plus cursor metadata.
*
* The window START is snapped back to a real user-prompt line so a window never splits an assistant
* turn's `tool_use` from its `tool_result`: `messagesToEvents` is stateful (`toolIdToName` carries a
* tool_use id → name across lines so the later tool_result renders the tool name), so a mid-turn cut
* would drop tool names. A user-prompt line never sits between a tool_use and its tool_result, so
* snapping there keeps every turn whole.
*/
export function windowRawLines(
rawLines: string[],
opts: { limit: number; before?: string },
): { window: string[]; hasMore: boolean; oldestCursor: string | null; staleCursor?: boolean } {
// Per-line uuid + whether the line is a real user-prompt turn start (parse each line once).
const meta = rawLines.map((line) => {
if (!line.trim()) return { uuid: null as string | null, turnStart: false }
let raw: Record<string, unknown>
try { raw = JSON.parse(line) as Record<string, unknown> } catch { return { uuid: null as string | null, turnStart: false } }
const uuid = (raw.uuid ?? raw.id ?? raw.message_id ?? null) as string | null
const msg = transformLine(raw)
const turnStart = msg ? realUserText(msg) !== null : false
return { uuid, turnStart }
})
let endIndex = rawLines.length
if (opts.before) {
const ci = meta.findIndex((m) => m.uuid === opts.before)
// Cursor no longer in the file (should not happen — the transcript is append-only) → signal a
// full reload rather than returning a wrong window.
if (ci < 0) return { window: [], hasMore: false, oldestCursor: null, staleCursor: true }
endIndex = ci // exclusive: the client already holds this line
}
let start = Math.max(0, endIndex - opts.limit)
while (start > 0 && !meta[start].turnStart) start--
return {
window: rawLines.slice(start, endIndex),
hasMore: start > 0,
oldestCursor: meta[start]?.uuid ?? null,
}
}
// ── Live tailing (watcher lines) with derived turn lifecycle ──────────────────────────────────────
export interface TurnState {
turnOpen: boolean
toolIdToName: Map<string, string>
/** tool_use ids started but not yet resolved by a tool_result. */
pendingTools: Set<string>
thinkingCounter: number
}
export function newTurnState(): TurnState {
return { turnOpen: false, toolIdToName: new Map(), pendingTools: new Set(), thinkingCounter: 0 }
}
/**
* Fold a transcript that already exists into a fresh normalizer, and say whether what came out is
* HISTORY (to be swallowed) or the LIVE conversation (to be emitted).
*
* Attaching to a session normally means catching up on turns the user has already seen elsewhere, so the
* events are dropped and only a turn left half-open is replayed. That assumption breaks when the agent
* exists BEFORE its session: the first prompt is what makes the engine open the transcript, so by the
* time anything binds it, the whole first turn — question, tools and answer — can already be on disk.
* Folded as history it is lost silently, and silently is literal: no `turn_started` means no
* `turn_ended` and no recap either, so nothing in the log records that a turn ever happened.
*
* `live` picks the destination. It also forces `turnOpen` to false, because the caller's mid-turn rescue
* ("replay the last turn_started") would otherwise emit that event a second time on top of the events
* returned here.
*/
export function foldTranscript(
ingest: (line: string) => LiveEvent[],
lines: string[],
turnOpenAfter: () => boolean,
opts: { live: boolean },
): { history: LiveEvent[]; live: LiveEvent[]; turnOpen: boolean } {
const folded: LiveEvent[] = []
for (const line of lines) folded.push(...ingest(line))
return {
history: opts.live ? [] : folded,
live: opts.live ? folded : [],
turnOpen: !opts.live && turnOpenAfter(),
}
}
// Terminal assistant stop reasons that close a turn. `tool_use` is deliberately absent (the turn
// continues into the tool). `pause_turn` is absent too — it may legitimately resume; the Stop hook
// (which only fires when the agent is truly done) is the authoritative catch-all for that case.
// `max_tokens`/`refusal` are included so a turn that ends on them still closes from the JSONL alone.
const TERMINAL_STOP_REASONS = new Set(['end_turn', 'stop_sequence', 'max_tokens', 'refusal'])
/**
* Convert ONE appended JSONL line into live events. Derives turn_started/turn_ended from content:
* - a real user prompt → (turn_ended if one was open) + turn_started{userMessage}
* - assistant blocks → thinking/text/tool_start (tool ids tracked as pending)
* - user tool_results → tool_end (resolves pending ids)
* - assistant stop_reason in TERMINAL_STOP_REASONS with no pending tools → turn_ended
*/
export function lineToEvents(rawLine: string, state: TurnState): LiveEvent[] {
if (!rawLine.trim()) return []
let raw: Record<string, unknown>
try { raw = JSON.parse(rawLine) as Record<string, unknown> } catch { return [] }
// A valid-JSON but non-object line (the literal `null`, a number, a bare string) would make the
// property access in compactEventFromRaw / transformLine throw — and on the unguarded watcher path
// that kills the daemon. Any non-object line has nothing to emit.
if (!raw || typeof raw !== 'object') return []
// Compaction: emit the boundary indicator; suppress the injected summary/meta lines. The turn state
// is left untouched, so an auto-compact that fires MID-TURN keeps the open turn open (the assistant
// continuation after the boundary just streams under it) instead of the summary opening a fake turn.
const compact = compactEventFromRaw(raw)
if (compact) return compact
// Sub-agent finished. Deliberately does NOT touch the turn state: the parent turn already closed on the
// launch ack, and this record is not a prompt.
const finished = taskNotificationEvent(raw)
if (finished) return [finished]
const msg = transformLine(raw)
if (!msg?.message) return []
const events: LiveEvent[] = []
if (msg.type === 'user') {
// Interrupt: close the open turn and start NOTHING. This is also the only signal for an ESC pressed
// straight in the terminal — no cancel frame reaches the adapter there — so the turn has to end here.
if (isInterruptLine(msg)) {
if (!state.turnOpen) return []
state.turnOpen = false
state.pendingTools.clear()
return [{ type: 'turn_ended', payload: { aborted: true } }]
}
const userText = realUserText(msg)
if (userText !== null) {
// New prompt (typed in the terminal OR injected from the web) — starts a turn.
if (state.turnOpen) events.push({ type: 'turn_ended', payload: {} })
state.turnOpen = true
state.pendingTools.clear()
events.push({ type: 'turn_started', payload: { userMessage: userText } })
return events
}
// tool_result echoes: resolve pending tools.
const ends = toolResultEvents(msg, state.toolIdToName)
for (const e of ends) {
if (e.type === 'tool_end') state.pendingTools.delete(e.payload.id)
}
events.push(...ends)
return events
}
// assistant
const before = events.length
events.push(...assistantEvents(msg, state.toolIdToName, 'thinking-live-', state.thinkingCounter))
state.thinkingCounter += events.slice(before).filter((e) => e.type === 'thinking_delta').length
for (const e of events) {
if (e.type === 'tool_start') state.pendingTools.add(e.payload.id)
}
const stop = msg.stopReason
if (state.turnOpen && stop && TERMINAL_STOP_REASONS.has(stop) && state.pendingTools.size === 0) {
state.turnOpen = false
events.push({ type: 'turn_ended', payload: {} })
}
return events
}