one-code-extension 0.1.4 → 0.3.1

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 +222 -177
  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 +128 -0
  8. package/docs/guide/configuration.md +173 -0
  9. package/docs/guide/differences-from-claude-code.md +152 -0
  10. package/docs/guide/doctor.md +88 -0
  11. package/docs/guide/hooks.md +111 -0
  12. package/docs/guide/installation.md +126 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
  14. package/docs/guide/providers-and-models.md +196 -0
  15. package/docs/guide/reference.md +226 -0
  16. package/docs/guide/sessions-and-context.md +192 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +178 -0
  18. package/docs/guide/subagents-and-workflows.md +195 -0
  19. package/docs/guide/tasks-and-background-work.md +113 -0
  20. package/docs/guide/terminal-interface.md +157 -0
  21. package/docs/guide/tools.md +177 -0
  22. package/docs/guide/troubleshooting.md +177 -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 -217
  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,29 +1,51 @@
1
1
  /**
2
2
  * The classifier call itself — the one file here that talks to a provider.
3
3
  *
4
- * A one-shot `completeSimple` rather than an agent session: the classifier gets
5
- * no tools, no session, and no history beyond the user messages it is handed, so
6
- * there is nothing for it to be talked into doing. Its only output is a verdict.
4
+ * Claude Code's two-stage formula, reproduced (docs/decisions/auto-mode.md, P4):
5
+ * Stage 1 grades HARM ONLY (maxTokens 64). severity < 50 → allow, no stage 2.
6
+ * Stage 2 applies intent + ALLOW (maxTokens 8192, <thinking> CoT) → severity +
7
+ * <category> (+ our verified <intent>). Both stages share one system prompt and
8
+ * transcript byte-for-byte. Only the system prompt (the ~30k-token ruleset) is
9
+ * a cache hit across stages and calls: pi-ai marks a breakpoint on the last
10
+ * user block only, so the <transcript> (up to ~15k tokens) is re-read uncached
11
+ * by stage 2 and by every gated call on Anthropic-style providers. Known,
12
+ * deferred — docs/decisions/caching.md "Classifier transcript" and
13
+ * docs/upstream_prs.md #16 (fixable locally via pi-ai's `onPayload`).
7
14
  *
8
- * Model selection prefers a small fast model (Claude Code uses a Sonnet-class
9
- * model for this), overridable with `autoMode.classifierModel`, falling back to
10
- * the session's own model so auto mode still works on a single-model setup.
15
+ * A one-shot `completeSimple` per stage rather than an agent session: no tools,
16
+ * no history beyond the transcript it is handed, nothing to be talked into. Every
17
+ * failure path — no model, no credentials, timeout, provider error, truncated or
18
+ * unparseable reply — returns a block; a gate that cannot reach its classifier
19
+ * has approved nothing.
11
20
  */
12
21
 
13
- import type { Model, Api } from "@earendil-works/pi-ai";
22
+ import type { Model, Api, AssistantMessage, ThinkingLevel } from "@earendil-works/pi-ai";
14
23
  import { completeSimple } from "@earendil-works/pi-ai/compat";
15
24
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
25
+ import { forcedReasoningLevel, isReasoningMandatoryError, reasoningRetryLevel } from "../lib/model-policy.ts";
16
26
  import type { AutoModeConfig } from "./config.ts";
17
- import { buildClassifierPrompt, type ClassifyRequest, type ClassifyVerdict, parseVerdict } from "./prompt.ts";
27
+ import {
28
+ buildPayload,
29
+ type ClassifyRequest,
30
+ type ClassifyVerdict,
31
+ parseSeverity,
32
+ parseStage2,
33
+ reviewUser,
34
+ SEVERITY_THRESHOLD,
35
+ stage1User,
36
+ stage2User,
37
+ } from "./prompt.ts";
18
38
  import {
19
39
  type Candidate,
40
+ type ClassifierNotice,
20
41
  classifierCandidates,
21
42
  describeCandidate,
22
43
  isModelUnavailableError,
44
+ replyText,
45
+ withAuthBaseUrl,
23
46
  } from "./model-select.ts";
24
- import { indexRules } from "./rules.ts";
25
47
 
26
- /** A slow classifier stalls every tool call, so the wait is capped. */
48
+ /** A slow classifier stalls every tool call, so the wait is capped per stage. */
27
49
  export const CLASSIFIER_TIMEOUT_MS = 30_000;
28
50
 
29
51
  /**
@@ -41,9 +63,6 @@ export const ROTATE_AFTER_TIMEOUTS = 2;
41
63
  * (`deps.signal?.aborted`) before treating an abort as a timeout.
42
64
  */
43
65
  export function isClassifierTimeout(message: string): boolean {
44
- // `\babort` (no trailing boundary) so "abort", "aborted", and "aborting" all
45
- // match — the observed message is "Request aborted", which a trailing \b
46
- // would miss. The others are whole words.
47
66
  return /\babort|\btimed?\s*out\b|\btimeout\b|\betimedout\b|\bdeadline\b/i.test(message);
48
67
  }
49
68
 
@@ -61,14 +80,32 @@ export interface ClassifierState {
61
80
  notified: Set<string>;
62
81
  /**
63
82
  * Consecutive calls the pinned model has timed out on. Reset by any success;
64
- * at ROTATE_AFTER_TIMEOUTS the pin is released so the session re-picks rather
65
- * than staying bound to a model that has fallen behind demand.
83
+ * at ROTATE_AFTER_TIMEOUTS the pin is released so the session re-picks.
66
84
  */
67
85
  timeoutStreak: number;
86
+ /**
87
+ * The candidate chain cached for the current (session model, classifier config)
88
+ * signature, so the O(catalog) ranking is not rebuilt on every gated tool call.
89
+ * Invalidated only when the session model or the `classifierModel` setting
90
+ * changes — catalog churn is deliberately ignored, matching the pin, which
91
+ * already commits the session to one classifier. The live `rejected` filter is
92
+ * still applied to the cached chain per call, so a model that dies mid-session
93
+ * is still stepped over without a rebuild.
94
+ */
95
+ chainCache?: { signature: string; candidates: Candidate[]; notices: ClassifierNotice[] };
96
+ /**
97
+ * `provider/id` -> the thinking level a model turned out to REQUIRE (its
98
+ * provider rejected the thinking-off request with "reasoning is mandatory").
99
+ * Learned once per model via a single retry, then sent on every later call so
100
+ * the failed round-trip is never paid again. Only needed when the model's
101
+ * catalog entry lacks `thinkingLevelMap.off: null` (that case is handled
102
+ * proactively, without an error).
103
+ */
104
+ forcedReasoning: Map<string, ThinkingLevel>;
68
105
  }
69
106
 
70
107
  export function createClassifierState(): ClassifierState {
71
- return { rejected: new Set(), notified: new Set(), timeoutStreak: 0 };
108
+ return { rejected: new Set(), notified: new Set(), timeoutStreak: 0, forcedReasoning: new Map() };
72
109
  }
73
110
 
74
111
  export interface ClassifierDeps {
@@ -79,23 +116,66 @@ export interface ClassifierDeps {
79
116
  state: ClassifierState;
80
117
  /** Tell the user something once — which model is in use, or that theirs is dead. */
81
118
  onNotice?: (message: string, level: "info" | "warning") => void;
119
+ /** Report each classifier reply's usage, for the all-in footer cost. Observer
120
+ * only — it never affects the payload sent or the verdict parsed. */
121
+ onUsage?: (usage: unknown) => void;
122
+ /**
123
+ * A candidate the provider refused as not usable on this account (stepped
124
+ * over and recorded in `state.rejected`). The wiring republishes it on
125
+ * `lib/model-unusable.ts`'s channel so subagent selection — which shares this
126
+ * classifier's floor and therefore its first pick — skips it too.
127
+ */
128
+ onModelUnusable?: (model: string, reason: string) => void;
129
+ /**
130
+ * Completed-run subagent review: evaluate the sequence with a single stage-2
131
+ * style full-eval call instead of the two-stage gate (there is no harm floor
132
+ * to short-circuit, and the whole point is the intent/ALLOW judgment).
133
+ */
134
+ reviewOnly?: boolean;
135
+ }
136
+
137
+ /** How a single candidate's attempt failed, so the outer loop can react. */
138
+ type StepKind = "timeout" | "cancelled" | "unavailable" | "error" | "truncated";
139
+ class StepError extends Error {
140
+ constructor(
141
+ readonly kind: StepKind,
142
+ message: string,
143
+ ) {
144
+ super(message);
145
+ }
146
+ }
147
+
148
+ /** Signature that must stay equal for the cached chain to be reused (see ClassifierState.chainCache). */
149
+ function selectionSignature(deps: ClassifierDeps): string {
150
+ const session = deps.sessionModel ? `${deps.sessionModel.provider}/${deps.sessionModel.id}` : "(none)";
151
+ return `${session}|${deps.config.classifierModel ?? ""}|${deps.config.classifierModelSetFor ?? ""}`;
82
152
  }
83
153
 
84
- /** The candidate chain, minus anything already found unusable this session. */
85
- function remainingCandidates(deps: ClassifierDeps): Candidate[] {
86
- const all = classifierCandidates({
87
- available: deps.registry.getAvailable(),
88
- sessionModel: deps.sessionModel,
89
- configured: deps.config.classifierModel,
90
- });
154
+ /** The candidate chain (minus anything already unusable this session) and its notices. */
155
+ function remainingCandidates(deps: ClassifierDeps): { candidates: Candidate[]; notices: ClassifierNotice[] } {
156
+ const signature = selectionSignature(deps);
157
+ let cached = deps.state.chainCache;
158
+ if (!cached || cached.signature !== signature) {
159
+ const built = classifierCandidates({
160
+ available: deps.registry.getAvailable(),
161
+ sessionModel: deps.sessionModel,
162
+ configured: deps.config.classifierModel,
163
+ configuredSetForContainment: deps.config.classifierModelSetFor,
164
+ });
165
+ cached = { signature, candidates: built.candidates, notices: built.notices };
166
+ // Don't poison the cache with an empty chain (e.g. a not-yet-populated
167
+ // registry) — that would permanently block the gate; recompute next call.
168
+ if (built.candidates.length > 0) deps.state.chainCache = cached;
169
+ }
170
+ const { candidates: all, notices } = cached;
91
171
  const usable = all.filter((entry) => !deps.state.rejected.has(`${entry.model.provider}/${entry.model.id}`));
92
- if (usable.length > 0) return usable;
172
+ if (usable.length > 0) return { candidates: usable, notices };
93
173
  // If everything has been rejected, the session model is still worth one more
94
174
  // attempt: a gate that stops asking has stopped gating. It has to be the
95
- // session model specifically — the *last* chain entry is the cost-ranked
96
- // pick, which nothing price-chosen should lead, let alone be the sole retry.
175
+ // session model specifically — the *last* chain entry is the cost-ranked pick,
176
+ // which nothing price-chosen should lead, let alone be the sole retry.
97
177
  const session = all.filter((entry) => entry.source === "session");
98
- return session.length > 0 ? session : all.slice(-1);
178
+ return { candidates: session.length > 0 ? session : all.slice(-1), notices };
99
179
  }
100
180
 
101
181
  function notifyOnce(deps: ClassifierDeps, key: string, message: string, level: "info" | "warning") {
@@ -105,36 +185,35 @@ function notifyOnce(deps: ClassifierDeps, key: string, message: string, level: "
105
185
  }
106
186
 
107
187
  /**
108
- * Ask the classifier about one tool call. Every failure path — no model, no
109
- * credentials, timeout, provider error, unparseable reply — returns a block.
110
- * An approval gate that cannot reach its classifier has not approved anything.
188
+ * Ask the classifier about one tool call (or, with `reviewOnly`, one finished
189
+ * subagent run). See the module comment for the two-stage flow.
111
190
  */
112
191
  export async function classify(request: ClassifyRequest, deps: ClassifierDeps): Promise<ClassifyVerdict> {
113
- const candidates = remainingCandidates(deps);
192
+ const { candidates, notices } = remainingCandidates(deps);
114
193
  if (candidates.length === 0) {
115
194
  return { decision: "block", reason: "No model is available to run the auto-mode classifier.", tier: "unmatched" };
116
195
  }
117
196
 
118
- // A configured model that matches nothing available would otherwise fall
119
- // through in silence, leaving the user believing their setting is in force
120
- // while something else screens their calls.
121
- const configured = deps.config.classifierModel;
122
- if (configured && !candidates.some((entry) => entry.source === "configured")) {
123
- notifyOnce(
124
- deps,
125
- `unresolved:${configured}`,
126
- `autoMode.classifierModel is set to "${configured}", which is not an available model — check the name and that its provider is authenticated. Auto mode is using ${describeCandidate(candidates[0])} instead.`,
127
- "warning",
128
- );
129
- }
130
-
131
- const index = indexRules(deps.config);
132
- const { system, user } = buildClassifierPrompt(request, deps.config, index);
197
+ // Selection notices (a configured model unavailable or overridden as stale, a
198
+ // cross-provider setting honored) — surfaced once each at their own level, keyed
199
+ // by their text so an informational "honored" line is not shown as a warning.
200
+ for (const notice of notices) notifyOnce(deps, notice.text, notice.text, notice.level);
201
+
202
+ // buildPayload builds the ~110KB ruleset once and returns the grounding index
203
+ // derived from it, so the ruleset is not rebuilt/re-parsed a second time here.
204
+ const { system, userPrefix, index } = buildPayload(request);
205
+ const stage1Text = stage1User(userPrefix);
206
+ // A permission rule refused something this turn, so stage 2 gets the extra
207
+ // instruction that the user's intent does not excuse an equivalent-effect
208
+ // retry (prompt.ts STAGE2_DENIAL_ADDENDUM). Stage 1 grades harm only and is
209
+ // left byte-identical to CC's.
210
+ const afterRuleDenial = request.transcript.some((entry) => entry.kind === "denied");
211
+ const debug = process.env.CC_AUTO_MODE_DEBUG;
133
212
 
134
213
  // A model already pinned this session is tried first, so the classifier does
135
- // not change under the session while the pin is healthy. The rest of the
136
- // chain stays behind it: a pinned model can be withdrawn mid-session, and
137
- // the same call must then step onward rather than fail every call forever.
214
+ // not change under the session while the pin is healthy. The rest of the chain
215
+ // stays behind it: a pinned model can be withdrawn mid-session, and the same
216
+ // call must then step onward rather than fail every call forever.
138
217
  const pinned = deps.state.pinned;
139
218
  const attempts = pinned
140
219
  ? [
@@ -155,234 +234,233 @@ export async function classify(request: ClassifyRequest, deps: ClassifierDeps):
155
234
 
156
235
  let lastError = "";
157
236
  let sawTimeout = false;
158
- for (const candidate of attempts) {
237
+ let timedOutKey: string | undefined;
238
+ // Walks `attempts` in order; a candidate that turns out to require thinking is
239
+ // re-queued at the front for exactly one retry (see the reasoning-mandatory
240
+ // branch below). The `!== undefined` guard narrows `candidate`, so no cast.
241
+ const queue = [...attempts];
242
+ for (let candidate = queue.shift(); candidate !== undefined; candidate = queue.shift()) {
159
243
  const model = candidate.model;
160
244
  const key = `${model.provider}/${model.id}`;
161
245
 
162
246
  const auth = await deps.registry.getApiKeyAndHeaders(model);
163
247
  if (!auth.ok) {
248
+ // No working credentials for this model: a subagent on it would fail the
249
+ // same way, so it is published like a provider refusal.
164
250
  reject(key);
251
+ deps.onModelUnusable?.(key, auth.error);
165
252
  lastError = auth.error;
166
253
  continue;
167
254
  }
168
-
169
- // Some pi builds resolve a per-provider baseUrl alongside the key; it is not
170
- // in every published version of the auth type, so it is read defensively.
171
- const baseUrl = (auth as { baseUrl?: string }).baseUrl;
172
-
173
- const attempt = (maxTokens: number) => {
255
+ const resolvedModel = withAuthBaseUrl(model, auth);
256
+
257
+ // One provider call. `reasoning`/`temperature` are deliberately omitted: pi
258
+ // turns thinking off entirely when `reasoning` is absent (what a classifier
259
+ // wants), and `temperature` is deprecated/unsupported on several models and
260
+ // fails closed. See docs/decisions/auto-mode.md. The one exception: a model
261
+ // that CANNOT disable thinking (catalog-marked, or learned via the
262
+ // reasoning-mandatory retry below) gets its lowest supported level — the
263
+ // off-request would 400 and the gate would fail closed on every call.
264
+ const reasoning = deps.state.forcedReasoning.get(key) ?? forcedReasoningLevel(model);
265
+ const runCall = (userText: string, maxTokens: number, stage: number) => {
266
+ if (debug === "2") {
267
+ process.stderr.write(
268
+ `\n[auto-mode payload] stage=${stage} model=${key} maxTokens=${maxTokens} systemLen=${system.length} userLen=${userText.length}\n` +
269
+ `===SYSTEM===\n${system}\n===USER===\n${userText}\n===END===\n`,
270
+ );
271
+ }
174
272
  const timeout = AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS);
175
273
  return completeSimple(
176
- baseUrl ? ({ ...model, baseUrl } as Model<Api>) : model,
177
- { systemPrompt: system, messages: [{ role: "user", content: user, timestamp: Date.now() }] },
274
+ resolvedModel,
275
+ { systemPrompt: system, messages: [{ role: "user", content: userText, timestamp: Date.now() }] },
178
276
  {
179
277
  apiKey: auth.apiKey,
180
278
  headers: auth.headers,
181
279
  env: auth.env,
182
280
  signal: deps.signal ? AbortSignal.any([deps.signal, timeout]) : timeout,
183
- ...(process.env.CC_AUTO_MODE_DEBUG === "2"
184
- ? {
185
- onPayload: (payload: unknown) => {
186
- const body = payload as { system?: Array<Record<string, unknown>> };
187
- const blocks = (body.system ?? []).map(
188
- (b) =>
189
- `{keys=${Object.keys(b).join(",")} textLen=${String((b as { text?: string }).text ?? "").length} cache=${JSON.stringify((b as { cache_control?: unknown }).cache_control)}}`,
190
- );
191
- process.stderr.write(`[auto-mode payload] systemBlocks=${blocks.join(" ")}\n`);
192
- return undefined;
193
- },
194
- }
195
- : {}),
196
281
  maxTokens,
197
- // `reasoning` is deliberately omitted: pi turns thinking off entirely
198
- // when it is absent, which is what a classifier wants (fast, cheap,
199
- // deterministic). Passing even "minimal" enables thinking, and the
200
- // resulting budget is derived from maxTokens — at 512 that lands under
201
- // Anthropic's 1024-token floor and every request 400s, which the gate
202
- // then correctly but uselessly reports as a block.
203
- //
204
- // `temperature` is likewise NOT passed. It looks free — a classifier
205
- // wants determinism — but it is deprecated on Claude Sonnet 5 and
206
- // unsupported on Opus 4.7+, and pi's compat data still advertises it,
207
- // so the request 400s and the gate blocks every single call. Several
208
- // OpenAI reasoning models reject it too. A gate that must work across
209
- // 38 providers cannot afford an option that fails closed on some of
210
- // them for a property it does not need.
211
282
  cacheRetention: "long",
283
+ ...(reasoning ? { reasoning } : {}),
212
284
  },
213
285
  );
214
286
  };
215
287
 
216
- // A cut-off reply (stopReason "length") voids the verdict, so retry once with
217
- // real headroom. Used for both the initial call and the post-timeout retry.
218
- const attemptWithLengthRetry = async () => {
219
- let reply = await attempt(1024);
220
- if (reply.stopReason === "length") reply = await attempt(4096);
221
- return reply;
288
+ // Turn one reply into text, or throw a StepError the loop knows how to route.
289
+ // `big` is the retry cap for a length-truncated reply; a still-truncated reply
290
+ // at `big` is its own outcome (not a candidate to step past).
291
+ const call = async (userText: string, base: number, big: number, stage: number): Promise<string> => {
292
+ const inspect = (reply: AssistantMessage): string | "length" => {
293
+ deps.onUsage?.(reply.usage);
294
+ if (deps.signal?.aborted) throw new StepError("cancelled", "cancelled");
295
+ if (reply.stopReason === "error" || reply.stopReason === "aborted") {
296
+ const msg = reply.errorMessage ?? reply.stopReason ?? "provider error";
297
+ if (isClassifierTimeout(msg)) throw new StepError("timeout", msg);
298
+ throw new StepError(isModelUnavailableError(msg) ? "unavailable" : "error", msg);
299
+ }
300
+ if (reply.stopReason === "length") return "length";
301
+ return replyText(reply);
302
+ };
303
+
304
+ let reply = await runCall(userText, base, stage);
305
+ let seen: string | "length";
306
+ try {
307
+ seen = inspect(reply);
308
+ } catch (error) {
309
+ // One retry on a timeout, before giving up on this model — a stall under
310
+ // load usually clears on a second try. A user cancel or a substantive
311
+ // error is not retried.
312
+ if (error instanceof StepError && error.kind === "timeout") {
313
+ reply = await runCall(userText, base, stage);
314
+ seen = inspect(reply);
315
+ } else {
316
+ throw error;
317
+ }
318
+ }
319
+ if (seen !== "length") return seen;
320
+ // A cut-off reply voids the verdict, so retry once with real headroom.
321
+ reply = await runCall(userText, big, stage);
322
+ const retried = inspect(reply);
323
+ if (retried === "length") throw new StepError("truncated", "verdict truncated at maxTokens");
324
+ return retried;
222
325
  };
223
326
 
224
- let failure: string | undefined;
225
327
  try {
226
- // A verdict is one JSON object, but the cap needs headroom over "one
227
- // JSON object": models that reasoning cannot be disabled on (deepseek
228
- // via opencode, observed live) burn output budget on deliberation —
229
- // most on exactly the risky calls that deserve it — and a truncated
230
- // reply voids the verdict. So a cut-off reply gets one retry with real
231
- // headroom and a fresh timeout; the cap bounds runaway output, not
232
- // typical cost, since replies still stop at the closing brace.
233
- let reply = await attemptWithLengthRetry();
328
+ let verdict: ClassifyVerdict;
329
+ let stageInfo: string;
330
+
331
+ if (deps.reviewOnly) {
332
+ // A finished run has no harm floor to short-circuit — go straight to the
333
+ // full stage-2 evaluation, reframed to judge the whole sequence.
334
+ const text = await call(reviewUser(userPrefix), 1024, 4096, 2);
335
+ verdict = parseStage2(text, index, request.userMessages);
336
+ stageInfo = `review sev=${parseSeverity(text) ?? "?"}`;
337
+ } else {
338
+ // STAGE 1 — harm only. maxTokens 64 like CC; if the reply overruns 64
339
+ // without a readable severity, one retry with headroom recovers it.
340
+ const s1 = await call(stage1Text, 64, 1024, 1);
341
+ const sev1 = parseSeverity(s1);
342
+ if (sev1 !== null && sev1 < SEVERITY_THRESHOLD) {
343
+ verdict = { decision: "allow", reason: "", tier: "allow" };
344
+ stageInfo = `s1=${sev1} (cleared, no stage 2)`;
345
+ } else {
346
+ // sev1 >= 50, or unparseable → fall to the fuller stage-2 evaluation.
347
+ // stage2User is built here (not eagerly) so the common allow case
348
+ // does not concatenate the transcript prefix a second time.
349
+ const s2 = await call(stage2User(userPrefix, afterRuleDenial), 1024, 4096, 2);
350
+ verdict = parseStage2(s2, index, request.userMessages);
351
+ stageInfo = `s1=${sev1 ?? "?"} s2=${parseSeverity(s2) ?? "?"}`;
352
+ // Stage 1 flagged the call and stage 2 cleared it without quoting the
353
+ // user's intent or citing any rule. CC allows here (severity rules),
354
+ // so this stays an allow — but tagged, so the decision log and debug
355
+ // trace show a clear that rests on nothing checkable (review P11).
356
+ if (verdict.decision === "allow" && verdict.tier === "allow" && !verdict.ruleId) {
357
+ verdict = { ...verdict, ruleId: "unverified-clear" };
358
+ }
359
+ }
360
+ }
234
361
 
235
- // One retry on a timeout, before giving up on this model. A stall under
236
- // load usually clears on a second try; a model that is simply too slow
237
- // times out again, and the loop then steps to the next candidate (or, if
238
- // none remains, the call falls through to a user prompt). A *user* cancel
239
- // also aborts — that is not a timeout and must not be retried.
240
- if (
241
- (reply.stopReason === "aborted" || reply.stopReason === "error") &&
242
- !deps.signal?.aborted &&
243
- isClassifierTimeout(reply.errorMessage ?? reply.stopReason ?? "")
244
- ) {
245
- reply = await attemptWithLengthRetry();
362
+ // A verdict came back, so any building timeout streak is over.
363
+ deps.state.timeoutStreak = 0;
364
+ // Pin on the first success, so the classifier cannot change under the
365
+ // session and a working model is not re-litigated per call.
366
+ if (!deps.state.pinned) {
367
+ deps.state.pinned = model;
368
+ notifyOnce(deps, `using:${key}`, `Auto mode is screening calls with ${describeCandidate(candidate)}.`, "info");
369
+ }
370
+ if (debug) {
371
+ process.stderr.write(
372
+ `[auto-mode] ${key} ${request.toolName} → ${verdict.decision}` +
373
+ `${verdict.ruleId ? ` (${verdict.ruleId})` : ""} [${stageInfo}]\n` +
374
+ // An unverified intent quote is printed whole: the next mismatch class
375
+ // (2026-09-05 H4 was wrapping quotation marks) is only visible in the
376
+ // exact text the model produced.
377
+ (verdict.raw ? ` raw: ${verdict.ruleId === "intent-unverified" ? verdict.raw : verdict.raw.slice(0, 300)}\n` : ""),
378
+ );
246
379
  }
380
+ return verdict;
381
+ } catch (error) {
382
+ // A non-StepError is an unexpected throw; treat it as a substantive
383
+ // "error" (surface it, don't step past it) — same as StepError("error").
384
+ const kind: StepKind = error instanceof StepError ? error.kind : "error";
385
+ lastError = error instanceof Error ? error.message : String(error);
247
386
 
248
- if (reply.stopReason === "error" || reply.stopReason === "aborted") {
249
- failure = reply.errorMessage ?? reply.stopReason;
250
- } else if (reply.stopReason === "length") {
251
- // Still cut off at 4096 — same treatment as an unparseable reply (a
252
- // verdict that cannot be read approved nothing), but named, because
253
- // "unreadable response" sent a live debugging session hunting for
254
- // JSON bugs when the real cause was the output budget.
255
- if (process.env.CC_AUTO_MODE_DEBUG) {
256
- const u = reply.usage as { input?: number; output?: number; reasoning?: number } | undefined;
257
- process.stderr.write(
258
- `[auto-mode] ${key} ${request.toolName} → verdict truncated at maxTokens ` +
259
- `(in=${u?.input ?? "?"} out=${u?.output ?? "?"} reasoning=${u?.reasoning ?? 0})\n`,
260
- );
261
- }
387
+ if (kind === "cancelled") {
388
+ return { decision: "block", reason: "Auto-mode classification was cancelled.", tier: "unmatched" };
389
+ }
390
+ if (kind === "truncated") {
391
+ if (debug) process.stderr.write(`[auto-mode] ${key} ${request.toolName} → verdict truncated at maxTokens\n`);
262
392
  return {
263
393
  decision: "block",
264
394
  reason:
265
- "The approval classifier's reply was cut off by its output limit before the verdict completed — its analysis ran too long. If this keeps happening, pin a stronger classifier model with /auto-mode model.",
395
+ "The approval classifier's reply was cut off by its output limit before the verdict completed. If this keeps happening, pin a stronger classifier model with /auto-mode model.",
266
396
  tier: "unmatched",
267
397
  };
268
- } else {
269
- const text = reply.content
270
- .filter((block): block is { type: "text"; text: string } => block.type === "text")
271
- .map((block) => block.text)
272
- .join("");
273
- const verdict = parseVerdict(text, index, request.userMessages);
274
-
275
- // A verdict came back, so the timeout streak that might have been
276
- // building is over.
277
- deps.state.timeoutStreak = 0;
278
-
279
- // Pin on the first success, so the classifier cannot change under the
280
- // session and a working model is not re-litigated per call.
281
- if (!deps.state.pinned) {
282
- deps.state.pinned = model;
283
- notifyOnce(deps, `using:${key}`, `Auto mode is screening calls with ${describeCandidate(candidate)}.`, "info");
284
- }
285
-
286
- // Allows carry no reason, so without this the permissive direction is
287
- // invisible — which is the harder one to notice and the more costly one
288
- // to get wrong. `CC_AUTO_MODE_DEBUG=1` puts the raw reply on stderr.
289
- if (process.env.CC_AUTO_MODE_DEBUG) {
290
- const u = reply.usage as
291
- | {
292
- input?: number;
293
- output?: number;
294
- reasoning?: number;
295
- cacheRead?: number;
296
- cacheWrite?: number;
297
- cost?: unknown;
298
- }
299
- | undefined;
300
- const cost = u?.cost;
301
- const spend =
302
- cost && typeof cost === "object"
303
- ? Object.entries(cost as Record<string, unknown>)
304
- .filter(([, value]) => typeof value === "number" && value > 0)
305
- .map(([name, value]) => `${name}=$${(value as number).toFixed(6)}`)
306
- .join(" ")
307
- : "";
308
- process.stderr.write(
309
- `[auto-mode] ${key} ${request.toolName} → ${verdict.decision}${verdict.ruleId ? ` (${verdict.ruleId})` : ""}\n` +
310
- ` usage: in=${u?.input ?? "?"} out=${u?.output ?? "?"} reasoning=${u?.reasoning ?? 0} cacheRead=${u?.cacheRead ?? 0} cacheWrite=${u?.cacheWrite ?? 0} ${spend}\n` +
311
- ` raw: ${text.replace(/\s+/g, " ").slice(0, 400)}\n`,
398
+ }
399
+ if (kind === "error") {
400
+ // The provider refused the thinking-off request outright (metadata gap:
401
+ // no `thinkingLevelMap.off: null` on this catalog entry). Learn the
402
+ // model's floor once and retry the same candidate with it.
403
+ if (!reasoning && isReasoningMandatoryError(lastError)) {
404
+ const level = reasoningRetryLevel(model);
405
+ deps.state.forcedReasoning.set(key, level);
406
+ notifyOnce(
407
+ deps,
408
+ `forced-reasoning:${key}`,
409
+ `${key} cannot run with thinking disabled; the classifier now runs it at ${level} thinking.`,
410
+ "info",
312
411
  );
412
+ queue.unshift(candidate);
413
+ continue;
313
414
  }
314
- return verdict;
415
+ // A substantive, non-transient failure that is not "model unusable here":
416
+ // surface it rather than papering over something about to clear.
417
+ return { decision: "block", reason: `Auto-mode classifier could not be reached (${lastError}).`, tier: "unmatched" };
315
418
  }
316
- } catch (error) {
317
- failure = error instanceof Error ? error.message : String(error);
318
- }
319
-
320
- lastError = failure ?? "unknown error";
321
-
322
- // A user cancel aborts the same way a timeout does. It is not a failure to
323
- // route around — the user asked to stop, so stop.
324
- if (deps.signal?.aborted) {
325
- return { decision: "block", reason: "Auto-mode classification was cancelled.", tier: "unmatched" };
326
- }
327
-
328
- // A timeout (after the one retry above) is transient, so the model is NOT
329
- // rejected — it may be fine on the next call. Instead we step to the next
330
- // candidate, which is a different model of the *same or cheaper* price in
331
- // the same provider (the chain is privacy- and budget-contained upstream).
332
- // A pinned model that keeps timing out is unpinned so the session re-picks.
333
- if (isClassifierTimeout(lastError)) {
334
- sawTimeout = true;
335
- if (deps.state.pinned && `${deps.state.pinned.provider}/${deps.state.pinned.id}` === key) {
336
- deps.state.timeoutStreak += 1;
337
- if (deps.state.timeoutStreak >= ROTATE_AFTER_TIMEOUTS) deps.state.pinned = undefined;
419
+ if (kind === "timeout") {
420
+ // Transient — do NOT reject the model, it may be fine next call. Step to
421
+ // the next candidate; a pinned model that keeps timing out is unpinned.
422
+ sawTimeout = true;
423
+ timedOutKey = key;
424
+ if (deps.state.pinned && `${deps.state.pinned.provider}/${deps.state.pinned.id}` === key) {
425
+ deps.state.timeoutStreak += 1;
426
+ if (deps.state.timeoutStreak >= ROTATE_AFTER_TIMEOUTS) deps.state.pinned = undefined;
427
+ }
428
+ continue;
338
429
  }
339
- continue;
340
- }
341
-
342
- // A model that is simply not usable here is worth stepping over. Any other
343
- // transient failure is not: switching models would paper over something
344
- // about to clear, so that surfaces as a block and the same model is tried
345
- // again next call.
346
- if (!isModelUnavailableError(lastError)) {
347
- return {
348
- decision: "block",
349
- reason: `Auto-mode classifier could not be reached (${lastError}).`,
350
- tier: "unmatched",
351
- };
430
+ // unavailable: this model is not usable here — step over it and record it.
431
+ reject(key);
432
+ deps.onModelUnusable?.(key, lastError);
433
+ const isConfigured = candidate.source === "configured";
434
+ notifyOnce(
435
+ deps,
436
+ `rejected:${key}`,
437
+ isConfigured
438
+ ? `Auto mode cannot use ${key} from autoMode.classifierModel (${lastError}). Set a different model in ~/.onecode/settings.json.`
439
+ : `Auto mode cannot use ${key} as its classifier (${lastError}); trying another model. Set autoMode.classifierModel in ~/.onecode/settings.json to choose one.`,
440
+ "warning",
441
+ );
352
442
  }
353
-
354
- reject(key);
355
- const isConfigured = candidate.source === "configured";
356
- notifyOnce(
357
- deps,
358
- `rejected:${key}`,
359
- isConfigured
360
- ? `Auto mode cannot use ${key} from autoMode.classifierModel (${lastError}). Set a different model in ~/.claude/settings.json.`
361
- : `Auto mode cannot use ${key} as its classifier (${lastError}); trying another model. Set autoMode.classifierModel in ~/.claude/settings.json to choose one.`,
362
- "warning",
363
- );
364
443
  }
365
444
 
366
- // Every candidate timed out (with a retry each) and none was reachable in
367
- // time. This is NOT a judgement that the call is unsafe — it was never
368
- // screened — so it is surfaced as its own tier. The gate turns a `timeout`
369
- // into a user prompt where it can (there is someone to ask), and only blocks
370
- // outright when running non-interactively.
445
+ // Every candidate timed out (with a retry each) and none was reachable in time.
446
+ // This is NOT a judgement that the call is unsafe — it was never screened — so
447
+ // it is surfaced as its own tier. The gate turns a timeout into a user prompt
448
+ // where it can, and only blocks outright when running non-interactively.
371
449
  if (sawTimeout) {
450
+ const model = timedOutKey ? timedOutKey.split("/").pop() : undefined;
372
451
  return {
373
452
  decision: "block",
374
453
  tier: "timeout",
375
454
  reason:
376
- "Auto mode could not screen this call in time — the approval classifier timed out, so the call was not judged either way. " +
377
- "Approve it if you recognise it as safe, or pin a faster classifier with /auto-mode model.",
455
+ `Auto mode could not screen this ${request.toolName} call in time — the approval classifier${model ? ` (${model})` : ""} ` +
456
+ "is temporarily unavailable (timed out), so the call was not judged either way.",
378
457
  };
379
458
  }
380
459
 
381
- // Every candidate was unusable — say which knob fixes it rather than leaving
382
- // the user with a gate that blocks everything for no stated reason.
460
+ // Every candidate was unusable — say which knob fixes it.
383
461
  return {
384
462
  decision: "block",
385
- reason: `No usable auto-mode classifier model (last error: ${lastError}). Set autoMode.classifierModel in ~/.claude/settings.json.`,
463
+ reason: `No usable auto-mode classifier model (last error: ${lastError}). Set autoMode.classifierModel in ~/.onecode/settings.json.`,
386
464
  tier: "unmatched",
387
465
  };
388
466
  }