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
@@ -15,6 +15,8 @@
15
15
  * assume (see model-select.ts).
16
16
  */
17
17
 
18
+ import { truncateLine } from "../lib/tui-render.ts";
19
+
18
20
  export interface PickerEntry {
19
21
  provider: string;
20
22
  id: string;
@@ -143,7 +145,7 @@ export function modelPickerComponent(
143
145
  tui: { requestRender(): void },
144
146
  theme: unknown,
145
147
  done: (choice: PickerEntry | null) => void,
146
- ): { render(): string[]; handleInput(data: string): void; invalidate(): void } {
148
+ ): { render(width: number): string[]; handleInput(data: string): void; invalidate(): void } {
147
149
  const paint: Paint = (color, text) => {
148
150
  const themed = theme as { fg?(c: string, t: string): string } | undefined;
149
151
  try {
@@ -155,23 +157,34 @@ export function modelPickerComponent(
155
157
  let query = "";
156
158
  let index = 0;
157
159
  let filtered = options.entries;
160
+ // pi calls render(width) every frame and the picker repaints per keystroke;
161
+ // memoize by width and clear on any state change so a wide default subtitle
162
+ // (115 columns) never reaches the wire uncut — an overwide line crashes pi
163
+ // in regular TUI mode (TUI-REVIEW H2).
164
+ let cache: { width: number; lines: string[] } | undefined;
158
165
  return {
159
- render: () => [
160
- "",
161
- ...renderModelPicker(
162
- {
163
- entries: filtered,
164
- index,
165
- query,
166
- total: options.entries.length,
167
- current: options.current,
168
- title: options.title,
169
- subtitle: options.subtitle,
170
- },
171
- paint,
172
- ),
173
- "",
174
- ],
166
+ render: (width: number) => {
167
+ if (cache && cache.width === width) return cache.lines;
168
+ const lines = [
169
+ "",
170
+ ...renderModelPicker(
171
+ {
172
+ entries: filtered,
173
+ index,
174
+ query,
175
+ total: options.entries.length,
176
+ current: options.current,
177
+ title: options.title,
178
+ subtitle: options.subtitle,
179
+ },
180
+ paint,
181
+ width,
182
+ ),
183
+ "",
184
+ ];
185
+ cache = { width, lines };
186
+ return lines;
187
+ },
175
188
  handleInput: (data: string) => {
176
189
  const key = decodePickerKey(data);
177
190
  if (!key) return;
@@ -184,27 +197,36 @@ export function modelPickerComponent(
184
197
  filtered = filterEntries(options.entries, query);
185
198
  index = 0;
186
199
  }
200
+ cache = undefined;
187
201
  tui.requestRender();
188
202
  },
189
- invalidate: () => {},
203
+ invalidate: () => {
204
+ cache = undefined;
205
+ },
190
206
  };
191
207
  }
192
208
 
193
- export function renderModelPicker(view: PickerView, paint: Paint): string[] {
209
+ /** Key hint, kept on its own line so it survives at narrow widths. */
210
+ const PICKER_HINT = "type to filter · ↑/↓ · enter · esc";
211
+ /** Default privacy note for the classifier picker (the key hint rides its own line below). */
212
+ const CLASSIFIER_SUBTITLE = "It reads your prompts and CLAUDE.md — picking another provider sends them there.";
213
+
214
+ export function renderModelPicker(view: PickerView, paint: Paint, width = Infinity): string[] {
194
215
  const maxVisible = view.maxVisible ?? 10;
216
+ // Every line is cut to `width` — pi-tui crashes the whole app on a rendered
217
+ // line wider than the terminal (TUI-REVIEW H2). The default Infinity leaves
218
+ // lines uncut (truncateLine returns them unchanged) for unit tests.
219
+ const cut = (line: string): string => truncateLine(line, width);
195
220
  const lines: string[] = [];
196
- lines.push(paint("accent", view.title ?? "Select the auto-mode classifier model"));
197
- lines.push(
198
- paint(
199
- "dim",
200
- view.subtitle ??
201
- "It reads your prompts and CLAUDE.md — picking another provider sends them there. type to filter · ↑/↓ · enter · esc",
202
- ),
203
- );
204
- lines.push(` filter: ${view.query}${paint("dim", "▏")}`);
221
+ lines.push(cut(paint("accent", view.title ?? "Select the auto-mode classifier model")));
222
+ lines.push(cut(paint("dim", view.subtitle ?? CLASSIFIER_SUBTITLE)));
223
+ // The key hint is always its own line, so a narrow terminal never clips the
224
+ // only place the keys are documented.
225
+ lines.push(cut(paint("dim", ` ${PICKER_HINT}`)));
226
+ lines.push(cut(` filter: ${view.query}${paint("dim", "▏")}`));
205
227
 
206
228
  if (view.entries.length === 0) {
207
- lines.push(paint("warning", " (no available model matches)"));
229
+ lines.push(cut(paint("warning", " (no available model matches)")));
208
230
  return lines;
209
231
  }
210
232
 
@@ -216,10 +238,10 @@ export function renderModelPicker(view: PickerView, paint: Paint): string[] {
216
238
  .filter(Boolean)
217
239
  .join(" ");
218
240
  const suffix = annotations ? ` ${paint("dim", annotations)}` : "";
219
- lines.push(at === view.index ? `${paint("accent", `❯ ${spec}`)}${suffix}` : ` ${spec}${suffix}`);
241
+ lines.push(cut(at === view.index ? `${paint("accent", `❯ ${spec}`)}${suffix}` : ` ${spec}${suffix}`));
220
242
  }
221
243
  if (view.entries.length > maxVisible || view.entries.length < view.total) {
222
- lines.push(paint("dim", ` ${view.entries.length} of ${view.total} models${view.query ? " match" : ""}`));
244
+ lines.push(cut(paint("dim", ` ${view.entries.length} of ${view.total} models${view.query ? " match" : ""}`)));
223
245
  }
224
246
  return lines;
225
247
  }
@@ -26,22 +26,22 @@
26
26
  * match" is arbitrary. Cost is the one signal every provider carries.
27
27
  */
28
28
 
29
- import type { Api, Model } from "@earendil-works/pi-ai";
29
+ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
30
30
  import {
31
+ crossesProvider,
31
32
  findConfigured,
32
- findRoleProfileModel,
33
- hintRank,
33
+ isStaleContainmentStamp,
34
34
  modelIdentity,
35
- modelsContainedToSession,
36
- pricedInput,
35
+ modelSpec as spec,
37
36
  } from "../lib/model-policy.ts";
37
+ import { capableContainedCandidates } from "../lib/model-tier.ts";
38
38
 
39
39
  export { findConfigured } from "../lib/model-policy.ts";
40
40
 
41
41
  export interface Candidate {
42
42
  model: Model<Api>;
43
43
  /** How this candidate was arrived at, for the notice shown to the user. */
44
- source: "configured" | "role-profile" | "cheapest-in-provider" | "session";
44
+ source: "configured" | "economical" | "session";
45
45
  }
46
46
 
47
47
  export interface SelectInput {
@@ -50,92 +50,141 @@ export interface SelectInput {
50
50
  sessionModel: Model<Api> | undefined;
51
51
  /** `autoMode.classifierModel`, if set. May name any provider. */
52
52
  configured?: string;
53
+ /**
54
+ * The containment identity (`modelIdentity().containment`) the `classifierModel`
55
+ * setting was stamped for when set via `/auto-mode model`. When it differs from
56
+ * the current session's containment, a cross-provider setting is treated as
57
+ * stale (set for a session since left) and overridden with a warning — the same
58
+ * shape as the subagent `subagentModelSetFor` stamp. Undefined for a hand-edited
59
+ * setting.
60
+ */
61
+ configuredSetForContainment?: string;
62
+ }
63
+
64
+ /** A user-facing selection notice, tagged so an informational one is not shown as a warning. */
65
+ export interface ClassifierNotice {
66
+ level: "info" | "warning";
67
+ text: string;
53
68
  }
54
69
 
55
70
  /**
56
- * The ordered candidate chain. More than one is returned so a candidate that
57
- * turns out to be unusable at call time (not entitled on this account, withdrawn
58
- * by the provider) can be stepped over instead of failing every tool call — the
59
- * same shape as pi's subagent `fallbackModels`.
71
+ * The ordered candidate chain plus any user-facing notices. More than one
72
+ * candidate is returned so one that turns out to be unusable at call time (not
73
+ * entitled on this account, withdrawn by the provider) can be stepped over
74
+ * instead of failing every tool call — the same shape as pi's subagent
75
+ * `fallbackModels`.
60
76
  *
61
77
  * The chain always ends at the session model when there is one, so auto mode
62
78
  * degrades to "correct but not cheap" rather than to "broken".
79
+ *
80
+ * The automatic pick is the SAME floor-gated selector the subagent default uses
81
+ * (`capableContainedCandidates`): the cheapest same-provider model (cheap →
82
+ * workhorse → frontier, never `tiny`) at or above `automaticTierFloor` — a
83
+ * session on a workhorse-or-better model is screened by a workhorse-or-better
84
+ * model, Claude Code's `min(main, sonnet)`. `docs/decisions/auto-mode.md`
85
+ * measured the same `rm -rf` grading stage-1 62 on Sonnet and ~22 on Haiku —
86
+ * "a weak classifier is a weak boundary" — yet until 2026-09-05 the selector
87
+ * picked Haiku even when the session itself was Sonnet, so the gate's threshold
88
+ * behaviour depended on which model happened to be cheapest
89
+ * (PERMISSIONS-REVIEW-2026-09-05 M6). A cheap session keeps a cheap screener
90
+ * (nothing cheaper and capable exists), and `autoMode.classifierModel`
91
+ * overrides either way. The `tiny` exclusion is the capability floor
92
+ * `auto-mode.md` recorded as still-missing. Since 2026-09-11 subagents share
93
+ * the floor, so a session screens and delegates on one model.
94
+ *
95
+ * The floor governs the AUTOMATIC pick only; the terminal fallback is never
96
+ * refused, so the chain is empty only when there is no session model and nothing
97
+ * available at all.
63
98
  */
64
- export function classifierCandidates({ available, sessionModel, configured }: SelectInput): Candidate[] {
99
+ export function classifierCandidates({
100
+ available,
101
+ sessionModel,
102
+ configured,
103
+ configuredSetForContainment,
104
+ }: SelectInput): { candidates: Candidate[]; notices: ClassifierNotice[] } {
65
105
  const candidates: Candidate[] = [];
106
+ const notices: ClassifierNotice[] = [];
66
107
  const push = (model: Model<Api> | undefined, source: Candidate["source"]) => {
67
108
  if (!model) return;
68
109
  if (candidates.some((entry) => entry.model.provider === model.provider && entry.model.id === model.id)) return;
69
110
  candidates.push({ model, source });
70
111
  };
71
112
 
72
- // 1. What the user asked for, wherever it lives. Naming a provider is choosing it.
73
- if (configured) push(findConfigured(available, configured), "configured");
74
-
75
- /**
76
- * Containment is shared with subagents. For a direct provider it is the
77
- * provider itself; gateways additionally preserve a reliable route/family;
78
- * opaque routers expose only the session model.
79
- */
80
- const inProvider = sessionModel ? modelsContainedToSession(available, sessionModel) : [];
81
-
82
- /**
83
- * No candidate may cost more per token than the model doing the actual work.
84
- * Screening a call more expensively than making it is indefensible, and it
85
- * happened: on an OpenRouter session at `z-ai/glm-4.6` ($0.50/M) the table's
86
- * `anthropic/claude-haiku-4.5` ($1/M) was selected, because this ceiling was
87
- * originally applied only to the cost-ranked branch and not to the table.
88
- */
89
- const sessionPrice = sessionModel ? pricedInput(sessionModel) : undefined;
90
- const withinBudget = (model: Model<Api>) => {
91
- if (sessionPrice === undefined) return true;
92
- const price = pricedInput(model);
93
- return price === undefined || price <= sessionPrice;
94
- };
113
+ // 1. What the user asked for. Naming a provider is choosing it — but a setting
114
+ // stamped for a provider this session has since left is stale and overridden
115
+ // with a warning (parity with the subagent setting); a genuine cross-provider
116
+ // choice made for THIS session is honored with an announcement, because the
117
+ // classifier reads the user's prompts and CLAUDE.md.
118
+ const resolved = configured ? findConfigured(available, configured) : undefined;
119
+ if (resolved) {
120
+ if (sessionModel && crossesProvider(resolved, sessionModel)) {
121
+ if (isStaleContainmentStamp(configuredSetForContainment, sessionModel)) {
122
+ notices.push({
123
+ level: "warning",
124
+ text:
125
+ `autoMode.classifierModel ${spec(resolved)} was set for a different provider than this session (${spec(sessionModel)}); ` +
126
+ "a same-provider model screens calls instead. Re-set it with /auto-mode model on this session to use it here.",
127
+ });
128
+ } else {
129
+ // Informational, not a warning: the user deliberately set this for this
130
+ // session, so it is doing exactly what they asked.
131
+ notices.push({
132
+ level: "info",
133
+ text:
134
+ `autoMode.classifierModel ${spec(resolved)} is a different provider than this session (${spec(sessionModel)}) — ` +
135
+ "it reads your prompts and CLAUDE.md, so those go to that provider. It was set for this session, so it is honored.",
136
+ });
137
+ push(resolved, "configured");
138
+ }
139
+ } else {
140
+ push(resolved, "configured");
141
+ }
142
+ }
95
143
 
96
- // 2. A reviewed small-but-capable model for this provider/family. The
97
- // profile is short and applied to the live catalog; price alone never
98
- // promotes a model into the classifier role.
144
+ // 2. The cheapest capable same-provider model, no dearer than the work being
145
+ // screened — screening a call more expensively than making it is
146
+ // indefensible. The shared gate excludes `tiny`, unpriced/opaque rows, and
147
+ // the session model itself, so an unpriced provider yields nothing here and
148
+ // the session model (step 3) screens the calls.
149
+ // With an Artificial Analysis snapshot (an optional user key —
150
+ // lib/capability-index.ts) the floor is MEASURED: a candidate whose coding
151
+ // index reaches min(session, Sonnet 5), on the thinking-off variant the
152
+ // classifier actually runs, screens — Claude Code's min(main, Sonnet) taken
153
+ // literally, so a flash-class model that measurably matches its vendor's
154
+ // flagship may screen it. A measured failure is never admitted; an
155
+ // unscored candidate (no key, no confirmed match) is judged by the
156
+ // name-class tier floor below, exactly as before the snapshot existed.
99
157
  if (sessionModel) {
100
- // Reuse the containment set computed above instead of recomputing it inside.
101
- push(findRoleProfileModel(available, sessionModel, "classifier", withinBudget, inProvider), "role-profile");
158
+ for (const model of capableContainedCandidates(available, sessionModel, "classifier")) push(model, "economical");
102
159
  }
103
160
 
104
- // 3. The cheapest genuinely-priced model in the provider.
105
- const cheaper = inProvider
106
- .map((model) => ({ model, price: pricedInput(model) }))
107
- .filter((entry): entry is { model: Model<Api>; price: number } => entry.price !== undefined)
108
- .filter((entry) => withinBudget(entry.model))
109
- .sort((a, b) => a.price - b.price || hintRank(a.model) - hintRank(b.model));
110
- const cheapest = cheaper[0]?.model;
111
-
112
- /**
113
- * 4. The session's own model, ahead of the cost-ranked pick.
114
- *
115
- * Price is not evidence of suitability, and a name is barely better. On
116
- * OpenRouter the cheapest model in the catalog is `inclusionai/ling-2.6-flash`
117
- * at $0.01/M, which would otherwise become the security boundary for being
118
- * cheap. An attempt to gate that on the name carrying a known small-model word
119
- * failed on the same example — "flash" is in the name, because any vendor may
120
- * put it there; the word means "someone called this small", not "this family is
121
- * known good".
122
- *
123
- * So nothing chosen on price alone leads. Providers that *are* vetted get their
124
- * saving from the table above (which covers OpenRouter and every mainstream
125
- * provider); anywhere else the model the user already trusted for the real work
126
- * screens the calls — correct, merely not cheap — and `classifierModel` is
127
- * there for someone who wants otherwise.
128
- */
161
+ // 3. The session's own model: always correct, just not cheap. Terminal
162
+ // fallback, so the gate degrades to screening on it rather than to broken.
163
+ // No tier floor here: a floor that refused a `tiny` session model would
164
+ // disarm auto mode on exactly the sessions least able to do without it,
165
+ // on a price-and-name heuristic rather than measured competence. The
166
+ // circumvention of a permission rule that motivated one is handled by
167
+ // giving the classifier the user's own deny rules to judge by effect
168
+ // (permissions/rule-prose.ts), not by picking a different screener.
129
169
  push(sessionModel, "session");
130
170
 
131
- // 5. Last resort, for when even the session model cannot serve.
132
- push(cheapest, "cheapest-in-provider");
133
-
134
171
  // Nothing configured and no session model (a headless run with a bare
135
172
  // registry) — take anything available rather than refusing outright.
136
173
  if (candidates.length === 0) push(available[0], "session");
137
174
 
138
- return candidates;
175
+ // A configured spec that matched nothing available would otherwise fall through
176
+ // in silence, leaving the user believing their setting is in force.
177
+ if (configured && !resolved) {
178
+ const instead = candidates[0];
179
+ notices.push({
180
+ level: "warning",
181
+ text:
182
+ `autoMode.classifierModel is set to "${configured}", which is not an available model — check the name and that its provider is authenticated.` +
183
+ (instead ? ` Auto mode is using ${describeCandidate(instead)} instead.` : ""),
184
+ });
185
+ }
186
+
187
+ return { candidates, notices };
139
188
  }
140
189
 
141
190
  /**
@@ -149,30 +198,52 @@ export function classifierCandidates({ available, sessionModel, configured }: Se
149
198
  * model for the whole session over a blip. Misreading billing as transient
150
199
  * merely retries and blocks noisily; misreading a blip as permanent bricks the
151
200
  * candidate — so uncertainty goes to transient.
201
+ *
202
+ * "not supported" is here because a plan/entitlement refusal is often phrased
203
+ * that way rather than as a 403: Codex answers a classifier call on
204
+ * `gpt-5.3-codex-spark` with "The 'gpt-5.3-codex-spark' model is not supported
205
+ * when using Codex with a ChatGPT account." Without the match that read as a
206
+ * substantive error, so the chain never stepped past the cheapest-capable pick
207
+ * and auto mode blocked every call of the session (findings §10.19). It is
208
+ * anchored to the word "model" ahead of it, because plenty of REQUEST-shape
209
+ * complaints are phrased the same way ("streaming is not supported",
210
+ * "response_format is not supported for this model") and those are fixable
211
+ * quirks of one call, not a model this account can never use.
152
212
  */
153
213
  export function isModelUnavailableError(message: string): boolean {
154
- return /\b(401|403|404)\b|not_found|not found|does not exist|(no|have|lacks?)\s+access|unauthoriz|forbidden|invalid[_ -]?model|model[_ -]?not|unsupported[_ -]?model|no such model|entitl|insufficient[_ -]?quota|exceeded your current quota|billing/i.test(
214
+ return /\b(401|403|404)\b|not_found|not found|does not exist|(no|have|lacks?)\s+access|unauthoriz|forbidden|invalid[_ -]?model|model[_ -]?not|unsupported[_ -]?model|\bmodel\b[^.\n]{0,40}not supported|no such model|entitl|insufficient[_ -]?quota|exceeded your current quota|billing/i.test(
155
215
  message,
156
216
  );
157
217
  }
158
218
 
159
219
  /** One-line description for the notice and `/auto-mode config`. */
160
220
  export function describeCandidate(candidate: Candidate): string {
161
- const name = `${candidate.model.provider}/${candidate.model.id}`;
221
+ const name = spec(candidate.model);
222
+ const where = modelIdentity(candidate.model).profile ?? candidate.model.provider;
162
223
  switch (candidate.source) {
163
224
  case "configured":
164
225
  return `${name} (from autoMode.classifierModel)`;
165
- case "role-profile": {
166
- const where = modelIdentity(candidate.model).profile ?? candidate.model.provider;
167
- return `${name} (classifier profile for ${where})`;
168
- }
169
- case "cheapest-in-provider": {
170
- const where = modelIdentity(candidate.model).profile ?? candidate.model.provider;
171
- return `${name} (cheapest available within ${where})`;
172
- }
173
- case "session": {
174
- const where = modelIdentity(candidate.model).profile ?? candidate.model.provider;
175
- return `${name} (this session's model — no vetted smaller model within ${where})`;
176
- }
226
+ case "economical":
227
+ return `${name} (cheapest model within ${where} that meets the capability floor — measured coding index when an Artificial Analysis snapshot is cached, else the session's tier)`;
228
+ case "session":
229
+ return `${name} (this session's model — nothing cheaper within ${where} meets the capability floor)`;
177
230
  }
178
231
  }
232
+
233
+ /** Concatenated text blocks of a one-shot reply (classifier verdicts, setup drafts). */
234
+ export function replyText(reply: AssistantMessage): string {
235
+ return reply.content
236
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
237
+ .map((block) => block.text)
238
+ .join("");
239
+ }
240
+
241
+ /**
242
+ * Some pi builds resolve a per-provider baseUrl alongside the API key; it is
243
+ * not in every published version of the auth type, so it is read defensively
244
+ * and, when present, carried onto the model for the call.
245
+ */
246
+ export function withAuthBaseUrl(model: Model<Api>, auth: unknown): Model<Api> {
247
+ const baseUrl = (auth as { baseUrl?: string }).baseUrl;
248
+ return baseUrl ? ({ ...model, baseUrl } as Model<Api>) : model;
249
+ }
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { lstatSync, readlinkSync, realpathSync } from "node:fs";
19
19
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
20
+ import { expandTilde } from "../lib/paths.ts";
20
21
 
21
22
  /**
22
23
  * Tools whose calls write to a path. Lives here — the lowest shared layer both
@@ -29,6 +30,17 @@ export function isWritingTool(toolName: string): boolean {
29
30
  return WRITING_TOOLS.has(toolName);
30
31
  }
31
32
 
33
+ /**
34
+ * The path a file tool call targets (`path` for pi's built-ins, `file_path`
35
+ * for Claude Code-shaped calls, `notebook_path` for notebook_edit), or
36
+ * undefined. The one place that knows the field names, shared by every
37
+ * path-based gate.
38
+ */
39
+ export function pathArgument(input: Record<string, unknown> | undefined): string | undefined {
40
+ const path = input?.path ?? input?.file_path ?? input?.notebook_path;
41
+ return typeof path === "string" ? path : undefined;
42
+ }
43
+
32
44
  /** Case-folded on darwin/win32, where the filesystem is case-insensitive. */
33
45
  function normalize(target: string): string {
34
46
  const normalized = resolve(target).replace(/\\/g, "/").replace(/\/+$/, "");
@@ -103,7 +115,7 @@ export function isWithin(base: string, target: string): boolean {
103
115
  */
104
116
  export function toAbsolute(cwd: string, token: string, home: string): string {
105
117
  if (token === "~") return home;
106
- if (token.startsWith("~/")) return resolve(home, token.slice(2));
118
+ if (token === "~" || token.startsWith("~/")) return resolve(expandTilde(token, home));
107
119
  if (isAbsolute(token)) return resolve(token);
108
120
  return resolve(cwd, token);
109
121
  }
@@ -76,6 +76,14 @@ export class PauseTracker {
76
76
  return this.paused;
77
77
  }
78
78
 
79
+ /** Forget everything — a new session (`/clear`, `/resume`) starts with a clean record. */
80
+ reset(): void {
81
+ this.consecutive = 0;
82
+ this.total = 0;
83
+ this.paused = false;
84
+ this.denials.length = 0;
85
+ }
86
+
79
87
  /** Most recent first, for the `/permissions` "recently denied" listing. */
80
88
  recentDenials(limit = 10): Denial[] {
81
89
  return this.denials.slice(-limit).reverse();