diff --git a/src/data.js b/src/data.js index 7830f42..8ee6a64 100644 --- a/src/data.js +++ b/src/data.js @@ -2750,11 +2750,16 @@ function loadSessionDetail(sessionId, project) { if (found.format === 'claude') { if (entry.type === 'user' || entry.type === 'assistant') { - const content = extractContent((entry.message || {}).content); + const rawContent = (entry.message || {}).content; + const content = extractContent(rawContent); if (content) { const msg = { role: entry.type, content: content.slice(0, 2000), uuid: entry.uuid || '' }; + if (entry.type === 'user') { + if (isFilteredClaudeStructuredMessage(content)) continue; + const structured = parseStructuredMessage('claude', entry.type, content, entry); + if (structured) msg.structured = structured; + } if (entry.type === 'assistant') { - const rawContent = (entry.message || {}).content; if (Array.isArray(rawContent)) { const tools = extractTools(rawContent); if (tools.length > 0) msg.tools = tools; @@ -2763,6 +2768,20 @@ function loadSessionDetail(sessionId, project) { messages.push(msg); } } + if (entry.type === 'queue-operation') { + const content = extractContent(entry.content); + if (content) { + const structured = parseStructuredMessage('claude', 'queue', content, entry); + if (structured) { + messages.push({ + role: 'queue', + content: content.slice(0, 2000), + uuid: entry.uuid || '', + structured: structured, + }); + } + } + } } else { // Codex format: response_item with payload if (entry.type === 'response_item' && entry.payload) { @@ -2772,7 +2791,7 @@ function loadSessionDetail(sessionId, project) { const content = extractContent(entry.payload.content); if (content && !isSystemMessage(content)) { const msg = { role: role, content: content.slice(0, 2000), uuid: '' }; - const structured = parseStructuredMessage('codex', role, content); + const structured = parseStructuredMessage('codex', role, content, entry); if (structured) msg.structured = structured; messages.push(msg); } @@ -3278,6 +3297,10 @@ function extractContent(raw) { return String(raw); } +const STRUCTURED_TAG_PATTERN = '[a-z_][a-z0-9_-]*'; +const STRUCTURED_WRAPPER_RE = new RegExp('^<(' + STRUCTURED_TAG_PATTERN + ')>\\s*([\\s\\S]*?)\\s*\\1>$', 'i'); +const STRUCTURED_FIELD_RE = new RegExp('<(' + STRUCTURED_TAG_PATTERN + ')>([\\s\\S]*?)\\1>', 'ig'); +const FILTERED_CLAUDE_STRUCTURED_TAGS = new Set(['local-command-caveat']); const CODEX_STRUCTURED_MESSAGE_FIELDS = { user_shell_command: [ { field: 'command', max_length: 0 }, @@ -3290,6 +3313,36 @@ const CODEX_STRUCTURED_MESSAGE_FIELDS = { ], }; +const CLAUDE_STRUCTURED_MESSAGE_FIELDS = { + slash_command: [ + { tag: 'command-name', field: 'command_name', max_length: 200 }, + { tag: 'command-message', field: 'command_message', max_length: 200 }, + { tag: 'command-args', field: 'command_args', max_length: 500, required: false }, + ], + bash_result: [ + { tag: 'bash-stdout', field: 'stdout', max_length: 1500, required: false }, + { tag: 'bash-stderr', field: 'stderr', max_length: 1500, required: false }, + ], + task_notification: [ + { tag: 'task-id', field: 'task_id', max_length: 120 }, + { tag: 'tool-use-id', field: 'tool_use_id', max_length: 120 }, + { tag: 'output-file', field: 'output_file', max_length: 500 }, + { tag: 'status', field: 'status', max_length: 40 }, + { tag: 'summary', field: 'summary', max_length: 300 }, + { tag: 'result', field: 'result', max_length: 1500, required: false }, + { tag: 'usage', field: 'usage', max_length: 0, required: false }, + ], + task_notification_monitor: [ + { tag: 'task-id', field: 'task_id', max_length: 120 }, + { tag: 'summary', field: 'summary', max_length: 300 }, + { tag: 'event', field: 'event', max_length: 1500 }, + ], + task_usage: [ + { tag: 'total_tokens', field: 'total_tokens', max_length: 40 }, + { tag: 'tool_uses', field: 'tool_uses', max_length: 40 }, + { tag: 'duration_ms', field: 'duration_ms', max_length: 40 }, + ], +}; function normalizeStructuredField(value) { return typeof value === 'string' ? value.trim() : ''; } @@ -3303,7 +3356,7 @@ function truncateStructuredField(value, maxLength) { function parseStructuredWrapper(content) { const trimmed = typeof content === 'string' ? content.trim() : ''; if (!trimmed) return null; - const match = trimmed.match(/^<([a-z_][a-z0-9_]*)>\s*([\s\S]*?)\s*<\/\1>$/i); + const match = trimmed.match(STRUCTURED_WRAPPER_RE); if (!match) return null; return { tag: match[1], body: match[2] }; } @@ -3312,8 +3365,11 @@ function parseStructuredFields(body, fieldDescriptors) { if (!body || !Array.isArray(fieldDescriptors) || fieldDescriptors.length === 0) return null; const fields = {}; - const allowedFields = new Set(fieldDescriptors.map(function(def) { return def.field; })); - const pattern = /<([a-z_][a-z0-9_]*)>([\s\S]*?)<\/\1>/ig; + const fieldsByTag = new Map(fieldDescriptors.map(function(def) { + return [def.tag || def.field, def]; + })); + // Clone the global regex so each parse starts with a clean lastIndex. + const pattern = new RegExp(STRUCTURED_FIELD_RE.source, STRUCTURED_FIELD_RE.flags); let cursor = 0; let match; @@ -3321,18 +3377,19 @@ function parseStructuredFields(body, fieldDescriptors) { if (body.slice(cursor, match.index).trim()) return null; const tag = match[1]; - if (!allowedFields.has(tag) || fields[tag] !== undefined) return null; + const def = fieldsByTag.get(tag); + if (!def || fields[def.field] !== undefined) return null; const value = normalizeStructuredField(match[2]); - if (!value) return null; - fields[tag] = value; + if (!value && def.required !== false) return null; + fields[def.field] = value; cursor = match.index + match[0].length; } if (body.slice(cursor).trim()) return null; - if (Object.keys(fields).length !== fieldDescriptors.length) return null; for (const def of fieldDescriptors) { + if (def.required === false) continue; if (!fields[def.field]) return null; } @@ -3347,6 +3404,15 @@ function applyStructuredFieldThresholds(fields, fieldDescriptors) { return result; } +function parseStructuredSingleTag(content, tag, fieldName, maxLength) { + const wrapped = parseStructuredWrapper(content); + if (!wrapped || wrapped.tag !== tag) return null; + const value = normalizeStructuredField(wrapped.body); + if (!value) return null; + const fields = {}; + fields[fieldName] = truncateStructuredField(value, maxLength || 0); + return fields; +} function parseCodexStructuredMessage(content) { const wrapped = parseStructuredWrapper(content); if (!wrapped) return null; @@ -3364,9 +3430,78 @@ function parseCodexStructuredMessage(content) { }; } -function parseStructuredMessage(agent, role, content) { - if (role !== 'user' || !content) return null; +function isFilteredClaudeStructuredMessage(content) { + const wrapped = parseStructuredWrapper(content); + return !!(wrapped && FILTERED_CLAUDE_STRUCTURED_TAGS.has(wrapped.tag)); +} + +function parseClaudeTaskNotification(content) { + const wrapped = parseStructuredWrapper(content); + if (!wrapped || wrapped.tag !== 'task-notification') return null; + + let fieldDescriptors = CLAUDE_STRUCTURED_MESSAGE_FIELDS.task_notification; + let fields = parseStructuredFields(wrapped.body, fieldDescriptors); + if (!fields) { + fieldDescriptors = CLAUDE_STRUCTURED_MESSAGE_FIELDS.task_notification_monitor; + fields = parseStructuredFields(wrapped.body, fieldDescriptors); + } + if (!fields) return null; + + const parsedFields = applyStructuredFieldThresholds(fields, fieldDescriptors); + if (parsedFields.usage) { + const usageFields = parseStructuredFields(parsedFields.usage, CLAUDE_STRUCTURED_MESSAGE_FIELDS.task_usage); + parsedFields.usage = usageFields + ? applyStructuredFieldThresholds(usageFields, CLAUDE_STRUCTURED_MESSAGE_FIELDS.task_usage) + : null; + } + + return { + agent: 'claude', + kind: 'task_notification', + fields: parsedFields, + }; +} + +function parseClaudeStructuredMessage(entry, content) { + if (!content) return null; + + const slashCommandFields = parseStructuredFields(content, CLAUDE_STRUCTURED_MESSAGE_FIELDS.slash_command); + if (slashCommandFields) { + return { + agent: 'claude', + kind: 'slash_command', + fields: applyStructuredFieldThresholds(slashCommandFields, CLAUDE_STRUCTURED_MESSAGE_FIELDS.slash_command), + }; + } + + const bashInputFields = parseStructuredSingleTag(content, 'bash-input', 'input', 0); + if (bashInputFields) { + return { agent: 'claude', kind: 'bash_input', fields: bashInputFields }; + } + + const bashResultFields = parseStructuredFields(content, CLAUDE_STRUCTURED_MESSAGE_FIELDS.bash_result); + if (bashResultFields) { + return { + agent: 'claude', + kind: 'bash_result', + fields: applyStructuredFieldThresholds(bashResultFields, CLAUDE_STRUCTURED_MESSAGE_FIELDS.bash_result), + }; + } + + const localCommandStdoutFields = parseStructuredSingleTag(content, 'local-command-stdout', 'output', 1500); + if (localCommandStdoutFields) { + return { agent: 'claude', kind: 'local_command_stdout', fields: localCommandStdoutFields }; + } + + return parseClaudeTaskNotification(content); +} + +function parseStructuredMessage(agent, role, content, entry) { + if (!content) return null; if (agent === 'codex') return parseCodexStructuredMessage(content); + if (agent === 'claude' && (role === 'user' || role === 'queue')) { + return parseClaudeStructuredMessage(entry, content); + } return null; } @@ -4951,5 +5086,11 @@ module.exports = { normalizeProjectPath, shortenHomePath, detectWindowsWslHomes, + parseStructuredWrapper, + parseStructuredFields, + parseClaudeTaskNotification, + parseClaudeStructuredMessage, + parseStructuredMessage, + isFilteredClaudeStructuredMessage, }, }; diff --git a/src/frontend/detail.js b/src/frontend/detail.js index 1e1065d..b763489 100644 --- a/src/frontend/detail.js +++ b/src/frontend/detail.js @@ -193,6 +193,11 @@ function closeDetail() { var structuredMessageRenderers = { 'codex:user_shell_command': renderCodexUserShellCommand, 'codex:user_action': renderCodexUserAction, + 'claude:slash_command': renderClaudeSlashCommand, + 'claude:bash_input': renderClaudeBashInput, + 'claude:bash_result': renderClaudeBashResult, + 'claude:local_command_stdout': renderClaudeLocalCommandStdout, + 'claude:task_notification': renderClaudeTaskNotification, }; function renderStructuredBlock(text) { @@ -203,6 +208,10 @@ function renderStructuredSection(label, text) { return '