patch-cc 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- patch_cc/bun/__init__.py +6 -0
- patch_cc/bun/blob.py +256 -0
- patch_cc/bun/container.py +129 -0
- patch_cc/bun/elf.py +281 -0
- patch_cc/bun/errors.py +12 -0
- patch_cc/bun/macho.py +124 -0
- patch_cc/cache.py +86 -0
- patch_cc/cli.py +399 -0
- patch_cc/doctor.py +116 -0
- patch_cc/locate.py +117 -0
- patch_cc/menu.py +1135 -0
- patch_cc/patcher.py +244 -0
- patch_cc/patches/__init__.py +75 -0
- patch_cc/patches/agents.py +289 -0
- patch_cc/patches/base.py +200 -0
- patch_cc/patches/chrome.py +187 -0
- patch_cc/patches/output.py +173 -0
- patch_cc/patches/streaming.py +922 -0
- patch_cc/patches/thinking.py +88 -0
- patch_cc/ui.py +23 -0
- patch_cc-0.1.0.dist-info/METADATA +141 -0
- patch_cc-0.1.0.dist-info/RECORD +26 -0
- patch_cc-0.1.0.dist-info/WHEEL +4 -0
- patch_cc-0.1.0.dist-info/entry_points.txt +2 -0
- patch_cc-0.1.0.dist-info/licenses/LICENSE +21 -0
patch_cc/patcher.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Orchestration: read a binary, run selected patches, write it back safely."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__, locate
|
|
13
|
+
from .bun import Bundle, container
|
|
14
|
+
from .patches import ALL_PATCHES, DEFAULT_SUFFIX, SENTINEL, Options, Outcome, Patch
|
|
15
|
+
|
|
16
|
+
#: Every patched bundle ends with one comment line recording exactly what was
|
|
17
|
+
#: applied. Comments cannot collide with code, survive re-extraction, and make
|
|
18
|
+
#: ``status`` a parse instead of a guess -- value-flip patches leave no other
|
|
19
|
+
#: fingerprint.
|
|
20
|
+
MANIFEST_PREFIX = "//patch-cc "
|
|
21
|
+
|
|
22
|
+
#: Fingerprints of binaries patched by versions before the manifest existed.
|
|
23
|
+
_LEGACY_MARKER = "(Claude Code)\\n" + DEFAULT_SUFFIX
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AlreadyPatchedError(RuntimeError):
|
|
27
|
+
"""The only source available is already patched; patching it would stack.
|
|
28
|
+
|
|
29
|
+
Our edits change lengths, so a second pass over a patched bundle corrupts
|
|
30
|
+
rather than updates. There is deliberately no force-override: when no
|
|
31
|
+
pristine backup exists the only honest fixes are ``restore`` or a
|
|
32
|
+
reinstall.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class PatchReport:
|
|
38
|
+
version: str | None
|
|
39
|
+
kind: str
|
|
40
|
+
original_size: int
|
|
41
|
+
patched_size: int = 0
|
|
42
|
+
results: list[tuple[Patch, Outcome]] = field(default_factory=list)
|
|
43
|
+
backup: Path | None = None
|
|
44
|
+
output: Path | None = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def landed_ids(self) -> list[str]:
|
|
48
|
+
return [p.id for p, o in self.results if o.landed]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def regressions(self) -> list[Patch]:
|
|
52
|
+
"""Selected patches that were expected to change something but did not."""
|
|
53
|
+
return [p for p, o in self.results if not o.landed]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def partial(self) -> list[tuple[Patch, list[str]]]:
|
|
57
|
+
"""Patches that landed but had some sub-step miss."""
|
|
58
|
+
out = []
|
|
59
|
+
for patch, outcome in self.results:
|
|
60
|
+
missed = outcome.missed_steps()
|
|
61
|
+
if outcome.landed and missed:
|
|
62
|
+
out.append((patch, missed))
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_manifest(landed: list[str], options: Options) -> str:
|
|
67
|
+
payload: dict = {"v": 1, "tool": __version__, "patches": landed}
|
|
68
|
+
if options.rebrands:
|
|
69
|
+
payload["brand"] = options.brand
|
|
70
|
+
if options.version_suffix != DEFAULT_SUFFIX:
|
|
71
|
+
payload["suffix"] = options.version_suffix
|
|
72
|
+
if options.subagent_models:
|
|
73
|
+
payload["models"] = options.subagent_models
|
|
74
|
+
return "\n" + MANIFEST_PREFIX + json.dumps(payload, separators=(",", ":")) + "\n"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_manifest(source: str) -> dict | None:
|
|
78
|
+
"""The applied-patch record, or ``None`` for pristine/legacy binaries."""
|
|
79
|
+
start = source.rfind("\n" + MANIFEST_PREFIX)
|
|
80
|
+
if start == -1:
|
|
81
|
+
return None
|
|
82
|
+
start += 1 + len(MANIFEST_PREFIX)
|
|
83
|
+
end = source.find("\n", start)
|
|
84
|
+
line = source[start:] if end == -1 else source[start:end]
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(line)
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
return None
|
|
89
|
+
return data if isinstance(data, dict) else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_patched(source: str) -> bool:
|
|
93
|
+
return (
|
|
94
|
+
("\n" + MANIFEST_PREFIX) in source
|
|
95
|
+
or SENTINEL in source
|
|
96
|
+
or _LEGACY_MARKER in source
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def selected_patches(ids: list[str]) -> list[Patch]:
|
|
101
|
+
"""Resolve ids to patches, preserving registry (run) order."""
|
|
102
|
+
wanted = set(ids)
|
|
103
|
+
return [patch for patch in ALL_PATCHES if patch.id in wanted]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_patches(
|
|
107
|
+
source: str, patches: list[Patch], options: Options
|
|
108
|
+
) -> tuple[str, list[tuple[Patch, Outcome]]]:
|
|
109
|
+
results: list[tuple[Patch, Outcome]] = []
|
|
110
|
+
current = source
|
|
111
|
+
for patch in patches:
|
|
112
|
+
current, outcome = patch.run(current, options)
|
|
113
|
+
results.append((patch, outcome))
|
|
114
|
+
return current, results
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _backup_dir() -> Path:
|
|
118
|
+
base = os.environ.get("XDG_DATA_HOME")
|
|
119
|
+
root = Path(base) if base else Path.home() / ".local" / "share"
|
|
120
|
+
return root / "patch-cc" / "backups"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def backup_path_for(install: locate.Installation) -> Path:
|
|
124
|
+
"""The single source of truth for where a binary's backup lives.
|
|
125
|
+
|
|
126
|
+
Canonical native installs are version-named, giving a clean
|
|
127
|
+
``<name>.<version>.orig``. When the name is not a version we cannot tell two
|
|
128
|
+
unrelated ``claude`` binaries apart by name alone, so a short hash of the
|
|
129
|
+
absolute path is mixed in to keep their backups distinct.
|
|
130
|
+
"""
|
|
131
|
+
root = _backup_dir()
|
|
132
|
+
if install.version:
|
|
133
|
+
stem = f"{install.binary.name}.{install.version}"
|
|
134
|
+
else:
|
|
135
|
+
digest = hashlib.sha256(str(install.binary.resolve()).encode()).hexdigest()[:8]
|
|
136
|
+
stem = f"{install.binary.name}.unknown-{digest}"
|
|
137
|
+
return root / f"{stem}.orig"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def read_pristine(install: locate.Installation) -> Bundle:
|
|
141
|
+
"""The bundle patching starts from: the backup when one exists.
|
|
142
|
+
|
|
143
|
+
Patching never stacks edits on edits -- each apply begins at this pristine
|
|
144
|
+
source, so the selected set is always exactly what ends up in the binary.
|
|
145
|
+
"""
|
|
146
|
+
backup = backup_path_for(install)
|
|
147
|
+
return container.read(str(backup if backup.exists() else install.binary))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _backup(install: locate.Installation, *, pristine: bool) -> Path | None:
|
|
151
|
+
"""Record the pristine original once, so ``restore`` is a plain copy back.
|
|
152
|
+
|
|
153
|
+
Only ever captures a binary that is actually unpatched: backing up an
|
|
154
|
+
already-marked binary would enshrine a poisoned "original" that ``restore``
|
|
155
|
+
would later hand back as clean.
|
|
156
|
+
"""
|
|
157
|
+
dest = backup_path_for(install)
|
|
158
|
+
if dest.exists():
|
|
159
|
+
return dest
|
|
160
|
+
if not pristine:
|
|
161
|
+
return None
|
|
162
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
shutil.copy2(install.binary, dest)
|
|
164
|
+
return dest
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def patch_installation(
|
|
168
|
+
install: locate.Installation,
|
|
169
|
+
selected: list[str],
|
|
170
|
+
options: Options,
|
|
171
|
+
*,
|
|
172
|
+
bundle: Bundle | None = None,
|
|
173
|
+
out_path: Path | None = None,
|
|
174
|
+
make_backup: bool = True,
|
|
175
|
+
) -> PatchReport:
|
|
176
|
+
"""Patch ``install`` (or write to ``out_path``) with the ``selected`` patches.
|
|
177
|
+
|
|
178
|
+
Patching always starts from a pristine source (:func:`read_pristine`), so
|
|
179
|
+
re-applying replaces the previous patch set instead of stacking on it.
|
|
180
|
+
``bundle`` may carry that already-read source (the CLI reads it for
|
|
181
|
+
validation first).
|
|
182
|
+
"""
|
|
183
|
+
source = bundle if bundle is not None else read_pristine(install)
|
|
184
|
+
if is_patched(source.source):
|
|
185
|
+
raise AlreadyPatchedError(
|
|
186
|
+
f"{install.binary} is already patched and no pristine backup exists, "
|
|
187
|
+
"so there is nothing clean to patch from. Run `patch-cc restore`, "
|
|
188
|
+
"or reinstall Claude to get a clean binary."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
patches = selected_patches(selected)
|
|
192
|
+
patched_source, results = run_patches(source.source, patches, options)
|
|
193
|
+
|
|
194
|
+
report = PatchReport(
|
|
195
|
+
version=install.version,
|
|
196
|
+
kind=source.kind,
|
|
197
|
+
original_size=source.binary_size,
|
|
198
|
+
results=results,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
landed = report.landed_ids
|
|
202
|
+
if not landed:
|
|
203
|
+
# Nothing changed; writing would only strip bytecode for no benefit.
|
|
204
|
+
return report
|
|
205
|
+
|
|
206
|
+
patched_source += build_manifest(landed, options)
|
|
207
|
+
|
|
208
|
+
target = out_path or install.binary
|
|
209
|
+
if make_backup and out_path is None:
|
|
210
|
+
report.backup = _backup(install, pristine=not is_patched(source.source))
|
|
211
|
+
|
|
212
|
+
container.write(source, patched_source, str(target))
|
|
213
|
+
report.output = Path(target)
|
|
214
|
+
report.patched_size = Path(target).stat().st_size
|
|
215
|
+
return report
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def clean_source_path(install: locate.Installation) -> Path | None:
|
|
219
|
+
"""A binary whose bundle is guaranteed unpatched, for matcher-health tests.
|
|
220
|
+
|
|
221
|
+
If the installed binary is already patched, our own edits have removed the
|
|
222
|
+
anchors the matchers look for, so a dry-run against it conflates
|
|
223
|
+
"already applied" with "anchor gone". The pristine backup is the honest
|
|
224
|
+
thing to test against.
|
|
225
|
+
"""
|
|
226
|
+
backup = backup_path_for(install)
|
|
227
|
+
return backup if backup.exists() else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def restore(install: locate.Installation) -> Path:
|
|
231
|
+
"""Copy the pristine backup back over the installed binary."""
|
|
232
|
+
backup = backup_path_for(install)
|
|
233
|
+
if not backup.exists():
|
|
234
|
+
raise FileNotFoundError(
|
|
235
|
+
f"No backup found for {install.binary.name} "
|
|
236
|
+
f"{install.version or '(unknown version)'} at {backup}. "
|
|
237
|
+
"If Claude auto-updated, the original for this version was never saved -- "
|
|
238
|
+
"reinstall to get a clean binary."
|
|
239
|
+
)
|
|
240
|
+
# A full-file copy-back, so it works for both ELF and Mach-O.
|
|
241
|
+
from .bun.elf import atomic_write # noqa: PLC0415
|
|
242
|
+
|
|
243
|
+
atomic_write(str(install.binary), backup.read_bytes(), mode_from=str(backup))
|
|
244
|
+
return install.binary
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The patch registry.
|
|
2
|
+
|
|
3
|
+
Patches run in registration order and each sees the previous one's output.
|
|
4
|
+
The order below is the upstream order; do not reorder casually -- some patches
|
|
5
|
+
depend on regions an earlier one leaves untouched.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import agents, chrome, output, streaming, thinking
|
|
11
|
+
from .base import (
|
|
12
|
+
DEFAULT_BRAND,
|
|
13
|
+
DEFAULT_SUFFIX,
|
|
14
|
+
GROUP_AGENTS,
|
|
15
|
+
GROUP_CHROME,
|
|
16
|
+
GROUP_LIVE,
|
|
17
|
+
GROUP_OUTPUT,
|
|
18
|
+
GROUP_THINKING,
|
|
19
|
+
SENTINEL,
|
|
20
|
+
Options,
|
|
21
|
+
Outcome,
|
|
22
|
+
Patch,
|
|
23
|
+
derived_brand,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
ALL_PATCHES: list[Patch] = [
|
|
27
|
+
*output.PATCHES,
|
|
28
|
+
*thinking.PATCHES,
|
|
29
|
+
*streaming.PATCHES,
|
|
30
|
+
*agents.PATCHES,
|
|
31
|
+
*chrome.PATCHES,
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
GROUP_ORDER = [GROUP_OUTPUT, GROUP_THINKING, GROUP_LIVE, GROUP_AGENTS, GROUP_CHROME]
|
|
35
|
+
|
|
36
|
+
_BY_ID = {patch.id: patch for patch in ALL_PATCHES}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get(patch_id: str) -> Patch:
|
|
40
|
+
try:
|
|
41
|
+
return _BY_ID[patch_id]
|
|
42
|
+
except KeyError:
|
|
43
|
+
raise KeyError(f"unknown patch id: {patch_id}") from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ids() -> list[str]:
|
|
47
|
+
return [patch.id for patch in ALL_PATCHES]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_ids() -> list[str]:
|
|
51
|
+
return [patch.id for patch in ALL_PATCHES if patch.default]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def by_group() -> dict[str, list[Patch]]:
|
|
55
|
+
grouped: dict[str, list[Patch]] = {group: [] for group in GROUP_ORDER}
|
|
56
|
+
for patch in ALL_PATCHES:
|
|
57
|
+
grouped.setdefault(patch.group, []).append(patch)
|
|
58
|
+
return grouped
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"ALL_PATCHES",
|
|
63
|
+
"DEFAULT_BRAND",
|
|
64
|
+
"DEFAULT_SUFFIX",
|
|
65
|
+
"GROUP_ORDER",
|
|
66
|
+
"Options",
|
|
67
|
+
"Outcome",
|
|
68
|
+
"Patch",
|
|
69
|
+
"SENTINEL",
|
|
70
|
+
"derived_brand",
|
|
71
|
+
"get",
|
|
72
|
+
"ids",
|
|
73
|
+
"default_ids",
|
|
74
|
+
"by_group",
|
|
75
|
+
]
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Subagent patches: prompt visibility, and overriding built-in models.
|
|
2
|
+
|
|
3
|
+
Everything the model override offers is discovered from the bundle itself:
|
|
4
|
+
|
|
5
|
+
* **Agents** come from the built-in definition shape
|
|
6
|
+
``agentType:"<name>",whenToUse:...`` carrying ``source:"built-in"``.
|
|
7
|
+
* **Models** come from the Task tool's own input schema -- the
|
|
8
|
+
``model:enum([...])`` whose describe-string starts "Optional model override".
|
|
9
|
+
|
|
10
|
+
So a new upstream agent or model shows up here without a code change, and we
|
|
11
|
+
can never offer a name the binary would reject.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from .base import GROUP_AGENTS, IDENT, Options, Outcome, Patch, compile_js, splice
|
|
20
|
+
|
|
21
|
+
# ------------------------------------------------------- prompt visibility
|
|
22
|
+
|
|
23
|
+
_BACKGROUNDED = '"Backgrounded agent"'
|
|
24
|
+
_LIVE_PROMPT_MOUNT = compile_js(
|
|
25
|
+
rf"({IDENT})&&({IDENT})&&({IDENT})\.createElement\(m,\{{marginBottom:1\}},"
|
|
26
|
+
rf"\3\.createElement\(({IDENT}),\{{prompt:\2\}}\)\)"
|
|
27
|
+
)
|
|
28
|
+
_EMPTY_STATE = compile_js(
|
|
29
|
+
rf"if\(({IDENT})\.length===0&&!?\(({IDENT})&&({IDENT})\)\)return"
|
|
30
|
+
)
|
|
31
|
+
_TRANSCRIPT_MODE = compile_js(rf"isTranscriptMode:({IDENT})=!1")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _subagent_prompt(content: str, _options: Options, outcome: Outcome) -> str:
|
|
35
|
+
"""Show the subagent ``Prompt`` block outside transcript mode."""
|
|
36
|
+
gate = outcome.step("gate")
|
|
37
|
+
output = content
|
|
38
|
+
index = 0
|
|
39
|
+
|
|
40
|
+
while True:
|
|
41
|
+
anchor = output.find(_BACKGROUNDED, index)
|
|
42
|
+
if anchor == -1:
|
|
43
|
+
break
|
|
44
|
+
fn_start = output.rfind("function ", 0, anchor)
|
|
45
|
+
fn_end_candidate = output.find("function ", anchor + len(_BACKGROUNDED))
|
|
46
|
+
fn_end = len(output) if fn_end_candidate == -1 else fn_end_candidate
|
|
47
|
+
index = anchor + len(_BACKGROUNDED)
|
|
48
|
+
if fn_start == -1 or fn_end <= fn_start:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
segment = output[fn_start:fn_end]
|
|
52
|
+
relevant = (
|
|
53
|
+
'action:"app:toggleTranscript"' in segment
|
|
54
|
+
and 'fallback:"ctrl+o"' in segment
|
|
55
|
+
and "isTranscriptMode:" in segment
|
|
56
|
+
and "{prompt:" in segment
|
|
57
|
+
and ",theme:" in segment
|
|
58
|
+
)
|
|
59
|
+
if not relevant:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
transcript = _TRANSCRIPT_MODE.search(segment)
|
|
63
|
+
if not transcript:
|
|
64
|
+
continue
|
|
65
|
+
transcript_var = transcript.group(1)
|
|
66
|
+
gate_pattern = compile_js(rf"{re.escape(transcript_var)}&&({IDENT})&&")
|
|
67
|
+
|
|
68
|
+
def drop_gate(match: re.Match[str]) -> str:
|
|
69
|
+
prompt_var = match.group(1)
|
|
70
|
+
nearby = segment[match.end() : match.end() + 260]
|
|
71
|
+
if f"{{prompt:{prompt_var},theme:" not in nearby:
|
|
72
|
+
return match.group(0)
|
|
73
|
+
gate.candidates += 1
|
|
74
|
+
gate.applied += 1
|
|
75
|
+
return f"{prompt_var}&&"
|
|
76
|
+
|
|
77
|
+
next_segment = gate_pattern.sub(drop_gate, segment)
|
|
78
|
+
if next_segment != segment:
|
|
79
|
+
output = splice(output, fn_start, fn_end, next_segment)
|
|
80
|
+
index = fn_start + len(next_segment)
|
|
81
|
+
|
|
82
|
+
mount = outcome.step("mount")
|
|
83
|
+
|
|
84
|
+
def rewrite_mount(match: re.Match[str]) -> str:
|
|
85
|
+
_transcript, prompt_var, ns, component = match.groups()
|
|
86
|
+
mount.candidates += 1
|
|
87
|
+
replacement = (
|
|
88
|
+
f"{prompt_var}&&{ns}.createElement(m,{{marginBottom:1}},"
|
|
89
|
+
f"{ns}.createElement({component},{{prompt:{prompt_var}}}))"
|
|
90
|
+
)
|
|
91
|
+
if replacement != match.group(0):
|
|
92
|
+
mount.applied += 1
|
|
93
|
+
return replacement
|
|
94
|
+
|
|
95
|
+
output = _LIVE_PROMPT_MOUNT.sub(rewrite_mount, output)
|
|
96
|
+
|
|
97
|
+
empty = outcome.step("empty-state")
|
|
98
|
+
|
|
99
|
+
def rewrite_empty(match: re.Match[str]) -> str:
|
|
100
|
+
rows, _transcript, prompt_var = match.groups()
|
|
101
|
+
empty.candidates += 1
|
|
102
|
+
replacement = f"if({rows}.length===0&&!{prompt_var})return"
|
|
103
|
+
if replacement != match.group(0):
|
|
104
|
+
empty.applied += 1
|
|
105
|
+
return replacement
|
|
106
|
+
|
|
107
|
+
return _EMPTY_STATE.sub(rewrite_empty, output)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ----------------------------------------------------------- discovery
|
|
111
|
+
|
|
112
|
+
#: Always offered besides the discovered aliases: keep the agent on whatever
|
|
113
|
+
#: the main loop runs.
|
|
114
|
+
INHERIT = "inherit"
|
|
115
|
+
|
|
116
|
+
_AGENT_DEF = compile_js(r'agentType:"([\w-]+)",whenToUse:')
|
|
117
|
+
_MODEL_FIELD = compile_js(r'model:"([\w\[\].-]+)"')
|
|
118
|
+
#: A definition object is scanned at most this far; every known definition fits
|
|
119
|
+
#: well within it, and the cap keeps a moved anchor from swallowing a neighbour.
|
|
120
|
+
_DEF_WINDOW = 3000
|
|
121
|
+
|
|
122
|
+
_MODEL_ENUM = compile_js(
|
|
123
|
+
rf'model:{IDENT}\.enum\(\[((?:"[\w\[\]]+",?)+)\]\)\.optional\(\)'
|
|
124
|
+
rf'\.describe\([`"]Optional model override'
|
|
125
|
+
)
|
|
126
|
+
#: Used only if the Task-tool schema anchor ever disappears.
|
|
127
|
+
_FALLBACK_MODELS = ("haiku", "sonnet", "opus")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(slots=True, frozen=True)
|
|
131
|
+
class BuiltinAgent:
|
|
132
|
+
"""One built-in agent definition as found in a bundle."""
|
|
133
|
+
|
|
134
|
+
name: str
|
|
135
|
+
#: Current ``model:"..."`` literal, or ``None`` when the definition has no
|
|
136
|
+
#: model field (which the runtime treats as inherit).
|
|
137
|
+
model: str | None
|
|
138
|
+
#: Offset of the definition anchor in the scanned source.
|
|
139
|
+
start: int
|
|
140
|
+
#: Offset of the model *value* inside the source, ``-1`` when absent.
|
|
141
|
+
model_start: int
|
|
142
|
+
#: Where a ``model:"...",`` property would be inserted.
|
|
143
|
+
insert_at: int
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def effective_model(self) -> str:
|
|
147
|
+
return self.model or INHERIT
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def discover_agents(source: str) -> list[BuiltinAgent]:
|
|
151
|
+
"""Built-in agent definitions as they exist in *this* bundle.
|
|
152
|
+
|
|
153
|
+
Definitions marked internal (their ``whenToUse`` says so) are not offered:
|
|
154
|
+
they are orchestration plumbing, not agents a user chooses.
|
|
155
|
+
"""
|
|
156
|
+
agents: list[BuiltinAgent] = []
|
|
157
|
+
seen: set[str] = set()
|
|
158
|
+
for match in _AGENT_DEF.finditer(source):
|
|
159
|
+
name = match.group(1)
|
|
160
|
+
window = source[match.start() : match.start() + _DEF_WINDOW]
|
|
161
|
+
stop = window.find("getSystemPrompt:")
|
|
162
|
+
span = window if stop == -1 else window[:stop]
|
|
163
|
+
if 'source:"built-in"' not in span or 'whenToUse:"Internal' in span:
|
|
164
|
+
continue
|
|
165
|
+
if name in seen:
|
|
166
|
+
continue
|
|
167
|
+
seen.add(name)
|
|
168
|
+
field = _MODEL_FIELD.search(span)
|
|
169
|
+
agents.append(
|
|
170
|
+
BuiltinAgent(
|
|
171
|
+
name=name,
|
|
172
|
+
model=field.group(1) if field else None,
|
|
173
|
+
start=match.start(),
|
|
174
|
+
model_start=match.start() + field.start(1) if field else -1,
|
|
175
|
+
insert_at=match.end() - len("whenToUse:"),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
return agents
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def discover_models(source: str) -> list[str]:
|
|
182
|
+
"""Model aliases the binary's own Task tool accepts for subagents."""
|
|
183
|
+
match = _MODEL_ENUM.search(source)
|
|
184
|
+
if not match:
|
|
185
|
+
return list(_FALLBACK_MODELS)
|
|
186
|
+
return re.findall(r'"([\w\[\]]+)"', match.group(1))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --------------------------------------------------------- model overrides
|
|
190
|
+
|
|
191
|
+
# One helper resolves a built-in agent's default model and, for exactly one
|
|
192
|
+
# agent (Explore today), ignores the definition's model field in favour of its
|
|
193
|
+
# own pin. Overriding that agent means neutralising this bypass so the
|
|
194
|
+
# definition -- which we just rewrote -- is authoritative again.
|
|
195
|
+
_MODEL_BYPASS = compile_js(
|
|
196
|
+
rf"function ({IDENT})\(({IDENT}),({IDENT})\)\{{"
|
|
197
|
+
rf'if\(\2\.agentType!==({IDENT})\.agentType\|\|\2\.source!=="built-in"\)return \2\.model;'
|
|
198
|
+
rf'return {IDENT}\(\3\)\?{IDENT}:"inherit"\}}'
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def bypassed_agent(source: str) -> str | None:
|
|
203
|
+
"""Name of the agent whose definition the bypass helper overrides, if any."""
|
|
204
|
+
match = _MODEL_BYPASS.search(source)
|
|
205
|
+
if not match:
|
|
206
|
+
return None
|
|
207
|
+
def_var = re.escape(match.group(4))
|
|
208
|
+
assign = compile_js(rf'(?<![\w$]){def_var}=\{{agentType:"([\w-]+)"').search(source)
|
|
209
|
+
return assign.group(1) if assign else None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _neutralize_bypass(content: str, step: Outcome) -> str:
|
|
213
|
+
def rewrite(match: re.Match[str]) -> str:
|
|
214
|
+
step.candidates += 1
|
|
215
|
+
step.applied += 1
|
|
216
|
+
name, obj, model = match.group(1, 2, 3)
|
|
217
|
+
return f"function {name}({obj},{model}){{return {obj}.model}}"
|
|
218
|
+
|
|
219
|
+
output = _MODEL_BYPASS.sub(rewrite, content, count=1)
|
|
220
|
+
if step.candidates == 0:
|
|
221
|
+
step.note(
|
|
222
|
+
"model-bypass helper not found; the pinned agent may keep its own default"
|
|
223
|
+
)
|
|
224
|
+
return output
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _subagent_models(content: str, options: Options, outcome: Outcome) -> str:
|
|
228
|
+
"""Write the chosen model into each overridden built-in definition.
|
|
229
|
+
|
|
230
|
+
Definitions with a ``model:"..."`` literal get it rewritten; definitions
|
|
231
|
+
without one get it inserted. Both target offsets from a fresh discovery
|
|
232
|
+
pass, so this never guesses about the bytes between anchor and value.
|
|
233
|
+
"""
|
|
234
|
+
if not options.subagent_models:
|
|
235
|
+
outcome.note("no subagent model overrides configured")
|
|
236
|
+
return content
|
|
237
|
+
|
|
238
|
+
output = content
|
|
239
|
+
offered = {INHERIT, *discover_models(output)}
|
|
240
|
+
|
|
241
|
+
for agent, target in sorted(options.subagent_models.items()):
|
|
242
|
+
step = outcome.step(agent)
|
|
243
|
+
if target not in offered:
|
|
244
|
+
step.note(f"model {target!r} not offered by this bundle; skipped")
|
|
245
|
+
continue
|
|
246
|
+
info = next((a for a in discover_agents(output) if a.name == agent), None)
|
|
247
|
+
if info is None:
|
|
248
|
+
step.note(f"no built-in agent {agent!r} in this bundle; skipped")
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
step.candidates += 1
|
|
252
|
+
if info.effective_model == target:
|
|
253
|
+
continue # already the desired model
|
|
254
|
+
if info.model is None:
|
|
255
|
+
output = splice(
|
|
256
|
+
output, info.insert_at, info.insert_at, f'model:"{target}",'
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
output = splice(
|
|
260
|
+
output, info.model_start, info.model_start + len(info.model), target
|
|
261
|
+
)
|
|
262
|
+
step.applied += 1
|
|
263
|
+
|
|
264
|
+
pinned = bypassed_agent(output)
|
|
265
|
+
if pinned is not None and pinned in options.subagent_models:
|
|
266
|
+
output = _neutralize_bypass(output, outcome.step("model-bypass"))
|
|
267
|
+
|
|
268
|
+
return output
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
PATCHES = [
|
|
272
|
+
Patch(
|
|
273
|
+
id="subagent-prompt",
|
|
274
|
+
title="Show subagent prompts",
|
|
275
|
+
summary="Show a subagent's Prompt block during normal use, not only in transcript mode.",
|
|
276
|
+
group=GROUP_AGENTS,
|
|
277
|
+
fn=_subagent_prompt,
|
|
278
|
+
anchors=('"Backgrounded agent"', 'action:"app:toggleTranscript"'),
|
|
279
|
+
),
|
|
280
|
+
Patch(
|
|
281
|
+
id="subagent-models",
|
|
282
|
+
title="Override subagent models",
|
|
283
|
+
summary="Choose the default model for the built-in agents found in your binary.",
|
|
284
|
+
group=GROUP_AGENTS,
|
|
285
|
+
fn=_subagent_models,
|
|
286
|
+
default=False,
|
|
287
|
+
anchors=('agentType:"', "Optional model override"),
|
|
288
|
+
),
|
|
289
|
+
]
|