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 171e6500..9f9f311e 100644
--- a/src/patches/autoAcceptPlanMode.ts
+++ b/src/patches/autoAcceptPlanMode.ts
@@ -14,6 +14,139 @@
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(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 ? openBrace + lastMatch.index : null;
+ }
+ }
+ }
+
+ 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) {
@@ -25,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;
}
@@ -74,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;
@@ -83,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(
@@ -99,14 +235,32 @@ 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-keep-context");return null;`;
+ const newFile =
+ oldFile.slice(0, enclosingReturnIdx) +
+ insertion +
+ oldFile.slice(enclosingReturnIdx);
+
+ showDiff(
+ oldFile,
+ newFile,
+ insertion,
+ enclosingReturnIdx,
+ enclosingReturnIdx
);
- return null;
+ 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) +
@@ -114,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 7e9f9d72..067de499 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]+),`,
@@ -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 0ef72683..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 =
@@ -344,6 +359,19 @@ 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 (
+ findThemedBoxWrapper(fileContents, restStyleBoxMatch[1]) ??
+ restStyleBoxMatch[1]
+ );
+ }
+
console.error(
'patch: findBoxComponent: failed to find Box component (neither ink-box createElement nor displayName found)'
);
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/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/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('./index');
+ return {
+ ...actual,
+ findChalkVar: () => 'chalk',
+ showDiff: vi.fn(),
+ };
+});
+
+const baseHighlighter = (
+ overrides: Partial
+): 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 11d70a73..13001c50 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,31 @@ 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 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?.style?void 0:${segVar2}.highlight?.color` +
+ `,backgroundColor:${segVar2}.highlight?.style?void 0:${segVar2}.highlight?.backgroundColor` +
+ `,dimColor:${segVar2}.highlight?.dimColor` +
+ `,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) +
- newMatches2[0] +
+ augmentedRenderer +
workingFile.slice(newMatches2.index + newMatches2[0].length);
showDiff(oldFile, newFile, 'shimmer guard + renderer', 0, 0);
@@ -138,8 +163,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 +189,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;
}
@@ -179,8 +213,9 @@ 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
- JSON.stringify(highlighter.format).replace(/\{MATCH\}/g, '"+x+"'); // preserve legacy side-effect-free transform shape for diff stability
+ 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
// Note: format handling for this branch is currently color/style-only.
@@ -193,19 +228,56 @@ 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);
+ 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}${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) {
+ console.error(
+ 'patch: inputPatternHighlighters: no usable highlighters generated (all skipped)'
+ );
+ return null;
}
const replacement = match[1] + genCode + match[2];
@@ -271,9 +343,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/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 5630cb0b..f0b0aa52 100644
--- a/src/patches/modelSelector.ts
+++ b/src/patches/modelSelector.ts
@@ -39,14 +39,19 @@ 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.
- 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/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..ac4bb32b 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)
);
@@ -499,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 5a3f82c7..31e37a1f 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;
};
/**
@@ -105,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;
};
@@ -112,14 +140,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 +176,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 +205,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 +223,49 @@ export const writeSessionMemory = (oldFile: string): string | null => {
let newFile = patchExtraction(oldFile);
if (!newFile) return null;
- newFile = patchPastSessions(newFile);
- if (!newFile) return null;
+ const usedLegacyExtraction = newFile.includes('tengu_session_memory');
- newFile = patchTokenLimits(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) {
+ 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..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;
};
/**
@@ -183,16 +205,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..11c2b30c 100644
--- a/src/patches/slashCommands.ts
+++ b/src/patches/slashCommands.ts
@@ -3,49 +3,101 @@
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;
+ 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;
+};
- // Find the '[' in the match
- const bracketIndex = fileContents.indexOf('[', match.index);
- if (bracketIndex === -1) {
- console.error(
- 'patch: findSlashCommandListEndPosition: failed to find bracketIndex'
+/**
+ * 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(),...])
+ *
+ * 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
+): 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 anchorWindow = fileContents.slice(
+ Math.max(0, m.index - 12000),
+ Math.min(fileContents.length, m.index + 12000)
);
- 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
+ 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) {
+ 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..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.
*
@@ -24,9 +48,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) {
@@ -42,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 00433491..1ca57aa1 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');
@@ -268,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');
}
}
@@ -404,6 +411,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 +422,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..e8b5cce9 100644
--- a/src/patches/thinkerFormat.ts
+++ b/src/patches/thinkerFormat.ts
@@ -5,18 +5,19 @@ 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/
+ );
- 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 +26,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 +45,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 +56,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/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 6d261b76..f6f6f7d9 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;
@@ -459,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
@@ -478,10 +566,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 +580,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 +604,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 +792,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) {
@@ -1061,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 ae231897..0ff1235b 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,27 +148,47 @@ 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(
'patch: userMessageDisplay: failed to find user message display pattern'
);
- return null;
+ return oldFile;
}
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..86f7e78f 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 =
+ /(?:[$\w]+\.)?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;
};
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', () => {