diff --git a/src/defaultSettings.ts b/src/defaultSettings.ts index 9240c211..81043e5a 100644 --- a/src/defaultSettings.ts +++ b/src/defaultSettings.ts @@ -719,6 +719,8 @@ export const DEFAULT_SETTINGS: Settings = { enableVoiceMode: false, enableVoiceConciseOutput: true, enableChannelsMode: false, + disableGarnetLoom: false, + enableMaxAgentTurnsOverride: false, }, toolsets: [], defaultToolset: null, diff --git a/src/patches/agentsMd.ts b/src/patches/agentsMd.ts index dc1f8e50..fd719be0 100644 --- a/src/patches/agentsMd.ts +++ b/src/patches/agentsMd.ts @@ -67,10 +67,19 @@ export const writeAgentsMd = ( const fsPattern = /([$\w]+(?:\(\))?)\.(?:readFileSync|existsSync|statSync)/; const fsMatch = funcMatch[0].match(fsPattern); + if (!fsMatch) { - console.error('patch: agentsMd: failed to find fs expression in function'); - return null; + // CC 2.1.97+: reading and processing are split into separate functions. + // The content processor (sa_) has "Skipping non-text file" but no fs calls. + // Check if the feature is already built into this CC version. + if (file.includes('didReroute') && file.includes('AGENTS.md')) { + // CC 2.1.97+ already has AGENTS.md fallback built in — no patch needed + return file; + } + // Otherwise, patch the async reader function. + return writeAgentsMdAsync(file, altNames); } + const fsExpr = fsMatch[1]; const altNamesJson = JSON.stringify(altNames); @@ -119,3 +128,123 @@ export const writeAgentsMd = ( return newFile; }; + +/** + * CC 2.1.97+ variant: the file reading function is async and split from processing. + * + * Structure: + * ``` + * async function yP4(q,K,_){ + * try { + * let Y = await X8().readFile(q, {encoding:"utf-8"}); + * return sa_(Y, q, K, _); + * } catch(z) { + * return ta_(z, q), {info:null, includePaths:[]}; + * } + * } + * ``` + * + * We patch the catch block to try alternative filenames when ENOENT + CLAUDE.md. + */ +const writeAgentsMdAsync = ( + file: string, + altNames: string[] +): string | null => { + const infoNullStr = '{info:null,includePaths:[]}'; + + // Find the async reader function by locating {info:null,includePaths:[]} + // near a readFile call + let infoNullIdx = -1; + let searchStart = 0; + while (true) { + const idx = file.indexOf(infoNullStr, searchStart); + if (idx === -1) break; + + const lookback = file.slice(Math.max(0, idx - 500), idx); + if (lookback.includes('.readFile')) { + infoNullIdx = idx; + break; + } + searchStart = idx + 1; + } + + if (infoNullIdx === -1) { + console.error( + 'patch: agentsMd: failed to find async CLAUDE.md reader function (2.1.97+)' + ); + return null; + } + + const lookback = file.slice(Math.max(0, infoNullIdx - 500), infoNullIdx); + + // Find the async function definition + const asyncFuncPattern = + /async function ([$\w]+)\(([$\w]+),([$\w]+),([$\w]+)\)\{/g; + const funcMatches = Array.from(lookback.matchAll(asyncFuncPattern)); + if (funcMatches.length === 0) { + console.error( + 'patch: agentsMd: failed to find async function definition (2.1.97+)' + ); + return null; + } + const lastFunc = funcMatches[funcMatches.length - 1]; + const funcName = lastFunc[1]; + const pathParam = lastFunc[2]; + const typeParam = lastFunc[3]; + const resolvedParam = lastFunc[4]; + + // Find the catch variable + const catchPattern = /catch\(([$\w]+)\)\{/g; + const catchMatches = Array.from(lookback.matchAll(catchPattern)); + if (catchMatches.length === 0) { + console.error('patch: agentsMd: failed to find catch block (2.1.97+)'); + return null; + } + const catchVar = catchMatches[catchMatches.length - 1][1]; + + // Step 1: Add didReroute parameter to the function signature + const sigStr = `async function ${funcName}(${pathParam},${typeParam},${resolvedParam})`; + const sigIdx = file.indexOf(sigStr); + if (sigIdx === -1) { + console.error( + 'patch: agentsMd: failed to locate function signature for injection (2.1.97+)' + ); + return null; + } + const closingParenIdx = sigIdx + sigStr.length - 1; + let newFile = + file.slice(0, closingParenIdx) + + ',didReroute' + + file.slice(closingParenIdx); + + showDiff(file, newFile, ',didReroute', closingParenIdx, closingParenIdx); + + // Step 2: Inject fallback in the catch block before {info:null,...} + // The catch block is: catch(z){return ta_(z,q),{info:null,includePaths:[]}} + // We inject before the return statement. + const catchBlockStr = `catch(${catchVar}){`; + const catchBlockIdx = newFile.indexOf(catchBlockStr, sigIdx); + if (catchBlockIdx === -1) { + console.error( + 'patch: agentsMd: failed to locate catch block for injection (2.1.97+)' + ); + return null; + } + const returnIdx = catchBlockIdx + catchBlockStr.length; + + const altNamesJson = JSON.stringify(altNames); + const fallback = + `if(${catchVar}&&${catchVar}.code==="ENOENT"&&!didReroute` + + `&&(${pathParam}.endsWith("/CLAUDE.md")||${pathParam}.endsWith("\\\\CLAUDE.md")))` + + `{for(let alt of ${altNamesJson})` + + `{try{let altPath=${pathParam}.slice(0,-9)+alt;` + + `let r=await ${funcName}(altPath,${typeParam},${resolvedParam},true);` + + `if(r.info)return r}catch(e){}}}`; + + const oldFile = newFile; + newFile = newFile.slice(0, returnIdx) + fallback + newFile.slice(returnIdx); + + showDiff(oldFile, newFile, fallback, returnIdx, returnIdx); + + return newFile; +}; diff --git a/src/patches/autoAcceptPlanMode.ts b/src/patches/autoAcceptPlanMode.ts index 90bf1686..4e763fb8 100644 --- a/src/patches/autoAcceptPlanMode.ts +++ b/src/patches/autoAcceptPlanMode.ts @@ -47,9 +47,11 @@ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { // Look for onChange handler after Ready to code const afterReady = oldFile.slice(readyIdx, readyIdx + 3000); - const onChangeMatch = afterReady.match( - /onChange:\([$\w]+\)=>([$\w]+)\([$\w]+\),onCancel/ - ); + // CC ≥2.1.97: onChange:J6,onCancel (direct reference) + // CC <2.1.97: onChange:(X)=>FUNC(X),onCancel (arrow wrapper) + const onChangeMatch = + afterReady.match(/onChange:([$\w]+),onCancel/) || + afterReady.match(/onChange:\([$\w]+\)=>([$\w]+)\([$\w]+\),onCancel/); if (!onChangeMatch) { console.error('patch: autoAcceptPlanMode: failed to find onChange handler'); return null; @@ -67,8 +69,10 @@ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { // Match the end of the "Exit plan mode?" conditional and the start of // the "Ready to code?" return. + // CC ≥2.1.97: wrapper changed from Fragment to Box (u) with props + // CC <2.1.97: wrapper is Fragment with null const pattern = - /(\}\}\)\)\)\);)(return [$\w]+\.default\.createElement\([$\w]+\.default\.Fragment,null,[$\w]+\.default\.createElement\([$\w]+,\{color:"planMode",title:"Ready to code\?")/; + /(\}\}\)\)\)\);)(return [$\w]+\.default\.createElement\((?:[$\w]+\.default\.Fragment,null|[$\w]+,\{[^}]*\}),[$\w]+\.default\.createElement\([$\w]+,\{color:"planMode",title:"Ready to code\?")/; const match = oldFile.match(pattern); if (!match || match.index === undefined) { diff --git a/src/patches/contextLimit.ts b/src/patches/contextLimit.ts index c66a2744..a2048881 100644 --- a/src/patches/contextLimit.ts +++ b/src/patches/contextLimit.ts @@ -1,11 +1,51 @@ -// Please see the note about writing patches in ./index +import { showDiff } from './index'; -import { globalReplace } from './index'; +/** + * Replaces the hardcoded default context limit (200K) with an env var override. + * + * The context window function determines the token limit for each model: + * ``` + * function TV(q,K){ + * if(Cf(q))return 1e6; // [1m] models → 1M + * if(K?.includes(ni)&&U01(q))return 1e6; // SDK beta + supported model → 1M + * if(cZ8(q))return 1e6; // coral_reef_sonnet experiment → 1M + * return eN1 // default: eN1 = 200000 + * } + * ``` + * + * We patch the final `return eN1` to read from CLAUDE_CODE_CONTEXT_LIMIT env var, + * falling back to the original eN1 value if unset. + * + * ```diff + * - return eN1 + * + return Number(process.env.CLAUDE_CODE_CONTEXT_LIMIT??eN1) + * ``` + */ +export const writeContextLimit = (file: string): string | null => { + // Find the context window function by its unique structure: + // three 1e6 returns (for special models) then a variable return (default) + const pattern = + /function ([$\w]+)\(([$\w]+),([$\w]+)\)\{if\([$\w]+\(\2\)\)return 1e6;if\(\3\?\.includes\([$\w]+\)&&[$\w]+\(\2\)\)return 1e6;if\([$\w]+\(\2\)\)return 1e6;return ([$\w]+)\}/; -export const writeContextLimit = (oldFile: string): string | null => { - return globalReplace( - oldFile, - /\b200000\b/, - '(+process.env.CLAUDE_CODE_CONTEXT_LIMIT||200000)' - ); + const match = file.match(pattern); + if (!match || match.index === undefined) { + console.error( + 'patch: contextLimit: failed to find context window function' + ); + return null; + } + + const defaultVar = match[4]; + const oldStr = `return ${defaultVar}}`; + const newStr = `return Number(process.env.CLAUDE_CODE_CONTEXT_LIMIT??${defaultVar})}`; + + const replaceStart = match.index + match[0].length - oldStr.length; + const newFile = + file.slice(0, replaceStart) + + newStr + + file.slice(replaceStart + oldStr.length); + + showDiff(file, newFile, newStr, replaceStart, replaceStart + oldStr.length); + + return newFile; }; diff --git a/src/patches/garnetLoom.ts b/src/patches/garnetLoom.ts new file mode 100644 index 00000000..8fc0410a --- /dev/null +++ b/src/patches/garnetLoom.ts @@ -0,0 +1,66 @@ +import { showDiff } from './index'; + +// ====== + +/** + * Disable tengu_garnet_loom — prevents auto-downgrade of Opus to Sonnet + * + * CC 2.1.97 has a feature gate `tengu_garnet_loom` that, when enabled server-side, + * auto-downgrades Opus subagents to Sonnet when context is under 200K tokens. + * + * The pattern in the subagent model resolver: + * if(!Y && Jz(j).includes("opus") && R8("tengu_garnet_loom",!1)){ + * let H=$5("sonnet"); return O(H,"sonnet") + * } + * + * This patch forces the R8("tengu_garnet_loom") check to always return false, + * ensuring Opus stays Opus regardless of what GrowthBook says. + */ +export const writeDisableGarnetLoom = (oldFile: string): string | null => { + // Pattern: R8("tengu_garnet_loom",!1) + // This appears in the subagent model resolver function + const pattern = /R8\("tengu_garnet_loom",!1\)/; + + const match = oldFile.match(pattern); + if (!match || match.index === undefined) { + // Try generic feature gate check function names + const altPattern = /([$\w]+)\("tengu_garnet_loom",![01]\)/; + const altMatch = oldFile.match(altPattern); + if (!altMatch || altMatch.index === undefined) { + console.error( + 'patch: garnetLoom: failed to find tengu_garnet_loom feature gate check' + ); + return null; + } + + // Replace the entire check with false + const replacement = '!1'; + const newFile = + oldFile.slice(0, altMatch.index) + + replacement + + oldFile.slice(altMatch.index + altMatch[0].length); + showDiff( + oldFile, + newFile, + replacement, + altMatch.index, + altMatch.index + altMatch[0].length + ); + return newFile; + } + + // Replace R8("tengu_garnet_loom",!1) with !1 (always false = never downgrade) + const replacement = '!1'; + const newFile = + oldFile.slice(0, match.index) + + replacement + + oldFile.slice(match.index + match[0].length); + showDiff( + oldFile, + newFile, + replacement, + match.index, + match.index + match[0].length + ); + return newFile; +}; diff --git a/src/patches/helpers.ts b/src/patches/helpers.ts index 94e2fbe1..203ef811 100644 --- a/src/patches/helpers.ts +++ b/src/patches/helpers.ts @@ -333,6 +333,35 @@ export const findBoxComponent = (fileContents: string): string | undefined => { return boxDisplayNameMatch[1]; } + // Method 4: Find Box by createElement usage near known anchor (CC 2.1.97+) + // In 2.1.97, ink-box is no longer used via createElement directly in most code. + // Instead, a Box wrapper component is assigned to a short local variable (e.g. `u`) + // and used as createElement(u, {flexDirection:...}). We find it by locating a known + // anchor (the "Claude Code" bold text in the header) and checking which variable + // is used with flexDirection props nearby. + const headerAnchor = fileContents.match( + /createElement\([$\w]+,\{bold:!0\},"Claude Code"\)/ + ); + if (headerAnchor && headerAnchor.index !== undefined) { + const searchStart = Math.max(0, headerAnchor.index - 2000); + const searchEnd = Math.min(fileContents.length, headerAnchor.index + 2000); + const region = fileContents.slice(searchStart, searchEnd); + const boxUsage = region.match( + /createElement\(([$\w]+),\{(?:flexDirection|gap|marginBottom)/ + ); + if (boxUsage) { + return boxUsage[1]; + } + } + + // Method 4b: Fallback — find Box wrapper by border prop destructuring + const borderBoxPattern = + /function ([$\w]+)\([$\w]+\)\{.{0,200}borderColor:[$\w]+,borderTopColor:[$\w]+,borderBottomColor:[$\w]+,borderLeftColor:[$\w]+,borderRightColor:[$\w]+,backgroundColor:[$\w]+,children:[$\w]+,ref:[$\w]+/; + const borderBoxMatch = fileContents.match(borderBoxPattern); + if (borderBoxMatch) { + return borderBoxMatch[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 ccb160b5..635f836f 100644 --- a/src/patches/hideStartupBanner.ts +++ b/src/patches/hideStartupBanner.ts @@ -3,22 +3,35 @@ import { LocationResult, showDiff } from './index'; const getStartupBannerLocation = (oldFile: string): LocationResult | null => { - // Find the createElement with isBeforeFirstMessage:!1 - const pattern = + // Try old pattern first: createElement with isBeforeFirstMessage:!1 + const oldPattern = /,[$\w]+\.createElement\([$\w]+,\{isBeforeFirstMessage:!1\}\),/; - const match = oldFile.match(pattern); + const oldMatch = oldFile.match(oldPattern); - if (!match || match.index === undefined) { - console.error( - 'patch: hideStartupBanner: failed to find startup banner createElement' - ); - return null; + if (oldMatch && oldMatch.index !== undefined) { + return { + startIndex: oldMatch.index, + endIndex: oldMatch.index + oldMatch[0].length, + }; + } + + // CC ≥2.1.97: startup redesigned to IDE onboarding screen + // Function: function YI4(){let q=j8(),K=_y.terminal||"unknown";return q.hasIdeOnboardingBeenShown?.[K]===!0} + const newPattern = + /function ([$\w]+)\(\)\{let [$\w]+=[$\w]+\(\),[$\w]+=[$\w]+\.terminal\|\|"unknown";return [$\w]+\.hasIdeOnboardingBeenShown\?\.\[[$\w]+\]===!0\}/; + const newMatch = oldFile.match(newPattern); + + if (newMatch && newMatch.index !== undefined) { + return { + startIndex: newMatch.index, + endIndex: newMatch.index + newMatch[0].length, + }; } - return { - startIndex: match.index, - endIndex: match.index + match[0].length, - }; + console.error( + 'patch: hideStartupBanner: failed to find startup banner pattern' + ); + return null; }; export const writeHideStartupBanner = (oldFile: string): string | null => { @@ -27,12 +40,30 @@ export const writeHideStartupBanner = (oldFile: string): string | null => { return null; } - // Remove the element by slicing it out (replace with just a comma to maintain syntax) + const originalText = oldFile.slice(location.startIndex, location.endIndex); + + let replacement: string; + + if (originalText.startsWith(',')) { + // Old pattern: remove the element by replacing with just a comma + replacement = ','; + } else { + // New pattern (CC ≥2.1.97): force YI4() to always return true (skip onboarding) + // Insert return !0; right after the opening brace + replacement = originalText.replace(/\{let/, '{return !0;let'); + } + const newFile = oldFile.slice(0, location.startIndex) + - ',' + + replacement + oldFile.slice(location.endIndex); - showDiff(oldFile, newFile, ',', location.startIndex, location.endIndex); + showDiff( + oldFile, + newFile, + replacement, + location.startIndex, + location.endIndex + ); return newFile; }; diff --git a/src/patches/increaseFileReadLimit.ts b/src/patches/increaseFileReadLimit.ts index 83a6c18b..09beea74 100644 --- a/src/patches/increaseFileReadLimit.ts +++ b/src/patches/increaseFileReadLimit.ts @@ -9,25 +9,34 @@ import { LocationResult, showDiff } from './index'; * the next ~100 characters to ensure we're targeting the correct value. */ const getFileReadLimitLocation = (oldFile: string): LocationResult | null => { - // Pattern: =25000, followed within ~100 chars by - const pattern = /=25000,([\s\S]{0,100})/; - const match = oldFile.match(pattern); + // CC ≥2.1.97: constants grouped together: TQ=2000,VQ=2000,em1=25000,... + // Use nearby constants as anchors instead of proximity + const newPattern = /TQ=\d+,VQ=\d+,([$\w]+)=25000/; + const newMatch = oldFile.match(newPattern); + + if (newMatch && newMatch.index !== undefined) { + // Find the position of "25000" in the match + const fullMatch = newMatch[0]; + const valueStart = newMatch.index + fullMatch.lastIndexOf('=25000') + 1; + const valueEnd = valueStart + 5; + return { startIndex: valueStart, endIndex: valueEnd }; + } + + // Fall back to old pattern: =25000, followed within ~100 chars by + const oldPattern = /=25000,([\s\S]{0,100})/; + const oldMatch = oldFile.match(oldPattern); - if (!match || match.index === undefined) { + if (!oldMatch || oldMatch.index === undefined) { console.error( - 'patch: increaseFileReadLimit: failed to find 25000 token limit near system-reminder' + 'patch: increaseFileReadLimit: failed to find 25000 token limit' ); return null; } - // The "25000" starts at match.index + 1 (after the "=") - const startIndex = match.index + 1; - const endIndex = startIndex + 5; // "25000" is 5 characters + const startIndex = oldMatch.index + 1; + const endIndex = startIndex + 5; - return { - startIndex, - endIndex, - }; + return { startIndex, endIndex }; }; export const writeIncreaseFileReadLimit = (oldFile: string): string | null => { diff --git a/src/patches/index.ts b/src/patches/index.ts index a1c69861..33f80196 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -73,6 +73,8 @@ import { writeWorktreeMode } from './worktreeMode'; import { writeAllowCustomAgentModels } from './allowCustomAgentModels'; import { writeVoiceMode } from './voiceMode'; import { writeChannelsMode } from './channelsMode'; +import { writeDisableGarnetLoom } from './garnetLoom'; +import { writeMaxAgentTurns } from './maxAgentTurns'; import { restoreNativeBinaryFromBackup, restoreClijsFromBackup, @@ -430,6 +432,20 @@ const PATCH_DEFINITIONS = [ description: 'Enable MCP channel notifications (--channels without allowlist or dev flag)', }, + { + id: 'disable-garnet-loom', + name: 'Disable garnet loom', + group: PatchGroup.MISC_CONFIGURABLE, + description: + 'Prevent server-side auto-downgrade of Opus subagents to Sonnet', + }, + { + id: 'max-agent-turns', + name: 'Max agent turns', + group: PatchGroup.MISC_CONFIGURABLE, + description: + 'Make subagent maxTurns configurable via CLAUDE_CODE_MAX_AGENT_TURNS env var', + }, ] as const; /** Union type of all valid patch IDs */ @@ -888,6 +904,14 @@ export const applyCustomization = async ( fn: c => writeChannelsMode(c), condition: !!config.settings.misc?.enableChannelsMode, }, + 'disable-garnet-loom': { + fn: c => writeDisableGarnetLoom(c), + condition: !!config.settings.misc?.disableGarnetLoom, + }, + 'max-agent-turns': { + fn: c => writeMaxAgentTurns(c), + condition: !!config.settings.misc?.enableMaxAgentTurnsOverride, + }, }; // ========================================================================== diff --git a/src/patches/maxAgentTurns.ts b/src/patches/maxAgentTurns.ts new file mode 100644 index 00000000..7b421b30 --- /dev/null +++ b/src/patches/maxAgentTurns.ts @@ -0,0 +1,60 @@ +import { showDiff } from './index'; + +// ====== + +/** + * Make subagent maxTurns configurable via environment variable + * + * CC 2.1.97 hardcodes cjz=20 as the default maxTurns for subagents, + * with ljz=100 as some upper limit. + * + * This patch replaces the hardcoded 20 with: + * Number(process.env.CLAUDE_CODE_MAX_AGENT_TURNS ?? 20) + * + * And the hardcoded 100 with: + * Number(process.env.CLAUDE_CODE_MAX_AGENT_TURNS_LIMIT ?? 100) + */ +export const writeMaxAgentTurns = (oldFile: string): string | null => { + // Pattern: cjz=20,ljz=100 + // These are the default maxTurns constants for subagents + const pattern = /([$\w]+)=20,([$\w]+)=100,([$\w]+),([$\w]+);var ([$\w]+)=L\(/; + + const match = oldFile.match(pattern); + if (!match || match.index === undefined) { + // Try simpler pattern with nearby context to verify correct 20,100 pair + const contextPattern = /([$\w]+)=20,([$\w]+)=100,[$\w]+,[$\w]+;/; + const contextMatch = oldFile.match(contextPattern); + if (!contextMatch || contextMatch.index === undefined) { + console.error('patch: maxAgentTurns: failed to find maxTurns constants'); + return null; + } + + const var1 = contextMatch[1]; + const var2 = contextMatch[2]; + const oldText = `${var1}=20,${var2}=100`; + const newText = `${var1}=Number(process.env.CLAUDE_CODE_MAX_AGENT_TURNS??20),${var2}=Number(process.env.CLAUDE_CODE_MAX_AGENT_TURNS_LIMIT??100)`; + + const idx = contextMatch.index; + const newFile = + oldFile.slice(0, idx) + + contextMatch[0].replace(oldText, newText) + + oldFile.slice(idx + contextMatch[0].length); + + showDiff(oldFile, newFile, newText, idx, idx + oldText.length); + return newFile; + } + + const var1 = match[1]; + const var2 = match[2]; + const oldText = `${var1}=20,${var2}=100`; + const newText = `${var1}=Number(process.env.CLAUDE_CODE_MAX_AGENT_TURNS??20),${var2}=Number(process.env.CLAUDE_CODE_MAX_AGENT_TURNS_LIMIT??100)`; + + const idx = match.index; + const newFile = + oldFile.slice(0, idx) + + match[0].replace(oldText, newText) + + oldFile.slice(idx + match[0].length); + + showDiff(oldFile, newFile, newText, idx, idx + oldText.length); + return newFile; +}; diff --git a/src/patches/mcpStartup.ts b/src/patches/mcpStartup.ts index c3d8b9e3..abbb9fe7 100644 --- a/src/patches/mcpStartup.ts +++ b/src/patches/mcpStartup.ts @@ -19,12 +19,34 @@ import { showDiff, LocationResult } from './index'; const getNonBlockingCheckLocation = ( oldFile: string ): LocationResult | null => { - // Match: !VARNAME(process.env.MCP_CONNECTION_NONBLOCKING) - // The variable name changes between npm/native builds, so we match any identifier - const pattern = /![$\w]+\(process\.env\.MCP_CONNECTION_NONBLOCKING\)/; - const match = oldFile.match(pattern); + // CC ≥2.1.97: R3=B6(process.env.MCP_CONNECTION_NONBLOCKING) + // We replace the assignment value with !0 (true) to force non-blocking + const newPattern = + /([$\w]+)=[$\w]+\(process\.env\.MCP_CONNECTION_NONBLOCKING\)/; + const newMatch = oldFile.match(newPattern); + + if (newMatch && newMatch.index !== undefined) { + // Replace the whole assignment RHS: VAR=B6(...) → VAR=!0 + const varName = newMatch[1]; + return { + startIndex: newMatch.index, + endIndex: newMatch.index + newMatch[0].length, + identifiers: [varName], + }; + } + + // Fall back to old pattern: !VARNAME(process.env.MCP_CONNECTION_NONBLOCKING) + const oldPattern = /![$\w]+\(process\.env\.MCP_CONNECTION_NONBLOCKING\)/; + const oldMatch = oldFile.match(oldPattern); + + if (!oldMatch || oldMatch.index === undefined) { + // CC ≥2.1.97: non-blocking may be hardcoded as R3=!0 (already default) + // Check if the flag is already true — if so, no patch needed + const hardcodedPattern = /([$\w]+)=!0,.{0,100}MCP_CONNECTION_NONBLOCKING/; + if (oldFile.match(hardcodedPattern)) { + return null; // Already non-blocking by default + } - if (!match || match.index === undefined) { console.error( 'patch: mcpStartup: failed to find MCP_CONNECTION_NONBLOCKING check' ); @@ -32,8 +54,8 @@ const getNonBlockingCheckLocation = ( } return { - startIndex: match.index, - endIndex: match.index + match[0].length, + startIndex: oldMatch.index, + endIndex: oldMatch.index + oldMatch[0].length, }; }; @@ -44,30 +66,41 @@ 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+)/; - const match = oldFile.match(pattern); + // CC ≥2.1.97: return q>0?q:3 (inside a function that parses the env var) + const newPattern = + /MCP_SERVER_CONNECTION_BATCH_SIZE\|\|"",10\);return [$\w]+>0\?[$\w]+:(\d+)/; + const newMatch = oldFile.match(newPattern); + + if (newMatch && newMatch.index !== undefined) { + const fullMatch = newMatch[0]; + const defaultValue = newMatch[1]; + const defaultValueOffset = fullMatch.lastIndexOf(defaultValue); + + const startIndex = newMatch.index + defaultValueOffset; + const endIndex = startIndex + defaultValue.length; - if (!match || match.index === undefined) { + return { startIndex, endIndex }; + } + + // Fall back to old pattern: MCP_SERVER_CONNECTION_BATCH_SIZE||"",10)||3 + const oldPattern = /MCP_SERVER_CONNECTION_BATCH_SIZE\|\|"",10\)\|\|(\d+)/; + const oldMatch = oldFile.match(oldPattern); + + if (!oldMatch || oldMatch.index === undefined) { console.error( 'patch: mcpStartup: failed to find MCP_SERVER_CONNECTION_BATCH_SIZE default' ); return null; } - // Find the position of the default number (the captured group) - const fullMatch = match[0]; - const defaultValue = match[1]; + const fullMatch = oldMatch[0]; + const defaultValue = oldMatch[1]; const defaultValueOffset = fullMatch.lastIndexOf(defaultValue); - const startIndex = match.index + defaultValueOffset; + const startIndex = oldMatch.index + defaultValueOffset; const endIndex = startIndex + defaultValue.length; - return { - startIndex, - endIndex, - }; + return { startIndex, endIndex }; }; /** @@ -76,11 +109,17 @@ const getBatchSizeLocation = (oldFile: string): LocationResult | null => { export const writeMcpNonBlocking = (oldFile: string): string | null => { const location = getNonBlockingCheckLocation(oldFile); if (!location) { + // CC ≥2.1.97: non-blocking is already the default (hardcoded !0) + const hardcoded = /([$\w]+)=!0,.{0,100}MCP_CONNECTION_NONBLOCKING/; + if (oldFile.match(hardcoded)) return oldFile; return null; } - // Replace the check with "false" to force non-blocking mode - const newValue = 'false'; + // New pattern (CC ≥2.1.97): replace VAR=B6(...) with VAR=!0 + // Old pattern: replace !fn(...) with false + const newValue = location.identifiers + ? `${location.identifiers[0]}=!0` + : 'false'; const newFile = oldFile.slice(0, location.startIndex) + newValue + diff --git a/src/patches/patchesAppliedIndication.ts b/src/patches/patchesAppliedIndication.ts index 3d5b4496..fd929a28 100644 --- a/src/patches/patchesAppliedIndication.ts +++ b/src/patches/patchesAppliedIndication.ts @@ -40,15 +40,38 @@ const findTweakccVersionLocation = ( const pattern = /[^$\w]([$\w]+)\.createElement\(([$\w]+),\{bold:!0\},"Claude Code"\)," ",([$\w]+)\.createElement\(([$\w]+),\{dimColor:!0\},"v",[$\w]+\)/; const match = fileContents.match(pattern); - if (!match || match.index === undefined) { + if (match && match.index !== undefined) { + // Insert right after this match + const insertIndex = match.index + match[0].length; + return { + startIndex: insertIndex, + endIndex: insertIndex, + }; + } + + // CC 2.1.97+: version display is in a memo-cached wrapper + // createElement(T,null,x," ",createElement(T,{dimColor:!0},"v",G)) + // Insert after inner createElement (before outer closing paren) + const boldIdx = fileContents.indexOf('{bold:!0},"Claude Code"'); + if (boldIdx === -1) { console.error( 'patch: patchesAppliedIndication: failed to find Claude Code version pattern' ); return null; } - // Insert right after this match - const insertIndex = match.index + match[0].length; + const searchRegion = fileContents.slice(boldIdx, boldIdx + 500); + const versionPattern = + /\.createElement\([$\w]+,\{dimColor:!0\},"v",[$\w]+\)(?=\))/; + const versionMatch = searchRegion.match(versionPattern); + if (!versionMatch || versionMatch.index === undefined) { + console.error( + 'patch: patchesAppliedIndication: failed to find Claude Code version pattern (2.1.97+)' + ); + return null; + } + + const insertIndex = boldIdx + versionMatch.index + versionMatch[0].length; return { startIndex: insertIndex, endIndex: insertIndex, @@ -183,7 +206,10 @@ const applyIndicatorPatchesListPatch = ( let currentIndex = startIndex; let insertionIndex = -1; - while (currentIndex < fileContents.length) { + while ( + currentIndex < fileContents.length && + currentIndex < startIndex + 5000 + ) { const ch = fileContents[currentIndex]; if (ch === '(') { level++; @@ -198,6 +224,23 @@ const applyIndicatorPatchesListPatch = ( currentIndex++; } + // CC 2.1.97+: memo caching flattens the nesting, so the stack machine won't find + // level 1. Instead, find the Fragment createElement near the end of the enclosing + // function and insert our element as an additional child. + if (insertionIndex === -1) { + // Find the enclosing function's return statement by looking for + // createElement(REACT.Fragment,null,...) after the startIndex + const searchRegion = fileContents.slice(startIndex, startIndex + 5000); + const fragmentPattern = + /\.createElement\(([$\w]+)\.Fragment,null,(?:[$\w]+,)*[$\w]+\)/; + const fragmentMatch = searchRegion.match(fragmentPattern); + if (fragmentMatch && fragmentMatch.index !== undefined) { + // Insert before the closing ) of the Fragment createElement + insertionIndex = + startIndex + fragmentMatch.index + fragmentMatch[0].length - 1; + } + } + if (insertionIndex === -1) { console.error( 'patch: patchesAppliedIndication: failed to find insertion point for PATCH 5' @@ -246,10 +289,17 @@ const applyIndicatorPatchesListPatch = ( const findPatchesListLocation = ( fileContents: string ): LocationResult | null => { - // 1. Find the same regex as patch 2 - const pattern = + // 1. Find "Claude Code" bold text as anchor + // Try full version pattern first (pre-2.1.97), then fallback to simpler pattern + let pattern: RegExp = /[^$\w]([$\w]+)\.createElement\(([$\w]+),\{bold:!0\},"Claude Code"\)," ",([$\w]+)\.createElement\(([$\w]+),\{dimColor:!0\},"v",[$\w]+\)/; - const match = fileContents.match(pattern); + let match = fileContents.match(pattern); + if (!match) { + // CC 2.1.97+: version is memo-cached separately, use simpler anchor + pattern = + /[^$\w]([$\w]+)\.createElement\(([$\w]+),\{bold:!0\},"Claude Code"\)/; + match = fileContents.match(pattern); + } if (!match || match.index === undefined) { console.error( 'patch: patchesAppliedIndication: failed to find Claude Code version pattern for patch 3' diff --git a/src/patches/sessionMemory.ts b/src/patches/sessionMemory.ts index 5a3f82c7..f5f2411b 100644 --- a/src/patches/sessionMemory.ts +++ b/src/patches/sessionMemory.ts @@ -177,18 +177,25 @@ const patchUpdateThresholds = (file: string): string | null => { }; /** - * Combined patch - applies extraction, past sessions, token limits, and update thresholds + * Combined patch - applies extraction, past sessions, token limits, and update thresholds. + * Past sessions, token limits, and update thresholds are non-fatal (patterns may be absent + * in newer CC versions where the feature is restructured). */ export const writeSessionMemory = (oldFile: string): string | null => { let newFile = patchExtraction(oldFile); if (!newFile) return null; - newFile = patchPastSessions(newFile); - if (!newFile) return null; + // Past sessions patch is non-fatal — tengu_coral_fern may be absent or restructured + const pastResult = patchPastSessions(newFile); + if (pastResult) newFile = pastResult; - newFile = patchTokenLimits(newFile); - if (!newFile) return null; + // Token limits patch is non-fatal — constants may be renamed or restructured + const limitsResult = patchTokenLimits(newFile); + if (limitsResult) newFile = limitsResult; + + // Update thresholds patch is non-fatal — constants may be renamed or restructured + const threshResult = patchUpdateThresholds(newFile); + if (threshResult) newFile = threshResult; - newFile = patchUpdateThresholds(newFile); return newFile; }; diff --git a/src/patches/statuslineUpdateThrottle.ts b/src/patches/statuslineUpdateThrottle.ts index 058fb5e0..07202419 100644 --- a/src/patches/statuslineUpdateThrottle.ts +++ b/src/patches/statuslineUpdateThrottle.ts @@ -91,8 +91,8 @@ export const writeStatuslineUpdateThrottle = ( useFixedInterval: boolean = false ): string | null => { // Pattern breakdown: - // - (([$\w]+)=([$\w]+(?:\.default)?)\.useCallback.{0,1000}statusLineText.{0,200}?) - // Match[1]: Everything up to and including the statusLineText context (firstPart) + // - (([$\w]+)=([$\w]+(?:\.default)?)\.useCallback.{0,1000}statusLineText.{0,100}\},\[[$\w,]+\]\)) + // Match[1]: Everything up to and including the useCallback deps array (firstPart) // Match[2]: The status line update function name (statuslineUpdateFn) // Match[3]: The React variable, possibly with .default (reactVar) // @@ -100,8 +100,9 @@ export const writeStatuslineUpdateThrottle = ( // Match[4]: The old debounced invocation (to be replaced) // 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") + // CC 2.1.97 setTimeout extra-arg form: setTimeout((R,x)=>{R.current=void 0,x()},300,f,Z) const pattern = - /(,([$\w]+)=([$\w]+(?:\.default)?)\.useCallback.{0,1000}statusLineText.{0,200}?),([$\w]+)=([$\w.]+\(\(\)=>(\2\(([$\w]+)\)),300\)|[$\w]+\(\2,300\)|.{0,100}\{[$\w]+\.current=void 0,\2\(\)\},300\)\},\[\2\]\))/; + /(,([$\w]+)=([$\w]+(?:\.default)?)\.useCallback.{0,1000}statusLineText.{0,100}\},\[[$\w,]+\]\)),([$\w]+)=([$\w.]+\(\(\)=>(\2\(([$\w]+)\)),300\)|[$\w]+\(\2,300\)|.{0,100}\{[$\w]+\.current=void 0,\2\(\)\},300\)\},\[\2\]\)|.{0,100}setTimeout\(\(([$\w]+),([$\w]+)\)=>\{[$\w]+\.current=void 0,[$\w]+\(\)\},300,[$\w]+,\2\)\},\[\2\]\))/; const match = oldFile.match(pattern); diff --git a/src/patches/suppressRateLimitOptions.ts b/src/patches/suppressRateLimitOptions.ts index a14ed187..84e7f756 100644 --- a/src/patches/suppressRateLimitOptions.ts +++ b/src/patches/suppressRateLimitOptions.ts @@ -5,8 +5,9 @@ import { showDiff } from './index'; export const writeSuppressRateLimitOptions = ( oldFile: string ): string | null => { + // CC ≥2.1.97: shorter prefix to avoid match failure from wide .createElement prefix const pattern = - /\.createElement.{0,500},showAllInTranscript:[$\w]+,agentDefinitions:[$\w]+,onOpenRateLimitOptions:([$\w]+)/; + /showAllInTranscript:[$\w]+,agentDefinitions:[$\w]+,onOpenRateLimitOptions:([$\w]+)/; const match = oldFile.match(pattern); diff --git a/src/patches/themes.ts b/src/patches/themes.ts index f357c40c..d8849cd7 100644 --- a/src/patches/themes.ts +++ b/src/patches/themes.ts @@ -20,8 +20,10 @@ function getThemesLocation(oldFile: string): { const objArrPat = /\[(?:\.\.\.\[\],)?(?:\{label:"(?:Dark|Light|Auto)[^"]*",value:"[^"]+"\},?)+\]/; + // CC ≥2.1.97: assignment form XZY={auto:"Auto...",dark:"Dark...",...} + // CC <2.1.97: return form return{auto:"Auto...",dark:"Dark...",...} const objPat = - /return\{(?:(?:[$\w]+|"[^"]+"):"(?:Auto|Dark|Light)[^"]*",?)+\}/; + /(?:return|[$\w]+\s*=)\{(?:(?:[$\w]+|"[^"]+"):"(?:[Aa]uto|[Dd]ark|[Ll]ight)[^"]*",?)+\}/; const objArrMatch = oldFile.match(objArrPat); const objMatch = oldFile.match(objPat); @@ -70,8 +72,16 @@ export const writeThemes = ( // Process in reverse order to avoid index shifting // Update theme mapping object (obj) + // Detect whether original used "return{...}" or "VAR={...}" form + const origObjText = oldFile.slice( + locations.obj.startIndex, + locations.obj.endIndex + ); + const objPrefix = origObjText.startsWith('return') + ? 'return' + : origObjText.slice(0, origObjText.indexOf('{')); const obj = - 'return' + + objPrefix + JSON.stringify( Object.fromEntries(themes.map(theme => [theme.id, theme.name])) ); diff --git a/src/patches/thinkerFormat.ts b/src/patches/thinkerFormat.ts index aed6bd45..ce92cab9 100644 --- a/src/patches/thinkerFormat.ts +++ b/src/patches/thinkerFormat.ts @@ -39,6 +39,28 @@ const getThinkerFormatLocation = (oldFile: string): LocationResult | null => { }; } + // CC ≥2.1.97 template literal form: =`${EXPR}… ` + const formatPatternTemplate = + /,([$\w]+)(=(`\$\{([$\w]+&&![$\w]+\.isIdle\?[$\w]+\.spinnerVerb\?\?[$\w]+:[$\w]+)\}(?:…|\\u2026) `))/; + const formatMatchTemplate = searchSection.match(formatPatternTemplate); + + if (formatMatchTemplate && formatMatchTemplate.index != undefined) { + return { + startIndex: + approxAreaMatch.index + + formatMatchTemplate.index + + formatMatchTemplate[1].length + + 1, + endIndex: + approxAreaMatch.index + + formatMatchTemplate.index + + formatMatchTemplate[1].length + + formatMatchTemplate[2].length + + 1, + identifiers: [formatMatchTemplate[4]], + }; + } + // Fallback pattern: =($a&&!$b.isIdle?$c.spinnerVerb??$d:$e)+"…" const formatPatternNew = /,([$\w]+)(=(\([$\w]+&&![$\w]+\.isIdle\?[$\w]+\.spinnerVerb\?\?[$\w]+:[$\w]+\))\+"(?:…|\\u2026)")/; diff --git a/src/patches/tokenCountRounding.ts b/src/patches/tokenCountRounding.ts index e6391248..d6268efa 100644 --- a/src/patches/tokenCountRounding.ts +++ b/src/patches/tokenCountRounding.ts @@ -33,30 +33,41 @@ export const writeTokenCountRounding = ( let post: string; let startIndex: number; - // Try newer version pattern first - // Pattern: overrideMessage:..., VAR=FUNC(EXPR),...key:"tokens"..., VAR," tokens" - const m1 = oldFile.match( - /(overrideMessage:.{0,10000},([$\w]+)=[$\w]+\()(.+?)(\),.{0,1000}key:"tokens".{0,200},\2," tokens")/ + // CC ≥2.1.97: token count computed directly as H6=W&&!W.isIdle?W.progress?.tokenCount??0:O6+D + // Look for the tokenCount assignment near " tokens" (template literal or string) + const m0 = oldFile.match( + /(,([$\w]+)=)([$\w]+&&![$\w]+\.isIdle\?[$\w]+\.progress\?\.tokenCount\?\?0:[$\w]+\+[$\w]+)(,.{0,200} tokens)/ ); - if (m1 && m1.index !== undefined) { - [fullMatch, pre, , partToWrap, post] = m1; - startIndex = m1.index; + if (m0 && m0.index !== undefined) { + [fullMatch, pre, , partToWrap, post] = m0; + startIndex = m0.index; } else { - // Try older version pattern - // Pattern: overrideMessage:...,key:"tokens"...FUNC(Math.round(...)) - const m2 = oldFile.match( - /(overrideMessage:.{0,10000},key:"tokens".{0,200}[$\w]+\()(Math\.round\(.+?\))(\))/ + // Try newer version pattern + // Pattern: overrideMessage:..., VAR=FUNC(EXPR),...key:"tokens"..., VAR," tokens" + const m1 = oldFile.match( + /(overrideMessage:.{0,10000},([$\w]+)=[$\w]+\()(.+?)(\),.{0,1000}key:"tokens".{0,200},\2," tokens")/ ); - if (m2 && m2.index !== undefined) { - [fullMatch, pre, partToWrap, post] = m2; - startIndex = m2.index; + if (m1 && m1.index !== undefined) { + [fullMatch, pre, , partToWrap, post] = m1; + startIndex = m1.index; } else { - console.error( - 'patch: tokenCountRounding: cannot find token count pattern in either newer or older CC format' + // Try older version pattern + // Pattern: overrideMessage:...,key:"tokens"...FUNC(Math.round(...)) + const m2 = oldFile.match( + /(overrideMessage:.{0,10000},key:"tokens".{0,200}[$\w]+\()(Math\.round\(.+?\))(\))/ ); - return null; + + if (m2 && m2.index !== undefined) { + [fullMatch, pre, partToWrap, post] = m2; + startIndex = m2.index; + } else { + console.error( + 'patch: tokenCountRounding: cannot find token count pattern in either newer or older CC format' + ); + return null; + } } } diff --git a/src/patches/userMessageDisplay.ts b/src/patches/userMessageDisplay.ts index 7b748bd1..aa8ff2b9 100644 --- a/src/patches/userMessageDisplay.ts +++ b/src/patches/userMessageDisplay.ts @@ -144,21 +144,33 @@ export const writeUserMessageDisplay = ( // See the older examples above. We explictly look for and match the component and subcomponent // that renders the ">" in older versions so that we can silently drop it in the replacement, // removing it in versions where it's present and not failing on versions where it's not. - const pattern = - /(No content found in user prompt message.{0,150}?\b)([$\w]+(?:\.default)?\.createElement.{0,30}\b[$\w]+(?:\.default)?\.createElement.{0,40}">.+?)?(([$\w]+(?:\.default)?\.createElement).{0,100})(\([$\w]+,(?:\{[^{}]+wrap:"wrap"\},([$\w]+)(?:\.trim\(\))?\)\)|\{text:([$\w]+)(?:,thinkingMetadata:[$\w]+)?\}\)\)?))/; + // Pattern for CC ≤2.1.21: uses createElement nesting with wrap/text props + const patternOld = + /(No content found in user prompt message.{0,150}?\b)([$\w]+(?:\.default)?\.createElement.{0,30}\b[$\w]+(?:\.default)?\.createElement.{0,40}">.+?)?(([$\w]+(?:\.default)?\.createElement).{0,300})(\([$\w]+,(?:\{[^{}]+wrap:"wrap"\},([$\w]+)(?:\.trim\(\))?\)\)|\{text:([$\w]+)(?:,thinkingMetadata:[$\w]+)?\}\)\)?|\{text:([$\w]+)[^}]*\}\)\)?))/; - const match = oldFile.match(pattern); + // Pattern for CC 2.1.97+: simplified return with template literal + // return CE(u,null,CE(T,null,Y8(` > ${w} `))) + const pattern297 = + /(No content found in user prompt message.{0,150}?return )([$\w]+(?:\.default)?\.createElement)\([$\w]+,null,\2\([$\w]+,null,[$\w]+\(` > \$\{([$\w]+)\} `\)\)\)/; - if (!match || match.index === undefined) { - console.error( - 'patch: userMessageDisplay: failed to find user message display pattern' - ); - return null; - } + let match = oldFile.match(patternOld); + let createElementFn: string; + let messageVar: string; - const createElementFn = match[4]; - // Either match[6] or match[7] will be present (never both) - const messageVar = match[6] ?? match[7]; + if (match && match.index !== undefined) { + createElementFn = match[4]; + messageVar = match[6] ?? match[7] ?? match[8]; + } else { + match = oldFile.match(pattern297); + if (!match || match.index === undefined) { + console.error( + 'patch: userMessageDisplay: failed to find user message display pattern' + ); + return null; + } + createElementFn = match[2]; + messageVar = match[3]; + } // Build box attributes (border and padding) const boxAttrs: string[] = []; diff --git a/src/patches/voiceMode.ts b/src/patches/voiceMode.ts index ed6ee866..085ee9ec 100644 --- a/src/patches/voiceMode.ts +++ b/src/patches/voiceMode.ts @@ -28,16 +28,35 @@ import { showDiff } from './index'; const patchAmberQuartz = (file: string): string | null => { - const pattern = /function [$\w]+\(\)\{return [$\w]+\("tengu_amber_quartz"/; + // CC ≥2.1.97: flag renamed to tengu_amber_quartz_disabled with inverted logic + // Pattern: function Z36(){return!R8("tengu_amber_quartz_disabled",!1)} + const newPattern = + /function ([$\w]+)\(\)\{return![$\w]+\("tengu_amber_quartz_disabled"/; + const newMatch = file.match(newPattern); + + if (newMatch && newMatch.index !== undefined) { + // Inverted logic: return!flagCheck means "return true if NOT disabled" + // We want to always return true, so insert return !0; at start of function body + const insertIndex = newMatch.index + newMatch[0].indexOf('{') + 1; + const insertion = 'return !0;'; + + const newFile = + file.slice(0, insertIndex) + insertion + file.slice(insertIndex); + + showDiff(file, newFile, insertion, insertIndex, insertIndex); + return newFile; + } - const match = file.match(pattern); + // Fall back to old pattern: function qX_(){return A9("tengu_amber_quartz",!1)} + const oldPattern = /function [$\w]+\(\)\{return [$\w]+\("tengu_amber_quartz"/; + const oldMatch = file.match(oldPattern); - if (!match || match.index === undefined) { + if (!oldMatch || oldMatch.index === undefined) { console.error('patch: voiceMode: failed to find tengu_amber_quartz gate'); return null; } - const insertIndex = match.index + match[0].indexOf('{') + 1; + const insertIndex = oldMatch.index + oldMatch[0].indexOf('{') + 1; const insertion = 'return !0;'; const newFile = @@ -82,8 +101,9 @@ export const writeVoiceMode = ( if (!newFile) return null; if (enableConciseOutput) { - newFile = patchConciseOutput(newFile); - if (!newFile) return null; + // sotto_voce was removed in CC ≥2.1.97 — make non-fatal + const result = patchConciseOutput(newFile); + if (result) newFile = result; } return newFile; diff --git a/src/types.ts b/src/types.ts index 0aa23747..c924989b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -137,6 +137,8 @@ export interface MiscConfig { enableVoiceMode: boolean; enableVoiceConciseOutput: boolean; enableChannelsMode: boolean; + disableGarnetLoom: boolean; + enableMaxAgentTurnsOverride: boolean; } export interface InputPatternHighlighter { diff --git a/src/ui/components/MiscView.tsx b/src/ui/components/MiscView.tsx index e0d9e3bf..6edfe285 100644 --- a/src/ui/components/MiscView.tsx +++ b/src/ui/components/MiscView.tsx @@ -85,6 +85,8 @@ export function MiscView({ onSubmit }: MiscViewProps) { enableVoiceMode: false, enableVoiceConciseOutput: true, enableChannelsMode: false, + disableGarnetLoom: false, + enableMaxAgentTurnsOverride: false, }; const ensureMisc = () => { @@ -448,6 +450,34 @@ export function MiscView({ onSubmit }: MiscViewProps) { }); }, }, + { + id: 'disableGarnetLoom', + title: 'Disable garnet loom (prevent Opus→Sonnet downgrade)', + description: + 'Prevents server-side auto-downgrade of Opus subagents to Sonnet when context is under 200K tokens.', + getValue: () => settings.misc?.disableGarnetLoom ?? false, + toggle: () => { + updateSettings(settings => { + ensureMisc(); + settings.misc!.disableGarnetLoom = + !settings.misc!.disableGarnetLoom; + }); + }, + }, + { + id: 'enableMaxAgentTurnsOverride', + title: 'Max agent turns override (env var)', + description: + 'Makes subagent maxTurns configurable via CLAUDE_CODE_MAX_AGENT_TURNS env var. Default: 20, max: 100.', + getValue: () => settings.misc?.enableMaxAgentTurnsOverride ?? false, + toggle: () => { + updateSettings(settings => { + ensureMisc(); + settings.misc!.enableMaxAgentTurnsOverride = + !settings.misc!.enableMaxAgentTurnsOverride; + }); + }, + }, { id: 'enableContextLimitOverride', title: 'Override context limit',