patch-cc 0.1.1__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.
- {patch_cc-0.1.1 → patch_cc-0.1.3}/PKG-INFO +4 -2
- {patch_cc-0.1.1 → patch_cc-0.1.3}/README.md +3 -1
- {patch_cc-0.1.1 → patch_cc-0.1.3}/docs/CONDUCT.md +3 -1
- patch_cc-0.1.3/docs/PLAYBOOK.md +366 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/pyproject.toml +1 -1
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/__init__.py +1 -1
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/cli.py +81 -64
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/doctor.py +12 -17
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/menu.py +22 -21
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/patcher.py +45 -16
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/patches/agents.py +83 -27
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/patches/base.py +83 -5
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/patches/chrome.py +14 -4
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/patches/streaming.py +80 -16
- patch_cc-0.1.3/src/patch_cc/patches/thinking.py +209 -0
- patch_cc-0.1.3/src/patch_cc/ui.py +53 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/uv.lock +1 -1
- patch_cc-0.1.1/docs/PLAYBOOK.md +0 -208
- patch_cc-0.1.1/src/patch_cc/patches/thinking.py +0 -88
- patch_cc-0.1.1/src/patch_cc/ui.py +0 -23
- {patch_cc-0.1.1 → patch_cc-0.1.3}/.github/workflows/ci.yml +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/.github/workflows/release.yml +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/.gitignore +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/LICENSE +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/docs/INTERNALS.md +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/bun/__init__.py +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/bun/blob.py +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/bun/container.py +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/bun/elf.py +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/bun/errors.py +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/bun/macho.py +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/cache.py +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/locate.py +0 -0
- {patch_cc-0.1.1 → patch_cc-0.1.3}/src/patch_cc/patches/__init__.py +0 -0
- {patch_cc-0.1.1 → 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.
|
|
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 |
|
|
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 |
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
229
|
-
for
|
|
230
|
-
|
|
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}] {
|
|
252
|
+
f" [{colour}]{mark}[/{colour}] {patch.id:20s} "
|
|
240
253
|
f"cand={outcome.candidates} applied={outcome.applied}"
|
|
241
254
|
)
|
|
242
|
-
|
|
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
|
|
260
|
-
anchors = result.anchors.get(
|
|
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
|
-
)
|
|
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 (
|
|
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:
|