This file is for maintainers, not end users.
Its job is to explain:
- what the native patching pipeline actually does
- how
patch-claude-display.tsis structured - what each patch is trying to change semantically
- what old upstream bundle shapes we currently depend on
- how to debug and repair a patch when a Claude update breaks it
This repo does not rebuild Claude Code from source. It patches the embedded JS bundle inside Anthropic's native binary.
The current flow is:
- Download or locate a native Claude binary.
Use
bash scripts/download-native-from-installer.shwhen you want the exact upstream native download path used by CI. - Use
tweakcc.readContent()to extract the embedded JS bundle. - Write that bundle to a temporary
content.jsfile. - Run
patch-claude-display.tsagainst that extracted JS. - Use
tweakcc.writeContent()to write the patched JS back into the binary. - Re-sign on macOS.
- Publish the patched binary.
The important consequence: almost all real behavior lives in patch-claude-display.ts. If a release regresses, that file is usually where the fix belongs.
patch-claude-display.ts: ordered patch pipeline for extracted bundle textscripts/download-native-from-installer.sh: exact upstream binary download flow via Anthropic's installer bucketscripts/patch-native-with-tweakcc.ts: native binary read/write flow viatweakcc.github/workflows/patch-claude.yml: CI download, patch, sign, release pathinstall-patched-claude.sh: installer that resolves release tags and downloads patched assetsinstall-patched-claude.ps1: Windows installer that resolves release tags and downloads patched assets
patch-claude-display.ts is a string-rewrite pipeline.
- Every patch function takes bundle text and returns
{ content, candidates, patched }. PATCH_MODULESdefines the patch order.- Patches run sequentially, so later patches see earlier rewrites.
- The patcher prints a per-module summary but does not fail if nothing changed.
main()writes the file only when the final content differs from the original.
That last point matters: No changes needed. is a successful exit, not a failure.
These rules are not style preferences. They are what keeps the patcher alive across upstream rebuilds.
- Never anchor on minified locals like
A_,mET,w,wg6, or similar. - Prefer stable string literals, switch case labels, prop names, control-flow shape, or unique neighboring tokens.
- If you have to match a function body, match the semantic shape of that body, not its symbol names.
- Only widen a matcher as much as needed to survive bundle churn.
- When a patch has multiple known upstream shapes, keep them as separate targeted branches instead of one giant regex.
scripts/patch-native-with-tweakcc.ts currently works like this:
- resolves
--inputand--output - copies the input binary to the output path when patching out-of-place
- loads
tweakcc - extracts embedded JS with
readContent() - if
tweakccfails on an ELF binary, falls back toscripts/vendored-elf-native.ts - writes that JS to a temp
content.js - invokes
node patch-claude-display.ts --file <temp-content.js> - reads the patched temp file back
- writes it into the output binary with
writeContent() - if
tweakccfails to repack an ELF binary, falls back toscripts/vendored-elf-native.ts
Important behavior:
- if
patch-claude-display.tsprints nonzero patch counts, the binary written bywriteContent()is patched - if
patch-claude-display.tsmakes no changes, the script still succeeds and the output binary can remain equivalent to upstream
Linux note:
- Claude native Linux builds changed format around 2.1.83 from the older Bun-at-EOF overlay layout to an ELF
.bunsection layout. tweakcc4.0.11 only handles the older ELF overlay path.scripts/vendored-elf-native.tsexists specifically to keep latest Linux binaries patchable without waiting on upstreamtweakcc.- For section-backed ELF binaries,
.bunsits right before the ELF section-header table. Growing.buncontent must movee_shoffforward and grow the containingLOADsegment; updating the section bytes alone overwrites section headers, detaches.bunfrom the segment table, and can produce runtime crashes on Linux x64. - Some Linux builds also keep non-allocated metadata sections such as
.comment,.note.stapsdt,.symtab,.strtab, and.shstrtabafter.bun. The vendored ELF writer may shift those payloads and update their section-header offsets when.bungrows. It should still refuse to shift later allocated sections, because that can change runtime mapping semantics.
Windows note:
- Windows native builds are PE binaries with a
.bunsection. tweakcccurrently has a PE read/write path, so Windows support should go through the samescripts/patch-native-with-tweakcc.tsflow first.- There is no vendored PE fallback yet. If Windows patching starts failing after an upstream format change, add a PE-specific fallback instead of changing the JS patcher.
- CI can execute Windows x64 builds on
windows-latestand Windows arm64 builds onwindows-11-arm.
Use this section when a future Claude update breaks something. For each patch, the key questions are:
- what user-visible behavior were we changing?
- what old bundle shape did we match?
- what likely changed upstream?
Intent:
- force collapsed read/search tool rows to render in verbose mode
Old bundle shape we match:
- a switch arm with
case"collapsed_read_search" - one build shape returns directly from the case
- another build shape uses a block form
case"collapsed_read_search":{ ... } - both forms contain a React renderer call with a
verbose:prop - older builds use
createElement(...); 2.1.186-style builds use JSX-runtime calls like.jsx(...)or.jsxs(...)
What we rewrite:
- replace the existing
verbose:<expr>withverbose:!0
Why this exists:
- some builds collapse read/search detail even when we want the default UI to expose tool-call data
Likely break signs:
- patch count drops to
0 - read/search tool blocks render in compact mode again
- upstream renamed the case label or changed the props carried by that renderer
Intent:
- render file creation output through the diff renderer so added lines keep
+styling and color
Old bundle shape we match:
- one switch arm for
case"create": - a nearby switch arm for
case"update": - the
createarm returns a simple write renderer with{filePath,content,verbose} - the
updatearm renders a richer diff component usingstructuredPatch - 2.1.186-style builds can use JSX-runtime calls like
.jsx(...)and.jsxs(...)instead ofcreateElement(...)
What we rewrite:
- replace the
createreturn path with a synthetic diff payload - build a one-sided
structuredPatchwhere every line is prefixed with+ - reuse the update renderer's
styleand component
Why this exists:
- plain "Wrote N lines" rendering throws away the visual diff treatment for newly created files
Likely break signs:
- created files lose green diff presentation
- patch count drops to
0 createandupdateare no longer in the same switch or the update renderer no longer exposesstructuredPatch
Intent:
- preserve muted add/remove row backgrounds during word-diff rendering
Old bundle shape we match:
- function body anchored near
"diffAddedWord";else if(! - child parts render with
backgroundColor:<expr> - the function also knows the diff
typeand a dimming flag parameter - older builds put
key:beforebackgroundColor:in acreateElement(...)props object - 2.1.186-style builds put the key after the props object in a JSX-runtime call, so match by the
backgroundColor:andpart-${...}-${...}shape instead of assuming prop order
What we rewrite:
- change child
backgroundColorto use a nullish fallback - if upstream did not provide a per-word color, fall back to line-level add/remove dimmed colors
Why this exists:
- word-diff spans could visually erase the line background, making additions/removals harder to read
Likely break signs:
- word-diff mode loses the surrounding row tint
- anchor string still exists but the child style shape changed
Intent:
- always render thinking blocks inline instead of hiding them behind transcript-only gates
Old bundle shape we match:
- switch arm
case"thinking": - an early return like
if(!... )return null; - renderer props containing
isTranscriptMode: - older builds also carry
hideInTranscript:, but newer builds can omit it - renderer calls can be either
createElement(...)or JSX-runtime.jsx(...)/.jsxs(...)
What we rewrite:
- remove the early null-return gate
- force
isTranscriptMode:!0 - force
hideInTranscript:!1when that prop exists
Why this exists:
- upstream often treats thinking content as transcript-only or conditionally hidden when we want it visible in the live UI
Likely break signs:
- thinking blocks disappear from the main message flow
- only final output appears while reasoning remains hidden
Intent:
- render redacted thinking summaries inline using the same renderer as normal thinking blocks
Old bundle shapes we match:
- adjacent switch arms for
case"redacted_thinking":andcase"thinking": - the redacted arm returns a placeholder/summary component
- the thinking arm renders a component with
addMargin:,param:,isTranscriptMode:, andverbose: - older builds use
createElement(...)and carryhideInTranscript: - 2.1.186-style builds use
.jsx(...)and can omithideInTranscript:
What we rewrite:
- replace the redacted arm with a thinking-renderer call
- synthesize a
{type:"thinking",thinking:<redacted data>}param - force transcript/verbose visibility, and force
hideInTranscript:!1only when upstream still has that prop
Why this exists:
- redacted thinking summaries otherwise stay hidden or render as a generic placeholder
Likely break signs:
- redacted summaries disappear while normal thinking still appears
redacted-thinking-inlinecandidate count drops to0- the redacted and thinking switch arms are no longer adjacent
Intent:
- repair live streaming thinking so it updates during generation and clears correctly between turns
This patch is intentionally broad because upstream has broken this in several different places.
Sub-fixes currently bundled here:
- memo cache fix: comparator cache should key on
thinking?.thinking, not just the outer object - prop threading fix: add missing
streamingThinking:prop to the main renderer when the surrounding prop bag clearly represents the conversation view - memo removal: disable one memo wrapper around the message-row renderer when its comparator shape references screen/columns/lastThinkingBlockId/streamingToolUseIDs and suppresses updates
- linger fix: replace the "remain visible for 30 seconds after stream end" path with
isStreamingonly - inline extras fix: materialize
streamingThinking.messagesin the transcript extras list, ordered alongside streaming tool-use blocks by content-block index - bottom-row suppressor: remove the separate live-thinking row that sits outside the main message flow so streaming thinking only renders inline once
- reducer/event fix: update the stream event handler so
stream_request_start,thinking,thinking_delta,text,message_delta, andmessage_stopkeep per-block streaming thinking state in sync without relying on footer-row rendering - duplicate-index fix: keep only one virtual streaming-thinking message per content-block index so repeated block-start handling cannot create two live blocks that receive the same later deltas
Old bundle shapes we match:
- memoized renderer logic near
hidePastThinking:!0,streamingThinking:<var> - a comparator function body checking
.screen!==,.columns!==,.lastThinkingBlockId,.streamingToolUseIDs - event handling logic near
type!=="stream_event"&&,type==="stream_request_start", andcase"thinking_delta" - older reducers called a helper inside
case"thinking_delta":<helper>(event.delta.thinking);return; - 2.1.116-style reducers can also use a bare
case"thinking_delta":return;, which means the live thinking state patch must no longer rely on that helper call existing - 2.1.138-style UI reducers can be
function <name>(event, opts){let{onSetStreamMode, onStreamingToolUses, onStreamingThinking, ...}=opts;...}and should be patched by those option names, not by a nearbytype!=="stream_event"filter - 2.1.144-style UI reducers can keep
thinking_deltainsidecase"content_block_delta":switch(event.delta.type){...}with a non-empty progress body likelet{delta}=event.event;if("estimated_tokens"in delta)...;return, so the live thinking patch must inject state updates ahead of that existing progress handler instead of assuming an emptythinking_deltacase - 2.1.163-style UI reducers can keep
thinking_deltain the samecase"content_block_delta"switch but expand the body to emit both estimated-token progress and text-length progress fromevent.delta.thinking, so the live thinking patch must preserve both branches while still appending the streamed text intostreamingThinking - current main-screen renderer shapes can carry
placeholderElement:andstreamingText:but omitshowThinkingHint:, so the prop-threading matcher must not depend on that prop being present before injectingstreamingThinking: - 2.1.168-style transcript renderers can drop
hidePastThinking:andstreamingThinking:from the renderer signature entirely while the top-level app still has anonStreamingThinking:<setter>callback backed by nearbyuseState(null). In that shape, rediscover the state variable from the setter and re-injectstreamingThinking:into both the transcript renderer call sites and the transcript renderer destructuring signature. - 2.1.168-style UI reducers can run
displayTransform?.finalize()inside themessage_stopbranch before switching to"tool-use", and can use a block-formcase"message_delta":{...}. The reducer patch must preserve those existing side effects while adding streaming-thinking cleanup before the stream transitions to normal response state. - 2.1.183-style UI reducers can keep
onStreamingThinking:<setter>on the outer event dispatcher while moving the stream-event switch into a separate inner handler that destructuresonSetStreamMode,onStreamingToolUses,onStreamingText, anddisplayTransform, but notonStreamingThinking. In that shape, injectonStreamingThinkinginto the inner handler destructuring, then patchstream_request_start, thinking/redacted-thinking block start,thinking_delta, text/message transitions, andmessage_stopthere. - 2.1.199-style live thinking can still use the same
onStreamingThinkingstate but may surface duplicate virtual entries if a thinking content-block start is handled more than once for the same index. TreatstreamingThinking.messagesas keyed by content-block index, not append-only. - the duplicate live-thinking suppressor should match the semantic row shape around
param:{type:"thinking",thinking:<var>.thinking}and the surroundingmarginTop:1wrapper, not a specific wrapper component identifier
Why this exists:
- upstream breakage here has shown up as stale thinking, no live thinking, delayed thinking, or thinking that only appears after completion
Likely break signs:
- thinking only appears after the assistant finishes
- previous turn's thinking leaks into the next turn
- live thinking vanishes in brief mode
- live streaming shows two thinking blocks at once
- live thinking pins itself to the bottom of the transcript instead of staying above the later streamed text/tool blocks
- patch count drops partially rather than fully; this often means only one of the sub-fixes drifted
- patch count still looks nonzero but live thinking is broken; check whether the reducer/event fix actually touched the stream-event handler, not just renderer prop threading or final assistant-message summary paths
Intent:
- show subagent
Promptblocks even outside transcript mode
Old bundle shape we match:
- renderer neighborhood anchored by
"Backgrounded agent" - same function also contains transcript toggle metadata like
action:"app:toggleTranscript"andfallback:"ctrl+o" - live prompt mount path shaped like
<transcriptModeVar> && <promptVar> && createElement(...) - empty-state guard shaped like
if(rows.length===0 && !(transcriptMode && prompt)) return
What we rewrite:
- remove the transcript-mode dependency from the prompt gate
- keep the prompt block mounted whenever prompt text exists
- treat prompt presence as content so the section does not early-return empty
Why this exists:
- upstream hides the prompt block unless transcript mode is active, which hides useful subagent context during normal use
Likely break signs:
- subagent cards show status but no prompt content
- prompt appears only after toggling transcript mode
Intent:
- disable spinner tips regardless of user settings
Old bundle shape we match:
- a guard like
if(settings().spinnerTipsEnabled===!1)return; - a separate boolean expression like
spinnerTipsEnabled!==!1
What we rewrite:
- force the guard to
if(!0)return; - force the enablement expression to
!1
Why this exists:
- spinner tips are noise in the patched UX, and upstream has had multiple paths that can re-enable them
Likely break signs:
- tips start showing again during idle/loading states
- only one candidate is found instead of two, meaning one code path moved
Intent:
- append a visible patched marker to plain
claude --versionoutput
Old bundle shape we match:
- a literal tail shaped like
}.VERSION} (Claude Code)`);return} - newer builds append build-ref data with
${HE()}and also carry the same version string in Commander option metadata
What we rewrite:
- inject
\n(patched)immediately after(Claude Code)
Why this exists:
- this is the easiest runtime verification that the installed binary is actually patched
Likely break signs:
claude --versionloses the(patched)line- CI still succeeds, so this must be checked deliberately
Intent:
- replace the npm/native migration warning text with a short patched marker
Old bundle shape we match:
- string payload containing
switched from npm to native installer - 2.1.186 no longer contains this string;
0candidates is expected there unless this patch is retargeted to a newer installation warning
What we rewrite:
- replace the entire quoted string payload with
(patched)
Why this exists:
- the upstream migration message is not useful in this patched distribution and consumes valuable space
Likely break signs:
- upstream rewrites the migration copy and the needle vanishes
- patch count drops to
0 - on 2.1.186, first confirm whether the old migration warning still exists before treating this as a regression
Intent:
- rename visible startup/help branding from
Claude CodetoConnoisseur's Code
Old bundle shapes we match:
- bold text node rendering
"Claude Code" - JSX text props shaped like
{bold:!0,children:"Claude Code"} - help/settings title template like
title:(`Claude Code v${...VERSION}`),color:"professionalBlue",defaultTab:"general" - welcome copy
"Welcome to Claude Code for " - welcome copy
"Welcome to Claude Code" - styled title helpers shaped like
<colorFn>("claude",<themeVar>)("Claude Code") - same helper with padded text
(" Claude Code ")
What we rewrite:
- replace those visible strings with
Connoisseur's Code
Why this exists:
- branding is the visible cue that the patched build is installed
Likely break signs:
- some screens show patched branding while others revert to upstream naming
- candidate counts change unevenly because only some string shapes moved
When a Claude update breaks a patch, do this in order.
- Patch extracted JS in dry-run mode first.
node patch-claude-display.ts --file ./content.js --dry-run-
Note which module dropped from its usual nonzero count to
0, or which module now has fewer hits than expected. -
Search the extracted bundle for the old semantic anchors, not the old minified names.
Examples:
rg 'case"collapsed_read_search"|case"thinking"|case"thinking_delta"|spinnerTipsEnabled|Backgrounded agent|Claude Code' content.js-
If the old anchor is gone, search for the user-visible string or prop names that still describe the same feature.
-
Update the matcher conservatively. Prefer adding a second shape branch over weakening the original regex until it matches too much.
-
Re-run dry-run and inspect both
candidatesandpatchedcounts. -
Patch a real native binary and verify behavior at runtime.
Minimum validation for patch work:
- run dry-run patching on extracted content
- patch a real native binary
- run the patched binary with
--versionand verify(patched)appears - manually inspect the UI areas touched by the patch
- on macOS, verify the final binary after re-signing
Useful commands:
node scripts/patch-native-with-tweakcc.ts --input ./claude --output ./claude.patched
./claude.patched --version
codesign --verify --verbose=2 ./claude.patchedIf you only changed the JS matcher and want fast feedback:
node patch-claude-display.ts --file ./content.js --list-patches
node patch-claude-display.ts --file ./content.js --dry-runCurrent CI behavior is not a proof that patching happened.
- the workflow uploads
work/${OUT_BASE}.patched - that file path is created by copying the original binary first
- if the patcher makes no changes, the job can still succeed
- runtime
--versionoutput is printed, but the workflow does not currently assert on(patched)
So when investigating release correctness, treat these as strong signals, in order:
- nonzero patch counts for the expected modules
- runtime
--versionoutput including(patched) - different checksums between original and patched binaries
- Keep this file updated when a patch's semantic target changes.
- If you add a new patch, document the old bundle shape and the user-visible intent here immediately.
- If you split a patch into multiple sub-fixes, say so here; future debugging depends on knowing which symptom each sub-fix addressed.
- Do not turn this back into a user-facing release guide. It exists to preserve maintainer context that is otherwise trapped inside minified bundle archaeology.