From 31a33356514d96a0d4bf3d97fb0de1b4b486b214 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 03:26:16 +0100 Subject: [PATCH] fix(sidecar): sanitize JSONL session files before resume (strips empty text blocks with cache_control) --- sidecar/claude-runner.ts | 111 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/sidecar/claude-runner.ts b/sidecar/claude-runner.ts index 01250b4..43b5a6b 100644 --- a/sidecar/claude-runner.ts +++ b/sidecar/claude-runner.ts @@ -8,7 +8,8 @@ import { execSync } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; -import { query, type Query } from '@anthropic-ai/claude-agent-sdk'; +import { query, listSessions, type Query } from '@anthropic-ai/claude-agent-sdk'; +import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs'; const rl = createInterface({ input: stdin }); @@ -121,12 +122,17 @@ async function handleQuery(msg: QueryMessage) { if (resumeMode === 'continue') { continueOpt = true; log(`Session ${sessionId}: continuing most recent session`); + // Sanitize the most recent session file before SDK reads it + sanitizeSessionFiles(cwd || process.cwd()); } else if (resumeMode === 'resume' && resumeSessionId) { resumeOpt = resumeSessionId; log(`Session ${sessionId}: resuming SDK session ${resumeSessionId}`); + // Sanitize the specific session file + sanitizeSessionFile(cwd || process.cwd(), resumeSessionId); } else if (resumeSessionId && !resumeMode) { // Legacy: direct resumeSessionId without resumeMode resumeOpt = resumeSessionId; + sanitizeSessionFile(cwd || process.cwd(), resumeSessionId); } const q = query({ @@ -238,5 +244,108 @@ if (claudePath) { log('WARNING: Claude CLI not found — agent sessions will fail'); } +// ── Session sanitizer ────────────────────────────────────────────────────── +// Fixes "cache_control cannot be set for empty text blocks" API error on resume. +// The SDK stores empty text blocks with cache_control during streaming; +// the API rejects them on replay. We strip them before the SDK reads the file. + +function encodeCwd(cwdPath: string): string { + return cwdPath.replace(/[^a-zA-Z0-9]/g, '-'); +} + +function sanitizeSessionFile(cwdPath: string, sdkSessionId: string): void { + const encoded = encodeCwd(cwdPath); + const filePath = join(homedir(), '.claude', 'projects', encoded, `${sdkSessionId}.jsonl`); + try { + sanitizeJsonlFile(filePath); + } catch (err) { + log(`sanitize: could not clean ${filePath}: ${err}`); + } +} + +function sanitizeSessionFiles(cwdPath: string): void { + // For 'continue' mode, sanitize the most recent session + try { + const sessions = listSessions({ dir: cwdPath, limit: 1 }); + if (sessions && sessions.length > 0) { + sanitizeSessionFile(cwdPath, (sessions[0] as Record).sessionId as string); + } + } catch (err) { + log(`sanitize: listSessions failed: ${err}`); + // Fallback: try to find and sanitize the most recent .jsonl file + const encoded = encodeCwd(cwdPath); + const dir = join(homedir(), '.claude', 'projects', encoded); + try { + const files = readdirSync(dir).filter(f => f.endsWith('.jsonl')); + if (files.length > 0) { + // Sort by mtime descending + files.sort((a, b) => { + try { + return statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs; + } catch { return 0; } + }); + sanitizeJsonlFile(join(dir, files[0])); + } + } catch { /* dir doesn't exist */ } + } +} + +function sanitizeJsonlFile(filePath: string): void { + if (!existsSync(filePath)) return; + + const content = readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + let modified = false; + + const cleaned = lines.map(line => { + if (!line.trim()) return line; + try { + const obj = JSON.parse(line); + if (cleanContentBlocks(obj)) { + modified = true; + return JSON.stringify(obj); + } + } catch { /* skip unparseable lines */ } + return line; + }); + + if (modified) { + writeFileSync(filePath, cleaned.join('\n')); + log(`sanitize: cleaned empty text blocks from ${filePath}`); + } +} + +function cleanContentBlocks(obj: Record): boolean { + let changed = false; + + // Check message.content array + const msg = obj.message as Record | undefined; + if (msg?.content && Array.isArray(msg.content)) { + const before = msg.content.length; + msg.content = (msg.content as Array>).filter(block => { + // Remove empty text blocks (with or without cache_control) + if (block.type === 'text' && (!block.text || !(block.text as string).trim())) { + return false; + } + return true; + }); + if (msg.content.length !== before) changed = true; + } + + // Also check top-level content array (some formats) + if (obj.content && Array.isArray(obj.content)) { + const before = (obj.content as unknown[]).length; + obj.content = (obj.content as Array>).filter(block => { + if (block.type === 'text' && (!block.text || !(block.text as string).trim())) { + return false; + } + return true; + }); + if ((obj.content as unknown[]).length !== before) changed = true; + } + + return changed; +} + log('Sidecar started'); send({ type: 'ready' });