@diegopetrucci/pi-extensions 0.1.73 → 0.1.76

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 (93) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +9 -4
  3. package/extensions/agent-workflow-audit/index.ts +1398 -1009
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/README.md +3 -1
  6. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  7. package/extensions/annotate-git-diff/git.ts +853 -767
  8. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  9. package/extensions/annotate-git-diff/index.ts +712 -671
  10. package/extensions/annotate-git-diff/package.json +1 -1
  11. package/extensions/annotate-git-diff/prompt.ts +45 -45
  12. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  13. package/extensions/annotate-git-diff/review-server.ts +337 -311
  14. package/extensions/annotate-git-diff/types.ts +132 -132
  15. package/extensions/annotate-git-diff/ui.ts +27 -27
  16. package/extensions/annotate-git-diff/watch.ts +74 -74
  17. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  18. package/extensions/annotate-git-diff/web/index.html +5 -6
  19. package/extensions/annotate-last-message/README.md +1 -1
  20. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  21. package/extensions/annotate-last-message/index.ts +210 -206
  22. package/extensions/annotate-last-message/package.json +1 -1
  23. package/extensions/annotate-last-message/prompt.ts +87 -66
  24. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  25. package/extensions/annotate-last-message/session.ts +94 -89
  26. package/extensions/annotate-last-message/theme.ts +199 -193
  27. package/extensions/annotate-last-message/types.ts +23 -23
  28. package/extensions/annotate-last-message/ui.ts +27 -25
  29. package/extensions/annotate-last-message/web/app.js +4 -7
  30. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  31. package/extensions/brrr/README.md +1 -1
  32. package/extensions/brrr/index.ts +213 -191
  33. package/extensions/brrr/package.json +1 -1
  34. package/extensions/claude-fast/README.md +5 -79
  35. package/extensions/claude-fast/index.ts +31 -289
  36. package/extensions/claude-fast/package.json +2 -3
  37. package/extensions/code-reviewer/README.md +1 -1
  38. package/extensions/code-reviewer/index.ts +1598 -1350
  39. package/extensions/code-reviewer/package.json +1 -1
  40. package/extensions/confirm-destructive/README.md +1 -1
  41. package/extensions/confirm-destructive/index.ts +55 -51
  42. package/extensions/confirm-destructive/package.json +1 -1
  43. package/extensions/context-cap/index.ts +312 -304
  44. package/extensions/context-cap/package.json +1 -1
  45. package/extensions/context-inspector/index.ts +1211 -1064
  46. package/extensions/context-inspector/package.json +1 -1
  47. package/extensions/contrarian/index.ts +1854 -1735
  48. package/extensions/contrarian/package.json +1 -1
  49. package/extensions/dirty-repo-guard/index.ts +41 -41
  50. package/extensions/dynamic-context-pruning/README.md +6 -38
  51. package/extensions/dynamic-context-pruning/index.ts +2124 -1980
  52. package/extensions/dynamic-context-pruning/package.json +1 -1
  53. package/extensions/fast/README.md +71 -21
  54. package/extensions/fast/index.ts +513 -313
  55. package/extensions/fast/package.json +1 -1
  56. package/extensions/git-footer/README.md +1 -1
  57. package/extensions/git-footer/index.ts +458 -411
  58. package/extensions/git-footer/package.json +2 -3
  59. package/extensions/gnosis/index.ts +180 -130
  60. package/extensions/gnosis/package.json +1 -1
  61. package/extensions/inline-bash/index.ts +88 -88
  62. package/extensions/inline-bash/package.json +1 -1
  63. package/extensions/librarian/index.ts +1669 -1419
  64. package/extensions/librarian/package.json +1 -1
  65. package/extensions/minimal-footer/index.ts +994 -1012
  66. package/extensions/minimal-footer/openai-usage.ts +195 -190
  67. package/extensions/notify/index.ts +222 -230
  68. package/extensions/notify/package.json +1 -1
  69. package/extensions/openai-fast/README.md +5 -73
  70. package/extensions/openai-fast/index.ts +32 -272
  71. package/extensions/openai-fast/package.json +2 -3
  72. package/extensions/oracle/index.ts +1731 -1625
  73. package/extensions/oracle/package.json +1 -1
  74. package/extensions/permission-gate/index.ts +658 -608
  75. package/extensions/permission-gate/package.json +1 -1
  76. package/extensions/permission-gate/powershell-safety.ts +840 -793
  77. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  78. package/extensions/project-mcp-json/README.md +199 -0
  79. package/extensions/project-mcp-json/config.ts +564 -0
  80. package/extensions/project-mcp-json/index.ts +119 -0
  81. package/extensions/project-mcp-json/package.json +38 -0
  82. package/extensions/quiet-tools/index.ts +282 -264
  83. package/extensions/review/README.md +2 -2
  84. package/extensions/review/index.ts +1745 -1872
  85. package/extensions/review/package.json +1 -1
  86. package/extensions/todo/index.ts +284 -265
  87. package/extensions/triage-comments/README.md +2 -2
  88. package/extensions/triage-comments/index.ts +2294 -1882
  89. package/extensions/triage-comments/package.json +1 -1
  90. package/package.json +6 -3
  91. package/assets/social-preview.png +0 -0
  92. package/extensions/claude-fast/claude-fast.example.json +0 -4
  93. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -3,7 +3,13 @@ import { existsSync } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { StringEnum, type Usage } from "@earendil-works/pi-ai";
6
- import { getAgentDir, getMarkdownTheme, type AgentToolResult, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ getAgentDir,
8
+ getMarkdownTheme,
9
+ type AgentToolResult,
10
+ type ExtensionAPI,
11
+ type ExtensionContext,
12
+ } from "@earendil-works/pi-coding-agent";
7
13
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
8
14
  import { Type } from "typebox";
9
15
 
@@ -11,89 +17,89 @@ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "
11
17
  type ThinkingLevelMap = Partial<Record<ThinkingLevel, unknown | null>>;
12
18
 
13
19
  type PiModel = {
14
- provider: string;
15
- id: string;
16
- name?: string;
17
- reasoning?: boolean;
18
- contextWindow?: number;
19
- maxTokens?: number;
20
- thinkingLevelMap?: ThinkingLevelMap;
20
+ provider: string;
21
+ id: string;
22
+ name?: string;
23
+ reasoning?: boolean;
24
+ contextWindow?: number;
25
+ maxTokens?: number;
26
+ thinkingLevelMap?: ThinkingLevelMap;
21
27
  };
22
28
 
23
29
  type ModelSelectionContext = {
24
- model?: PiModel;
25
- modelRegistry: { getAvailable(): PiModel[] | Promise<PiModel[]> };
26
- scopedModels?: ReadonlyArray<{ model: PiModel; thinkingLevel?: ThinkingLevel }>;
30
+ model?: PiModel;
31
+ modelRegistry: { getAvailable(): PiModel[] | Promise<PiModel[]> };
32
+ scopedModels?: ReadonlyArray<{ model: PiModel; thinkingLevel?: ThinkingLevel }>;
27
33
  };
28
34
 
29
35
  function modelKey(model: PiModel): string {
30
- return `${model.provider}/${model.id}`.toLowerCase();
36
+ return `${model.provider}/${model.id}`.toLowerCase();
31
37
  }
32
38
 
33
39
  function hasModelScope(ctx: ModelSelectionContext): boolean {
34
- return Array.isArray(ctx.scopedModels) && ctx.scopedModels.length > 0;
40
+ return Array.isArray(ctx.scopedModels) && ctx.scopedModels.length > 0;
35
41
  }
36
42
 
37
43
  function isModelInScope(ctx: ModelSelectionContext, model: PiModel): boolean {
38
- if (!hasModelScope(ctx)) return true;
39
- const key = modelKey(model);
40
- return ctx.scopedModels!.some((entry) => modelKey(entry.model) === key);
44
+ if (!hasModelScope(ctx)) return true;
45
+ const key = modelKey(model);
46
+ return ctx.scopedModels!.some((entry) => modelKey(entry.model) === key);
41
47
  }
42
48
 
43
49
  async function getAutoSelectableModels(ctx: ModelSelectionContext): Promise<PiModel[]> {
44
- const available = await ctx.modelRegistry.getAvailable();
45
- return hasModelScope(ctx) ? available.filter((model) => isModelInScope(ctx, model)) : available;
50
+ const available = await ctx.modelRegistry.getAvailable();
51
+ return hasModelScope(ctx) ? available.filter((model) => isModelInScope(ctx, model)) : available;
46
52
  }
47
53
 
48
54
  interface UsageStats {
49
- input: number;
50
- output: number;
51
- cacheRead: number;
52
- cacheWrite: number;
53
- cacheWrite1h?: number;
54
- reasoning?: number;
55
- totalTokens: number;
56
- cost: number;
57
- costInput: number;
58
- costOutput: number;
59
- costCacheRead: number;
60
- costCacheWrite: number;
61
- turns: number;
62
- contextTokens: number;
55
+ input: number;
56
+ output: number;
57
+ cacheRead: number;
58
+ cacheWrite: number;
59
+ cacheWrite1h?: number;
60
+ reasoning?: number;
61
+ totalTokens: number;
62
+ cost: number;
63
+ costInput: number;
64
+ costOutput: number;
65
+ costCacheRead: number;
66
+ costCacheWrite: number;
67
+ turns: number;
68
+ contextTokens: number;
63
69
  }
64
70
 
65
71
  interface ContrarianSelection {
66
- modelRef: string;
67
- provider: string;
68
- modelId: string;
69
- modelName?: string;
70
- thinkingLevel: ThinkingLevel;
71
- requestedThinkingLevel?: ThinkingLevel;
72
- thinkingLevelClamped?: boolean;
73
- autoSelected: boolean;
74
- selectionReason: string;
72
+ modelRef: string;
73
+ provider: string;
74
+ modelId: string;
75
+ modelName?: string;
76
+ thinkingLevel: ThinkingLevel;
77
+ requestedThinkingLevel?: ThinkingLevel;
78
+ thinkingLevelClamped?: boolean;
79
+ autoSelected: boolean;
80
+ selectionReason: string;
75
81
  }
76
82
 
77
83
  interface ContrarianDetails extends ContrarianSelection {
78
- includeBash: boolean;
79
- usage: UsageStats;
80
- stderr: string;
81
- exitCode: number;
82
- durationMs: number;
83
- cwd: string;
84
+ includeBash: boolean;
85
+ usage: UsageStats;
86
+ stderr: string;
87
+ exitCode: number;
88
+ durationMs: number;
89
+ cwd: string;
84
90
  }
85
91
 
86
92
  interface ContrarianUiRun {
87
- task: string;
88
- includeBash: boolean;
89
- startedAt: number;
90
- selection?: ContrarianSelection;
91
- preview?: string;
93
+ task: string;
94
+ includeBash: boolean;
95
+ startedAt: number;
96
+ selection?: ContrarianSelection;
97
+ preview?: string;
92
98
  }
93
99
 
94
100
  interface ContrarianPreferences {
95
- model?: string;
96
- thinkingLevel?: ThinkingLevel;
101
+ model?: string;
102
+ thinkingLevel?: ThinkingLevel;
97
103
  }
98
104
 
99
105
  const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"];
@@ -105,841 +111,828 @@ const CONTRARIAN_STATUS_ID = "contrarian";
105
111
  const CONTRARIAN_WIDGET_ID = "contrarian";
106
112
  const CONTRARIAN_CONFIG_FILE = "contrarian.json";
107
113
  const CONTRARIAN_MODEL_PREFERENCES = [
108
- "openai/gpt-6-astra ",
109
- "global.openai.gpt-6-astra ",
110
- "us.openai.gpt-6-astra ",
111
- "openai.gpt-6-astra ",
112
- "gpt-6-astra ",
113
- "gpt-6-astra",
114
- "openai/gpt-6.1-sol ",
115
- "global.openai.gpt-6.1-sol ",
116
- "us.openai.gpt-6.1-sol ",
117
- "openai.gpt-6.1-sol ",
118
- "gpt-6.1-sol ",
119
- "openai/gpt-6-sol ",
120
- "gpt-6-sol ",
121
- "gpt-6-sol",
122
- "gpt-5.6-sol",
123
- "gpt-5.6-terra",
124
- "openai/gpt-6-luna ",
125
- "gpt-6-luna ",
126
- "gpt-6-luna",
127
- "gpt-5.6-luna",
128
- "gpt-5.5",
129
- "anthropic/claude-opus-5.5 ",
130
- "global.anthropic.claude-opus-5-5 ",
131
- "au.anthropic.claude-opus-5-5 ",
132
- "eu.anthropic.claude-opus-5-5 ",
133
- "jp.anthropic.claude-opus-5-5 ",
134
- "us.anthropic.claude-opus-5-5 ",
135
- "anthropic.claude-opus-5-5 ",
136
- "claude-opus-5.5 ",
137
- "claude-opus-5-5 ",
138
- "claude-opus-5-5",
139
- "claude-opus-5.5",
140
- "claude-opus-5",
141
- "claude-opus-4-8",
142
- "claude-opus-4.8",
143
- "claude-sonnet-5",
144
- "claude-sonnet-4-6",
145
- "claude-sonnet-4.6",
146
- "claude-sonnet-4-5",
147
- "claude-sonnet-4.5",
148
- "claude-sonnet-4",
149
- "x-ai/grok-4.7 ",
150
- "spacexai/grok-4.7 ",
151
- "grok-4.7 ",
152
- "grok-4.7",
153
- "grok-4.6",
154
- "grok-4.5",
155
- "gemini-3.5-flash",
114
+ "openai/gpt-6-astra ",
115
+ "global.openai.gpt-6-astra ",
116
+ "us.openai.gpt-6-astra ",
117
+ "openai.gpt-6-astra ",
118
+ "gpt-6-astra ",
119
+ "gpt-6-astra",
120
+ "openai/gpt-6.1-sol ",
121
+ "global.openai.gpt-6.1-sol ",
122
+ "us.openai.gpt-6.1-sol ",
123
+ "openai.gpt-6.1-sol ",
124
+ "gpt-6.1-sol ",
125
+ "openai/gpt-6-sol ",
126
+ "gpt-6-sol ",
127
+ "gpt-6-sol",
128
+ "gpt-5.6-sol",
129
+ "gpt-5.6-terra",
130
+ "openai/gpt-6-luna ",
131
+ "gpt-6-luna ",
132
+ "gpt-6-luna",
133
+ "gpt-5.6-luna",
134
+ "gpt-5.5",
135
+ "anthropic/claude-opus-5.5 ",
136
+ "global.anthropic.claude-opus-5-5 ",
137
+ "au.anthropic.claude-opus-5-5 ",
138
+ "eu.anthropic.claude-opus-5-5 ",
139
+ "jp.anthropic.claude-opus-5-5 ",
140
+ "us.anthropic.claude-opus-5-5 ",
141
+ "anthropic.claude-opus-5-5 ",
142
+ "claude-opus-5.5 ",
143
+ "claude-opus-5-5 ",
144
+ "claude-opus-5-5",
145
+ "claude-opus-5.5",
146
+ "claude-opus-5",
147
+ "claude-opus-4-8",
148
+ "claude-opus-4.8",
149
+ "claude-sonnet-5",
150
+ "claude-sonnet-4-6",
151
+ "claude-sonnet-4.6",
152
+ "claude-sonnet-4-5",
153
+ "claude-sonnet-4.5",
154
+ "claude-sonnet-4",
155
+ "x-ai/grok-4.7 ",
156
+ "spacexai/grok-4.7 ",
157
+ "grok-4.7 ",
158
+ "grok-4.7",
159
+ "grok-4.6",
160
+ "grok-4.5",
161
+ "gemini-3.5-flash",
156
162
  ];
157
163
 
158
164
  const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
159
- "amazon-bedrock": [
160
- "global.anthropic.claude-opus-5-5 ",
161
- "au.anthropic.claude-opus-5-5 ",
162
- "eu.anthropic.claude-opus-5-5 ",
163
- "jp.anthropic.claude-opus-5-5 ",
164
- "us.anthropic.claude-opus-5-5 ",
165
- "anthropic.claude-opus-5-5 ",
166
- "claude-opus-5-5",
167
- "claude-opus-5",
168
- "claude-fable-5",
169
- "claude-opus-4-8",
170
- "claude-opus-4-7",
171
- "claude-opus-4-6",
172
- "claude-opus-4-5",
173
- "claude-opus-4-1",
174
- "claude-opus-4",
175
- "global.openai.gpt-6-astra ",
176
- "us.openai.gpt-6-astra ",
177
- "openai.gpt-6-astra ",
178
- "gpt-6-astra",
179
- "global.openai.gpt-6.1-sol ",
180
- "us.openai.gpt-6.1-sol ",
181
- "openai.gpt-6.1-sol ",
182
- "openai.gpt-5.6-sol",
183
- "openai.gpt-5.6-terra",
184
- "openai.gpt-5.6-luna",
185
- "claude-sonnet-5",
186
- "claude-sonnet-4-6",
187
- "claude-sonnet-4-5",
188
- "claude-sonnet-4",
189
- "deepseek.v3.2",
190
- "kimi-k2.5",
191
- "minimax-m2.5",
192
- "minimax-m2.1",
193
- "zai.glm-5",
194
- "xai.grok-4.6",
195
- ],
196
- anthropic: [
197
- "claude-opus-5-5 ",
198
- "claude-opus-5-5",
199
- "claude-opus-5",
200
- "claude-fable-5",
201
- "claude-opus-4-8",
202
- "claude-opus-4-7",
203
- "claude-opus-4-6",
204
- "claude-opus-4-5",
205
- "claude-sonnet-5",
206
- "claude-sonnet-4-6",
207
- "claude-sonnet-4-5",
208
- ],
209
- "ant-ling": ["Ling-2.6-1T", "Ling-2.6-flash"],
210
- "azure-openai-responses": [
211
- "gpt-6-astra ",
212
- "gpt-6-astra",
213
- "gpt-6.1-sol ",
214
- "gpt-6-sol ",
215
- "gpt-6-sol",
216
- "gpt-5.6-sol",
217
- "gpt-5.6-terra",
218
- "gpt-6-luna ",
219
- "gpt-6-luna",
220
- "gpt-5.6-luna",
221
- "gpt-5.5-pro",
222
- "gpt-5.5",
223
- "gpt-5.4-pro",
224
- "gpt-5.4",
225
- "gpt-5.3-codex",
226
- "gpt-5-pro",
227
- "gpt-5-chat-latest",
228
- "gpt-5.2-pro",
229
- "gpt-5.2",
230
- "gpt-5.1",
231
- "o3-pro",
232
- "o1-pro",
233
- "gpt-5.4-mini",
234
- "gpt-5-mini",
235
- ],
236
- baseten: [
237
- "moonshotai/Kimi-K3",
238
- "moonshotai/Kimi-K2.7-Code",
239
- "zai-org/GLM-5.3",
240
- "deepseek-ai/DeepSeek-V4-Pro-0813",
241
- "deepseek-ai/DeepSeek-V4-Pro",
242
- "zai-org/GLM-5.2",
243
- "zai-org/GLM-5.1",
244
- "zai-org/GLM-5",
245
- "moonshotai/Kimi-K2.6",
246
- "moonshotai/Kimi-K2.5",
247
- "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B",
248
- "nvidia/Nemotron-120B-A12B",
249
- "openai/gpt-oss-120b",
250
- "deepseek-ai/DeepSeek-V4-Flash-0731",
251
- "thinkingmachines/inkling",
252
- "thinkingmachines/inkling-small",
253
- "zai-org/GLM-5.2-Fast",
254
- "zai-org/GLM-4.7",
255
- ],
256
- cerebras: ["gpt-oss-120b", "qwen-3.8-27b"],
257
- "cloudflare-ai-gateway": [
258
- "claude-fable-5",
259
- "claude-opus-5",
260
- "claude-opus-4.8",
261
- "claude-opus-4.7",
262
- "claude-opus-4.6",
263
- "claude-opus-4.5",
264
- "gpt-6-astra ",
265
- "gpt-6-astra",
266
- "gpt-5.5",
267
- "gpt-5.4",
268
- "workers-ai/@cf/moonshotai/kimi-k2.7-code",
269
- "workers-ai/@cf/deepseek-ai/deepseek-v4-pro-0813",
270
- "workers-ai/@cf/zai-org/glm-5.3",
271
- "workers-ai/@cf/zai-org/glm-5.2",
272
- "workers-ai/@cf/moonshotai/kimi-k2.6",
273
- "workers-ai/@cf/nvidia/nemotron-3-120b-a12b",
274
- "workers-ai/@cf/openai/gpt-oss-120b",
275
- "workers-ai/@cf/zai-org/glm-4.7-flash",
276
- ],
277
- "cloudflare-workers-ai": [
278
- "@cf/moonshotai/kimi-k2.7-code",
279
- "@cf/deepseek-ai/deepseek-v4-pro-0813",
280
- "@cf/zai-org/glm-5.3",
281
- "@cf/zai-org/glm-5.2",
282
- "@cf/moonshotai/kimi-k2.6",
283
- "@cf/nvidia/nemotron-3-120b-a12b",
284
- "@cf/openai/gpt-oss-120b",
285
- "@cf/qwen/qwen3.8-27b",
286
- "@cf/qwen/qwen3-30b-a3b-fp8",
287
- "@cf/zai-org/glm-4.7-flash",
288
- ],
289
- deepseek: ["deepseek-v4-pro", "deepseek-flash"],
290
- fireworks: [
291
- "accounts/fireworks/models/kimi-k3",
292
- "accounts/fireworks/routers/kimi-k3-fast",
293
- "accounts/fireworks/models/minimax-m3",
294
- "accounts/fireworks/models/gpt-oss-120b",
295
- ],
296
- "github-copilot": [
297
- "claude-opus-5.5 ",
298
- "claude-opus-5.5",
299
- "claude-opus-5",
300
- "claude-fable-5",
301
- "claude-opus-4.8",
302
- "claude-opus-4.7",
303
- "gpt-6-astra ",
304
- "gpt-6-astra",
305
- "gpt-6.1-sol ",
306
- "gpt-6-sol ",
307
- "gpt-6-sol",
308
- "gpt-5.6-sol",
309
- "gpt-5.6-terra",
310
- "gpt-6-luna ",
311
- "gpt-6-luna",
312
- "gpt-5.6-luna",
313
- "gpt-5.5",
314
- "gpt-5.4",
315
- "gpt-5.3-codex",
316
- "gemini-3.5-flash",
317
- "claude-sonnet-5",
318
- "claude-sonnet-4.6",
319
- "gpt-5-mini",
320
- "grok-4.7 ",
321
- "grok-4.7",
322
- "grok-4.6",
323
- "grok-4.5",
324
- "kimi-k3",
325
- "kimi-k2.7-code",
326
- ],
327
- google: [
328
- "gemini-3.1-pro-preview-customtools",
329
- "gemini-3.1-pro-preview",
330
- "gemini-3.5-flash",
331
- "gemini-2.5-pro",
332
- "gemini-2.5-flash",
333
- "gemini-3.1-flash-lite-preview",
334
- "gemini-3.1-flash-lite",
335
- "gemini-2.5-flash-lite",
336
- ],
337
- "google-vertex": [
338
- "gemini-3.1-pro-preview-customtools",
339
- "gemini-3.1-pro-preview",
340
- "gemini-3.5-flash",
341
- "gemini-3-flash-preview",
342
- "gemini-2.5-pro",
343
- "gemini-2.5-flash",
344
- "gemini-3.1-flash-lite",
345
- "gemini-2.5-flash-lite",
346
- ],
347
- groq: [
348
- "openai/gpt-oss-120b",
349
- "llama-3.3-70b-versatile",
350
- "openai/gpt-oss-20b",
351
- ],
352
- huggingface: [
353
- "moonshotai/Kimi-K3",
354
- "zai-org/GLM-5.1",
355
- "zai-org/GLM-5.3-Flash",
356
- "deepseek-ai/DeepSeek-V4-Pro-0813",
357
- "deepseek-ai/DeepSeek-V4-Pro",
358
- "moonshotai/Kimi-K2.6",
359
- "MiniMaxAI/MiniMax-M2.7",
360
- "Qwen/Qwen3.5-397B-A17B",
361
- "Qwen/Qwen3-235B-A22B-Thinking-2507",
362
- "moonshotai/Kimi-K2.5",
363
- "deepseek-ai/DeepSeek-V3.2",
364
- "MiniMaxAI/MiniMax-M2.5",
365
- "Qwen/Qwen3-Coder-Next",
366
- ],
367
- "kimi-coding": ["k3", "kimi-for-coding", "kimi-for-coding-highspeed"],
368
- meta: [
369
- "muse-spark-1.3",
370
- "muse-spark-1.3-contributor",
371
- "muse-spark-1.2",
372
- "muse-spark-1.2-contributor",
373
- "muse-spark-1.1",
374
- ],
375
- minimax: ["MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7"],
376
- "minimax-cn": ["MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7"],
377
- mistral: [
378
- "mistral-medium-2604",
379
- "mistral-medium-3.5",
380
- "mistral-medium-latest",
381
- "magistral-medium-latest",
382
- "devstral-medium-latest",
383
- "mistral-large-latest",
384
- "mistral-large-2411",
385
- "mistral-medium-2508",
386
- "mistral-small-2603",
387
- "devstral-2512",
388
- "zai-glm-5-2",
389
- ],
390
- openai: [
391
- "gpt-6-astra ",
392
- "gpt-6-astra",
393
- "gpt-6.1-sol ",
394
- "gpt-6-sol ",
395
- "gpt-6-sol",
396
- "gpt-5.6-sol",
397
- "gpt-5.6-terra",
398
- "gpt-6-luna ",
399
- "gpt-6-luna",
400
- "gpt-5.6-luna",
401
- "gpt-5.5-pro",
402
- "gpt-5.5",
403
- "gpt-5.4-pro",
404
- "gpt-5.4",
405
- "gpt-5.3-codex",
406
- "gpt-5-pro",
407
- "gpt-5-chat-latest",
408
- "gpt-5.2-pro",
409
- "gpt-5.2",
410
- "gpt-5.1",
411
- "o3-pro",
412
- "o1-pro",
413
- "gpt-5.4-mini",
414
- "gpt-5-mini",
415
- ],
416
- "openai-codex": [
417
- "gpt-6-astra ",
418
- "gpt-6-astra",
419
- "gpt-6.1-sol ",
420
- "gpt-6-sol ",
421
- "gpt-6-sol",
422
- "gpt-5.6-sol",
423
- "gpt-5.6-terra",
424
- "gpt-6-luna ",
425
- "gpt-6-luna",
426
- "gpt-5.6-luna",
427
- "gpt-5.5",
428
- "gpt-5.3-codex-spark",
429
- ],
430
- opencode: [
431
- "claude-opus-5-5 ",
432
- "claude-opus-5-5",
433
- "claude-opus-5",
434
- "claude-fable-5",
435
- "gpt-6-astra ",
436
- "gpt-6-astra",
437
- "gpt-6.1-sol ",
438
- "gpt-6-sol ",
439
- "gpt-6-sol",
440
- "gpt-5.6-sol",
441
- "gpt-5.6-terra",
442
- "gpt-6-luna ",
443
- "gpt-6-luna",
444
- "gpt-5.6-luna",
445
- "gpt-5.5-pro",
446
- "gpt-5.5",
447
- "gpt-5.4-pro",
448
- "gpt-5.4",
449
- "claude-opus-4-8",
450
- "claude-opus-4-7",
451
- "claude-opus-4-6",
452
- "claude-opus-4-5",
453
- "gpt-5.3-codex",
454
- "gpt-5.2-codex",
455
- "gpt-5.1-codex",
456
- "grok-4.6",
457
- "grok-4.5",
458
- "gemini-3.5-flash",
459
- "gemini-3.1-pro",
460
- "glm-5.2",
461
- "glm-5.1",
462
- "glm-5",
463
- "kimi-k3",
464
- "kimi-k2.7-code",
465
- "kimi-k2.6",
466
- "kimi-k2.5",
467
- "qwen3.6-plus",
468
- "qwen3.5-plus",
469
- "minimax-m3",
470
- "minimax-m2.7",
471
- ],
472
- "opencode-go": [
473
- "deepseek-v4-pro",
474
- "glm-5.3",
475
- "glm-5.2",
476
- "qwen3.7-plus",
477
- "mimo-v2.5-pro",
478
- "mimo-v2.5",
479
- "minimax-m3",
480
- "minimax-m2.7",
481
- "kimi-k3",
482
- "kimi-k2.7-code",
483
- "grok-4.7 ",
484
- "grok-4.7",
485
- "grok-4.6",
486
- ],
487
- openrouter: [
488
- "anthropic/claude-opus-5.5 ",
489
- "anthropic/claude-opus-5.5",
490
- "anthropic/claude-opus-5",
491
- "anthropic/claude-fable-5",
492
- "~anthropic/claude-fable-latest",
493
- "anthropic/claude-opus-4.8",
494
- "anthropic/claude-opus-4.7",
495
- "anthropic/claude-opus-4.6",
496
- "anthropic/claude-opus-4.5",
497
- "anthropic/claude-opus-4.1",
498
- "openai/gpt-6-astra ",
499
- "openai/gpt-6-astra",
500
- "openai/gpt-6.1-sol ",
501
- "openai/gpt-6-sol ",
502
- "openai/gpt-6-sol",
503
- "openai/gpt-5.6-sol-pro",
504
- "openai/gpt-5.6-sol",
505
- "openai/gpt-5.6-terra-pro",
506
- "openai/gpt-5.6-terra",
507
- "openai/gpt-6-luna ",
508
- "openai/gpt-6-luna",
509
- "openai/gpt-5.6-luna-pro",
510
- "openai/gpt-5.6-luna",
511
- "openai/gpt-5.5-pro",
512
- "openai/gpt-5.5",
513
- "~openai/gpt-astra-latest",
514
- "google/gemini-3.1-pro-preview-customtools",
515
- "google/gemini-3.1-pro-preview",
516
- "~google/gemini-pro-latest",
517
- "google/gemini-3.5-flash",
518
- "google/gemini-2.5-pro",
519
- "moonshotai/kimi-k3",
520
- "moonshotai/kimi-k2.7-code",
521
- "~moonshotai/kimi-latest",
522
- "moonshotai/kimi-k2.6",
523
- "moonshotai/kimi-k2-thinking",
524
- "x-ai/grok-4.7 ",
525
- "x-ai/grok-4.7",
526
- "x-ai/grok-4.6",
527
- "x-ai/grok-4.5",
528
- "~x-ai/grok-latest",
529
- "deepseek/deepseek-v4-pro",
530
- "deepseek/deepseek-r1",
531
- "deepseek/deepseek-v3.2",
532
- "minimax/minimax-m3",
533
- "minimax/minimax-m2.7",
534
- "minimax/minimax-m2.1",
535
- "z-ai/glm-5.3",
536
- "z-ai/glm-5.2",
537
- "z-ai/glm-5.1",
538
- ],
539
- "qwen-token-plan": [
540
- "qwen3.8-max",
541
- "qwen3.7-max",
542
- "qwen3.7-plus",
543
- "qwen3.6-plus",
544
- "deepseek-v4-pro",
545
- "glm-5.2",
546
- "kimi-k2.7-code",
547
- "minimax-m2.5",
548
- ],
549
- "qwen-token-plan-cn": [
550
- "qwen3.8-max",
551
- "qwen3.7-max",
552
- "qwen3.7-plus",
553
- "qwen3.6-plus",
554
- "deepseek-v4-pro",
555
- "glm-5.2",
556
- "kimi-k2.7-code",
557
- "minimax-m2.5",
558
- ],
559
- "qwen-token-plan-individual": [
560
- "qwen3.8-max",
561
- "qwen3.7-max",
562
- "qwen3.7-plus",
563
- "qwen3.6-flash",
564
- "deepseek-v4-pro",
565
- "glm-5.2",
566
- "deepseek-v4-flash-0731",
567
- ],
568
- radius: [
569
- "gpt-6-astra ",
570
- "gpt-6-astra",
571
- "claude-opus-5-5 ",
572
- "claude-opus-5-5",
573
- "claude-opus-5",
574
- "gpt-6-sol ",
575
- "gpt-6-sol",
576
- "gpt-5.6-sol",
577
- "gpt-5.6-terra",
578
- "gpt-6-luna ",
579
- "gpt-6-luna",
580
- "gpt-5.6-luna",
581
- "claude-fable-5-1",
582
- "claude-fable-5",
583
- "claude-opus-4-8",
584
- "claude-sonnet-5",
585
- "gpt-5.5",
586
- "gpt-5.4",
587
- "gpt-5.3-codex",
588
- "kimi-k3",
589
- "glm-5.3",
590
- "deepseek-v4.1-flash",
591
- "gpt-5.4-mini",
592
- "glm-5.3-flash",
593
- ],
594
- together: [
595
- "deepseek-ai/DeepSeek-V4-Pro",
596
- "moonshotai/Kimi-K3",
597
- "zai-org/GLM-5.3-Flash",
598
- "zai-org/GLM-5.2",
599
- "Qwen/Qwen3.7-Max",
600
- "Qwen/Qwen3.6-Plus",
601
- "MiniMaxAI/MiniMax-M3",
602
- "MiniMaxAI/MiniMax-M2.7",
603
- "openai/gpt-oss-120b",
604
- "openai/gpt-oss-20b",
605
- "nvidia/nemotron-3-ultra-550b-a55b",
606
- "google/gemma-4-31B-it",
607
- ],
608
- typesafe: [],
609
- "vercel-ai-gateway": [
610
- "anthropic/claude-opus-5.5 ",
611
- "anthropic/claude-opus-5.5",
612
- "anthropic/claude-opus-5",
613
- "anthropic/claude-fable-5",
614
- "anthropic/claude-opus-4.8",
615
- "anthropic/claude-opus-4.7",
616
- "anthropic/claude-opus-4.6",
617
- "anthropic/claude-opus-4.5",
618
- "anthropic/claude-sonnet-5",
619
- "anthropic/claude-sonnet-4.6",
620
- "openai/gpt-6-astra ",
621
- "openai/gpt-6-astra",
622
- "openai/gpt-6.1-sol ",
623
- "openai/gpt-6-sol ",
624
- "openai/gpt-6-sol",
625
- "openai/gpt-5.6-sol",
626
- "openai/gpt-5.6-terra",
627
- "openai/gpt-6-luna ",
628
- "openai/gpt-6-luna",
629
- "openai/gpt-5.6-luna",
630
- "openai/gpt-5.5-pro",
631
- "openai/gpt-5.5",
632
- "openai/gpt-5.4-pro",
633
- "openai/gpt-5.4",
634
- "openai/gpt-5.3-codex",
635
- "openai/gpt-5.2-codex",
636
- "openai/gpt-5.1-codex",
637
- "openai/gpt-5-codex",
638
- "moonshotai/kimi-k3",
639
- "moonshotai/kimi-k2.7-code",
640
- "moonshotai/kimi-k2.7-code-highspeed",
641
- "moonshotai/kimi-k2.6",
642
- "moonshotai/kimi-k2-thinking",
643
- "deepseek/deepseek-v4-pro",
644
- "deepseek/deepseek-v3.2-thinking",
645
- "alibaba/qwen3.7-max",
646
- "alibaba/qwen3.7-plus",
647
- "alibaba/qwen3.6-plus",
648
- "alibaba/qwen3.5-plus",
649
- "alibaba/qwen3-max-thinking",
650
- "google/gemini-3.1-pro-preview",
651
- "google/gemini-3.5-flash",
652
- "google/gemini-3-flash",
653
- "spacexai/grok-4.7 ",
654
- "spacexai/grok-4.7",
655
- "spacexai/grok-4.6",
656
- "xai/grok-4.5",
657
- "xai/grok-4.20-reasoning",
658
- "minimax/minimax-m3",
659
- "zai/glm-5.3",
660
- "zai/glm-5.2",
661
- "zai/glm-5.1",
662
- ],
663
- xai: [
664
- "grok-4.7 ",
665
- "grok-4.7",
666
- "grok-4.6",
667
- "grok-4.5",
668
- "grok-4.3",
669
- ],
670
- xiaomi: ["mimo-v2.5-pro-ultraspeed", "mimo-v2.5-pro", "mimo-v2.5"],
671
- "xiaomi-token-plan-ams": ["mimo-v2.5-pro", "mimo-v2.5"],
672
- "xiaomi-token-plan-cn": ["mimo-v2.5-pro", "mimo-v2.5"],
673
- "xiaomi-token-plan-sgp": ["mimo-v2.5-pro", "mimo-v2.5"],
674
- nvidia: [
675
- "nvidia/nemotron-3-ultra-550b-a55b",
676
- "nvidia/nemotron-3-super-120b-a12b",
677
- "moonshotai/kimi-k3",
678
- "moonshotai/kimi-k2.6",
679
- "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
680
- ],
681
- zai: [
682
- "glm-5.3",
683
- "glm-5.2",
684
- "glm-5-turbo",
685
- "glm-4.7",
686
- ],
687
- "zai-coding-cn": [
688
- "glm-5.3",
689
- "glm-5.3-highspeed",
690
- "glm-5.3-flash",
691
- "glm-4.6v",
692
- ],
693
- moonshotai: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"],
694
- "moonshotai-cn": ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"],
165
+ "amazon-bedrock": [
166
+ "global.anthropic.claude-opus-5-5 ",
167
+ "au.anthropic.claude-opus-5-5 ",
168
+ "eu.anthropic.claude-opus-5-5 ",
169
+ "jp.anthropic.claude-opus-5-5 ",
170
+ "us.anthropic.claude-opus-5-5 ",
171
+ "anthropic.claude-opus-5-5 ",
172
+ "claude-opus-5-5",
173
+ "claude-opus-5",
174
+ "claude-fable-5",
175
+ "claude-opus-4-8",
176
+ "claude-opus-4-7",
177
+ "claude-opus-4-6",
178
+ "claude-opus-4-5",
179
+ "claude-opus-4-1",
180
+ "claude-opus-4",
181
+ "global.openai.gpt-6-astra ",
182
+ "us.openai.gpt-6-astra ",
183
+ "openai.gpt-6-astra ",
184
+ "gpt-6-astra",
185
+ "global.openai.gpt-6.1-sol ",
186
+ "us.openai.gpt-6.1-sol ",
187
+ "openai.gpt-6.1-sol ",
188
+ "openai.gpt-5.6-sol",
189
+ "openai.gpt-5.6-terra",
190
+ "openai.gpt-5.6-luna",
191
+ "claude-sonnet-5",
192
+ "claude-sonnet-4-6",
193
+ "claude-sonnet-4-5",
194
+ "claude-sonnet-4",
195
+ "deepseek.v3.2",
196
+ "kimi-k2.5",
197
+ "minimax-m2.5",
198
+ "minimax-m2.1",
199
+ "zai.glm-5",
200
+ "xai.grok-4.6",
201
+ ],
202
+ anthropic: [
203
+ "claude-opus-5-5 ",
204
+ "claude-opus-5-5",
205
+ "claude-opus-5",
206
+ "claude-fable-5",
207
+ "claude-opus-4-8",
208
+ "claude-opus-4-7",
209
+ "claude-opus-4-6",
210
+ "claude-opus-4-5",
211
+ "claude-sonnet-5",
212
+ "claude-sonnet-4-6",
213
+ "claude-sonnet-4-5",
214
+ ],
215
+ "ant-ling": ["Ling-2.6-1T", "Ling-2.6-flash"],
216
+ "azure-openai-responses": [
217
+ "gpt-6-astra ",
218
+ "gpt-6-astra",
219
+ "gpt-6.1-sol ",
220
+ "gpt-6-sol ",
221
+ "gpt-6-sol",
222
+ "gpt-5.6-sol",
223
+ "gpt-5.6-terra",
224
+ "gpt-6-luna ",
225
+ "gpt-6-luna",
226
+ "gpt-5.6-luna",
227
+ "gpt-5.5-pro",
228
+ "gpt-5.5",
229
+ "gpt-5.4-pro",
230
+ "gpt-5.4 ",
231
+ "gpt-5.3-codex",
232
+ "gpt-5-pro",
233
+ "gpt-5-chat-latest",
234
+ "gpt-5.2-pro",
235
+ "gpt-5.2",
236
+ "gpt-5.1",
237
+ "o3-pro",
238
+ "o1-pro",
239
+ "gpt-5.4-mini",
240
+ "gpt-5-mini",
241
+ ],
242
+ baseten: [
243
+ "moonshotai/Kimi-K3",
244
+ "moonshotai/Kimi-K2.7-Code ",
245
+ "zai-org/GLM-5.3 ",
246
+ "deepseek-ai/DeepSeek-V4-Pro-0813",
247
+ "deepseek-ai/DeepSeek-V4-Pro",
248
+ "zai-org/GLM-5.2 ",
249
+ "zai-org/GLM-5.1",
250
+ "zai-org/GLM-5 ",
251
+ "moonshotai/Kimi-K2.6",
252
+ "moonshotai/Kimi-K2.5",
253
+ "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B",
254
+ "nvidia/Nemotron-120B-A12B",
255
+ "openai/gpt-oss-120b",
256
+ "deepseek-ai/DeepSeek-V4-Flash-0731",
257
+ "thinkingmachines/inkling ",
258
+ "thinkingmachines/inkling-small",
259
+ "zai-org/GLM-5.2-Fast",
260
+ "zai-org/GLM-4.7",
261
+ ],
262
+ cerebras: ["gpt-oss-120b", "qwen-3.8-27b"],
263
+ "cloudflare-ai-gateway": [
264
+ "claude-fable-5",
265
+ "claude-opus-5",
266
+ "claude-opus-4.8",
267
+ "claude-opus-4.7",
268
+ "claude-opus-4.6",
269
+ "claude-opus-4.5",
270
+ "gpt-6-astra ",
271
+ "gpt-6-astra",
272
+ "gpt-5.5",
273
+ "gpt-5.4 ",
274
+ "workers-ai/@cf/moonshotai/kimi-k2.7-code",
275
+ "workers-ai/@cf/deepseek-ai/deepseek-v4-pro-0813",
276
+ "workers-ai/@cf/zai-org/glm-5.3",
277
+ "workers-ai/@cf/zai-org/glm-5.2",
278
+ "workers-ai/@cf/moonshotai/kimi-k2.6",
279
+ "workers-ai/@cf/nvidia/nemotron-3-120b-a12b",
280
+ "workers-ai/@cf/openai/gpt-oss-120b",
281
+ "workers-ai/@cf/zai-org/glm-4.7-flash",
282
+ ],
283
+ "cloudflare-workers-ai": [
284
+ "@cf/moonshotai/kimi-k2.7-code",
285
+ "@cf/deepseek-ai/deepseek-v4-pro-0813",
286
+ "@cf/zai-org/glm-5.3",
287
+ "@cf/zai-org/glm-5.2",
288
+ "@cf/moonshotai/kimi-k2.6",
289
+ "@cf/nvidia/nemotron-3-120b-a12b",
290
+ "@cf/openai/gpt-oss-120b",
291
+ "@cf/qwen/qwen3.8-27b",
292
+ "@cf/qwen/qwen3-30b-a3b-fp8",
293
+ "@cf/zai-org/glm-4.7-flash",
294
+ ],
295
+ deepseek: ["deepseek-v4-pro", "deepseek-flash"],
296
+ fireworks: [
297
+ "accounts/fireworks/models/kimi-k3",
298
+ "accounts/fireworks/routers/kimi-k3-fast",
299
+ "accounts/fireworks/models/minimax-m3",
300
+ "accounts/fireworks/models/gpt-oss-120b",
301
+ ],
302
+ "github-copilot": [
303
+ "claude-opus-5.5 ",
304
+ "claude-opus-5.5",
305
+ "claude-opus-5",
306
+ "claude-fable-5",
307
+ "claude-opus-4.8",
308
+ "claude-opus-4.7",
309
+ "gpt-6-astra ",
310
+ "gpt-6-astra",
311
+ "gpt-6.1-sol ",
312
+ "gpt-6-sol ",
313
+ "gpt-6-sol",
314
+ "gpt-5.6-sol",
315
+ "gpt-5.6-terra",
316
+ "gpt-6-luna ",
317
+ "gpt-6-luna",
318
+ "gpt-5.6-luna",
319
+ "gpt-5.5",
320
+ "gpt-5.4 ",
321
+ "gpt-5.3-codex",
322
+ "gemini-3.5-flash",
323
+ "claude-sonnet-5",
324
+ "claude-sonnet-4.6",
325
+ "gpt-5-mini",
326
+ "grok-4.7 ",
327
+ "grok-4.7",
328
+ "grok-4.6",
329
+ "grok-4.5",
330
+ "kimi-k3",
331
+ "kimi-k2.7-code ",
332
+ ],
333
+ google: [
334
+ "gemini-3.1-pro-preview-customtools",
335
+ "gemini-3.1-pro-preview",
336
+ "gemini-3.5-flash",
337
+ "gemini-2.5-pro",
338
+ "gemini-2.5-flash ",
339
+ "gemini-3.1-flash-lite-preview",
340
+ "gemini-3.1-flash-lite",
341
+ "gemini-2.5-flash-lite",
342
+ ],
343
+ "google-vertex": [
344
+ "gemini-3.1-pro-preview-customtools",
345
+ "gemini-3.1-pro-preview",
346
+ "gemini-3.5-flash",
347
+ "gemini-3-flash-preview",
348
+ "gemini-2.5-pro",
349
+ "gemini-2.5-flash ",
350
+ "gemini-3.1-flash-lite",
351
+ "gemini-2.5-flash-lite",
352
+ ],
353
+ groq: ["openai/gpt-oss-120b", "llama-3.3-70b-versatile", "openai/gpt-oss-20b"],
354
+ huggingface: [
355
+ "moonshotai/Kimi-K3",
356
+ "zai-org/GLM-5.1",
357
+ "zai-org/GLM-5.3-Flash",
358
+ "deepseek-ai/DeepSeek-V4-Pro-0813",
359
+ "deepseek-ai/DeepSeek-V4-Pro",
360
+ "moonshotai/Kimi-K2.6",
361
+ "MiniMaxAI/MiniMax-M2.7",
362
+ "Qwen/Qwen3.5-397B-A17B",
363
+ "Qwen/Qwen3-235B-A22B-Thinking-2507",
364
+ "moonshotai/Kimi-K2.5",
365
+ "deepseek-ai/DeepSeek-V3.2",
366
+ "MiniMaxAI/MiniMax-M2.5",
367
+ "Qwen/Qwen3-Coder-Next",
368
+ ],
369
+ "kimi-coding": ["k3", "kimi-for-coding ", "kimi-for-coding-highspeed"],
370
+ meta: [
371
+ "muse-spark-1.3 ",
372
+ "muse-spark-1.3-contributor",
373
+ "muse-spark-1.2 ",
374
+ "muse-spark-1.2-contributor",
375
+ "muse-spark-1.1",
376
+ ],
377
+ minimax: ["MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7"],
378
+ "minimax-cn": ["MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7"],
379
+ mistral: [
380
+ "mistral-medium-2604",
381
+ "mistral-medium-3.5",
382
+ "mistral-medium-latest",
383
+ "magistral-medium-latest",
384
+ "devstral-medium-latest",
385
+ "mistral-large-latest",
386
+ "mistral-large-2411",
387
+ "mistral-medium-2508",
388
+ "mistral-small-2603",
389
+ "devstral-2512",
390
+ "zai-glm-5-2",
391
+ ],
392
+ openai: [
393
+ "gpt-6-astra ",
394
+ "gpt-6-astra",
395
+ "gpt-6.1-sol ",
396
+ "gpt-6-sol ",
397
+ "gpt-6-sol",
398
+ "gpt-5.6-sol",
399
+ "gpt-5.6-terra",
400
+ "gpt-6-luna ",
401
+ "gpt-6-luna",
402
+ "gpt-5.6-luna",
403
+ "gpt-5.5-pro",
404
+ "gpt-5.5",
405
+ "gpt-5.4-pro",
406
+ "gpt-5.4 ",
407
+ "gpt-5.3-codex",
408
+ "gpt-5-pro",
409
+ "gpt-5-chat-latest",
410
+ "gpt-5.2-pro",
411
+ "gpt-5.2",
412
+ "gpt-5.1",
413
+ "o3-pro",
414
+ "o1-pro",
415
+ "gpt-5.4-mini",
416
+ "gpt-5-mini",
417
+ ],
418
+ "openai-codex": [
419
+ "gpt-6-astra ",
420
+ "gpt-6-astra",
421
+ "gpt-6.1-sol ",
422
+ "gpt-6-sol ",
423
+ "gpt-6-sol",
424
+ "gpt-5.6-sol",
425
+ "gpt-5.6-terra",
426
+ "gpt-6-luna ",
427
+ "gpt-6-luna",
428
+ "gpt-5.6-luna",
429
+ "gpt-5.5",
430
+ "gpt-5.3-codex-spark",
431
+ ],
432
+ opencode: [
433
+ "claude-opus-5-5 ",
434
+ "claude-opus-5-5",
435
+ "claude-opus-5",
436
+ "claude-fable-5",
437
+ "gpt-6-astra ",
438
+ "gpt-6-astra",
439
+ "gpt-6.1-sol ",
440
+ "gpt-6-sol ",
441
+ "gpt-6-sol",
442
+ "gpt-5.6-sol",
443
+ "gpt-5.6-terra",
444
+ "gpt-6-luna ",
445
+ "gpt-6-luna",
446
+ "gpt-5.6-luna",
447
+ "gpt-5.5-pro",
448
+ "gpt-5.5",
449
+ "gpt-5.4-pro",
450
+ "gpt-5.4 ",
451
+ "claude-opus-4-8",
452
+ "claude-opus-4-7",
453
+ "claude-opus-4-6",
454
+ "claude-opus-4-5",
455
+ "gpt-5.3-codex",
456
+ "gpt-5.2-codex",
457
+ "gpt-5.1-codex",
458
+ "grok-4.6",
459
+ "grok-4.5",
460
+ "gemini-3.5-flash",
461
+ "gemini-3.1-pro",
462
+ "glm-5.2",
463
+ "glm-5.1",
464
+ "glm-5 ",
465
+ "kimi-k3",
466
+ "kimi-k2.7-code ",
467
+ "kimi-k2.6",
468
+ "kimi-k2.5",
469
+ "qwen3.6-plus",
470
+ "qwen3.5-plus",
471
+ "minimax-m3",
472
+ "minimax-m2.7",
473
+ ],
474
+ "opencode-go": [
475
+ "deepseek-v4-pro",
476
+ "glm-5.3 ",
477
+ "glm-5.2",
478
+ "qwen3.7-plus",
479
+ "mimo-v2.5-pro",
480
+ "mimo-v2.5",
481
+ "minimax-m3",
482
+ "minimax-m2.7",
483
+ "kimi-k3",
484
+ "kimi-k2.7-code ",
485
+ "grok-4.7 ",
486
+ "grok-4.7",
487
+ "grok-4.6",
488
+ ],
489
+ openrouter: [
490
+ "anthropic/claude-opus-5.5 ",
491
+ "anthropic/claude-opus-5.5",
492
+ "anthropic/claude-opus-5",
493
+ "anthropic/claude-fable-5",
494
+ "~anthropic/claude-fable-latest",
495
+ "anthropic/claude-opus-4.8",
496
+ "anthropic/claude-opus-4.7",
497
+ "anthropic/claude-opus-4.6",
498
+ "anthropic/claude-opus-4.5",
499
+ "anthropic/claude-opus-4.1",
500
+ "openai/gpt-6-astra ",
501
+ "openai/gpt-6-astra",
502
+ "openai/gpt-6.1-sol ",
503
+ "openai/gpt-6-sol ",
504
+ "openai/gpt-6-sol",
505
+ "openai/gpt-5.6-sol-pro",
506
+ "openai/gpt-5.6-sol",
507
+ "openai/gpt-5.6-terra-pro",
508
+ "openai/gpt-5.6-terra",
509
+ "openai/gpt-6-luna ",
510
+ "openai/gpt-6-luna",
511
+ "openai/gpt-5.6-luna-pro",
512
+ "openai/gpt-5.6-luna",
513
+ "openai/gpt-5.5-pro",
514
+ "openai/gpt-5.5",
515
+ "~openai/gpt-astra-latest",
516
+ "google/gemini-3.1-pro-preview-customtools",
517
+ "google/gemini-3.1-pro-preview",
518
+ "~google/gemini-pro-latest",
519
+ "google/gemini-3.5-flash",
520
+ "google/gemini-2.5-pro",
521
+ "moonshotai/kimi-k3",
522
+ "moonshotai/kimi-k2.7-code ",
523
+ "~moonshotai/kimi-latest",
524
+ "moonshotai/kimi-k2.6",
525
+ "moonshotai/kimi-k2-thinking",
526
+ "x-ai/grok-4.7 ",
527
+ "x-ai/grok-4.7",
528
+ "x-ai/grok-4.6",
529
+ "x-ai/grok-4.5",
530
+ "~x-ai/grok-latest",
531
+ "deepseek/deepseek-v4-pro",
532
+ "deepseek/deepseek-r1",
533
+ "deepseek/deepseek-v3.2",
534
+ "minimax/minimax-m3",
535
+ "minimax/minimax-m2.7",
536
+ "minimax/minimax-m2.1",
537
+ "z-ai/glm-5.3",
538
+ "z-ai/glm-5.2",
539
+ "z-ai/glm-5.1",
540
+ ],
541
+ "qwen-token-plan": [
542
+ "qwen3.8-max",
543
+ "qwen3.7-max",
544
+ "qwen3.7-plus",
545
+ "qwen3.6-plus",
546
+ "deepseek-v4-pro",
547
+ "glm-5.2",
548
+ "kimi-k2.7-code ",
549
+ "minimax-m2.5",
550
+ ],
551
+ "qwen-token-plan-cn": [
552
+ "qwen3.8-max",
553
+ "qwen3.7-max",
554
+ "qwen3.7-plus",
555
+ "qwen3.6-plus",
556
+ "deepseek-v4-pro",
557
+ "glm-5.2",
558
+ "kimi-k2.7-code ",
559
+ "minimax-m2.5",
560
+ ],
561
+ "qwen-token-plan-individual": [
562
+ "qwen3.8-max",
563
+ "qwen3.7-max",
564
+ "qwen3.7-plus",
565
+ "qwen3.6-flash",
566
+ "deepseek-v4-pro",
567
+ "glm-5.2",
568
+ "deepseek-v4-flash-0731",
569
+ ],
570
+ radius: [
571
+ "gpt-6-astra ",
572
+ "gpt-6-astra",
573
+ "claude-opus-5-5 ",
574
+ "claude-opus-5-5",
575
+ "claude-opus-5",
576
+ "gpt-6-sol ",
577
+ "gpt-6-sol",
578
+ "gpt-5.6-sol",
579
+ "gpt-5.6-terra",
580
+ "gpt-6-luna ",
581
+ "gpt-6-luna",
582
+ "gpt-5.6-luna",
583
+ "claude-fable-5-1",
584
+ "claude-fable-5",
585
+ "claude-opus-4-8",
586
+ "claude-sonnet-5",
587
+ "gpt-5.5",
588
+ "gpt-5.4 ",
589
+ "gpt-5.3-codex",
590
+ "kimi-k3",
591
+ "glm-5.3 ",
592
+ "deepseek-v4.1-flash",
593
+ "gpt-5.4-mini",
594
+ "glm-5.3-flash",
595
+ ],
596
+ together: [
597
+ "deepseek-ai/DeepSeek-V4-Pro",
598
+ "moonshotai/Kimi-K3",
599
+ "zai-org/GLM-5.3-Flash",
600
+ "zai-org/GLM-5.2",
601
+ "Qwen/Qwen3.7-Max",
602
+ "Qwen/Qwen3.6-Plus",
603
+ "MiniMaxAI/MiniMax-M3",
604
+ "MiniMaxAI/MiniMax-M2.7",
605
+ "openai/gpt-oss-120b",
606
+ "openai/gpt-oss-20b",
607
+ "nvidia/nemotron-3-ultra-550b-a55b",
608
+ "google/gemma-4-31B-it",
609
+ ],
610
+ typesafe: [],
611
+ "vercel-ai-gateway": [
612
+ "anthropic/claude-opus-5.5 ",
613
+ "anthropic/claude-opus-5.5",
614
+ "anthropic/claude-opus-5",
615
+ "anthropic/claude-fable-5",
616
+ "anthropic/claude-opus-4.8",
617
+ "anthropic/claude-opus-4.7",
618
+ "anthropic/claude-opus-4.6",
619
+ "anthropic/claude-opus-4.5",
620
+ "anthropic/claude-sonnet-5",
621
+ "anthropic/claude-sonnet-4.6",
622
+ "openai/gpt-6-astra ",
623
+ "openai/gpt-6-astra",
624
+ "openai/gpt-6.1-sol ",
625
+ "openai/gpt-6-sol ",
626
+ "openai/gpt-6-sol",
627
+ "openai/gpt-5.6-sol",
628
+ "openai/gpt-5.6-terra",
629
+ "openai/gpt-6-luna ",
630
+ "openai/gpt-6-luna",
631
+ "openai/gpt-5.6-luna",
632
+ "openai/gpt-5.5-pro",
633
+ "openai/gpt-5.5",
634
+ "openai/gpt-5.4-pro",
635
+ "openai/gpt-5.4",
636
+ "openai/gpt-5.3-codex",
637
+ "openai/gpt-5.2-codex",
638
+ "openai/gpt-5.1-codex",
639
+ "openai/gpt-5-codex",
640
+ "moonshotai/kimi-k3",
641
+ "moonshotai/kimi-k2.7-code ",
642
+ "moonshotai/kimi-k2.7-code-highspeed",
643
+ "moonshotai/kimi-k2.6",
644
+ "moonshotai/kimi-k2-thinking",
645
+ "deepseek/deepseek-v4-pro",
646
+ "deepseek/deepseek-v3.2-thinking",
647
+ "alibaba/qwen3.7-max",
648
+ "alibaba/qwen3.7-plus",
649
+ "alibaba/qwen3.6-plus",
650
+ "alibaba/qwen3.5-plus",
651
+ "alibaba/qwen3-max-thinking",
652
+ "google/gemini-3.1-pro-preview",
653
+ "google/gemini-3.5-flash",
654
+ "google/gemini-3-flash",
655
+ "spacexai/grok-4.7 ",
656
+ "spacexai/grok-4.7",
657
+ "spacexai/grok-4.6",
658
+ "xai/grok-4.5",
659
+ "xai/grok-4.20-reasoning",
660
+ "minimax/minimax-m3",
661
+ "zai/glm-5.3",
662
+ "zai/glm-5.2",
663
+ "zai/glm-5.1",
664
+ ],
665
+ xai: ["grok-4.7 ", "grok-4.7", "grok-4.6", "grok-4.5", "grok-4.3"],
666
+ xiaomi: ["mimo-v2.5-pro-ultraspeed", "mimo-v2.5-pro", "mimo-v2.5"],
667
+ "xiaomi-token-plan-ams": ["mimo-v2.5-pro", "mimo-v2.5"],
668
+ "xiaomi-token-plan-cn": ["mimo-v2.5-pro", "mimo-v2.5"],
669
+ "xiaomi-token-plan-sgp": ["mimo-v2.5-pro", "mimo-v2.5"],
670
+ nvidia: [
671
+ "nvidia/nemotron-3-ultra-550b-a55b",
672
+ "nvidia/nemotron-3-super-120b-a12b",
673
+ "moonshotai/kimi-k3",
674
+ "moonshotai/kimi-k2.6",
675
+ "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
676
+ ],
677
+ zai: ["glm-5.3 ", "glm-5.2", "glm-5-turbo", "glm-4.7"],
678
+ "zai-coding-cn": ["glm-5.3 ", "glm-5.3-highspeed", "glm-5.3-flash", "glm-4.6v"],
679
+ moonshotai: ["kimi-k3", "kimi-k2.7-code ", "kimi-k2.7-code-highspeed", "kimi-k2.6"],
680
+ "moonshotai-cn": ["kimi-k3", "kimi-k2.7-code ", "kimi-k2.7-code-highspeed", "kimi-k2.6"],
695
681
  };
696
682
 
697
683
  const CONTRARIAN_SYSTEM_PROMPT = [
698
- "You are the Contrarian: an independent, read-only adversarial analyst.",
699
- "Your job is to stress-test a proposal, plan, design, assumption, bug hypothesis, review conclusion, product direction, or decision by developing the strongest credible opposing case for the delegating primary agent.",
700
- "You are read-only. Never modify files, create patches, install dependencies, change configuration, implement fixes, or delegate work to other agents. Your output is adversarial analysis, evidence, and recommendations only.",
701
- "Use the available tools to inspect the repository and gather evidence. If bash is available, use it only for non-mutating inspection commands.",
702
- "Analysis process:",
703
- "1. Identify the core claim, decision, or direction being challenged.",
704
- "2. Gather the local read-only context needed to evaluate it accurately.",
705
- "3. Steelman the strongest credible opposing position before judging whether it holds.",
706
- "4. Identify hidden assumptions, failure modes, alternative interpretations, tradeoffs, and disconfirming evidence.",
707
- "5. Clearly separate confirmed objections, plausible concerns, and unresolved unknowns.",
708
- "6. Do not implement fixes or produce patches.",
709
- "If blocked by missing context, missing access, or a decision the delegating primary agent must make, state the blocker explicitly and continue with caveated analysis where possible.",
710
- "Return a concise markdown report with: Core claim or proposal being challenged; Strongest opposing case; Evidence reviewed; Which objections are confirmed, speculative, or unresolved; Residual risks, caveats, and recommended next steps, without implementing fixes.",
684
+ "You are the Contrarian: an independent, read-only adversarial analyst.",
685
+ "Your job is to stress-test a proposal, plan, design, assumption, bug hypothesis, review conclusion, product direction, or decision by developing the strongest credible opposing case for the delegating primary agent.",
686
+ "You are read-only. Never modify files, create patches, install dependencies, change configuration, implement fixes, or delegate work to other agents. Your output is adversarial analysis, evidence, and recommendations only.",
687
+ "Use the available tools to inspect the repository and gather evidence. If bash is available, use it only for non-mutating inspection commands.",
688
+ "Analysis process:",
689
+ "1. Identify the core claim, decision, or direction being challenged.",
690
+ "2. Gather the local read-only context needed to evaluate it accurately.",
691
+ "3. Steelman the strongest credible opposing position before judging whether it holds.",
692
+ "4. Identify hidden assumptions, failure modes, alternative interpretations, tradeoffs, and disconfirming evidence.",
693
+ "5. Clearly separate confirmed objections, plausible concerns, and unresolved unknowns.",
694
+ "6. Do not implement fixes or produce patches.",
695
+ "If blocked by missing context, missing access, or a decision the delegating primary agent must make, state the blocker explicitly and continue with caveated analysis where possible.",
696
+ "Return a concise markdown report with: Core claim or proposal being challenged; Strongest opposing case; Evidence reviewed; Which objections are confirmed, speculative, or unresolved; Residual risks, caveats, and recommended next steps, without implementing fixes.",
711
697
  ].join("\n");
712
698
 
713
699
  const ContrarianParams = Type.Object({
714
- task: Type.String({
715
- description: "Question or task for the contrarian. Include enough context for a stand-alone review or analysis.",
716
- }),
717
- includeBash: Type.Optional(
718
- Type.Boolean({
719
- description: "Allow non-mutating bash inspection in addition to read/grep/find/ls. Default: false.",
720
- default: false,
721
- }),
722
- ),
723
- model: Type.Optional(
724
- Type.String({
725
- description:
726
- "Optional exact model or model pattern override. If omitted, the extension prefers a strong reasoning model on the opposite model family/provider when available, then falls back to the current provider/subscription.",
727
- }),
728
- ),
729
- thinkingLevel: Type.Optional(
730
- StringEnum(THINKING_LEVELS, {
731
- description:
732
- "Optional reasoning level override for the contrarian subprocess. Defaults request high for reasoning models and off for non-reasoning models, then clamp to matched model capabilities.",
733
- }),
734
- ),
735
- cwd: Type.Optional(Type.String({ description: "Optional working directory for the contrarian subprocess." })),
700
+ task: Type.String({
701
+ description:
702
+ "Question or task for the contrarian. Include enough context for a stand-alone review or analysis.",
703
+ }),
704
+ includeBash: Type.Optional(
705
+ Type.Boolean({
706
+ description:
707
+ "Allow non-mutating bash inspection in addition to read/grep/find/ls. Default: false.",
708
+ default: false,
709
+ }),
710
+ ),
711
+ model: Type.Optional(
712
+ Type.String({
713
+ description:
714
+ "Optional exact model or model pattern override. If omitted, the extension prefers a strong reasoning model on the opposite model family/provider when available, then falls back to the current provider/subscription.",
715
+ }),
716
+ ),
717
+ thinkingLevel: Type.Optional(
718
+ StringEnum(THINKING_LEVELS, {
719
+ description:
720
+ "Optional reasoning level override for the contrarian subprocess. Defaults request high for reasoning models and off for non-reasoning models, then clamp to matched model capabilities.",
721
+ }),
722
+ ),
723
+ cwd: Type.Optional(
724
+ Type.String({ description: "Optional working directory for the contrarian subprocess." }),
725
+ ),
736
726
  });
737
727
 
738
728
  function createEmptyUsage(): UsageStats {
739
- return {
740
- input: 0,
741
- output: 0,
742
- cacheRead: 0,
743
- cacheWrite: 0,
744
- totalTokens: 0,
745
- cost: 0,
746
- costInput: 0,
747
- costOutput: 0,
748
- costCacheRead: 0,
749
- costCacheWrite: 0,
750
- turns: 0,
751
- contextTokens: 0,
752
- };
729
+ return {
730
+ input: 0,
731
+ output: 0,
732
+ cacheRead: 0,
733
+ cacheWrite: 0,
734
+ totalTokens: 0,
735
+ cost: 0,
736
+ costInput: 0,
737
+ costOutput: 0,
738
+ costCacheRead: 0,
739
+ costCacheWrite: 0,
740
+ turns: 0,
741
+ contextTokens: 0,
742
+ };
753
743
  }
754
744
 
755
745
  function cloneUsageStats(stats: UsageStats): UsageStats {
756
- return { ...stats };
746
+ return { ...stats };
757
747
  }
758
748
 
759
749
  function mergeUsageStats(target: UsageStats, source: UsageStats | undefined): UsageStats {
760
- if (!source) return target;
761
- target.input += source.input;
762
- target.output += source.output;
763
- target.cacheRead += source.cacheRead;
764
- target.cacheWrite += source.cacheWrite;
765
- if (source.cacheWrite1h !== undefined) {
766
- target.cacheWrite1h = (target.cacheWrite1h ?? 0) + source.cacheWrite1h;
767
- }
768
- if (source.reasoning !== undefined) {
769
- target.reasoning = (target.reasoning ?? 0) + source.reasoning;
770
- }
771
- target.totalTokens += source.totalTokens;
772
- target.cost += source.cost;
773
- target.costInput += source.costInput;
774
- target.costOutput += source.costOutput;
775
- target.costCacheRead += source.costCacheRead;
776
- target.costCacheWrite += source.costCacheWrite;
777
- target.turns += source.turns;
778
- target.contextTokens = source.contextTokens || target.contextTokens;
779
- return target;
750
+ if (!source) return target;
751
+ target.input += source.input;
752
+ target.output += source.output;
753
+ target.cacheRead += source.cacheRead;
754
+ target.cacheWrite += source.cacheWrite;
755
+ if (source.cacheWrite1h !== undefined) {
756
+ target.cacheWrite1h = (target.cacheWrite1h ?? 0) + source.cacheWrite1h;
757
+ }
758
+ if (source.reasoning !== undefined) {
759
+ target.reasoning = (target.reasoning ?? 0) + source.reasoning;
760
+ }
761
+ target.totalTokens += source.totalTokens;
762
+ target.cost += source.cost;
763
+ target.costInput += source.costInput;
764
+ target.costOutput += source.costOutput;
765
+ target.costCacheRead += source.costCacheRead;
766
+ target.costCacheWrite += source.costCacheWrite;
767
+ target.turns += source.turns;
768
+ target.contextTokens = source.contextTokens || target.contextTokens;
769
+ return target;
780
770
  }
781
771
 
782
772
  function usageStatsToToolUsage(stats: UsageStats): Usage {
783
- return {
784
- input: stats.input,
785
- output: stats.output,
786
- cacheRead: stats.cacheRead,
787
- cacheWrite: stats.cacheWrite,
788
- ...(stats.cacheWrite1h !== undefined ? { cacheWrite1h: stats.cacheWrite1h } : {}),
789
- ...(stats.reasoning !== undefined ? { reasoning: stats.reasoning } : {}),
790
- totalTokens: stats.totalTokens,
791
- cost: {
792
- input: stats.costInput,
793
- output: stats.costOutput,
794
- cacheRead: stats.costCacheRead,
795
- cacheWrite: stats.costCacheWrite,
796
- total: stats.cost,
797
- },
798
- };
773
+ return {
774
+ input: stats.input,
775
+ output: stats.output,
776
+ cacheRead: stats.cacheRead,
777
+ cacheWrite: stats.cacheWrite,
778
+ ...(stats.cacheWrite1h !== undefined ? { cacheWrite1h: stats.cacheWrite1h } : {}),
779
+ ...(stats.reasoning !== undefined ? { reasoning: stats.reasoning } : {}),
780
+ totalTokens: stats.totalTokens,
781
+ cost: {
782
+ input: stats.costInput,
783
+ output: stats.costOutput,
784
+ cacheRead: stats.costCacheRead,
785
+ cacheWrite: stats.costCacheWrite,
786
+ total: stats.cost,
787
+ },
788
+ };
799
789
  }
800
790
 
801
791
  function addUsageStats(
802
- target: UsageStats,
803
- source: Usage | undefined,
804
- options: { countTurn?: boolean; updateContextTokens?: boolean } = {},
792
+ target: UsageStats,
793
+ source: Usage | undefined,
794
+ options: { countTurn?: boolean; updateContextTokens?: boolean } = {},
805
795
  ): void {
806
- if (!source) return;
807
- if (options.countTurn) target.turns += 1;
808
- target.input += source.input || 0;
809
- target.output += source.output || 0;
810
- target.cacheRead += source.cacheRead || 0;
811
- target.cacheWrite += source.cacheWrite || 0;
812
- if (source.cacheWrite1h !== undefined) {
813
- target.cacheWrite1h = (target.cacheWrite1h ?? 0) + source.cacheWrite1h;
814
- }
815
- if (source.reasoning !== undefined) {
816
- target.reasoning = (target.reasoning ?? 0) + source.reasoning;
817
- }
818
- target.totalTokens += source.totalTokens || 0;
819
- target.cost += source.cost?.total || 0;
820
- target.costInput += source.cost?.input || 0;
821
- target.costOutput += source.cost?.output || 0;
822
- target.costCacheRead += source.cost?.cacheRead || 0;
823
- target.costCacheWrite += source.cost?.cacheWrite || 0;
824
- if (options.updateContextTokens) {
825
- target.contextTokens = source.totalTokens || target.contextTokens;
826
- }
796
+ if (!source) return;
797
+ if (options.countTurn) target.turns += 1;
798
+ target.input += source.input || 0;
799
+ target.output += source.output || 0;
800
+ target.cacheRead += source.cacheRead || 0;
801
+ target.cacheWrite += source.cacheWrite || 0;
802
+ if (source.cacheWrite1h !== undefined) {
803
+ target.cacheWrite1h = (target.cacheWrite1h ?? 0) + source.cacheWrite1h;
804
+ }
805
+ if (source.reasoning !== undefined) {
806
+ target.reasoning = (target.reasoning ?? 0) + source.reasoning;
807
+ }
808
+ target.totalTokens += source.totalTokens || 0;
809
+ target.cost += source.cost?.total || 0;
810
+ target.costInput += source.cost?.input || 0;
811
+ target.costOutput += source.cost?.output || 0;
812
+ target.costCacheRead += source.cost?.cacheRead || 0;
813
+ target.costCacheWrite += source.cost?.cacheWrite || 0;
814
+ if (options.updateContextTokens) {
815
+ target.contextTokens = source.totalTokens || target.contextTokens;
816
+ }
827
817
  }
828
818
 
829
819
  function getContrarianConfigPath(): string {
830
- return path.join(getAgentDir(), "extensions", CONTRARIAN_CONFIG_FILE);
820
+ return path.join(getAgentDir(), "extensions", CONTRARIAN_CONFIG_FILE);
831
821
  }
832
822
 
833
823
  function parseThinkingLevel(value: unknown): ThinkingLevel | undefined {
834
- if (typeof value !== "string") return undefined;
835
- const normalized = value.trim().toLowerCase();
836
- return (THINKING_LEVELS as readonly string[]).includes(normalized) ? (normalized as ThinkingLevel) : undefined;
824
+ if (typeof value !== "string") return undefined;
825
+ const normalized = value.trim().toLowerCase();
826
+ return (THINKING_LEVELS as readonly string[]).includes(normalized)
827
+ ? (normalized as ThinkingLevel)
828
+ : undefined;
837
829
  }
838
830
 
839
831
  function normalizeModelPreference(value: unknown): string | undefined {
840
- if (typeof value !== "string") return undefined;
841
- const trimmed = value.trim();
842
- return trimmed ? trimmed : undefined;
832
+ if (typeof value !== "string") return undefined;
833
+ const trimmed = value.trim();
834
+ return trimmed ? trimmed : undefined;
843
835
  }
844
836
 
845
837
  function parseModelPreference(value: unknown): { model?: string; thinkingLevel?: ThinkingLevel } {
846
- const model = normalizeModelPreference(value);
847
- if (!model) return {};
848
- const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh|max)$/i);
849
- if (!match?.[1]) return { model };
850
- return { model: match[1], thinkingLevel: parseThinkingLevel(match[2]) };
838
+ const model = normalizeModelPreference(value);
839
+ if (!model) return {};
840
+ const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh|max)$/i);
841
+ if (!match?.[1]) return { model };
842
+ return { model: match[1], thinkingLevel: parseThinkingLevel(match[2]) };
851
843
  }
852
844
 
853
845
  async function readContrarianPreferences(): Promise<ContrarianPreferences> {
854
- try {
855
- const raw = await fs.readFile(getContrarianConfigPath(), "utf8");
856
- const parsed = JSON.parse(raw) as {
857
- model?: unknown;
858
- defaultModel?: unknown;
859
- thinkingLevel?: unknown;
860
- defaultThinkingLevel?: unknown;
861
- };
862
- return {
863
- model: parseModelPreference(parsed.model).model ?? parseModelPreference(parsed.defaultModel).model,
864
- thinkingLevel:
865
- parseThinkingLevel(parsed.thinkingLevel) ??
866
- parseThinkingLevel(parsed.defaultThinkingLevel) ??
867
- parseModelPreference(parsed.model).thinkingLevel ??
868
- parseModelPreference(parsed.defaultModel).thinkingLevel,
869
- };
870
- } catch {
871
- return {};
872
- }
846
+ try {
847
+ const raw = await fs.readFile(getContrarianConfigPath(), "utf8");
848
+ const parsed = JSON.parse(raw) as {
849
+ model?: unknown;
850
+ defaultModel?: unknown;
851
+ thinkingLevel?: unknown;
852
+ defaultThinkingLevel?: unknown;
853
+ };
854
+ return {
855
+ model:
856
+ parseModelPreference(parsed.model).model ?? parseModelPreference(parsed.defaultModel).model,
857
+ thinkingLevel:
858
+ parseThinkingLevel(parsed.thinkingLevel) ??
859
+ parseThinkingLevel(parsed.defaultThinkingLevel) ??
860
+ parseModelPreference(parsed.model).thinkingLevel ??
861
+ parseModelPreference(parsed.defaultModel).thinkingLevel,
862
+ };
863
+ } catch {
864
+ return {};
865
+ }
873
866
  }
874
867
 
875
868
  async function writeContrarianPreferences(preferences: ContrarianPreferences): Promise<void> {
876
- const configPath = getContrarianConfigPath();
877
- const config = {
878
- ...(preferences.model ? { model: preferences.model } : {}),
879
- ...(preferences.thinkingLevel ? { thinkingLevel: preferences.thinkingLevel } : {}),
880
- updatedAt: new Date().toISOString(),
881
- };
882
- await fs.mkdir(path.dirname(configPath), { recursive: true });
883
- await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
869
+ const configPath = getContrarianConfigPath();
870
+ const config = {
871
+ ...(preferences.model ? { model: preferences.model } : {}),
872
+ ...(preferences.thinkingLevel ? { thinkingLevel: preferences.thinkingLevel } : {}),
873
+ updatedAt: new Date().toISOString(),
874
+ };
875
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
876
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
884
877
  }
885
878
 
886
879
  function formatContrarianPreferences(preferences: ContrarianPreferences): string {
887
- const model = preferences.model ?? "auto";
888
- const thinkingLevel = preferences.thinkingLevel ?? "auto";
889
- return `Contrarian defaults: model=${model}, thinkingLevel=${thinkingLevel}. Config: ${getContrarianConfigPath()}`;
880
+ const model = preferences.model ?? "auto";
881
+ const thinkingLevel = preferences.thinkingLevel ?? "auto";
882
+ return `Contrarian defaults: model=${model}, thinkingLevel=${thinkingLevel}. Config: ${getContrarianConfigPath()}`;
890
883
  }
891
884
 
892
885
  function notifyCommand(ctx: any, message: string, kind = "info"): void {
893
- if (ctx.hasUI && ctx.ui) ctx.ui.notify(message, kind);
894
- else console.log(message);
886
+ if (ctx.hasUI && ctx.ui) ctx.ui.notify(message, kind);
887
+ else console.log(message);
895
888
  }
896
889
 
897
890
  function formatTokens(count: number): string {
898
- if (count < 1000) return count.toString();
899
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
900
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
901
- return `${(count / 1000000).toFixed(1)}M`;
891
+ if (count < 1000) return count.toString();
892
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
893
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
894
+ return `${(count / 1000000).toFixed(1)}M`;
902
895
  }
903
896
 
904
897
  function formatDuration(durationMs: number): string {
905
- if (durationMs < 1000) return `${durationMs}ms`;
906
- if (durationMs < 60_000) return `${(durationMs / 1000).toFixed(1)}s`;
907
- return `${(durationMs / 60_000).toFixed(1)}m`;
898
+ if (durationMs < 1000) return `${durationMs}ms`;
899
+ if (durationMs < 60_000) return `${(durationMs / 1000).toFixed(1)}s`;
900
+ return `${(durationMs / 60_000).toFixed(1)}m`;
908
901
  }
909
902
 
910
903
  function formatUsage(stats: UsageStats): string {
911
- const parts: string[] = [];
912
- if (stats.turns) parts.push(`${stats.turns} turn${stats.turns === 1 ? "" : "s"}`);
913
- if (stats.input) parts.push(`↑${formatTokens(stats.input)}`);
914
- if (stats.output) parts.push(`↓${formatTokens(stats.output)}`);
915
- if (stats.cacheRead) parts.push(`R${formatTokens(stats.cacheRead)}`);
916
- if (stats.cacheWrite) parts.push(`W${formatTokens(stats.cacheWrite)}`);
917
- if (stats.reasoning) parts.push(`T${formatTokens(stats.reasoning)}`);
918
- if (stats.cost) parts.push(`$${stats.cost.toFixed(4)}`);
919
- if (stats.contextTokens) parts.push(`ctx:${formatTokens(stats.contextTokens)}`);
920
- return parts.join(" ");
904
+ const parts: string[] = [];
905
+ if (stats.turns) parts.push(`${stats.turns} turn${stats.turns === 1 ? "" : "s"}`);
906
+ if (stats.input) parts.push(`↑${formatTokens(stats.input)}`);
907
+ if (stats.output) parts.push(`↓${formatTokens(stats.output)}`);
908
+ if (stats.cacheRead) parts.push(`R${formatTokens(stats.cacheRead)}`);
909
+ if (stats.cacheWrite) parts.push(`W${formatTokens(stats.cacheWrite)}`);
910
+ if (stats.reasoning) parts.push(`T${formatTokens(stats.reasoning)}`);
911
+ if (stats.cost) parts.push(`$${stats.cost.toFixed(4)}`);
912
+ if (stats.contextTokens) parts.push(`ctx:${formatTokens(stats.contextTokens)}`);
913
+ return parts.join(" ");
921
914
  }
922
915
 
923
916
  function extractTextFromContent(content: unknown): string {
924
- if (!Array.isArray(content)) return "";
925
- const parts: string[] = [];
926
- for (const part of content) {
927
- if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
928
- const text = (part as { text?: string }).text;
929
- if (typeof text === "string" && text.trim()) parts.push(text);
930
- }
931
- }
932
- return parts.join("\n\n").trim();
917
+ if (!Array.isArray(content)) return "";
918
+ const parts: string[] = [];
919
+ for (const part of content) {
920
+ if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
921
+ const text = (part as { text?: string }).text;
922
+ if (typeof text === "string" && text.trim()) parts.push(text);
923
+ }
924
+ }
925
+ return parts.join("\n\n").trim();
933
926
  }
934
927
 
935
928
  // Errors that typically resolve on retry: provider overload, rate limiting,
936
929
  // transient 5xx, gateway/network failures, and request timeouts. Keep this list
937
930
  // pattern-based so we recognize variants across providers without enumerating them.
938
931
  const TRANSIENT_ERROR_PATTERN =
939
- /\b(overload(?:ed)?|rate[ _-]?limit(?:ed)?|too many requests|429|500|502|503|504|bad gateway|service unavailable|gateway timeout|temporarily unavailable|timed?[ _-]?out|timeout|econnreset|econnrefused|etimedout|enetunreach|socket hang up|fetch failed)\b/i;
932
+ /\b(overload(?:ed)?|rate[ _-]?limit(?:ed)?|too many requests|429|500|502|503|504|bad gateway|service unavailable|gateway timeout|temporarily unavailable|timed?[ _-]?out|timeout|econnreset|econnrefused|etimedout|enetunreach|socket hang up|fetch failed)\b/i;
940
933
 
941
934
  function isTransientErrorMessage(message: string): boolean {
942
- return TRANSIENT_ERROR_PATTERN.test(message);
935
+ return TRANSIENT_ERROR_PATTERN.test(message);
943
936
  }
944
937
 
945
938
  // A model the catalog advertises but the active provider/subscription cannot
@@ -947,1009 +940,1135 @@ function isTransientErrorMessage(message: string): boolean {
947
940
  // access-gated tiers). These are NOT transient: the same model keeps failing,
948
941
  // so callers should fall back to a different model instead of retrying it.
949
942
  const MODEL_AVAILABILITY_ERROR_PATTERN =
950
- /\b(?:404|403|not[_ ]?found(?:[_ ]?error)?|model[_ ]?not[_ ]?found(?:[_ ]?error)?|no such model|unknown model|does not exist|is not available|not available|model[_ ]?not[_ ]?available|unsupported model|invalid model|forbidden|access[ _-]?denied|permission[ _-]?denied|not[ _-]?entitled|do(?:es)? not have access)\b/i;
943
+ /\b(?:404|403|not[_ ]?found(?:[_ ]?error)?|model[_ ]?not[_ ]?found(?:[_ ]?error)?|no such model|unknown model|does not exist|is not available|not available|model[_ ]?not[_ ]?available|unsupported model|invalid model|forbidden|access[ _-]?denied|permission[ _-]?denied|not[ _-]?entitled|do(?:es)? not have access)\b/i;
951
944
 
952
945
  function isModelAvailabilityError(message: string | undefined): boolean {
953
- if (!message) return false;
954
- if (isTransientErrorMessage(message)) return false;
955
- return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
946
+ if (!message) return false;
947
+ if (isTransientErrorMessage(message)) return false;
948
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
956
949
  }
957
950
 
958
- function formatContrarianModelError(stopReason: "error" | "aborted", errorMessage: string | undefined): string {
959
- const trimmed = errorMessage?.trim();
960
- if (stopReason === "aborted") {
961
- return trimmed ? `Contrarian model turn aborted: ${trimmed}` : "Contrarian model turn aborted.";
962
- }
963
- if (!trimmed) return "Contrarian model error (no detail provided by provider).";
964
- const base = `Contrarian model error: ${trimmed}`;
965
- return isTransientErrorMessage(trimmed) ? `${base} (transient; retry may succeed)` : base;
951
+ function formatContrarianModelError(
952
+ stopReason: "error" | "aborted",
953
+ errorMessage: string | undefined,
954
+ ): string {
955
+ const trimmed = errorMessage?.trim();
956
+ if (stopReason === "aborted") {
957
+ return trimmed ? `Contrarian model turn aborted: ${trimmed}` : "Contrarian model turn aborted.";
958
+ }
959
+ if (!trimmed) return "Contrarian model error (no detail provided by provider).";
960
+ const base = `Contrarian model error: ${trimmed}`;
961
+ return isTransientErrorMessage(trimmed) ? `${base} (transient; retry may succeed)` : base;
966
962
  }
967
963
 
968
964
  function parseVersionScore(text: string): number {
969
- const matches = text.match(/\d+(?:\.\d+){0,2}/g) ?? [];
970
- let best = 0;
971
- for (const match of matches) {
972
- const [major = "0", minor = "0", patch = "0"] = match.split(".");
973
- const score = Number(major) * 1_000_000 + Number(minor) * 1_000 + Number(patch);
974
- if (score > best) best = score;
975
- }
976
- return best;
965
+ const matches = text.match(/\d+(?:\.\d+){0,2}/g) ?? [];
966
+ let best = 0;
967
+ for (const match of matches) {
968
+ const [major = "0", minor = "0", patch = "0"] = match.split(".");
969
+ const score = Number(major) * 1_000_000 + Number(minor) * 1_000 + Number(patch);
970
+ if (score > best) best = score;
971
+ }
972
+ return best;
977
973
  }
978
974
 
979
975
  function rankModel(model: PiModel): number {
980
- const text = `${model.id} ${model.name ?? ""}`.toLowerCase();
981
- const has = (regex: RegExp): boolean => regex.test(text);
982
- let score = 0;
983
-
984
- if (model.reasoning) score += 10_000_000;
985
- score += parseVersionScore(text) * 1_000;
986
- score += Math.min(model.maxTokens ?? 0, 200_000);
987
- score += Math.floor(Math.min(model.contextWindow ?? 0, 1_000_000) / 100);
988
-
989
- if (has(/\bopus\b/)) score += 350_000;
990
- if (has(/\bpro\b/)) score += 180_000;
991
- if (has(/\bmax\b/)) score += 60_000;
992
- if (has(/\bultra\b/)) score += 150_000;
993
- if (has(/\bsonnet\b/)) score += 120_000;
994
- if (has(/\bcodex\b|\bcoder\b|\bcode\b/)) score += 40_000;
995
- if (has(/\breasoning\b|\bthink(?:ing)?\b/)) score += 60_000;
996
-
997
- if (has(/\bhaiku\b/)) score -= 420_000;
998
- if (has(/\bmini\b/)) score -= 520_000;
999
- if (has(/\bnano\b/)) score -= 700_000;
1000
- if (has(/\bflash\b/)) score -= 520_000;
1001
- if (has(/\bspark\b/)) score -= 650_000;
1002
- if (has(/\blite\b|\bsmall\b|\bfast\b|\binstant\b/)) score -= 300_000;
1003
-
1004
- return score;
976
+ const text = `${model.id} ${model.name ?? ""}`.toLowerCase();
977
+ const has = (regex: RegExp): boolean => regex.test(text);
978
+ let score = 0;
979
+
980
+ if (model.reasoning) score += 10_000_000;
981
+ score += parseVersionScore(text) * 1_000;
982
+ score += Math.min(model.maxTokens ?? 0, 200_000);
983
+ score += Math.floor(Math.min(model.contextWindow ?? 0, 1_000_000) / 100);
984
+
985
+ if (has(/\bopus\b/)) score += 350_000;
986
+ if (has(/\bpro\b/)) score += 180_000;
987
+ if (has(/\bmax\b/)) score += 60_000;
988
+ if (has(/\bultra\b/)) score += 150_000;
989
+ if (has(/\bsonnet\b/)) score += 120_000;
990
+ if (has(/\bcodex\b|\bcoder\b|\bcode\b/)) score += 40_000;
991
+ if (has(/\breasoning\b|\bthink(?:ing)?\b/)) score += 60_000;
992
+
993
+ if (has(/\bhaiku\b/)) score -= 420_000;
994
+ if (has(/\bmini\b/)) score -= 520_000;
995
+ if (has(/\bnano\b/)) score -= 700_000;
996
+ if (has(/\bflash\b/)) score -= 520_000;
997
+ if (has(/\bspark\b/)) score -= 650_000;
998
+ if (has(/\blite\b|\bsmall\b|\bfast\b|\binstant\b/)) score -= 300_000;
999
+
1000
+ return score;
1005
1001
  }
1006
1002
 
1007
1003
  function shorten(text: string, max: number): string {
1008
- const singleLine = text.replace(/\s+/g, " ").trim();
1009
- if (singleLine.length <= max) return singleLine;
1010
- return `${singleLine.slice(0, Math.max(1, max - 3))}...`;
1004
+ const singleLine = text.replace(/\s+/g, " ").trim();
1005
+ if (singleLine.length <= max) return singleLine;
1006
+ return `${singleLine.slice(0, Math.max(1, max - 3))}...`;
1011
1007
  }
1012
1008
 
1013
1009
  function getProviderPreferenceList(provider: string | undefined): string[] | undefined {
1014
- if (!provider) return undefined;
1015
- return PROVIDER_MODEL_PREFERENCES[provider.toLowerCase()];
1010
+ if (!provider) return undefined;
1011
+ return PROVIDER_MODEL_PREFERENCES[provider.toLowerCase()];
1012
+ }
1013
+
1014
+ function matchesPreferencePattern(haystack: string, pattern: string): boolean {
1015
+ const needle = pattern.toLowerCase();
1016
+ if (!needle.endsWith(" ")) return haystack.includes(needle);
1017
+ const separator = haystack.indexOf(" ");
1018
+ const id = separator === -1 ? haystack : haystack.slice(0, separator);
1019
+ return `${id} `.includes(needle);
1016
1020
  }
1017
1021
 
1018
- function selectPreferredModel(models: PiModel[], provider: string | undefined): PiModel | undefined {
1019
- const preferences = getProviderPreferenceList(provider);
1020
- if (!preferences || preferences.length === 0) return undefined;
1021
- const lowered = models.map((model) => ({ model, haystack: `${model.id} ${model.name ?? ""}`.toLowerCase() }));
1022
- for (const pattern of preferences) {
1023
- const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
1024
- if (match) return match.model;
1025
- }
1026
- return undefined;
1022
+ function selectPreferredModel(
1023
+ models: PiModel[],
1024
+ provider: string | undefined,
1025
+ ): PiModel | undefined {
1026
+ const preferences = getProviderPreferenceList(provider);
1027
+ if (!preferences || preferences.length === 0) return undefined;
1028
+ const lowered = models.map((model) => ({
1029
+ model,
1030
+ haystack: `${model.id} ${model.name ?? ""}`.toLowerCase(),
1031
+ }));
1032
+ for (const pattern of preferences) {
1033
+ const match = lowered.find((entry) => matchesPreferencePattern(entry.haystack, pattern));
1034
+ if (match) return match.model;
1035
+ }
1036
+ return undefined;
1027
1037
  }
1028
1038
 
1029
1039
  function modelText(model: PiModel): string {
1030
- return `${model.provider} ${model.id} ${model.name ?? ""}`.toLowerCase();
1040
+ return `${model.provider} ${model.id} ${model.name ?? ""}`.toLowerCase();
1031
1041
  }
1032
1042
 
1033
1043
  function isAnthropicFamily(model: PiModel): boolean {
1034
- return /\b(anthropic|claude|opus|sonnet|haiku|fable)\b/.test(modelText(model));
1044
+ return /\b(anthropic|claude|opus|sonnet|haiku|fable)\b/.test(modelText(model));
1035
1045
  }
1036
1046
 
1037
1047
  function isOpenAiFamily(model: PiModel): boolean {
1038
- return /\b(openai|codex|chatgpt|gpt[-_. ]?\d|o[1345](?:\b|-|_))\b/.test(modelText(model));
1048
+ return /\b(openai|codex|chatgpt|gpt[-_. ]?\d|o[1345](?:\b|-|_))\b/.test(modelText(model));
1039
1049
  }
1040
1050
 
1041
1051
  function selectPreferredAcrossProviders(models: PiModel[]): PiModel | undefined {
1042
- const lowered = models.map((model) => ({ model, haystack: `${model.id} ${model.name ?? ""}`.toLowerCase() }));
1043
- for (const pattern of CONTRARIAN_MODEL_PREFERENCES) {
1044
- const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
1045
- if (match) return match.model;
1046
- }
1047
- for (const preferences of Object.values(PROVIDER_MODEL_PREFERENCES)) {
1048
- for (const pattern of preferences) {
1049
- const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
1050
- if (match) return match.model;
1051
- }
1052
- }
1053
- return undefined;
1052
+ const lowered = models.map((model) => ({
1053
+ model,
1054
+ haystack: `${model.id} ${model.name ?? ""}`.toLowerCase(),
1055
+ }));
1056
+ for (const pattern of CONTRARIAN_MODEL_PREFERENCES) {
1057
+ const match = lowered.find((entry) => matchesPreferencePattern(entry.haystack, pattern));
1058
+ if (match) return match.model;
1059
+ }
1060
+ for (const preferences of Object.values(PROVIDER_MODEL_PREFERENCES)) {
1061
+ for (const pattern of preferences) {
1062
+ const match = lowered.find((entry) => matchesPreferencePattern(entry.haystack, pattern));
1063
+ if (match) return match.model;
1064
+ }
1065
+ }
1066
+ return undefined;
1054
1067
  }
1055
1068
 
1056
1069
  function getPiInvocation(args: string[]): { command: string; args: string[] } {
1057
- const currentScript = process.argv[1];
1058
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
1059
- if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
1060
- return { command: process.execPath, args: [currentScript, ...args] };
1061
- }
1062
-
1063
- const execName = path.basename(process.execPath).toLowerCase();
1064
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
1065
- if (!isGenericRuntime) {
1066
- return { command: process.execPath, args };
1067
- }
1068
-
1069
- return { command: "pi", args };
1070
+ const currentScript = process.argv[1];
1071
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
1072
+ if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
1073
+ return { command: process.execPath, args: [currentScript, ...args] };
1074
+ }
1075
+
1076
+ const execName = path.basename(process.execPath).toLowerCase();
1077
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
1078
+ if (!isGenericRuntime) {
1079
+ return { command: process.execPath, args };
1080
+ }
1081
+
1082
+ return { command: "pi", args };
1070
1083
  }
1071
1084
 
1072
1085
  function withThinking(modelRef: string, thinkingLevel: ThinkingLevel): string {
1073
- if (/(?:^|\/)[^:]+:(off|minimal|low|medium|high|xhigh|max)$/i.test(modelRef)) return modelRef;
1074
- return `${modelRef}:${thinkingLevel}`;
1086
+ if (/(?:^|\/)[^:]+:(off|minimal|low|medium|high|xhigh|max)$/i.test(modelRef)) return modelRef;
1087
+ return `${modelRef}:${thinkingLevel}`;
1075
1088
  }
1076
1089
 
1077
1090
  // Keep these local so the extension stays compatible with older pi peer installs that do not export clamp helpers.
1078
1091
  function isThinkingLevelSupported(model: PiModel, level: ThinkingLevel): boolean {
1079
- if (!model.reasoning) return level === "off";
1092
+ if (!model.reasoning) return level === "off";
1080
1093
 
1081
- const map = model.thinkingLevelMap;
1082
- if (level === "xhigh" || level === "max") {
1083
- return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
1084
- }
1085
- return map?.[level] !== null;
1094
+ const map = model.thinkingLevelMap;
1095
+ if (level === "xhigh" || level === "max") {
1096
+ return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
1097
+ }
1098
+ return map?.[level] !== null;
1086
1099
  }
1087
1100
 
1088
1101
  function clampThinkingLevel(model: PiModel, requested: ThinkingLevel): ThinkingLevel {
1089
- if (isThinkingLevelSupported(model, requested)) return requested;
1090
-
1091
- const requestedIndex = THINKING_LEVELS.indexOf(requested);
1092
- for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index++) {
1093
- const level = THINKING_LEVELS[index];
1094
- if (isThinkingLevelSupported(model, level)) return level;
1095
- }
1096
- for (let index = requestedIndex - 1; index >= 0; index--) {
1097
- const level = THINKING_LEVELS[index];
1098
- if (isThinkingLevelSupported(model, level)) return level;
1099
- }
1100
-
1101
- return "off";
1102
+ if (isThinkingLevelSupported(model, requested)) return requested;
1103
+
1104
+ const requestedIndex = THINKING_LEVELS.indexOf(requested);
1105
+ for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index++) {
1106
+ const level = THINKING_LEVELS[index];
1107
+ if (isThinkingLevelSupported(model, level)) return level;
1108
+ }
1109
+ for (let index = requestedIndex - 1; index >= 0; index--) {
1110
+ const level = THINKING_LEVELS[index];
1111
+ if (isThinkingLevelSupported(model, level)) return level;
1112
+ }
1113
+
1114
+ return "off";
1102
1115
  }
1103
1116
 
1104
1117
  function resolveThinkingLevel(
1105
- model: PiModel | undefined,
1106
- override: ThinkingLevel | undefined,
1118
+ model: PiModel | undefined,
1119
+ override: ThinkingLevel | undefined,
1107
1120
  ): { requested: ThinkingLevel; effective: ThinkingLevel; clamped: boolean } {
1108
- const requested = override ?? (model?.reasoning ? DEFAULT_THINKING_LEVEL : "off");
1109
- const effective = model ? clampThinkingLevel(model, requested) : requested;
1110
- return { requested, effective, clamped: effective !== requested };
1121
+ const requested = override ?? (model?.reasoning ? DEFAULT_THINKING_LEVEL : "off");
1122
+ const effective = model ? clampThinkingLevel(model, requested) : requested;
1123
+ return { requested, effective, clamped: effective !== requested };
1111
1124
  }
1112
1125
 
1113
1126
  function appendThinkingLevelClampReason(
1114
- reason: string,
1115
- resolution: { requested: ThinkingLevel; effective: ThinkingLevel; clamped: boolean },
1127
+ reason: string,
1128
+ resolution: { requested: ThinkingLevel; effective: ThinkingLevel; clamped: boolean },
1116
1129
  ): string {
1117
- if (!resolution.clamped) return reason;
1118
- return `${reason} Requested thinking level ${resolution.requested} was clamped to ${resolution.effective} based on the matched model's capabilities.`;
1130
+ if (!resolution.clamped) return reason;
1131
+ return `${reason} Requested thinking level ${resolution.requested} was clamped to ${resolution.effective} based on the matched model's capabilities.`;
1119
1132
  }
1120
1133
 
1121
1134
  async function findAvailableModel(
1122
- ctx: ModelSelectionContext,
1123
- modelRef: string,
1135
+ ctx: ModelSelectionContext,
1136
+ modelRef: string,
1124
1137
  ): Promise<PiModel | undefined> {
1125
- const available = await ctx.modelRegistry.getAvailable();
1126
- const trimmed = modelRef.trim();
1127
- const provider = trimmed.includes("/") ? trimmed.split("/")[0].toLowerCase() : ctx.model?.provider?.toLowerCase();
1128
- const id = trimmed.includes("/") ? trimmed.split("/").slice(1).join("/").toLowerCase() : trimmed.toLowerCase();
1129
-
1130
- const exact = available.find(
1131
- (model) => model.id.toLowerCase() === id && (!provider || model.provider.toLowerCase() === provider),
1132
- );
1133
- if (exact) return exact;
1134
-
1135
- const partial = available.find(
1136
- (model) =>
1137
- model.id.toLowerCase().includes(id) && (!provider || model.provider.toLowerCase() === provider),
1138
- );
1139
- if (partial) return partial;
1140
-
1141
- if (!provider) {
1142
- const uniqueById = available.filter((model) => model.id.toLowerCase() === id);
1143
- if (uniqueById.length === 1) return uniqueById[0];
1144
- }
1145
-
1146
- return undefined;
1138
+ const available = await ctx.modelRegistry.getAvailable();
1139
+ const trimmed = modelRef.trim();
1140
+ const provider = trimmed.includes("/")
1141
+ ? trimmed.split("/")[0].toLowerCase()
1142
+ : ctx.model?.provider?.toLowerCase();
1143
+ const id = trimmed.includes("/")
1144
+ ? trimmed.split("/").slice(1).join("/").toLowerCase()
1145
+ : trimmed.toLowerCase();
1146
+
1147
+ const exact = available.find(
1148
+ (model) =>
1149
+ model.id.toLowerCase() === id && (!provider || model.provider.toLowerCase() === provider),
1150
+ );
1151
+ if (exact) return exact;
1152
+
1153
+ const partial = available.find(
1154
+ (model) =>
1155
+ model.id.toLowerCase().includes(id) &&
1156
+ (!provider || model.provider.toLowerCase() === provider),
1157
+ );
1158
+ if (partial) return partial;
1159
+
1160
+ if (!provider) {
1161
+ const uniqueById = available.filter((model) => model.id.toLowerCase() === id);
1162
+ if (uniqueById.length === 1) return uniqueById[0];
1163
+ }
1164
+
1165
+ return undefined;
1147
1166
  }
1148
1167
 
1149
1168
  function toContrarianSelection(
1150
- model: PiModel,
1151
- thinkingLevelOverride: ThinkingLevel | undefined,
1152
- reason: string,
1169
+ model: PiModel,
1170
+ thinkingLevelOverride: ThinkingLevel | undefined,
1171
+ reason: string,
1153
1172
  ): ContrarianSelection {
1154
- const thinking = resolveThinkingLevel(model, thinkingLevelOverride);
1155
- return {
1156
- modelRef: `${model.provider}/${model.id}`,
1157
- provider: model.provider,
1158
- modelId: model.id,
1159
- modelName: model.name,
1160
- thinkingLevel: thinking.effective,
1161
- ...(thinking.clamped ? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true } : {}),
1162
- autoSelected: true,
1163
- selectionReason: appendThinkingLevelClampReason(reason, thinking),
1164
- };
1173
+ const thinking = resolveThinkingLevel(model, thinkingLevelOverride);
1174
+ return {
1175
+ modelRef: `${model.provider}/${model.id}`,
1176
+ provider: model.provider,
1177
+ modelId: model.id,
1178
+ modelName: model.name,
1179
+ thinkingLevel: thinking.effective,
1180
+ ...(thinking.clamped
1181
+ ? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true }
1182
+ : {}),
1183
+ autoSelected: true,
1184
+ selectionReason: appendThinkingLevelClampReason(reason, thinking),
1185
+ };
1165
1186
  }
1166
1187
 
1167
1188
  function buildSessionFallbackSelection(
1168
- ctx: ModelSelectionContext,
1169
- thinkingLevelOverride: ThinkingLevel | undefined,
1189
+ ctx: ModelSelectionContext,
1190
+ thinkingLevelOverride: ThinkingLevel | undefined,
1170
1191
  ): ContrarianSelection | undefined {
1171
- const model = ctx.model;
1172
- if (!model || !isModelInScope(ctx, model)) return undefined;
1173
- return toContrarianSelection(
1174
- model,
1175
- thinkingLevelOverride,
1176
- `Used the current session model ${model.provider}/${model.id} as a final fallback.`,
1177
- );
1192
+ const model = ctx.model;
1193
+ if (!model || !isModelInScope(ctx, model)) return undefined;
1194
+ return toContrarianSelection(
1195
+ model,
1196
+ thinkingLevelOverride,
1197
+ `Used the current session model ${model.provider}/${model.id} as a final fallback.`,
1198
+ );
1178
1199
  }
1179
1200
 
1180
- function withFallbackReason(selection: ContrarianSelection, previous: ContrarianSelection): ContrarianSelection {
1181
- return {
1182
- ...selection,
1183
- selectionReason: `${selection.selectionReason} (Fell back from ${previous.modelRef} after a model-availability error.)`,
1184
- };
1201
+ function withFallbackReason(
1202
+ selection: ContrarianSelection,
1203
+ previous: ContrarianSelection,
1204
+ ): ContrarianSelection {
1205
+ return {
1206
+ ...selection,
1207
+ selectionReason: `${selection.selectionReason} (Fell back from ${previous.modelRef} after a model-availability error.)`,
1208
+ };
1185
1209
  }
1186
1210
 
1187
1211
  async function selectContrarianModel(
1188
- ctx: ModelSelectionContext,
1189
- thinkingLevelOverride?: ThinkingLevel,
1190
- ): Promise<{ ok: true; selection: ContrarianSelection; ordered: ContrarianSelection[] } | { ok: false; error: string }> {
1191
- const available = await getAutoSelectableModels(ctx);
1192
- if (available.length === 0) {
1193
- return {
1194
- ok: false,
1195
- error: hasModelScope(ctx)
1196
- ? "No authenticated models are available in the current session model scope. Adjust the scope, log in, or configure an API key."
1197
- : "No authenticated models are available. Log in or configure an API key first.",
1198
- };
1199
- }
1200
-
1201
- const currentModel = ctx.model;
1202
- const currentProvider = currentModel?.provider;
1203
- const sameProvider = currentProvider ? available.filter((model) => model.provider === currentProvider) : [];
1204
- const sameProviderReasoning = sameProvider.filter((model) => model.reasoning);
1205
- const allReasoning = available.filter((model) => model.reasoning);
1206
- const oppositeFamily = currentModel
1207
- ? isAnthropicFamily(currentModel)
1208
- ? available.filter(isOpenAiFamily)
1209
- : isOpenAiFamily(currentModel)
1210
- ? available.filter(isAnthropicFamily)
1211
- : []
1212
- : [];
1213
- const oppositeProvider = currentProvider ? available.filter((model) => model.provider !== currentProvider) : [];
1214
- const oppositeProviderFamily = currentProvider
1215
- ? oppositeFamily.filter((model) => model.provider !== currentProvider)
1216
- : oppositeFamily;
1217
- const sameProviderOppositeFamily = currentProvider
1218
- ? oppositeFamily.filter((model) => model.provider === currentProvider)
1219
- : [];
1220
- const oppositeProviderFamilyReasoning = oppositeProviderFamily.filter((model) => model.reasoning);
1221
- const sameProviderOppositeFamilyReasoning = sameProviderOppositeFamily.filter((model) => model.reasoning);
1222
- const oppositeFamilyReasoning = oppositeFamily.filter((model) => model.reasoning);
1223
- const oppositeProviderReasoning = oppositeProvider.filter((model) => model.reasoning);
1224
-
1225
- let candidates = oppositeProviderFamilyReasoning;
1226
- let reason = "Selected the top-ranked reasoning model from the opposite model family on a different provider for an independent adversarial pass.";
1227
- let providerForPreferences: string | undefined = undefined;
1228
-
1229
- if (candidates.length === 0 && oppositeProviderFamily.length > 0) {
1230
- candidates = oppositeProviderFamily;
1231
- reason = "No reasoning models were available in the opposite model family on a different provider, so the top-ranked opposite-family model on a different provider was used.";
1232
- } else if (candidates.length === 0 && oppositeProviderReasoning.length > 0) {
1233
- candidates = oppositeProviderReasoning;
1234
- reason = "No recognized opposite-family model was available, so the top-ranked reasoning model on a different provider was used.";
1235
- } else if (candidates.length === 0 && oppositeProvider.length > 0) {
1236
- candidates = oppositeProvider;
1237
- reason = "No recognized opposite-family reasoning model was available, so the top-ranked model on a different provider was used.";
1238
- } else if (candidates.length === 0 && sameProviderOppositeFamilyReasoning.length > 0) {
1239
- candidates = sameProviderOppositeFamilyReasoning;
1240
- reason = "No different provider was available, so the top-ranked reasoning model from the opposite model family on the current provider was used.";
1241
- } else if (candidates.length === 0 && sameProviderOppositeFamily.length > 0) {
1242
- candidates = sameProviderOppositeFamily;
1243
- reason = "No different provider or opposite-family reasoning model was available, so the top-ranked opposite-family model on the current provider was used.";
1244
- } else if (candidates.length === 0 && oppositeFamilyReasoning.length > 0) {
1245
- candidates = oppositeFamilyReasoning;
1246
- reason = "Selected the top-ranked reasoning model from the opposite model family for an independent adversarial pass.";
1247
- } else if (candidates.length === 0 && sameProviderReasoning.length > 0) {
1248
- candidates = sameProviderReasoning;
1249
- providerForPreferences = currentProvider;
1250
- reason = "No opposite provider/model family was available, so the top-ranked reasoning model on the current provider was used.";
1251
- } else if (candidates.length === 0 && sameProvider.length > 0) {
1252
- candidates = sameProvider;
1253
- providerForPreferences = currentProvider;
1254
- reason = "No opposite provider/model family or current-provider reasoning model was available, so the top-ranked model on the current provider was used.";
1255
- } else if (candidates.length === 0 && allReasoning.length > 0) {
1256
- candidates = allReasoning;
1257
- reason = "No current model/provider was active, so the top-ranked reasoning model across all available providers was used.";
1258
- } else if (candidates.length === 0) {
1259
- candidates = available;
1260
- reason = "No reasoning models were available, so the top-ranked model across all available providers was used.";
1261
- }
1262
-
1263
- const sorted = [...candidates].sort((a, b) => rankModel(b) - rankModel(a));
1264
- const preferred = providerForPreferences
1265
- ? selectPreferredModel(candidates, providerForPreferences)
1266
- : selectPreferredAcrossProviders(candidates);
1267
- const orderedModels = preferred ? [preferred, ...sorted.filter((model) => model !== preferred)] : sorted;
1268
-
1269
- const ordered = orderedModels.map((model, index) =>
1270
- toContrarianSelection(
1271
- model,
1272
- thinkingLevelOverride,
1273
- index === 0
1274
- ? preferred
1275
- ? `Selected ${model.id} via the hardcoded preference lists while preferring an opposite provider/model family.`
1276
- : reason
1277
- : `Auto-selected ${model.provider}/${model.id} as a lower-ranked fallback.`,
1278
- ),
1279
- );
1280
-
1281
- return { ok: true, selection: ordered[0], ordered };
1212
+ ctx: ModelSelectionContext,
1213
+ thinkingLevelOverride?: ThinkingLevel,
1214
+ ): Promise<
1215
+ | { ok: true; selection: ContrarianSelection; ordered: ContrarianSelection[] }
1216
+ | { ok: false; error: string }
1217
+ > {
1218
+ const available = await getAutoSelectableModels(ctx);
1219
+ if (available.length === 0) {
1220
+ return {
1221
+ ok: false,
1222
+ error: hasModelScope(ctx)
1223
+ ? "No authenticated models are available in the current session model scope. Adjust the scope, log in, or configure an API key."
1224
+ : "No authenticated models are available. Log in or configure an API key first.",
1225
+ };
1226
+ }
1227
+
1228
+ const currentModel = ctx.model;
1229
+ const currentProvider = currentModel?.provider;
1230
+ const sameProvider = currentProvider
1231
+ ? available.filter((model) => model.provider === currentProvider)
1232
+ : [];
1233
+ const sameProviderReasoning = sameProvider.filter((model) => model.reasoning);
1234
+ const allReasoning = available.filter((model) => model.reasoning);
1235
+ const oppositeFamily = currentModel
1236
+ ? isAnthropicFamily(currentModel)
1237
+ ? available.filter(isOpenAiFamily)
1238
+ : isOpenAiFamily(currentModel)
1239
+ ? available.filter(isAnthropicFamily)
1240
+ : []
1241
+ : [];
1242
+ const oppositeProvider = currentProvider
1243
+ ? available.filter((model) => model.provider !== currentProvider)
1244
+ : [];
1245
+ const oppositeProviderFamily = currentProvider
1246
+ ? oppositeFamily.filter((model) => model.provider !== currentProvider)
1247
+ : oppositeFamily;
1248
+ const sameProviderOppositeFamily = currentProvider
1249
+ ? oppositeFamily.filter((model) => model.provider === currentProvider)
1250
+ : [];
1251
+ const oppositeProviderFamilyReasoning = oppositeProviderFamily.filter((model) => model.reasoning);
1252
+ const sameProviderOppositeFamilyReasoning = sameProviderOppositeFamily.filter(
1253
+ (model) => model.reasoning,
1254
+ );
1255
+ const oppositeProviderReasoning = oppositeProvider.filter((model) => model.reasoning);
1256
+
1257
+ let candidates = oppositeProviderFamilyReasoning;
1258
+ let reason =
1259
+ "Selected the top-ranked reasoning model from the opposite model family on a different provider for an independent adversarial pass.";
1260
+ let providerForPreferences: string | undefined = undefined;
1261
+
1262
+ if (candidates.length === 0 && oppositeProviderFamily.length > 0) {
1263
+ candidates = oppositeProviderFamily;
1264
+ reason =
1265
+ "No reasoning models were available in the opposite model family on a different provider, so the top-ranked opposite-family model on a different provider was used.";
1266
+ } else if (candidates.length === 0 && oppositeProviderReasoning.length > 0) {
1267
+ candidates = oppositeProviderReasoning;
1268
+ reason =
1269
+ "No recognized opposite-family model was available, so the top-ranked reasoning model on a different provider was used.";
1270
+ } else if (candidates.length === 0 && oppositeProvider.length > 0) {
1271
+ candidates = oppositeProvider;
1272
+ reason =
1273
+ "No recognized opposite-family reasoning model was available, so the top-ranked model on a different provider was used.";
1274
+ } else if (candidates.length === 0 && sameProviderOppositeFamilyReasoning.length > 0) {
1275
+ candidates = sameProviderOppositeFamilyReasoning;
1276
+ reason =
1277
+ "No different provider was available, so the top-ranked reasoning model from the opposite model family on the current provider was used.";
1278
+ } else if (candidates.length === 0 && sameProviderOppositeFamily.length > 0) {
1279
+ candidates = sameProviderOppositeFamily;
1280
+ reason =
1281
+ "No different provider or opposite-family reasoning model was available, so the top-ranked opposite-family model on the current provider was used.";
1282
+ } else if (candidates.length === 0 && sameProviderReasoning.length > 0) {
1283
+ candidates = sameProviderReasoning;
1284
+ providerForPreferences = currentProvider;
1285
+ reason =
1286
+ "No opposite provider/model family was available, so the top-ranked reasoning model on the current provider was used.";
1287
+ } else if (candidates.length === 0 && sameProvider.length > 0) {
1288
+ candidates = sameProvider;
1289
+ providerForPreferences = currentProvider;
1290
+ reason =
1291
+ "No opposite provider/model family or current-provider reasoning model was available, so the top-ranked model on the current provider was used.";
1292
+ } else if (candidates.length === 0 && allReasoning.length > 0) {
1293
+ candidates = allReasoning;
1294
+ reason =
1295
+ "No current model/provider was active, so the top-ranked reasoning model across all available providers was used.";
1296
+ } else if (candidates.length === 0) {
1297
+ candidates = available;
1298
+ reason =
1299
+ "No reasoning models were available, so the top-ranked model across all available providers was used.";
1300
+ }
1301
+
1302
+ const sorted = [...candidates].sort((a, b) => rankModel(b) - rankModel(a));
1303
+ const preferred = providerForPreferences
1304
+ ? selectPreferredModel(candidates, providerForPreferences)
1305
+ : selectPreferredAcrossProviders(candidates);
1306
+ const orderedModels = preferred
1307
+ ? [preferred, ...sorted.filter((model) => model !== preferred)]
1308
+ : sorted;
1309
+
1310
+ const ordered = orderedModels.map((model, index) =>
1311
+ toContrarianSelection(
1312
+ model,
1313
+ thinkingLevelOverride,
1314
+ index === 0
1315
+ ? preferred
1316
+ ? `Selected ${model.id} via the hardcoded preference lists while preferring an opposite provider/model family.`
1317
+ : reason
1318
+ : `Auto-selected ${model.provider}/${model.id} as a lower-ranked fallback.`,
1319
+ ),
1320
+ );
1321
+
1322
+ return { ok: true, selection: ordered[0], ordered };
1282
1323
  }
1283
1324
 
1284
1325
  function updateContrarianUi(ctx: ExtensionContext, activeRuns: Map<string, ContrarianUiRun>): void {
1285
- if (!ctx.hasUI) return;
1286
- const theme = ctx.ui.theme;
1287
- if (activeRuns.size === 0) {
1288
- ctx.ui.setStatus(CONTRARIAN_STATUS_ID, undefined);
1289
- ctx.ui.setWidget(CONTRARIAN_WIDGET_ID, undefined);
1290
- return;
1291
- }
1292
-
1293
- const runs = [...activeRuns.values()].sort((a, b) => b.startedAt - a.startedAt);
1294
- const primary = runs[0];
1295
- const activeCount = runs.length;
1296
- const modelText = primary.selection?.modelId ?? "selecting…";
1297
- const thinkingText = primary.selection?.thinkingLevel && primary.selection.thinkingLevel !== "off"
1298
- ? ` ${primary.selection.thinkingLevel}`
1299
- : "";
1300
- const elapsed = formatDuration(Date.now() - primary.startedAt);
1301
- const mode = primary.includeBash ? "read-only+bash" : "read-only";
1302
- const status =
1303
- theme.fg("accent", "🔮 contrarian") +
1304
- theme.fg("dim", ` ${modelText}${thinkingText} · ${mode} · ${elapsed}`) +
1305
- (activeCount > 1 ? theme.fg("warning", ` · ${activeCount} active`) : "");
1306
- ctx.ui.setStatus(CONTRARIAN_STATUS_ID, status);
1307
-
1308
- const lines = [
1309
- `🔮 Contrarian ${activeCount > 1 ? `(${activeCount} active)` : ""}`.trim(),
1310
- `${primary.selection?.modelRef ?? "selecting model…"} · ${mode} · ${elapsed}`,
1311
- `task: ${shorten(primary.task, 110)}`,
1312
- ];
1313
- if (primary.preview && primary.preview.trim()) lines.push(`preview: ${shorten(primary.preview, 110)}`);
1314
- ctx.ui.setWidget(CONTRARIAN_WIDGET_ID, lines, { placement: "belowEditor" });
1326
+ if (!ctx.hasUI) return;
1327
+ const theme = ctx.ui.theme;
1328
+ if (activeRuns.size === 0) {
1329
+ ctx.ui.setStatus(CONTRARIAN_STATUS_ID, undefined);
1330
+ ctx.ui.setWidget(CONTRARIAN_WIDGET_ID, undefined);
1331
+ return;
1332
+ }
1333
+
1334
+ const runs = [...activeRuns.values()].sort((a, b) => b.startedAt - a.startedAt);
1335
+ const primary = runs[0];
1336
+ const activeCount = runs.length;
1337
+ const modelText = primary.selection?.modelId ?? "selecting…";
1338
+ const thinkingText =
1339
+ primary.selection?.thinkingLevel && primary.selection.thinkingLevel !== "off"
1340
+ ? ` ${primary.selection.thinkingLevel}`
1341
+ : "";
1342
+ const elapsed = formatDuration(Date.now() - primary.startedAt);
1343
+ const mode = primary.includeBash ? "read-only+bash" : "read-only";
1344
+ const status =
1345
+ theme.fg("accent", "🔮 contrarian") +
1346
+ theme.fg("dim", ` ${modelText}${thinkingText} · ${mode} · ${elapsed}`) +
1347
+ (activeCount > 1 ? theme.fg("warning", ` · ${activeCount} active`) : "");
1348
+ ctx.ui.setStatus(CONTRARIAN_STATUS_ID, status);
1349
+
1350
+ const lines = [
1351
+ `🔮 Contrarian ${activeCount > 1 ? `(${activeCount} active)` : ""}`.trim(),
1352
+ `${primary.selection?.modelRef ?? "selecting model…"} · ${mode} · ${elapsed}`,
1353
+ `task: ${shorten(primary.task, 110)}`,
1354
+ ];
1355
+ if (primary.preview && primary.preview.trim())
1356
+ lines.push(`preview: ${shorten(primary.preview, 110)}`);
1357
+ ctx.ui.setWidget(CONTRARIAN_WIDGET_ID, lines, { placement: "belowEditor" });
1315
1358
  }
1316
1359
 
1317
1360
  async function runContrarian(
1318
- selection: ContrarianSelection,
1319
- params: { task: string; includeBash?: boolean; cwd?: string },
1320
- signal: AbortSignal | undefined,
1321
- onUpdate: ((result: { content: Array<{ type: "text"; text: string }>; details: ContrarianDetails }) => void) | undefined,
1322
- defaultCwd: string,
1323
- spawnImpl: typeof spawn = spawn,
1324
- ): Promise<{ ok: true; output: string; details: ContrarianDetails } | { ok: false; error: string; details: ContrarianDetails }> {
1325
- const cwd = params.cwd ?? defaultCwd;
1326
- const includeBash = params.includeBash ?? false;
1327
- const tools = includeBash ? READ_ONLY_PLUS_BASH_TOOLS : READ_ONLY_TOOLS;
1328
- const startedAt = Date.now();
1329
- const usage = createEmptyUsage();
1330
- let currentText = "";
1331
- let finalOutput = "";
1332
- let stderr = "";
1333
-
1334
- let lastStopReason: string | undefined;
1335
- let lastErrorMessage: string | undefined;
1336
-
1337
- const details: ContrarianDetails = {
1338
- ...selection,
1339
- includeBash,
1340
- usage,
1341
- stderr,
1342
- exitCode: 0,
1343
- durationMs: 0,
1344
- cwd,
1345
- };
1346
-
1347
- const emit = () => {
1348
- details.stderr = stderr;
1349
- details.durationMs = Date.now() - startedAt;
1350
- onUpdate?.({
1351
- content: [{ type: "text", text: currentText || finalOutput || "Consulting contrarian..." }],
1352
- details,
1353
- });
1354
- };
1355
-
1356
- const modelArg = withThinking(selection.modelRef, selection.thinkingLevel);
1357
- const args = [
1358
- "--mode",
1359
- "json",
1360
- "-p",
1361
- "--no-session",
1362
- "--model",
1363
- modelArg,
1364
- "--tools",
1365
- tools.join(","),
1366
- "--append-system-prompt",
1367
- CONTRARIAN_SYSTEM_PROMPT,
1368
- params.task,
1369
- ];
1370
-
1371
- const invocation = getPiInvocation(args);
1372
- let wasAborted = false;
1373
-
1374
- const exitCode = await new Promise<number>((resolve) => {
1375
- const proc = spawnImpl(invocation.command, invocation.args, {
1376
- cwd,
1377
- shell: false,
1378
- stdio: ["ignore", "pipe", "pipe"],
1379
- });
1380
- let buffer = "";
1381
-
1382
- const processLine = (line: string) => {
1383
- if (!line.trim()) return;
1384
- let event: any;
1385
- try {
1386
- event = JSON.parse(line);
1387
- } catch {
1388
- return;
1389
- }
1390
-
1391
- if (event.type === "message_start" && event.message?.role === "assistant") {
1392
- // A later assistant turn supersedes any earlier tool/intermediate answer.
1393
- // Keep only the current message so a tool-only final turn cannot return stale text.
1394
- currentText = "";
1395
- finalOutput = "";
1396
- emit();
1397
- return;
1398
- }
1399
-
1400
- if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
1401
- currentText += event.assistantMessageEvent.delta ?? "";
1402
- emit();
1403
- return;
1404
- }
1405
-
1406
- if (event.type === "message_end" && event.message?.role === "assistant") {
1407
- const text = extractTextFromContent(event.message.content) || currentText;
1408
- // message_end is authoritative, including an empty tool-only message.
1409
- finalOutput = text;
1410
- currentText = "";
1411
-
1412
- const stopReason = event.message.stopReason;
1413
- lastStopReason = typeof stopReason === "string" ? stopReason : undefined;
1414
- const errorMessageField = event.message.errorMessage;
1415
- lastErrorMessage = typeof errorMessageField === "string" ? errorMessageField : undefined;
1416
-
1417
- addUsageStats(usage, event.message.usage as Usage | undefined, {
1418
- countTurn: true,
1419
- updateContextTokens: true,
1420
- });
1421
- emit();
1422
- return;
1423
- }
1424
-
1425
- if (event.type === "compaction_end") {
1426
- addUsageStats(usage, event.result?.usage as Usage | undefined);
1427
- }
1428
- };
1429
-
1430
- proc.stdout.on("data", (data) => {
1431
- buffer += data.toString();
1432
- const lines = buffer.split("\n");
1433
- buffer = lines.pop() || "";
1434
- for (const line of lines) processLine(line);
1435
- });
1436
-
1437
- proc.stderr.on("data", (data) => {
1438
- stderr += data.toString();
1439
- emit();
1440
- });
1441
-
1442
- let closed = false;
1443
- let killTimer: ReturnType<typeof setTimeout> | undefined;
1444
- let abortListener: (() => void) | undefined;
1445
-
1446
- const childHasExited = () => proc.exitCode !== null || proc.signalCode !== null;
1447
- const clearEscalation = () => {
1448
- if (killTimer) {
1449
- clearTimeout(killTimer);
1450
- killTimer = undefined;
1451
- }
1452
- };
1453
- const cleanup = () => {
1454
- clearEscalation();
1455
- if (signal && abortListener) {
1456
- signal.removeEventListener("abort", abortListener);
1457
- abortListener = undefined;
1458
- }
1459
- };
1460
-
1461
- proc.on("exit", () => {
1462
- clearEscalation();
1463
- });
1464
-
1465
- proc.on("close", (code) => {
1466
- closed = true;
1467
- cleanup();
1468
- if (buffer.trim()) processLine(buffer);
1469
- resolve(code ?? 0);
1470
- });
1471
-
1472
- proc.on("error", (error) => {
1473
- cleanup();
1474
- stderr += `${error instanceof Error ? error.message : String(error)}\n`;
1475
- resolve(1);
1476
- });
1477
-
1478
- if (signal) {
1479
- abortListener = () => {
1480
- wasAborted = true;
1481
- proc.kill("SIGTERM");
1482
- if (closed || childHasExited()) return;
1483
- killTimer = setTimeout(() => {
1484
- if (!closed && !childHasExited()) proc.kill("SIGKILL");
1485
- }, 5000);
1486
- };
1487
- if (signal.aborted) abortListener();
1488
- else signal.addEventListener("abort", abortListener, { once: true });
1489
- }
1490
- });
1491
-
1492
- details.stderr = stderr.trim();
1493
- details.exitCode = exitCode;
1494
- details.durationMs = Date.now() - startedAt;
1495
-
1496
- if (wasAborted) {
1497
- if (details.exitCode === 0) details.exitCode = 1;
1498
- return { ok: false, error: "Contrarian was aborted.", details };
1499
- }
1500
-
1501
- // The pi subprocess in `--mode json` does not promote an errored assistant
1502
- // turn to a non-zero exit code or stderr; the error is only carried on the
1503
- // streamed assistant message via stopReason/errorMessage. Surface that here
1504
- // so callers can distinguish transient provider errors from a genuinely
1505
- // empty response.
1506
- if (lastStopReason === "error" || lastStopReason === "aborted") {
1507
- if (details.exitCode === 0) details.exitCode = 1;
1508
- return {
1509
- ok: false,
1510
- error: formatContrarianModelError(lastStopReason, lastErrorMessage),
1511
- details,
1512
- };
1513
- }
1514
-
1515
- if (exitCode !== 0) {
1516
- return {
1517
- ok: false,
1518
- error: details.stderr || finalOutput || "Contrarian subprocess failed.",
1519
- details,
1520
- };
1521
- }
1522
-
1523
- if (!finalOutput.trim()) {
1524
- if (details.exitCode === 0) details.exitCode = 1;
1525
- return {
1526
- ok: false,
1527
- error: details.stderr || "Contrarian finished without returning any text.",
1528
- details,
1529
- };
1530
- }
1531
-
1532
- return {
1533
- ok: true,
1534
- output: finalOutput.trim(),
1535
- details,
1536
- };
1361
+ selection: ContrarianSelection,
1362
+ params: { task: string; includeBash?: boolean; cwd?: string },
1363
+ signal: AbortSignal | undefined,
1364
+ onUpdate:
1365
+ | ((result: {
1366
+ content: Array<{ type: "text"; text: string }>;
1367
+ details: ContrarianDetails;
1368
+ }) => void)
1369
+ | undefined,
1370
+ defaultCwd: string,
1371
+ spawnImpl: typeof spawn = spawn,
1372
+ ): Promise<
1373
+ | { ok: true; output: string; details: ContrarianDetails }
1374
+ | { ok: false; error: string; details: ContrarianDetails }
1375
+ > {
1376
+ const cwd = params.cwd ?? defaultCwd;
1377
+ const includeBash = params.includeBash ?? false;
1378
+ const tools = includeBash ? READ_ONLY_PLUS_BASH_TOOLS : READ_ONLY_TOOLS;
1379
+ const startedAt = Date.now();
1380
+ const usage = createEmptyUsage();
1381
+ let currentText = "";
1382
+ let finalOutput = "";
1383
+ let stderr = "";
1384
+
1385
+ let lastStopReason: string | undefined;
1386
+ let lastErrorMessage: string | undefined;
1387
+
1388
+ const details: ContrarianDetails = {
1389
+ ...selection,
1390
+ includeBash,
1391
+ usage,
1392
+ stderr,
1393
+ exitCode: 0,
1394
+ durationMs: 0,
1395
+ cwd,
1396
+ };
1397
+
1398
+ const emit = () => {
1399
+ details.stderr = stderr;
1400
+ details.durationMs = Date.now() - startedAt;
1401
+ onUpdate?.({
1402
+ content: [{ type: "text", text: currentText || finalOutput || "Consulting contrarian..." }],
1403
+ details,
1404
+ });
1405
+ };
1406
+
1407
+ const modelArg = withThinking(selection.modelRef, selection.thinkingLevel);
1408
+ const args = [
1409
+ "--mode",
1410
+ "json",
1411
+ "-p",
1412
+ "--no-session",
1413
+ "--model",
1414
+ modelArg,
1415
+ "--tools",
1416
+ tools.join(","),
1417
+ "--append-system-prompt",
1418
+ CONTRARIAN_SYSTEM_PROMPT,
1419
+ params.task,
1420
+ ];
1421
+
1422
+ const invocation = getPiInvocation(args);
1423
+ let wasAborted = false;
1424
+
1425
+ const exitCode = await new Promise<number>((resolve) => {
1426
+ const proc = spawnImpl(invocation.command, invocation.args, {
1427
+ cwd,
1428
+ shell: false,
1429
+ stdio: ["ignore", "pipe", "pipe"],
1430
+ });
1431
+ let buffer = "";
1432
+
1433
+ const processLine = (line: string) => {
1434
+ if (!line.trim()) return;
1435
+ let event: any;
1436
+ try {
1437
+ event = JSON.parse(line);
1438
+ } catch {
1439
+ return;
1440
+ }
1441
+
1442
+ if (event.type === "message_start" && event.message?.role === "assistant") {
1443
+ // A later assistant turn supersedes any earlier tool/intermediate answer.
1444
+ // Keep only the current message so a tool-only final turn cannot return stale text.
1445
+ currentText = "";
1446
+ finalOutput = "";
1447
+ emit();
1448
+ return;
1449
+ }
1450
+
1451
+ if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
1452
+ currentText += event.assistantMessageEvent.delta ?? "";
1453
+ emit();
1454
+ return;
1455
+ }
1456
+
1457
+ if (event.type === "message_end" && event.message?.role === "assistant") {
1458
+ const text = extractTextFromContent(event.message.content) || currentText;
1459
+ // message_end is authoritative, including an empty tool-only message.
1460
+ finalOutput = text;
1461
+ currentText = "";
1462
+
1463
+ const stopReason = event.message.stopReason;
1464
+ lastStopReason = typeof stopReason === "string" ? stopReason : undefined;
1465
+ const errorMessageField = event.message.errorMessage;
1466
+ lastErrorMessage = typeof errorMessageField === "string" ? errorMessageField : undefined;
1467
+
1468
+ addUsageStats(usage, event.message.usage as Usage | undefined, {
1469
+ countTurn: true,
1470
+ updateContextTokens: true,
1471
+ });
1472
+ emit();
1473
+ return;
1474
+ }
1475
+
1476
+ if (event.type === "compaction_end") {
1477
+ addUsageStats(usage, event.result?.usage as Usage | undefined);
1478
+ }
1479
+ };
1480
+
1481
+ proc.stdout.on("data", (data) => {
1482
+ buffer += data.toString();
1483
+ const lines = buffer.split("\n");
1484
+ buffer = lines.pop() || "";
1485
+ for (const line of lines) processLine(line);
1486
+ });
1487
+
1488
+ proc.stderr.on("data", (data) => {
1489
+ stderr += data.toString();
1490
+ emit();
1491
+ });
1492
+
1493
+ let closed = false;
1494
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
1495
+ let abortListener: (() => void) | undefined;
1496
+
1497
+ const childHasExited = () => proc.exitCode !== null || proc.signalCode !== null;
1498
+ const clearEscalation = () => {
1499
+ if (killTimer) {
1500
+ clearTimeout(killTimer);
1501
+ killTimer = undefined;
1502
+ }
1503
+ };
1504
+ const cleanup = () => {
1505
+ clearEscalation();
1506
+ if (signal && abortListener) {
1507
+ signal.removeEventListener("abort", abortListener);
1508
+ abortListener = undefined;
1509
+ }
1510
+ };
1511
+
1512
+ proc.on("exit", () => {
1513
+ clearEscalation();
1514
+ });
1515
+
1516
+ proc.on("close", (code) => {
1517
+ closed = true;
1518
+ cleanup();
1519
+ if (buffer.trim()) processLine(buffer);
1520
+ resolve(code ?? 0);
1521
+ });
1522
+
1523
+ proc.on("error", (error) => {
1524
+ cleanup();
1525
+ stderr += `${error instanceof Error ? error.message : String(error)}\n`;
1526
+ resolve(1);
1527
+ });
1528
+
1529
+ if (signal) {
1530
+ abortListener = () => {
1531
+ wasAborted = true;
1532
+ proc.kill("SIGTERM");
1533
+ if (closed || childHasExited()) return;
1534
+ killTimer = setTimeout(() => {
1535
+ if (!closed && !childHasExited()) proc.kill("SIGKILL");
1536
+ }, 5000);
1537
+ };
1538
+ if (signal.aborted) abortListener();
1539
+ else signal.addEventListener("abort", abortListener, { once: true });
1540
+ }
1541
+ });
1542
+
1543
+ details.stderr = stderr.trim();
1544
+ details.exitCode = exitCode;
1545
+ details.durationMs = Date.now() - startedAt;
1546
+
1547
+ if (wasAborted) {
1548
+ if (details.exitCode === 0) details.exitCode = 1;
1549
+ return { ok: false, error: "Contrarian was aborted.", details };
1550
+ }
1551
+
1552
+ // The pi subprocess in `--mode json` does not promote an errored assistant
1553
+ // turn to a non-zero exit code or stderr; the error is only carried on the
1554
+ // streamed assistant message via stopReason/errorMessage. Surface that here
1555
+ // so callers can distinguish transient provider errors from a genuinely
1556
+ // empty response.
1557
+ if (lastStopReason === "error" || lastStopReason === "aborted") {
1558
+ if (details.exitCode === 0) details.exitCode = 1;
1559
+ return {
1560
+ ok: false,
1561
+ error: formatContrarianModelError(lastStopReason, lastErrorMessage),
1562
+ details,
1563
+ };
1564
+ }
1565
+
1566
+ if (exitCode !== 0) {
1567
+ return {
1568
+ ok: false,
1569
+ error: details.stderr || finalOutput || "Contrarian subprocess failed.",
1570
+ details,
1571
+ };
1572
+ }
1573
+
1574
+ if (!finalOutput.trim()) {
1575
+ if (details.exitCode === 0) details.exitCode = 1;
1576
+ return {
1577
+ ok: false,
1578
+ error: details.stderr || "Contrarian finished without returning any text.",
1579
+ details,
1580
+ };
1581
+ }
1582
+
1583
+ return {
1584
+ ok: true,
1585
+ output: finalOutput.trim(),
1586
+ details,
1587
+ };
1537
1588
  }
1538
1589
 
1539
1590
  function renderCollapsedText(text: string, lineLimit = COLLAPSED_LINE_LIMIT): string {
1540
- const lines = text.trim().split("\n");
1541
- if (lines.length <= lineLimit) return lines.join("\n");
1542
- return [...lines.slice(0, lineLimit), `... (${lines.length - lineLimit} more lines)`].join("\n");
1591
+ const lines = text.trim().split("\n");
1592
+ if (lines.length <= lineLimit) return lines.join("\n");
1593
+ return [...lines.slice(0, lineLimit), `... (${lines.length - lineLimit} more lines)`].join("\n");
1543
1594
  }
1544
1595
 
1545
1596
  type ContrarianExtensionDeps = {
1546
- spawnImpl?: typeof spawn;
1597
+ spawnImpl?: typeof spawn;
1547
1598
  };
1548
1599
 
1549
1600
  export const __test__ = {
1550
- buildSessionFallbackSelection,
1551
- createContrarianExtension,
1552
- findAvailableModel,
1553
- isModelAvailabilityError,
1554
- parseModelPreference,
1555
- runContrarian,
1556
- resolveThinkingLevel,
1557
- selectContrarianModel,
1601
+ buildSessionFallbackSelection,
1602
+ createContrarianExtension,
1603
+ findAvailableModel,
1604
+ isModelAvailabilityError,
1605
+ parseModelPreference,
1606
+ runContrarian,
1607
+ resolveThinkingLevel,
1608
+ selectContrarianModel,
1558
1609
  };
1559
1610
 
1560
1611
  export default function contrarianExtension(pi: ExtensionAPI) {
1561
- createContrarianExtension(pi);
1612
+ createContrarianExtension(pi);
1562
1613
  }
1563
1614
 
1564
1615
  function createContrarianExtension(pi: ExtensionAPI, deps: ContrarianExtensionDeps = {}) {
1565
- const spawnImpl = deps.spawnImpl ?? spawn;
1566
- const activeRuns = new Map<string, ContrarianUiRun>();
1567
- let preferences: ContrarianPreferences = {};
1568
-
1569
- pi.on("session_start", async (_event, ctx) => {
1570
- preferences = await readContrarianPreferences();
1571
- activeRuns.clear();
1572
- updateContrarianUi(ctx, activeRuns);
1573
- });
1574
-
1575
- pi.registerCommand("contrarian", {
1576
- description: "Configure Contrarian default model and thinking level for future contrarian tool calls",
1577
- getArgumentCompletions: (prefix) => {
1578
- const parts = prefix.trim().toLowerCase().split(/\s+/).filter(Boolean);
1579
- const first = parts[0] ?? "";
1580
- if (parts.length <= 1) {
1581
- const commands = ["status", "model", "thinking", "clear"];
1582
- const matches = commands.filter((command) => command.startsWith(first));
1583
- return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1584
- }
1585
- if (parts[0] === "thinking") {
1586
- const query = parts[1] ?? "";
1587
- const values = ["auto", ...THINKING_LEVELS];
1588
- const matches = values.filter((value) => value.startsWith(query));
1589
- return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1590
- }
1591
- if (parts[0] === "clear") {
1592
- const query = parts[1] ?? "";
1593
- const values = ["all", "model", "thinking"];
1594
- const matches = values.filter((value) => value.startsWith(query));
1595
- return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1596
- }
1597
- return null;
1598
- },
1599
- handler: async (args, ctx) => {
1600
- const raw = args.trim();
1601
- const tokens = raw ? raw.split(/\s+/) : [];
1602
- const [command = "status", ...rest] = tokens;
1603
- const action = command.toLowerCase();
1604
- const configPath = getContrarianConfigPath();
1605
-
1606
- const save = async (next: ContrarianPreferences): Promise<string | undefined> => {
1607
- preferences = next;
1608
- try {
1609
- await writeContrarianPreferences(preferences);
1610
- return undefined;
1611
- } catch (error) {
1612
- const message = error instanceof Error ? error.message : String(error);
1613
- return `Preference changed for this process, but could not save ${configPath}: ${message}`;
1614
- }
1615
- };
1616
-
1617
- if (action === "status" || action === "show") {
1618
- notifyCommand(ctx, formatContrarianPreferences(preferences));
1619
- return;
1620
- }
1621
-
1622
- if (action === "model") {
1623
- const model = rest.join(" ").trim();
1624
- const normalizedModelAction = model.toLowerCase();
1625
- if (!model) {
1626
- notifyCommand(ctx, "Usage: /contrarian model <provider/model|auto>", "warning");
1627
- return;
1628
- }
1629
- if (normalizedModelAction === "auto" || normalizedModelAction === "clear" || normalizedModelAction === "default") {
1630
- const warning = await save({ ...preferences, model: undefined });
1631
- notifyCommand(ctx, `Contrarian default model cleared; future contrarian calls will auto-select. ${warning ?? formatContrarianPreferences(preferences)}`, warning ? "warning" : "info");
1632
- return;
1633
- }
1634
- const parsedModel = parseModelPreference(model);
1635
- const next = {
1636
- ...preferences,
1637
- model: parsedModel.model,
1638
- thinkingLevel: parsedModel.thinkingLevel ?? preferences.thinkingLevel,
1639
- };
1640
- const warning = await save(next);
1641
- notifyCommand(ctx, `Contrarian default model set to ${parsedModel.model}. ${warning ?? formatContrarianPreferences(preferences)}`, warning ? "warning" : "info");
1642
- return;
1643
- }
1644
-
1645
- if (action === "thinking" || action === "think" || action === "thinking-level") {
1646
- const value = rest[0]?.trim().toLowerCase();
1647
- if (!value) {
1648
- notifyCommand(ctx, `Usage: /contrarian thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
1649
- return;
1650
- }
1651
- if (value === "auto" || value === "clear" || value === "default") {
1652
- const warning = await save({ ...preferences, thinkingLevel: undefined });
1653
- notifyCommand(ctx, `Contrarian default thinking level cleared; future contrarian calls will use built-in defaults. ${warning ?? formatContrarianPreferences(preferences)}`, warning ? "warning" : "info");
1654
- return;
1655
- }
1656
- const thinkingLevel = parseThinkingLevel(value);
1657
- if (!thinkingLevel) {
1658
- notifyCommand(ctx, `Usage: /contrarian thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
1659
- return;
1660
- }
1661
- const warning = await save({ ...preferences, thinkingLevel });
1662
- notifyCommand(ctx, `Contrarian default thinking level set to ${thinkingLevel}. ${warning ?? formatContrarianPreferences(preferences)}`, warning ? "warning" : "info");
1663
- return;
1664
- }
1665
-
1666
- if (action === "clear" || action === "reset") {
1667
- const target = rest[0]?.trim().toLowerCase() || "all";
1668
- let next: ContrarianPreferences;
1669
- if (target === "all") next = {};
1670
- else if (target === "model") next = { ...preferences, model: undefined };
1671
- else if (target === "thinking" || target === "thinking-level") next = { ...preferences, thinkingLevel: undefined };
1672
- else {
1673
- notifyCommand(ctx, "Usage: /contrarian clear [all|model|thinking]", "warning");
1674
- return;
1675
- }
1676
- const warning = await save(next);
1677
- notifyCommand(ctx, `Contrarian defaults cleared (${target}). ${warning ?? formatContrarianPreferences(preferences)}`, warning ? "warning" : "info");
1678
- return;
1679
- }
1680
-
1681
- notifyCommand(ctx, "Usage: /contrarian status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|max|auto> | clear [all|model|thinking]", "warning");
1682
- },
1683
- });
1684
-
1685
- pi.registerCommand("contrarian-model", {
1686
- description: "Show which model the contrarian would use right now",
1687
- handler: async (_args, ctx) => {
1688
- const defaultModel = parseModelPreference(preferences.model);
1689
- if (defaultModel.model) {
1690
- const matched = await findAvailableModel(ctx, defaultModel.model);
1691
- const thinking = resolveThinkingLevel(matched, preferences.thinkingLevel ?? defaultModel.thinkingLevel);
1692
- const modelRef = matched ? `${matched.provider}/${matched.id}` : defaultModel.model;
1693
- const suffix = matched
1694
- ? appendThinkingLevelClampReason("Configured default contrarian model is active.", thinking)
1695
- : "Configured default contrarian model is active, but it was not matched against the authenticated model list.";
1696
- notifyCommand(ctx, `Contrarian: ${modelRef} (${thinking.effective}) — ${suffix}`);
1697
- return;
1698
- }
1699
- const selectionResult = await selectContrarianModel(ctx, preferences.thinkingLevel);
1700
- if (!selectionResult.ok) {
1701
- notifyCommand(ctx, selectionResult.error, "error");
1702
- return;
1703
- }
1704
-
1705
- const { selection } = selectionResult;
1706
- const message = `Contrarian: ${selection.modelRef} (${selection.thinkingLevel}) — ${selection.selectionReason}`;
1707
- notifyCommand(ctx, message);
1708
- },
1709
- });
1710
-
1711
- pi.on("tool_result", async (event) => {
1712
- if (event.toolName !== "contrarian") return undefined;
1713
- const details = event.details as ContrarianDetails | undefined;
1714
- if ((details?.exitCode ?? 0) === 0) return undefined;
1715
- return { isError: true };
1716
- });
1717
-
1718
- pi.registerTool({
1719
- name: "contrarian",
1720
- label: "Contrarian",
1721
- description:
1722
- "Consult a separate read-only contrarian subprocess that stress-tests plans, designs, assumptions, bug hypotheses, and conclusions by steelmanning the strongest opposing case.",
1723
- promptSnippet:
1724
- "Consult a read-only contrarian that prefers a strong opposite-family/provider model for adversarial analysis.",
1725
- promptGuidelines: [
1726
- "Use contrarian when you need to stress-test a proposal, implementation plan, design, assumption, bug hypothesis, review conclusion, or product direction.",
1727
- "Ask contrarian to steelman the strongest opposing case and separate confirmed objections, plausible concerns, and unresolved unknowns.",
1728
- "Do not use contrarian for routine low-value work; contrarian is slower than the main agent.",
1729
- "The contrarian tool is read-only by default and only exposes read, grep, find, and ls unless contrarian includeBash is enabled.",
1730
- "Set contrarian includeBash only when the extra bash inspection tool is genuinely useful; keep contrarian relying on read, grep, find, and ls otherwise.",
1731
- "The contrarian tool requests high by default for reasoning models; contrarian defaults and explicit thinkingLevel overrides are clamped to the effective model-supported level when the model is matched.",
1732
- ],
1733
- parameters: ContrarianParams,
1734
-
1735
- async execute(toolCallId, params, signal, onUpdate, ctx) {
1736
- const rawTask = (params as { task?: unknown }).task;
1737
- const task = typeof rawTask === "string" ? rawTask.trim() : "";
1738
- if (!task) throw new Error("Invalid parameters: expected task to be a non-empty string.");
1739
-
1740
- const explicitModel = parseModelPreference(params.model);
1741
- const defaultModel = parseModelPreference(preferences.model);
1742
- const configuredModel = explicitModel.model ?? defaultModel.model;
1743
- const configuredThinkingLevel =
1744
- params.thinkingLevel ?? explicitModel.thinkingLevel ?? preferences.thinkingLevel ?? defaultModel.thinkingLevel;
1745
- const uiRun: ContrarianUiRun = {
1746
- task,
1747
- includeBash: params.includeBash ?? false,
1748
- startedAt: Date.now(),
1749
- };
1750
- activeRuns.set(toolCallId, uiRun);
1751
- updateContrarianUi(ctx, activeRuns);
1752
-
1753
- try {
1754
- const attempts: ContrarianSelection[] = [];
1755
- const seen = new Set<string>();
1756
- const aggregateUsage = createEmptyUsage();
1757
- const finalizeResult = (
1758
- result: { content: Array<{ type: "text"; text: string }>; details: ContrarianDetails },
1759
- ): AgentToolResult<ContrarianDetails> => ({
1760
- ...result,
1761
- details: { ...result.details, usage: cloneUsageStats(aggregateUsage) },
1762
- usage: usageStatsToToolUsage(aggregateUsage),
1763
- });
1764
- const pushAttempt = (candidate: ContrarianSelection | undefined) => {
1765
- if (!candidate) return;
1766
- const key = `${candidate.provider}/${candidate.modelId}`.toLowerCase();
1767
- if (seen.has(key)) return;
1768
- seen.add(key);
1769
- attempts.push(candidate);
1770
- };
1771
-
1772
- if (configuredModel) {
1773
- const modelRef = configuredModel;
1774
- const matched = await findAvailableModel(ctx, modelRef);
1775
- const provider =
1776
- matched?.provider ?? (modelRef.includes("/") ? modelRef.split("/")[0] : ctx.model?.provider ?? "unknown");
1777
- const modelId = matched?.id ?? (modelRef.includes("/") ? modelRef.split("/").slice(1).join("/") : modelRef);
1778
- const thinking = resolveThinkingLevel(matched, configuredThinkingLevel);
1779
- const usedToolOverride = !!explicitModel.model;
1780
- const selectionReason = matched
1781
- ? appendThinkingLevelClampReason(
1782
- usedToolOverride
1783
- ? "Used the explicit model override provided in the tool call."
1784
- : "Used the configured default contrarian model.",
1785
- thinking,
1786
- )
1787
- : usedToolOverride
1788
- ? "Used the explicit model override provided in the tool call. The model was not matched against the authenticated model list, so the reasoning level fallback was applied."
1789
- : "Used the configured default contrarian model. The model was not matched against the authenticated model list, so the reasoning level fallback was applied.";
1790
- pushAttempt({
1791
- modelRef: matched ? `${matched.provider}/${matched.id}` : modelRef,
1792
- provider,
1793
- modelId,
1794
- modelName: matched?.name,
1795
- thinkingLevel: thinking.effective,
1796
- ...(matched && thinking.clamped
1797
- ? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true }
1798
- : {}),
1799
- autoSelected: false,
1800
- selectionReason,
1801
- });
1802
- } else {
1803
- const selectionResult = await selectContrarianModel(ctx, configuredThinkingLevel);
1804
- if (!selectionResult.ok) {
1805
- return finalizeResult({
1806
- content: [{ type: "text", text: selectionResult.error }],
1807
- details: {
1808
- modelRef: "",
1809
- provider: ctx.model?.provider ?? "unknown",
1810
- modelId: "",
1811
- modelName: undefined,
1812
- thinkingLevel: configuredThinkingLevel ?? DEFAULT_THINKING_LEVEL,
1813
- autoSelected: true,
1814
- selectionReason: selectionResult.error,
1815
- includeBash: params.includeBash ?? false,
1816
- usage: createEmptyUsage(),
1817
- stderr: "",
1818
- exitCode: 1,
1819
- durationMs: 0,
1820
- cwd: params.cwd ?? ctx.cwd,
1821
- },
1822
- });
1823
- }
1824
- for (const candidate of selectionResult.ordered) pushAttempt(candidate);
1825
- }
1826
-
1827
- // Keep the known-good session model as a final fallback: the catalog can
1828
- // advertise models the active provider/subscription cannot actually serve
1829
- // (legacy snapshots, access-gated tiers), which fail with a not-found/404
1830
- // error. Falling back lets the run degrade gracefully instead of hard-failing.
1831
- pushAttempt(buildSessionFallbackSelection(ctx, configuredThinkingLevel));
1832
-
1833
- const handleUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: ContrarianDetails }) => {
1834
- uiRun.preview = partial.content[0]?.text ?? uiRun.preview;
1835
- updateContrarianUi(ctx, activeRuns);
1836
- onUpdate?.(partial);
1837
- };
1838
-
1839
- let lastErrorResult: { ok: false; error: string; details: ContrarianDetails } | undefined;
1840
- for (let index = 0; index < attempts.length; index++) {
1841
- const previous = index > 0 ? attempts[index - 1] : undefined;
1842
- const attempt = previous ? withFallbackReason(attempts[index], previous) : attempts[index];
1843
- uiRun.selection = attempt;
1844
- updateContrarianUi(ctx, activeRuns);
1845
-
1846
- const result = await runContrarian(attempt, { ...params, task }, signal, handleUpdate, ctx.cwd, spawnImpl);
1847
- mergeUsageStats(aggregateUsage, result.details.usage);
1848
- if (result.ok) {
1849
- return finalizeResult({
1850
- content: [{ type: "text", text: result.output }],
1851
- details: result.details,
1852
- });
1853
- }
1854
-
1855
- lastErrorResult = result;
1856
- const canFallBack = index < attempts.length - 1 && isModelAvailabilityError(result.error);
1857
- if (!canFallBack) {
1858
- return finalizeResult({
1859
- content: [{ type: "text", text: result.error }],
1860
- details: result.details,
1861
- });
1862
- }
1863
- }
1864
-
1865
- return finalizeResult({
1866
- content: [
1867
- { type: "text", text: lastErrorResult?.error ?? "Contrarian could not select an available model." },
1868
- ],
1869
- details:
1870
- lastErrorResult?.details ?? {
1871
- modelRef: "",
1872
- provider: ctx.model?.provider ?? "unknown",
1873
- modelId: "",
1874
- modelName: undefined,
1875
- thinkingLevel: configuredThinkingLevel ?? DEFAULT_THINKING_LEVEL,
1876
- autoSelected: true,
1877
- selectionReason: "No authenticated models were available to run the contrarian.",
1878
- includeBash: params.includeBash ?? false,
1879
- usage: createEmptyUsage(),
1880
- stderr: "",
1881
- exitCode: 1,
1882
- durationMs: 0,
1883
- cwd: params.cwd ?? ctx.cwd,
1884
- },
1885
- });
1886
- } finally {
1887
- activeRuns.delete(toolCallId);
1888
- updateContrarianUi(ctx, activeRuns);
1889
- }
1890
- },
1891
-
1892
- renderCall(args, theme) {
1893
- const task = args.task.length > 90 ? `${args.task.slice(0, 90)}...` : args.task;
1894
- const mode = args.includeBash ? "read-only+bash" : "read-only";
1895
- const override = args.model ? ` ${theme.fg("muted", `[${args.model}]`)}` : "";
1896
- const thinking = args.thinkingLevel ? ` ${theme.fg("warning", `(${args.thinkingLevel})`)}` : "";
1897
- return new Text(
1898
- `${theme.fg("toolTitle", theme.bold("contrarian "))}${theme.fg("accent", mode)}${override}${thinking}\n ${theme.fg("dim", task)}`,
1899
- 0,
1900
- 0,
1901
- );
1902
- },
1903
-
1904
- renderResult(result, { expanded }, theme) {
1905
- const details = result.details as ContrarianDetails | undefined;
1906
- const body = result.content[0]?.type === "text" ? result.content[0].text : "(no output)";
1907
- if (!details) return new Text(body, 0, 0);
1908
-
1909
- const isError = (details.exitCode ?? 0) !== 0;
1910
- const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
1911
- const header = `${icon} ${theme.fg("toolTitle", theme.bold("contrarian "))}${theme.fg("accent", details.modelRef || "(auto)")}`;
1912
- const subheader = [
1913
- details.thinkingLevel !== "off" ? details.thinkingLevel : undefined,
1914
- details.includeBash ? "read-only+bash" : "read-only",
1915
- formatDuration(details.durationMs),
1916
- ]
1917
- .filter(Boolean)
1918
- .join(" · ");
1919
- const usage = formatUsage(details.usage);
1920
-
1921
- if (!expanded) {
1922
- let text = header;
1923
- if (subheader) text += `\n${theme.fg("dim", subheader)}`;
1924
- text += `\n\n${theme.fg("toolOutput", renderCollapsedText(body))}`;
1925
- if (usage) text += `\n\n${theme.fg("dim", usage)}`;
1926
- if (isError && details.stderr) text += `\n${theme.fg("error", renderCollapsedText(details.stderr, 4))}`;
1927
- text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1928
- return new Text(text, 0, 0);
1929
- }
1930
-
1931
- const container = new Container();
1932
- container.addChild(new Text(header, 0, 0));
1933
- if (subheader) container.addChild(new Text(theme.fg("dim", subheader), 0, 0));
1934
- container.addChild(new Spacer(1));
1935
- container.addChild(new Text(theme.fg("muted", "Selection"), 0, 0));
1936
- container.addChild(new Text(theme.fg("dim", details.selectionReason), 0, 0));
1937
- container.addChild(new Spacer(1));
1938
- container.addChild(new Text(theme.fg("muted", "Output"), 0, 0));
1939
- container.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme()));
1940
- if (usage) {
1941
- container.addChild(new Spacer(1));
1942
- container.addChild(new Text(theme.fg("muted", "Usage"), 0, 0));
1943
- container.addChild(new Text(theme.fg("dim", usage), 0, 0));
1944
- }
1945
- if (details.stderr) {
1946
- container.addChild(new Spacer(1));
1947
- container.addChild(new Text(theme.fg("muted", "stderr"), 0, 0));
1948
- container.addChild(
1949
- new Text(isError ? theme.fg("error", details.stderr) : theme.fg("dim", details.stderr), 0, 0),
1950
- );
1951
- }
1952
- return container;
1953
- },
1954
- });
1616
+ const spawnImpl = deps.spawnImpl ?? spawn;
1617
+ const activeRuns = new Map<string, ContrarianUiRun>();
1618
+ let preferences: ContrarianPreferences = {};
1619
+
1620
+ pi.on("session_start", async (_event, ctx) => {
1621
+ preferences = await readContrarianPreferences();
1622
+ activeRuns.clear();
1623
+ updateContrarianUi(ctx, activeRuns);
1624
+ });
1625
+
1626
+ pi.registerCommand("contrarian", {
1627
+ description:
1628
+ "Configure Contrarian default model and thinking level for future contrarian tool calls",
1629
+ getArgumentCompletions: (prefix) => {
1630
+ const parts = prefix.trim().toLowerCase().split(/\s+/).filter(Boolean);
1631
+ const first = parts[0] ?? "";
1632
+ if (parts.length <= 1) {
1633
+ const commands = ["status", "model", "thinking", "clear"];
1634
+ const matches = commands.filter((command) => command.startsWith(first));
1635
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1636
+ }
1637
+ if (parts[0] === "thinking") {
1638
+ const query = parts[1] ?? "";
1639
+ const values = ["auto", ...THINKING_LEVELS];
1640
+ const matches = values.filter((value) => value.startsWith(query));
1641
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1642
+ }
1643
+ if (parts[0] === "clear") {
1644
+ const query = parts[1] ?? "";
1645
+ const values = ["all", "model", "thinking"];
1646
+ const matches = values.filter((value) => value.startsWith(query));
1647
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1648
+ }
1649
+ return null;
1650
+ },
1651
+ handler: async (args, ctx) => {
1652
+ const raw = args.trim();
1653
+ const tokens = raw ? raw.split(/\s+/) : [];
1654
+ const [command = "status", ...rest] = tokens;
1655
+ const action = command.toLowerCase();
1656
+ const configPath = getContrarianConfigPath();
1657
+
1658
+ const save = async (next: ContrarianPreferences): Promise<string | undefined> => {
1659
+ preferences = next;
1660
+ try {
1661
+ await writeContrarianPreferences(preferences);
1662
+ return undefined;
1663
+ } catch (error) {
1664
+ const message = error instanceof Error ? error.message : String(error);
1665
+ return `Preference changed for this process, but could not save ${configPath}: ${message}`;
1666
+ }
1667
+ };
1668
+
1669
+ if (action === "status" || action === "show") {
1670
+ notifyCommand(ctx, formatContrarianPreferences(preferences));
1671
+ return;
1672
+ }
1673
+
1674
+ if (action === "model") {
1675
+ const model = rest.join(" ").trim();
1676
+ const normalizedModelAction = model.toLowerCase();
1677
+ if (!model) {
1678
+ notifyCommand(ctx, "Usage: /contrarian model <provider/model|auto>", "warning");
1679
+ return;
1680
+ }
1681
+ if (
1682
+ normalizedModelAction === "auto" ||
1683
+ normalizedModelAction === "clear" ||
1684
+ normalizedModelAction === "default"
1685
+ ) {
1686
+ const warning = await save({ ...preferences, model: undefined });
1687
+ notifyCommand(
1688
+ ctx,
1689
+ `Contrarian default model cleared; future contrarian calls will auto-select. ${warning ?? formatContrarianPreferences(preferences)}`,
1690
+ warning ? "warning" : "info",
1691
+ );
1692
+ return;
1693
+ }
1694
+ const parsedModel = parseModelPreference(model);
1695
+ const next = {
1696
+ ...preferences,
1697
+ model: parsedModel.model,
1698
+ thinkingLevel: parsedModel.thinkingLevel ?? preferences.thinkingLevel,
1699
+ };
1700
+ const warning = await save(next);
1701
+ notifyCommand(
1702
+ ctx,
1703
+ `Contrarian default model set to ${parsedModel.model}. ${warning ?? formatContrarianPreferences(preferences)}`,
1704
+ warning ? "warning" : "info",
1705
+ );
1706
+ return;
1707
+ }
1708
+
1709
+ if (action === "thinking" || action === "think" || action === "thinking-level") {
1710
+ const value = rest[0]?.trim().toLowerCase();
1711
+ if (!value) {
1712
+ notifyCommand(
1713
+ ctx,
1714
+ `Usage: /contrarian thinking ${THINKING_LEVELS.join(" | ")} | auto`,
1715
+ "warning",
1716
+ );
1717
+ return;
1718
+ }
1719
+ if (value === "auto" || value === "clear" || value === "default") {
1720
+ const warning = await save({ ...preferences, thinkingLevel: undefined });
1721
+ notifyCommand(
1722
+ ctx,
1723
+ `Contrarian default thinking level cleared; future contrarian calls will use built-in defaults. ${warning ?? formatContrarianPreferences(preferences)}`,
1724
+ warning ? "warning" : "info",
1725
+ );
1726
+ return;
1727
+ }
1728
+ const thinkingLevel = parseThinkingLevel(value);
1729
+ if (!thinkingLevel) {
1730
+ notifyCommand(
1731
+ ctx,
1732
+ `Usage: /contrarian thinking ${THINKING_LEVELS.join(" | ")} | auto`,
1733
+ "warning",
1734
+ );
1735
+ return;
1736
+ }
1737
+ const warning = await save({ ...preferences, thinkingLevel });
1738
+ notifyCommand(
1739
+ ctx,
1740
+ `Contrarian default thinking level set to ${thinkingLevel}. ${warning ?? formatContrarianPreferences(preferences)}`,
1741
+ warning ? "warning" : "info",
1742
+ );
1743
+ return;
1744
+ }
1745
+
1746
+ if (action === "clear" || action === "reset") {
1747
+ const target = rest[0]?.trim().toLowerCase() || "all";
1748
+ let next: ContrarianPreferences;
1749
+ if (target === "all") next = {};
1750
+ else if (target === "model") next = { ...preferences, model: undefined };
1751
+ else if (target === "thinking" || target === "thinking-level")
1752
+ next = { ...preferences, thinkingLevel: undefined };
1753
+ else {
1754
+ notifyCommand(ctx, "Usage: /contrarian clear [all|model|thinking]", "warning");
1755
+ return;
1756
+ }
1757
+ const warning = await save(next);
1758
+ notifyCommand(
1759
+ ctx,
1760
+ `Contrarian defaults cleared (${target}). ${warning ?? formatContrarianPreferences(preferences)}`,
1761
+ warning ? "warning" : "info",
1762
+ );
1763
+ return;
1764
+ }
1765
+
1766
+ notifyCommand(
1767
+ ctx,
1768
+ "Usage: /contrarian status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|max|auto> | clear [all|model|thinking]",
1769
+ "warning",
1770
+ );
1771
+ },
1772
+ });
1773
+
1774
+ pi.registerCommand("contrarian-model", {
1775
+ description: "Show which model the contrarian would use right now",
1776
+ handler: async (_args, ctx) => {
1777
+ const defaultModel = parseModelPreference(preferences.model);
1778
+ if (defaultModel.model) {
1779
+ const matched = await findAvailableModel(ctx, defaultModel.model);
1780
+ const thinking = resolveThinkingLevel(
1781
+ matched,
1782
+ preferences.thinkingLevel ?? defaultModel.thinkingLevel,
1783
+ );
1784
+ const modelRef = matched ? `${matched.provider}/${matched.id}` : defaultModel.model;
1785
+ const suffix = matched
1786
+ ? appendThinkingLevelClampReason(
1787
+ "Configured default contrarian model is active.",
1788
+ thinking,
1789
+ )
1790
+ : "Configured default contrarian model is active, but it was not matched against the authenticated model list.";
1791
+ notifyCommand(ctx, `Contrarian: ${modelRef} (${thinking.effective}) — ${suffix}`);
1792
+ return;
1793
+ }
1794
+ const selectionResult = await selectContrarianModel(ctx, preferences.thinkingLevel);
1795
+ if (!selectionResult.ok) {
1796
+ notifyCommand(ctx, selectionResult.error, "error");
1797
+ return;
1798
+ }
1799
+
1800
+ const { selection } = selectionResult;
1801
+ const message = `Contrarian: ${selection.modelRef} (${selection.thinkingLevel}) — ${selection.selectionReason}`;
1802
+ notifyCommand(ctx, message);
1803
+ },
1804
+ });
1805
+
1806
+ pi.on("tool_result", async (event) => {
1807
+ if (event.toolName !== "contrarian") return undefined;
1808
+ const details = event.details as ContrarianDetails | undefined;
1809
+ if ((details?.exitCode ?? 0) === 0) return undefined;
1810
+ return { isError: true };
1811
+ });
1812
+
1813
+ pi.registerTool({
1814
+ name: "contrarian",
1815
+ label: "Contrarian",
1816
+ description:
1817
+ "Consult a separate read-only contrarian subprocess that stress-tests plans, designs, assumptions, bug hypotheses, and conclusions by steelmanning the strongest opposing case.",
1818
+ promptSnippet:
1819
+ "Consult a read-only contrarian that prefers a strong opposite-family/provider model for adversarial analysis.",
1820
+ promptGuidelines: [
1821
+ "Use contrarian when you need to stress-test a proposal, implementation plan, design, assumption, bug hypothesis, review conclusion, or product direction.",
1822
+ "Ask contrarian to steelman the strongest opposing case and separate confirmed objections, plausible concerns, and unresolved unknowns.",
1823
+ "Do not use contrarian for routine low-value work; contrarian is slower than the main agent.",
1824
+ "The contrarian tool is read-only by default and only exposes read, grep, find, and ls unless contrarian includeBash is enabled.",
1825
+ "Set contrarian includeBash only when the extra bash inspection tool is genuinely useful; keep contrarian relying on read, grep, find, and ls otherwise.",
1826
+ "The contrarian tool requests high by default for reasoning models; contrarian defaults and explicit thinkingLevel overrides are clamped to the effective model-supported level when the model is matched.",
1827
+ ],
1828
+ parameters: ContrarianParams,
1829
+
1830
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1831
+ const rawTask = (params as { task?: unknown }).task;
1832
+ const task = typeof rawTask === "string" ? rawTask.trim() : "";
1833
+ if (!task) throw new Error("Invalid parameters: expected task to be a non-empty string.");
1834
+
1835
+ const explicitModel = parseModelPreference(params.model);
1836
+ const defaultModel = parseModelPreference(preferences.model);
1837
+ const configuredModel = explicitModel.model ?? defaultModel.model;
1838
+ const configuredThinkingLevel =
1839
+ params.thinkingLevel ??
1840
+ explicitModel.thinkingLevel ??
1841
+ preferences.thinkingLevel ??
1842
+ defaultModel.thinkingLevel;
1843
+ const uiRun: ContrarianUiRun = {
1844
+ task,
1845
+ includeBash: params.includeBash ?? false,
1846
+ startedAt: Date.now(),
1847
+ };
1848
+ activeRuns.set(toolCallId, uiRun);
1849
+ updateContrarianUi(ctx, activeRuns);
1850
+
1851
+ try {
1852
+ const attempts: ContrarianSelection[] = [];
1853
+ const seen = new Set<string>();
1854
+ const aggregateUsage = createEmptyUsage();
1855
+ const finalizeResult = (result: {
1856
+ content: Array<{ type: "text"; text: string }>;
1857
+ details: ContrarianDetails;
1858
+ }): AgentToolResult<ContrarianDetails> => ({
1859
+ ...result,
1860
+ details: { ...result.details, usage: cloneUsageStats(aggregateUsage) },
1861
+ usage: usageStatsToToolUsage(aggregateUsage),
1862
+ });
1863
+ const pushAttempt = (candidate: ContrarianSelection | undefined) => {
1864
+ if (!candidate) return;
1865
+ const key = `${candidate.provider}/${candidate.modelId}`.toLowerCase();
1866
+ if (seen.has(key)) return;
1867
+ seen.add(key);
1868
+ attempts.push(candidate);
1869
+ };
1870
+
1871
+ if (configuredModel) {
1872
+ const modelRef = configuredModel;
1873
+ const matched = await findAvailableModel(ctx, modelRef);
1874
+ const provider =
1875
+ matched?.provider ??
1876
+ (modelRef.includes("/") ? modelRef.split("/")[0] : (ctx.model?.provider ?? "unknown"));
1877
+ const modelId =
1878
+ matched?.id ??
1879
+ (modelRef.includes("/") ? modelRef.split("/").slice(1).join("/") : modelRef);
1880
+ const thinking = resolveThinkingLevel(matched, configuredThinkingLevel);
1881
+ const usedToolOverride = !!explicitModel.model;
1882
+ const selectionReason = matched
1883
+ ? appendThinkingLevelClampReason(
1884
+ usedToolOverride
1885
+ ? "Used the explicit model override provided in the tool call."
1886
+ : "Used the configured default contrarian model.",
1887
+ thinking,
1888
+ )
1889
+ : usedToolOverride
1890
+ ? "Used the explicit model override provided in the tool call. The model was not matched against the authenticated model list, so the reasoning level fallback was applied."
1891
+ : "Used the configured default contrarian model. The model was not matched against the authenticated model list, so the reasoning level fallback was applied.";
1892
+ pushAttempt({
1893
+ modelRef: matched ? `${matched.provider}/${matched.id}` : modelRef,
1894
+ provider,
1895
+ modelId,
1896
+ modelName: matched?.name,
1897
+ thinkingLevel: thinking.effective,
1898
+ ...(matched && thinking.clamped
1899
+ ? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true }
1900
+ : {}),
1901
+ autoSelected: false,
1902
+ selectionReason,
1903
+ });
1904
+ } else {
1905
+ const selectionResult = await selectContrarianModel(ctx, configuredThinkingLevel);
1906
+ if (!selectionResult.ok) {
1907
+ return finalizeResult({
1908
+ content: [{ type: "text", text: selectionResult.error }],
1909
+ details: {
1910
+ modelRef: "",
1911
+ provider: ctx.model?.provider ?? "unknown",
1912
+ modelId: "",
1913
+ modelName: undefined,
1914
+ thinkingLevel: configuredThinkingLevel ?? DEFAULT_THINKING_LEVEL,
1915
+ autoSelected: true,
1916
+ selectionReason: selectionResult.error,
1917
+ includeBash: params.includeBash ?? false,
1918
+ usage: createEmptyUsage(),
1919
+ stderr: "",
1920
+ exitCode: 1,
1921
+ durationMs: 0,
1922
+ cwd: params.cwd ?? ctx.cwd,
1923
+ },
1924
+ });
1925
+ }
1926
+ for (const candidate of selectionResult.ordered) pushAttempt(candidate);
1927
+ }
1928
+
1929
+ pushAttempt(buildSessionFallbackSelection(ctx, configuredThinkingLevel));
1930
+
1931
+ const handleUpdate = (partial: {
1932
+ content: Array<{ type: "text"; text: string }>;
1933
+ details: ContrarianDetails;
1934
+ }) => {
1935
+ uiRun.preview = partial.content[0]?.text ?? uiRun.preview;
1936
+ updateContrarianUi(ctx, activeRuns);
1937
+ onUpdate?.(partial);
1938
+ };
1939
+
1940
+ let lastErrorResult: { ok: false; error: string; details: ContrarianDetails } | undefined;
1941
+ for (let index = 0; index < attempts.length; index++) {
1942
+ const previous = index > 0 ? attempts[index - 1] : undefined;
1943
+ const attempt = previous
1944
+ ? withFallbackReason(attempts[index], previous)
1945
+ : attempts[index];
1946
+ uiRun.selection = attempt;
1947
+ updateContrarianUi(ctx, activeRuns);
1948
+
1949
+ const result = await runContrarian(
1950
+ attempt,
1951
+ { ...params, task },
1952
+ signal,
1953
+ handleUpdate,
1954
+ ctx.cwd,
1955
+ spawnImpl,
1956
+ );
1957
+ mergeUsageStats(aggregateUsage, result.details.usage);
1958
+ if (result.ok) {
1959
+ return finalizeResult({
1960
+ content: [{ type: "text", text: result.output }],
1961
+ details: result.details,
1962
+ });
1963
+ }
1964
+
1965
+ lastErrorResult = result;
1966
+ const canFallBack = index < attempts.length - 1 && isModelAvailabilityError(result.error);
1967
+ if (!canFallBack) {
1968
+ return finalizeResult({
1969
+ content: [{ type: "text", text: result.error }],
1970
+ details: result.details,
1971
+ });
1972
+ }
1973
+ }
1974
+
1975
+ return finalizeResult({
1976
+ content: [
1977
+ {
1978
+ type: "text",
1979
+ text: lastErrorResult?.error ?? "Contrarian could not select an available model.",
1980
+ },
1981
+ ],
1982
+ details: lastErrorResult?.details ?? {
1983
+ modelRef: "",
1984
+ provider: ctx.model?.provider ?? "unknown",
1985
+ modelId: "",
1986
+ modelName: undefined,
1987
+ thinkingLevel: configuredThinkingLevel ?? DEFAULT_THINKING_LEVEL,
1988
+ autoSelected: true,
1989
+ selectionReason: "No authenticated models were available to run the contrarian.",
1990
+ includeBash: params.includeBash ?? false,
1991
+ usage: createEmptyUsage(),
1992
+ stderr: "",
1993
+ exitCode: 1,
1994
+ durationMs: 0,
1995
+ cwd: params.cwd ?? ctx.cwd,
1996
+ },
1997
+ });
1998
+ } finally {
1999
+ activeRuns.delete(toolCallId);
2000
+ updateContrarianUi(ctx, activeRuns);
2001
+ }
2002
+ },
2003
+
2004
+ renderCall(args, theme) {
2005
+ const task = args.task.length > 90 ? `${args.task.slice(0, 90)}...` : args.task;
2006
+ const mode = args.includeBash ? "read-only+bash" : "read-only";
2007
+ const override = args.model ? ` ${theme.fg("muted", `[${args.model}]`)}` : "";
2008
+ const thinking = args.thinkingLevel
2009
+ ? ` ${theme.fg("warning", `(${args.thinkingLevel})`)}`
2010
+ : "";
2011
+ return new Text(
2012
+ `${theme.fg("toolTitle", theme.bold("contrarian "))}${theme.fg("accent", mode)}${override}${thinking}\n ${theme.fg("dim", task)}`,
2013
+ 0,
2014
+ 0,
2015
+ );
2016
+ },
2017
+
2018
+ renderResult(result, { expanded }, theme) {
2019
+ const details = result.details as ContrarianDetails | undefined;
2020
+ const body = result.content[0]?.type === "text" ? result.content[0].text : "(no output)";
2021
+ if (!details) return new Text(body, 0, 0);
2022
+
2023
+ const isError = (details.exitCode ?? 0) !== 0;
2024
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
2025
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold("contrarian "))}${theme.fg("accent", details.modelRef || "(auto)")}`;
2026
+ const subheader = [
2027
+ details.thinkingLevel !== "off" ? details.thinkingLevel : undefined,
2028
+ details.includeBash ? "read-only+bash" : "read-only",
2029
+ formatDuration(details.durationMs),
2030
+ ]
2031
+ .filter(Boolean)
2032
+ .join(" · ");
2033
+ const usage = formatUsage(details.usage);
2034
+
2035
+ if (!expanded) {
2036
+ let text = header;
2037
+ if (subheader) text += `\n${theme.fg("dim", subheader)}`;
2038
+ text += `\n\n${theme.fg("toolOutput", renderCollapsedText(body))}`;
2039
+ if (usage) text += `\n\n${theme.fg("dim", usage)}`;
2040
+ if (isError && details.stderr)
2041
+ text += `\n${theme.fg("error", renderCollapsedText(details.stderr, 4))}`;
2042
+ text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
2043
+ return new Text(text, 0, 0);
2044
+ }
2045
+
2046
+ const container = new Container();
2047
+ container.addChild(new Text(header, 0, 0));
2048
+ if (subheader) container.addChild(new Text(theme.fg("dim", subheader), 0, 0));
2049
+ container.addChild(new Spacer(1));
2050
+ container.addChild(new Text(theme.fg("muted", "Selection"), 0, 0));
2051
+ container.addChild(new Text(theme.fg("dim", details.selectionReason), 0, 0));
2052
+ container.addChild(new Spacer(1));
2053
+ container.addChild(new Text(theme.fg("muted", "Output"), 0, 0));
2054
+ container.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme()));
2055
+ if (usage) {
2056
+ container.addChild(new Spacer(1));
2057
+ container.addChild(new Text(theme.fg("muted", "Usage"), 0, 0));
2058
+ container.addChild(new Text(theme.fg("dim", usage), 0, 0));
2059
+ }
2060
+ if (details.stderr) {
2061
+ container.addChild(new Spacer(1));
2062
+ container.addChild(new Text(theme.fg("muted", "stderr"), 0, 0));
2063
+ container.addChild(
2064
+ new Text(
2065
+ isError ? theme.fg("error", details.stderr) : theme.fg("dim", details.stderr),
2066
+ 0,
2067
+ 0,
2068
+ ),
2069
+ );
2070
+ }
2071
+ return container;
2072
+ },
2073
+ });
1955
2074
  }