one-code-extension 0.1.0
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.
- package/LICENSE +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
# Auto mode
|
|
2
|
+
|
|
3
|
+
Part of [Decisions](../decisions.md).
|
|
4
|
+
|
|
5
|
+
## Auto mode: a deterministic pre-gate in front of an LLM classifier
|
|
6
|
+
|
|
7
|
+
Claude Code's auto mode replaces per-action prompts with a classifier that
|
|
8
|
+
blocks anything irreversible, destructive, or aimed outside your environment.
|
|
9
|
+
The harness-discipline entry in
|
|
10
|
+
[`tools.md`](tools.md#harness-discipline-divergences-found-by-self-comparison)
|
|
11
|
+
once recorded it as deliberately unimplemented — no equivalent classifier
|
|
12
|
+
existed here. This entry supersedes that: one exists now,
|
|
13
|
+
built from two pieces that fail in different directions.
|
|
14
|
+
|
|
15
|
+
**The inverted contract, which is the whole design.** The deterministic half is
|
|
16
|
+
ported from the MI Copilot shell sandbox (a hand-rolled POSIX tokenizer plus
|
|
17
|
+
name-based command/path lists). In that codebase it is the *only* gate in Edit
|
|
18
|
+
mode, so every tokenizer gap is an exploitable bypass — its security review
|
|
19
|
+
found 80 confirmed findings, and the systemic root causes are all variations of
|
|
20
|
+
"a list drifted" or "the parser did not see it". Porting it as-is would import
|
|
21
|
+
all of that.
|
|
22
|
+
|
|
23
|
+
So its contract is inverted here: **`shell-analysis.ts` may only ever conclude
|
|
24
|
+
"provably safe", never "unsafe".** It sits in front of the classifier, which
|
|
25
|
+
sits in front of a user prompt, so an unrecognised command, a parse failure, an
|
|
26
|
+
unresolvable path, or syntax it does not model escalates instead of passing. A
|
|
27
|
+
gap now costs one classifier call rather than being a bypass, which converts the
|
|
28
|
+
review's entire tokenizer-gap class from vulnerabilities into latency. The
|
|
29
|
+
`CLAUDE.md` rule "never add a deny path to shell-analysis.ts" exists to keep
|
|
30
|
+
that property from being eroded by a well-meaning later change.
|
|
31
|
+
|
|
32
|
+
Its second job turned out to matter as much as the fast path: it extracts
|
|
33
|
+
**deterministic evidence** — the real command behind any wrapper, resolved write
|
|
34
|
+
targets with containment already decided, credential paths, paths whose contents
|
|
35
|
+
execute later — and hands that to the classifier. Parsing shell is what an LLM
|
|
36
|
+
classifier is worst at, so giving it facts instead of a command string is the
|
|
37
|
+
actual synergy between the halves, not just an optimisation.
|
|
38
|
+
|
|
39
|
+
**Review findings fixed while porting**, all now covered by tests that name the
|
|
40
|
+
finding: N1 (`$'…'` ANSI-C quoting, which upstream left a spurious `$` on so
|
|
41
|
+
path checks never ran), N2 (bare `.`/`..`), N3 (brace expansion), N4 (unspaced
|
|
42
|
+
`<>&|` boundaries), N5 (transparent wrappers — `env rm -rf ~/Desktop` classified
|
|
43
|
+
as a harmless `env`), N6 (tar/rsync/zip), N7 (`find -ok`/`-fprintf`), N8 (script
|
|
44
|
+
interpreters), N9 (dangling-symlink leaf: `ln -s /outside x` then `echo > x`
|
|
45
|
+
resolved inside the project while bash wrote outside), N10 (`>|`), N11 (git
|
|
46
|
+
default-deny by subcommand instead of enumerating mutations, so `git rm`/`mv`/
|
|
47
|
+
`archive`/`config` no longer slip through), N12/F6 (`cd` tracked; every token
|
|
48
|
+
checked against the credential list, not just path-shaped ones), N13/F3/F10 (one
|
|
49
|
+
shared case-folded denylist module instead of three that drifted), N18 (messages
|
|
50
|
+
quote the original token, never an expanded value — upstream leaked a token
|
|
51
|
+
through a *block* message), N20 (`.git/hooks`, `.git/config`, `mvnw` flagged
|
|
52
|
+
even in-project), N23 (`/proc`, `/sys`), F1 (`git -c` escalates).
|
|
53
|
+
|
|
54
|
+
**Config faithfully follows Claude Code**: prose `environment`/`allow`/
|
|
55
|
+
`soft_deny`/`hard_deny` lists with `"$defaults"` splicing, `classifyAllShell`,
|
|
56
|
+
the four-tier precedence (hard_deny > soft_deny > allow > explicit intent), and
|
|
57
|
+
the pause after 3 consecutive or 20 total blocks. Two properties are load-bearing
|
|
58
|
+
rather than cosmetic: `autoMode` is read from user and managed settings only —
|
|
59
|
+
never the project's, or a checked-in file could grant itself allow rules and
|
|
60
|
+
disable the gate containing it — and only pi's `input` event feeds the intent
|
|
61
|
+
tier, so intent cannot be manufactured by a prompt injection in a file the agent
|
|
62
|
+
read. Broad execution allow rules (`Bash`, `Bash(*)`) are suspended in auto mode
|
|
63
|
+
for the same reason: one would be a standing bypass.
|
|
64
|
+
|
|
65
|
+
**The classifier is a one-shot `completeSimple`** via `@earendil-works/pi-ai/compat`
|
|
66
|
+
with credentials from `ctx.modelRegistry.getApiKeyAndHeaders` — no tools, no
|
|
67
|
+
session, no history beyond the user messages handed to it, so there is nothing
|
|
68
|
+
for it to be talked into doing. Every failure path (no model, no credentials,
|
|
69
|
+
timeout, provider error, unparseable reply) returns a *block*; a gate that
|
|
70
|
+
cannot reach its classifier has approved nothing.
|
|
71
|
+
|
|
72
|
+
**Two bugs the live runs caught, both invisible to unit tests.** First,
|
|
73
|
+
`reasoning: "minimal"` with `maxTokens: 512` derives a thinking budget under
|
|
74
|
+
Anthropic's 1024-token floor, so every classifier request 400d — and the gate
|
|
75
|
+
dutifully reported each one as a block. It failed closed, correctly, and was
|
|
76
|
+
also completely broken. Omitting `reasoning` disables thinking outright
|
|
77
|
+
(`streamSimple` checks `!options?.reasoning`), which is what a classifier wants
|
|
78
|
+
anyway. Second, containment compared a realpath'd write target against an
|
|
79
|
+
unresolved working directory; on macOS `/var/folders/…` is a symlink to
|
|
80
|
+
`/private/var/…`, so every in-project write read as an escape.
|
|
81
|
+
|
|
82
|
+
**Calibration was verified in both directions against a live model**, which is
|
|
83
|
+
the only way to tell an over-blocking gate from a working one: routine in-project
|
|
84
|
+
work (mkdir, write, append, `git add`, `git commit`) ran unprompted; `echo hello >
|
|
85
|
+
~/probe.txt` was blocked when the user had not named that path, and allowed when
|
|
86
|
+
they had; "back up the project somewhere outside it, pick a location" was blocked
|
|
87
|
+
with "user did not name the backup path". Reaching that took two fixes — a
|
|
88
|
+
soft_deny rule worded as "irreversible *deletion*" was being stretched to cover a
|
|
89
|
+
file *creation*, and the intent tier needed a worked example before the
|
|
90
|
+
classifier would actually apply it. An auto mode that blocks what the user
|
|
91
|
+
plainly asked for gets switched off, so that tier failing quietly is as much a
|
|
92
|
+
defect as a missed block.
|
|
93
|
+
|
|
94
|
+
## Auto-mode parity pass against the published Claude Code behaviour
|
|
95
|
+
|
|
96
|
+
After the first implementation, the auto-mode docs were read line by line against
|
|
97
|
+
what had been built. Two things converged independently and are worth recording as
|
|
98
|
+
confirmation rather than coincidence: Claude Code's decision order is the same four
|
|
99
|
+
steps (rules resolve → reads and working-directory edits auto-approve → everything
|
|
100
|
+
else classified → a block returns its reason to the model), and it also **strips tool
|
|
101
|
+
results from the classifier's view** "so hostile content in a file or web page cannot
|
|
102
|
+
manipulate it directly" — the same isolation reached here by feeding the intent tier
|
|
103
|
+
only from pi's `input` event.
|
|
104
|
+
|
|
105
|
+
Seven divergences were found and fixed:
|
|
106
|
+
|
|
107
|
+
1. **The pause total counter never reset.** Claude Code resets it when it is the
|
|
108
|
+
counter that triggered the fallback; without that, resuming from a 20-block pause
|
|
109
|
+
leaves `total` at the limit and the next single block re-pauses immediately, making
|
|
110
|
+
the resume single-use. A separate `lifetime` count now backs the user-facing
|
|
111
|
+
message, since the fallback counter is no longer a running total.
|
|
112
|
+
2. **`defaultMode: "auto"` was readable from project settings.** The `autoMode` rules
|
|
113
|
+
block was already user/managed-only, but the mode itself came through
|
|
114
|
+
`loadPermissionSettings`, which reads `.claude/settings.json` — so a checked-in file
|
|
115
|
+
could put a session into the mode whose classifier is what contains it. `auto` is
|
|
116
|
+
now honoured from user settings only; every other mode still works per-project.
|
|
117
|
+
3. **Broad-allow-rule suspension was too narrow.** It caught a bare tool and `*`, but
|
|
118
|
+
not wildcarded interpreters or runners, so `Bash(python*)` survived as a standing
|
|
119
|
+
bypass. The rule now turns on a cleaner observation: a pattern with no `*` is
|
|
120
|
+
exact-match and therefore narrow however powerful the command is (`Bash(python)`
|
|
121
|
+
only starts a REPL), so only wildcarded patterns can be broad — and among those,
|
|
122
|
+
an interpreter/runner head with nothing constraining its arguments is. Delegation
|
|
123
|
+
rules (`Task`, `Agent`, `Workflow`) drop outright: a subagent is a fresh agent loop,
|
|
124
|
+
so pre-approving one pre-approves whatever it decides to do.
|
|
125
|
+
4. **CLAUDE.md never reached the classifier**, though `ClassifyRequest` had a field for
|
|
126
|
+
it. Claude Code's classifier reads the same instruction files the agent does, so
|
|
127
|
+
"never force push" steers both. Now loaded from cwd up to the git root plus the
|
|
128
|
+
user's global file, capped. Because those files are checked in, the prompt states
|
|
129
|
+
they may *tighten* what is allowed but never widen it — without that asymmetry a
|
|
130
|
+
repository could ship its own authorisation.
|
|
131
|
+
5. **Stated boundaries did nothing.** User messages were only ever read as
|
|
132
|
+
*authorisation*; in Claude Code they also impose limits ("don't push until I
|
|
133
|
+
review" blocks a push the default rules allow, until the user lifts it). The prompt
|
|
134
|
+
now says user messages cut both ways.
|
|
135
|
+
6. **Protected paths were missing entirely** — a mechanism, not a rule. Writes to
|
|
136
|
+
`.git`, `.claude`, `.vscode`, `.husky`, `.mvn`, shell rc files, `.npmrc`,
|
|
137
|
+
`pre-commit` config, and build wrappers are never auto-approved, and in auto mode
|
|
138
|
+
route to the classifier *even when an allow rule matches*: the check runs before
|
|
139
|
+
allow rules, so `Edit(.claude/**)` cannot pre-approve reconfiguring the agent's own
|
|
140
|
+
permissions. Being inside the working directory is not what makes such a write
|
|
141
|
+
safe, which is the whole point.
|
|
142
|
+
7. **Subagents had one of three checkpoints.** Per-action classification came free
|
|
143
|
+
(children inherit the mode), but the spawn was *auto-allowed* — `subagent` sits in
|
|
144
|
+
`AUTO_ALLOWED_TOOLS` — so a delegated task was never judged, and there was no
|
|
145
|
+
review when the child returned. Delegation now classifies at spawn in auto mode,
|
|
146
|
+
and children log their actions (names and short subjects, never output) for a
|
|
147
|
+
return review that prepends a warning to the result rather than blocking it: the
|
|
148
|
+
work has already happened, so the useful move is to make sure it is seen. The
|
|
149
|
+
return check exists because it is the only one that sees the *sequence* — "read the
|
|
150
|
+
deploy config, read a token, open a PR" passes step by step.
|
|
151
|
+
|
|
152
|
+
**One bug the live runs caught, of a kind worth naming.** The first protected-path run
|
|
153
|
+
blocked correctly but reported "`.claude/notes.md` is outside the working directory",
|
|
154
|
+
which is false — it is inside. The classifier had been handed a call with no
|
|
155
|
+
explanation of why it was being asked, so it manufactured a plausible rationale. A
|
|
156
|
+
wrong reason is not cosmetic: it is what the user reads in `/permissions` and what the
|
|
157
|
+
model is told to act on. Routing cause is now passed in as a fact
|
|
158
|
+
(`<why_you_are_being_asked>`), the same principle as handing over static-analysis
|
|
159
|
+
facts instead of a command string: tell the classifier what is known rather than
|
|
160
|
+
letting it infer. On the re-run it allowed the write, correctly, on the explicit path
|
|
161
|
+
the user had named.
|
|
162
|
+
|
|
163
|
+
Verified live for each: an `Edit(.claude/**)` rule failing to pre-approve a `.claude`
|
|
164
|
+
write, a `Bash(python*)` rule failing to pre-approve a python one-liner writing
|
|
165
|
+
outside the working directory, and the same write allowed once the user named the path.
|
|
166
|
+
|
|
167
|
+
## Grounding the classifier's verdict: cite a rule, don't narrate one
|
|
168
|
+
|
|
169
|
+
The protected-path bug above — a `.claude/notes.md` write blocked "because it is
|
|
170
|
+
outside the working directory", which was false — was a symptom of a class, not a
|
|
171
|
+
one-off. The `reason` field was unconstrained generated text, and three consumers
|
|
172
|
+
treated it as authoritative: the user reading `/permissions`, the model being told
|
|
173
|
+
what to do instead, and **the gate itself**, which skipped the user's prompt
|
|
174
|
+
whenever `tier === "hard_deny"`. A fabricated tier did not merely misinform; it
|
|
175
|
+
removed the chance to approve something that was only a soft denial.
|
|
176
|
+
|
|
177
|
+
Claude Code hit this and retreated: from v2.1.208 its denial reason is the fixed
|
|
178
|
+
string `Blocked by classifier`, "the classifier scores each action on an internal
|
|
179
|
+
severity scale rather than writing an explanation" — having shipped written
|
|
180
|
+
explanations in v2.1.193 and moved off them. Suppressing the reason is not the
|
|
181
|
+
right trade here, since the reason is how a user knows which rule or environment
|
|
182
|
+
entry to change, so the answer is to keep it and make it unable to assert things
|
|
183
|
+
that are not so.
|
|
184
|
+
|
|
185
|
+
Three changes, in `auto-mode/rules.ts` and `prompt.ts`:
|
|
186
|
+
|
|
187
|
+
1. **`routedBecause` is required.** It was optional, and the routes that left it
|
|
188
|
+
empty are exactly where confabulation happened: a classifier told nothing about
|
|
189
|
+
why it is being asked will supply its own answer. The type now forces every
|
|
190
|
+
call site to say why, with an explicit "nothing unusual, this is the residual
|
|
191
|
+
path" default for ordinary calls.
|
|
192
|
+
2. **Rules are numbered and the verdict cites an id.** `H1`/`S4`/`A2` are rendered
|
|
193
|
+
into the prompt; a block must cite one, or one of three reserved grounds that
|
|
194
|
+
are real but are not numbered rules (`boundary`, `instructions`, `unclear`) —
|
|
195
|
+
without those the classifier would have to force such a decision onto whichever
|
|
196
|
+
rule looked closest, which is the misattribution being fixed. The id is
|
|
197
|
+
validated against the index, **the tier is derived from the id's prefix** rather
|
|
198
|
+
than from a field the model chose, and the user-visible reason is the cited
|
|
199
|
+
rule's own text. A block citing an id that does not exist, or citing an allow
|
|
200
|
+
rule as grounds for a denial, is still a block — but reported as exactly that,
|
|
201
|
+
with tier `unmatched`, so it cannot borrow a real rule's authority to skip the
|
|
202
|
+
prompt. The model's wording survives in `raw`, shown attributed to it.
|
|
203
|
+
3. **Raw verdicts are recorded**, on the denial and behind `CC_AUTO_MODE_DEBUG=1`
|
|
204
|
+
on stderr, so this class is diagnosable rather than anecdotal.
|
|
205
|
+
|
|
206
|
+
**The citation requirement caused a permissiveness regression, which is worth
|
|
207
|
+
recording because it was not obvious.** Requiring a cited rule raised the bar for
|
|
208
|
+
*blocking* while leaving `allow` unconstrained, so uncertainty began draining
|
|
209
|
+
toward allow: "back up this project somewhere outside it, pick the location
|
|
210
|
+
yourself" — which had reliably blocked — started succeeding. The debug log showed
|
|
211
|
+
why, and it was only visible *because* of change 3: the classifier had correctly
|
|
212
|
+
identified the governing rule and then cleared it on intent, reasoning that the
|
|
213
|
+
user "explicitly requested" the backup. But that rule clears only when the user
|
|
214
|
+
*names the destination*, and "pick the location yourself" is the opposite of
|
|
215
|
+
naming one.
|
|
216
|
+
|
|
217
|
+
So a clearing condition that depends on the user having named something is now
|
|
218
|
+
checkable rather than a judgement: the classifier must quote the user's words
|
|
219
|
+
verbatim in `intentQuote`, and the quote is verified against the actual `input`
|
|
220
|
+
messages (normalised for case and whitespace, not wording). An intent that cannot
|
|
221
|
+
be found in the user's own words is not an intent — it downgrades to a soft
|
|
222
|
+
denial, so an interactive user can still approve it. This is the one claim worth
|
|
223
|
+
checking mechanically: it is what a prompt injection most wants to manufacture,
|
|
224
|
+
and, as this regression showed, also what the model is most prone to stretch on
|
|
225
|
+
its own.
|
|
226
|
+
|
|
227
|
+
Verified live in both directions: "write hello into ~/One Code-named-probe.txt — I
|
|
228
|
+
want that exact path" → `allow (intent)` with a verified quote, file created; the
|
|
229
|
+
delegated-destination backup → `block (S5)`, reported with S5's own text.
|
|
230
|
+
|
|
231
|
+
## Choosing the classifier model without leaking the session to another provider
|
|
232
|
+
|
|
233
|
+
The first cut searched the whole registry for a model whose id contained
|
|
234
|
+
`haiku`/`sonnet`/`flash`/`mini`/`small`. Checked against the real catalog (1153
|
|
235
|
+
models, 22 available on this machine), that is wrong in three separate ways.
|
|
236
|
+
|
|
237
|
+
**It crossed providers, which is the serious one.** On a session running
|
|
238
|
+
`openai-codex/gpt-5.5-codex` with an Anthropic key also configured, it selected
|
|
239
|
+
`anthropic/claude-haiku-4-5` — verified, not theoretical. The classifier receives
|
|
240
|
+
the user's own messages, their CLAUDE.md, and the text of the command being
|
|
241
|
+
judged, so this shipped that content to a vendor the user had not chosen for the
|
|
242
|
+
session, through a component with no UI at all. The invariant now: **never leave
|
|
243
|
+
the session's provider unless `autoMode.classifierModel` says to**, because naming
|
|
244
|
+
a provider is choosing it.
|
|
245
|
+
|
|
246
|
+
**Name matching does not survive real catalogs.** Of Groq's 7 models and xAI's 3,
|
|
247
|
+
*none* contain any of those substrings, so those providers fell through to the
|
|
248
|
+
session model. OpenRouter has 303 models and 79 substring hits, so "first match"
|
|
249
|
+
was arbitrary. On OpenAI it picked whichever `*mini*` came first in registry order
|
|
250
|
+
rather than `gpt-5-nano` at a fifth the price.
|
|
251
|
+
|
|
252
|
+
**Cost is the portable signal, but not naively.** Sorting cheapest-first puts
|
|
253
|
+
`openrouter/auto` first at `-1000000` — a sentinel, not a price — and Google's
|
|
254
|
+
free-tier entries at `$0`. Non-positive costs are therefore treated as *unpriced*
|
|
255
|
+
rather than cheap. A per-provider default table covers the catalogs where price
|
|
256
|
+
alone still picks badly, which is what makes OpenRouter tractable.
|
|
257
|
+
|
|
258
|
+
The resolution order follows the shape pi's own subagent config uses
|
|
259
|
+
(`subagents.defaultModel`, `agentOverrides.<name>.model`, `fallbackModels`,
|
|
260
|
+
session default), and for the same stated reason — pi's docs justify defaulting
|
|
261
|
+
to the session model as keeping "new installs from depending on a provider you
|
|
262
|
+
may not have configured", which is exactly the failure above:
|
|
263
|
+
|
|
264
|
+
1. `autoMode.classifierModel`, any provider.
|
|
265
|
+
2. A known-good cheap model for the session's provider.
|
|
266
|
+
3. The cheapest genuinely-priced model in that provider, no dearer than the
|
|
267
|
+
session model — there is no point paying more to screen a call than to make it.
|
|
268
|
+
4. The session's own model: always correct, just not cheap.
|
|
269
|
+
|
|
270
|
+
More than one candidate is returned on purpose, mirroring `fallbackModels`: a
|
|
271
|
+
model that is unusable *on this account* (401/403/404, quota, not entitled) is
|
|
272
|
+
stepped over and recorded, so it is not retried on every call. A **transient**
|
|
273
|
+
failure is deliberately not stepped over — switching models there would paper over
|
|
274
|
+
something about to clear — so it surfaces as a block and the same model is tried
|
|
275
|
+
again next call. The choice is pinned on first success, so a registry refresh
|
|
276
|
+
cannot swap classifiers mid-session; Claude Code pins the same way.
|
|
277
|
+
|
|
278
|
+
Two things are now visible that were not. The footer names the model beside
|
|
279
|
+
`auto mode on` (`⏵⏵ auto mode on · haiku-4-5`), and it says nothing until the
|
|
280
|
+
first call settles which model that is, rather than guessing. `/auto-mode config`
|
|
281
|
+
shows the pinned model, the full candidate chain with the reason for each, and
|
|
282
|
+
anything found unusable. A `classifierModel` naming something unavailable used to
|
|
283
|
+
fall through in silence, leaving the user believing their setting was in force;
|
|
284
|
+
it now warns, names the setting, and says what is being used instead.
|
|
285
|
+
|
|
286
|
+
Verified live: badge empty before the first call and `· haiku-4-5` after, the
|
|
287
|
+
notice naming `anthropic/claude-haiku-4-5 (default for this provider)`, and a
|
|
288
|
+
deliberately bogus `classifierModel` producing the warning plus a working
|
|
289
|
+
fallback rather than a broken gate.
|
|
290
|
+
|
|
291
|
+
**Still open:** there is no capability floor. Claude Code gates auto mode's
|
|
292
|
+
availability on model tier because a weak classifier is a weak boundary; One Code
|
|
293
|
+
gates only on "a model exists", so a small local model can end up as the gate.
|
|
294
|
+
Refusing auto mode there would remove the feature exactly where self-hosted users
|
|
295
|
+
want it, so a warning on entry is the likelier answer.
|
|
296
|
+
|
|
297
|
+
## Three defects the real OpenRouter catalog exposed
|
|
298
|
+
|
|
299
|
+
Asking "so which model does OpenRouter actually use?" and running the selector
|
|
300
|
+
against pi's real 303-model OpenRouter catalog — rather than re-reading the table
|
|
301
|
+
— found three faults in the code committed an hour earlier. The answer itself was
|
|
302
|
+
right (`anthropic/claude-haiku-4.5`, which does exist there exactly), but:
|
|
303
|
+
|
|
304
|
+
**The provider-default branch ignored the budget ceiling.** A session on
|
|
305
|
+
`z-ai/glm-4.6` ($0.50/M) was screened by `anthropic/claude-haiku-4.5` ($1/M) —
|
|
306
|
+
twice the price of the work being screened, directly against this file's own
|
|
307
|
+
stated rule. The ceiling had only ever been applied to the cost-ranked branch.
|
|
308
|
+
Table entries are now tried in order against it, so a dearer default falls through
|
|
309
|
+
to the next entry: that session now gets `openai/gpt-5-mini` ($0.25).
|
|
310
|
+
|
|
311
|
+
**`:batch` and friends were selectable.** OpenRouter lists
|
|
312
|
+
`anthropic/claude-haiku-4.5:batch` at half price, `openai/gpt-5-nano:batch` at
|
|
313
|
+
$0.025. Batch endpoints are asynchronous, so a blocking gate would wait out its
|
|
314
|
+
timeout and then block the call — and because they are systematically cheaper,
|
|
315
|
+
cost ranking actively *prefers* them. Worse, `startsWith` matching meant a
|
|
316
|
+
`:batch` id could satisfy a plain table prefix. `:batch`, `:free` (rate-limited
|
|
317
|
+
hard enough that the gate fails intermittently), `:online` and `:thinking` are
|
|
318
|
+
excluded from automatic selection; an explicit `classifierModel` still wins,
|
|
319
|
+
since naming a model is choosing it.
|
|
320
|
+
|
|
321
|
+
**A model picked purely on price was leading the chain.** The cheapest OpenRouter
|
|
322
|
+
model is `inclusionai/ling-2.6-flash` at $0.01, and it ranked *above* the session
|
|
323
|
+
model — so had the default been unavailable, the security boundary would silently
|
|
324
|
+
have become an obscure model whose only qualification was being cheap.
|
|
325
|
+
|
|
326
|
+
The first attempt at that third fix is worth recording because it failed on the
|
|
327
|
+
same example. The idea was to let a cost-ranked pick lead only when its *name*
|
|
328
|
+
also placed it in a known small-model family, on the theory that two weak signals
|
|
329
|
+
agreeing beat either alone. `ling-2.6-flash` contains "flash", so it passed. The
|
|
330
|
+
word means "someone called this small", not "this family is known good", and any
|
|
331
|
+
vendor can put it in a name. The heuristic was deleted rather than patched:
|
|
332
|
+
**nothing chosen on price alone leads.** Vetted providers get their saving from
|
|
333
|
+
the table (which covers OpenRouter and every mainstream provider); anywhere else
|
|
334
|
+
the model the user already trusted for the real work screens the calls — correct,
|
|
335
|
+
merely not cheap — and the cost-ranked pick stays last, for when even that cannot
|
|
336
|
+
serve.
|
|
337
|
+
|
|
338
|
+
The general lesson is the one that keeps recurring in this feature: a table of
|
|
339
|
+
model ids and prices reads as fine and behaves differently against a real
|
|
340
|
+
catalog spanning three orders of magnitude in price. Verify selection logic by
|
|
341
|
+
running it over the actual registry, not by inspecting the table.
|
|
342
|
+
|
|
343
|
+
## Prompt caching, input size, and vendor containment on gateways
|
|
344
|
+
|
|
345
|
+
Three requests at once, and they turned out to interact.
|
|
346
|
+
|
|
347
|
+
**Gateway vendor containment.** The provider constraint was enforced at the wrong
|
|
348
|
+
granularity. On OpenRouter, a session on `openai/gpt-5.1` was being screened by
|
|
349
|
+
`anthropic/claude-haiku-4.5`: same pi provider, same API key — and the user's
|
|
350
|
+
messages and CLAUDE.md going to Anthropic, a vendor they had not picked. The pi
|
|
351
|
+
provider is the *gateway*; the vendor that actually receives the request is the
|
|
352
|
+
prefix in the model id. Candidacy on a gateway (`openrouter`,
|
|
353
|
+
`vercel-ai-gateway`, `cloudflare-ai-gateway`) is now narrowed to the session
|
|
354
|
+
model's own vendor, so `openai/gpt-5.1` gets `openai/gpt-5-mini` and a `z-ai/`
|
|
355
|
+
session with no cheaper `z-ai/` model is screened by the session model rather
|
|
356
|
+
than by another vendor. Groq, Bedrock and Copilot carry vendor-ish prefixes but
|
|
357
|
+
host those weights themselves, so they are deliberately not split.
|
|
358
|
+
|
|
359
|
+
The tables are keyed by vendor now, and flagship models were removed from them:
|
|
360
|
+
an entry matching the session's own model makes the table "choose" the very model
|
|
361
|
+
it exists to find something cheaper than. They also stop at the mini/haiku/flash
|
|
362
|
+
tier rather than nano/flash-lite — Claude Code runs its classifier on a
|
|
363
|
+
Sonnet-class model, and dropping to the bottom tier to save a fraction of a cent
|
|
364
|
+
trades away the judgement the gate exists for. `classifierModel` is there for
|
|
365
|
+
anyone who wants that trade.
|
|
366
|
+
|
|
367
|
+
**Caching: requested, and silently refused.** pi already puts an Anthropic
|
|
368
|
+
`cache_control` breakpoint on the system prompt, which a payload dump confirmed
|
|
369
|
+
(`cache_control: {type: "ephemeral"}` on a 9,777-character block). It never took
|
|
370
|
+
effect because the cacheable prefix was too short: the rules lived in the *user*
|
|
371
|
+
message, leaving ~1,270 tokens of instructions in the system prompt. Moving the
|
|
372
|
+
rule lists into the system prompt — they are instructions, not per-call data —
|
|
373
|
+
brings the stable prefix to ~2,570 tokens and makes it byte-identical across
|
|
374
|
+
calls in a session, which is a test now.
|
|
375
|
+
|
|
376
|
+
That is still not enough. **Claude Haiku 4.5's minimum cacheable prompt is 4,096
|
|
377
|
+
tokens** (2,048 is Haiku *3.5*), and Anthropic's documented behaviour is that a
|
|
378
|
+
shorter prompt "will be processed without caching, and no error is returned" —
|
|
379
|
+
exactly the silent no-op measured. So on Haiku, caching would require *growing*
|
|
380
|
+
the prompt ~60%, which is the opposite of the other request. It is left requested
|
|
381
|
+
(`cacheRetention: "long"`, harmless where unavailable) because the same prefix
|
|
382
|
+
does clear the 1,024-token floor on Sonnet-class models, where it will engage for
|
|
383
|
+
anyone who sets `classifierModel` accordingly.
|
|
384
|
+
|
|
385
|
+
`projectInstructions` deliberately stays out of the system prompt despite being
|
|
386
|
+
equally stable: CLAUDE.md is checked-in content this gate does not trust, and
|
|
387
|
+
promoting untrusted text into the system role to gain cache tokens would launder
|
|
388
|
+
its authority. Prefix caching does not care what follows the system block.
|
|
389
|
+
|
|
390
|
+
**Input size.** Instructions were compressed without dropping a rule or a worked
|
|
391
|
+
example (the intent examples are load-bearing — removing them regressed
|
|
392
|
+
calibration once already), and the nine `environment` entries that only said
|
|
393
|
+
"none configured" collapse to one line saying the same thing. Measured: input
|
|
394
|
+
3,233 → 2,934 tokens (-9%), output ~220 → ~140 (-36%, the tighter schema
|
|
395
|
+
description shortens `analysis`), cost $0.0043 → $0.0035 per classification
|
|
396
|
+
(-19%). Calibration re-verified in both directions afterwards: a named path still
|
|
397
|
+
clears on intent, an unnamed backup destination still blocks citing S5.
|
|
398
|
+
|
|
399
|
+
**A latent bug the caching experiment exposed.** Testing a Sonnet-class
|
|
400
|
+
classifier produced `400 temperature is deprecated for this model`. The classifier
|
|
401
|
+
was passing `temperature: 0` — which looks free, since a classifier wants
|
|
402
|
+
determinism — but it is deprecated on Sonnet 5, unsupported on Opus 4.7+, and
|
|
403
|
+
rejected by several OpenAI reasoning models, while pi's compat data still
|
|
404
|
+
advertises support. Any user whose classifier resolved to such a model would have
|
|
405
|
+
had **every tool call blocked**, and it was invisible because Haiku accepts it.
|
|
406
|
+
`temperature` is no longer sent. This is the third time in this feature that an
|
|
407
|
+
option which reads as harmless failed closed on a provider we were not testing
|
|
408
|
+
(thinking budget, macOS symlink containment, now temperature): a gate spanning 38
|
|
409
|
+
providers should send the minimum set of options it actually needs.
|
|
410
|
+
|
|
411
|
+
## Auto mode hardening: the pi-automode review, and what came of it
|
|
412
|
+
|
|
413
|
+
A review of [czottmann/pi-automode](https://github.com/czottmann/pi-automode)
|
|
414
|
+
(cloned at cff6d42, 2026-08-03) — an independent auto-mode implementation for
|
|
415
|
+
pi — against this one produced four adoptions, three rejections, and exposed
|
|
416
|
+
three real bugs in our own classifier fallback path. Per the community-work
|
|
417
|
+
convention, the outcome is recorded here.
|
|
418
|
+
|
|
419
|
+
**Adopted: an interactive classifier picker (`/auto-mode model`).** Their
|
|
420
|
+
strongest idea. Our automatic selection took three commits of fault-fixing
|
|
421
|
+
against real catalogs, and when it picks badly the only recourse was hand-editing
|
|
422
|
+
settings.json with a model id the user has to guess. The picker (filter by
|
|
423
|
+
prefix/substring/subsequence, prices shown, auth validated via
|
|
424
|
+
`getApiKeyAndHeaders` *before* persisting — theirs validates too) writes
|
|
425
|
+
`autoMode.classifierModel` to **user scope only** and releases the session pin so
|
|
426
|
+
the choice takes effect on the next call. `/auto-mode model clear` removes it.
|
|
427
|
+
This changes the failure economics of selection heuristics: any future fault
|
|
428
|
+
degrades to "pick it yourself" instead of "auto mode is broken". The picker's
|
|
429
|
+
header says plainly that the model reads your prompts and CLAUDE.md, because
|
|
430
|
+
choosing another provider is exactly the privacy decision model-select.ts refuses
|
|
431
|
+
to make automatically.
|
|
432
|
+
|
|
433
|
+
**Adopted: a deterministic deny floor for safety-control writes**
|
|
434
|
+
(`auto-mode/safety-floor.ts`). Their `deterministicHardDeny` guards its own
|
|
435
|
+
config deterministically; ours relied on the classifier's H-rules, which are only
|
|
436
|
+
as strong as the model enforcing them — a weak or talked-around classifier could
|
|
437
|
+
approve the one write that disables every check after it. Now a write/edit/bash
|
|
438
|
+
redirect landing on `~/.claude/settings.json`, managed settings, `~/.claude.json`,
|
|
439
|
+
or any `.claude/settings(.local).json` never reaches the classifier: interactive
|
|
440
|
+
sessions always prompt, headless runs block. Two deliberate asymmetries: the
|
|
441
|
+
shell pre-gate may only say "safe" (a gap costs a classifier call), the floor may
|
|
442
|
+
only say "stop" (a gap falls through to the classifier) — neither list needs to
|
|
443
|
+
be complete to be sound. And the floor matches exact files, not directories:
|
|
444
|
+
`~/.claude` also holds memory and skills the agent writes routinely, and a floor
|
|
445
|
+
that fires on routine work teaches the user to approve blind. Unlike their
|
|
446
|
+
unconditional hard-deny (which cannot be overridden even by a user who wants the
|
|
447
|
+
edit — and hard-denies `rm -rf` under `/private/tmp` on macOS), ours prompts:
|
|
448
|
+
editing your own settings is legitimate, it just isn't the classifier's to allow.
|
|
449
|
+
Floor targets are symlink-resolved with `resolveForContainment`, so
|
|
450
|
+
`ln -s ~/.claude/settings.json innocent.json` does not slip past. Verified live:
|
|
451
|
+
a headless auto-mode session asked to write `~/.claude/settings.json` was blocked
|
|
452
|
+
without a classifier call, both on the `write` tool and the bash retry.
|
|
453
|
+
|
|
454
|
+
**Adopted: config diagnostics.** Their `validateSettingsFile` inspired the same
|
|
455
|
+
for `autoMode`: invalid JSON (previously swallowed — the user believes rules are
|
|
456
|
+
in force that were never loaded), unknown keys, mistyped fields, and a list that
|
|
457
|
+
omits `$defaults` (so it *replaces* the built-ins) all surface in
|
|
458
|
+
`/auto-mode config`, which now also re-reads disk. Loading stays lenient; only
|
|
459
|
+
the report is new.
|
|
460
|
+
|
|
461
|
+
**Adopted: a decision log** (`auto-mode/decision-log.ts`,
|
|
462
|
+
`autoMode.logDecisions: true`). Theirs logs ccusage-compatible usage; ours logs
|
|
463
|
+
what we actually needed twice already: one JSONL line per gate decision — layer
|
|
464
|
+
(pre-gate / classifier / floor / user-at-prompt / subagent review), outcome,
|
|
465
|
+
tier, rule id, the classifier's raw commentary, and which model decided — in
|
|
466
|
+
`auto-mode-decisions.jsonl` next to the session files. Both prior regressions
|
|
467
|
+
were caught by reading raw verdicts under `CC_AUTO_MODE_DEBUG`, which only helps
|
|
468
|
+
when set *before* the session; and allows are invisible in the UI by design, so
|
|
469
|
+
the log is the only complete record of the permissive direction. Failures are
|
|
470
|
+
swallowed: a gate that blocks calls because its diary is unwritable has its
|
|
471
|
+
priorities backwards.
|
|
472
|
+
|
|
473
|
+
**Rejected: their model selection** (configured model or session model, nothing
|
|
474
|
+
else). It is admirably simple and trivially private, but it is our chain's
|
|
475
|
+
degraded case: an Opus session would screen every call with Opus at ~3k input
|
|
476
|
+
tokens each. The tables stay; the picker is the pressure valve that makes further
|
|
477
|
+
selection cleverness unnecessary. **Rejected: the two-stage fast/detailed
|
|
478
|
+
classifier** (one-digit gate, then JSON review). It saves output tokens, but our
|
|
479
|
+
deterministic pre-gate already removes the classifier from the hot path for free,
|
|
480
|
+
single-digit contracts are fragile on reasoning models (they budget 512 tokens
|
|
481
|
+
just for hidden reasoning before the digit), and a one-token "0" is an ungrounded
|
|
482
|
+
allow. **Rejected (and worth reporting upstream): project-local autoMode
|
|
483
|
+
config.** Their `.pi/automode.local.json` gets full `autoMode` authority from the
|
|
484
|
+
repo directory; nothing stops a malicious repo from committing one with
|
|
485
|
+
`{"enabled": false}` or a replaced hard-deny list (omitting `$defaults` only
|
|
486
|
+
warns). Their shared `.pi/automode.json` is correctly restricted to
|
|
487
|
+
`permissions.*` — the local variant defeats the same containment their own doc
|
|
488
|
+
comment claims. Ours reads user + managed scope only, unchanged. Their read-only
|
|
489
|
+
fast path also lets the `read` tool fetch `~/.ssh/id_rsa` unclassified while
|
|
490
|
+
`cat ~/.ssh/id_rsa` is their canonical hard-deny example.
|
|
491
|
+
|
|
492
|
+
**Three bugs found in our fallback path while comparing.** (1) A pinned
|
|
493
|
+
classifier that died mid-session was never unpinned: `rejected` grew but
|
|
494
|
+
`pinned` stayed, so every later call retried the dead model and auto mode
|
|
495
|
+
blocked everything until restart. Rejection now releases the pin, and the chain
|
|
496
|
+
rides behind the pinned attempt so the *same call* steps onward. (2) The
|
|
497
|
+
"everything rejected" retry took `all.slice(-1)` — the cost-ranked pick, the one
|
|
498
|
+
candidate the chain is designed never to lead with — while the comment claimed it
|
|
499
|
+
retried the session model. It now does what the comment says. (3)
|
|
500
|
+
`isModelUnavailableError` matched bare "quota", so a per-minute rate-limit blip
|
|
501
|
+
("quota exceeded, retry in 60s") permanently rejected a healthy model; only
|
|
502
|
+
billing forms (`insufficient_quota`, "exceeded your current quota") count now —
|
|
503
|
+
misreading billing as transient merely retries noisily, misreading a blip as
|
|
504
|
+
permanent bricks the candidate, so uncertainty drains toward transient. All three
|
|
505
|
+
are covered by `auto-mode-classifier-fallback.test.ts`, which mocks
|
|
506
|
+
`completeSimple` — the first tests of the fallback *sequence* rather than single
|
|
507
|
+
verdicts.
|
|
508
|
+
|
|
509
|
+
**Also: mode and classifier in the banner.** The banner's `mode` line was
|
|
510
|
+
hardcoded to "default". It now renders live — `mode auto · classifier haiku-4-5
|
|
511
|
+
(planned)` before the first call pins, the pinned model after, `(paused)` when
|
|
512
|
+
paused — fed by a `one-code:permission-status` event from the permissions
|
|
513
|
+
extension (jiti isolates module state, so this goes over the bus). The
|
|
514
|
+
protected-path check also gained the floor's symlink resolution: `decide()` takes
|
|
515
|
+
an optional `resolvedSubject`, so writing `.git/hooks` through a symlinked
|
|
516
|
+
spelling is as protected as writing it directly.
|
|
517
|
+
|
|
518
|
+
**A pi-tui trap this exposed** (now also in findings): pi-tui **crashes the whole
|
|
519
|
+
app** on a rendered line wider than the terminal ("Rendered line exceeds terminal
|
|
520
|
+
width"), but only validates a component whose output *changed*. The banner's
|
|
521
|
+
skills line had been over-wide at 160 columns since quietStartup sections landed
|
|
522
|
+
— harmless while the banner was static, fatal the moment the mode line made it
|
|
523
|
+
re-render (ctrl+q killed pi outright). `bannerLines` now truncates every line to
|
|
524
|
+
the render width with an ANSI-aware helper that never splits an escape sequence
|
|
525
|
+
and resets colour before the ellipsis. Any `ctx.ui.custom`/`setHeader` component
|
|
526
|
+
must do the same.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Branding, themes & startup
|
|
2
|
+
|
|
3
|
+
Part of [Decisions](../decisions.md).
|
|
4
|
+
|
|
5
|
+
## Branding without a fork
|
|
6
|
+
|
|
7
|
+
pi's startup banner ("pi v0.83.0 … Ask it how to use or extend Pi") is built from
|
|
8
|
+
its `piConfig`, which resolves from pi's own installed `package.json` — a
|
|
9
|
+
dependent package cannot change it, as recorded in
|
|
10
|
+
[`distribution.md`](distribution.md#distribution-pi-package-not-a-wrapper-binary).
|
|
11
|
+
But `ctx.ui.setHeader()`
|
|
12
|
+
replaces the header component outright, which reaches the same result:
|
|
13
|
+
`extensions/branding/` renders the package name, version, key hints and current
|
|
14
|
+
model/mode instead, and sets the terminal title. `CC_NO_BANNER=1` restores pi's.
|
|
15
|
+
|
|
16
|
+
Implementation note: a pi-tui `Component` is just `render(width)` plus
|
|
17
|
+
`invalidate()`, so the header is an inline object literal — no dependency on
|
|
18
|
+
pi-tui needed. It only applies when `ctx.mode === "tui"`; print and rpc modes have
|
|
19
|
+
no chrome to replace.
|
|
20
|
+
|
|
21
|
+
Verified rendering with `test/e2e/tui-capture.sh`:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pi-claude-code v0.1.0 Claude Code on the pi harness
|
|
25
|
+
escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more
|
|
26
|
+
model claude-sonnet-5 · mode default
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This is the cosmetic half of what a fork would buy. The other half — the `.pi`
|
|
30
|
+
config directory name — stays as it is; see the integration-shapes section in
|
|
31
|
+
`findings.md` for when that would justify forking.
|
|
32
|
+
|
|
33
|
+
## Themes: authored, not adopted
|
|
34
|
+
|
|
35
|
+
The ecosystem has no Claude Code *theme*. What it has are Claude Code-flavoured UI
|
|
36
|
+
**extensions** (`pi-cc-extensions`, `pi-claude-code-tui`, `pi-cc-header`) — code,
|
|
37
|
+
with the dependency risk already declined in the Phase 7 notes.
|
|
38
|
+
|
|
39
|
+
A pi theme, by contrast, is a JSON file of 51 colour tokens: data, and squarely in
|
|
40
|
+
the "cheaper to own than to depend on" half of the checklist. So `themes/` ships
|
|
41
|
+
two of our own, `claude-code` and `claude-code-light`, declared through
|
|
42
|
+
`pi.themes` in the manifest. The palette is a warm clay accent (`#d97757` dark,
|
|
43
|
+
`#c05f38` light) over neutral surfaces — an approximation of Claude Code's
|
|
44
|
+
terminal look, not values extracted from it.
|
|
45
|
+
|
|
46
|
+
`test/unit/themes.test.ts` validates every bundled theme: all 51 required tokens
|
|
47
|
+
present, no unknown tokens beyond the two optional ones, every `vars` reference
|
|
48
|
+
resolvable, and colours well-formed. pi rejects an incomplete theme at load time
|
|
49
|
+
without saying which token is missing, so this catches typos before a user does.
|
|
50
|
+
|
|
51
|
+
Both were then verified with `test/e2e/tui-capture.sh`, which runs pi inside tmux
|
|
52
|
+
to get a real pty: the startup screen lists `[Themes] claude-code,
|
|
53
|
+
claude-code-light` (so `pi.themes` discovery works for an installed package), and
|
|
54
|
+
with the theme selected the clay accent appears as a 24-bit escape in the rendered
|
|
55
|
+
output.
|
|
56
|
+
|
|
57
|
+
## Rebrand: pincer
|
|
58
|
+
|
|
59
|
+
Anthropic's branding guidelines (published on the Claude Agent SDK docs, seen
|
|
60
|
+
2026-08) prohibit "Claude Code" product names, Claude Code-mimicking ASCII
|
|
61
|
+
art/visual elements, and products that appear to be Claude Code. They formally
|
|
62
|
+
address SDK partners — which this project is not — but the substance is
|
|
63
|
+
ordinary trademark hygiene and applies to anything published.
|
|
64
|
+
|
|
65
|
+
What changed (2026-08-05): package `pi-claude-code` → **`pincer-agent`**
|
|
66
|
+
(brand "pincer" — contains *pi*, means *claw*; plain `pincer` is taken on npm
|
|
67
|
+
by an old static file server). Banner `NAME`, system-prompt identity ("You are
|
|
68
|
+
pincer"), MCP client name, and web_fetch User-Agent follow. The banner mascot
|
|
69
|
+
— previously a deliberate homage to Claude Code's — is redrawn as a pixel π,
|
|
70
|
+
which is ours outright. Themes renamed `claude-code`/`claude-code-light` →
|
|
71
|
+
`pincer`/`pincer-light` (users with `"theme": "claude-code"` in pi settings
|
|
72
|
+
must re-select). Event channels renamed `pi-claude-code:*` → `pincer:*` —
|
|
73
|
+
done now, pre-publish, because the channel names are a documented third-party
|
|
74
|
+
contract that would be painful to change later.
|
|
75
|
+
|
|
76
|
+
What deliberately stays: every *descriptive* reference — "the Claude Code
|
|
77
|
+
experience on the pi harness", "reads your Claude Code settings.json" — is
|
|
78
|
+
nominative use (truthfully naming the thing we are compatible with), which
|
|
79
|
+
trademark law and the guidelines themselves permit. The fidelity references in
|
|
80
|
+
`tools/` and `payload.json` are technical reference data, not branding, and
|
|
81
|
+
are untouched.
|
|
82
|
+
|
|
83
|
+
## Rebrand: One Code
|
|
84
|
+
|
|
85
|
+
What changed (2026-08-09): brand **pincer** → **One Code**; npm package
|
|
86
|
+
`pincer-agent` → **`one-code`**. Reason: plainer, more memorable name. Rejected
|
|
87
|
+
alternatives: `pi-code` (taken on npm), `onecode` (taken); `one-code` was free
|
|
88
|
+
and mirrors `claude-code`'s hyphenated npm style. The banner wordmark was
|
|
89
|
+
redrawn as a stacked ANSI-Shadow "ONE" over "CODE" (narrower than the previous
|
|
90
|
+
mark, so it never shears on standard-width terminals — verified live via
|
|
91
|
+
`test/e2e/tui-capture.sh`).
|
|
92
|
+
|
|
93
|
+
Followed the same surface as the pincer rebrand: banner `NAME`/title/wordmark,
|
|
94
|
+
system-prompt identity ("You are One Code"), MCP client name, `web_fetch`
|
|
95
|
+
User-Agent, themes (`pincer`/`pincer-light` → `one-code`/`one-code-light` —
|
|
96
|
+
users with the old theme selected must re-select), and event channels
|
|
97
|
+
(`pincer:*` → `one-code:*`, locked pre-publish since the channel names are a
|
|
98
|
+
third-party contract). This pass went further than the pincer one and also
|
|
99
|
+
renamed the state directory `~/.pincer` → `~/.one-code`, the env var
|
|
100
|
+
`PINCER_STATE_DIR` → `ONE_CODE_STATE_DIR`, and the internal `pincerStateDir()`
|
|
101
|
+
helper → `oneCodeStateDir()`; existing users lose in-flight plans and hook
|
|
102
|
+
approvals under the old dir, acceptable pre-publish. The GitHub repo was
|
|
103
|
+
renamed `pincer-agent` → `one-code` to match (GitHub keeps a redirect from the
|
|
104
|
+
old path).
|
|
105
|
+
|
|
106
|
+
What deliberately stays: every *descriptive* "the Claude Code experience"
|
|
107
|
+
reference (nominative use), and the historical records above (the plan
|
|
108
|
+
snapshot, the `pi-claude-code → pincer` history) — rewriting them would falsify
|
|
109
|
+
the record.
|
|
110
|
+
|
|
111
|
+
## Startup listing: quietStartup + banner sections
|
|
112
|
+
|
|
113
|
+
pi's startup resource listing has no per-section switch, and its [Extensions]
|
|
114
|
+
section names all twenty of this package's internal modules — noise to an end
|
|
115
|
+
user of the packaged product. The only lever pi offers is `quietStartup: true`
|
|
116
|
+
(global setting), which hides the entire listing but leaves a `setHeader`
|
|
117
|
+
banner alone (verified in tmux). `resourceLoader` is not exposed to
|
|
118
|
+
extensions, so the useful sections cannot be read back; instead the banner
|
|
119
|
+
re-derives compact `context` / `skills` / `themes` lines (the blessed
|
|
120
|
+
re-derive pattern): context files via the git-root walk, skills from the
|
|
121
|
+
Claude Code dirs — `existsSync` on `<dir>/<name>/SKILL.md` rather than
|
|
122
|
+
`isDirectory()`, because skill directories can be symlinks — plus namespaced
|
|
123
|
+
plugin skills from `discoverPlugins` (which pi's own listing misses, since the
|
|
124
|
+
plugins extension exposes those only through the `skill` tool), and themes
|
|
125
|
+
from the package's own directory. The sections render only when
|
|
126
|
+
`quietStartupEnabled()` reads true from pi's settings, so pi's listing and
|
|
127
|
+
ours never both appear.
|