/** * Poisoned-transcript repair for the Claude Agent SDK driver. * * The Anthropic Messages API can permanently reject a conversation once its * persisted history contains a malformed content block. Because the bad block * lives in the SDK's on-disk JSONL transcript, every replayed turn fails with * the same `400 invalid_request_error` and the session is bricked with no * in-band recovery. * * This module repairs the transcript in place, covering three known poison * classes: * * 1. **Image `media_type` mismatch / omission.** An image block whose declared * `media_type` does not match — or is missing for — the actual bytes: * `... The image was specified using the image/png media type, but the * image appears to be a image/jpeg image`. Produced by the Claude Code * `Read` tool's format fallback (anthropics/claude-code#55338, #30124, * #33179). Repaired by sniffing the base64 magic bytes and correcting * `media_type`. * 2. **Oversized image.** An image whose decoded payload exceeds the API's * 5 MB per-image limit (anthropics/claude-code#34566). Replaced with a * text stub — the byte budget cannot be recovered any other way. * 3. **`cache_control` on an empty text block.** A text block carrying a * `cache_control` marker with empty/whitespace `text` * (anthropics/claude-code#59626). The `cache_control` field is stripped. * * Genuinely unidentifiable image blocks are also replaced with a text stub. * * Recovering by content scan (rather than parsing the API's `messages.N` * index) is deliberate: the index addresses the assembled API request, not a * JSONL line, and reproducing the SDK's message-assembly is fragile. A full * scan is index-free and repairs every poisoned block in one pass. * * **Async and line-streaming since #454.** A Claude Code transcript is routinely * multi-megabyte (base64 images inline in `tool_result` blocks), and this runs on * a failed turn *and before every resume* — inside the single-threaded `skaile * serve` loop. The previous `readFileSync` + `writeFileSync` + `renameSync` held * the loop for the whole file and materialised it twice over as one string. Both * passes now stream line by line off a `createReadStream`, so the loop is yielded * between lines and peak memory is one JSONL line rather than the whole file, and * every I/O phase is deadline-bounded. * * Streaming was chosen over a worker thread: the work is I/O-bound rather than * CPU-bound (per-line `JSON.parse` on a yielded loop is not what stalls a turn), * and a worker would add a structured-clone of the same multi-MB payload plus a * thread lifecycle to a path that must never block a turn. * * @module */ /** * Minimal logger shape accepted by {@link scrubPoisonedTranscript}. Compatible * with the bridge `Logger`, but narrowed so the helper stays unit-testable * without a real logger instance. * * @category Transcript repair */ export interface ScrubLogger { info(message: string, data?: Record): void; warn(message: string, data?: Record): void; } /** * Options for {@link scrubPoisonedTranscript}. * * @category Transcript repair */ export interface ScrubTranscriptOptions { /** * Claude Code config directory — the parent of `projects/`. Resolve from * `CLAUDE_CONFIG_DIR`, falling back to `~/.claude`. */ configDir: string; /** SDK session id whose transcript JSONL should be repaired. */ sessionId: string; /** Optional logger for diagnostics. */ log?: ScrubLogger; } /** * Outcome of a {@link scrubPoisonedTranscript} pass. * * @category Transcript repair */ export interface ScrubTranscriptResult { /** The located JSONL transcript path, or `null` when not found. */ filePath: string | null; /** Image blocks whose `media_type` was corrected (or filled in) to match the bytes. */ corrected: number; /** * Image blocks replaced with a text stub — either unidentifiable bytes or a * payload over the API's 5 MB per-image limit. */ stubbed: number; /** Empty text blocks from which a rejected `cache_control` marker was stripped. */ cacheStripped: number; /** * `true` when the transcript was located, contained at least one poisoned * block, and was rewritten. `false` means nothing needed repair (or the * file was not found) — the caller should not retry the resume. */ changed: boolean; } /** * Identify an image's media type from the leading bytes of its base64 payload. * * Recognises the four formats the Anthropic API accepts. Returns `null` when * the magic bytes match no known format — the caller treats that as a * genuinely corrupt block and stubs it. * * @param base64 - Base64-encoded image data (only the prefix is inspected). * @returns An `image/*` media type, or `null` when unrecognised. * @category Transcript repair */ export declare function sniffImageMediaType(base64: string): string | null; /** * Locate the JSONL transcript for `sessionId` under `/projects/`. * * Claude Code encodes the project directory into the `projects/` subfolder * name; rather than reproduce that encoding, this scans every subfolder for * `.jsonl` (the session id is a UUID — globally unique). * * Async and deadline-bounded since #454. The `existsSync(projectsDir)` probe is * gone: `readdir` already fails on a missing directory, so the probe was a * second stat on the same path for no information. * * `timeoutMs` defaults to {@link fsDeadlineMs} so callers that only need the * path (the resume probe) can omit it. * * @category Transcript repair */ export declare function locateTranscriptPath(configDir: string, sessionId: string, timeoutMs?: number): Promise; /** * Scan a Claude Code SDK transcript for image content blocks whose declared * `media_type` does not match the actual bytes, repair them, and atomically * rewrite the file. * * Safe to call after a turn has failed: the SDK query is dead at that point, * and the next resume re-reads the transcript from disk. * * **Async since #454.** Two streaming passes rather than one whole-file read: * pass 1 counts repairs without writing anything, and pass 2 runs only when * pass 1 found something. The common case — the preventive scrub of a clean * transcript before every resume — is therefore one streaming read and zero * writes, and a clean transcript is still left byte-for-byte untouched. * * @param opts - Config directory, session id, and an optional logger. * @returns A {@link ScrubTranscriptResult}. When `changed` is `true` the * caller may retry the resume with the same session id. * @category Transcript repair */ export declare function scrubPoisonedTranscript(opts: ScrubTranscriptOptions): Promise; //# sourceMappingURL=scrub-transcript.d.ts.map