one-code-extension 0.1.3 → 0.3.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.
Files changed (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -202
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
- "name": "one-code",
3
+ "name": "onecode",
4
4
  "vars": {
5
5
  "clay": "#d97757",
6
6
  "clayDeep": "#b8623f",
@@ -72,5 +72,10 @@
72
72
  "thinkingXhigh": "red",
73
73
  "thinkingMax": "violet",
74
74
  "bashMode": "amber"
75
+ },
76
+ "export": {
77
+ "pageBg": "#1f1d1b",
78
+ "cardBg": "surface",
79
+ "infoBg": "surfaceRaised"
75
80
  }
76
81
  }
package/types/app.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  /** Types for the bundled app's plain-JS modules (app/), used by unit tests. */
2
2
  declare module "*/app/update-check.mjs" {
3
+ export const CHECK_INTERVAL_MS: number;
3
4
  export function isNewerVersion(candidate: string, current: string): boolean;
4
- export function createUpdateCheck(options: { currentVersion: string; upgradeHint: string }): (pi: unknown) => void;
5
+ export function isOffline(env?: Record<string, string | undefined>): boolean;
6
+ export function checkedRecently(stampPath: string | undefined, now?: number): boolean;
7
+ export function createUpdateCheck(options: {
8
+ currentVersion: string;
9
+ upgradeHint: string;
10
+ stampPath?: string;
11
+ }): (pi: unknown) => void;
5
12
  }
@@ -1,12 +1,55 @@
1
1
  /**
2
2
  * Type stub for the one bundled community pi package. It ships raw TypeScript
3
3
  * written against a different pi version, so typechecking its sources against
4
- * our pinned pi types produces spurious errors. tsconfig `paths` points the
5
- * compiler here, while jiti resolves the real module at runtime.
4
+ * our pinned pi types produces spurious errors. tsconfig `paths` maps every
5
+ * `pi-web-search/src/*` subpath here, while jiti resolves the real module at
6
+ * runtime.
7
+ *
8
+ * Only the exports `extensions/web/index.ts` uses are declared. Loosely typed
9
+ * on purpose (`any` for pi-web-search's own result/context shapes): the goal is
10
+ * to keep the package out of our typecheck, not to re-type it.
6
11
  */
7
12
 
8
13
  declare module "pi-web-search/src/index.ts" {
9
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ /** Registers `web_search` + Gemini-only `url_context` and the active-tools sync that hides url_context off Gemini. */
10
16
  const factory: (pi: ExtensionAPI) => unknown;
11
17
  export default factory;
12
18
  }
19
+
20
+ declare module "pi-web-search/src/api.ts" {
21
+ // The real api.ts (loaded by jiti at runtime) maps a Google provider — by
22
+ // `provider` or `api` field — to "google", the only kind that supports
23
+ // url_context. Declared loosely so the extension can call it with a model
24
+ // object without importing pi-ai's Model type. Keeps the Gemini gate in one
25
+ // place (pi-web-search's own) instead of a hand-copied string.
26
+ export function getProviderKind(
27
+ model: { provider?: string; api?: string } | undefined,
28
+ ): "google" | "openai" | "xai" | "anthropic" | "unsupported";
29
+ }
30
+
31
+ declare module "pi-web-search/src/web_search.ts" {
32
+ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
33
+ /**
34
+ * pi-web-search's provider-native `web_search` execute (query + optional
35
+ * urls). It never sets `isError`; a failure is "Failed: …" text with
36
+ * `details.error` set — the caller stamps `isError` from that.
37
+ */
38
+ export function webSearch(
39
+ id: string,
40
+ params: { query: string; urls?: string[] },
41
+ signal: AbortSignal,
42
+ onUpdate: AgentToolUpdateCallback | undefined,
43
+ ctx: ExtensionContext,
44
+ ): Promise<AgentToolResult<any> & { isError?: boolean }>;
45
+ }
46
+
47
+ declare module "pi-web-search/src/utils.ts" {
48
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
49
+ /**
50
+ * The model native search would run on: the `web-search.json` opt-in model
51
+ * when configured, else the current model — or `undefined` when neither has
52
+ * a provider-native search API (every `openai-completions` model).
53
+ */
54
+ export function getWebSearchModel(ctx: ExtensionContext): Promise<{ provider: string; id: string } | undefined>;
55
+ }
@@ -1,526 +0,0 @@
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.