| name | binary-patcher-migration |
| description | Migrate the binary patcher to a new Claude Code version. Re-derive bytes for changed patches, investigate missing anchors, mark obsoleted patches, verify empirically in fresh sessions, and open a PR. Use when Claude Code releases a new version (announced or stealth-on-CDN) and the patcher needs to follow. |
| argument-hint | <version> (e.g., '2.1.131') |
| disable-model-invocation | true |
| user-invocable | true |
| effort | max |
| allowed-tools | ["Bash","Read","Edit","Glob","Grep","Agent","WebFetch"] |
Binary Patcher Migration
Operational manual for migrating patches when Claude Code releases a new version.
Minified JS identifiers in the binary drift per release, breaking patches whose
byte sequences encode specific identifiers. This skill walks through the
six-phase migration: acquire → status → per-patch handling → apply + verify →
document → PR.
Target version: $ARGUMENTS
Environment
Active claude: !`readlink ~/.local/bin/claude | xargs basename 2>/dev/null`
Installed: !`ls ~/.local/share/claude/versions/ 2>/dev/null | grep -E '^[0-9]' | sort -V | tr '\n' ' '`
Latest on CDN: !`claude-version-manager list --remote 2>/dev/null | head -8 | tail -5`
Current branch: !`git -C ~/claude-workspace branch --show-current 2>/dev/null`
Working tree: !`git -C ~/claude-workspace status --short 2>/dev/null | head -10`
Latest commit: !`git -C ~/claude-workspace log --oneline -1 main 2>/dev/null`
Source-of-truth files
- Patches:
cc-lib/cc_lib/claude_binary_patching.py — PATCHES sequence and module docstring (alphabetical Patches section, Anchor Presence Survey, Site Count Evolution table, Version Log).
- CLI:
scripts/claude-binary-patcher.py — apply / check / restore.
- Source mirror:
~/claude-code-best/ — TypeScript reference for what minified IDs map to. Structural guide; binary is ground truth.
- Pristine originals:
~/.claude-workspace/binary-patcher/originals/<version> — vanilla binaries kept for restore + comparison.
Phase 0: Acquire + branch
claude-version-manager fetch $ARGUMENTS --activate
gh api repos/anthropics/claude-code/contents/CHANGELOG.md --jq '.content' | base64 -d | head -60
gh release view v$ARGUMENTS --repo anthropics/claude-code --json tagName 2>&1 | head -3
cd ~/claude-workspace
git checkout main && git pull --ff-only
git checkout -b feat/binary-patches-$ARGUMENTS
Stealth releases: proceed with extra caution (Anthropic may pull the build — 2.1.127 was apparently a failed kill build). If user prefers stability, pin to the latest tagged version instead.
Read the changelog with patch obsoletion in mind. Watch for entries that match the bug a current patch was created to fix — Anthropic shipping the upstream fix means the patch may now be redundant. Concrete examples:
- 2.1.128: "rejection rendering changed to silent" → made
reject-show-comment a no-op
- 2.1.136: "Fixed MCP tool results being invisible when the server returns content blocks" → made
mcp-array-content-to-string redundant
When a changelog line lights up a patch like this, flag it for the Phase 3 vanilla-vs-patched comparison. Don't pre-decide — verify empirically — but the changelog signal saves you from "why is this patch silently doing nothing useful" debugging later.
Worktree workflow. If you work in a git worktree instead of a regular branch, the installed claude-binary-patcher launcher hardcodes the main tree's cc-lib path and won't pick up your worktree edits. Run the script directly from the worktree instead:
uv run --no-project --script ~/claude-workspace/.claude/worktrees/<wt>/scripts/claude-binary-patcher.py check --all
Phase 1: Status check
claude-binary-patcher check --all
Each patch reports one of:
| Status | Meaning | Next |
|---|
applied | Anchor + new bytes match | Skip |
unpatched (N sites) | Anchor + old bytes match | Apply (no re-derivation) |
changed (anchor found, code different) | Anchor still matches but bytes drifted | Re-derive (Phase 2a) |
missing (anchor not found) | Anchor itself is gone | Investigate (Phase 2b) |
skipped (out of range, max_version=X) | Patch's [min_version, max_version] excludes the active CC version | Skip — no action needed |
Obsolete patches (those with max_version set to a prior release) automatically report as skipped, and apply --all filters them out of per-patch output entirely — they appear only as a single end-of-output summary line. No manual cross-referencing required.
Categorize all patches before doing any work.
Phase 2a: Re-derive changed patches
Step 1: Get context around the anchor
PATCHED=~/.local/share/claude/versions/$ARGUMENTS
perl -e '
my $data = do { local $/; open(my $fh, "<:raw", $ARGV[0]) or die; <$fh> };
while ($data =~ /(.{120}ANCHOR_REGEX.{200})/sg) { print "$&\n\n"; last }
' "$PATCHED"
Step 2: Identify the renames
Compare the printed bytes to the patch's existing old field. Identifier categories that drift each release:
| Category | Examples (2.1.126 → 2.1.128 → 2.1.131) | Stability |
|---|
| Statsig accessor | G_ → Z_ → G_ | Oscillates — back-and-forth between releases |
| Gate function | at() → Me() → ve() | Renamed every release |
| Constant identifier | e76 → EK6 (different prefix kinds in 2.1.131) | Renamed every release |
| Helper chain | Q36(Yj_(T)) → $D_(TQH(T)) → RD_(WcH(T)) | Renamed every release |
| React module | _8 → q8 → O8 | Renamed every release |
| JSX components | IL5/M6/FY_/... → JN5/X6/Gw_/... → hv5/G6/bw_/... | All renamed every release |
| String literals | "contentArray", "tengu_scratch", prop names | Stable — use as anchors |
Don't assume drift is monotonic — a name reverted in 2.1.131 may revert again. The bytes are version-specific; the strategy is forever.
Step 3: Construct same-length new bytes
The patcher requires len(old) == len(new). Verify before editing:
echo -n "Old: "; printf 'function ve(){return G_("tengu_scratch",!1)}' | wc -c
echo -n "New: "; printf 'function ve(){return!0/*scratchpad always*/}' | wc -c
When lengths differ, pad to match:
- Whitespace inside string literals:
wrap:"truncate" → wrap:"wrap" (4 trailing spaces)
- Comment block extension:
if(!G_("tengu_coral_fern",!1))return[] → if(0/*coral_fern_gate_check*/)return[]
- Drop redundant fields + trailing spaces:
return{...,schema:RD_(WcH(T))} → return{...,type:"contentArray"} (3 trailing spaces; schema field dropped)
Step 4: Verify uniqueness
Patch byte counts depend on the binary's bundle layout: 2 sites for 2.1.0..2.1.137 (the __BUN segment held a duplicate), 1 site for 2.1.138+ (Anthropic removed the duplication). Whichever count, every site must contain identical bytes:
grep -aoE 'EXPECTED_OLD_BYTES_REGEX' "$PATCHED" | sort -u | wc -l
Step 5: Update PatchDef + apply
Edit cc-lib/cc_lib/claude_binary_patching.py. Update old, new, possibly anchor, and bump min_version to the new version.
claude-binary-patcher check <patch-name>
claude-binary-patcher apply <patch-name>
claude-binary-patcher check <patch-name>
Phase 2b: Investigate missing patches
Quick checks first (each ~10 seconds), subagents only when those fail.
Quick check 1: Renamed function
If the anchor was a function header (function NAME(){...}), search by the surviving Statsig flag name:
grep -aoE 'function \w+\(\)\{return \w+\("FLAG_NAME"' "$PATCHED"
Found one match → just renamed. Update anchor + old bytes, retry.
Quick check 2: Renamed identifier
If the anchor was a constant reference like T.content.startsWith(EK6), search structurally:
grep -aoE 'T\.content\.startsWith\(\w+\)' "$PATCHED" | sort -u
One result → identifier just renamed. Update bytes.
Quick check 3: Feature removed
ORIG=~/.claude-workspace/binary-patcher/originals/<previous-version>
echo -n "old: "; grep -ac "feature_string" "$ORIG"
echo -n "new: "; grep -ac "feature_string" "$PATCHED"
Zero in new → feature REMOVED → mark obsolete (Phase 4).
Quick check 4: Renamed JSX block
For visibility patches that pivot on a JSX prop set, search by structurally-stable props:
grep -aoE 'createElement\(\w+,\{progressMessages:\w+,tools:\w+,verbose:\w+\}\)' "$PATCHED"
One match → component just renamed. Pull the full JSX block context with a wide perl window (.{200}...{800}) and update the new bytes.
Subagent investigation
When the four quick checks all fail or you're hitting an architectural change (the 2.1.126 → 2.1.128 session-memory removal was the canonical case), spawn 3-4 parallel unrestricted-worker subagents on Opus across vectors:
| Vector | What to investigate |
|---|
| Binary diff | Truly removed or just renamed/relocated? Compare string sets between OLD and NEW. Empirical, ground-truth-first. |
| GitHub | Issues + PRs + commits between previous and current tag. gh release view. Community forks (Piebald-AI/tweakcc, claude-code-best). |
| Anthropic public | docs.anthropic.com / code.claude.com / engineering blog / changelog / Discord. Silence often noted as evidence. |
| Community | Reddit (r/ClaudeAI), Hacker News, peer dev blogs (claudefa.st, giuseppegurgone, simonw). |
| Source mirror | ~/claude-code-best/ — structural reference. Mirror lags upstream and may have its own divergent fixes. |
For routine bug-fix releases (no architectural changes signalled in the changelog), the four quick checks usually suffice. Don't burn tokens on subagents reflexively — reserve them for genuine "where did this go?" mysteries.
Subagent prompt template
Empirical investigation of [specific question].
# Background
[What was, what changed — concrete byte-level facts]
# The question
[ONE clear question with verdict format]
# Sources
1. ~/.claude-workspace/binary-patcher/originals/<old-version>
2. ~/.local/share/claude/versions/<new-version>
3. ~/claude-code-best/ (TypeScript source mirror)
4. ~/.claude/projects/<project>/<sid>.jsonl (recent session for empirical traces)
# Output format
Under 500 words. Sections:
**Verdict**: ✅ / ⚠️ / ❌
**Empirical evidence**: quote actual bytes / function bodies, cite paths + offsets
**Open questions**: what remains uncertain
Don't speculate. Cite everything.
Phase 3: Apply + empirical verify
claude-binary-patcher apply --all
claude-binary-patcher check --all
If pre-flight refuses, the error names the unsatisfied setting (e.g., inject-searching-past-context-prompt requires autoMemoryEnabled: true in ~/.claude/settings.json). Fix the setting and re-run, OR exclude the patch by name and apply the rest. Don't bypass the check — INERT patches (applied bytes that never execute due to a runtime gate) are silent footguns.
Three patches now have an automated verifier that drives the real interactive CLI in tmux and asserts the behavior flip across a vanilla / single-patch / all-patches control — prefer these over manual checking; each exits non-zero on any miss:
uv run --script scripts/verify-hook-ask.py
uv run --script scripts/verify-show-subagent.py
uv run --script scripts/verify-429.py
Each leaks a throwaway projects entry into ~/.claude.json per run — clean up with claude project purge <workdir> -y, never a hand-edit (concurrent-writer race, GH #3117). Their docstrings carry the behavior spec for those three patches. Verify the remaining runtime-behavior patches in a fresh claude-exec session (not the current one — the running process has the pre-loaded binary in memory):
| Patch family | How to verify |
|---|
statusline | Bottom statusline wraps multi-line, doesn't truncate with … |
inject-searching-past-context-prompt | System prompt has "## Searching past context" section (introspect via debug log or visible context) |
scratchpad | System prompt has "## Scratchpad Directory" section pointing to a session-scoped path; dir created on first write |
Vanilla-vs-patched comparison protocol
If patched behavior looks wrong, don't assume the patch is broken. Anthropic may have changed the underlying behavior so the patch is now inert (a no-op) or unnecessary (no bug to fix). Compare:
claude-binary-patcher restore
claude-binary-patcher apply --all
| Vanilla | Patched | Conclusion |
|---|
| Wrong / empty | Same wrong / empty | Anthropic changed upstream → remove (Phase 4) |
| Works correctly | Same correctness (or better) | Anthropic shipped the upstream fix → patch redundant → remove (Phase 4) |
| Works correctly | Wrong | Patch broke something → debug or revert |
| Wrong | Works correctly | Patch is doing its job → ship |
Real examples:
- 2.1.128:
reject-show-comment looked broken because rejection rendering was empty. Vanilla comparison revealed Anthropic had silently changed rendering to be empty in vanilla too — patch became a no-op, removed.
- 2.1.138:
mcp-array-content-to-string's 2.1.136 changelog entry ("Fixed MCP tool results being invisible when the server returns content blocks") flagged the patch as a candidate for obsoletion. Vanilla 2.1.138 rendered the array content as clean CSV text (better than the patch's raw-JSON output) — upstream-fix superseded the patch, removed.
Phase 4: Remove obsolete patches
When a patch's underlying feature is removed or behavior changed upstream such that the patch is no longer applicable on currently-supported versions, remove it from the codebase entirely. This applies the CLAUDE.md "ideal state over backwards compat" principle — the bytes are preserved in git history; anyone on an older version can pin to an earlier commit.
For each obsolete patch:
- Delete the PatchDef from the
PATCHES sequence in cc-lib/cc_lib/claude_binary_patching.py.
- Delete the alphabetical entry from the module docstring's Patches section.
- Drop the column from the Site Count Evolution table.
- Fix cross-references — search the file for the patch name (e.g.,
grep -n 'patch-name' cc-lib/cc_lib/claude_binary_patching.py) and remove or rewrite any references in remaining patches' descriptions.
- Document the removal in the new version's Version Log entry. State the upstream cause (e.g., "Anthropic shipped the fix in 2.1.136") and cite empirical evidence (vanilla-vs-patched comparison from Phase 3).
Historical Version Log entries that mention the removed patch stay intact — they're a record of what happened at those migrations. Don't retroactively edit them.
If you're unsure whether a patch is truly obsolete vs just broken for the current version, do the vanilla-vs-patched comparison in Phase 3 first. The comparison is the deciding evidence — don't remove on speculation.
Historical note: this codebase used to mark obsolete patches with max_version='<last-applicable-version>' instead of removing them. That left dead code in PATCHES and inconsistent docstring/table state. The shift to outright removal happened in PR #121 (2.1.138 migration).
Phase 5: Documentation
Edit cc-lib/cc_lib/claude_binary_patching.py module docstring.
Site Count Evolution table
Add a row for the new version. Only the active multi-version-history patches have columns. When you remove an obsolete patch in Phase 4, drop its column too — historical site counts for the dropped patch live in git history.
Version statusline inject-searching-past-context-prompt
2.1.131 2 2
2.1.138 1 1
<new> ? ?
(Site counts are 2 for 2.1.0..2.1.137 and 1 for 2.1.138+, reflecting the __BUN segment removal.)
Version Log entry
Add at the top of Version Log:: (newest first). Date in YYYY-MM-DD.
For routine bug-fix releases, keep the entry terse (one line per patch):
<new-version> (<YYYY-MM-DD>)
<One-line release character — bug fixes, regressions, etc.>
Patch updates:
- <patch-name>: <change one-liner>
- <patch-name>: clean apply (anchor + bytes stable since <prev-version>).
For releases with architectural changes, expand to a paragraph + per-patch deep notes — see the existing 2.1.128 entry as the reference (session-memory removal context).
Phase 6: Pre-commit + commit + push + PR
uv run pre-commit run --files cc-lib/cc_lib/claude_binary_patching.py \
|| uv run pre-commit run --files cc-lib/cc_lib/claude_binary_patching.py
git add cc-lib/cc_lib/claude_binary_patching.py
git commit -m "$(cat <<'EOF'
binary-patcher: re-derive patches for <version>
<One-paragraph summary of release character + patch changes>
Patch updates:
- <patch-name>: <change>
- <patch-name>: <change>
EOF
)"
git push -u origin feat/binary-patches-$ARGUMENTS
gh pr create --title "Binary patches for Claude Code $ARGUMENTS" --body-file scratch/pr-binary-patches-$ARGUMENTS.md
PR description structure (real examples in PR #102, #106, #108):
- TL;DR table: status per patch
- Per-patch deep-dives for non-trivial changes
- Empirical verification checklist
- Outcome summary (N applicable patches, M sites)
- Links to investigation gists if subagents were spawned
Cheatsheet
PATCHED=~/.local/share/claude/versions/$ARGUMENTS
ORIG=~/.claude-workspace/binary-patcher/originals/<previous-version>
Find context around a known anchor
perl -e '
my $data = do { local $/; open(my $fh, "<:raw", $ARGV[0]) or die; <$fh> };
while ($data =~ /(.{120}ANCHOR_REGEX.{200})/sg) { print "$&\n\n"; last }
' "$PATCHED"
perl -e '
my $data = do { local $/; open(my $fh, "<:raw", $ARGV[0]) or die; <$fh> };
while ($data =~ /(.{200}ANCHOR_REGEX.{800})/sg) { print "$&\n\n"; last }
' "$PATCHED"
Find a function definition
perl -e '
my $data = do { local $/; open(my $fh, "<:raw", $ARGV[0]) or die; <$fh> };
while ($data =~ /(function NAME\([^)]*\)\{.{0,400}?\})/sg) { print "$1\n"; last }
' "$PATCHED"
Find a feature gate function by Statsig flag name
grep -aoE 'function \w+\(\)\{return \w+\("FLAG_NAME"' "$PATCHED"
Find a JSON.stringify wrapper (post-2.1.128 tracing pattern)
grep -aoE '\b\w+=JSON\.stringify\b' "$PATCHED" | sort -u
grep -aoE 'function \w+\(H[^)]*\)\{using \w+=\w+`JSON\.stringify' "$PATCHED"
Find a JSX site by stable prop set
grep -aoE 'createElement\(\w+,\{progressMessages:\w+,tools:\w+,verbose:\w+\}\)' "$PATCHED"
Verify byte budget
echo -n "Old: "; printf 'old_bytes_here' | wc -c
echo -n "New: "; printf 'new_bytes_here' | wc -c
Verify all sites have identical bytes (1 site post-2.1.138, 2 sites pre-2.1.138)
grep -aoE 'pattern' "$PATCHED" | sort -u | wc -l
Compare feature presence between versions
echo -n "old: "; grep -ac "feature_string" "$ORIG"
echo -n "new: "; grep -ac "feature_string" "$PATCHED"
Find all Statsig flag references
grep -aoE '\b\w+\("flag_name"[^)]*\)' "$PATCHED" | sort -u
Anchor selection rules
- Anchor must survive the patch. If the anchor is part of
old, post-patch detection breaks (status reports missing). Use stable post-context substrings.
- String literals are most stable (flag names, prop names, error messages, JSX prop sets).
- Minified function names are LEAST stable — typically renamed every release.
- Examples that work:
b'"contentArray"' (string literal)
b'){let Y;if(_[5]===Symbol.for("react.memo_cache_sen' (React Compiler memoization marker, post-context)
b'O8.createElement(hv5,{progressMessages:_,tools:q,verbose:K})' (JSX prop set, structurally stable across T→K outer substitution)
- Examples that DON'T work (avoid):
b'q.content.startsWith(UZH)' when the patch replaces UZH with "Z" — anchor disappears post-patch
- Bare 2-3 character minified IDs without surrounding context — too many false matches
Patch-strategy patterns
| Strategy | When to use | Example |
|---|
| Falsify a check | Routing decision routes content to a wrong renderer | T.content.startsWith(EK6) → T.content.startsWith("Z") |
| Short-circuit gate body | Statsig gate cached false, blocking a feature | function ve(){return G_("tengu_scratch",!1)} → function ve(){return!0/*scratchpad always*/} |
| Variable substitution | JSX block conditionally hides info | 4× T → K in show-subagent JSX (T was isTranscriptMode, K is verbose) |
| Discriminator preservation | Patch breaks a related consumer | return T → return q!=="ide"?EH(T):T (preserves IDE plugin's array-shape consumer) |
Antipatterns
- Don't apply --all blindly without
check first. The 5-state table tells you what's needed.
- Don't add NEW patches in a version-migration PR. That's scope creep. Re-derive existing patches; new patches go in their own PR.
- Don't skip vanilla-vs-patched comparison when behavior is ambiguous.
reject-show-comment looked broken on 2.1.128 but vanilla was also "broken" — Anthropic had silently changed the rendering. Saved by the comparison.
- Don't reflexively spawn subagents for
missing patches. Routine bug-fix releases solve with the four Phase 2b quick checks. Reserve subagents for architectural changes.
- Don't trust subagent verdicts without empirical follow-up. After a subagent says "this should work," still test in a fresh session.
- Don't trust a 100-character preview when verifying constants. I assumed
UZH and EK6 were the same constant because their first 100 chars matched — they diverged at character 152 (STOP what you... vs To tell you how...). Always extract the FULL constant content before equating.
- Don't add patches based on flawed routing analysis.
reject-show-comment-dispatcher was added based on misreading the dispatcher's branches. Trace the ACTUAL flow through the function, not the assumed one.
- Don't keep dead patches "just in case." If Anthropic removed the feature or shipped the fix upstream (verify via Phase 3 vanilla-vs-patched), remove the PatchDef entirely per Phase 4. Don't try to "make it work" when there's nothing to fix; don't preserve dead code with
max_version.
- Don't write journey residue in docstrings or PR descriptions. Document state, not the path.
- Don't forget pre-commit before commit. Failed pre-commit means a wasted commit message.
Phases checklist