patch-cc 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {patch_cc-0.1.2 → patch_cc-0.1.3}/PKG-INFO +4 -2
  2. {patch_cc-0.1.2 → patch_cc-0.1.3}/README.md +3 -1
  3. {patch_cc-0.1.2 → patch_cc-0.1.3}/docs/CONDUCT.md +3 -1
  4. patch_cc-0.1.3/docs/PLAYBOOK.md +366 -0
  5. {patch_cc-0.1.2 → patch_cc-0.1.3}/pyproject.toml +1 -1
  6. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/__init__.py +1 -1
  7. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/cli.py +81 -64
  8. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/doctor.py +12 -17
  9. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/menu.py +22 -21
  10. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/patcher.py +45 -16
  11. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/patches/agents.py +83 -27
  12. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/patches/base.py +83 -5
  13. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/patches/chrome.py +14 -4
  14. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/patches/streaming.py +80 -16
  15. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/patches/thinking.py +72 -5
  16. patch_cc-0.1.3/src/patch_cc/ui.py +53 -0
  17. {patch_cc-0.1.2 → patch_cc-0.1.3}/uv.lock +1 -1
  18. patch_cc-0.1.2/docs/PLAYBOOK.md +0 -208
  19. patch_cc-0.1.2/src/patch_cc/ui.py +0 -23
  20. {patch_cc-0.1.2 → patch_cc-0.1.3}/.github/workflows/ci.yml +0 -0
  21. {patch_cc-0.1.2 → patch_cc-0.1.3}/.github/workflows/release.yml +0 -0
  22. {patch_cc-0.1.2 → patch_cc-0.1.3}/.gitignore +0 -0
  23. {patch_cc-0.1.2 → patch_cc-0.1.3}/LICENSE +0 -0
  24. {patch_cc-0.1.2 → patch_cc-0.1.3}/docs/INTERNALS.md +0 -0
  25. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/bun/__init__.py +0 -0
  26. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/bun/blob.py +0 -0
  27. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/bun/container.py +0 -0
  28. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/bun/elf.py +0 -0
  29. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/bun/errors.py +0 -0
  30. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/bun/macho.py +0 -0
  31. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/cache.py +0 -0
  32. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/locate.py +0 -0
  33. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/patches/__init__.py +0 -0
  34. {patch_cc-0.1.2 → patch_cc-0.1.3}/src/patch_cc/patches/output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patch-cc
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Interactive patcher for the Claude Code native binary
5
5
  Project-URL: Homepage, https://github.com/anfreire/patch-cc
6
6
  Project-URL: Repository, https://github.com/anfreire/patch-cc
@@ -76,7 +76,8 @@ patch-cc # then just run it
76
76
  |---|---|---|
77
77
  | Output & diffs | Detailed tool calls | Show full read/search calls, not collapsed summaries |
78
78
  | | Colour new files as diffs | Created files render with `+` lines and green |
79
- | Thinking | Always show thinking | Thinking blocks stay inline — no `ctrl+o` |
79
+ | Thinking | Opt out of server-side experiments | Some accounts land in one that empties every thinking block |
80
+ | | Always show thinking | Thinking blocks stay inline — no `ctrl+o` |
80
81
  | Live thinking | Stream thinking live | See reasoning as it is generated, inline and in order |
81
82
  | Subagents | Show subagent prompts | Prompt blocks visible during normal use |
82
83
  | | Override subagent models | Pick the model per built-in agent (discovered from your binary) |
@@ -98,6 +99,7 @@ uvx patch-cc apply --model Explore=haiku --model general-purpose=opus
98
99
  uvx patch-cc apply --suffix "(mine)" # custom --version marker
99
100
  uvx patch-cc status # exactly what is applied
100
101
  uvx patch-cc doctor # do all patches match this build?
102
+ uvx patch-cc doctor path/to/claude # ...or match some other binary
101
103
  uvx patch-cc list # patches + your binary's agents/models
102
104
  uvx patch-cc restore # put the original back
103
105
  ```
@@ -48,7 +48,8 @@ patch-cc # then just run it
48
48
  |---|---|---|
49
49
  | Output & diffs | Detailed tool calls | Show full read/search calls, not collapsed summaries |
50
50
  | | Colour new files as diffs | Created files render with `+` lines and green |
51
- | Thinking | Always show thinking | Thinking blocks stay inline — no `ctrl+o` |
51
+ | Thinking | Opt out of server-side experiments | Some accounts land in one that empties every thinking block |
52
+ | | Always show thinking | Thinking blocks stay inline — no `ctrl+o` |
52
53
  | Live thinking | Stream thinking live | See reasoning as it is generated, inline and in order |
53
54
  | Subagents | Show subagent prompts | Prompt blocks visible during normal use |
54
55
  | | Override subagent models | Pick the model per built-in agent (discovered from your binary) |
@@ -70,6 +71,7 @@ uvx patch-cc apply --model Explore=haiku --model general-purpose=opus
70
71
  uvx patch-cc apply --suffix "(mine)" # custom --version marker
71
72
  uvx patch-cc status # exactly what is applied
72
73
  uvx patch-cc doctor # do all patches match this build?
74
+ uvx patch-cc doctor path/to/claude # ...or match some other binary
73
75
  uvx patch-cc list # patches + your binary's agents/models
74
76
  uvx patch-cc restore # put the original back
75
77
  ```
@@ -41,7 +41,9 @@ shape that already absorbs every case, found rather than bolted on.
41
41
  - **Report absent apart from broken.** A matcher that finds nothing may be a
42
42
  shape this build simply lacks — most patches carry several — not a regression.
43
43
  Keep "gone", "already applied", and "not on this build" as distinct signals;
44
- never collapse them into one number.
44
+ never collapse them into one number. Which one a sub-step's silence means is
45
+ not guesswork: declare it (`expect=True`, or a variant group) so a green tick
46
+ cannot cover a dead feature. See [PLAYBOOK.md](PLAYBOOK.md).
45
47
 
46
48
  - **Port faithfully.** When you change a patch, verify its output against the
47
49
  upstream reference on a real bundle — byte-identical where behaviour must not
@@ -0,0 +1,366 @@
1
+ # Patch playbook
2
+
3
+ For maintainers. How the patches are built to survive Claude updates, and how to
4
+ repair one when a build breaks it.
5
+
6
+ This is a Python port of
7
+ [a-connoisseur/patch-claude-code](https://github.com/a-connoisseur/patch-claude-code);
8
+ that repo's git history is the best archive of how each matcher has drifted over
9
+ time.
10
+
11
+ ## Matcher rules
12
+
13
+ The minified bundle is regenerated on every Claude build, so identifiers churn
14
+ constantly. These rules are what keep matchers alive:
15
+
16
+ - **Never** anchor on a minified local (`A_`, `mET`, `wg6`). Anchor on string
17
+ literals, `case` labels, prop names, or control-flow shape.
18
+ - Match the *semantic shape* of a function body, not its symbol names.
19
+ - When upstream ships more than one shape for the same feature, add a second
20
+ narrow branch — don't widen one regex until it over-matches.
21
+ - Accept statement variants a minifier flips between. The thinking gate broke
22
+ on 2.1.216 solely because `if(x)return null;` became `if(x){return null}` —
23
+ matchers should allow both from day one.
24
+ - Match an optional brace **pair** conditionally (`(\{)?…(?(1)\})`), never as
25
+ two independent `\{?` / `\}?`. A lone optional closing brace eats the
26
+ *enclosing* block's `}` on the unbraced shape, and a rewrite that emits its
27
+ own leaves the bundle unbalanced — with `candidates`/`applied` unchanged, so
28
+ nothing looks wrong until Claude fails to start.
29
+ - Compile with `re.ASCII` (helper `compile_js`) so `\w` behaves like JS.
30
+ - JS `.replace(re, fn)` without `/g` replaces **once** → `re.sub(..., count=1)`.
31
+ JS `.replace("a","b")` on a string also replaces once → `str.replace(a, b, 1)`.
32
+ Getting this wrong over-rewrites.
33
+ - Always pass a *function* replacement to `re.sub`, never a template string
34
+ (backslashes and `\g<>` would be interpreted).
35
+
36
+ ## Discovery instead of hardcoding
37
+
38
+ Anything the binary can enumerate for us, it does:
39
+
40
+ - **Built-in agents** are found by the definition shape
41
+ `agentType:"<name>",whenToUse:` carrying `source:"built-in"` in the same
42
+ object (`agents.discover_agents`). Definitions whose `whenToUse` begins with
43
+ `"Internal` are plumbing and are not offered.
44
+ - **Model aliases** come from the Task tool's own input schema: the
45
+ `model:<zod>.enum([...])` whose describe-string starts
46
+ `Optional model override` (`agents.discover_models`).
47
+
48
+ A new upstream agent or model appears in `patch-cc list`, the menu, and
49
+ `--model` validation without a code change. If the enum anchor ever vanishes,
50
+ discovery falls back to `haiku/sonnet/opus` — `doctor` prints both lists, so a
51
+ missing agent or alias is visible at a glance.
52
+
53
+ ## The manifest
54
+
55
+ Every patched bundle ends with one comment line:
56
+
57
+ ```
58
+ //patch-cc {"v":1,"tool":"0.1.0","patches":[...],"brand":...,"models":{...}}
59
+ ```
60
+
61
+ A comment cannot collide with code, survives re-extraction, and makes `status`
62
+ a parse instead of a guess — several patches are value flips
63
+ (`verbose:!0`) that leave no other fingerprint. `is_patched` also still
64
+ recognises the legacy fingerprints (`__cc_` identifiers, the old `--version`
65
+ marker) so binaries patched by pre-manifest versions are not mistaken for
66
+ clean.
67
+
68
+ ## How resilience is detected
69
+
70
+ `patch-cc doctor` runs every patch against a **clean** bundle (the pristine
71
+ backup if the installed binary is already patched) and reports, per patch:
72
+
73
+ - `candidates` — how many times the anchor/shape was found
74
+ - `applied` — how many rewrites actually changed something
75
+
76
+ Configurable patches are fed a synthetic configuration built from the bundle's
77
+ own discovered agents and models (every agent assigned a model different from
78
+ its current one, a test brand), so branding and the model overrides are
79
+ exercised for real — nothing is exempt from the dry run.
80
+
81
+ Two failure modes, kept distinct:
82
+
83
+ | symptom | meaning |
84
+ |---|---|
85
+ | `candidates == 0` | the anchor is **gone** — a real regression |
86
+ | `candidates > 0, applied == 0` | shape found, rewrite was a no-op — usually already patched |
87
+
88
+ `doctor` prints the documented anchor counts for any broken patch, so a `0`
89
+ next to an anchor points straight at what moved.
90
+
91
+ ### Expectations — why a green tick means something
92
+
93
+ Counting alone cannot tell "this build lacks that shape" from "the feature is
94
+ dead": a patch whose optional shapes rewrite happily while a load-bearing one
95
+ is gone still changes bytes, and would read as green. So each sub-step declares
96
+ what its absence *means* (`Outcome.step(..., expect=...)`):
97
+
98
+ | mark | meaning | absence is |
99
+ |---|---|---|
100
+ | *(default)* | a shape only some builds carry | informational |
101
+ | `expect=True` | the patch does nothing useful without it | a regression |
102
+ | `expect="<group>"` | alternate shapes; at least one must land | a regression |
103
+
104
+ `Outcome.unmet()` turns a violation into a sentence (`required step
105
+ group-routing found nothing`); `Outcome.failures()` adds any exception the
106
+ patch raised, because a patch that threw and a patch that missed an
107
+ expectation are the same verdict wearing different clothes; `Outcome.health`
108
+ folds them into `ok` / `partial` / `broken`. Every surface (`apply`, `doctor`,
109
+ the menu) reads *those two* and never re-derives either — `doctor` once judged
110
+ on counts alone and printed a red cross and "all patches still match" in the
111
+ same report. Adding a sub-step means deciding which row of the table it is in;
112
+ that decision is the whole safety net.
113
+
114
+ Two rules keep the net from having holes:
115
+
116
+ - **Count what the rewrite *achieved*, not that some rewrite happened.** A step
117
+ built from many literal edits lands as soon as *one* of them applies, so an
118
+ incidental edit can vouch for the essential ones. `live-thinking` learned this
119
+ the hard way: a reducer whose setter threading applied while every event arm
120
+ had drifted reported hits and streamed nothing. The two edits that *are* the
121
+ feature are now checked by the markers their builders emit
122
+ (`streaming._CORE_UPDATES`), which no amount of neighbouring churn can fake.
123
+ - **Declare an expectation before the work, not inside it.** A step created only
124
+ by its own success cannot report its own absence. `_live_thinking` registers
125
+ the core updates up front; `agents.bypassed_agents` resolves the pinned agent
126
+ from the helper's durable head so a drifted body still has a step to fail.
127
+
128
+ `apply` acts on the verdict: a broken patch is re-run out of the final pass, so
129
+ its orphan edits never reach the binary, the manifest never claims it, and the
130
+ command exits non-zero. The healthy patches still apply.
131
+
132
+ That re-run is a **fixpoint**, not one retry. Patches see each other's output,
133
+ so dropping one can change what the next finds; the set is settled only when a
134
+ whole run comes back with nothing broken, and each patch is reported by the last
135
+ run it took part in. Judging the bytes of the final run by the verdicts of the
136
+ first is how a manifest starts lying. The manifest is held to the same rule:
137
+ `brand`, `suffix` and `models` are recorded only when *their* patch landed, so
138
+ `status` can never assert a name the bundle does not contain.
139
+
140
+ ## Sub-steps, and why `live-thinking` has them
141
+
142
+ Most patches are one rewrite. `live-thinking` is ~11 named sub-steps, because
143
+ upstream has reshaped the stream reducer at least three times and a single hit
144
+ count cannot tell "all landed" from "half silently drifted".
145
+
146
+ Each sub-step records its own `candidates`/`applied`. Sub-steps come in two
147
+ kinds:
148
+
149
+ - **Independent fixes** (`memo-cache`, `linger`, `display-mode`, `bottom-row`,
150
+ …) — each may or may not be present on a given build.
151
+ - **Mutually-exclusive reducer variants** — `reducer-destructured` (2.1.138+),
152
+ `reducer-inner` (2.1.183+), `reducer-legacy` (pre-2.1.138). On any one build
153
+ exactly one should land. On 2.1.216 it is `reducer-inner`.
154
+
155
+ An *optional* sub-step that finds nothing is reported as absent, not broken —
156
+ it is just a shape this build doesn't have. A sub-step that finds a shape but
157
+ fails to rewrite it (`candidates > 0, applied == 0`) shows up in
158
+ `missed_steps()`.
159
+
160
+ The rest carry expectations, so their absence is checked rather than noted:
161
+
162
+ - **Required** — `prop-threading`, `display-mode`, `transcript-signature`,
163
+ `inline-extras`, plus `block-start` and `thinking-delta`. Each is a link in
164
+ the chain from stream event to rendered row; without any one of them live
165
+ thinking is dead however many other steps land. The last two are not matchers
166
+ but *proof*: they are credited only when the rewritten reducer body actually
167
+ contains the state updates, which is what recognising a reducer does not by
168
+ itself establish.
169
+ - **The `reducer` group** — `reducer-destructured`, `reducer-inner`,
170
+ `reducer-legacy`. At least one must land; none landing is the signal that
171
+ upstream shipped a fourth reducer shape that needs a new variant. Two landing
172
+ is allowed on purpose — a transitional build carrying two reducers would have
173
+ both correctly patched, and that is no reason to cry wolf.
174
+ - **Optional** — `memo-cache`, `memo-removal`, `linger`, `bottom-row`,
175
+ `final-summary`. The first four match nothing on 2.1.216+ and are kept for
176
+ older builds; `discover` is a notes-only channel that records how far back the
177
+ state back-scan reached, which is the early warning for `_DISCOVER_WINDOW`
178
+ (35,116 of 50,000 on 2.1.217).
179
+
180
+ Notes print on every run, green ones included — an early warning held back
181
+ until something breaks arrives too late to be one. Absences are the noisy half
182
+ (most patches lack several shapes on any build) and wait for a verdict that is
183
+ not `ok`. Both surfaces draw the same list (`ui.findings`): the CLI and the menu
184
+ each worded their own once, and the menu's copy had quietly lost the exception
185
+ that broke a patch along with every note.
186
+
187
+ ## Repairing a broken patch
188
+
189
+ 1. Get a clean bundle from a current binary:
190
+
191
+ ```bash
192
+ patch-cc extract ~/.local/share/claude/versions/<ver> > clean.js
193
+ ```
194
+
195
+ 2. Run `patch-cc doctor`. Note which patch dropped to `candidates == 0`, or —
196
+ for `live-thinking` — which sub-step.
197
+
198
+ 3. Search the clean bundle for the *semantic* anchor, not the old identifier:
199
+
200
+ ```bash
201
+ rg 'case"collapsed_read_search"|case"thinking_delta"|spinnerTipsEnabled|Backgrounded agent' clean.js
202
+ ```
203
+
204
+ 4. If the anchor moved, find the new shape and update the matcher in the
205
+ relevant `src/patch_cc/patches/*.py`. Prefer adding a branch over loosening
206
+ the existing regex.
207
+
208
+ 5. Re-run `doctor` until the patch (and each expected sub-step) is green, then
209
+ apply to a real binary and check the behaviour at runtime.
210
+
211
+ 6. Sweep the fix over the versions you still have. `doctor` takes a path, and
212
+ every binary patch-cc has ever touched left a pristine copy in
213
+ `~/.local/share/patch-cc/backups/`:
214
+
215
+ ```bash
216
+ for b in ~/.local/share/patch-cc/backups/*.orig; do
217
+ echo "== $b"; patch-cc doctor "$b" || true
218
+ done
219
+ ```
220
+
221
+ That is what keeps "add a narrow branch" honest: a widened regex that
222
+ over-matches an older build shows up here instead of in a bug report.
223
+
224
+ ## Patch reference
225
+
226
+ Grouped as in `patch-cc list`. Each entry: what it changes, the stable anchor,
227
+ and where it lives.
228
+
229
+ ### Output & diffs — `output.py`
230
+
231
+ - **`tool-calls`** — force verbose collapsed read/search rows.
232
+ Anchor: `case"collapsed_read_search"`.
233
+ *Value-flip* (`verbose:!0`) — the manifest is its only fingerprint.
234
+ - **`create-diff`** — render created files through the diff renderer with `+`
235
+ lines. Anchors: adjacent `case"create":` / `case"update":`; the update arm
236
+ must expose `structuredPatch`.
237
+
238
+ ### Thinking — `thinking.py`
239
+
240
+ - **`thinking-summaries`** — stop echoing the account's server-side experiment
241
+ bucket, so the API returns thinking blocks with text in them.
242
+ Anchor: the `?.atis` read plus the getter's whole return shape, the read tied
243
+ to both its uses by backreference (header name: `x-cc-atis`). Matching the
244
+ property and then replacing a brace-free body would also hit any *other*
245
+ function reading it, deleting whatever else that one did; one `.atis` per
246
+ bundle is today's happenstance, not an invariant. Candidates are counted off
247
+ the header name, not the matcher, so a reshaped getter reads as
248
+ `candidates > 0, applied == 0` — a matcher to repair — instead of the zero
249
+ that would equally mean upstream retired the mechanism.
250
+ Claude Code caches a GrowthBook assignment (`clientDataCacheSlots[...].atis`
251
+ in `~/.claude.json`, one slot per account × entrypoint × model) and replays it
252
+ to the API on every request so the server applies the same bucket. A slot in a
253
+ bucket that withholds thinking summaries is served thinking blocks carrying a
254
+ signature and an **empty string** — no `display` mode, effort level or
255
+ `thinking.type` changes it, and two requests that differ only in the header
256
+ differ in nothing else. Because the slot is per model too, one model can think
257
+ visibly while another stays blank in the same session. Every other thinking
258
+ patch then
259
+ renders that empty string faithfully, which is why the symptom reads as
260
+ "thinking works on one account and not another, same binary, same config".
261
+ The getter is read in exactly one place, to set that one header, so emptying
262
+ it lets the header's existing `if(value!==void 0)` guard skip it — nothing is
263
+ sent, and no branch was added to stop it. Mind the breadth: this drops the
264
+ bucket for *every* experiment the account is enrolled in, not just the one
265
+ that empties thinking. Local feature values still come from the on-disk cache,
266
+ so only the server's view of the assignment changes.
267
+
268
+ Diagnose from a transcript rather than by eye — `thinking` blocks are
269
+ recorded whether or not they carry text:
270
+
271
+ ```bash
272
+ jq -r 'select(.type=="assistant").message.content[]?
273
+ | select(.type=="thinking") | (.thinking|length)' \
274
+ ~/.claude/projects/<slug>/<session>.jsonl | sort -n | uniq -c
275
+ ```
276
+
277
+ A column of `0`s is this patch missing (or a bucket it does not yet cover);
278
+ a spread of real lengths means the text arrived and the problem is rendering.
279
+
280
+ - **`thinking-inline`** — make historical thinking blocks render inline.
281
+ Anchor: `case"thinking":` containing `isTranscriptMode:`. Two rewrites:
282
+ remove the early null-return (both `if(!a&&!b)return null;` and the 2.1.216
283
+ block form `if(!a&&!b){return null}`), then force `isTranscriptMode:!0`
284
+ (and `hideInTranscript:!1` where present) in the renderer props. The
285
+ component itself has no gate — an empty summary renders nothing, which is
286
+ why trivially short thinks may still show no block.
287
+
288
+ ### Live thinking — `streaming.py`
289
+
290
+ - **`live-thinking`** — the ~11-step patch above. Discovery anchor is
291
+ `onStreamingThinking:` → `useState(null)` (the older `hidePastThinking`
292
+ anchor is gone as of 2.1.216 — the fallback back-scan is load-bearing).
293
+ Reducer anchors: `type==="stream_request_start"`, `case"thinking_delta"`,
294
+ `content_block_start`.
295
+ The `display-mode` sub-step defaults the request's thinking display to
296
+ `"summarized"`; without it the API only streams summary text when the
297
+ `showThinkingSummaries` setting is on. Two shapes: the legacy inline env
298
+ check, and the 2.1.216 form that hoists
299
+ `X=qt(process.env.CLAUDE_CODE_DISABLE_THINKING)` and gates the display
300
+ behind extra feature-helper calls (kept verbatim by the matcher).
301
+
302
+ ### Subagents — `agents.py`
303
+
304
+ - **`subagent-prompt`** — show the Prompt block outside transcript mode.
305
+ Anchor: `"Backgrounded agent"` + `action:"app:toggleTranscript"`.
306
+ - **`subagent-models`** — write the chosen model into each overridden built-in
307
+ definition (discovered as above): rewrite the `model:"..."` literal when the
308
+ definition has one, insert `model:"...",` right after `agentType:"...",`
309
+ when it doesn't. Both splice at offsets from a fresh discovery pass.
310
+ **The bypass:** one helper ignores the definition's model for a single
311
+ pinned agent (Explore today) — shape
312
+ `function f(def,main){if(def.agentType!==X.agentType||def.source!=="built-in")return def.model;…;return g(main)?PIN:"inherit"}`.
313
+ When the pinned agent (resolved by following `X` back to its
314
+ `X={agentType:"..."}` assignment) is among the overrides, the body is
315
+ rewritten to `return def.model`. Without this, Explore ignores every
316
+ override at runtime — the literal is written but dead.
317
+
318
+ The helper is matched in two pieces on purpose. Its **head** (the
319
+ two-condition guard) identifies it and names the pinned agent; its **body**
320
+ is what gets replaced, and upstream keeps growing it — 2.1.217 inserted a
321
+ `CLAUDE_CODE_DISABLE_EXPLORE_INHERIT_CAP` escape hatch between the two,
322
+ which silently cost every Explore override until the body matcher learned to
323
+ skip intervening brace-free statements. Resolving the agent from the head
324
+ alone is what makes a future body reshape *loud*: we still know an override
325
+ is at stake, so `bypass:<agent>` is a required step that fails, instead of the
326
+ agent's identity vanishing with the match and the step never existing.
327
+
328
+ If the **head** goes too there is no step to fail — nothing left names the
329
+ agent — and no way to tell "upstream stopped pinning" from "the guard
330
+ reshaped". That one is a note instead, on a patch that stays green; it is why
331
+ notes print on green runs.
332
+
333
+ `bypassed_agents` returns each helper's **offset**, and the rewrite is
334
+ anchored there (`_MODEL_BYPASS.match(content, at)`). Identifying one helper
335
+ and then rewriting whichever one a fresh search happens to find first is how
336
+ you neutralise an unrelated helper and report success; anchoring also means a
337
+ build that pins two agents gets both handled, rewritten last-first so earlier
338
+ offsets stay valid.
339
+
340
+ ### Chrome & branding — `chrome.py`
341
+
342
+ - **`spinner-tips`** — force spinner tips off. Anchors: `spinnerTipsEnabled===!1`
343
+ guard and `spinnerTipsEnabled!==!1` expression (two paths). *Value-flip.*
344
+ Each path counts candidates off the **setting name**, not its own regex, so a
345
+ reshaped path reads as `candidates > 0, applied == 0` — a miss — instead of
346
+ the zero that would be indistinguishable from a build that lacks it, with the
347
+ other path carrying the patch to green and tips still showing.
348
+ - **`version-marker`** — append `\n<suffix>` after `}.VERSION} (Claude Code)`
349
+ (default `(patched)`, customisable via `--suffix`; escaped for the template
350
+ literal it lands in).
351
+ - **`branding`** — rename visible `Claude Code` startup/help strings to a
352
+ chosen name. Several string shapes, each its own sub-step. On by default,
353
+ deriving `<username>'s Code`; `--brand NAME` names it explicitly, and selects
354
+ the patch when it is not already in the set.
355
+
356
+ ## Removed patches
357
+
358
+ Kept here so nobody reintroduces them without knowing why they left:
359
+
360
+ - **`word-diff-bg`** — as of 2.1.216 the word spans are nested inside a row
361
+ element that already carries the line background; the fallback could never
362
+ change a pixel. Confirmed redundant in live A/B.
363
+ - **`installer-label`** — its target string left the bundle in ~2.1.186.
364
+ - **`redacted-thinking`** — untestable against the real API (no way to elicit
365
+ a `redacted_thinking` block), and the native-only tool keeps its surface to
366
+ what can be verified.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "patch-cc"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Interactive patcher for the Claude Code native binary"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """patch-cc: an interactive patcher for the Claude Code native binary."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.3"
@@ -6,7 +6,7 @@ subcommand so nothing needs the TUI:
6
6
  patch-cc apply [PATCH ...] [--brand [NAME]] [--model AGENT=MODEL]
7
7
  [--suffix TEXT]
8
8
  patch-cc status
9
- patch-cc doctor
9
+ patch-cc doctor [PATH] # PATH: check any binary, e.g. an old backup
10
10
  patch-cc list
11
11
  patch-cc restore
12
12
  patch-cc extract PATH # dump the JS bundle (debugging)
@@ -21,12 +21,15 @@ from __future__ import annotations
21
21
 
22
22
  import argparse
23
23
  import sys
24
+ from pathlib import Path
25
+
26
+ from rich.markup import escape
24
27
 
25
28
  from . import locate, patcher
26
- from .bun import BunError
27
- from .patches import Options, by_group, default_ids, derived_brand, ids
29
+ from .bun import BunError, Bundle
30
+ from .patches import Options, Outcome, by_group, default_ids, derived_brand, ids
28
31
  from .patches.agents import INHERIT, discover_agents, discover_models
29
- from .ui import console, err, heading, ok, warn
32
+ from .ui import MARKS, console, err, findings, heading, ok, warn
30
33
 
31
34
  #: ``--brand`` with no value: derive the name from the system username.
32
35
  _DERIVE = ""
@@ -93,24 +96,19 @@ def _requested(args, source: str) -> tuple[list[str], Options]:
93
96
  return selected, options
94
97
 
95
98
 
99
+ def _print_findings(outcome: Outcome) -> None:
100
+ """The detail under a patch line -- worded in :func:`ui.findings`."""
101
+ for style, text in findings(outcome):
102
+ console.print(f" [{style}]· {text}[/{style}]")
103
+
104
+
96
105
  def _print_report(report: patcher.PatchReport) -> None:
97
106
  heading("Patch results")
98
107
  for patch, outcome in report.results:
99
- missed = outcome.missed_steps()
100
- if outcome.landed and not missed:
101
- mark = "[green]✓[/green]"
102
- elif outcome.landed:
103
- mark = "[yellow]~[/yellow]"
104
- else:
105
- mark = "[red]✗[/red]"
108
+ mark, colour = MARKS[outcome.health]
106
109
  detail = f" applied {outcome.applied}" if outcome.applied else ""
107
- console.print(f" {mark} {patch.title:28s}{detail}")
108
- for name in missed:
109
- console.print(
110
- f" [yellow]· sub-step matched but not applied:[/yellow] {name}"
111
- )
112
- for note in outcome.notes:
113
- console.print(f" [dim]· {note}[/dim]")
110
+ console.print(f" [{colour}]{mark}[/{colour}] {patch.title:28s}{detail}")
111
+ _print_findings(outcome)
114
112
 
115
113
  if report.output is None:
116
114
  console.print()
@@ -132,7 +130,7 @@ def _print_report(report: patcher.PatchReport) -> None:
132
130
  console.print(f" [dim]backup: {report.backup}[/dim]")
133
131
  if report.regressions:
134
132
  warn(
135
- f"{len(report.regressions)} patch(es) matched nothing: "
133
+ f"{len(report.regressions)} patch(es) did not apply and were left out: "
136
134
  + ", ".join(p.id for p in report.regressions)
137
135
  )
138
136
  console.print(" [dim]Run `patch-cc doctor` for anchor details.[/dim]")
@@ -156,7 +154,7 @@ def cmd_apply(args) -> int:
156
154
  _print_report(report)
157
155
  if report.output is not None:
158
156
  console.print("\n[dim]Restart Claude Code for changes to take effect.[/dim]")
159
- return 0 if report.output is not None else 1
157
+ return 0 if report.ok else 1
160
158
 
161
159
 
162
160
  def cmd_status(args) -> int:
@@ -197,54 +195,64 @@ def cmd_status(args) -> int:
197
195
  return 0
198
196
 
199
197
 
200
- def cmd_doctor(args) -> int:
201
- from . import doctor
202
- from .bun import container
198
+ def _doctor_target(path: str | None) -> tuple[Bundle, str] | None:
199
+ """The clean bundle to test and how to label it, or ``None`` if there is none.
200
+
201
+ Matcher health is only meaningful against an unpatched bundle: our own edits
202
+ remove the very anchors the matchers look for. An explicit path is taken as
203
+ given -- that is how any kept backup becomes a regression corpus -- while the
204
+ installed binary falls back to its pristine copy when it is already patched.
205
+ """
206
+ from .bun import container # noqa: PLC0415
207
+
208
+ if path is not None:
209
+ # A name the user typed is data, not markup: an unescaped `claude[old]`
210
+ # would have rich swallow the brackets as a style tag and report health
211
+ # against a file that is not the one being tested.
212
+ name = escape(Path(path).name)
213
+ bundle = container.read(path)
214
+ if patcher.is_patched(bundle.source):
215
+ warn(f"{name} is already patched; nothing clean to test.")
216
+ console.print(" [dim]Point doctor at a pristine binary or backup.[/dim]")
217
+ return None
218
+ return bundle, name
203
219
 
204
220
  install = locate.find_or_raise()
205
- installed = container.read(str(install.binary))
206
-
207
- # Matcher health must be tested on a clean bundle. If the installed binary
208
- # is already patched, our edits have removed the anchors, so fall back to
209
- # the pristine backup.
210
- test_bundle = installed
211
- source_note = ""
212
- if patcher.is_patched(installed.source):
213
- clean = patcher.clean_source_path(install)
214
- if clean is None:
215
- warn("Installed binary is already patched and no clean backup exists.")
216
- console.print(
217
- " [dim]Matcher health can't be checked against a patched binary. "
218
- "Run `patch-cc restore`, or test a freshly downloaded binary.[/dim]"
219
- )
220
- return 1
221
- test_bundle = container.read(str(clean))
222
- source_note = (
223
- " [dim](installed binary is patched; testing against backup)[/dim]"
221
+ bundle = container.read(str(install.binary))
222
+ label = f"Claude {install.version or '?'}"
223
+ if not patcher.is_patched(bundle.source):
224
+ return bundle, label
225
+
226
+ clean = patcher.clean_source_path(install)
227
+ if clean is None:
228
+ warn("Installed binary is already patched and no clean backup exists.")
229
+ console.print(
230
+ " [dim]Matcher health can't be checked against a patched binary. "
231
+ "Run `patch-cc restore`, or test a freshly downloaded binary.[/dim]"
224
232
  )
233
+ return None
234
+ return container.read(str(clean)), (
235
+ f"{label} [dim](installed binary is patched; testing against backup)[/dim]"
236
+ )
225
237
 
238
+
239
+ def cmd_doctor(args) -> int:
240
+ from . import doctor
241
+
242
+ target = _doctor_target(args.path)
243
+ if target is None:
244
+ return 1
245
+ test_bundle, label = target
226
246
  result = doctor.dryrun(test_bundle)
227
247
 
228
- heading(f"Patch health against Claude {install.version or '?'}{source_note}")
229
- for check in result.checks:
230
- outcome = check.outcome
231
- missed = outcome.missed_steps()
232
- if outcome.landed and not missed:
233
- mark, colour = "✓", "green"
234
- elif outcome.landed:
235
- mark, colour = "~", "yellow"
236
- else:
237
- mark, colour = "✗", "red"
248
+ heading(f"Patch health against {label}")
249
+ for patch, outcome in result.results:
250
+ mark, colour = MARKS[outcome.health]
238
251
  console.print(
239
- f" [{colour}]{mark}[/{colour}] {check.patch.id:20s} "
252
+ f" [{colour}]{mark}[/{colour}] {patch.id:20s} "
240
253
  f"cand={outcome.candidates} applied={outcome.applied}"
241
254
  )
242
- for name in missed:
243
- sub = outcome.steps[name]
244
- console.print(
245
- f" [yellow]sub-step {name} missed[/yellow] "
246
- f"{'· ' + '; '.join(sub.notes) if sub.notes else ''}"
247
- )
255
+ _print_findings(outcome)
248
256
 
249
257
  agents = (
250
258
  ", ".join(f"{a.name}={a.effective_model}" for a in result.agents)
@@ -256,8 +264,8 @@ def cmd_doctor(args) -> int:
256
264
  if result.broken:
257
265
  console.print()
258
266
  warn(f"{len(result.broken)} patch(es) no longer match. Anchor counts:")
259
- for check in result.broken:
260
- anchors = result.anchors.get(check.patch.id, {})
267
+ for patch in result.broken:
268
+ anchors = result.anchors.get(patch.id, {})
261
269
  for anchor, count in anchors.items():
262
270
  colour = "red" if count == 0 else "dim"
263
271
  console.print(f" [{colour}]{count:3d}[/{colour}] {anchor}")
@@ -363,9 +371,15 @@ def build_parser() -> argparse.ArgumentParser:
363
371
  sub.add_parser(
364
372
  "status", help="show what is applied to the installed binary"
365
373
  ).set_defaults(func=cmd_status)
366
- sub.add_parser(
374
+ p_doctor = sub.add_parser(
367
375
  "doctor", help="check every patch still matches this build"
368
- ).set_defaults(func=cmd_doctor)
376
+ )
377
+ p_doctor.add_argument(
378
+ "path",
379
+ nargs="?",
380
+ help="binary to check (default: the installed one)",
381
+ )
382
+ p_doctor.set_defaults(func=cmd_doctor)
369
383
  sub.add_parser(
370
384
  "list", help="list patches, and the agents/models in your binary"
371
385
  ).set_defaults(func=cmd_list)
@@ -387,7 +401,10 @@ def main(argv: list[str] | None = None) -> int:
387
401
  args = parser.parse_args(argv)
388
402
  try:
389
403
  return args.func(args)
390
- except (FileNotFoundError, BunError) as exc:
404
+ except (OSError, BunError) as exc:
405
+ # Every path argument is a filesystem question, so the whole OSError
406
+ # family (missing, a directory, unreadable) is an answer to report --
407
+ # not a traceback. FileNotFoundError is one of them.
391
408
  err(str(exc))
392
409
  return 1
393
410
  except KeyboardInterrupt: