@diegopetrucci/pi-extensions 0.1.74 → 0.1.78

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