diff --git a/src/nativeInstallation.ts b/src/nativeInstallation.ts index 2ee3eb0f..f50c2ddd 100644 --- a/src/nativeInstallation.ts +++ b/src/nativeInstallation.ts @@ -210,6 +210,47 @@ function getStringPointerContent( ); } +/** + * Bun CJS wrapper that wraps module contents in newer native binaries. + * The wrapper is: `// @bun @bytecode @bun-cjs\n(function(...) {` ... `})`. + * Patches expect raw JS without this wrapper. + */ +const BUN_CJS_PREFIX = + '// @bun @bytecode @bun-cjs\n(function(exports, require, module, __filename, __dirname) {'; +const BUN_CJS_SUFFIX = '})\n'; + +/** + * Strip the Bun CJS wrapper from module contents if present. + * Returns the unwrapped JS and a flag indicating if stripping occurred. + */ +function stripBunCjsWrapper(content: Buffer): { + stripped: Buffer; + hadWrapper: boolean; +} { + const str = content.toString('utf-8'); + if (str.startsWith(BUN_CJS_PREFIX) && str.endsWith(BUN_CJS_SUFFIX)) { + const inner = str.slice( + BUN_CJS_PREFIX.length, + str.length - BUN_CJS_SUFFIX.length + ); + debug( + `stripBunCjsWrapper: stripped CJS wrapper (${content.length} -> ${inner.length} bytes)` + ); + return { stripped: Buffer.from(inner, 'utf-8'), hadWrapper: true }; + } + return { stripped: content, hadWrapper: false }; +} + +/** + * Re-wrap JS content with the Bun CJS wrapper. + */ +function addBunCjsWrapper(content: Buffer): Buffer { + return Buffer.from( + BUN_CJS_PREFIX + content.toString('utf-8') + BUN_CJS_SUFFIX, + 'utf-8' + ); +} + function parseStringPointer(buffer: Buffer, offset: number): StringPointer { return { offset: buffer.readUInt32LE(offset), @@ -439,23 +480,22 @@ function extractBunDataFromSection(sectionData: Buffer): BunData { let headerSize: number; let bunDataSize: number; - // Check which format matches the section length (allowing for padding up to 4KB) - if ( - sectionData.length >= 8 && - expectedLengthU64 <= sectionData.length && - expectedLengthU64 >= sectionData.length - 4096 - ) { - // u64 format matches + const isValidPayload = (hdrSize: number, dataSize: number): boolean => { + if (dataSize <= 0 || hdrSize + dataSize > sectionData.length) return false; + if (dataSize < BUN_TRAILER.length) return false; + const trailerOffset = hdrSize + dataSize - BUN_TRAILER.length; + return sectionData + .subarray(trailerOffset, trailerOffset + BUN_TRAILER.length) + .equals(BUN_TRAILER); + }; + + if (sectionData.length >= 8 && isValidPayload(8, bunDataSizeU64)) { headerSize = 8; bunDataSize = bunDataSizeU64; debug( `extractBunDataFromSection: detected u64 header format (Bun >= 1.3.4)` ); - } else if ( - expectedLengthU32 <= sectionData.length && - expectedLengthU32 >= sectionData.length - 4096 - ) { - // u32 format matches + } else if (isValidPayload(4, bunDataSizeU32)) { headerSize = 4; bunDataSize = bunDataSizeU32; debug( @@ -727,10 +767,10 @@ export function extractClaudeJsFromNativeInstallation( // - Windows: B:/~BUN/root/claude.exe if (!isClaudeModule(moduleName)) return undefined; - const moduleContents = getStringPointerContent( - bunData, - module.contents - ); + const rawContents = getStringPointerContent(bunData, module.contents); + + // Strip Bun CJS wrapper if present (Bun >= 1.3.x native binaries) + const { stripped: moduleContents } = stripBunCjsWrapper(rawContents); debug( `extractClaudeJsFromNativeInstallation: Found claude module, contents length=${moduleContents.length}` @@ -759,11 +799,43 @@ export function extractClaudeJsFromNativeInstallation( } } +/** + * Calculate the total bun data blob size without allocating the buffer. + * This mirrors the layout logic in rebuildBunData phases 2-3. + * + * Layout: [strings with null terminators][modules list][compileExecArgv + null][OFFSETS][TRAILER] + */ +function calculateBunDataSize( + stringsData: Buffer[], + moduleCount: number, + moduleStructSize: number, + bunOffsets: BunOffsets +): number { + let size = 0; + + // Strings with null terminators + for (const s of stringsData) { + size += s.length + 1; + } + + // Module structures + size += moduleCount * moduleStructSize; + + // compileExecArgv (we just need its length from the original offsets) + size += bunOffsets.compileExecArgvPtr.length + 1; + + // Offsets struct + trailer + size += SIZEOF_OFFSETS + BUN_TRAILER.length; + + return size; +} + function rebuildBunData( bunData: Buffer, bunOffsets: BunOffsets, modifiedClaudeJs: Buffer | null, - moduleStructSize: number + moduleStructSize: number, + sectionSizeBudget?: number ): Buffer { // Phase 1: Collect all string data const stringsData: Buffer[] = []; @@ -787,7 +859,16 @@ function rebuildBunData( // Check if this is claude.js and we have modified contents let contentsBytes: Buffer; if (modifiedClaudeJs && isClaudeModule(moduleName)) { - contentsBytes = modifiedClaudeJs; + // Check if the original had a CJS wrapper — if so, re-wrap the modified JS + const originalContents = getStringPointerContent( + bunData, + module.contents + ); + const originalHadWrapper = + stripBunCjsWrapper(originalContents).hadWrapper; + contentsBytes = originalHadWrapper + ? addBunCjsWrapper(modifiedClaudeJs) + : modifiedClaudeJs; } else { contentsBytes = getStringPointerContent(bunData, module.contents); } @@ -829,6 +910,64 @@ function rebuildBunData( }); const stringsPerModule = moduleStructSize === SIZEOF_MODULE_NEW ? 6 : 4; + const EMPTY = Buffer.alloc(0); + + // Phase 1.5: If we have a size budget, do a trial layout to check fit. + // If the full data exceeds the budget, truncate expendable fields + // (sourcemaps, bytecodes, moduleInfo, bytecodeOriginPath) to make room. + // These are non-essential: sourcemaps only affect error stack traces, + // bytecodes are re-generated at runtime, and the others are metadata. + if (sectionSizeBudget !== undefined) { + const trialSize = calculateBunDataSize( + stringsData, + modulesMetadata.length, + moduleStructSize, + bunOffsets + ); + if (trialSize > sectionSizeBudget) { + debug( + `rebuildBunData: trial size ${trialSize} exceeds budget ${sectionSizeBudget}, ` + + `truncating sourcemaps/bytecodes/moduleInfo/bytecodeOriginPath to fit` + ); + + // Zero out expendable fields in both stringsData and modulesMetadata + for (let i = 0; i < modulesMetadata.length; i++) { + const baseIdx = i * stringsPerModule; + // String layout per module: + // [0]=name, [1]=contents, [2]=sourcemap, [3]=bytecode + // For new format: [4]=moduleInfo, [5]=bytecodeOriginPath + stringsData[baseIdx + 2] = EMPTY; // sourcemap + stringsData[baseIdx + 3] = EMPTY; // bytecode + modulesMetadata[i].sourcemap = EMPTY; + modulesMetadata[i].bytecode = EMPTY; + + if (moduleStructSize === SIZEOF_MODULE_NEW) { + stringsData[baseIdx + 4] = EMPTY; // moduleInfo + stringsData[baseIdx + 5] = EMPTY; // bytecodeOriginPath + modulesMetadata[i].moduleInfo = EMPTY; + modulesMetadata[i].bytecodeOriginPath = EMPTY; + } + } + + const truncatedSize = calculateBunDataSize( + stringsData, + modulesMetadata.length, + moduleStructSize, + bunOffsets + ); + debug( + `rebuildBunData: size after truncation: ${truncatedSize} (budget: ${sectionSizeBudget})` + ); + + if (truncatedSize > sectionSizeBudget) { + throw new Error( + `Even after truncating sourcemaps/bytecodes, rebuilt data (${truncatedSize} bytes) ` + + `still exceeds section budget (${sectionSizeBudget} bytes). ` + + `The patched JS content is too large to fit.` + ); + } + } + } // Phase 2: Calculate buffer layout let currentOffset = 0; @@ -1199,12 +1338,6 @@ function repackPE( } } -/** - * Alignment constant used by BUN_COMPILED in c-bindings.cpp. - * The BUN_COMPILED symbol is placed with __attribute__((aligned(BLOB_HEADER_ALIGNMENT))). - */ -const BLOB_HEADER_ALIGNMENT = 16384; - /** * Repack an ELF binary that uses the new .bun section format (post-PR#26923). * @@ -1214,10 +1347,14 @@ const BLOB_HEADER_ALIGNMENT = 16384; * (located at its original position in the RW data segment). At runtime, the * Bun runtime reads BUN_COMPILED.size as a vaddr pointer to the mapped data. * - * On repack we need to: - * 1. Set the .bun section content (LIEF handles file layout) - * 2. Update the PT_LOAD segment's fileSize/virtualSize to cover the new data - * 3. Patch BUN_COMPILED.size with the (possibly unchanged) vaddr + * Uses direct file I/O instead of LIEF's binary.write() to avoid std::bad_alloc + * errors on large ELF binaries (~228MB). LIEF is used only to parse the binary + * structure and locate the .bun section's file offset. The new section data is + * written directly at that offset, which is safe because: + * - The rebuilt bun data blob is the same size (patches preserve content length + * via the CJS wrapper round-trip) + * - No ELF structural changes are needed (section/segment headers stay the same) + * - The BUN_COMPILED vaddr pointer doesn't change */ function repackELFSection( elfBinary: LIEF.ELF.Binary, @@ -1233,109 +1370,103 @@ function repackELFSection( } const newSectionData = buildSectionData(newBunBuffer, sectionHeaderSize); + const originalSectionSize = Number(bunSection.size); + const sectionFileOffset = Number(bunSection.offset); - debug(`repackELFSection: Original section size: ${bunSection.size}`); + debug(`repackELFSection: Original section size: ${originalSectionSize}`); debug(`repackELFSection: New section data size: ${newSectionData.length}`); - - // Find the .bun PT_LOAD segment (read-only, vaddr matches .bun section) - const bunSectionVaddr = bunSection.virtualAddress; - const segments = elfBinary.segments(); - const bunSegment = segments.find( - s => - s.type === 'LOAD' && - s.flags === 4 && // PF_R - s.virtualAddress === bunSectionVaddr + debug( + `repackELFSection: Section file offset: 0x${sectionFileOffset.toString(16)}` ); - if (!bunSegment) { + if (newSectionData.length > originalSectionSize) { throw new Error( - `.bun PT_LOAD segment not found (looking for LOAD with vaddr=0x${bunSectionVaddr.toString(16)})` + `New .bun section data (${newSectionData.length} bytes) exceeds original section ` + + `(${originalSectionSize} bytes). Cannot grow ELF sections with direct write. ` + + `Ensure patches do not change the overall content size.` ); } - debug( - `repackELFSection: Found .bun segment: vaddr=0x${bunSegment.virtualAddress.toString(16)}, ` + - `filesz=0x${bunSegment.fileSize.toString(16)}, memsz=0x${bunSegment.virtualSize.toString(16)}` - ); + // Build a buffer that is exactly the original section size. + // If the new data is smaller, zero-pad the remainder. The Bun runtime + // reads the payload length from the u64 header, so trailing zeros are + // ignored. + let sectionBuffer: Buffer; + if (newSectionData.length === originalSectionSize) { + sectionBuffer = newSectionData; + } else { + debug( + `repackELFSection: Padding new data from ${newSectionData.length} to ${originalSectionSize} bytes` + ); + sectionBuffer = Buffer.alloc(originalSectionSize, 0); + newSectionData.copy(sectionBuffer, 0); + } - // Find the original BUN_COMPILED location by searching for the .bun section's - // vaddr value at BLOB_HEADER_ALIGNMENT-aligned virtual addresses in the RW - // LOAD segment. writeBunSection() wrote the vaddr at the ORIGINAL .bun section - // location (where BUN_COMPILED lives in the base binary's data segment), then - // relocated the section header to point to the appended data. - const vaddrBytes = Buffer.alloc(8); - vaddrBytes.writeBigUInt64LE(bunSectionVaddr); + // Write the section data directly to the file at the section's file offset. + // This bypasses LIEF's ELF builder which fails with std::bad_alloc on large + // binaries. We use atomic copy-then-write to avoid corrupting the binary. + const tempPath = outputPath + '.tmp'; + let tempCreated = false; - let bunCompiledVaddr: bigint | null = null; + try { + debug( + `repackELFSection: Copying ${binPath} to ${tempPath} for atomic write...` + ); + fs.copyFileSync(binPath, tempPath); + tempCreated = true; + + const fd = fs.openSync(tempPath, 'r+'); + try { + const bytesWritten = fs.writeSync( + fd, + sectionBuffer, + 0, + sectionBuffer.length, + sectionFileOffset + ); + debug( + `repackELFSection: Wrote ${bytesWritten} bytes at offset 0x${sectionFileOffset.toString(16)}` + ); - // Find the RW LOAD segment (flags include W=2) - const rwSegment = segments.find( - s => s.type === 'LOAD' && (s.flags & 2) !== 0 - ); + if (bytesWritten !== sectionBuffer.length) { + throw new Error( + `Short write: expected ${sectionBuffer.length} bytes, wrote ${bytesWritten}` + ); + } + } finally { + fs.closeSync(fd); + } - if (rwSegment) { - const rwContent = rwSegment.content; - const rwVaddrStart = Number(rwSegment.virtualAddress); + const origStat = fs.statSync(binPath); + fs.chmodSync(tempPath, origStat.mode); - // Search at BLOB_HEADER_ALIGNMENT-aligned virtual addresses - const firstAligned = - Math.ceil(rwVaddrStart / BLOB_HEADER_ALIGNMENT) * BLOB_HEADER_ALIGNMENT; + fs.renameSync(tempPath, outputPath); + tempCreated = false; - for ( - let va = firstAligned; - va < rwVaddrStart + rwContent.length - 8; - va += BLOB_HEADER_ALIGNMENT + debug('repackELFSection: Write completed successfully'); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error.code === 'ETXTBSY' || + error.code === 'EBUSY' || + error.code === 'EPERM') ) { - const off = va - rwVaddrStart; - if (rwContent.subarray(off, off + 8).equals(vaddrBytes)) { - bunCompiledVaddr = BigInt(va); - debug( - `repackELFSection: BUN_COMPILED at vaddr 0x${bunCompiledVaddr.toString(16)} ` + - `(offset ${off} in RW segment)` - ); - break; + throw new Error( + 'Cannot update the Claude executable while it is running.\n' + + 'Please close all Claude instances and try again.' + ); + } + throw error; + } finally { + if (tempCreated) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors } } } - - if (bunCompiledVaddr === null) { - throw new Error( - 'Could not find original BUN_COMPILED location in binary' - ); - } - - // Set the new section content - bunSection.content = newSectionData; - - // Update the PT_LOAD segment sizes to cover the new content. - // LIEF's ELF Builder handles the actual file layout, but we need to tell - // the segment how big the mapped region should be. - const pageSize = Number(bunSegment.alignment); - const alignedSize = BigInt( - Math.ceil(newSectionData.length / pageSize) * pageSize - ); - bunSegment.fileSize = alignedSize; - bunSegment.virtualSize = alignedSize; - - debug( - `repackELFSection: Updated segment: filesz=0x${bunSegment.fileSize.toString(16)}, ` + - `memsz=0x${bunSegment.virtualSize.toString(16)}` - ); - - // Patch BUN_COMPILED.size to point to the .bun section vaddr. - // The vaddr doesn't change (LIEF keeps the section at the same virtual address), - // so we just re-write the same value to ensure it's correct. - const vaddrPatch = Buffer.alloc(8); - vaddrPatch.writeBigUInt64LE(bunSectionVaddr); - elfBinary.patchAddress(bunCompiledVaddr, vaddrPatch); - - debug( - `repackELFSection: Patched BUN_COMPILED at vaddr 0x${bunCompiledVaddr.toString(16)} -> 0x${bunSectionVaddr.toString(16)}` - ); - - // Write the modified binary - atomicWriteBinary(elfBinary, outputPath, binPath); - debug('repackELFSection: Write completed successfully'); } catch (error) { console.error('repackELFSection failed:', error); throw error; @@ -1400,11 +1531,31 @@ export function repackNativeInstallation( // Extract Bun data and rebuild with modified claude.js const { bunOffsets, bunData, sectionHeaderSize, moduleStructSize } = getBunData(binary); + + // For the ELF .bun section format, we use direct file I/O and cannot grow the + // section. Compute a size budget so rebuildBunData() can truncate expendable + // fields (sourcemaps, bytecodes) when the patched JS is larger than the original. + let sectionSizeBudget: number | undefined; + if (binary.format === 'ELF' && sectionHeaderSize) { + const elfBinary = binary as LIEF.ELF.Binary; + const bunSection = elfBinary.getSection('.bun'); + if (bunSection) { + // The section holds [u64/u32 header][bun data blob]. + // The bun data blob must fit in (sectionSize - headerSize). + sectionSizeBudget = Number(bunSection.size) - sectionHeaderSize; + debug( + `repackNativeInstallation: ELF .bun section budget = ${sectionSizeBudget} bytes ` + + `(section=${Number(bunSection.size)}, header=${sectionHeaderSize})` + ); + } + } + const newBuffer = rebuildBunData( bunData, bunOffsets, modifiedClaudeJs, - moduleStructSize + moduleStructSize, + sectionSizeBudget ); switch (binary.format) { diff --git a/src/patches/agentsMd.ts b/src/patches/agentsMd.ts index dc1f8e50..d134d640 100644 --- a/src/patches/agentsMd.ts +++ b/src/patches/agentsMd.ts @@ -1,56 +1,38 @@ // Please see the note about writing patches in ./index -import { showDiff } from './index'; +import { escapeIdent, showDiff } from './index'; /** * Patches the CLAUDE.md file reading function to also check for alternative * filenames (e.g., AGENTS.md) when CLAUDE.md doesn't exist. * - * This finds the function that reads CLAUDE.md files and modifies it to: - * 1. Add a `didReroute` parameter to the function - * 2. At the early `return null` (when the file doesn't exist), check if the - * path ends with CLAUDE.md and try alternative names (unless didReroute - * is true) - * 3. Recursive calls pass didReroute=true to avoid infinite loops + * Supports two code shapes: * - * CC 2.1.62 (approx. by Claude): - * ```diff - * -function _t7(A, q) { - * +function _t7(A, q, didReroute) { - * try { - * let K = x1(); - * - if (!K.existsSync(A) || !K.statSync(A).isFile()) return null; - * + if (!K.existsSync(A) || !K.statSync(A).isFile()) { - * + if (!didReroute && (A.endsWith("/CLAUDE.md") || A.endsWith("\\CLAUDE.md"))) { - * + for (let alt of ["AGENTS.md", "GEMINI.md", "QWEN.md"]) { - * + let altPath = A.slice(0, -9) + alt; - * + if (K.existsSync(altPath) && K.statSync(altPath).isFile()) - * + return _t7(altPath, q, true); - * + } - * + } - * + return null; - * + } - * let Y = UL9(A).toLowerCase(); - * if (Y && !dL9.has(Y)) - * return (I(`Skipping non-text file in @include: ${A}`), null); - * let z = K.readFileSync(A, { encoding: "utf-8" }), - * { content: w, paths: H } = cL9(z); - * return { path: A, type: q, content: w, globs: H }; - * } catch (K) { - * if (K instanceof Error && K.message.includes("EACCES")) - * n("tengu_claude_md_permission_error", { - * is_access_error: 1, - * has_home_dir: A.includes(_8()) ? 1 : 0, - * }); - * } - * return null; - * } - * ``` + * **Sync (CC ≤ 2.1.84):** A single function reads, checks existence, and + * processes the file. We add a `didReroute` parameter and inject the fallback + * at the early `return null`. + * + * **Async (CC ≥ 2.1.85):** The function was split into three: + * - content processor (has "Skipping non-text file" but no fs ops) + * - async reader (calls `readFile`, then the content processor) + * - error handler (ENOENT / EISDIR / EACCES) + * We patch the *async reader* instead: add `didReroute` and inject the + * fallback in its `catch` block. */ export const writeAgentsMd = ( file: string, altNames: string[] ): string | null => { + // CC ≥ 2.1.87 ships with native AGENTS.md / alternative MD file support. + // Detect the fallback loop: endsWith("...CLAUDE.md")...for(let ... of [ + if (/CLAUDE\.md.{0,100}for\(let \w+ of \["AGENTS\.md"/.test(file)) { + console.log( + 'patch: agentsMd: alternative MD file support already present natively — skipping' + ); + return file; + } + + // Step 1: Locate the content-processing function via the "Skipping" anchor. const funcPattern = /(function ([$\w]+)\(([$\w]+),([^)]+?))\)(?:.|\n){0,500}Skipping non-text file in @include/; @@ -59,29 +41,48 @@ export const writeAgentsMd = ( console.error('patch: agentsMd: failed to find CLAUDE.md reading function'); return null; } + + // Step 2: Decide which code shape we're dealing with. + const fsPattern = /([$\w]+(?:\(\))?)\.(?:readFileSync|existsSync|statSync)/; + const fsMatch = funcMatch[0].match(fsPattern); + + if (fsMatch) { + // Sync single-function pattern (CC ≤ 2.1.84) + return writeAgentsMdSync( + file, + funcMatch as RegExpMatchArray & { index: number }, + fsMatch[1], + altNames + ); + } + + // Async split-function pattern (CC ≥ 2.1.85) + return writeAgentsMdAsync(file, funcMatch[2], altNames); +}; + +// ─── Sync strategy (unchanged logic, extracted) ────────────────────────────── + +const writeAgentsMdSync = ( + file: string, + funcMatch: RegExpMatchArray & { index: number }, + fsExpr: string, + altNames: string[] +): string | null => { const upToFuncParamsClosingParen = funcMatch[1]; const functionName = funcMatch[2]; const firstParam = funcMatch[3]; const restParams = funcMatch[4]; const funcStart = funcMatch.index; - const fsPattern = /([$\w]+(?:\(\))?)\.(?:readFileSync|existsSync|statSync)/; - const fsMatch = funcMatch[0].match(fsPattern); - if (!fsMatch) { - console.error('patch: agentsMd: failed to find fs expression in function'); - return null; - } - const fsExpr = fsMatch[1]; - const altNamesJson = JSON.stringify(altNames); - // Step 1: Add didReroute parameter to function signature + // Add didReroute parameter to function signature const sigIndex = funcStart + upToFuncParamsClosingParen.length; let newFile = file.slice(0, sigIndex) + ',didReroute' + file.slice(sigIndex); showDiff(file, newFile, ',didReroute', sigIndex, sigIndex); - // Step 2: Inject fallback at the early return null (when file doesn't exist) + // Inject fallback at the early return null (when file doesn't exist) const funcBody = newFile.slice(funcStart); // CC ≤2.1.62: existsSync/isFile check before reading @@ -119,3 +120,82 @@ export const writeAgentsMd = ( return newFile; }; + +// ─── Async strategy (CC ≥ 2.1.85) ─────────────────────────────────────────── + +const writeAgentsMdAsync = ( + file: string, + contentProcessorName: string, + altNames: string[] +): string | null => { + // Find the async reader function: + // async function Fb8(H,$,q){ + // try{ let _=await FS.readFile(H,...); return CONTENT_PROC(_,...) } + // catch(K){ return ERR_HANDLER(K,H),{info:null,includePaths:[]} } + // } + const readerPattern = new RegExp( + `(async function ([$\\w]+)\\(([$\\w]+),([^)]+))\\)\\{try\\{` + + `[^}]{0,200}\\.readFile\\(\\3,.{0,100}${escapeIdent(contentProcessorName)}\\(` + ); + const readerMatch = file.match(readerPattern); + if (!readerMatch || readerMatch.index === undefined) { + console.error( + 'patch: agentsMd: failed to find async CLAUDE.md reader function' + ); + return null; + } + + const readerSig = readerMatch[1]; // e.g. "async function Fb8(H,$,q" + const readerFuncName = readerMatch[2]; // e.g. "Fb8" + const pathParam = readerMatch[3]; // e.g. "H" + const restParams = readerMatch[4]; // e.g. "$,q" + + const altNamesJson = JSON.stringify(altNames); + + // Step 1: Add didReroute parameter to the reader's signature. + const sigIndex = readerMatch.index + readerSig.length; + let newFile = file.slice(0, sigIndex) + ',didReroute' + file.slice(sigIndex); + + showDiff(file, newFile, ',didReroute', sigIndex, sigIndex); + + // Step 2: Replace the catch block's return statement with fallback logic. + // Before: return al4(K,H),{info:null,includePaths:[]} + // After: al4(K,H); if(!didReroute && ...) { try alts } return{info:null,...} + const catchReturnPattern = new RegExp( + `return ([$\\w]+)\\(([$\\w]+),${escapeIdent(pathParam)}\\),\\{info:null,includePaths:\\[\\]\\}` + ); + + // Search in the vicinity of the async function (in the already-modified file). + const searchStart = readerMatch.index; + const searchSlice = newFile.slice(searchStart, searchStart + 1000); + const catchMatch = searchSlice.match(catchReturnPattern); + + if (!catchMatch || catchMatch.index === undefined) { + console.error( + 'patch: agentsMd: failed to find catch return in async reader' + ); + return null; + } + + const errorHandlerName = catchMatch[1]; // e.g. "al4" + const catchVar = catchMatch[2]; // e.g. "K" + + const replacement = + `${errorHandlerName}(${catchVar},${pathParam});` + + `if(!didReroute&&(${pathParam}.endsWith("/CLAUDE.md")||${pathParam}.endsWith("\\\\CLAUDE.md"))){` + + `for(let alt of ${altNamesJson}){let altPath=${pathParam}.slice(0,-9)+alt;` + + `let r=await ${readerFuncName}(altPath,${restParams},true);if(r.info)return r}}` + + `return{info:null,includePaths:[]}`; + + const catchReturnStart = searchStart + catchMatch.index; + const catchReturnEnd = catchReturnStart + catchMatch[0].length; + + newFile = + newFile.slice(0, catchReturnStart) + + replacement + + newFile.slice(catchReturnEnd); + + showDiff(file, newFile, replacement, catchReturnStart, catchReturnEnd); + + return newFile; +}; diff --git a/src/patches/fixLspSupport.ts b/src/patches/fixLspSupport.ts index 7e9f9d72..f4605bea 100644 --- a/src/patches/fixLspSupport.ts +++ b/src/patches/fixLspSupport.ts @@ -95,6 +95,18 @@ const getOpenDocumentLocation = (oldFile: string): LocationResult | null => { }; export const writeFixLspSupport = (oldFile: string): string | null => { + // CC ≥ 2.1.87 ships with native LSP didOpen support and removed the + // validation throws — skip if the feature is already present. + if ( + oldFile.includes('textDocument/didOpen') && + !oldFile.includes('restartOnCrash is not yet implemented') + ) { + console.log( + 'patch: fixLspSupport: LSP fixes already present natively — skipping' + ); + return oldFile; + } + // Patch 1: Comment out the validation by replacing with nothing const validationPattern1 = /if\([$\w]+\.restartOnCrash!==void 0\)throw Error\(`LSP server '\$\{[$\w]+\}': restartOnCrash is not yet implemented\. Remove this field from the configuration\.`\);/g; diff --git a/src/patches/helpers.ts b/src/patches/helpers.ts index 94e2fbe1..15f4c281 100644 --- a/src/patches/helpers.ts +++ b/src/patches/helpers.ts @@ -296,7 +296,7 @@ export const findTextComponent = (fileContents: string): string | undefined => { // The minified Text component has this signature: // function X({color:A,backgroundColor:B,dimColor:C=!1,bold:D=!1,...}) const textComponentPattern = - /\bfunction ([$\w]+).{0,20}color:[$\w]+,backgroundColor:[$\w]+,dimColor:[$\w]+(?:=![01])?,bold:[$\w]+(?:=![01])?/; + /\bfunction ([$\w]+).{0,80}color:[$\w]+,backgroundColor:[$\w]+,dimColor:[$\w]+(?:=![01])?,bold:[$\w]+(?:=![01])?/; const match = fileContents.match(textComponentPattern); if (!match) { console.log('patch: findTextComponent: failed to find text component'); @@ -311,7 +311,7 @@ export const findTextComponent = (fileContents: string): string | undefined => { export const findBoxComponent = (fileContents: string): string | undefined => { // Method 1: Find Box by ink-box createElement with local variable (CC ~2.0.x) const inkBoxPattern = - /function ([$\w]+)\(.{0,2000}[^$\w]([$\w]+)=[$\w]+(?:\.default)?\.createElement\("ink-box".{0,200}?return \2/; + /function ([$\w]+)\(.{0,2000}[^$\w]([$\w]+)=[$\w]+(?:\.default)?\.createElement\("ink-box".{0,500}?return \2/; const inkBoxMatch = fileContents.match(inkBoxPattern); if (inkBoxMatch) { return inkBoxMatch[1]; @@ -320,7 +320,7 @@ export const findBoxComponent = (fileContents: string): string | undefined => { // Method 2: Find Box by direct return of createElement("ink-box"...) (CC 2.1.20+) // Pattern: function NAME({children:T,...}){...createElement("ink-box",...),T)} const directReturnPattern = - /function ([$\w]+)\(\{children:[$\w]+,flexWrap:[$\w]+.{0,2000}?\.createElement\("ink-box"/; + /function ([$\w]+)\(.{0,200}children:[$\w]+,flexWrap:[$\w]+.{0,2000}?\.createElement\("ink-box"/; const directReturnMatch = fileContents.match(directReturnPattern); if (directReturnMatch) { return directReturnMatch[1]; diff --git a/src/patches/index.ts b/src/patches/index.ts index 9df6c879..20c41c06 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -838,7 +838,10 @@ export const applyCustomization = async ( }, 'mcp-non-blocking': { fn: c => writeMcpNonBlocking(c), - condition: !!config.settings.misc?.mcpConnectionNonBlocking, + condition: + !!config.settings.misc?.mcpConnectionNonBlocking && + (ccInstInfo.version == null || + compareVersions(ccInstInfo.version, '2.1.85') < 0), }, 'mcp-batch-size': { fn: c => writeMcpBatchSize(c, config.settings.misc!.mcpServerBatchSize!), diff --git a/src/patches/modelSelector.ts b/src/patches/modelSelector.ts index 5630cb0b..ca65e0a6 100644 --- a/src/patches/modelSelector.ts +++ b/src/patches/modelSelector.ts @@ -68,6 +68,16 @@ const findCustomModelListInsertionPoint = ( }; export const writeModelCustomizations = (oldFile: string): string | null => { + // Skip if custom models are already injected (e.g. from a previous + // tweakcc run baked into the backup, or future native support). + // The JSON.stringify format uses quoted keys: {"value":"claude-opus-4-6",...} + if (oldFile.includes('"value":"claude-opus-4-6"')) { + console.log( + 'patch: modelCustomizations: custom models already present — skipping' + ); + return oldFile; + } + const found = findCustomModelListInsertionPoint(oldFile); if (!found) return null; diff --git a/src/patches/opusplan1m.ts b/src/patches/opusplan1m.ts index 1c396f41..1c66514b 100644 --- a/src/patches/opusplan1m.ts +++ b/src/patches/opusplan1m.ts @@ -293,6 +293,14 @@ const patchAlwaysShowInModelSelector = (oldFile: string): string | null => { * Main entry point: Apply all opusplan[1m] patches */ export const writeOpusplan1m = (oldFile: string): string | null => { + // CC ≥ 2.1.87 ships opusplan[1m] natively — skip if already present. + if (oldFile.includes('"opusplan[1m]"')) { + console.log( + 'patch: opusplan1m: opusplan[1m] already supported natively — skipping' + ); + return oldFile; + } + let newFile = oldFile; // Patch 1: Mode switching function diff --git a/src/patches/patchesAppliedIndication.ts b/src/patches/patchesAppliedIndication.ts index 3d5b4496..58169657 100644 --- a/src/patches/patchesAppliedIndication.ts +++ b/src/patches/patchesAppliedIndication.ts @@ -30,16 +30,43 @@ export const findVersionOutputLocation = ( }; }; +/** + * Find the Claude Code version display pattern. + * + * Supports two shapes: + * - **Inline (pre-React Compiler):** + * `createElement(TEXT,{bold:!0},"Claude Code")," ",createElement(TEXT,{dimColor:!0},"v",VER)` + * - **Memoized (React Compiler, CC ≥ 2.1.85):** + * The bold "Claude Code" is cached in a variable, then referenced in + * `createElement(TEXT,null,CACHED," ",createElement(TEXT,{dimColor:!0},"v",VER))` + */ +const findVersionDisplay = (fileContents: string): RegExpMatchArray | null => { + // Strategy 1: inline pattern + const inlinePattern = + /[^$\w]([$\w]+)\.createElement\(([$\w]+),\{bold:!0\},"Claude Code"\)," ",([$\w]+)\.createElement\(([$\w]+),\{dimColor:!0\},"v",[$\w]+\)/; + const inlineMatch = fileContents.match(inlinePattern); + if (inlineMatch) return inlineMatch; + + // Strategy 2: React Compiler memoized + const memoPattern = + /([$\w]+)=([$\w]+)\.createElement\(([$\w]+),\{bold:!0\},"Claude Code"\)/; + const memoMatch = fileContents.match(memoPattern); + if (!memoMatch) return null; + + const cachedVar = memoMatch[1]; + const versionPattern = new RegExp( + `[^$\\w][$\\w]+\\.createElement\\([$\\w]+,null,${escapeIdent(cachedVar)}," ",[$\\w]+\\.createElement\\([$\\w]+,\\{dimColor:!0\\},"v",[$\\w]+\\)` + ); + return fileContents.match(versionPattern); +}; + /** * PATCH 2: Finds the location to insert tweakcc version in the header */ const findTweakccVersionLocation = ( fileContents: string ): LocationResult | null => { - // Find Claude Code version display - const pattern = - /[^$\w]([$\w]+)\.createElement\(([$\w]+),\{bold:!0\},"Claude Code"\)," ",([$\w]+)\.createElement\(([$\w]+),\{dimColor:!0\},"v",[$\w]+\)/; - const match = fileContents.match(pattern); + const match = findVersionDisplay(fileContents); if (!match || match.index === undefined) { console.error( 'patch: patchesAppliedIndication: failed to find Claude Code version pattern' @@ -246,10 +273,8 @@ const applyIndicatorPatchesListPatch = ( const findPatchesListLocation = ( fileContents: string ): LocationResult | null => { - // 1. Find the same regex as patch 2 - const pattern = - /[^$\w]([$\w]+)\.createElement\(([$\w]+),\{bold:!0\},"Claude Code"\)," ",([$\w]+)\.createElement\(([$\w]+),\{dimColor:!0\},"v",[$\w]+\)/; - const match = fileContents.match(pattern); + // 1. Find the version display (same helper as patch 2) + const match = findVersionDisplay(fileContents); if (!match || match.index === undefined) { console.error( 'patch: patchesAppliedIndication: failed to find Claude Code version pattern for patch 3' @@ -439,12 +464,17 @@ export const writePatchesAppliedIndication = ( chalkVar ); if (!patch4Result) { - console.error('patch: patchesAppliedIndication: patch 4 failed'); - return null; + // PATCH 4 can fail on React Compiler builds (CC ≥ 2.1.85) where the + // indicator view layout no longer uses static alignItems/minHeight + // props. Gracefully skip — patches 1-3 still provide the header + // version and patches list. + console.warn( + 'patch: patchesAppliedIndication: patch 4 skipped (indicator view tweakcc version)' + ); + } else { + content = patch4Result.content; + patch4ClosingParenIndex = patch4Result.closingParenIndex; } - - content = patch4Result.content; - patch4ClosingParenIndex = patch4Result.closingParenIndex; } // PATCH 5: Add patches applied list to indicator view (if enabled) @@ -456,10 +486,12 @@ export const writePatchesAppliedIndication = ( /alignItems:"center",minHeight:([$\w]+\?\d+:\d+|\d+),?/; const alignItemsMatch = content.match(alignItemsPattern); if (!alignItemsMatch || alignItemsMatch.index === undefined) { - console.error( - 'patch: patchesAppliedIndication: failed to find reference point for PATCH 5' + // React Compiler builds (CC ≥ 2.1.85) may not have this pattern. + // Gracefully skip patches 4+5 — patches 1-3 still work. + console.warn( + 'patch: patchesAppliedIndication: patch 5 skipped (no indicator view reference point)' ); - return null; + return content; } patch4ClosingParenIndex = alignItemsMatch.index + alignItemsMatch[0].length; @@ -475,10 +507,16 @@ export const writePatchesAppliedIndication = ( patchesApplies ); if (!finalContent) { - console.error('patch: patchesAppliedIndication: patch 5 failed'); - return null; + // PATCH 5 can fail on React Compiler builds (CC ≥ 2.1.85) where the + // component tree is flattened into variable assignments, making the + // paren-counting stack machine invalid. Gracefully skip — patches 1-4 + // still provide the tweakcc version and header list. + console.warn( + 'patch: patchesAppliedIndication: patch 5 skipped (indicator view patches list)' + ); + } else { + content = finalContent; } - content = finalContent; } return content; diff --git a/src/patches/statuslineUpdateThrottle.ts b/src/patches/statuslineUpdateThrottle.ts index 058fb5e0..469ee84e 100644 --- a/src/patches/statuslineUpdateThrottle.ts +++ b/src/patches/statuslineUpdateThrottle.ts @@ -101,7 +101,7 @@ export const writeStatuslineUpdateThrottle = ( // Match[5]: The function call with parameter if newer format (e.g., "I(A)") // Match[6]: The argument to the function if newer format (e.g., "A") const pattern = - /(,([$\w]+)=([$\w]+(?:\.default)?)\.useCallback.{0,1000}statusLineText.{0,200}?),([$\w]+)=([$\w.]+\(\(\)=>(\2\(([$\w]+)\)),300\)|[$\w]+\(\2,300\)|.{0,100}\{[$\w]+\.current=void 0,\2\(\)\},300\)\},\[\2\]\))/; + /(,([$\w]+)=([$\w]+(?:\.default)?)\.useCallback.{0,1000}statusLineText.{0,200}?),([$\w]+)=([$\w.]+\(\(\)=>(\2\(([$\w]+)\)),300\)|[$\w]+\(\2,300\)|.{0,100}\{[$\w]+\.current=void 0,\2\(\)\},300\)\},\[\2\]\)|\3\.useCallback\(\(\)=>\{.{0,200}setTimeout\(\([$\w]+,[$\w]+\)=>\{[$\w]+\.current=void 0,[$\w]+\(\)\},300,[$\w]+,\2\)\},\[\2\]\))/; const match = oldFile.match(pattern); diff --git a/src/patches/themes.ts b/src/patches/themes.ts index f357c40c..4ec5ebeb 100644 --- a/src/patches/themes.ts +++ b/src/patches/themes.ts @@ -21,7 +21,7 @@ function getThemesLocation(oldFile: string): { const objArrPat = /\[(?:\.\.\.\[\],)?(?:\{label:"(?:Dark|Light|Auto)[^"]*",value:"[^"]+"\},?)+\]/; const objPat = - /return\{(?:(?:[$\w]+|"[^"]+"):"(?:Auto|Dark|Light)[^"]*",?)+\}/; + /(return|[$\w]+=)\{(?:(?:[$\w]+|"[^"]+"):"(?:Auto|Dark|Light)[^"]*",?)+\}/; const objArrMatch = oldFile.match(objArrPat); const objMatch = oldFile.match(objPat); @@ -48,6 +48,7 @@ function getThemesLocation(oldFile: string): { obj: { startIndex: objMatch.index, endIndex: objMatch.index + objMatch[0].length, + identifiers: [objMatch[1]], }, }; } @@ -70,8 +71,10 @@ export const writeThemes = ( // Process in reverse order to avoid index shifting // Update theme mapping object (obj) + // Preserve the original prefix (either "return" or a variable assignment like "Lr9=") + const objPrefix = locations.obj.identifiers?.[0] ?? 'return'; const obj = - 'return' + + objPrefix + JSON.stringify( Object.fromEntries(themes.map(theme => [theme.id, theme.name])) ); diff --git a/src/patches/thinkerFormat.ts b/src/patches/thinkerFormat.ts index aed6bd45..03ad83ea 100644 --- a/src/patches/thinkerFormat.ts +++ b/src/patches/thinkerFormat.ts @@ -61,6 +61,28 @@ const getThinkerFormatLocation = (oldFile: string): LocationResult | null => { }; } + // CC ≥ 2.1.87 template-literal pattern: =`${expr}… ` + const formatPatternTpl = + /,([$\w]+)(=`\$\{([$\w]+&&![$\w]+\.isIdle\?[$\w]+\.spinnerVerb\?\?[$\w]+:[$\w]+)\}(?:…|\\u2026) ?`)/; + const formatMatchTpl = searchSection.match(formatPatternTpl); + + if (formatMatchTpl && formatMatchTpl.index != undefined) { + return { + startIndex: + approxAreaMatch.index + + formatMatchTpl.index + + formatMatchTpl[1].length + + 1, + endIndex: + approxAreaMatch.index + + formatMatchTpl.index + + formatMatchTpl[1].length + + formatMatchTpl[2].length + + 1, + identifiers: [formatMatchTpl[3]], + }; + } + console.error('patch: thinker format: failed to find formatMatch'); return null; }; diff --git a/src/patches/thinkingVerbs.ts b/src/patches/thinkingVerbs.ts index f011ddf6..75857444 100644 --- a/src/patches/thinkingVerbs.ts +++ b/src/patches/thinkingVerbs.ts @@ -59,7 +59,11 @@ const patchPresentTenseVerbs = ( }; const patchPastTenseVerbs = (file: string, verbs: string[]): string | null => { - const pattern = /\[("[A-Z][a-z'é\-\\xA-F0-9]+ed",?){5,}\]/; + // CC ≥ 2.1.87 includes non-"ed" words like "Beboppin'" in the past-tense + // array, so we can't require every entry to end in "ed". Match any + // capitalised word (same character class as the present-tense pattern) and + // rely on the {50,} minimum count to avoid false positives. + const pattern = /\[("[A-Z][a-z'é\-\\xA-F0-9]+",?){50,}\]/; const match = file.match(pattern); diff --git a/src/patches/thinkingVisibility.ts b/src/patches/thinkingVisibility.ts index a10aa28b..1bbf9d34 100644 --- a/src/patches/thinkingVisibility.ts +++ b/src/patches/thinkingVisibility.ts @@ -36,6 +36,16 @@ import { showDiff } from './index'; */ export const writeThinkingVisibility = (oldFile: string): string | null => { + // CC ≥ 2.1.87 ships with thinking blocks always visible — skip if already configured. + const nativeCheck = + /case"thinking":\{(?:(?!case")[^]){0,600}isTranscriptMode:true/; + if (nativeCheck.test(oldFile)) { + console.log( + 'patch: thinkingVisibility: already configured natively — skipping' + ); + return oldFile; + } + // Unified pattern that matches both formats: // - Group 1: `case"thinking":` (+/- `{`) // - Group 2: `if(...) return null;` (the early return we want to remove) diff --git a/src/patches/userMessageDisplay.ts b/src/patches/userMessageDisplay.ts index 7b748bd1..560580da 100644 --- a/src/patches/userMessageDisplay.ts +++ b/src/patches/userMessageDisplay.ts @@ -145,9 +145,81 @@ export const writeUserMessageDisplay = ( // that renders the ">" in older versions so that we can silently drop it in the replacement, // removing it in versions where it's present and not failing on versions where it's not. const pattern = - /(No content found in user prompt message.{0,150}?\b)([$\w]+(?:\.default)?\.createElement.{0,30}\b[$\w]+(?:\.default)?\.createElement.{0,40}">.+?)?(([$\w]+(?:\.default)?\.createElement).{0,100})(\([$\w]+,(?:\{[^{}]+wrap:"wrap"\},([$\w]+)(?:\.trim\(\))?\)\)|\{text:([$\w]+)(?:,thinkingMetadata:[$\w]+)?\}\)\)?))/; + /(No content found in user prompt message.{0,250}?\b)([$\w]+(?:\.default)?\.createElement.{0,30}\b[$\w]+(?:\.default)?\.createElement.{0,40}">.+?)?(([$\w]+(?:\.default)?\.createElement).{0,200})(\([$\w]+,(?:\{[^{}]+wrap:"wrap"\},([$\w]+)(?:\.trim\(\))?\)\)|\{text:([$\w]+)[^}]*\}\)\)?))/; - const match = oldFile.match(pattern); + let match = oldFile.match(pattern); + + // CC ≥ 2.1.87 React-Compiler pattern: much simpler createElement chain with + // a formatting function call like K$(` > ${A} `) + if (!match) { + const patternNew = + /(No content found in user prompt message.{0,150}?return\s+)(([$\w]+(?:\.default)?)\.createElement\(([$\w]+),null,\3\.createElement\(([$\w]+),null,([$\w]+)\(` > \$\{([$\w]+)\} `\)\)\))/; + match = oldFile.match(patternNew); + if (match && match.index !== undefined) { + const createElFn = match[3] + '.createElement'; + const msgVar = match[7]; + + // Build chalk chain (same logic as the legacy path below) + let chain = chalkVar; + if (config.foregroundColor !== 'default') { + const fgM = config.foregroundColor.match(/\d+/g); + if (fgM) chain += `.rgb(${fgM.join(',')})`; + } + if ( + config.backgroundColor !== 'default' && + config.backgroundColor !== null + ) { + const bgM = config.backgroundColor.match(/\d+/g); + if (bgM) chain += `.bgRgb(${bgM.join(',')})`; + } + if (config.styling.includes('bold')) chain += '.bold'; + if (config.styling.includes('italic')) chain += '.italic'; + if (config.styling.includes('underline')) chain += '.underline'; + if (config.styling.includes('strikethrough')) chain += '.strikethrough'; + if (config.styling.includes('inverse')) chain += '.inverse'; + + const fmt = + '`' + config.format.replace(/\{\}/g, '${' + msgVar + '}') + '`'; + const chalkStr = `${chain}(${fmt})`; + + // Build box attrs inline (same logic as legacy path below) + const bAttrs: string[] = []; + if (config.borderStyle !== 'none') { + if (config.borderStyle.startsWith('topBottom')) { + const borders: Record = { + topBottomSingle: '─', + topBottomDouble: '═', + topBottomBold: '━', + }; + const ch = borders[config.borderStyle] ?? '─'; + bAttrs.push( + `borderStyle:{top:"${ch}",bottom:"${ch}",left:" ",right:" ",topLeft:" ",topRight:" ",bottomLeft:" ",bottomRight:" "}` + ); + } else { + bAttrs.push(`borderStyle:"${config.borderStyle}"`); + } + const bcM = config.borderColor.match(/\d+/g); + if (bcM) bAttrs.push(`borderColor:"rgb(${bcM.join(',')})"`); + } + if (config.paddingX > 0) bAttrs.push(`paddingX:${config.paddingX}`); + if (config.paddingY > 0) bAttrs.push(`paddingY:${config.paddingY}`); + if (config.fitBoxToContent) bAttrs.push(`alignSelf:"flex-start"`); + const boxStr = bAttrs.length > 0 ? `{${bAttrs.join(',')}}` : 'null'; + + const replacement = + match[1] + + `${createElFn}(${boxComponent},${boxStr},${createElFn}(${textComponent},null,${chalkStr}))`; + + const startIndex = match.index; + const endIndex = startIndex + match[0].length; + + const newFile = + oldFile.slice(0, startIndex) + replacement + oldFile.slice(endIndex); + + showDiff(oldFile, newFile, replacement, startIndex, endIndex); + return newFile; + } + } if (!match || match.index === undefined) { console.error(