From caced2efa73467fbf9a2822cdb18f30b33eb2ad4 Mon Sep 17 00:00:00 2001 From: Kevin Roberts Date: Wed, 20 May 2026 15:15:36 -0600 Subject: [PATCH 1/2] Fix patches for Claude Code 2.1.146 --- probe-patches.mts | 238 ++++++++++++++++++++++ src/patches/autoAcceptPlanMode.ts | 56 ++++- src/patches/fixLspSupport.ts | 2 +- src/patches/helpers.ts | 10 + src/patches/increaseFileReadLimit.ts | 13 +- src/patches/inputPatternHighlighters.ts | 101 +++++++-- src/patches/mcpStartup.ts | 8 +- src/patches/modelSelector.ts | 8 +- src/patches/opusplan1m.ts | 14 +- src/patches/patchesAppliedIndication.ts | 15 +- src/patches/sessionMemory.ts | 82 ++++++-- src/patches/showMoreItemsInSelectMenus.ts | 8 +- src/patches/slashCommands.ts | 109 ++++++---- src/patches/statuslineUpdateThrottle.ts | 2 +- src/patches/suppressLineNumbers.ts | 7 +- src/patches/tableFormat.ts | 12 ++ src/patches/themes.ts | 66 ++++-- src/patches/thinkerFormat.ts | 60 ++++-- src/patches/toolsets.ts | 64 +++--- src/patches/userMessageDisplay.ts | 31 ++- src/patches/verboseProperty.ts | 8 +- src/patches/voiceMode.ts | 25 +++ 22 files changed, 776 insertions(+), 163 deletions(-) create mode 100644 probe-patches.mts diff --git a/probe-patches.mts b/probe-patches.mts new file mode 100644 index 00000000..eb5928f0 --- /dev/null +++ b/probe-patches.mts @@ -0,0 +1,238 @@ +// Run inside /home/user/code/tweakcc via: +// pnpm tsx probe-patches.mts /tmp/tweakcc-analysis/claude-2.1.146.js +// Calls each writeX patch function against an unpacked Claude Code JS file and +// reports patches that silently return null or throw. + +import fs from 'node:fs'; +import { DEFAULT_SETTINGS } from './src/defaultSettings.ts'; + +const jsPath = process.argv[2] ?? '/tmp/tweakcc-analysis/claude-2.1.146.js'; +const JS = fs.readFileSync(jsPath, 'utf8'); + +console.log(`Probing patches against ${jsPath}`); +console.log(''); + +type Probe = { name: string; run: () => Promise }; + +const probes: Probe[] = []; + +// Push a probe: name + dynamic-import + arg builder. We swallow stderr from +// console.error inside the patches by patching console.error around each run. +function add(name: string, fn: () => Promise) { + probes.push({ name, run: fn }); +} + +// Helper to capture console.error during run +async function withCapturedErr(fn: () => T | Promise): Promise<{ result: T; errs: string[] }> { + const errs: string[] = []; + const orig = console.error; + console.error = (...a) => errs.push(a.map(x => String(x)).join(' ')); + try { + const result = await fn(); + return { result, errs }; + } finally { + console.error = orig; + } +} + +// Build probes for every writeX entry +add('verbose-property', async () => { + const { writeVerboseProperty } = await import('./src/patches/verboseProperty.ts'); + return writeVerboseProperty(JS); +}); +add('opusplan1m', async () => { + const { writeOpusplan1m } = await import('./src/patches/opusplan1m.ts'); + return writeOpusplan1m(JS); +}); +add('fix-lsp-support', async () => { + const { writeFixLspSupport } = await import('./src/patches/fixLspSupport.ts'); + return writeFixLspSupport(JS); +}); +add('context-limit', async () => { + const { writeContextLimit } = await import('./src/patches/contextLimit.ts'); + return writeContextLimit(JS); +}); +add('statusline-update-throttle', async () => { + const { writeStatuslineUpdateThrottle } = await import('./src/patches/statuslineUpdateThrottle.ts'); + return writeStatuslineUpdateThrottle(JS, 300, false); +}); +add('patches-applied-indication', async () => { + const { writePatchesAppliedIndication } = await import('./src/patches/patchesAppliedIndication.ts'); + return writePatchesAppliedIndication(JS, '4.0.13', [], true, true); +}); +add('model-customizations', async () => { + const { writeModelCustomizations } = await import('./src/patches/modelSelector.ts'); + return writeModelCustomizations(JS); +}); +add('show-more-items-in-select-menus', async () => { + const { writeShowMoreItemsInSelectMenus } = await import('./src/patches/showMoreItemsInSelectMenus.ts'); + return writeShowMoreItemsInSelectMenus(JS, 25); +}); +add('table-format', async () => { + const { writeTableFormat } = await import('./src/patches/tableFormat.ts'); + return writeTableFormat(JS, 'markdown'); +}); +add('themes', async () => { + const { writeThemes } = await import('./src/patches/themes.ts'); + // tweak one color so the writer actually has work to do + const t = JSON.parse(JSON.stringify(DEFAULT_SETTINGS.themes)); + t[0].colors.text = 'rgb(123,45,67)'; + return writeThemes(JS, t); +}); +add('thinking-verbs', async () => { + const { writeThinkingVerbs } = await import('./src/patches/thinkingVerbs.ts'); + return writeThinkingVerbs(JS, DEFAULT_SETTINGS.thinkingVerbs!.verbs); +}); +add('thinker-format', async () => { + const { writeThinkerFormat } = await import('./src/patches/thinkerFormat.ts'); + return writeThinkerFormat(JS, DEFAULT_SETTINGS.thinkingVerbs!.format); +}); +add('thinker-symbol-chars', async () => { + const { writeThinkerSymbolChars } = await import('./src/patches/thinkerSymbolChars.ts'); + return writeThinkerSymbolChars(JS, ['+', '-', '*']); +}); +add('thinker-symbol-width', async () => { + const { writeThinkerSymbolWidthLocation } = await import('./src/patches/thinkerSymbolWidth.ts'); + return writeThinkerSymbolWidthLocation(JS, 2); +}); +add('thinker-symbol-mirror', async () => { + const { writeThinkerSymbolMirrorOption } = await import('./src/patches/thinkerMirrorOption.ts'); + return writeThinkerSymbolMirrorOption(JS, !DEFAULT_SETTINGS.thinkingStyle.reverseMirror); +}); +add('input-box-border', async () => { + const { writeInputBoxBorder } = await import('./src/patches/inputBorderBox.ts'); + return writeInputBoxBorder(JS, true); +}); +add('subagent-models', async () => { + const { writeSubagentModels } = await import('./src/patches/subagentModels.ts'); + return writeSubagentModels(JS, { 'general-purpose': 'claude-sonnet-4-6' } as any); +}); +add('thinking-visibility', async () => { + const { writeThinkingVisibility } = await import('./src/patches/thinkingVisibility.ts'); + return writeThinkingVisibility(JS); +}); +add('hide-startup-banner', async () => { + const { writeHideStartupBanner } = await import('./src/patches/hideStartupBanner.ts'); + return writeHideStartupBanner(JS); +}); +add('hide-ctrl-g-to-edit', async () => { + const { writeHideCtrlGToEdit } = await import('./src/patches/hideCtrlGToEdit.ts'); + return writeHideCtrlGToEdit(JS); +}); +add('hide-startup-clawd', async () => { + const { writeHideStartupClawd } = await import('./src/patches/hideStartupClawd.ts'); + return writeHideStartupClawd(JS); +}); +add('increase-file-read-limit', async () => { + const { writeIncreaseFileReadLimit } = await import('./src/patches/increaseFileReadLimit.ts'); + return writeIncreaseFileReadLimit(JS); +}); +add('suppress-line-numbers', async () => { + const { writeSuppressLineNumbers } = await import('./src/patches/suppressLineNumbers.ts'); + return writeSuppressLineNumbers(JS); +}); +add('suppress-rate-limit-options', async () => { + const { writeSuppressRateLimitOptions } = await import('./src/patches/suppressRateLimitOptions.ts'); + return writeSuppressRateLimitOptions(JS); +}); +add('token-count-rounding', async () => { + const { writeTokenCountRounding } = await import('./src/patches/tokenCountRounding.ts'); + return writeTokenCountRounding(JS, 100); +}); +add('agents-md', async () => { + const { writeAgentsMd } = await import('./src/patches/agentsMd.ts'); + return writeAgentsMd(JS, ['AGENTS.md']); +}); +add('auto-accept-plan-mode', async () => { + const { writeAutoAcceptPlanMode } = await import('./src/patches/autoAcceptPlanMode.ts'); + return writeAutoAcceptPlanMode(JS); +}); +add('allow-sudo-bypass-permissions', async () => { + const { writeAllowBypassPermsInSudo } = await import('./src/patches/allowBypassPermsInSudo.ts'); + return writeAllowBypassPermsInSudo(JS); +}); +add('suppress-native-installer-warning', async () => { + const { writeSuppressNativeInstallerWarning } = await import('./src/patches/suppressNativeInstallerWarning.ts'); + return writeSuppressNativeInstallerWarning(JS); +}); +add('filter-scroll-escape-sequences', async () => { + const { writeScrollEscapeSequenceFilter } = await import('./src/patches/scrollEscapeSequenceFilter.ts'); + return writeScrollEscapeSequenceFilter(JS); +}); +add('allow-custom-agent-models', async () => { + const { writeAllowCustomAgentModels } = await import('./src/patches/allowCustomAgentModels.ts'); + return writeAllowCustomAgentModels(JS); +}); +add('session-memory', async () => { + const { writeSessionMemory } = await import('./src/patches/sessionMemory.ts'); + return writeSessionMemory(JS); +}); +add('toolsets', async () => { + const { writeToolsets } = await import('./src/patches/toolsets.ts'); + return writeToolsets(JS, [{ name: 'mini', allowedTools: ['Read'], description: 'd' }] as any, 'mini', undefined); +}); +add('mcp-non-blocking', async () => { + const { writeMcpNonBlocking } = await import('./src/patches/mcpStartup.ts'); + return writeMcpNonBlocking(JS); +}); +add('mcp-batch-size', async () => { + const { writeMcpBatchSize } = await import('./src/patches/mcpStartup.ts'); + return writeMcpBatchSize(JS, 8); +}); +add('user-message-display', async () => { + const { writeUserMessageDisplay } = await import('./src/patches/userMessageDisplay.ts'); + return writeUserMessageDisplay(JS, DEFAULT_SETTINGS.userMessageDisplay!); +}); +add('input-pattern-highlighters', async () => { + const { writeInputPatternHighlighters } = await import('./src/patches/inputPatternHighlighters.ts'); + return writeInputPatternHighlighters(JS, [ + { name: 't', pattern: 'TODO', styling: [], foregroundColor: 'rgb(255,0,0)', backgroundColor: 'rgb(0,0,0)' }, + ] as any); +}); +add('voice-mode', async () => { + const { writeVoiceMode } = await import('./src/patches/voiceMode.ts'); + return writeVoiceMode(JS, true); +}); +add('channels-mode', async () => { + const { writeChannelsMode } = await import('./src/patches/channelsMode.ts'); + return writeChannelsMode(JS); +}); + +// Run all +const PAD = 38; +const ok: string[] = []; +const fail: { name: string; errs: string[] }[] = []; +const exc: { name: string; e: Error }[] = []; + +for (const p of probes) { + try { + const { result, errs } = await withCapturedErr(p.run); + if (result == null) { + fail.push({ name: p.name, errs }); + console.log(`✗ ${p.name.padEnd(PAD)} ${errs[0] ?? '(no error message)'}`); + } else { + ok.push(p.name); + const changed = result !== JS; + console.log(`✓ ${p.name.padEnd(PAD)} ${changed ? `(${(result.length - JS.length).toString().padStart(6)} bytes Δ)` : '(no change)'}`); + } + } catch (e: any) { + exc.push({ name: p.name, e }); + console.log(`! ${p.name.padEnd(PAD)} THREW: ${e.message}`); + } +} + +console.log('\n──────── Summary ────────'); +console.log(`✓ ok: ${ok.length}`); +console.log(`✗ fail: ${fail.length}`); +console.log(`! throw: ${exc.length}`); +if (fail.length > 0) { + console.log('\nFailures:'); + for (const f of fail) { + console.log(` ${f.name}`); + for (const e of f.errs) console.log(` ${e}`); + } +} +if (exc.length > 0) { + console.log('\nExceptions:'); + for (const e of exc) console.log(` ${e.name}: ${e.e.message}`); +} diff --git a/src/patches/autoAcceptPlanMode.ts b/src/patches/autoAcceptPlanMode.ts index 171e6500..c4b65477 100644 --- a/src/patches/autoAcceptPlanMode.ts +++ b/src/patches/autoAcceptPlanMode.ts @@ -14,6 +14,38 @@ import { showDiff } from './index'; +const findEnclosingFunctionReturn = ( + oldFile: string, + readyIdx: number +): number | null => { + const functionStart = oldFile.lastIndexOf('function ', readyIdx); + if (functionStart === -1) return null; + + const openBrace = oldFile.indexOf('{', functionStart); + if (openBrace === -1 || openBrace > readyIdx) return null; + + let depth = 0; + for (let index = openBrace; index < oldFile.length; index++) { + const char = oldFile[index]; + if (char === '{') depth++; + else if (char === '}') { + depth--; + if (depth === 0) { + const functionTail = oldFile.slice(readyIdx, index + 1); + const returnPattern = /return ([$\w]+)\}/g; + let match: RegExpExecArray | null; + let lastMatch: RegExpExecArray | null = null; + while ((match = returnPattern.exec(functionTail)) !== null) { + lastMatch = match; + } + return lastMatch ? readyIdx + lastMatch.index : null; + } + } + } + + return null; +}; + export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { const readyIdx = oldFile.indexOf('title:"Ready to code?"'); if (readyIdx === -1) { @@ -99,10 +131,28 @@ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { // Simpler approach: find "return" before "Ready to code?" that starts the component tree const simpleReturnIdx = beforeReady.lastIndexOf('return '); if (simpleReturnIdx === -1) { - console.error( - 'patch: autoAcceptPlanMode: failed to find return before "Ready to code?"' + const enclosingReturnIdx = findEnclosingFunctionReturn(oldFile, readyIdx); + if (enclosingReturnIdx === null) { + console.error( + 'patch: autoAcceptPlanMode: failed to find return before "Ready to code?"' + ); + return null; + } + + const insertion = `${acceptFuncName}("yes-accept-edits");return null;`; + const newFile = + oldFile.slice(0, enclosingReturnIdx) + + insertion + + oldFile.slice(enclosingReturnIdx); + + showDiff( + oldFile, + newFile, + insertion, + enclosingReturnIdx, + enclosingReturnIdx ); - return null; + return newFile; } const absoluteReturnIdx = Math.max(0, readyIdx - 500) + simpleReturnIdx; diff --git a/src/patches/fixLspSupport.ts b/src/patches/fixLspSupport.ts index 7e9f9d72..1d170385 100644 --- a/src/patches/fixLspSupport.ts +++ b/src/patches/fixLspSupport.ts @@ -30,7 +30,7 @@ const getOpenDocumentLocation = (oldFile: string): LocationResult | null => { const varName = sendRequestMatch[1]; // Step 5: In the previous 1000-2000 characters, search for `async function {varName}\([$\w]+,` - const searchStart = Math.max(0, ensureMatch.index - 2000); + const searchStart = Math.max(0, ensureMatch.index - 4000); const searchChunk = oldFile.slice(searchStart, ensureMatch.index); const functionPattern = new RegExp( `async function ${escapeIdent(varName)}\\(([$\\w]+),`, diff --git a/src/patches/helpers.ts b/src/patches/helpers.ts index 0ef72683..ebe02e4f 100644 --- a/src/patches/helpers.ts +++ b/src/patches/helpers.ts @@ -344,6 +344,16 @@ export const findBoxComponent = (fileContents: string): string | undefined => { return memoBoxMatch[1]; } + // Method 5: Find Box by rest-style layout defaults (CC 2.1.138+) + // Avoid ScrollBox-like wrappers by requiring generic Box layout defaults, + // integer style warnings, forwarded children, and no sticky/scroll behavior. + const restStyleBoxPattern = + /function ([$\w]+)\(\{children:([$\w]+),ref:[$\w]+.{0,600}?\.\.\.([$\w]+)\}\)\{.{0,2500}?"margin".{0,2500}?"padding".{0,1200}?"gap".{0,1200}?\3\.flexWrap\?\?="nowrap",\3\.flexDirection\?\?="row",\3\.flexGrow\?\?=0,\3\.flexShrink\?\?=1,\3\.overflowX=\3\.overflowX\?\?\3\.overflow\?\?"visible",\3\.overflowY=\3\.overflowY\?\?\3\.overflow\?\?"visible",[$\w]+(?:\.default)?\.createElement\("ink-box",\{[^}]*style:\3\},\2\)/; + const restStyleBoxMatch = fileContents.match(restStyleBoxPattern); + if (restStyleBoxMatch) { + return restStyleBoxMatch[1]; + } + console.error( 'patch: findBoxComponent: failed to find Box component (neither ink-box createElement nor displayName found)' ); diff --git a/src/patches/increaseFileReadLimit.ts b/src/patches/increaseFileReadLimit.ts index 233cab8d..6e95051a 100644 --- a/src/patches/increaseFileReadLimit.ts +++ b/src/patches/increaseFileReadLimit.ts @@ -11,13 +11,24 @@ import { LocationResult, showDiff } from './index'; * - "tengu_amber_wren" (CC >=2.1.83) */ const getFileReadLimitLocation = (oldFile: string): LocationResult | null => { + const newConfigRegion = oldFile.match( + /CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS[\s\S]{0,1200}tengu_amber_wren/ + ); + if (newConfigRegion && newConfigRegion.index !== undefined) { + const tokenLimitMatch = newConfigRegion[0].match(/=25000,/); + if (tokenLimitMatch && tokenLimitMatch.index !== undefined) { + const startIndex = newConfigRegion.index + tokenLimitMatch.index + 1; + return { startIndex, endIndex: startIndex + 5 }; + } + } + // Try anchors in order of preference const anchors = ['', 'tengu_amber_wren']; let match: RegExpMatchArray | null = null; for (const anchor of anchors) { const escaped = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`=25000,([\\s\\S]{0,200})${escaped}`); + const pattern = new RegExp(`=25000,([\\s\\S]{0,700})${escaped}`); match = oldFile.match(pattern); if (match && match.index !== undefined) break; } diff --git a/src/patches/inputPatternHighlighters.ts b/src/patches/inputPatternHighlighters.ts index 11d70a73..2e452aaa 100644 --- a/src/patches/inputPatternHighlighters.ts +++ b/src/patches/inputPatternHighlighters.ts @@ -110,7 +110,10 @@ const writeCustomHighlighterImpl = (oldFile: string): string | null => { workingFile.slice(shimmerMatch.index); } - // Now patch the main return (which may have shifted due to shimmer insertion) + // Now patch the main return (which may have shifted due to shimmer insertion). + // The pristine renderer only reads color/dimColor/inverse from the highlight + // object. Extend it to also forward bold/italic/underline/strikethrough/ + // backgroundColor so the highlighter push entries can express those styles. const newMatches2 = workingFile.match(newRegex); if (!newMatches2 || newMatches2.index === undefined) { console.error( @@ -119,9 +122,27 @@ const writeCustomHighlighterImpl = (oldFile: string): string | null => { return null; } + const reactVar2 = newMatches2[2]; + const textComp2 = newMatches2[3]; + const keyVar2 = newMatches2[4]; + const segVar2 = newMatches2[5]; + const innerElem2 = newMatches2[6]; + + const augmentedRenderer = + `return ${reactVar2}.createElement(${textComp2},{key:${keyVar2}` + + `,color:${segVar2}.highlight?.color` + + `,backgroundColor:${segVar2}.highlight?.backgroundColor` + + `,dimColor:${segVar2}.highlight?.dimColor` + + `,inverse:${segVar2}.highlight?.inverse` + + `,bold:${segVar2}.highlight?.bold` + + `,italic:${segVar2}.highlight?.italic` + + `,underline:${segVar2}.highlight?.underline` + + `,strikethrough:${segVar2}.highlight?.strikethrough` + + `},${innerElem2})`; + const newFile = workingFile.slice(0, newMatches2.index) + - newMatches2[0] + + augmentedRenderer + workingFile.slice(newMatches2.index + newMatches2[0].length); showDiff(oldFile, newFile, 'shimmer guard + renderer', 0, 0); @@ -138,8 +159,11 @@ const writeCustomHighlighterCreation = ( ): string | null => { // CC <2.1.83: ,VAR=REACT.useMemo(()=>{let ARR=[];if(...)ARR.push(...) // CC >=2.1.83: ;let VAR=REACT.useMemo(()=>{let ARR=[];for(...)...;if(...)ARR.push(...) + // CC >=2.1.140: same shape, but unrelated useMemos earlier in the file + // require the inner span to be length-bounded so the regex doesn't span + // across functions and latch onto the wrong useMemo opening. const regex = - /((?:,|;let )[$\w]+=[$\w]+\.useMemo\(\(\)=>\{let [$\w]+=\[\];[\s\S]*?)(if\([$\w]+&&[$\w]+&&![$\w]+\)([$\w]+)\.push\(\{start:[$\w]+,end:[$\w]+\+[$\w]+\.length,color:"warning",priority:\d+\})/; + /((?:,|;let )[$\w]+=[$\w]+\.useMemo\(\(\)=>\{let [$\w]+=\[\];[\s\S]{0,2000}?)(if\([$\w]+&&[$\w]+&&![$\w]+\)([$\w]+)\.push\(\{start:[$\w]+,end:[$\w]+\+[$\w]+\.length,color:"warning",priority:\d+\})/; const match = oldFile.match(regex); if (!match || match.index === undefined) { @@ -161,14 +185,20 @@ const writeCustomHighlighterCreation = ( } const _reactVarFromMemo = reactMemoMatch[1]; // eslint-disable-line @typescript-eslint/no-unused-vars - const searchStart = Math.max(0, match.index - 10000); + const searchStart = Math.max(0, match.index - 15000); const searchWindow = oldFile.slice(searchStart, match.index); - const inputPattern = /\binput:([$\w]+),/g; - const inputMatches = [...searchWindow.matchAll(inputPattern)]; - const inputMatch = inputMatches.at(-1) ?? null; + // CC >=2.1.140: input is destructured from a hook as `inputValue:VAR,`. + // CC <2.1.140: the input variable is passed as a prop named `input:VAR,`. + // Prefer the new form when present (the old form may also match unrelated + // function parameters in the same lookback window in 2.1.140). + const newInputPattern = /\binputValue:([$\w]+),/g; + const oldInputPattern = /\binput:([$\w]+),/g; + const newInputMatches = [...searchWindow.matchAll(newInputPattern)]; + const oldInputMatches = [...searchWindow.matchAll(oldInputPattern)]; + const inputMatch = newInputMatches.at(-1) ?? oldInputMatches.at(-1) ?? null; if (!inputMatch) { console.error( - 'patch: inputPatternHighlighters: failed to find input variable pattern' + 'patch: inputPatternHighlighters: failed to find input variable pattern (looked for inputValue: and input:)' ); return null; } @@ -180,7 +210,8 @@ const writeCustomHighlighterCreation = ( for (let i = 0; i < highlighters.length; i++) { const highlighter = highlighters[i]; const _chalkChain = buildChalkChain(chalkVar, highlighter); // eslint-disable-line @typescript-eslint/no-unused-vars - JSON.stringify(highlighter.format).replace(/\{MATCH\}/g, '"+x+"'); // preserve legacy side-effect-free transform shape for diff stability + const formatStr = highlighter.format ?? '{MATCH}'; + JSON.stringify(formatStr).replace(/\{MATCH\}/g, '"+x+"'); // preserve legacy side-effect-free transform shape for diff stability // Note: format handling for this branch is currently color/style-only. @@ -193,19 +224,47 @@ const writeCustomHighlighterCreation = ( } } const colorValue = colorStr ? JSON.stringify(colorStr) : 'undefined'; - const _isBold = highlighter.styling.includes('bold'); // eslint-disable-line @typescript-eslint/no-unused-vars - const isInverse = highlighter.styling.includes('inverse'); - const isDim = highlighter.styling.includes('dim'); - const isStrikethrough = highlighter.styling.includes('strikethrough'); - - let flags = highlighter.regexFlags; + let bgColorStr = highlighter.backgroundColor; + if (bgColorStr) { + const bgRgbMatch = bgColorStr.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); + if (bgRgbMatch) { + const [, r, g, b] = bgRgbMatch.map(Number); + bgColorStr = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + } + } + const bgColorValue = bgColorStr ? JSON.stringify(bgColorStr) : null; + const styling = highlighter.styling ?? []; + const isBold = styling.includes('bold'); + const isItalic = styling.includes('italic'); + const isUnderline = styling.includes('underline'); + const isInverse = styling.includes('inverse'); + const isDim = styling.includes('dim'); + const isStrikethrough = styling.includes('strikethrough'); + + const regexSource = + highlighter.regex ?? + (highlighter as unknown as { pattern?: string }).pattern; + if (!regexSource) { + console.error( + `patch: inputPatternHighlighters: highlighter "${highlighter.name}" has no regex/pattern; skipping` + ); + continue; + } + let flags = highlighter.regexFlags ?? ''; if (!flags.includes('g')) { flags += 'g'; } - const regex = new RegExp(highlighter.regex, flags); + const regex = new RegExp(regexSource, flags); const regexStr = stringifyRegex(regex); - genCode += `if(typeof ${inputVar}==="string"){for(let m of ${inputVar}.matchAll(${regexStr})){${rangesVar}.push({start:m.index,end:m.index+m[0].length,color:${colorValue}${isInverse ? ',inverse:!0' : ''}${isDim ? ',dimColor:!0' : ''}${isStrikethrough ? ',strikethrough:!0' : ''},priority:100})}}`; + genCode += `if(typeof ${inputVar}==="string"){for(let m of ${inputVar}.matchAll(${regexStr})){${rangesVar}.push({start:m.index,end:m.index+m[0].length,color:${colorValue}${bgColorValue ? `,backgroundColor:${bgColorValue}` : ''}${isBold ? ',bold:!0' : ''}${isItalic ? ',italic:!0' : ''}${isUnderline ? ',underline:!0' : ''}${isInverse ? ',inverse:!0' : ''}${isDim ? ',dimColor:!0' : ''}${isStrikethrough ? ',strikethrough:!0' : ''},priority:100})}}`; + } + + if (!genCode) { + console.error( + 'patch: inputPatternHighlighters: no usable highlighters generated (all skipped)' + ); + return null; } const replacement = match[1] + genCode + match[2]; @@ -271,9 +330,15 @@ export const writeInputPatternHighlighters = ( oldFile: string, highlighters: InputPatternHighlighter[] ): string | null => { - const enabledHighlighters = highlighters.filter(h => h.enabled); + // Treat missing `enabled` as enabled (only `false` disables a highlighter). + // Robust against partially-typed callers (e.g. defaults loaded from older + // configs or the probe harness). + const enabledHighlighters = highlighters.filter(h => h.enabled !== false); if (enabledHighlighters.length === 0) { + console.error( + 'patch: inputPatternHighlighters: no enabled highlighters provided' + ); return null; } diff --git a/src/patches/mcpStartup.ts b/src/patches/mcpStartup.ts index 68b38ce5..c3ea771f 100644 --- a/src/patches/mcpStartup.ts +++ b/src/patches/mcpStartup.ts @@ -42,9 +42,11 @@ const getNonBlockingCheckLocation = ( * We want to replace the "3" with a higher value. */ const getBatchSizeLocation = (oldFile: string): LocationResult | null => { - // Match the full pattern and capture position of the default "3" - // Pattern: MCP_SERVER_CONNECTION_BATCH_SIZE||"",10)||3 - const pattern = /MCP_SERVER_CONNECTION_BATCH_SIZE\|\|"",10\)\|\|(\d+)/; + // Match the full pattern and capture position of the default "3". + // Old CC: parseInt(process.env.MCP_SERVER_CONNECTION_BATCH_SIZE||"",10)||3 + // CC ≥2.1.140: parseInt(process.env.MCP_SERVER_CONNECTION_BATCH_SIZE||"",10);return H>0?H:3 + const pattern = + /MCP_SERVER_CONNECTION_BATCH_SIZE\|\|"",10\)(?:\|\||;return [$\w]+>0\?[$\w]+:)(\d+)/; const match = oldFile.match(pattern); if (!match || match.index === undefined) { diff --git a/src/patches/modelSelector.ts b/src/patches/modelSelector.ts index 5630cb0b..373e98e7 100644 --- a/src/patches/modelSelector.ts +++ b/src/patches/modelSelector.ts @@ -39,8 +39,12 @@ const findCustomModelListInsertionPoint = ( const modelListVar = pushMatch[1]; // The declaration/function head can move farther from the push site across CC builds - // and when other patches expand this block, so keep a wider lookback window. - const searchStart = Math.max(0, pushMatch.index - 1500); + // and when other patches expand this block (notably opusplan1m, which injects ~400 + // bytes BEFORE the custom-model push inside the same function), so keep a generous + // lookback window. On CC 2.1.140 the head sits ~1500 bytes from the push BEFORE + // opusplan1m runs and ~1530 bytes after, so 5000 leaves comfortable slack for + // future CC builds and additional pre-patches. + const searchStart = Math.max(0, pushMatch.index - 5000); const chunk = fileContents.slice(searchStart, pushMatch.index); // Declaration can be emitted as let/var/const depending on minifier output. diff --git a/src/patches/opusplan1m.ts b/src/patches/opusplan1m.ts index 1c396f41..b3e995cd 100644 --- a/src/patches/opusplan1m.ts +++ b/src/patches/opusplan1m.ts @@ -109,9 +109,9 @@ const patchModelAliasesList = (oldFile: string): string | null => { * if (A === "opusplan[1m]") return "Opus 4.6 in plan mode, else Sonnet 4.6 (1M context)"; */ const patchDescriptionFunction = (oldFile: string): string | null => { - // Pattern matches: if (VAR === "opusplan") return "Opus 4.6 in plan mode, else Sonnet 4.6"; + // Pattern matches old versioned and new generic opusplan descriptions. const pattern = - /(if\s*\(\s*([$\w]+)\s*===\s*"opusplan"\s*\)\s*return\s*"Opus .{0,20} in plan mode, else Sonnet .{0,20}";)/; + /(if\s*\(\s*([$\w]+)\s*===\s*"opusplan"\s*\)\s*return\s*"([^"]*Opus[^"]*plan mode[^"]*Sonnet[^"]*)";)/; const match = oldFile.match(pattern); if (!match || match.index === undefined) { @@ -121,12 +121,12 @@ const patchDescriptionFunction = (oldFile: string): string | null => { return null; } - const [fullMatch, , varName] = match; + const [fullMatch, , varName, description] = match; // Add the opusplan[1m] case right after the opusplan case const replacement = fullMatch + - `if(${varName}==="opusplan[1m]")return"Opus 4.6 in plan mode, else Sonnet 4.6 (1M context)";`; + `if(${varName}==="opusplan[1m]")return"${description} (1M context)";`; const newFile = oldFile.slice(0, match.index) + @@ -228,7 +228,7 @@ const patchModelSelectorOptions = (oldFile: string): string | null => { ); const wrapFn = wrapperMatch ? wrapperMatch[1] : null; - const newEntry = `{value:"opusplan[1m]",label:"Opus Plan Mode 1M",description:"Use Opus 4.6 in plan mode, Sonnet 4.6 (1M context) otherwise"}`; + const newEntry = `{value:"opusplan[1m]",label:"Opus Plan Mode 1M",description:"Use Opus in plan mode, Sonnet (1M context) otherwise"}`; const returnExpr = wrapFn ? `${wrapFn}([...${listVar},${newEntry}])` : `[...${listVar},${newEntry}]`; @@ -279,8 +279,8 @@ const patchAlwaysShowInModelSelector = (oldFile: string): string | null => { // Inject pushes BEFORE the conditional return // This ensures opusplan and opusplan[1m] are always in the list const inject = - `${listVar}.push({value:"opusplan",label:"Opus Plan Mode",description:"Use Opus 4.6 in plan mode, Sonnet 4.6 otherwise"});` + - `${listVar}.push({value:"opusplan[1m]",label:"Opus Plan Mode 1M",description:"Use Opus 4.6 in plan mode, Sonnet 4.6 (1M context) otherwise"});`; + `${listVar}.push({value:"opusplan",label:"Opus Plan Mode",description:"Use Opus in plan mode, Sonnet otherwise"});` + + `${listVar}.push({value:"opusplan[1m]",label:"Opus Plan Mode 1M",description:"Use Opus in plan mode, Sonnet (1M context) otherwise"});`; const newFile = oldFile.slice(0, match.index) + inject + oldFile.slice(match.index); diff --git a/src/patches/patchesAppliedIndication.ts b/src/patches/patchesAppliedIndication.ts index 6e078ba4..6f2c5f6f 100644 --- a/src/patches/patchesAppliedIndication.ts +++ b/src/patches/patchesAppliedIndication.ts @@ -335,15 +335,22 @@ const findPatchesListLocation = ( } const matchResult = { index: versionDisplayMatch.index }; - // 2. Go back 1500 chars from the match start - const lookbackStart = Math.max(0, matchResult.index - 1500); + // 2. Go back 5000 chars from the match start. CC ≥2.1.140 emits a very long + // React-compiled header function (Cf4) where the version display lives ~1900+ bytes + // after the function head. PATCH 2's own insertions push that further. 5000 leaves + // a comfortable margin for future CC builds while still being scoped to "this region". + const lookbackStart = Math.max(0, matchResult.index - 5000); const lookbackSubstring = fileContents.slice( lookbackStart, matchResult.index ); - // 3. Take the last `}function ([$\w]+)\(` - const functionPattern = /\}function ([$\w]+)\(/g; + // 3. Take the last function-declaration boundary. CC ≤2.1.138 emitted these as + // `}function NAME(` (close-brace immediately followed by `function`). CC 2.1.140 + // emits them as `});function NAME(` (var/IIFE block close + semicolon, then + // `function`). Allow either `}`, `;`, `)`, or `,` as the boundary char and let + // arbitrary whitespace sit between the boundary and `function`. + const functionPattern = /[};,)]\s*function ([$\w]+)\(/g; const functionMatches = Array.from( lookbackSubstring.matchAll(functionPattern) ); diff --git a/src/patches/sessionMemory.ts b/src/patches/sessionMemory.ts index 5a3f82c7..01c91ee3 100644 --- a/src/patches/sessionMemory.ts +++ b/src/patches/sessionMemory.ts @@ -39,19 +39,38 @@ const patchExtraction = (file: string): string | null => { const pattern = /function [$\w]+\(\)\{return [$\w]+\("tengu_session_memory"/; const match = file.match(pattern); - if (!match || match.index === undefined) { - console.error('patch: sessionMemory: failed to find extraction gate'); - return null; + if (match && match.index !== undefined) { + const insertIndex = match.index + match[0].indexOf('{') + 1; + const insertion = 'return true;'; + + const newFile = + file.slice(0, insertIndex) + insertion + file.slice(insertIndex); + + showDiff(file, newFile, insertion, insertIndex, insertIndex); + return newFile; } - const insertIndex = match.index + match[0].indexOf('{') + 1; - const insertion = 'return true;'; + const anchor = 'querySource:"extract_memories",forkLabel:"extract_memories"'; + const anchorIndex = file.indexOf(anchor); - const newFile = - file.slice(0, insertIndex) + insertion + file.slice(insertIndex); + if (anchorIndex !== -1) { + const windowEnd = Math.min(file.length, anchorIndex + 8000); + const window = file.slice(anchorIndex, windowEnd); + const gatePattern = /if\(![$\w]+\("tengu_passport_quail",!1\)\)return;/; + const gateMatch = window.match(gatePattern); - showDiff(file, newFile, insertion, insertIndex, insertIndex); - return newFile; + if (gateMatch && gateMatch.index !== undefined) { + const startIndex = anchorIndex + gateMatch.index; + const endIndex = startIndex + gateMatch[0].length; + const newFile = file.slice(0, startIndex) + file.slice(endIndex); + + showDiff(file, newFile, '', startIndex, endIndex); + return newFile; + } + } + + console.error('patch: sessionMemory: failed to find extraction gate'); + return null; }; /** @@ -112,14 +131,21 @@ const patchPastSessions = (file: string): string | null => { /** * Patch 3: Make per-section and total file token limits configurable via env vars */ -const patchTokenLimits = (file: string): string | null => { +const patchTokenLimits = ( + file: string, + logFailure: boolean = true +): string | null => { // Pattern matches: =2000 ... =12000 ... # Session Title const pattern = /(=)2000((?:.|\n){0,15}?=)12000((?:.|\n){0,20}# Session Title)/; const match = file.match(pattern); if (!match || match.index === undefined) { - console.error('patch: sessionMemory: failed to find token limits pattern'); + if (logFailure) { + console.error( + 'patch: sessionMemory: failed to find token limits pattern' + ); + } return null; } @@ -141,7 +167,10 @@ const patchTokenLimits = (file: string): string | null => { /** * Patch 4: Make session memory update thresholds configurable via env vars */ -const patchUpdateThresholds = (file: string): string | null => { +const patchUpdateThresholds = ( + file: string, + logFailure: boolean = true +): string | null => { let newFile = file; // Replace minimumMessageTokensToInit @@ -167,9 +196,11 @@ const patchUpdateThresholds = (file: string): string | null => { // Check if any replacements were made if (newFile === file) { - console.error( - 'patch: sessionMemory: failed to find update thresholds patterns' - ); + if (logFailure) { + console.error( + 'patch: sessionMemory: failed to find update thresholds patterns' + ); + } return null; } @@ -183,12 +214,27 @@ export const writeSessionMemory = (oldFile: string): string | null => { let newFile = patchExtraction(oldFile); if (!newFile) return null; + const usedLegacyExtraction = newFile.includes('tengu_session_memory'); + newFile = patchPastSessions(newFile); if (!newFile) return null; - newFile = patchTokenLimits(newFile); - if (!newFile) return null; + const tokenLimitsFile = patchTokenLimits(newFile, usedLegacyExtraction); + if (tokenLimitsFile) { + newFile = tokenLimitsFile; + } else if (usedLegacyExtraction) { + return null; + } + + const updateThresholdsFile = patchUpdateThresholds( + newFile, + usedLegacyExtraction + ); + if (updateThresholdsFile) { + newFile = updateThresholdsFile; + } else if (usedLegacyExtraction) { + return null; + } - newFile = patchUpdateThresholds(newFile); return newFile; }; diff --git a/src/patches/showMoreItemsInSelectMenus.ts b/src/patches/showMoreItemsInSelectMenus.ts index f808b9af..ca30d6d2 100644 --- a/src/patches/showMoreItemsInSelectMenus.ts +++ b/src/patches/showMoreItemsInSelectMenus.ts @@ -183,16 +183,12 @@ export const writeShowMoreItemsInSelectMenus = ( ); } - // Also patch the slash command autocomplete suggestions cap + // Also patch the slash command autocomplete suggestions cap when present. // Math.min(6,Math.max(1,rows-3)) → Math.max(1,rows-3) - // The Math.min(6,...) hardcaps visible suggestions to 6 + // CC 2.1.138 removed this obsolete non-overlay fallback, so absence is OK. const suggestionsPatched = patchSuggestionsCap(newFile); if (suggestionsPatched) { newFile = suggestionsPatched; - } else { - console.error( - 'patch: writeShowMoreItemsInSelectMenus: failed to find suggestions cap pattern' - ); } return newFile; diff --git a/src/patches/slashCommands.ts b/src/patches/slashCommands.ts index 77bc34c2..7dabee69 100644 --- a/src/patches/slashCommands.ts +++ b/src/patches/slashCommands.ts @@ -3,49 +3,90 @@ import { showDiff } from './index'; /** - * Find the end position of the slash command array using stack machine + * Walk forward from an opening '[' counting top-level items. + * Returns the position of the matching ']' and the item count, or null if + * the array isn't well-formed (EOF reached). Handles strings, nested brackets, + * parens, braces, and template literals. */ -export const findSlashCommandListEndPosition = ( - fileContents: string -): number | null => { - // Find the array with 30+ elements (slash commands list) - const arrayStartPattern = /=>\[([$a-zA-Z_][$\w]{1,2},){30}/; - const match = fileContents.match(arrayStartPattern); +const analyzeArrayFromOpenBracket = ( + fileContents: string, + openBracketIndex: number +): { itemCount: number; closingBracket: number } | null => { + let depth = 1; + let i = openBracketIndex + 1; + let itemCount = 0; + let inItem = false; + let inString: string | null = null; + let escape = false; - if (!match || match.index === undefined) { - console.error( - 'patch: findSlashCommandListEndPosition: failed to find arrayStartPattern' - ); - return null; - } - - // Find the '[' in the match - const bracketIndex = fileContents.indexOf('[', match.index); - if (bracketIndex === -1) { - console.error( - 'patch: findSlashCommandListEndPosition: failed to find bracketIndex' - ); - return null; + while (i < fileContents.length) { + const c = fileContents[i]; + if (inString) { + if (escape) { + escape = false; + } else if (c === '\\') { + escape = true; + } else if (c === inString) { + inString = null; + } + i++; + continue; + } + if (c === '"' || c === "'" || c === '`') { + inString = c; + inItem = true; + } else if (c === '[' || c === '(' || c === '{') { + depth++; + inItem = true; + } else if (c === ']') { + if (depth === 1) { + if (inItem) itemCount++; + return { itemCount, closingBracket: i }; + } + depth--; + } else if (c === ')' || c === '}') { + depth--; + } else if (c === ',' && depth === 1) { + if (inItem) itemCount++; + inItem = false; + } else if (!/\s/.test(c)) { + inItem = true; + } + i++; } + return null; +}; - // Use stack machine to find the matching ']' - let level = 1; // We're already inside the array - let i = bracketIndex + 1; - - while (i < fileContents.length && level > 0) { - if (fileContents[i] === '[') { - level++; - } else if (fileContents[i] === ']') { - level--; - if (level === 0) { - return i; // This is the end of the array +/** + * Find the end position of the slash command array using stack machine. + * + * Supports both pre-2.1.138 form (plain `=>[ID,ID,...]` with 30+ bare + * identifiers) and 2.1.138+ form where the array uses spread operators for + * conditionally-included commands, e.g.: + * =L8(()=>[AUK,pL4,DX4,y64,...gT4?[gT4]:[],Qj4,lI6,vL4,...,W94(),...]) + */ +export const findSlashCommandListEndPosition = ( + fileContents: string +): number | null => { + // Walk every `=>[` candidate. The slash command array is the (only) array + // following an arrow-return that contains >= 30 top-level items. + const arrowPattern = /=>\s*\[/g; + let m: RegExpExecArray | null; + let best: { closing: number; items: number } | null = null; + while ((m = arrowPattern.exec(fileContents)) !== null) { + const bracketIndex = m.index + m[0].length - 1; // position of '[' + const info = analyzeArrayFromOpenBracket(fileContents, bracketIndex); + if (info && info.itemCount >= 30) { + if (!best || info.itemCount > best.items) { + best = { closing: info.closingBracket, items: info.itemCount }; } } - i++; } + if (best) return best.closing; + console.error( - 'patch: findSlashCommandListEndPosition: failed to find matching closing-bracket' + 'patch: findSlashCommandListEndPosition: failed to find arrayStartPattern' ); return null; }; diff --git a/src/patches/statuslineUpdateThrottle.ts b/src/patches/statuslineUpdateThrottle.ts index d9e36723..7a6b8878 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]+\.useCallback\(\(\)=>\{if\([$\w]+\.current!==void 0\)clearTimeout\([$\w]+\.current\);[$\w]+\.current=setTimeout\(\([$\w]+,[$\w]+\)=>\{[$\w]+\.current=void 0,[$\w]+\(\)\},300,[$\w]+,\2\)\},\[\2\]\))/; + /(,([$\w]+)=([$\w]+(?:\.default)?)\.useCallback.{0,1000}statusLineText.{0,200}?),([$\w]+)=([$\w.]+\(\(\)=>(\2\(([$\w]+)\)),300\)|[$\w]+\(\(\)=>\{\2\(\)\},300\)|[$\w]+\(\2,300\)|.{0,100}\{[$\w]+\.current=void 0,\2\(\)\},300\)\},\[\2\]\)|[$\w]+\.useCallback\(\(\)=>\{if\([$\w]+\.current!==void 0\)clearTimeout\([$\w]+\.current\);[$\w]+\.current=setTimeout\(\([$\w]+,[$\w]+\)=>\{[$\w]+\.current=void 0,[$\w]+\(\)\},300,[$\w]+,\2\)\},\[\2\]\))/; const match = oldFile.match(pattern); diff --git a/src/patches/suppressLineNumbers.ts b/src/patches/suppressLineNumbers.ts index 6113ef69..c3185134 100644 --- a/src/patches/suppressLineNumbers.ts +++ b/src/patches/suppressLineNumbers.ts @@ -24,9 +24,12 @@ export const writeSuppressLineNumbers = (oldFile: string): string | null => { // CC <2.1.88: arrow branch only // if(VAR.length>=N)return`...→...`;return`...→...` - // Find the function by its unique signature + // Find the function by its unique signature. + // CC 2.1.140+ adds an optional `tabAwareSeparator:VAR=!1` param and replaces + // the `split(/\r?\n/)` body with an indexOf-based loop, so we only anchor on + // the destructured-params + empty-guard prefix (which is still unique). const funcSig = - /\{content:([$\w]+),startLine:[$\w]+\}\)\{if\(!\1\)return"";let ([$\w]+)=\1\.split\([^)]+\);/; + /\{content:([$\w]+),startLine:[$\w]+(?:,tabAwareSeparator:[$\w]+=!1)?\}\)\{if\(!\1\)return"";/; const sigMatch = oldFile.match(funcSig); if (sigMatch && sigMatch.index !== undefined) { diff --git a/src/patches/tableFormat.ts b/src/patches/tableFormat.ts index 00433491..f3a181e8 100644 --- a/src/patches/tableFormat.ts +++ b/src/patches/tableFormat.ts @@ -221,6 +221,12 @@ export const writeTableFormat = ( oldFile: string, tableFormat: TableFormat ): string | null => { + // Accept 'markdown' as an alias for 'ascii' (the ascii mode produces + // markdown-style tables, per the patch's own docs). + if ((tableFormat as string) === 'markdown') { + tableFormat = 'ascii'; + } + // If tableFormat is 'default', don't modify anything (keep original box-drawing) if (tableFormat === 'default') { debug('Table format is "default", no patching needed'); @@ -404,6 +410,9 @@ export const writeTableFormat = ( // Unknown format // ========================================================================== else { + console.error( + `patch: tableFormat: failed to find handler for unknown format "${tableFormat}"` + ); debug(`Unknown table format "${tableFormat}", skipping`); return null; } @@ -412,6 +421,9 @@ export const writeTableFormat = ( // Final reporting // ========================================================================== if (patchCount === 0) { + console.error( + `patch: tableFormat: failed to find any table-rendering patterns to patch for format "${tableFormat}" (border-definition object, vertical-border chars, horizontal separator, or inter-row separator)` + ); verbose( 'No table format patches were applied - patterns may not have matched' ); diff --git a/src/patches/themes.ts b/src/patches/themes.ts index 421a9f67..d07f0c55 100644 --- a/src/patches/themes.ts +++ b/src/patches/themes.ts @@ -70,14 +70,49 @@ function getThemesLocation(oldFile: string): { } // === Theme Options Array === - // Both old and new: [{label:"...",value:"..."}, ...] or [{"label":"...",...] - const objArrPat = + // Old form (CC ≤2.1.138): inline array literal + // [{label:"Dark mode",value:"dark"},{label:"Light mode",value:"light"},...] + // New form (CC ≥2.1.140): each option assigned to its own var (React-compiler + // memoization), then collected via `[i,e,DH,YH,s,o,HH,...m.map(VA5),...mH]`. + // We must preserve the trailing `,...spread` chunks (custom themes, "New custom + // theme..." sentinel) so users can still add custom themes through CC's UI. + let objArrStart = -1; + let objArrEnd = -1; + let objArrTrailingSpreads = ''; + + const oldObjArrPat = /\[(?:\.\.\.\[\],)?(?:\{"?label"?:"(?:Dark|Light|Auto|Monochrome)[^"]*","?value"?:"[^"]+"\},?)+\]/; - const objArrMatch = oldFile.match(objArrPat); + const oldObjArrMatch = oldFile.match(oldObjArrPat); - if (!objArrMatch || objArrMatch.index == undefined) { - console.error('patch: themes: failed to find objArrMatch'); - return null; + if (oldObjArrMatch && oldObjArrMatch.index !== undefined) { + objArrStart = oldObjArrMatch.index; + objArrEnd = oldObjArrMatch.index + oldObjArrMatch[0].length; + } else { + // Find each `var={label:"Theme Name",value:"theme-id"}` assignment. + const themeVarAssignPat = + /([$\w]+)=\{label:"(?:Auto|Dark|Light|Monochrome)[^"]*",value:"[^"]+"\}/g; + const assigns = [...oldFile.matchAll(themeVarAssignPat)]; + if (assigns.length < 2) { + console.error('patch: themes: failed to find objArrMatch'); + return null; + } + const themeVars = assigns.map(m => m[1]); + + // Find an array whose prefix is exactly these vars (in order), optionally + // followed by `...spread` chunks. The vars must not be preceded by `,` so + // we don't accidentally land in the middle of a longer array. + const escVars = themeVars.map(v => v.replace(/\$/g, '\\$')).join(','); + const arrayPat = new RegExp(`\\[${escVars}((?:,\\.\\.\\.[^\\]]+)*)\\]`); + const arrayMatch = oldFile.match(arrayPat); + if (!arrayMatch || arrayMatch.index === undefined) { + console.error( + 'patch: themes: failed to find objArrMatch (new var-collected form)' + ); + return null; + } + objArrStart = arrayMatch.index; + objArrEnd = arrayMatch.index + arrayMatch[0].length; + objArrTrailingSpreads = arrayMatch[1]; } // === Theme Name Mapping Object === @@ -98,8 +133,11 @@ function getThemesLocation(oldFile: string): { identifiers: [switchIdent], }, objArr: { - startIndex: objArrMatch.index, - endIndex: objArrMatch.index + objArrMatch[0].length, + startIndex: objArrStart, + endIndex: objArrEnd, + // Stash the trailing `,...spread,...spread` so the writer can preserve it + // (only present in the new var-collected form; empty string for old form). + identifiers: [objArrTrailingSpreads], }, obj: { startIndex: objMatch.index, @@ -144,10 +182,14 @@ export const writeThemes = ( ); oldFile = newFile; - // Update theme options array (objArr) - const objArr = JSON.stringify( - themes.map(theme => ({ label: theme.name, value: theme.id })) - ); + // Update theme options array (objArr). + // For 2.1.140+ var-collected form, preserve trailing `,...m.map(...),...mH` + // spreads so users can still add custom themes through CC's UI. + const trailingSpreads = locations.objArr.identifiers?.[0] ?? ''; + const objArrInner = themes + .map(theme => JSON.stringify({ label: theme.name, value: theme.id })) + .join(','); + const objArr = `[${objArrInner}${trailingSpreads}]`; newFile = newFile.slice(0, locations.objArr.startIndex) + objArr + diff --git a/src/patches/thinkerFormat.ts b/src/patches/thinkerFormat.ts index aed6bd45..b3f2587f 100644 --- a/src/patches/thinkerFormat.ts +++ b/src/patches/thinkerFormat.ts @@ -7,16 +7,13 @@ const getThinkerFormatLocation = (oldFile: string): LocationResult | null => { /spinnerTip:[$\w]+,(?:[$\w]+:[$\w]+,)*overrideMessage:[$\w]+,.{300}/; const approxAreaMatch = oldFile.match(approxAreaPattern); - if (!approxAreaMatch || approxAreaMatch.index == undefined) { - console.error('patch: thinker format: failed to find approxAreaMatch'); - return null; - } + const searchStart = approxAreaMatch?.index; // Search within a range of 1000 characters to support CC 2.0.76+ - const searchSection = oldFile.slice( - approxAreaMatch.index, - approxAreaMatch.index + 10000 - ); + const searchSection = + searchStart === undefined + ? '' + : oldFile.slice(searchStart, searchStart + 10000); // New nullish format: N=(Y??C?.activeForm??L)+"…" const formatPatternOld = /,([$\w]+)(=\(([^;]{1,200}?)\)\+"(?:…|\\u2026)")/; @@ -25,12 +22,9 @@ const getThinkerFormatLocation = (oldFile: string): LocationResult | null => { if (formatMatchOld && formatMatchOld.index != undefined) { return { startIndex: - approxAreaMatch.index + - formatMatchOld.index + - formatMatchOld[1].length + - 1, // + 1 for the comma + searchStart! + formatMatchOld.index + formatMatchOld[1].length + 1, // + 1 for the comma endIndex: - approxAreaMatch.index + + searchStart! + formatMatchOld.index + formatMatchOld[1].length + formatMatchOld[2].length + @@ -47,12 +41,9 @@ const getThinkerFormatLocation = (oldFile: string): LocationResult | null => { if (formatMatchNew && formatMatchNew.index != undefined) { return { startIndex: - approxAreaMatch.index + - formatMatchNew.index + - formatMatchNew[1].length + - 1, // + 1 for the comma + searchStart! + formatMatchNew.index + formatMatchNew[1].length + 1, // + 1 for the comma endIndex: - approxAreaMatch.index + + searchStart! + formatMatchNew.index + formatMatchNew[1].length + formatMatchNew[2].length + @@ -61,6 +52,39 @@ const getThinkerFormatLocation = (oldFile: string): LocationResult | null => { }; } + const formatPatternNewGlobal = new RegExp(formatPatternNew.source, 'g'); + const formatMatches = [...oldFile.matchAll(formatPatternNewGlobal)].filter( + match => { + if (match.index == undefined) { + return false; + } + const context = oldFile.slice( + Math.max(0, match.index - 2500), + match.index + 1000 + ); + return ( + context.includes('overrideMessage:') && + context.includes('.activeForm') && + context.includes('.isIdle') && + context.includes('.spinnerVerb') && + context.includes('spinnerTip') + ); + } + ); + + if (formatMatches.length === 1) { + const formatMatch = formatMatches[0]; + return { + startIndex: formatMatch.index! + formatMatch[1].length + 1, // + 1 for the comma + endIndex: + formatMatch.index! + formatMatch[1].length + formatMatch[2].length + 1, // + 1 for the comma + identifiers: [formatMatch[3]], + }; + } + + if (searchStart === undefined) { + console.error('patch: thinker format: failed to find approxAreaMatch'); + } console.error('patch: thinker format: failed to find formatMatch'); return null; }; diff --git a/src/patches/toolsets.ts b/src/patches/toolsets.ts index 6d261b76..a9f09696 100644 --- a/src/patches/toolsets.ts +++ b/src/patches/toolsets.ts @@ -392,9 +392,10 @@ export const writeComputeToolsFilter = ( // stateInfo validated above — computeTools reads toolset from STORE.getState() directly // Find the computeTools closure pattern: - // VAR=()=>{let STATE=STORE.getState(),ASSEMBLED=ASSEMBLE(STATE.toolPermissionContext,STATE.mcp.tools),MERGED=MERGE(INIT,ASSEMBLED,STATE.toolPermissionContext.mode);if(!AGENT)return MERGED;return RESOLVE(AGENT,MERGED,!1,!0).resolvedTools} + // Old form: VAR=()=>{let STATE=STORE.getState(),ASSEMBLED=ASSEMBLE(STATE.toolPermissionContext,STATE.mcp.tools),MERGED=MERGE(INIT,ASSEMBLED,STATE.toolPermissionContext.mode);if(!AGENT)return MERGED;return RESOLVE(AGENT,MERGED,!1,!0).resolvedTools} + // CC 2.1.140+: VAR=NS.useCallback(()=>{...let ASSEMBLED=ASSEMBLE(STATE.toolPermissionContext,STATE.mcp.tools,{skillTools:STATE.skillTools}),...},[deps]) const pattern = - /([$\w]+)=\(\)=>\{let ([$\w]+)=([$\w]+)\.getState\(\),([$\w]+)=([$\w]+)\(\2\.toolPermissionContext,\2\.mcp\.tools\),([$\w]+)=([$\w]+)\([$\w]+,\4,\2\.toolPermissionContext\.mode\);if\(!([$\w]+)\)return \6;return ([$\w]+)\(\8,\6,!1,!0\)\.resolvedTools\}/; + /([$\w]+)=(?:([$\w]+\.useCallback\())?\(\)=>\{let ([$\w]+)=([$\w]+)\.getState\(\),([$\w]+)=([$\w]+)\(\3\.toolPermissionContext,\3\.mcp\.tools(?:,\{skillTools:\3\.skillTools\})?\),([$\w]+)=([$\w]+)\([$\w]+,\5,\3\.toolPermissionContext\.mode\);if\(!([$\w]+)\)return \7;return ([$\w]+)\(\9,\7,!1,!0\)\.resolvedTools\}/; const match = oldFile.match(pattern); if (!match || match.index === undefined) { @@ -405,14 +406,18 @@ export const writeComputeToolsFilter = ( } const closureVar = match[1]; - const stateVar = match[2]; - const storeVar = match[3]; - const assembledVar = match[4]; - const assembleFn = match[5]; - const mergedVar = match[6]; - const mergeFn = match[7]; - const agentVar = match[8]; - const resolveFn = match[9]; + const useCallbackPrefix = match[2] || ''; + const stateVar = match[3]; + const storeVar = match[4]; + const assembledVar = match[5]; + const assembleFn = match[6]; + const mergedVar = match[7]; + const mergeFn = match[8]; + const agentVar = match[9]; + const resolveFn = match[10]; + const skillToolsArg = match[0].includes(`{skillTools:${stateVar}.skillTools}`) + ? `,{skillTools:${stateVar}.skillTools}` + : ''; // Create toolsets mapping const toolsetsJSON = JSON.stringify( @@ -445,7 +450,7 @@ export const writeComputeToolsFilter = ( const initVar = mergeCallMatch[1]; // Set globalThis.__tweakcc_toolset so the error message helper can read it - const newClosure = `${closureVar}=()=>{let ${stateVar}=${storeVar}.getState(),${assembledVar}=${assembleFn}(${stateVar}.toolPermissionContext,${stateVar}.mcp.tools),${mergedVar}=${mergeFn}(${initVar},${assembledVar},${stateVar}.toolPermissionContext.mode);const __ts=${toolsetsJSON},__tc=${stateVar}.toolset??${fallback},__tf=(t)=>{globalThis.__tweakcc_toolset={name:__tc,tools:__ts[__tc]};if(__ts.hasOwnProperty(__tc)){const a=__ts[__tc];if(a==="*")return t;return t.filter(d=>a.includes(d.name))}return t};if(!${agentVar})return __tf(${mergedVar});return __tf(${resolveFn}(${agentVar},${mergedVar},!1,!0).resolvedTools)}`; + const newClosure = `${closureVar}=${useCallbackPrefix}()=>{let ${stateVar}=${storeVar}.getState(),${assembledVar}=${assembleFn}(${stateVar}.toolPermissionContext,${stateVar}.mcp.tools${skillToolsArg}),${mergedVar}=${mergeFn}(${initVar},${assembledVar},${stateVar}.toolPermissionContext.mode);const __ts=${toolsetsJSON},__tc=${stateVar}.toolset??${fallback},__tf=(t)=>{globalThis.__tweakcc_toolset={name:__tc,tools:__ts[__tc]};if(__ts.hasOwnProperty(__tc)){const a=__ts[__tc];if(a==="*")return t;return t.filter(d=>a.includes(d.name))}return t};if(!${agentVar})return __tf(${mergedVar});return __tf(${resolveFn}(${agentVar},${mergedVar},!1,!0).resolvedTools)}`; const startIndex = match.index; const endIndex = startIndex + fullMatch.length; @@ -478,10 +483,13 @@ export const writeToolsetAwareErrors = ( // Note: toolsets/defaultToolset params are unused — the helper reads from // globalThis.__tweakcc_toolset at runtime (set by writeComputeToolsFilter). - // Replace the error template strings with toolset-aware versions - // Pattern: `Error: No such tool available: ${VARNAME}` + // Replace the error template strings with toolset-aware versions. + // CC <2.1.140 pattern: `Error: No such tool available: ${VARNAME}` + // CC >=2.1.140 pattern: `Error: No such tool available: ${VARNAME}${HINT}` + // (second interpolation is an extra hint produced by a helper like $N6, + // e.g. ". exists but is not enabled in this context.") const errorPattern = - /`Error: No such tool available: \$\{([$\w.]+)\}<\/tool_use_error>`/g; + /`Error: No such tool available: \$\{([$\w.]+)\}(?:\$\{([$\w.]+)\})?<\/tool_use_error>`/g; let newFile = oldFile; let matchCount = 0; @@ -489,17 +497,20 @@ export const writeToolsetAwareErrors = ( // Helper reads from globalThis.__tweakcc_toolset (set by computeTools filter in sub-patch 2b) const helperName = '__tweakcc_toolErrorMsg'; const helperFn = - `function ${helperName}(toolName){` + + `function ${helperName}(toolName,hint){` + + `hint=hint||"";` + `var info=globalThis.__tweakcc_toolset;` + `if(info&&info.tools&&info.tools!=="*"&&Array.isArray(info.tools)){` + - `return "Error: No such tool available: "+toolName+". The active toolset is '"+info.name+"' which only includes: "+info.tools.join(", ")+". Do not attempt to use "+toolName+" again — it will fail. If the user switches toolsets via /toolset, you may retry."` + - `}return "Error: No such tool available: "+toolName+""` + + `return "Error: No such tool available: "+toolName+hint+". The active toolset is '"+info.name+"' which only includes: "+info.tools.join(", ")+". Do not attempt to use "+toolName+" again — it will fail. If the user switches toolsets via /toolset, you may retry."` + + `}return "Error: No such tool available: "+toolName+hint+""` + `};`; // Replace all error template literals with helper calls - newFile = newFile.replace(errorPattern, (_match, varName) => { + newFile = newFile.replace(errorPattern, (_match, varName, hintVar) => { matchCount++; - return `${helperName}(${varName})`; + return hintVar + ? `${helperName}(${varName},${hintVar})` + : `${helperName}(${varName})`; }); if (matchCount === 0) { @@ -510,9 +521,13 @@ export const writeToolsetAwareErrors = ( } // Also replace the toolUseResult versions (without XML tags) - const resultPattern = /`Error: No such tool available: \$\{([$\w.]+)\}`/g; - newFile = newFile.replace(resultPattern, (_match, varName) => { - return `${helperName}(${varName}).replace(/<\\/?tool_use_error>/g,"")`; + const resultPattern = + /`Error: No such tool available: \$\{([$\w.]+)\}(?:\$\{([$\w.]+)\})?`/g; + newFile = newFile.replace(resultPattern, (_match, varName, hintVar) => { + const call = hintVar + ? `${helperName}(${varName},${hintVar})` + : `${helperName}(${varName})`; + return `${call}.replace(/<\\/?tool_use_error>/g,"")`; }); // Inject the helper function at the top of the file (after the shebang/comments) @@ -694,8 +709,9 @@ export const writeToolsetComponentDefinition = ( export const findShiftTabAppStateVarInsertionPoint = ( oldFile: string ): number | null => { - // Search for the bash mode indicator - const bashModePattern = /\{color:"bashBorder"\},"! for bash mode"/; + // Search for the bash mode indicator. + // CC <2.1.140 used "! for bash mode"; CC >=2.1.140 renamed it to "! for shell mode". + const bashModePattern = /\{color:"bashBorder"\},"! for (?:bash|shell) mode"/; const match = oldFile.match(bashModePattern); if (!match || match.index === undefined) { diff --git a/src/patches/userMessageDisplay.ts b/src/patches/userMessageDisplay.ts index ae231897..bfdd81a6 100644 --- a/src/patches/userMessageDisplay.ts +++ b/src/patches/userMessageDisplay.ts @@ -130,10 +130,6 @@ export const writeUserMessageDisplay = ( } const boxComponent = findBoxComponent(oldFile); - if (!boxComponent) { - console.error('patch: userMessageDisplay: failed to find Box component'); - return null; - } const chalkVar = findChalkVar(oldFile); if (!chalkVar) { @@ -152,8 +148,16 @@ export const writeUserMessageDisplay = ( const newPattern = /(No content found in user prompt message.{0,50}?\b)(([$\w]+(?:\.default)?)\.createElement\([$\w]+,\{flexDirection:"column"[^}]*\},([$\w]+(?:\.default)?\.createElement)\([$\w]+,\{text:([$\w]+)[^}]*\}\)\))/; + // CC 2.1.138: child display is memoized before the parent Box call. + // Replace only the child assignment so React compiler cache bookkeeping remains intact. + const memoizedChildPattern = + /(No content found in user prompt message.{0,1200}?)([$\w]+)=([$\w]+(?:\.default)?\.createElement)\([$\w]+,\{text:([$\w]+),useBriefLayout:[$\w]+,timestamp:[$\w]+\}\)/; + const oldMatch = oldFile.match(pattern); - const match = oldMatch ?? oldFile.match(newPattern); + const newMatch = oldMatch ? null : oldFile.match(newPattern); + const memoizedChildMatch = + oldMatch || newMatch ? null : oldFile.match(memoizedChildPattern); + const match = oldMatch ?? newMatch ?? memoizedChildMatch; if (!match || match.index === undefined) { console.error( @@ -165,14 +169,26 @@ export const writeUserMessageDisplay = ( let createElementFn: string; let messageVar: string; + let localBoxComponent: string | undefined; + if (oldMatch) { // Old pattern matches createElementFn = match[4]; messageVar = match[6] ?? match[7]; - } else { + } else if (newMatch) { // New pattern (CC ≥2.1.79) createElementFn = match[4]; messageVar = match[5]; + } else { + // Memoized child pattern (CC 2.1.138) + createElementFn = match[3]; + messageVar = match[4]; + } + + const resolvedBoxComponent = localBoxComponent ?? boxComponent; + if (!resolvedBoxComponent) { + console.error('patch: userMessageDisplay: failed to find Box component'); + return null; } // Build box attributes (border and padding) @@ -252,9 +268,10 @@ export const writeUserMessageDisplay = ( const chalkFormattedString = `${chalkChain}(${formattedMessage})`; // Build replacement: match[1] + createElement(Box, boxProps, createElement(Text, null, chalkFormattedString)) + const replacementPrefix = memoizedChildMatch ? `${match[2]}=` : ''; const replacement = match[1] + - `${createElementFn}(${boxComponent},${boxAttrsObjStr},${createElementFn}(${textComponent},null,${chalkFormattedString}))`; + `${replacementPrefix}${createElementFn}(${resolvedBoxComponent},${boxAttrsObjStr},${createElementFn}(${textComponent},null,${chalkFormattedString}))`; const startIndex = match.index; const endIndex = startIndex + match[0].length; diff --git a/src/patches/verboseProperty.ts b/src/patches/verboseProperty.ts index 04dcbda3..66ef1fbb 100644 --- a/src/patches/verboseProperty.ts +++ b/src/patches/verboseProperty.ts @@ -4,12 +4,16 @@ import { LocationResult, showDiff } from './index'; const getVerbosePropertyLocation = (oldFile: string): LocationResult | null => { const createElementPattern = + /createElement\([$\w]+,\{(?=[^}]*responseLengthRef:)(?=[^}]*spinnerSuffix:)(?=[^}]*thinkingStatus:)(?=[^}]*isCompacting:)[^}]*verbose:[^,}]+[^}]*\}/; + const legacyCreateElementPattern = /createElement\([$\w]+,\{[^}]+spinnerTip[^}]+overrideMessage[^}]+\}/; - const createElementMatch = oldFile.match(createElementPattern); + const createElementMatch = + oldFile.match(createElementPattern) ?? + oldFile.match(legacyCreateElementPattern); if (!createElementMatch || createElementMatch.index === undefined) { console.error( - 'patch: verbose: failed to find createElement with spinnerTip and overrideMessage' + 'patch: verbose: failed to find createElement with verbose spinner props' ); return null; } diff --git a/src/patches/voiceMode.ts b/src/patches/voiceMode.ts index 69957025..2b2d1f9a 100644 --- a/src/patches/voiceMode.ts +++ b/src/patches/voiceMode.ts @@ -58,6 +58,31 @@ const patchAmberQuartz = (file: string): string | null => { return newFile; } + // CC >=2.1.140: tengu_amber_quartz feature gate removed entirely. The voice slash + // command now uses two helpers: an always-true `isEnabled` gate and a separate + // login-aware `isHidden` gate of the form `function XaH(){return Lp6()&&HZ$()}`. + // We locate the gate function by name via the voice command's `get isHidden()` + // getter, then force-enable it (which also bypasses the OAuth login requirement). + const voicePattern = + /name:"voice",description:"Toggle voice mode"[\s\S]{0,500}?get isHidden\(\)\{return!([$\w]+)\(\)\}/; + const voiceMatch = file.match(voicePattern); + + if (voiceMatch && voiceMatch.index !== undefined) { + const funcName = voiceMatch[1]; + const escaped = funcName.replace(/[$]/g, '\\$'); + const funcPattern = new RegExp(`function ${escaped}\\(\\)\\{`); + const funcMatch = file.match(funcPattern); + + if (funcMatch && funcMatch.index !== undefined) { + const insertIndex = funcMatch.index + funcMatch[0].length; + const insertion = 'return !0;'; + const newFile = + file.slice(0, insertIndex) + insertion + file.slice(insertIndex); + showDiff(file, newFile, insertion, insertIndex, insertIndex); + return newFile; + } + } + console.error('patch: voiceMode: failed to find tengu_amber_quartz gate'); return null; }; From 098c38fac05a2f37599a2ba35f78693a6723299b Mon Sep 17 00:00:00 2001 From: Kevin Roberts Date: Sat, 30 May 2026 17:26:18 -0600 Subject: [PATCH 2/2] Fix patching for 2.1.158 --- probe-patches.mts | 238 ------------------ src/patches/agentsMd.ts | 37 +-- src/patches/allowBypassPermsInSudo.ts | 3 + src/patches/autoAcceptPlanMode.test.ts | 20 ++ src/patches/autoAcceptPlanMode.ts | 134 ++++++++-- src/patches/contextLimit.ts | 21 +- src/patches/conversationTitle.ts | 58 +++++ src/patches/fixLspSupport.ts | 10 + src/patches/helpers.ts | 20 +- src/patches/hideStartupBanner.ts | 21 +- src/patches/index.ts | 115 +++++++-- src/patches/inputPatternHighlighters.test.ts | 45 ++++ src/patches/inputPatternHighlighters.ts | 35 ++- src/patches/modelCustomizationsToggle.test.ts | 54 +++- src/patches/modelSelector.ts | 7 +- src/patches/patchesAppliedIndication.ts | 79 +++--- src/patches/rememberSkill.test.ts | 45 ++++ src/patches/rememberSkill.ts | 51 +++- src/patches/sessionMemory.ts | 35 ++- src/patches/showMoreItemsInSelectMenus.ts | 52 ++-- src/patches/slashCommands.ts | 11 + src/patches/suppressLineNumbers.ts | 47 +++- src/patches/suppressNativeInstallerWarning.ts | 34 ++- src/patches/suppressRateLimitOptions.ts | 39 ++- src/patches/tableFormat.ts | 29 +-- src/patches/thinkerFormat.ts | 6 +- src/patches/tokenCountRounding.test.ts | 49 ++++ src/patches/tokenCountRounding.ts | 30 ++- src/patches/toolsets.ts | 102 +++++++- src/patches/userMessageDisplay.ts | 2 +- src/patches/verboseProperty.ts | 2 +- src/patches/worktreeMode.ts | 4 + src/tests/tableFormat.test.ts | 3 +- 33 files changed, 978 insertions(+), 460 deletions(-) delete mode 100644 probe-patches.mts create mode 100644 src/patches/autoAcceptPlanMode.test.ts create mode 100644 src/patches/inputPatternHighlighters.test.ts create mode 100644 src/patches/rememberSkill.test.ts create mode 100644 src/patches/tokenCountRounding.test.ts diff --git a/probe-patches.mts b/probe-patches.mts deleted file mode 100644 index eb5928f0..00000000 --- a/probe-patches.mts +++ /dev/null @@ -1,238 +0,0 @@ -// Run inside /home/user/code/tweakcc via: -// pnpm tsx probe-patches.mts /tmp/tweakcc-analysis/claude-2.1.146.js -// Calls each writeX patch function against an unpacked Claude Code JS file and -// reports patches that silently return null or throw. - -import fs from 'node:fs'; -import { DEFAULT_SETTINGS } from './src/defaultSettings.ts'; - -const jsPath = process.argv[2] ?? '/tmp/tweakcc-analysis/claude-2.1.146.js'; -const JS = fs.readFileSync(jsPath, 'utf8'); - -console.log(`Probing patches against ${jsPath}`); -console.log(''); - -type Probe = { name: string; run: () => Promise }; - -const probes: Probe[] = []; - -// Push a probe: name + dynamic-import + arg builder. We swallow stderr from -// console.error inside the patches by patching console.error around each run. -function add(name: string, fn: () => Promise) { - probes.push({ name, run: fn }); -} - -// Helper to capture console.error during run -async function withCapturedErr(fn: () => T | Promise): Promise<{ result: T; errs: string[] }> { - const errs: string[] = []; - const orig = console.error; - console.error = (...a) => errs.push(a.map(x => String(x)).join(' ')); - try { - const result = await fn(); - return { result, errs }; - } finally { - console.error = orig; - } -} - -// Build probes for every writeX entry -add('verbose-property', async () => { - const { writeVerboseProperty } = await import('./src/patches/verboseProperty.ts'); - return writeVerboseProperty(JS); -}); -add('opusplan1m', async () => { - const { writeOpusplan1m } = await import('./src/patches/opusplan1m.ts'); - return writeOpusplan1m(JS); -}); -add('fix-lsp-support', async () => { - const { writeFixLspSupport } = await import('./src/patches/fixLspSupport.ts'); - return writeFixLspSupport(JS); -}); -add('context-limit', async () => { - const { writeContextLimit } = await import('./src/patches/contextLimit.ts'); - return writeContextLimit(JS); -}); -add('statusline-update-throttle', async () => { - const { writeStatuslineUpdateThrottle } = await import('./src/patches/statuslineUpdateThrottle.ts'); - return writeStatuslineUpdateThrottle(JS, 300, false); -}); -add('patches-applied-indication', async () => { - const { writePatchesAppliedIndication } = await import('./src/patches/patchesAppliedIndication.ts'); - return writePatchesAppliedIndication(JS, '4.0.13', [], true, true); -}); -add('model-customizations', async () => { - const { writeModelCustomizations } = await import('./src/patches/modelSelector.ts'); - return writeModelCustomizations(JS); -}); -add('show-more-items-in-select-menus', async () => { - const { writeShowMoreItemsInSelectMenus } = await import('./src/patches/showMoreItemsInSelectMenus.ts'); - return writeShowMoreItemsInSelectMenus(JS, 25); -}); -add('table-format', async () => { - const { writeTableFormat } = await import('./src/patches/tableFormat.ts'); - return writeTableFormat(JS, 'markdown'); -}); -add('themes', async () => { - const { writeThemes } = await import('./src/patches/themes.ts'); - // tweak one color so the writer actually has work to do - const t = JSON.parse(JSON.stringify(DEFAULT_SETTINGS.themes)); - t[0].colors.text = 'rgb(123,45,67)'; - return writeThemes(JS, t); -}); -add('thinking-verbs', async () => { - const { writeThinkingVerbs } = await import('./src/patches/thinkingVerbs.ts'); - return writeThinkingVerbs(JS, DEFAULT_SETTINGS.thinkingVerbs!.verbs); -}); -add('thinker-format', async () => { - const { writeThinkerFormat } = await import('./src/patches/thinkerFormat.ts'); - return writeThinkerFormat(JS, DEFAULT_SETTINGS.thinkingVerbs!.format); -}); -add('thinker-symbol-chars', async () => { - const { writeThinkerSymbolChars } = await import('./src/patches/thinkerSymbolChars.ts'); - return writeThinkerSymbolChars(JS, ['+', '-', '*']); -}); -add('thinker-symbol-width', async () => { - const { writeThinkerSymbolWidthLocation } = await import('./src/patches/thinkerSymbolWidth.ts'); - return writeThinkerSymbolWidthLocation(JS, 2); -}); -add('thinker-symbol-mirror', async () => { - const { writeThinkerSymbolMirrorOption } = await import('./src/patches/thinkerMirrorOption.ts'); - return writeThinkerSymbolMirrorOption(JS, !DEFAULT_SETTINGS.thinkingStyle.reverseMirror); -}); -add('input-box-border', async () => { - const { writeInputBoxBorder } = await import('./src/patches/inputBorderBox.ts'); - return writeInputBoxBorder(JS, true); -}); -add('subagent-models', async () => { - const { writeSubagentModels } = await import('./src/patches/subagentModels.ts'); - return writeSubagentModels(JS, { 'general-purpose': 'claude-sonnet-4-6' } as any); -}); -add('thinking-visibility', async () => { - const { writeThinkingVisibility } = await import('./src/patches/thinkingVisibility.ts'); - return writeThinkingVisibility(JS); -}); -add('hide-startup-banner', async () => { - const { writeHideStartupBanner } = await import('./src/patches/hideStartupBanner.ts'); - return writeHideStartupBanner(JS); -}); -add('hide-ctrl-g-to-edit', async () => { - const { writeHideCtrlGToEdit } = await import('./src/patches/hideCtrlGToEdit.ts'); - return writeHideCtrlGToEdit(JS); -}); -add('hide-startup-clawd', async () => { - const { writeHideStartupClawd } = await import('./src/patches/hideStartupClawd.ts'); - return writeHideStartupClawd(JS); -}); -add('increase-file-read-limit', async () => { - const { writeIncreaseFileReadLimit } = await import('./src/patches/increaseFileReadLimit.ts'); - return writeIncreaseFileReadLimit(JS); -}); -add('suppress-line-numbers', async () => { - const { writeSuppressLineNumbers } = await import('./src/patches/suppressLineNumbers.ts'); - return writeSuppressLineNumbers(JS); -}); -add('suppress-rate-limit-options', async () => { - const { writeSuppressRateLimitOptions } = await import('./src/patches/suppressRateLimitOptions.ts'); - return writeSuppressRateLimitOptions(JS); -}); -add('token-count-rounding', async () => { - const { writeTokenCountRounding } = await import('./src/patches/tokenCountRounding.ts'); - return writeTokenCountRounding(JS, 100); -}); -add('agents-md', async () => { - const { writeAgentsMd } = await import('./src/patches/agentsMd.ts'); - return writeAgentsMd(JS, ['AGENTS.md']); -}); -add('auto-accept-plan-mode', async () => { - const { writeAutoAcceptPlanMode } = await import('./src/patches/autoAcceptPlanMode.ts'); - return writeAutoAcceptPlanMode(JS); -}); -add('allow-sudo-bypass-permissions', async () => { - const { writeAllowBypassPermsInSudo } = await import('./src/patches/allowBypassPermsInSudo.ts'); - return writeAllowBypassPermsInSudo(JS); -}); -add('suppress-native-installer-warning', async () => { - const { writeSuppressNativeInstallerWarning } = await import('./src/patches/suppressNativeInstallerWarning.ts'); - return writeSuppressNativeInstallerWarning(JS); -}); -add('filter-scroll-escape-sequences', async () => { - const { writeScrollEscapeSequenceFilter } = await import('./src/patches/scrollEscapeSequenceFilter.ts'); - return writeScrollEscapeSequenceFilter(JS); -}); -add('allow-custom-agent-models', async () => { - const { writeAllowCustomAgentModels } = await import('./src/patches/allowCustomAgentModels.ts'); - return writeAllowCustomAgentModels(JS); -}); -add('session-memory', async () => { - const { writeSessionMemory } = await import('./src/patches/sessionMemory.ts'); - return writeSessionMemory(JS); -}); -add('toolsets', async () => { - const { writeToolsets } = await import('./src/patches/toolsets.ts'); - return writeToolsets(JS, [{ name: 'mini', allowedTools: ['Read'], description: 'd' }] as any, 'mini', undefined); -}); -add('mcp-non-blocking', async () => { - const { writeMcpNonBlocking } = await import('./src/patches/mcpStartup.ts'); - return writeMcpNonBlocking(JS); -}); -add('mcp-batch-size', async () => { - const { writeMcpBatchSize } = await import('./src/patches/mcpStartup.ts'); - return writeMcpBatchSize(JS, 8); -}); -add('user-message-display', async () => { - const { writeUserMessageDisplay } = await import('./src/patches/userMessageDisplay.ts'); - return writeUserMessageDisplay(JS, DEFAULT_SETTINGS.userMessageDisplay!); -}); -add('input-pattern-highlighters', async () => { - const { writeInputPatternHighlighters } = await import('./src/patches/inputPatternHighlighters.ts'); - return writeInputPatternHighlighters(JS, [ - { name: 't', pattern: 'TODO', styling: [], foregroundColor: 'rgb(255,0,0)', backgroundColor: 'rgb(0,0,0)' }, - ] as any); -}); -add('voice-mode', async () => { - const { writeVoiceMode } = await import('./src/patches/voiceMode.ts'); - return writeVoiceMode(JS, true); -}); -add('channels-mode', async () => { - const { writeChannelsMode } = await import('./src/patches/channelsMode.ts'); - return writeChannelsMode(JS); -}); - -// Run all -const PAD = 38; -const ok: string[] = []; -const fail: { name: string; errs: string[] }[] = []; -const exc: { name: string; e: Error }[] = []; - -for (const p of probes) { - try { - const { result, errs } = await withCapturedErr(p.run); - if (result == null) { - fail.push({ name: p.name, errs }); - console.log(`✗ ${p.name.padEnd(PAD)} ${errs[0] ?? '(no error message)'}`); - } else { - ok.push(p.name); - const changed = result !== JS; - console.log(`✓ ${p.name.padEnd(PAD)} ${changed ? `(${(result.length - JS.length).toString().padStart(6)} bytes Δ)` : '(no change)'}`); - } - } catch (e: any) { - exc.push({ name: p.name, e }); - console.log(`! ${p.name.padEnd(PAD)} THREW: ${e.message}`); - } -} - -console.log('\n──────── Summary ────────'); -console.log(`✓ ok: ${ok.length}`); -console.log(`✗ fail: ${fail.length}`); -console.log(`! throw: ${exc.length}`); -if (fail.length > 0) { - console.log('\nFailures:'); - for (const f of fail) { - console.log(` ${f.name}`); - for (const e of f.errs) console.log(` ${e}`); - } -} -if (exc.length > 0) { - console.log('\nExceptions:'); - for (const e of exc) console.log(` ${e.name}: ${e.e.message}`); -} diff --git a/src/patches/agentsMd.ts b/src/patches/agentsMd.ts index defacedf..9fa8f860 100644 --- a/src/patches/agentsMd.ts +++ b/src/patches/agentsMd.ts @@ -11,39 +11,6 @@ import { showDiff } from './index'; * CC <=2.1.69 (sync): Function uses readFileSync/existsSync/statSync directly * CC >=2.1.83 (async): File reading is split into jh1 (async reader) and XB9 (processor) * The async reader catches ENOENT/EISDIR errors and returns {info:null,includePaths:[]} - * - * CC <=2.1.69: - * ```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") || ...)) { ... } - * + return null; - * + } - * ``` - * - * CC >=2.1.83: - * ```diff - * -async function jh1(A, q, K) { - * +async function jh1(A, q, K, didReroute) { - * try { - * let z = await j8().readFile(A, {encoding:"utf-8"}); - * return XB9(z, A, q, K) - * - } catch(_) { return DB9(_, A), {info:null,includePaths:[]} } - * + } catch(_) { - * + DB9(_, A); - * + if (!didReroute && (A.endsWith("/CLAUDE.md") || ...)) { - * + for (let alt of ["AGENTS.md",...]) { - * + let altPath = A.slice(0,-9) + alt; - * + try { let r = await jh1(altPath, q, K, true); if (r.info) return r; } catch {} - * + } - * + } - * + return {info:null,includePaths:[]} - * + } - * ``` */ export const writeAgentsMd = ( file: string, @@ -88,13 +55,13 @@ const writeAgentsMdAsync = ( const altNamesJson = JSON.stringify(altNames); const replacement = - `${funcSig},didReroute){try{let ${readVar}=await ${fsGetter}().readFile(${pathParam},{encoding:"utf-8"});return ${processorFunc}(${readVar},${pathParam},${typeParam},${thirdParam})}catch(${catchVar}){${errorHandler}(${catchVar},${pathParam});` + + `${funcSig},didReroute){try{let ${readVar}=await ${fsGetter}().readFile(${pathParam},{encoding:"utf-8"});return ${processorFunc}(${readVar},${pathParam},${typeParam},${thirdParam})}catch(${catchVar}){` + `if(!didReroute&&(${pathParam}.endsWith("/CLAUDE.md")||${pathParam}.endsWith("\\\\CLAUDE.md"))){` + `for(let alt of ${altNamesJson}){` + `let altPath=${pathParam}.slice(0,-9)+alt;` + `try{let r=await ${funcName}(altPath,${typeParam},${thirdParam},true);if(r.info)return r}catch{}` + `}}` + - `return{info:null,includePaths:[]}}}`; + `return ${errorHandler}(${catchVar},${pathParam}),{info:null,includePaths:[]}}}`; const startIndex = funcMatch.index; const endIndex = startIndex + fullMatch.length; diff --git a/src/patches/allowBypassPermsInSudo.ts b/src/patches/allowBypassPermsInSudo.ts index 99c20235..5c4302b3 100644 --- a/src/patches/allowBypassPermsInSudo.ts +++ b/src/patches/allowBypassPermsInSudo.ts @@ -13,6 +13,9 @@ export const writeAllowBypassPermsInSudo = (file: string): string | null => { const match = file.match(pattern); if (!match || match.index === undefined) { + if (!file.includes('root/sudo privileges')) { + return file; + } console.error('patch: allowBypassPermsInSudo: failed to find pattern'); return null; } diff --git a/src/patches/autoAcceptPlanMode.test.ts b/src/patches/autoAcceptPlanMode.test.ts new file mode 100644 index 00000000..be76d05e --- /dev/null +++ b/src/patches/autoAcceptPlanMode.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { writeAutoAcceptPlanMode } from './autoAcceptPlanMode'; + +describe('writeAutoAcceptPlanMode', () => { + it('finds the enclosing return even when it starts before the Ready prompt window', () => { + const filler = 'x'.repeat(700); + const input = + 'function A(){let h=(v)=>v;' + + `return R.default.createElement(Box,{children:"${filler}"},` + + 'R.default.createElement(Card,{color:"planMode",title:"Ready to code?",onChange:h,onCancel:z}));}'; + + const result = writeAutoAcceptPlanMode(input); + + expect(result).not.toBeNull(); + expect(result).toContain( + 'h("yes-accept-edits-keep-context");return null;return R.default.createElement' + ); + }); +}); diff --git a/src/patches/autoAcceptPlanMode.ts b/src/patches/autoAcceptPlanMode.ts index c4b65477..9f9f311e 100644 --- a/src/patches/autoAcceptPlanMode.ts +++ b/src/patches/autoAcceptPlanMode.ts @@ -31,14 +31,14 @@ const findEnclosingFunctionReturn = ( else if (char === '}') { depth--; if (depth === 0) { - const functionTail = oldFile.slice(readyIdx, index + 1); - const returnPattern = /return ([$\w]+)\}/g; + const functionTail = oldFile.slice(openBrace, index + 1); + const returnPattern = /return [$\w]+(?:\.default)?\.createElement/g; let match: RegExpExecArray | null; let lastMatch: RegExpExecArray | null = null; while ((match = returnPattern.exec(functionTail)) !== null) { lastMatch = match; } - return lastMatch ? readyIdx + lastMatch.index : null; + return lastMatch ? openBrace + lastMatch.index : null; } } } @@ -46,6 +46,107 @@ const findEnclosingFunctionReturn = ( return null; }; +const patchPlanModePrompts = (file: string): string => { + const replacements: Array< + [RegExp, string | ((...args: string[]) => string)] + > = [ + [ + /When ready, use \$\{([$\w]+)\} to present your plan for approval/g, + (_match, toolName) => + `When ready, use \${${toolName}} to exit plan mode. The plan will be approved automatically.`, + ], + [ + /Use this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval\./g, + 'Use this tool when you are in plan mode and have finished writing your plan to the plan file. Calling this tool exits plan mode and approves the plan automatically.', + ], + [ + /This tool simply signals that you're done planning and ready for the user to review and approve/g, + 'This tool signals that you are done planning and that the plan should be approved automatically', + ], + [ + /Once your plan is finalized, use THIS tool to request approval/g, + 'Once your plan is finalized, use THIS tool to approve the plan and proceed', + ], + [ + /ExitPlanMode inherently requests user approval of your plan\./g, + 'ExitPlanMode inherently approves your plan and lets you proceed.', + ], + [ + /Present your plan to the user for approval/g, + 'Exit plan mode; the plan will be approved automatically', + ], + [ + /design an implementation approach for user approval/g, + 'design an implementation approach before automatic approval', + ], + [ + /This tool REQUIRES user approval - they must consent to entering plan mode/g, + 'This tool enters plan mode; plan exit approval is handled automatically when auto-accept plan mode is enabled', + ], + [ + /Claude has written up a plan and is ready to execute\. Would you like to proceed\?/g, + 'Claude has written up a plan and is ready to execute. The plan is approved automatically.', + ], + [ + /Call `\$\{([$\w]+)\}` to present the plan for approval\./g, + (_match, toolName) => + `Call \`\${${toolName}}\` to exit plan mode; the plan will be approved automatically.`, + ], + [ + /## Phase 2: Spawn Workers \(After Plan Approval\)/g, + '## Phase 2: Spawn Workers (After Automatic Plan Approval)', + ], + [ + /Once the plan is approved, spawn/g, + 'After the plan is approved automatically, spawn', + ], + [ + /searchHint:"present plan for approval and start coding \(plan mode only\)"/g, + 'searchHint:"approve plan and start coding (plan mode only)"', + ], + [ + /async description\(\)\{return"Prompts the user to exit plan mode and start coding"\}/g, + 'async description(){return"Exits plan mode and starts coding"}', + ], + ]; + + let newFile = file; + for (const [pattern, replacement] of replacements) { + const before = newFile; + newFile = newFile.replace(pattern, replacement as never); + if (newFile !== before) { + showDiff(file, newFile, String(replacement), 0, 0); + } + } + + const planExitPermissionUpdate = + 'permissionUpdates:[{type:"setMode",mode:"acceptEdits",destination:"session"}]'; + + const permissionDefaultPattern = + /kind:"permission_exit_plan_mode_v2",payload:([\s\S]{0,300}?),result:([\s\S]{0,180}?),default:\{behavior:"cancelled"\}/; + const beforePermissionDefault = newFile; + newFile = newFile.replace( + permissionDefaultPattern, + `kind:"permission_exit_plan_mode_v2",payload:$1,result:$2,default:{behavior:"allow",${planExitPermissionUpdate}}` + ); + if (newFile !== beforePermissionDefault) { + showDiff(file, newFile, 'permission_exit_plan_mode_v2 default allow', 0, 0); + } + + const exitPlanCheckPermissionsPattern = + /async checkPermissions\(([$\w]+),([$\w]+)\)\{if\(([$\w]+)\(\)\)return\{behavior:"allow",updatedInput:\1\};return\{behavior:"ask",message:"Exit plan mode\?",updatedInput:\1\}\}/; + const beforeCheckPermissions = newFile; + newFile = newFile.replace( + exitPlanCheckPermissionsPattern, + `async checkPermissions($1,$2){return{behavior:"allow",updatedInput:$1,${planExitPermissionUpdate}}}` + ); + if (newFile !== beforeCheckPermissions) { + showDiff(file, newFile, 'ExitPlanMode checkPermissions allow', 0, 0); + } + + return newFile; +}; + export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { const readyIdx = oldFile.indexOf('title:"Ready to code?"'); if (readyIdx === -1) { @@ -57,7 +158,7 @@ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { // Check if already patched const alreadyPatchedPattern = - /[$\w]+\("yes-accept-edits"\);return null;return/; + /[$\w]+(?:\.current)?\("yes-accept-edits(?:-keep-context)?"\);return null;return/; if (alreadyPatchedPattern.test(oldFile)) { return oldFile; } @@ -106,7 +207,7 @@ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { const legacyMatch = oldFile.match(legacyReturnPattern); if (legacyMatch && legacyMatch.index !== undefined) { - const insertion = `${acceptFuncName}("yes-accept-edits");return null;`; + const insertion = `${acceptFuncName}("yes-accept-edits-keep-context");return null;`; const replacement = legacyMatch[1] + insertion + legacyMatch[2]; const startIndex = legacyMatch.index; const endIndex = startIndex + legacyMatch[0].length; @@ -115,12 +216,15 @@ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { oldFile.slice(0, startIndex) + replacement + oldFile.slice(endIndex); showDiff(oldFile, newFile, replacement, startIndex, endIndex); - return newFile; + return patchPlanModePrompts(newFile); } // CC >=2.1.83: Find "return React.createElement(Box,{...title:"Ready to code?" - // The return is preceded by various patterns, find it by searching backwards from readyIdx - const beforeReady = oldFile.slice(Math.max(0, readyIdx - 500), readyIdx); + // The return is preceded by various patterns, find it by searching backwards from readyIdx. + // Newer bundles can place a long prop list before the title, so keep this + // wider than the old 500-byte window and fall back to function-level search. + const returnSearchStart = Math.max(0, readyIdx - 2500); + const beforeReady = oldFile.slice(returnSearchStart, readyIdx); // Look for the return statement start const returnMatch = beforeReady.match( @@ -139,7 +243,7 @@ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { return null; } - const insertion = `${acceptFuncName}("yes-accept-edits");return null;`; + const insertion = `${acceptFuncName}("yes-accept-edits-keep-context");return null;`; const newFile = oldFile.slice(0, enclosingReturnIdx) + insertion + @@ -152,11 +256,11 @@ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { enclosingReturnIdx, enclosingReturnIdx ); - return newFile; + return patchPlanModePrompts(newFile); } - const absoluteReturnIdx = Math.max(0, readyIdx - 500) + simpleReturnIdx; - const insertion = `${acceptFuncName}("yes-accept-edits");return null;`; + const absoluteReturnIdx = returnSearchStart + simpleReturnIdx; + const insertion = `${acceptFuncName}("yes-accept-edits-keep-context");return null;`; const newFile = oldFile.slice(0, absoluteReturnIdx) + @@ -164,15 +268,15 @@ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { oldFile.slice(absoluteReturnIdx); showDiff(oldFile, newFile, insertion, absoluteReturnIdx, absoluteReturnIdx); - return newFile; + return patchPlanModePrompts(newFile); } const absoluteStart = Math.max(0, readyIdx - 500) + returnMatch.index!; - const insertion = `${acceptFuncName}("yes-accept-edits");return null;`; + const insertion = `${acceptFuncName}("yes-accept-edits-keep-context");return null;`; const newFile = oldFile.slice(0, absoluteStart) + insertion + oldFile.slice(absoluteStart); showDiff(oldFile, newFile, insertion, absoluteStart, absoluteStart); - return newFile; + return patchPlanModePrompts(newFile); }; diff --git a/src/patches/contextLimit.ts b/src/patches/contextLimit.ts index c66a2744..7a5c231a 100644 --- a/src/patches/contextLimit.ts +++ b/src/patches/contextLimit.ts @@ -1,11 +1,20 @@ // Please see the note about writing patches in ./index -import { globalReplace } from './index'; - export const writeContextLimit = (oldFile: string): string | null => { - return globalReplace( - oldFile, - /\b200000\b/, - '(+process.env.CLAUDE_CODE_CONTEXT_LIMIT||200000)' + const replacement = '(+process.env.CLAUDE_CODE_CONTEXT_LIMIT||200000)'; + const pattern = + /var ([\w$]+)=200000,([\w$]+)=20000,([\w$]+)=32000,([\w$]+)=(128000|64000);/; + const match = oldFile.match(pattern); + + if (!match) { + console.error( + 'patch: contextLimit: failed to find context limit constants' + ); + return null; + } + + return oldFile.replace( + pattern, + `var ${match[1]}=${replacement},${match[2]}=20000,${match[3]}=32000,${match[4]}=${match[5]};` ); }; diff --git a/src/patches/conversationTitle.ts b/src/patches/conversationTitle.ts index a9c6b393..c9e5cda8 100644 --- a/src/patches/conversationTitle.ts +++ b/src/patches/conversationTitle.ts @@ -497,7 +497,65 @@ export const enableRenameConversationCommand = ( /** * Apply all conversation title patches to the file */ +const writeModernTitleCommand = (oldFile: string): string | null => { + const commandListPattern = /(([$\w]+)=[$\w]+\(\(\)=>\[)/g; + let commandListMatch: RegExpExecArray | null = null; + for (const match of oldFile.matchAll(commandListPattern)) { + const anchorWindow = oldFile.slice( + Math.max(0, match.index - 12000), + Math.min(oldFile.length, match.index + 12000) + ); + if (/name:"[^"]+"[\s\S]{0,1200}description:/.test(anchorWindow)) { + commandListMatch = match; + break; + } + } + if (!commandListMatch || commandListMatch.index === undefined) return null; + + const modulePattern = + /var ([$\w]+)=\{\};[$\w]+\(\1,\{performSetColor:\(\)=>[$\w]+,call:\(\)=>[$\w]+\}\);async function [$\w]+\(([$\w]+),([$\w]+),([$\w]+)\)\{return \2\(await [$\w]+\(\4,\3\),\{display:"system"\}\),null\}/; + const moduleMatch = oldFile.match(modulePattern); + if (!moduleMatch || moduleMatch.index === undefined) { + console.error( + 'patch: conversationTitle: failed to find local command module anchor' + ); + return null; + } + + const moduleEnd = oldFile.indexOf( + 'var ', + moduleMatch.index + moduleMatch[0].length + ); + if (moduleEnd === -1) { + console.error( + 'patch: conversationTitle: failed to find local command module end' + ); + return null; + } + + const exportFn = moduleMatch[0].match(/;([$\w]+)\(/)?.[1] ?? 'P$'; + const insertion = `var tweakccTitleModule={};${exportFn}(tweakccTitleModule,{call:()=>tweakccTitleCall});async function tweakccTitleCall(args,context){let title=args?.trim?.()??"";if(!title)return{type:"text",value:"Please specify a conversation title."};context.setAppState?.((state)=>({...state,customTitle:title}));process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE="1";if(process.platform==="win32")process.title="Claude: "+title;else process.stdout.write("\\x1B]0;Claude: "+title+"\\x07");let message="Conversation title set to "+title;if(context.options?.isNonInteractiveSession){process.stdout.write(message+"\\n");return{type:"skip"}}return{type:"text",value:message}}`; + const commandDef = `tweakccTitleCommand={type:"local",name:"title",description:"Set the conversation title",argumentHint:"",supportsNonInteractive:!0,userFacingName(){return"title"},load:()=>Promise.resolve(tweakccTitleModule)},`; + + let newFile = + oldFile.slice(0, moduleEnd) + insertion + oldFile.slice(moduleEnd); + const adjustedCommandIndex = + commandListMatch.index + + commandListMatch[1].length + + (commandListMatch.index >= moduleEnd ? insertion.length : 0); + newFile = + newFile.slice(0, adjustedCommandIndex) + + commandDef + + newFile.slice(adjustedCommandIndex); + + showDiff(oldFile, newFile, insertion + commandDef, moduleEnd, moduleEnd); + return newFile; +}; + export const writeConversationTitle = (oldFile: string): string | null => { + const modernResult = writeModernTitleCommand(oldFile); + if (modernResult) return modernResult; + let result: string | null = oldFile; // Step 1: Write /title slash command diff --git a/src/patches/fixLspSupport.ts b/src/patches/fixLspSupport.ts index 1d170385..067de499 100644 --- a/src/patches/fixLspSupport.ts +++ b/src/patches/fixLspSupport.ts @@ -95,6 +95,16 @@ const getOpenDocumentLocation = (oldFile: string): LocationResult | null => { }; export const writeFixLspSupport = (oldFile: string): string | null => { + // CC >= 2.1.152 has native open/change/save/close file sync for LSP. + if ( + oldFile.includes('textDocument/didOpen') && + /openFile:[$\w]+,changeFile:[$\w]+,saveFile:[$\w]+,closeFile:[$\w]+/.test( + oldFile + ) + ) { + 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 ebe02e4f..c63a265c 100644 --- a/src/patches/helpers.ts +++ b/src/patches/helpers.ts @@ -308,6 +308,21 @@ export const findTextComponent = (fileContents: string): string | undefined => { /** * Find the Box component variable name */ +const findThemedBoxWrapper = ( + fileContents: string, + rawBoxComponent: string +): string | undefined => { + const wrapperFactoryIdent = '[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*'; + const rawAliasPattern = new RegExp( + `var [^;]{0,120};var [$\\w]+=${wrapperFactoryIdent}\\(\\(\\)=>\\{[^}]{0,500}([$\\w]+)=${escapeIdent(rawBoxComponent)}\\}\\)` + ); + const rawAlias = fileContents.match(rawAliasPattern)?.[1] ?? rawBoxComponent; + const wrapperPattern = new RegExp( + `function ([$\\w]+)\\([^)]+\\)\\{(?=[\\s\\S]{0,2500}createElement\\(${escapeIdent(rawAlias)},\\{\\.\\.\\.[$\\w]+,borderColor:)[\\s\\S]{0,3000}?return [$\\w]+\\}var [^;]{0,160};var [$\\w]+=${wrapperFactoryIdent}\\(\\(\\)=>\\{[^}]{0,600}([$\\w]+)=\\1\\}\\)` + ); + return fileContents.match(wrapperPattern)?.[2]; +}; + export const findBoxComponent = (fileContents: string): string | undefined => { // Method 1: Find Box by ink-box createElement with local variable (CC ~2.0.x) const inkBoxPattern = @@ -351,7 +366,10 @@ export const findBoxComponent = (fileContents: string): string | undefined => { /function ([$\w]+)\(\{children:([$\w]+),ref:[$\w]+.{0,600}?\.\.\.([$\w]+)\}\)\{.{0,2500}?"margin".{0,2500}?"padding".{0,1200}?"gap".{0,1200}?\3\.flexWrap\?\?="nowrap",\3\.flexDirection\?\?="row",\3\.flexGrow\?\?=0,\3\.flexShrink\?\?=1,\3\.overflowX=\3\.overflowX\?\?\3\.overflow\?\?"visible",\3\.overflowY=\3\.overflowY\?\?\3\.overflow\?\?"visible",[$\w]+(?:\.default)?\.createElement\("ink-box",\{[^}]*style:\3\},\2\)/; const restStyleBoxMatch = fileContents.match(restStyleBoxPattern); if (restStyleBoxMatch) { - return restStyleBoxMatch[1]; + return ( + findThemedBoxWrapper(fileContents, restStyleBoxMatch[1]) ?? + restStyleBoxMatch[1] + ); } console.error( diff --git a/src/patches/hideStartupBanner.ts b/src/patches/hideStartupBanner.ts index db8d2210..5e9eef2d 100644 --- a/src/patches/hideStartupBanner.ts +++ b/src/patches/hideStartupBanner.ts @@ -29,6 +29,26 @@ export const writeHideStartupBanner = (oldFile: string): string | null => { return newFile; } + // CC >=2.1.156: the startup card component contains both the full-logo + // branch and the compact/horizontal card branch. Disable the whole component. + const modernCardPatterns = [ + /(function [$\w]+\(\)\{)(?=let [$\w]+=[\w$]+\.c\(\d+\),[$\w]+=[\w$]+\(\)\.oauthAccount\?\.displayName\?\?""|let [$\w]+=[\w$]+\(\),[$\w]+=[\w$]+\?\.displayName\?\?"")/, + /(function [$\w]+\(\)\{)(?=let [$\w]+=[\w$]+\.c\(\d+\),[$\w]+=[\w$]+\(\),[$\w]+=[\w$]+\?\.displayName\?\?"")/, + ]; + + for (const modernCardPattern of modernCardPatterns) { + const modernCardMatch = oldFile.match(modernCardPattern); + if (modernCardMatch && modernCardMatch.index !== undefined) { + const insertIndex = modernCardMatch.index + modernCardMatch[1].length; + const insertion = 'return null;'; + const newFile = + oldFile.slice(0, insertIndex) + insertion + oldFile.slice(insertIndex); + + showDiff(oldFile, newFile, insertion, insertIndex, insertIndex); + return newFile; + } + } + // CC >=2.1.83: The startup banner is a standalone zero-arg component function. // It contains both "Apple_Terminal" (for theme branching) and "Welcome to Claude Code". // Insert `return null;` at the start of its body. @@ -36,7 +56,6 @@ export const writeHideStartupBanner = (oldFile: string): string | null => { let funcMatch: RegExpExecArray | null; while ((funcMatch = funcPattern.exec(oldFile)) !== null) { - // Verify this function also contains "Welcome to Claude Code" const bodyStart = funcMatch.index + funcMatch[0].length; const bodyPreview = oldFile.slice(bodyStart, bodyStart + 5000); if (bodyPreview.includes('Welcome to Claude Code')) { diff --git a/src/patches/index.ts b/src/patches/index.ts index 7dd0f6b5..d6e65c2a 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -1,6 +1,8 @@ import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { CONFIG_DIR, @@ -537,6 +539,27 @@ const applyPatchImplementations = ( return { content, results }; }; +const assertNativeBinaryStarts = (binaryPath: string) => { + const result = spawnSync(binaryPath, ['--version'], { + encoding: 'utf8', + timeout: 15000, + }); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + + if ( + result.error || + result.status !== 0 || + /Expected CommonJS module|Bun v|TypeError/.test(output) + ) { + const error = new Error( + `Patched native binary failed startup sanity check (${binaryPath}).\n` + + output.trim() + ); + error.stack = error.message; + throw error; + } +}; + // ============================================================================= // Main Apply Function // ============================================================================= @@ -599,12 +622,14 @@ export const applyCustomization = async ( // ========================================================================== // Apply system prompt customizations (has its own result format) // ========================================================================== - const systemPromptsResult = await applySystemPrompts( - content, - ccInstInfo.version, - undefined, // escapeNonAscii - auto-detect - patchFilter - ); + const systemPromptsResult = ccInstInfo.nativeInstallationPath + ? { newContent: content, results: [] } + : await applySystemPrompts( + content, + ccInstInfo.version, + undefined, // escapeNonAscii - auto-detect + patchFilter + ); content = systemPromptsResult.newContent; // Sort system prompt results alphabetically by name before adding @@ -637,6 +662,7 @@ export const applyCustomization = async ( // Always Applied 'verbose-property': { fn: c => writeVerboseProperty(c), + condition: !ccInstInfo.nativeInstallationPath, }, 'context-limit': { fn: c => writeContextLimit(c), @@ -644,6 +670,8 @@ export const applyCustomization = async ( }, opusplan1m: { fn: c => writeOpusplan1m(c), + condition: + modelCustomizationsEnabled && !ccInstInfo.nativeInstallationPath, }, 'thinking-block-styling': { fn: c => writeThinkingBlockStyling(c), @@ -697,11 +725,17 @@ export const applyCustomization = async ( }, 'thinking-verbs': { fn: c => writeThinkingVerbs(c, config.settings.thinkingVerbs!.verbs), - condition: !!config.settings.thinkingVerbs, + condition: + !!config.settings.thinkingVerbs && + JSON.stringify(config.settings.thinkingVerbs.verbs) !== + JSON.stringify(DEFAULT_SETTINGS.thinkingVerbs.verbs), }, 'thinker-format': { fn: c => writeThinkerFormat(c, config.settings.thinkingVerbs!.format), - condition: !!config.settings.thinkingVerbs, + condition: + !!config.settings.thinkingVerbs && + config.settings.thinkingVerbs.format !== + DEFAULT_SETTINGS.thinkingVerbs.format, }, 'thinker-symbol-chars': { fn: c => writeThinkerSymbolChars(c, config.settings.thinkingStyle.phases), @@ -746,12 +780,16 @@ export const applyCustomization = async ( fn: c => writeInputBoxBorder(c, config.settings.inputBox!.removeBorder), condition: !!( config.settings.inputBox && - typeof config.settings.inputBox.removeBorder === 'boolean' + config.settings.inputBox.removeBorder !== + DEFAULT_SETTINGS.inputBox.removeBorder ), }, 'subagent-models': { fn: c => writeSubagentModels(c, config.settings.subagentModels!), - condition: !!config.settings.subagentModels, + condition: + !!config.settings.subagentModels && + JSON.stringify(config.settings.subagentModels) !== + JSON.stringify(DEFAULT_SETTINGS.subagentModels), }, 'thinking-visibility': { fn: c => writeThinkingVisibility(c), @@ -788,10 +826,7 @@ export const applyCustomization = async ( }, 'remember-skill': { fn: c => writeRememberSkill(c), - condition: - !!config.settings.misc?.enableRememberSkill && - !!ccInstInfo.version && - compareVersions(ccInstInfo.version, '2.1.42') < 0, + condition: !!config.settings.misc?.enableRememberSkill, }, 'agents-md': { fn: c => writeAgentsMd(c, config.settings.claudeMdAltNames!), @@ -823,10 +858,7 @@ export const applyCustomization = async ( }, 'worktree-mode': { fn: c => writeWorktreeMode(c), - condition: - !!config.settings.misc?.enableWorktreeMode && - !!ccInstInfo.version && - compareVersions(ccInstInfo.version, '2.1.51') < 0, + condition: !!config.settings.misc?.enableWorktreeMode, }, 'session-memory': { fn: c => writeSessionMemory(c), @@ -854,7 +886,12 @@ export const applyCustomization = async ( }, 'user-message-display': { fn: c => writeUserMessageDisplay(c, config.settings.userMessageDisplay!), - condition: !!config.settings.userMessageDisplay, + condition: !!( + config.settings.userMessageDisplay && + JSON.stringify(config.settings.userMessageDisplay) !== + JSON.stringify(DEFAULT_SETTINGS.userMessageDisplay) && + !ccInstInfo.nativeInstallationPath + ), }, 'input-pattern-highlighters': { fn: c => @@ -871,10 +908,7 @@ export const applyCustomization = async ( fn: c => writeConversationTitle(c), condition: (config.settings.misc?.enableConversationTitle ?? true) && - !!( - ccInstInfo.version && - compareVersions(ccInstInfo.version, '2.0.64') < 0 - ), + !ccInstInfo.nativeInstallationPath, }, 'voice-mode': { fn: c => @@ -898,6 +932,16 @@ export const applyCustomization = async ( content = patchedContent; allResults.push(...patchResults); + const failedBinaryPatches = patchResults.filter(r => r.failed); + if (ccInstInfo.nativeInstallationPath && failedBinaryPatches.length > 0) { + const error = new Error( + 'Refusing to repack native binary because one or more binary patches failed: ' + + failedBinaryPatches.map(r => r.id).join(', ') + ); + error.stack = error.message; + throw error; + } + // ========================================================================== // Write the modified content back // ========================================================================== @@ -913,11 +957,28 @@ export const applyCustomization = async ( debug(`Saved patched JS from native to: ${patchedPath}`); const modifiedBuffer = Buffer.from(content, 'utf8'); - await repackNativeInstallation( - ccInstInfo.nativeInstallationPath, - modifiedBuffer, - ccInstInfo.nativeInstallationPath + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tweakcc-native-')); + const tempBinaryPath = path.join( + tempDir, + path.basename(ccInstInfo.nativeInstallationPath) ); + + try { + await fs.copyFile(ccInstInfo.nativeInstallationPath, tempBinaryPath); + await fs.chmod( + tempBinaryPath, + fsSync.statSync(ccInstInfo.nativeInstallationPath).mode + ); + await repackNativeInstallation( + tempBinaryPath, + modifiedBuffer, + tempBinaryPath + ); + assertNativeBinaryStarts(tempBinaryPath); + await fs.copyFile(tempBinaryPath, ccInstInfo.nativeInstallationPath); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } } else { // For NPM installations: replace the cli.js file if (!ccInstInfo.cliPath) { diff --git a/src/patches/inputPatternHighlighters.test.ts b/src/patches/inputPatternHighlighters.test.ts new file mode 100644 index 00000000..c6d1515d --- /dev/null +++ b/src/patches/inputPatternHighlighters.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { InputPatternHighlighter } from '../types'; +import { writeInputPatternHighlighters } from './inputPatternHighlighters'; + +vi.mock('./index', async () => { + const actual = await vi.importActual<typeof import('./index')>('./index'); + return { + ...actual, + findChalkVar: () => 'chalk', + showDiff: vi.fn(), + }; +}); + +const baseHighlighter = ( + overrides: Partial<InputPatternHighlighter> +): InputPatternHighlighter => ({ + name: 'test', + regex: 'ok', + regexFlags: 'g', + format: '{MATCH}', + styling: [], + foregroundColor: '#ffffff', + backgroundColor: null, + enabled: true, + ...overrides, +}); + +describe('writeInputPatternHighlighters', () => { + it('skips invalid user regexes and still emits valid highlighters', () => { + const input = + 'let props={inputValue:inputText,other:1};' + + 'return R.createElement(T,{key:E,color:N.highlight?.color,dimColor:N.highlight?.dimColor,inverse:N.highlight?.inverse},R.createElement(I,null,N.text));' + + ';let ranges=React.useMemo(()=>{let arr=[];if(a&&b&&!c)arr.push({start:s,end:s+l.length,color:"warning",priority:1})},[]);'; + + const result = writeInputPatternHighlighters(input, [ + baseHighlighter({ name: 'broken', regex: '[', regexFlags: 'g' }), + baseHighlighter({ name: 'valid', regex: 'todo', regexFlags: '' }), + ]); + + expect(result).not.toBeNull(); + expect(result).toContain('matchAll(new RegExp("todo", "g"))'); + expect(result).not.toContain('new RegExp("["'); + }); +}); diff --git a/src/patches/inputPatternHighlighters.ts b/src/patches/inputPatternHighlighters.ts index 2e452aaa..13001c50 100644 --- a/src/patches/inputPatternHighlighters.ts +++ b/src/patches/inputPatternHighlighters.ts @@ -128,17 +128,21 @@ const writeCustomHighlighterImpl = (oldFile: string): string | null => { const segVar2 = newMatches2[5]; const innerElem2 = newMatches2[6]; + const styledText = + `${segVar2}.highlight?.style?` + + `${segVar2}.highlight.style(${segVar2}.text):${segVar2}.text`; + const styledInnerElem = innerElem2.replace(`${segVar2}.text`, styledText); const augmentedRenderer = `return ${reactVar2}.createElement(${textComp2},{key:${keyVar2}` + - `,color:${segVar2}.highlight?.color` + - `,backgroundColor:${segVar2}.highlight?.backgroundColor` + + `,color:${segVar2}.highlight?.style?void 0:${segVar2}.highlight?.color` + + `,backgroundColor:${segVar2}.highlight?.style?void 0:${segVar2}.highlight?.backgroundColor` + `,dimColor:${segVar2}.highlight?.dimColor` + - `,inverse:${segVar2}.highlight?.inverse` + - `,bold:${segVar2}.highlight?.bold` + - `,italic:${segVar2}.highlight?.italic` + - `,underline:${segVar2}.highlight?.underline` + - `,strikethrough:${segVar2}.highlight?.strikethrough` + - `},${innerElem2})`; + `,inverse:${segVar2}.highlight?.style?void 0:${segVar2}.highlight?.inverse` + + `,bold:${segVar2}.highlight?.style?void 0:${segVar2}.highlight?.bold` + + `,italic:${segVar2}.highlight?.style?void 0:${segVar2}.highlight?.italic` + + `,underline:${segVar2}.highlight?.style?void 0:${segVar2}.highlight?.underline` + + `,strikethrough:${segVar2}.highlight?.style?void 0:${segVar2}.highlight?.strikethrough` + + `},${styledInnerElem})`; const newFile = workingFile.slice(0, newMatches2.index) + @@ -209,7 +213,7 @@ const writeCustomHighlighterCreation = ( let genCode = ''; for (let i = 0; i < highlighters.length; i++) { const highlighter = highlighters[i]; - const _chalkChain = buildChalkChain(chalkVar, highlighter); // eslint-disable-line @typescript-eslint/no-unused-vars + const chalkChain = buildChalkChain(chalkVar, highlighter); const formatStr = highlighter.format ?? '{MATCH}'; JSON.stringify(formatStr).replace(/\{MATCH\}/g, '"+x+"'); // preserve legacy side-effect-free transform shape for diff stability @@ -254,10 +258,19 @@ const writeCustomHighlighterCreation = ( if (!flags.includes('g')) { flags += 'g'; } - const regex = new RegExp(regexSource, flags); + let regex: RegExp; + try { + regex = new RegExp(regexSource, flags); + } catch (error) { + console.error( + `patch: inputPatternHighlighters: highlighter "${highlighter.name}" has invalid regex; skipping`, + error + ); + continue; + } const regexStr = stringifyRegex(regex); - genCode += `if(typeof ${inputVar}==="string"){for(let m of ${inputVar}.matchAll(${regexStr})){${rangesVar}.push({start:m.index,end:m.index+m[0].length,color:${colorValue}${bgColorValue ? `,backgroundColor:${bgColorValue}` : ''}${isBold ? ',bold:!0' : ''}${isItalic ? ',italic:!0' : ''}${isUnderline ? ',underline:!0' : ''}${isInverse ? ',inverse:!0' : ''}${isDim ? ',dimColor:!0' : ''}${isStrikethrough ? ',strikethrough:!0' : ''},priority:100})}}`; + genCode += `if(typeof ${inputVar}==="string"){for(let m of ${inputVar}.matchAll(${regexStr})){${rangesVar}.push({start:m.index,end:m.index+m[0].length,color:${colorValue}${bgColorValue ? `,backgroundColor:${bgColorValue}` : ''}${isBold ? ',bold:!0' : ''}${isItalic ? ',italic:!0' : ''}${isUnderline ? ',underline:!0' : ''}${isInverse ? ',inverse:!0' : ''}${isDim ? ',dimColor:!0' : ''}${isStrikethrough ? ',strikethrough:!0' : ''},style:(x)=>${chalkChain}(x),priority:100})}}`; } if (!genCode) { diff --git a/src/patches/modelCustomizationsToggle.test.ts b/src/patches/modelCustomizationsToggle.test.ts index 58b158dc..d0b7c0e9 100644 --- a/src/patches/modelCustomizationsToggle.test.ts +++ b/src/patches/modelCustomizationsToggle.test.ts @@ -1,7 +1,12 @@ import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('node:child_process', () => ({ + spawnSync: vi.fn(() => ({ status: 0, stdout: '2.1.158', stderr: '' })), +})); + import { DEFAULT_SETTINGS } from '../defaultSettings'; import { ClaudeCodeInstallationInfo, TweakccConfig } from '../types'; import { updateConfigFile } from '../config'; @@ -13,9 +18,19 @@ import { applySystemPrompts } from './systemPrompts'; import { applyCustomization } from './index'; const mockReadFile = vi.hoisted(() => vi.fn()); +const mockExtractClaudeJsFromNativeInstallation = vi.hoisted(() => vi.fn()); +const mockRepackNativeInstallation = vi.hoisted(() => vi.fn()); +const mockCopyFile = vi.hoisted(() => vi.fn()); +const mockChmod = vi.hoisted(() => vi.fn()); +const mockMkdtemp = vi.hoisted(() => vi.fn()); +const mockRm = vi.hoisted(() => vi.fn()); vi.mock('node:fs/promises', () => ({ readFile: mockReadFile, + copyFile: mockCopyFile, + chmod: mockChmod, + mkdtemp: mockMkdtemp, + rm: mockRm, })); vi.mock('../config', () => ({ @@ -39,8 +54,9 @@ vi.mock('../installationBackup', () => ({ })); vi.mock('../nativeInstallationLoader', () => ({ - extractClaudeJsFromNativeInstallation: vi.fn(), - repackNativeInstallation: vi.fn(), + extractClaudeJsFromNativeInstallation: + mockExtractClaudeJsFromNativeInstallation, + repackNativeInstallation: mockRepackNativeInstallation, })); vi.mock('./modelSelector', () => ({ @@ -65,6 +81,8 @@ const PATCH_IDS = [ 'show-more-items-in-select-menus', ] as const; +const NATIVE_UNSAFE_PATCH_IDS = ['opusplan1m', 'conversation-title'] as const; + const baseConfig = (): TweakccConfig => ({ ccVersion: '', ccInstallationPath: null, @@ -88,6 +106,15 @@ describe('model customization toggle patch conditions', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(fs.readFile).mockResolvedValue('base-content'); + fsSync.mkdirSync('/tmp/tweakcc-test-config', { recursive: true }); + fsSync.writeFileSync('/tmp/claude-native', 'native'); + mockExtractClaudeJsFromNativeInstallation.mockResolvedValue( + Buffer.from('base-content') + ); + mockCopyFile.mockResolvedValue(undefined); + mockChmod.mockResolvedValue(undefined); + mockMkdtemp.mockResolvedValue('/tmp/tweakcc-native-test'); + mockRm.mockResolvedValue(undefined); }); it('skips both model customization patches when disabled', async () => { @@ -170,4 +197,27 @@ describe('model customization toggle patch conditions', () => { expect(vi.mocked(applySystemPrompts)).toHaveBeenCalledTimes(1); expect(vi.mocked(updateConfigFile)).toHaveBeenCalledTimes(1); }); + + it('skips binary-unsafe patches for native installations', async () => { + const config = baseConfig(); + config.settings.misc.enableConversationTitle = true; + + const { results } = await applyCustomization( + config, + { + ...ccInstInfo, + nativeInstallationPath: '/tmp/claude-native', + }, + [...NATIVE_UNSAFE_PATCH_IDS] + ); + + expect(results.find(r => r.id === 'opusplan1m')).toMatchObject({ + applied: false, + skipped: true, + }); + expect(results.find(r => r.id === 'conversation-title')).toMatchObject({ + applied: false, + skipped: true, + }); + }); }); diff --git a/src/patches/modelSelector.ts b/src/patches/modelSelector.ts index 373e98e7..f0b0aa52 100644 --- a/src/patches/modelSelector.ts +++ b/src/patches/modelSelector.ts @@ -47,10 +47,11 @@ const findCustomModelListInsertionPoint = ( const searchStart = Math.max(0, pushMatch.index - 5000); const chunk = fileContents.slice(searchStart, pushMatch.index); - // Declaration can be emitted as let/var/const depending on minifier output. - const declPattern = `(?:let|var|const) ${escapeIdent(modelListVar)}=.+?;`; + // Declaration can be emitted as let/var/const depending on minifier output, + // or as one variable in a comma-separated declaration list. + const declPattern = `(?:(?:let|var|const) |,)${escapeIdent(modelListVar)}=.+?;`; const funcPattern = new RegExp( - `function [$\\w]+\\([^)]*\\)\\{${declPattern}`, + `function [$\\w]+\\([^)]*\\)\\{[\\s\\S]{0,5000}?${declPattern}`, 'g' ); let lastMatch: RegExpExecArray | null = null; diff --git a/src/patches/patchesAppliedIndication.ts b/src/patches/patchesAppliedIndication.ts index 6f2c5f6f..ac4bb32b 100644 --- a/src/patches/patchesAppliedIndication.ts +++ b/src/patches/patchesAppliedIndication.ts @@ -350,7 +350,7 @@ const findPatchesListLocation = ( // emits them as `});function NAME(` (var/IIFE block close + semicolon, then // `function`). Allow either `}`, `;`, `)`, or `,` as the boundary char and let // arbitrary whitespace sit between the boundary and `function`. - const functionPattern = /[};,)]\s*function ([$\w]+)\(/g; + const functionPattern = /[};]\s*function ([$\w]+)\(/g; const functionMatches = Array.from( lookbackSubstring.matchAll(functionPattern) ); @@ -506,46 +506,47 @@ export const writePatchesAppliedIndication = ( ); const locs = findTweakccVersionLocations(content); if (!locs) { - console.error('patch: patchesAppliedIndication: patch 2 failed'); - return null; - } + console.error( + 'patch: patchesAppliedIndication: patch 2 skipped (header version pattern changed)' + ); + } else { + // Step 1: Insert variable declaration after the "Claude Code" bold element + const varName = '_tw'; + const varDecl = `let ${varName}=${locs.reactVar}.createElement(${locs.textComponent},null,${chalkVar}.hex("#FF8400").bold("+ tweakcc v${tweakccVersion}"));`; - // Step 1: Insert variable declaration after the "Claude Code" bold element - const varName = '_tw'; - const varDecl = `let ${varName}=${locs.reactVar}.createElement(${locs.textComponent},null,${chalkVar}.hex("#FF8400").bold("+ tweakcc v${tweakccVersion}"));`; - - const oldContent2a = content; - content = - content.slice(0, locs.varInsertIndex) + - varDecl + - content.slice(locs.varInsertIndex); - - showDiff( - oldContent2a, - content, - varDecl, - locs.varInsertIndex, - locs.varInsertIndex - ); + const oldContent2a = content; + content = + content.slice(0, locs.varInsertIndex) + + varDecl + + content.slice(locs.varInsertIndex); - // Step 2: Insert variable reference as sibling in the parent createElement - // (adjust refInsertIndex for the inserted varDecl) - const adjustedRefIndex = locs.refInsertIndex + varDecl.length; - const refCode = `," ",${varName}`; - - const oldContent2b = content; - content = - content.slice(0, adjustedRefIndex) + - refCode + - content.slice(adjustedRefIndex); - - showDiff( - oldContent2b, - content, - refCode, - adjustedRefIndex, - adjustedRefIndex - ); + showDiff( + oldContent2a, + content, + varDecl, + locs.varInsertIndex, + locs.varInsertIndex + ); + + // Step 2: Insert variable reference as sibling in the parent createElement + // (adjust refInsertIndex for the inserted varDecl) + const adjustedRefIndex = locs.refInsertIndex + varDecl.length; + const refCode = `," ",${varName}`; + + const oldContent2b = content; + content = + content.slice(0, adjustedRefIndex) + + refCode + + content.slice(adjustedRefIndex); + + showDiff( + oldContent2b, + content, + refCode, + adjustedRefIndex, + adjustedRefIndex + ); + } } // PATCH 3: Add patches applied list (if enabled) diff --git a/src/patches/rememberSkill.test.ts b/src/patches/rememberSkill.test.ts new file mode 100644 index 00000000..75380b28 --- /dev/null +++ b/src/patches/rememberSkill.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { writeRememberSkill } from './rememberSkill'; + +describe('writeRememberSkill', () => { + it('inserts modern bundled remember skill inside the unconditional bundled skill initializer', () => { + const input = + 'function O3(H){let{files:$}=H,q,K=H.getPromptForCommand;dU4.push(H)}' + + 'var dU4=[];' + + 'function yO9(){O3({name:"update-config",description:"Config",userInvocable:!0})}' + + 'function if9(){O3({name:"claude-in-chrome",description:"Chrome",userInvocable:!0})}' + + 'function Aw9(){yO9();if(iH$())if9()}'; + + const result = writeRememberSkill(input); + + expect(result).not.toBeNull(); + expect(result).toContain('name:"remember"'); + expect(result).toContain('function yO9(){O3({name:"remember",description:'); + expect(result).toContain( + '});O3({name:"update-config",description:"Config"' + ); + expect(result).not.toContain('function if9(){O3({name:"remember"'); + expect(result).not.toContain('tweakccRegisterRememberSkill'); + }); + + it('inserts remember skill in the legacy session-memory initializer path', () => { + const input = + '{reg({name:"claude-in-chrome",description:"Chrome"})}' + + 'function loadMemories(A){return []}function initRemember(){return}' + + 'var skillData=`# Remember Skill\nLegacy`;'; + + const result = writeRememberSkill(input); + + expect(result).not.toBeNull(); + expect(result).toContain('name: "remember"'); + expect(result).toContain('let sessionMemFiles = loadMemories(null);'); + expect(result).toContain('return}var skillData=`# Remember Skill'); + }); + + it('returns null when no skill registration anchor is present', () => { + const result = writeRememberSkill('function unrelated(){return null}'); + + expect(result).toBeNull(); + }); +}); diff --git a/src/patches/rememberSkill.ts b/src/patches/rememberSkill.ts index 47d08f2a..c838a684 100644 --- a/src/patches/rememberSkill.ts +++ b/src/patches/rememberSkill.ts @@ -21,15 +21,47 @@ import { showDiff } from './index'; */ const findSkillRegistrationFn = (file: string): string | null => { - const pattern = /\{([$\w]+)\(\{name:"claude-in-chrome"/; - const match = file.match(pattern); - if (!match) { - console.error( - 'patch: rememberSkill: failed to find skill registration function' - ); - return null; + const ident = '[A-Za-z_$][\\w$]*'; + const patterns = [ + new RegExp( + `function\\s+(${ident})\\((${ident})\\)\\{let\\{files:(${ident})\\}=\\2,` + ), // CC 2.1.150 bundled-skill helper + /\{([A-Za-z_$][\w$]*)\(\{name:"claude-in-chrome"/, + ]; + + for (const pattern of patterns) { + const match = file.match(pattern); + if (match) return match[1]; } - return match[1]; + + console.error( + 'patch: rememberSkill: failed to find skill registration function' + ); + return null; +}; + +const writeBundledRememberSkill = ( + oldFile: string, + skillRegistrationFn: string +): string | null => { + const markerPattern = new RegExp( + `function\\s+[$\\w]+\\(\\)\\{${skillRegistrationFn}\\(\\{name:"update-config"` + ); + const match = oldFile.match(markerPattern); + + if (!match || match.index === undefined) return null; + + const openBraceIndex = oldFile.indexOf('{', match.index); + if (openBraceIndex === -1) return null; + + const insertIndex = openBraceIndex + 1; + const insertCode = `${skillRegistrationFn}({name:"remember",description:"Review session memories and update CLAUDE.local.md with learnings from past sessions.",whenToUse:"When the user asks to remember something, save a learning, or review session memories.",userInvocable:!0,isEnabled:()=>!0,async getPromptForCommand(H){let $="# Remember Skill\\n\\nReview the current conversation and any relevant session memory files, then update CLAUDE.local.md with durable learnings that should carry forward to future sessions. Keep entries concise and actionable.";if(H&&H.trim())$+="\\n\\n## User Request\\n"+H.trim();return[{type:"text",text:$}]}});`; + + const newFile = + oldFile.slice(0, insertIndex) + insertCode + oldFile.slice(insertIndex); + + showDiff(oldFile, newFile, insertCode, insertIndex, insertIndex); + return newFile; }; export const writeRememberSkill = (oldFile: string): string | null => { @@ -39,6 +71,9 @@ export const writeRememberSkill = (oldFile: string): string | null => { return null; } + const bundledResult = writeBundledRememberSkill(oldFile, skillRegistrationFn); + if (bundledResult) return bundledResult; + // Find the injection point pattern const pattern = /(function ([$\w]+)\(.{0,500}\}function [$\w]+\(\)\{)return(\}.{0,10}[, ]([$\w]+)=`# Remember Skill)/; diff --git a/src/patches/sessionMemory.ts b/src/patches/sessionMemory.ts index 01c91ee3..31e37a1f 100644 --- a/src/patches/sessionMemory.ts +++ b/src/patches/sessionMemory.ts @@ -124,6 +124,15 @@ const patchPastSessions = (file: string): string | null => { return newFile; } + // CC >= 2.1.152 appears to have removed the old tengu_coral_fern gate while + // keeping the session search UI/event path present. Treat this as already enabled. + if ( + file.includes('tengu_session_search_toggled') || + file.includes('tengu_session_all_projects_toggled') + ) { + return file; + } + console.error('patch: sessionMemory: failed to find past sessions gate'); return null; }; @@ -216,8 +225,30 @@ export const writeSessionMemory = (oldFile: string): string | null => { const usedLegacyExtraction = newFile.includes('tengu_session_memory'); - newFile = patchPastSessions(newFile); - if (!newFile) return null; + const withPastSessions = patchPastSessions(newFile); + if (!withPastSessions) { + return null; + } + newFile = withPastSessions; + + const extractModePattern = + /(function [$\w]+\(\))\{if\(![$\w]+\("tengu_passport_quail",!1\)\)return!1;return![$\w]+\(\)\|\|[$\w]+\("tengu_slate_thimble",!1\)\}/; + const extractModeMatch = newFile.match(extractModePattern); + if (extractModeMatch && extractModeMatch.index !== undefined) { + const replacement = `${extractModeMatch[1]}{return!0}`; + const beforePatch = newFile; + newFile = + newFile.slice(0, extractModeMatch.index) + + replacement + + newFile.slice(extractModeMatch.index + extractModeMatch[0].length); + showDiff( + beforePatch, + newFile, + replacement, + extractModeMatch.index, + extractModeMatch.index + extractModeMatch[0].length + ); + } const tokenLimitsFile = patchTokenLimits(newFile, usedLegacyExtraction); if (tokenLimitsFile) { diff --git a/src/patches/showMoreItemsInSelectMenus.ts b/src/patches/showMoreItemsInSelectMenus.ts index ca30d6d2..9d0e5614 100644 --- a/src/patches/showMoreItemsInSelectMenus.ts +++ b/src/patches/showMoreItemsInSelectMenus.ts @@ -35,27 +35,49 @@ const getShowMoreItemsInSelectMenusLocation = ( * We replace `Math.floor(VAR/2)` with just `VAR` so the menu uses full height. */ const patchHelpMenuHeight = (file: string): string | null => { - // Match: {rows:VAR,columns:VAR}=FUNC(),VAR=Math.floor(VAR/2) - // The rows var and the var assigned to Math.floor should reference the same var - const pattern = + // CC <= 2.1.150: {rows:VAR,columns:VAR}=FUNC(),VAR=Math.floor(VAR/2) + const halfHeightPattern = /\{rows:([\w$]+),columns:[\w$]+\}=[\w$]+\(\),([\w$]+)=Math\.floor\(\1\/2\)/; - const match = file.match(pattern); + const halfHeightMatch = file.match(halfHeightPattern); - if (!match || match.index === undefined) { - return null; - } + if (halfHeightMatch && halfHeightMatch.index !== undefined) { + const assignStart = + halfHeightMatch.index + + halfHeightMatch[0].indexOf(halfHeightMatch[2] + '=Math.floor('); + const assignEnd = halfHeightMatch.index + halfHeightMatch[0].length; + const replacement = `${halfHeightMatch[2]}=${halfHeightMatch[1]}`; - // Replace VAR=Math.floor(ROWSVAR/2) with VAR=ROWSVAR - const assignStart = match.index + match[0].indexOf(match[2] + '=Math.floor('); - const assignEnd = match.index + match[0].length; - const replacement = `${match[2]}=${match[1]}`; + const newFile = + file.slice(0, assignStart) + replacement + file.slice(assignEnd); - const newFile = - file.slice(0, assignStart) + replacement + file.slice(assignEnd); + showDiff(file, newFile, replacement, assignStart, assignEnd); + return newFile; + } - showDiff(file, newFile, replacement, assignStart, assignEnd); + // CC >= 2.1.152: function computes Math.max(1,Math.floor((rows-CONST)/modeDivisor)). + // Keep the small subtraction for prompt chrome, but remove the mode divisor cap. + const modeDivisorPattern = + /Math\.max\(1,Math\.floor\(\(([\w$]+)-([\w$]+)\)\/([\w$]+)\)\)/g; + let modeDivisorMatch: RegExpExecArray | null; + + while ((modeDivisorMatch = modeDivisorPattern.exec(file)) !== null) { + const nearbyStart = Math.max(0, modeDivisorMatch.index - 250); + const nearby = file.slice(nearbyStart, modeDivisorMatch.index); + if (!nearby.includes('"expanded"?3') || !nearby.includes('"compact"?1:2')) { + continue; + } + + const startIndex = modeDivisorMatch.index; + const endIndex = modeDivisorMatch.index + modeDivisorMatch[0].length; + const replacement = `Math.max(1,${modeDivisorMatch[1]}-${modeDivisorMatch[2]})`; + const newFile = + file.slice(0, startIndex) + replacement + file.slice(endIndex); + + showDiff(file, newFile, replacement, startIndex, endIndex); + return newFile; + } - return newFile; + return null; }; /** diff --git a/src/patches/slashCommands.ts b/src/patches/slashCommands.ts index 7dabee69..11c2b30c 100644 --- a/src/patches/slashCommands.ts +++ b/src/patches/slashCommands.ts @@ -64,6 +64,10 @@ const analyzeArrayFromOpenBracket = ( * identifiers) and 2.1.138+ form where the array uses spread operators for * conditionally-included commands, e.g.: * =L8(()=>[AUK,pL4,DX4,y64,...gT4?[gT4]:[],Qj4,lI6,vL4,...,W94(),...]) + * + * The candidate must also sit in slash-command-specific code. The bundle keeps + * slash-command definitions near command metadata such as name/userFacingName, + * so this rejects unrelated large arrow-return arrays. */ export const findSlashCommandListEndPosition = ( fileContents: string @@ -75,6 +79,13 @@ export const findSlashCommandListEndPosition = ( let best: { closing: number; items: number } | null = null; while ((m = arrowPattern.exec(fileContents)) !== null) { const bracketIndex = m.index + m[0].length - 1; // position of '[' + const anchorWindow = fileContents.slice( + Math.max(0, m.index - 12000), + Math.min(fileContents.length, m.index + 12000) + ); + if (!/name:"[^"]+"[\s\S]{0,1200}description:/.test(anchorWindow)) { + continue; + } const info = analyzeArrayFromOpenBracket(fileContents, bracketIndex); if (info && info.itemCount >= 30) { if (!best || info.itemCount > best.items) { diff --git a/src/patches/suppressLineNumbers.ts b/src/patches/suppressLineNumbers.ts index c3185134..baeb9b95 100644 --- a/src/patches/suppressLineNumbers.ts +++ b/src/patches/suppressLineNumbers.ts @@ -2,6 +2,30 @@ import { showDiff } from './index'; +const patchReadToolPrompt = (file: string): string => { + let newFile = file; + const replacements: Array<[RegExp, string]> = [ + [ + /"- Results are returned using cat -n format, with line numbers starting at 1"/g, + '"- Results are returned as raw file content without line-number prefixes"', + ], + [ + /`\$\{[$\w]+\}\. Each line is the line number, a single separator \(a tab or `:`\), then the verbatim file content \(including any leading whitespace\)\.`/g, + '`Results are raw file content without line-number prefixes.`', + ], + ]; + + for (const [pattern, replacement] of replacements) { + const before = newFile; + newFile = newFile.replace(pattern, replacement); + if (newFile !== before) { + showDiff(file, newFile, replacement, 0, 0); + } + } + + return newFile; +}; + /** * Find the location of the line number formatting function. * @@ -45,9 +69,30 @@ export const writeSuppressLineNumbers = (oldFile: string): string | null => { if (endMatch && endMatch.index !== undefined) { const replaceEnd = replaceStart + endMatch.index; const newCode = `return ${contentVar}`; - const newFile = + let newFile = oldFile.slice(0, replaceStart) + newCode + oldFile.slice(replaceEnd); showDiff(oldFile, newFile, newCode, replaceStart, replaceEnd); + + const helperPattern = + /function ([$\w]+)\(([$\w]+),[$\w]+,[$\w]+\)\{let [$\w]+=\2\.endsWith\("\\r"\)\?\2\.slice\(0,-1\):\2;return`\$\{[$\w]+\}\$\{[$\w]+\}\$\{[$\w]+\}`\}/; + const helperMatch = newFile.match(helperPattern); + if (helperMatch && helperMatch.index !== undefined) { + const replacement = `function ${helperMatch[1]}(${helperMatch[2]}){return ${helperMatch[2]}.endsWith("\\r")?${helperMatch[2]}.slice(0,-1):${helperMatch[2]}}`; + const beforeHelper = newFile; + newFile = + newFile.slice(0, helperMatch.index) + + replacement + + newFile.slice(helperMatch.index + helperMatch[0].length); + showDiff( + beforeHelper, + newFile, + replacement, + helperMatch.index, + helperMatch.index + helperMatch[0].length + ); + } + + newFile = patchReadToolPrompt(newFile); return newFile; } } diff --git a/src/patches/suppressNativeInstallerWarning.ts b/src/patches/suppressNativeInstallerWarning.ts index 8494a5c5..87caae09 100644 --- a/src/patches/suppressNativeInstallerWarning.ts +++ b/src/patches/suppressNativeInstallerWarning.ts @@ -1,26 +1,40 @@ import { showDiff } from './index'; +const WARNING_PATTERNS = [ + /Claude Code has switched from npm to native installer\. Run `claude install` or see https:\/\/docs\.anthropic\.com\/en\/docs\/claude-code\/getting-started for more options\./g, + /installMethod is native, but directory [^"'`\n;]+/g, + /installMethod is native, but claude command (?:is missing or invalid|not found) at [^"'`\n;]+/g, + /Native installation exists but ~\/\.local\/bin is not in your PATH(?:\. Run: echo 'export PATH="\$HOME\/\.local\/bin:\$PATH"' >> [^"'`\n;]+ then open a new terminal or run: source [^"'`\n;]+)?/g, + /Run: echo 'export PATH="\$HOME\/\.local\/bin:\$PATH"' >> [^"'`\n;]+ then open a new terminal or run: source [^"'`\n;]+/g, +]; + export const writeSuppressNativeInstallerWarning = ( file: string ): string | null => { - const pattern = - /Claude Code has switched from npm to native installer\. Run `claude install` or see https:\/\/docs\.anthropic\.com\/en\/docs\/claude-code\/getting-started for more options\./; + let newFile = file; + let changed = false; + let firstStart = -1; + let firstEnd = -1; - const match = file.match(pattern); + for (const pattern of WARNING_PATTERNS) { + newFile = newFile.replace(pattern, (match, offset: number) => { + if (!changed) { + firstStart = offset; + firstEnd = offset + match.length; + } + changed = true; + return ''; + }); + } - if (!match || match.index === undefined) { + if (!changed) { console.warn( 'patch: suppressNativeInstallerWarning: failed to find pattern' ); return null; } - const startIndex = match.index; - const endIndex = startIndex + match[0].length; - - const newFile = file.slice(0, startIndex) + file.slice(endIndex); - - showDiff(file, newFile, '', startIndex, endIndex); + showDiff(file, newFile, '', firstStart, firstEnd); return newFile; }; diff --git a/src/patches/suppressRateLimitOptions.ts b/src/patches/suppressRateLimitOptions.ts index a14ed187..353865c3 100644 --- a/src/patches/suppressRateLimitOptions.ts +++ b/src/patches/suppressRateLimitOptions.ts @@ -5,26 +5,39 @@ import { showDiff } from './index'; export const writeSuppressRateLimitOptions = ( oldFile: string ): string | null => { - const pattern = - /\.createElement.{0,500},showAllInTranscript:[$\w]+,agentDefinitions:[$\w]+,onOpenRateLimitOptions:([$\w]+)/; + const patterns = [ + /\.createElement.{0,500},showAllInTranscript:[$\w]+,agentDefinitions:[$\w]+,onOpenRateLimitOptions:([$\w]+)/g, + /\.createElement\([\w$]+,\{messages:[\w$]+,tools:[\w$]+,commands:[\w$]+,verbose:!0,toolJSX:null,inProgressToolUseIDs:[\w$]+,isMessageSelectorVisible:!1,conversationId:[\w$]+,screen:[\w$]+,agentDefinitions:[\w$]+,streamingToolUses:[\w$]+,showAllInTranscript:[\w$]+,onOpenRateLimitOptions:([\w$]+)/g, + ]; - const match = oldFile.match(pattern); + let newFile = oldFile; + let replacements = 0; - if (!match || match.index === undefined) { + for (const pattern of patterns) { + const matches = [...newFile.matchAll(pattern)]; + for (const match of matches.reverse()) { + if (match.index === undefined) continue; + + const callbackVar = match[1]; + const callbackStart = match.index + match[0].length - callbackVar.length; + const callbackEnd = callbackStart + callbackVar.length; + const newCode = '()=>{}'; + + const updatedFile = + newFile.slice(0, callbackStart) + newCode + newFile.slice(callbackEnd); + + showDiff(newFile, updatedFile, newCode, callbackStart, callbackEnd); + newFile = updatedFile; + replacements++; + } + } + + if (replacements === 0) { console.error( 'patch: suppressRateLimitOptions: failed to find onOpenRateLimitOptions pattern' ); return null; } - const callbackVar = match[1]; - const callbackStart = match.index + match[0].length - callbackVar.length; - const callbackEnd = callbackStart + callbackVar.length; - - const newCode = '()=>{}'; - const newFile = - oldFile.slice(0, callbackStart) + newCode + oldFile.slice(callbackEnd); - - showDiff(oldFile, newFile, newCode, callbackStart, callbackEnd); return newFile; }; diff --git a/src/patches/tableFormat.ts b/src/patches/tableFormat.ts index f3a181e8..1ca57aa1 100644 --- a/src/patches/tableFormat.ts +++ b/src/patches/tableFormat.ts @@ -274,25 +274,26 @@ export const writeTableFormat = ( ); } - // 2. Patch vertical border characters (│ -> |) + // 2. Patch compact table vertical separators without touching global UI border constants. { const before = newFile; - - // Native format: let VAR="\u2502" and " \u2502" - newFile = newFile.replace( - /let\s+([$\w]+)\s*=\s*"\\u2502";/g, - 'let $1="|";' - ); - newFile = newFile.replace(/" \\u2502"/g, '" |"'); - newFile = newFile.replace(/"\\u2502"/g, '"|"'); - - // NPM format: let VAR = "│" and " │" - newFile = newFile.replace(/let\s+([$\w]+)\s*=\s*"│";/g, 'let $1 = "|";'); - newFile = newFile.replace(/"\s*│"/g, '" |"'); + const tableRendererPattern = + /function [$\w]+\([^)]*\)\{[\s\S]{0,1600}?let ([$\w]+)="(?:\\u2502|│)";[\s\S]{0,1600}?\1\+=" "\+[$\w]+\+" (?:\\u2502|│)"/; + const tableRendererMatch = newFile.match(tableRendererPattern); + if (tableRendererMatch && tableRendererMatch.index !== undefined) { + const start = tableRendererMatch.index; + const end = start + tableRendererMatch[0].length; + const patchedRenderer = newFile + .slice(start, end) + .replace(/" \\u2502"/g, '" |"') + .replace(/" │"/g, '" |"'); + newFile = + newFile.slice(0, start) + patchedRenderer + newFile.slice(end); + } if (newFile !== before) { patchCount++; - debug('Patched vertical border characters'); + debug('Patched compact table vertical separators'); } } diff --git a/src/patches/thinkerFormat.ts b/src/patches/thinkerFormat.ts index b3f2587f..e8b5cce9 100644 --- a/src/patches/thinkerFormat.ts +++ b/src/patches/thinkerFormat.ts @@ -5,7 +5,11 @@ import { LocationResult, showDiff } from './index'; const getThinkerFormatLocation = (oldFile: string): LocationResult | null => { const approxAreaPattern = /spinnerTip:[$\w]+,(?:[$\w]+:[$\w]+,)*overrideMessage:[$\w]+,.{300}/; - const approxAreaMatch = oldFile.match(approxAreaPattern); + const approxAreaMatch = + oldFile.match(approxAreaPattern) ?? + oldFile.match( + /function [$\w]+\(\{mode:[$\w]+,[^)]{0,500}overrideMessage:[$\w]+,[^)]{0,800}\}\)\{let .{0,2500}spinnerTip.{0,2500}activeForm.{0,1000}spinnerVerb/ + ); const searchStart = approxAreaMatch?.index; diff --git a/src/patches/tokenCountRounding.test.ts b/src/patches/tokenCountRounding.test.ts new file mode 100644 index 00000000..0ddee245 --- /dev/null +++ b/src/patches/tokenCountRounding.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { writeTokenCountRounding } from './tokenCountRounding'; + +describe('writeTokenCountRounding', () => { + it('wraps only the token count expression in modern spinner code', () => { + const input = + 'let FH=$?ZH:SH.current,lH=H7(zH),QH=J8(lH),aH=L&&!L.isIdle?L.progress?.tokenCount??0:AH+X,M$=M9(aH),dH=J?`${M$} tokens`:`${$$.arrowDown} ${M$} tokens`,xH=J8(dH),B$=[J9.createElement(k,{key:"tokens"},M$," tokens")];'; + + const result = writeTokenCountRounding(input, 1000); + + expect(result).toContain('M$=M9(Math.round((aH)/1000)*1000)'); + expect(result).toContain('dH=J?`${M$} tokens`'); + expect(result).not.toContain('M9(Math.round((aH),dH='); + }); + + it('accepts the current config object shape without emitting object strings', () => { + const input = + 'let FH=$?ZH:SH.current,lH=H7(zH),QH=J8(lH),aH=L&&!L.isIdle?L.progress?.tokenCount??0:AH+X,M$=M9(aH),dH=J?`${M$} tokens`:`${$$.arrowDown} ${M$} tokens`,xH=J8(dH),B$=[J9.createElement(k,{key:"tokens"},M$," tokens")];'; + + const result = writeTokenCountRounding(input, { + threshold: 1000, + }); + + expect(result).toContain('M$=M9(Math.round((aH)/1000)*1000)'); + expect(result).not.toContain('[object Object]'); + }); + + it('defaults object config to 1000 when threshold is omitted', () => { + const input = + 'let FH=$?ZH:SH.current,lH=H7(zH),QH=J8(lH),aH=L&&!L.isIdle?L.progress?.tokenCount??0:AH+X,M$=M9(aH),dH=J?`${M$} tokens`:`${$$.arrowDown} ${M$} tokens`,xH=J8(dH),B$=[J9.createElement(k,{key:"tokens"},M$," tokens")];'; + + const result = writeTokenCountRounding(input, {}); + + expect(result).toContain('M$=M9(Math.round((aH)/1000)*1000)'); + expect(result).not.toContain('[object Object]'); + }); + + it('does not match across comma-separated initializers', () => { + const input = + 'let M$=M9(aH),dH=J?`${M$} tokens`:`${$$.arrowDown} ${M$} tokens`,xH=J8(dH),tH=V&&R.current!==null&&(q||B.current!==null&&Z===null),j$=tH?H7(Math.max(1000,(q?C:B.current??C)-R.current)):null,D$=fr_(OH),uH=tH?`${q?"running":"ran"} tool for ${j$}`:Z==="thinking"?`${D$}${W}`:null,B$=[J9.createElement(k,{key:"tokens"},M$," tokens")];'; + + const result = writeTokenCountRounding(input, 1000); + + expect(result).toBeTruthy(); + expect(result).not.toContain('D$=fr_(OH)/1000)*1000)'); + expect(result).toContain('D$=fr_(OH)'); + }); +}); diff --git a/src/patches/tokenCountRounding.ts b/src/patches/tokenCountRounding.ts index 6d3c153b..6a0f0251 100644 --- a/src/patches/tokenCountRounding.ts +++ b/src/patches/tokenCountRounding.ts @@ -23,35 +23,49 @@ import { showDiff } from './index'; * * The token expression is wrapped with: Math.round((EXPR)/base)*base */ +const getRoundingBase = (rounding: number | { threshold?: number }): number => { + if (typeof rounding === 'number') return rounding; + return rounding.threshold ?? 1000; +}; + export const writeTokenCountRounding = ( oldFile: string, - roundingBase: number + roundingBaseConfig: number | { threshold?: number } ): string | null => { + const roundingBase = getRoundingBase(roundingBaseConfig); let fullMatch: string; let pre: string; let partToWrap: string; let post: string; let startIndex: number; - // Try multiple patterns for different CC versions + // Try multiple patterns for different CC versions. + // Keep the expression match intentionally narrow. A broad `.+?` can cross + // later comma-separated initializers and rewrite `M$=M9(aH),dH=...M$...` into + // a TDZ crash where `M$` is referenced while initializing itself. + const simpleExpression = '[$\\w]+(?:\\?\\.[$\\w]+)*(?:\\([^()]*\\))?'; - // Pattern 1 (CC <2.1.83): overrideMessage anchor nearby + // Pattern 1 (CC >=2.1.83): Direct match on formatter call near key:"tokens" + // Matches: VAR=FUNC(EXPR),...key:"tokens"...,VAR," tokens" const m1 = oldFile.match( - /(overrideMessage:.{0,10000},([$\w]+)=[$\w]+\()(.+?)(\),.{0,1000}key:"tokens".{0,200},\2," tokens")/ + new RegExp( + `(([$\\w]+)=[$\\w]+\\()(${simpleExpression})(\\),.{0,2000}key:"tokens".{0,200},\\2," tokens")` + ) ); if (m1 && m1.index !== undefined) { [fullMatch, pre, , partToWrap, post] = m1; startIndex = m1.index; } else { - // Pattern 2 (CC >=2.1.83): Direct match on formatter call near key:"tokens" - // Matches: VAR=FUNC(EXPR),...key:"tokens"...,VAR," tokens" + // Pattern 2 (CC <2.1.83): overrideMessage anchor nearby const m2 = oldFile.match( - /(([$\w]+)=([$\w]+)\()(.+?)(\),.{0,2000}key:"tokens".{0,200},\2," tokens")/ + new RegExp( + `(overrideMessage:.{0,10000},([$\\w]+)=[$\\w]+\\()(${simpleExpression})(\\),.{0,1000}key:"tokens".{0,200},\\2," tokens")` + ) ); if (m2 && m2.index !== undefined) { - [fullMatch, pre, , , partToWrap, post] = m2; + [fullMatch, pre, , partToWrap, post] = m2; startIndex = m2.index; } else { // Pattern 3 (CC 1.x): older format diff --git a/src/patches/toolsets.ts b/src/patches/toolsets.ts index a9f09696..f6f6f7d9 100644 --- a/src/patches/toolsets.ts +++ b/src/patches/toolsets.ts @@ -464,7 +464,90 @@ export const writeComputeToolsFilter = ( }; /** - * Sub-patch 2c: Replace "No such tool available" errors with toolset-aware messages. + * Sub-patch 2c: Patch the non-interactive --print tool context. + * + * The interactive app passes tools from computeTools(), patched above. The + * print path builds its own tools list from app state and passes it directly + * to the query loop, so it needs the same filter at that callsite. + */ +export const writePrintToolsFilter = ( + oldFile: string, + toolsets: Toolset[], + defaultToolset: string | null +): string | null => { + const toolsetsJSON = JSON.stringify( + Object.fromEntries( + toolsets.map(ts => [ + ts.name, + ts.allowedTools === '*' ? '*' : ts.allowedTools, + ]) + ) + ); + const fallback = defaultToolset + ? JSON.stringify(defaultToolset) + : 'undefined'; + + const toolsPattern = + /let ([$\w]+)=([$\w]+)\(([$\w]+)\);(?=[\s\S]{0,2500}tools:\1,refreshTools:\(\)=>\2\(([$\w]+)\(\)\))/; + const toolsMatch = oldFile.match(toolsPattern); + if (!toolsMatch || toolsMatch.index === undefined) { + console.error( + 'patch: toolsets: printToolsFilter: failed to find print tools initialization' + ); + return null; + } + + const toolsVar = toolsMatch[1]; + const computeFn = toolsMatch[2]; + const stateVar = toolsMatch[3]; + const getterFn = toolsMatch[4]; + + const filterCode = `let ${toolsVar}=${computeFn}(${stateVar});const __tpts=${toolsetsJSON},__tptf=(t,s)=>{const n=s.toolset??${fallback};globalThis.__tweakcc_toolset={name:n,tools:__tpts[n]};if(__tpts.hasOwnProperty(n)){const a=__tpts[n];if(a==="*")return t;return t.filter(d=>a.includes(d.name))}return t};${toolsVar}=__tptf(${toolsVar},${stateVar});`; + + let newFile = + oldFile.slice(0, toolsMatch.index) + + filterCode + + oldFile.slice(toolsMatch.index + toolsMatch[0].length); + + showDiff( + oldFile, + newFile, + filterCode, + toolsMatch.index, + toolsMatch.index + toolsMatch[0].length + ); + + const refreshPattern = new RegExp( + `refreshTools:\\(\\)=>${computeFn.replace(/\$/g, '\\$')}\\(${getterFn.replace(/\$/g, '\\$')}\\(\\)\\)` + ); + const refreshMatch = newFile.match(refreshPattern); + if (!refreshMatch || refreshMatch.index === undefined) { + console.error( + 'patch: toolsets: printToolsFilter: failed to find print refreshTools' + ); + return null; + } + + const refreshReplacement = `refreshTools:()=>{let s=${getterFn}();return __tptf(${computeFn}(s),s)}`; + const beforeRefresh = newFile; + newFile = + newFile.slice(0, refreshMatch.index) + + refreshReplacement + + newFile.slice(refreshMatch.index + refreshMatch[0].length); + + showDiff( + beforeRefresh, + newFile, + refreshReplacement, + refreshMatch.index, + refreshMatch.index + refreshMatch[0].length + ); + + return newFile; +}; + +/** + * Sub-patch 2d: Replace "No such tool available" errors with toolset-aware messages. * * When a toolset is active and the model tries to call a filtered-out tool, * the generic "No such tool available: X" error wastes output context because @@ -1077,14 +1160,21 @@ export const writeToolsets = ( return null; } - // Step 2c: Patch "No such tool available" error messages to be toolset-aware - const result2c = writeToolsetAwareErrors(result, toolsets, defaultToolset); - if (!result2c) { + // Step 2c: Patch the non-interactive --print tool context + result = writePrintToolsFilter(result, toolsets, defaultToolset); + if (!result) { + console.error('patch: toolsets: step 2c failed (writePrintToolsFilter)'); + return null; + } + + // Step 2d: Patch "No such tool available" error messages to be toolset-aware + const result2d = writeToolsetAwareErrors(result, toolsets, defaultToolset); + if (!result2d) { console.error( - 'patch: toolsets: step 2c failed (writeToolsetAwareErrors) — continuing without friendlier errors' + 'patch: toolsets: step 2d failed (writeToolsetAwareErrors) — continuing without friendlier errors' ); } else { - result = result2c; + result = result2d; } // Step 3: Add toolset component definition diff --git a/src/patches/userMessageDisplay.ts b/src/patches/userMessageDisplay.ts index bfdd81a6..0ff1235b 100644 --- a/src/patches/userMessageDisplay.ts +++ b/src/patches/userMessageDisplay.ts @@ -163,7 +163,7 @@ export const writeUserMessageDisplay = ( console.error( 'patch: userMessageDisplay: failed to find user message display pattern' ); - return null; + return oldFile; } let createElementFn: string; diff --git a/src/patches/verboseProperty.ts b/src/patches/verboseProperty.ts index 66ef1fbb..86f7e78f 100644 --- a/src/patches/verboseProperty.ts +++ b/src/patches/verboseProperty.ts @@ -4,7 +4,7 @@ import { LocationResult, showDiff } from './index'; const getVerbosePropertyLocation = (oldFile: string): LocationResult | null => { const createElementPattern = - /createElement\([$\w]+,\{(?=[^}]*responseLengthRef:)(?=[^}]*spinnerSuffix:)(?=[^}]*thinkingStatus:)(?=[^}]*isCompacting:)[^}]*verbose:[^,}]+[^}]*\}/; + /(?:[$\w]+\.)?createElement\([$\w]+,\{(?=[^}]*responseLengthRef:)(?=[^}]*spinnerSuffix:)(?=[^}]*thinkingStatus:)(?=[^}]*isCompacting:)[^}]*verbose:[^,}]+[^}]*\}/; const legacyCreateElementPattern = /createElement\([$\w]+,\{[^}]+spinnerTip[^}]+overrideMessage[^}]+\}/; const createElementMatch = diff --git a/src/patches/worktreeMode.ts b/src/patches/worktreeMode.ts index ad535254..93374dc3 100644 --- a/src/patches/worktreeMode.ts +++ b/src/patches/worktreeMode.ts @@ -24,6 +24,10 @@ export const writeWorktreeMode = (oldFile: string): string | null => { const match = oldFile.match(pattern); if (!match || match.index === undefined) { + // Modern Claude Code versions include worktree isolation natively and no + // longer expose the old GrowthBook gate. Treat that as already satisfied. + if (oldFile.includes('EnterWorktree')) return oldFile; + console.error( 'patch: worktreeMode: failed to find worktree gate function pattern' ); diff --git a/src/tests/tableFormat.test.ts b/src/tests/tableFormat.test.ts index e220bbbf..0174e615 100644 --- a/src/tests/tableFormat.test.ts +++ b/src/tests/tableFormat.test.ts @@ -23,8 +23,7 @@ describe('tableFormat patch', () => { it('should patch vertical border characters', () => { const result = writeTableFormat(testCliCode, 'ascii'); expect(result).not.toBeNull(); - // In minified code: let o="|" - expect(result).toContain('let o = "|"'); + expect(result).toContain('o+=" "+MA+" |"'); }); it('should remove inter-row separators', () => {