@diegopetrucci/pi-extensions 0.1.73 → 0.1.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +9 -4
  3. package/extensions/agent-workflow-audit/index.ts +1398 -1009
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/README.md +3 -1
  6. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  7. package/extensions/annotate-git-diff/git.ts +853 -767
  8. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  9. package/extensions/annotate-git-diff/index.ts +712 -671
  10. package/extensions/annotate-git-diff/package.json +1 -1
  11. package/extensions/annotate-git-diff/prompt.ts +45 -45
  12. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  13. package/extensions/annotate-git-diff/review-server.ts +337 -311
  14. package/extensions/annotate-git-diff/types.ts +132 -132
  15. package/extensions/annotate-git-diff/ui.ts +27 -27
  16. package/extensions/annotate-git-diff/watch.ts +74 -74
  17. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  18. package/extensions/annotate-git-diff/web/index.html +5 -6
  19. package/extensions/annotate-last-message/README.md +1 -1
  20. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  21. package/extensions/annotate-last-message/index.ts +210 -206
  22. package/extensions/annotate-last-message/package.json +1 -1
  23. package/extensions/annotate-last-message/prompt.ts +87 -66
  24. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  25. package/extensions/annotate-last-message/session.ts +94 -89
  26. package/extensions/annotate-last-message/theme.ts +199 -193
  27. package/extensions/annotate-last-message/types.ts +23 -23
  28. package/extensions/annotate-last-message/ui.ts +27 -25
  29. package/extensions/annotate-last-message/web/app.js +4 -7
  30. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  31. package/extensions/brrr/README.md +1 -1
  32. package/extensions/brrr/index.ts +213 -191
  33. package/extensions/brrr/package.json +1 -1
  34. package/extensions/claude-fast/README.md +5 -79
  35. package/extensions/claude-fast/index.ts +31 -289
  36. package/extensions/claude-fast/package.json +2 -3
  37. package/extensions/code-reviewer/README.md +1 -1
  38. package/extensions/code-reviewer/index.ts +1598 -1350
  39. package/extensions/code-reviewer/package.json +1 -1
  40. package/extensions/confirm-destructive/README.md +1 -1
  41. package/extensions/confirm-destructive/index.ts +55 -51
  42. package/extensions/confirm-destructive/package.json +1 -1
  43. package/extensions/context-cap/index.ts +312 -304
  44. package/extensions/context-cap/package.json +1 -1
  45. package/extensions/context-inspector/index.ts +1211 -1064
  46. package/extensions/context-inspector/package.json +1 -1
  47. package/extensions/contrarian/index.ts +1854 -1735
  48. package/extensions/contrarian/package.json +1 -1
  49. package/extensions/dirty-repo-guard/index.ts +41 -41
  50. package/extensions/dynamic-context-pruning/README.md +6 -38
  51. package/extensions/dynamic-context-pruning/index.ts +2124 -1980
  52. package/extensions/dynamic-context-pruning/package.json +1 -1
  53. package/extensions/fast/README.md +71 -21
  54. package/extensions/fast/index.ts +513 -313
  55. package/extensions/fast/package.json +1 -1
  56. package/extensions/git-footer/README.md +1 -1
  57. package/extensions/git-footer/index.ts +458 -411
  58. package/extensions/git-footer/package.json +2 -3
  59. package/extensions/gnosis/index.ts +180 -130
  60. package/extensions/gnosis/package.json +1 -1
  61. package/extensions/inline-bash/index.ts +88 -88
  62. package/extensions/inline-bash/package.json +1 -1
  63. package/extensions/librarian/index.ts +1669 -1419
  64. package/extensions/librarian/package.json +1 -1
  65. package/extensions/minimal-footer/index.ts +994 -1012
  66. package/extensions/minimal-footer/openai-usage.ts +195 -190
  67. package/extensions/notify/index.ts +222 -230
  68. package/extensions/notify/package.json +1 -1
  69. package/extensions/openai-fast/README.md +5 -73
  70. package/extensions/openai-fast/index.ts +32 -272
  71. package/extensions/openai-fast/package.json +2 -3
  72. package/extensions/oracle/index.ts +1731 -1625
  73. package/extensions/oracle/package.json +1 -1
  74. package/extensions/permission-gate/index.ts +658 -608
  75. package/extensions/permission-gate/package.json +1 -1
  76. package/extensions/permission-gate/powershell-safety.ts +840 -793
  77. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  78. package/extensions/project-mcp-json/README.md +199 -0
  79. package/extensions/project-mcp-json/config.ts +564 -0
  80. package/extensions/project-mcp-json/index.ts +119 -0
  81. package/extensions/project-mcp-json/package.json +38 -0
  82. package/extensions/quiet-tools/index.ts +282 -264
  83. package/extensions/review/README.md +2 -2
  84. package/extensions/review/index.ts +1745 -1872
  85. package/extensions/review/package.json +1 -1
  86. package/extensions/todo/index.ts +284 -265
  87. package/extensions/triage-comments/README.md +2 -2
  88. package/extensions/triage-comments/index.ts +2294 -1882
  89. package/extensions/triage-comments/package.json +1 -1
  90. package/package.json +6 -3
  91. package/assets/social-preview.png +0 -0
  92. package/extensions/claude-fast/claude-fast.example.json +0 -4
  93. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -3,11 +3,11 @@ import * as path from "node:path";
3
3
 
4
4
  import type { AgentSession, ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
- DefaultResourceLoader,
7
- SessionManager,
8
- SettingsManager,
9
- createAgentSession,
10
- getAgentDir,
6
+ DefaultResourceLoader,
7
+ SessionManager,
8
+ SettingsManager,
9
+ createAgentSession,
10
+ getAgentDir,
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import { Type } from "typebox";
13
13
 
@@ -23,1574 +23,1822 @@ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "ma
23
23
  type ThinkingLevel = (typeof THINKING_LEVELS)[number];
24
24
  type ThinkingLevelMap = Partial<Record<ThinkingLevel, unknown | null>>;
25
25
  const CODE_REVIEWER_MODEL_PREFERENCES = [
26
- "openai/gpt-6-astra ",
27
- "global.openai.gpt-6-astra ",
28
- "us.openai.gpt-6-astra ",
29
- "openai.gpt-6-astra ",
30
- "gpt-6-astra ",
31
- "gpt-6-astra",
32
- "openai/gpt-6.1-sol ",
33
- "global.openai.gpt-6.1-sol ",
34
- "us.openai.gpt-6.1-sol ",
35
- "openai.gpt-6.1-sol ",
36
- "gpt-6.1-sol ",
37
- "openai/gpt-6-sol ",
38
- "gpt-6-sol ",
39
- "gpt-6-sol",
40
- "gpt-5.6-sol",
41
- "gpt-5.6-terra",
42
- "openai/gpt-6-luna ",
43
- "gpt-6-luna ",
44
- "gpt-6-luna",
45
- "gpt-5.6-luna",
46
- "gpt-5.5",
47
- "anthropic/claude-opus-5.5 ",
48
- "global.anthropic.claude-opus-5-5 ",
49
- "au.anthropic.claude-opus-5-5 ",
50
- "eu.anthropic.claude-opus-5-5 ",
51
- "jp.anthropic.claude-opus-5-5 ",
52
- "us.anthropic.claude-opus-5-5 ",
53
- "anthropic.claude-opus-5-5 ",
54
- "claude-opus-5.5 ",
55
- "claude-opus-5-5 ",
56
- "claude-opus-5-5",
57
- "claude-opus-5.5",
58
- "claude-opus-5",
59
- "claude-opus-4-8",
60
- "claude-opus-4.8",
61
- "claude-sonnet-5",
62
- "claude-sonnet-4-6",
63
- "claude-sonnet-4.6",
64
- "claude-sonnet-4-5",
65
- "claude-sonnet-4.5",
66
- "claude-sonnet-4",
67
- "x-ai/grok-4.7 ",
68
- "spacexai/grok-4.7 ",
69
- "grok-4.7 ",
70
- "grok-4.7",
71
- "grok-4.6",
72
- "grok-4.5",
73
- "gemini-3.5-flash",
26
+ "openai/gpt-6-astra ",
27
+ "global.openai.gpt-6-astra ",
28
+ "us.openai.gpt-6-astra ",
29
+ "openai.gpt-6-astra ",
30
+ "gpt-6-astra ",
31
+ "gpt-6-astra",
32
+ "openai/gpt-6.1-sol ",
33
+ "global.openai.gpt-6.1-sol ",
34
+ "us.openai.gpt-6.1-sol ",
35
+ "openai.gpt-6.1-sol ",
36
+ "gpt-6.1-sol ",
37
+ "openai/gpt-6-sol ",
38
+ "gpt-6-sol ",
39
+ "gpt-6-sol",
40
+ "gpt-5.6-sol",
41
+ "gpt-5.6-terra",
42
+ "openai/gpt-6-luna ",
43
+ "gpt-6-luna ",
44
+ "gpt-6-luna",
45
+ "gpt-5.6-luna",
46
+ "gpt-5.5",
47
+ "anthropic/claude-opus-5.5 ",
48
+ "global.anthropic.claude-opus-5-5 ",
49
+ "au.anthropic.claude-opus-5-5 ",
50
+ "eu.anthropic.claude-opus-5-5 ",
51
+ "jp.anthropic.claude-opus-5-5 ",
52
+ "us.anthropic.claude-opus-5-5 ",
53
+ "anthropic.claude-opus-5-5 ",
54
+ "claude-opus-5.5 ",
55
+ "claude-opus-5-5 ",
56
+ "claude-opus-5-5",
57
+ "claude-opus-5.5",
58
+ "claude-opus-5",
59
+ "claude-opus-4-8",
60
+ "claude-opus-4.8",
61
+ "claude-sonnet-5",
62
+ "claude-sonnet-4-6",
63
+ "claude-sonnet-4.6",
64
+ "claude-sonnet-4-5",
65
+ "claude-sonnet-4.5",
66
+ "claude-sonnet-4",
67
+ "x-ai/grok-4.7 ",
68
+ "spacexai/grok-4.7 ",
69
+ "grok-4.7 ",
70
+ "grok-4.7",
71
+ "grok-4.6",
72
+ "grok-4.5",
73
+ "gemini-3.5-flash",
74
74
  ];
75
75
  const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
76
- "amazon-bedrock": [
77
- "global.anthropic.claude-opus-5-5 ",
78
- "au.anthropic.claude-opus-5-5 ",
79
- "eu.anthropic.claude-opus-5-5 ",
80
- "jp.anthropic.claude-opus-5-5 ",
81
- "us.anthropic.claude-opus-5-5 ",
82
- "anthropic.claude-opus-5-5 ",
83
- "claude-opus-5-5",
84
- "claude-opus-5",
85
- "claude-fable-5",
86
- "claude-opus-4-8",
87
- "global.openai.gpt-6-astra ",
88
- "us.openai.gpt-6-astra ",
89
- "openai.gpt-6-astra ",
90
- "gpt-6-astra",
91
- "claude-sonnet-5",
92
- "claude-sonnet-4-6",
93
- "claude-sonnet-4-5",
94
- ],
95
- anthropic: [
96
- "claude-opus-5-5 ",
97
- "claude-opus-5-5",
98
- "claude-opus-5",
99
- "claude-fable-5",
100
- "claude-opus-4-8",
101
- "claude-sonnet-5",
102
- "claude-sonnet-4-6",
103
- "claude-sonnet-4-5",
104
- ],
105
- "azure-openai-responses": [
106
- "gpt-6-astra ",
107
- "gpt-6-astra",
108
- "gpt-6.1-sol ",
109
- "gpt-6-sol ",
110
- "gpt-6-sol",
111
- "gpt-5.6-sol",
112
- "gpt-5.6-terra",
113
- "gpt-6-luna ",
114
- "gpt-6-luna",
115
- "gpt-5.6-luna",
116
- "gpt-5.5-pro",
117
- "gpt-5.5",
118
- "gpt-5.4-pro",
119
- "gpt-5.4",
120
- "gpt-5.3-codex",
121
- "gpt-5-pro",
122
- "gpt-5-chat-latest",
123
- "gpt-5.2-pro",
124
- "gpt-5.2",
125
- "gpt-5.1",
126
- "o3-pro",
127
- "o1-pro",
128
- "gpt-5.4-mini",
129
- "gpt-5-mini",
130
- ],
131
- "cloudflare-ai-gateway": [
132
- "claude-fable-5",
133
- "claude-opus-5",
134
- "claude-opus-4.8",
135
- "claude-opus-4.7",
136
- "claude-opus-4.6",
137
- "claude-opus-4.5",
138
- "gpt-6-astra ",
139
- "gpt-6-astra",
140
- "gpt-5.5",
141
- "gpt-5.4",
142
- ],
143
- "github-copilot": [
144
- "gpt-6-astra ",
145
- "gpt-6-astra",
146
- "gpt-6.1-sol ",
147
- "gpt-6-sol ",
148
- "gpt-6-sol",
149
- "gpt-5.6-sol",
150
- "gpt-5.6-terra",
151
- "gpt-6-luna ",
152
- "gpt-6-luna",
153
- "gpt-5.6-luna",
154
- "gpt-5.5",
155
- "claude-opus-5.5 ",
156
- "claude-opus-5.5",
157
- "claude-opus-5",
158
- "gpt-5.4",
159
- "gpt-5.3-codex",
160
- "gpt-5-mini",
161
- "grok-4.7 ",
162
- "grok-4.7",
163
- "grok-4.6",
164
- "grok-4.5",
165
- ],
166
- opencode: [
167
- "gpt-6-astra ",
168
- "gpt-6-astra",
169
- "gpt-6.1-sol ",
170
- "gpt-6-sol ",
171
- "gpt-6-sol",
172
- "gpt-5.6-sol",
173
- "gpt-5.6-terra",
174
- "gpt-6-luna ",
175
- "gpt-6-luna",
176
- "gpt-5.6-luna",
177
- "gpt-5.5-pro",
178
- "gpt-5.5",
179
- "claude-opus-5-5 ",
180
- "claude-opus-5-5",
181
- "claude-opus-5",
182
- "gpt-5.4-pro",
183
- "gpt-5.4",
184
- "gpt-5.3-codex",
185
- ],
186
- "opencode-go": [
187
- "grok-4.7 ",
188
- "grok-4.7",
189
- "grok-4.6",
190
- ],
191
- openrouter: [
192
- "openai/gpt-6-astra ",
193
- "openai/gpt-6-astra",
194
- "openai/gpt-6.1-sol ",
195
- "openai/gpt-6-sol ",
196
- "openai/gpt-6-sol",
197
- "openai/gpt-5.6-sol-pro",
198
- "openai/gpt-5.6-sol",
199
- "openai/gpt-5.6-terra-pro",
200
- "openai/gpt-5.6-terra",
201
- "openai/gpt-6-luna ",
202
- "openai/gpt-6-luna",
203
- "openai/gpt-5.6-luna-pro",
204
- "openai/gpt-5.6-luna",
205
- "openai/gpt-5.5-pro",
206
- "openai/gpt-5.5",
207
- "anthropic/claude-opus-5.5 ",
208
- "anthropic/claude-opus-5.5",
209
- "anthropic/claude-opus-5",
210
- "x-ai/grok-4.7 ",
211
- "x-ai/grok-4.7",
212
- "x-ai/grok-4.6",
213
- "x-ai/grok-4.5",
214
- ],
215
- radius: [
216
- "gpt-6-astra ",
217
- "gpt-6-astra",
218
- "gpt-6-sol ",
219
- "gpt-6-sol",
220
- "gpt-5.6-sol",
221
- "gpt-5.6-terra",
222
- "gpt-6-luna ",
223
- "gpt-6-luna",
224
- "gpt-5.6-luna",
225
- "gpt-5.5",
226
- "claude-opus-5-5 ",
227
- "claude-opus-5-5",
228
- "claude-opus-5",
229
- "gpt-5.4",
230
- "gpt-5.3-codex",
231
- "gpt-5.4-mini",
232
- ],
233
- openai: ["gpt-6-astra ", "gpt-6-astra", "gpt-6.1-sol ", "gpt-6-sol ", "gpt-6-sol", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6-luna ", "gpt-6-luna", "gpt-5.6-luna", "gpt-5.5", "gpt-5", "gpt-4.1", "o3", "o4-mini"],
234
- "openai-codex": [
235
- "gpt-6-astra ",
236
- "gpt-6-astra",
237
- "gpt-6.1-sol ",
238
- "gpt-6-sol ",
239
- "gpt-6-sol",
240
- "gpt-5.6-sol",
241
- "gpt-5.6-terra",
242
- "gpt-6-luna ",
243
- "gpt-6-luna",
244
- "gpt-5.6-luna",
245
- "gpt-5.5",
246
- "gpt-5.3-codex-spark",
247
- ],
248
- xai: ["grok-4.7 ", "grok-4.7", "grok-4.6", "grok-4.5"],
249
- "vercel-ai-gateway": [
250
- "anthropic/claude-opus-5.5 ",
251
- "anthropic/claude-opus-5.5",
252
- "anthropic/claude-opus-5",
253
- "anthropic/claude-fable-5",
254
- "anthropic/claude-opus-4.8",
255
- "anthropic/claude-opus-4.7",
256
- "anthropic/claude-opus-4.6",
257
- "anthropic/claude-sonnet-5",
258
- "openai/gpt-6-astra ",
259
- "openai/gpt-6-astra",
260
- "openai/gpt-6.1-sol ",
261
- "openai/gpt-6-sol ",
262
- "openai/gpt-6-sol",
263
- "openai/gpt-5.6-sol",
264
- "openai/gpt-5.6-terra",
265
- "openai/gpt-6-luna ",
266
- "openai/gpt-6-luna",
267
- "openai/gpt-5.6-luna",
268
- "openai/gpt-5.5",
269
- "spacexai/grok-4.7 ",
270
- "spacexai/grok-4.7",
271
- "spacexai/grok-4.6",
272
- "xai/grok-4.5",
273
- "google/gemini-3.5-flash",
274
- ],
76
+ "amazon-bedrock": [
77
+ "global.anthropic.claude-opus-5-5 ",
78
+ "au.anthropic.claude-opus-5-5 ",
79
+ "eu.anthropic.claude-opus-5-5 ",
80
+ "jp.anthropic.claude-opus-5-5 ",
81
+ "us.anthropic.claude-opus-5-5 ",
82
+ "anthropic.claude-opus-5-5 ",
83
+ "claude-opus-5-5",
84
+ "claude-opus-5",
85
+ "claude-fable-5",
86
+ "claude-opus-4-8",
87
+ "global.openai.gpt-6-astra ",
88
+ "us.openai.gpt-6-astra ",
89
+ "openai.gpt-6-astra ",
90
+ "gpt-6-astra",
91
+ "claude-sonnet-5",
92
+ "claude-sonnet-4-6",
93
+ "claude-sonnet-4-5",
94
+ ],
95
+ anthropic: [
96
+ "claude-opus-5-5 ",
97
+ "claude-opus-5-5",
98
+ "claude-opus-5",
99
+ "claude-fable-5",
100
+ "claude-opus-4-8",
101
+ "claude-sonnet-5",
102
+ "claude-sonnet-4-6",
103
+ "claude-sonnet-4-5",
104
+ ],
105
+ "azure-openai-responses": [
106
+ "gpt-6-astra ",
107
+ "gpt-6-astra",
108
+ "gpt-6.1-sol ",
109
+ "gpt-6-sol ",
110
+ "gpt-6-sol",
111
+ "gpt-5.6-sol",
112
+ "gpt-5.6-terra",
113
+ "gpt-6-luna ",
114
+ "gpt-6-luna",
115
+ "gpt-5.6-luna",
116
+ "gpt-5.5-pro",
117
+ "gpt-5.5",
118
+ "gpt-5.4-pro",
119
+ "gpt-5.4",
120
+ "gpt-5.3-codex",
121
+ "gpt-5-pro",
122
+ "gpt-5-chat-latest",
123
+ "gpt-5.2-pro",
124
+ "gpt-5.2",
125
+ "gpt-5.1",
126
+ "o3-pro",
127
+ "o1-pro",
128
+ "gpt-5.4-mini",
129
+ "gpt-5-mini",
130
+ ],
131
+ "cloudflare-ai-gateway": [
132
+ "claude-fable-5",
133
+ "claude-opus-5",
134
+ "claude-opus-4.8",
135
+ "claude-opus-4.7",
136
+ "claude-opus-4.6",
137
+ "claude-opus-4.5",
138
+ "gpt-6-astra ",
139
+ "gpt-6-astra",
140
+ "gpt-5.5",
141
+ "gpt-5.4",
142
+ ],
143
+ "github-copilot": [
144
+ "gpt-6-astra ",
145
+ "gpt-6-astra",
146
+ "gpt-6.1-sol ",
147
+ "gpt-6-sol ",
148
+ "gpt-6-sol",
149
+ "gpt-5.6-sol",
150
+ "gpt-5.6-terra",
151
+ "gpt-6-luna ",
152
+ "gpt-6-luna",
153
+ "gpt-5.6-luna",
154
+ "gpt-5.5",
155
+ "claude-opus-5.5 ",
156
+ "claude-opus-5.5",
157
+ "claude-opus-5",
158
+ "gpt-5.4",
159
+ "gpt-5.3-codex",
160
+ "gpt-5-mini",
161
+ "grok-4.7 ",
162
+ "grok-4.7",
163
+ "grok-4.6",
164
+ "grok-4.5",
165
+ ],
166
+ opencode: [
167
+ "gpt-6-astra ",
168
+ "gpt-6-astra",
169
+ "gpt-6.1-sol ",
170
+ "gpt-6-sol ",
171
+ "gpt-6-sol",
172
+ "gpt-5.6-sol",
173
+ "gpt-5.6-terra",
174
+ "gpt-6-luna ",
175
+ "gpt-6-luna",
176
+ "gpt-5.6-luna",
177
+ "gpt-5.5-pro",
178
+ "gpt-5.5",
179
+ "claude-opus-5-5 ",
180
+ "claude-opus-5-5",
181
+ "claude-opus-5",
182
+ "gpt-5.4-pro",
183
+ "gpt-5.4",
184
+ "gpt-5.3-codex",
185
+ ],
186
+ "opencode-go": ["grok-4.7 ", "grok-4.7", "grok-4.6"],
187
+ openrouter: [
188
+ "openai/gpt-6-astra ",
189
+ "openai/gpt-6-astra",
190
+ "openai/gpt-6.1-sol ",
191
+ "openai/gpt-6-sol ",
192
+ "openai/gpt-6-sol",
193
+ "openai/gpt-5.6-sol-pro",
194
+ "openai/gpt-5.6-sol",
195
+ "openai/gpt-5.6-terra-pro",
196
+ "openai/gpt-5.6-terra",
197
+ "openai/gpt-6-luna ",
198
+ "openai/gpt-6-luna",
199
+ "openai/gpt-5.6-luna-pro",
200
+ "openai/gpt-5.6-luna",
201
+ "openai/gpt-5.5-pro",
202
+ "openai/gpt-5.5",
203
+ "anthropic/claude-opus-5.5 ",
204
+ "anthropic/claude-opus-5.5",
205
+ "anthropic/claude-opus-5",
206
+ "x-ai/grok-4.7 ",
207
+ "x-ai/grok-4.7",
208
+ "x-ai/grok-4.6",
209
+ "x-ai/grok-4.5",
210
+ ],
211
+ radius: [
212
+ "gpt-6-astra ",
213
+ "gpt-6-astra",
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",
222
+ "claude-opus-5-5 ",
223
+ "claude-opus-5-5",
224
+ "claude-opus-5",
225
+ "gpt-5.4",
226
+ "gpt-5.3-codex",
227
+ "gpt-5.4-mini",
228
+ ],
229
+ openai: [
230
+ "gpt-6-astra ",
231
+ "gpt-6-astra",
232
+ "gpt-6.1-sol ",
233
+ "gpt-6-sol ",
234
+ "gpt-6-sol",
235
+ "gpt-5.6-sol",
236
+ "gpt-5.6-terra",
237
+ "gpt-6-luna ",
238
+ "gpt-6-luna",
239
+ "gpt-5.6-luna",
240
+ "gpt-5.5",
241
+ "gpt-5",
242
+ "gpt-4.1",
243
+ "o3",
244
+ "o4-mini",
245
+ ],
246
+ "openai-codex": [
247
+ "gpt-6-astra ",
248
+ "gpt-6-astra",
249
+ "gpt-6.1-sol ",
250
+ "gpt-6-sol ",
251
+ "gpt-6-sol",
252
+ "gpt-5.6-sol",
253
+ "gpt-5.6-terra",
254
+ "gpt-6-luna ",
255
+ "gpt-6-luna",
256
+ "gpt-5.6-luna",
257
+ "gpt-5.5",
258
+ "gpt-5.3-codex-spark",
259
+ ],
260
+ xai: ["grok-4.7 ", "grok-4.7", "grok-4.6", "grok-4.5"],
261
+ "vercel-ai-gateway": [
262
+ "anthropic/claude-opus-5.5 ",
263
+ "anthropic/claude-opus-5.5",
264
+ "anthropic/claude-opus-5",
265
+ "anthropic/claude-fable-5",
266
+ "anthropic/claude-opus-4.8",
267
+ "anthropic/claude-opus-4.7",
268
+ "anthropic/claude-opus-4.6",
269
+ "anthropic/claude-sonnet-5",
270
+ "openai/gpt-6-astra ",
271
+ "openai/gpt-6-astra",
272
+ "openai/gpt-6.1-sol ",
273
+ "openai/gpt-6-sol ",
274
+ "openai/gpt-6-sol",
275
+ "openai/gpt-5.6-sol",
276
+ "openai/gpt-5.6-terra",
277
+ "openai/gpt-6-luna ",
278
+ "openai/gpt-6-luna",
279
+ "openai/gpt-5.6-luna",
280
+ "openai/gpt-5.5",
281
+ "spacexai/grok-4.7 ",
282
+ "spacexai/grok-4.7",
283
+ "spacexai/grok-4.6",
284
+ "xai/grok-4.5",
285
+ "google/gemini-3.5-flash",
286
+ ],
275
287
  };
276
288
 
277
289
  const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "bash"]);
278
290
  const READ_ONLY_BASH_COMMANDS = new Set(["gh", "git", "pwd"]);
279
291
  const READ_ONLY_GIT_SUBCOMMANDS = new Set([
280
- "blame",
281
- "cat-file",
282
- "describe",
283
- "diff",
284
- "for-each-ref",
285
- "log",
286
- "ls-files",
287
- "ls-tree",
288
- "merge-base",
289
- "name-rev",
290
- "remote",
291
- "rev-parse",
292
- "shortlog",
293
- "show",
294
- "show-ref",
295
- "status",
296
- "whatchanged",
292
+ "blame",
293
+ "cat-file",
294
+ "describe",
295
+ "diff",
296
+ "for-each-ref",
297
+ "log",
298
+ "ls-files",
299
+ "ls-tree",
300
+ "merge-base",
301
+ "name-rev",
302
+ "remote",
303
+ "rev-parse",
304
+ "shortlog",
305
+ "show",
306
+ "show-ref",
307
+ "status",
308
+ "whatchanged",
297
309
  ]);
298
310
  const SAFE_GIT_BRANCH_FLAGS = new Set([
299
- "-a",
300
- "--all",
301
- "-r",
302
- "--remotes",
303
- "-v",
304
- "-vv",
305
- "--show-current",
306
- "--list",
307
- "--contains",
308
- "--merged",
309
- "--no-merged",
311
+ "-a",
312
+ "--all",
313
+ "-r",
314
+ "--remotes",
315
+ "-v",
316
+ "-vv",
317
+ "--show-current",
318
+ "--list",
319
+ "--contains",
320
+ "--merged",
321
+ "--no-merged",
310
322
  ]);
311
323
  const SAFE_GIT_GLOBAL_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
312
324
  const SAFE_GIT_CONFIG_OVERRIDES = ["core.pager=cat", "core.fsmonitor=false", "diff.external="];
313
325
  const GIT_SUBCOMMAND_SAFE_FLAGS: Partial<Record<string, string[]>> = {
314
- blame: ["--no-textconv"],
315
- diff: ["--no-ext-diff", "--no-textconv"],
316
- log: ["--no-ext-diff", "--no-textconv"],
317
- show: ["--no-ext-diff", "--no-textconv"],
318
- whatchanged: ["--no-ext-diff", "--no-textconv"],
326
+ blame: ["--no-textconv"],
327
+ diff: ["--no-ext-diff", "--no-textconv"],
328
+ log: ["--no-ext-diff", "--no-textconv"],
329
+ show: ["--no-ext-diff", "--no-textconv"],
330
+ whatchanged: ["--no-ext-diff", "--no-textconv"],
319
331
  };
320
332
  const GIT_OPTIONS_REQUIRING_LOCAL_FILE = [
321
- {
322
- flag: "--contents",
323
- blockedPrefixes: ["--con"],
324
- reason: "Code Reviewer bash blocks git --contents because it can read local files outside built-in path guards.",
325
- },
326
- {
327
- flag: "--pathspec-from-file",
328
- blockedPrefixes: ["--pathspec"],
329
- reason: "Code Reviewer bash blocks git --pathspec-from-file because it can read local files outside built-in path guards.",
330
- },
331
- {
332
- flag: "--ignore-revs-file",
333
- blockedPrefixes: ["--ignore-rev"],
334
- reason: "Code Reviewer bash blocks git --ignore-revs-file because it can read local files outside built-in path guards.",
335
- },
333
+ {
334
+ flag: "--contents",
335
+ blockedPrefixes: ["--con"],
336
+ reason:
337
+ "Code Reviewer bash blocks git --contents because it can read local files outside built-in path guards.",
338
+ },
339
+ {
340
+ flag: "--pathspec-from-file",
341
+ blockedPrefixes: ["--pathspec"],
342
+ reason:
343
+ "Code Reviewer bash blocks git --pathspec-from-file because it can read local files outside built-in path guards.",
344
+ },
345
+ {
346
+ flag: "--ignore-revs-file",
347
+ blockedPrefixes: ["--ignore-rev"],
348
+ reason:
349
+ "Code Reviewer bash blocks git --ignore-revs-file because it can read local files outside built-in path guards.",
350
+ },
336
351
  ] as const;
337
- const GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE: Partial<Record<string, Array<{ reason: string; matches: (token: string) => boolean }>>> = {
338
- blame: [
339
- {
340
- reason: "Code Reviewer bash blocks git blame -S because it can read local revs files outside built-in path guards.",
341
- matches: (token) => token === "-S" || (token.startsWith("-S") && token.length > 2),
342
- },
343
- ],
344
- "ls-files": [
345
- {
346
- reason: "Code Reviewer bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
347
- matches: (token) => token === "-X" || (token.startsWith("-X") && token.length > 2),
348
- },
349
- {
350
- reason: "Code Reviewer bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
351
- matches: (token) => {
352
- if (!token.startsWith("--")) return false;
353
- const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
354
- return option === "--exclude-from" || option.startsWith("--exclude-f");
355
- },
356
- },
357
- ],
352
+ const GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE: Partial<
353
+ Record<string, Array<{ reason: string; matches: (token: string) => boolean }>>
354
+ > = {
355
+ blame: [
356
+ {
357
+ reason:
358
+ "Code Reviewer bash blocks git blame -S because it can read local revs files outside built-in path guards.",
359
+ matches: (token) => token === "-S" || (token.startsWith("-S") && token.length > 2),
360
+ },
361
+ ],
362
+ "ls-files": [
363
+ {
364
+ reason:
365
+ "Code Reviewer bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
366
+ matches: (token) => token === "-X" || (token.startsWith("-X") && token.length > 2),
367
+ },
368
+ {
369
+ reason:
370
+ "Code Reviewer bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
371
+ matches: (token) => {
372
+ if (!token.startsWith("--")) return false;
373
+ const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
374
+ return option === "--exclude-from" || option.startsWith("--exclude-f");
375
+ },
376
+ },
377
+ ],
358
378
  };
359
- const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set(["--repo", "-R", "--hostname", "--jq", "-q", "--template"]);
379
+ const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set([
380
+ "--repo",
381
+ "-R",
382
+ "--hostname",
383
+ "--jq",
384
+ "-q",
385
+ "--template",
386
+ ]);
360
387
  const MUTATING_GH_API_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
361
388
 
362
389
  const CodeReviewerParams = Type.Object({
363
- task: Type.String({
364
- description: "What change to review, what ticket/story it should satisfy, and any specific review focus.",
365
- }),
366
- diff: Type.Optional(
367
- Type.String({
368
- description: "Optional diff, patch, or change summary to treat as the primary review target.",
369
- }),
370
- ),
371
- context: Type.Optional(
372
- Type.String({
373
- description: "Optional caller context such as constraints, known risks, or expected behavior.",
374
- }),
375
- ),
390
+ task: Type.String({
391
+ description:
392
+ "What change to review, what ticket/story it should satisfy, and any specific review focus.",
393
+ }),
394
+ diff: Type.Optional(
395
+ Type.String({
396
+ description: "Optional diff, patch, or change summary to treat as the primary review target.",
397
+ }),
398
+ ),
399
+ context: Type.Optional(
400
+ Type.String({
401
+ description:
402
+ "Optional caller context such as constraints, known risks, or expected behavior.",
403
+ }),
404
+ ),
376
405
  });
377
406
 
378
407
  type ToolCall = {
379
- id: string;
380
- name: string;
381
- args: unknown;
382
- startedAt: number;
383
- endedAt?: number;
384
- isError?: boolean;
408
+ id: string;
409
+ name: string;
410
+ args: unknown;
411
+ startedAt: number;
412
+ endedAt?: number;
413
+ isError?: boolean;
385
414
  };
386
415
 
387
416
  type ReviewStatus = "running" | "done" | "error" | "aborted";
388
417
 
389
418
  type ReviewDetails = {
390
- status: ReviewStatus;
391
- cwd: string;
392
- task: string;
393
- turns: number;
394
- toolCalls: ToolCall[];
395
- startedAt: number;
396
- endedAt?: number;
397
- error?: string;
398
- modelRef?: string;
399
- modelName?: string;
400
- requestedThinkingLevel?: ThinkingLevel;
401
- effectiveThinkingLevel?: ThinkingLevel;
402
- thinkingLevelClamped?: boolean;
403
- thinkingLevelNote?: string;
419
+ status: ReviewStatus;
420
+ cwd: string;
421
+ task: string;
422
+ turns: number;
423
+ toolCalls: ToolCall[];
424
+ startedAt: number;
425
+ endedAt?: number;
426
+ error?: string;
427
+ modelRef?: string;
428
+ modelName?: string;
429
+ requestedThinkingLevel?: ThinkingLevel;
430
+ effectiveThinkingLevel?: ThinkingLevel;
431
+ thinkingLevelClamped?: boolean;
432
+ thinkingLevelNote?: string;
404
433
  };
405
434
 
406
435
  type PiModel = {
407
- provider: string;
408
- id: string;
409
- name?: string;
410
- reasoning?: boolean;
411
- contextWindow?: number;
412
- maxTokens?: number;
413
- thinkingLevelMap?: ThinkingLevelMap;
436
+ provider: string;
437
+ id: string;
438
+ name?: string;
439
+ reasoning?: boolean;
440
+ contextWindow?: number;
441
+ maxTokens?: number;
442
+ thinkingLevelMap?: ThinkingLevelMap;
414
443
  };
415
444
 
416
445
  type ModelRegistryContext = {
417
- model?: PiModel;
418
- modelRegistry: { getAvailable(): PiModel[] | Promise<PiModel[]> };
419
- scopedModels?: ReadonlyArray<{ model: PiModel; thinkingLevel?: ThinkingLevel }>;
446
+ model?: PiModel;
447
+ modelRegistry: { getAvailable(): PiModel[] | Promise<PiModel[]> };
448
+ scopedModels?: ReadonlyArray<{ model: PiModel; thinkingLevel?: ThinkingLevel }>;
420
449
  };
421
450
 
422
451
  function modelKey(model: PiModel): string {
423
- return `${model.provider}/${model.id}`.toLowerCase();
452
+ return `${model.provider}/${model.id}`.toLowerCase();
424
453
  }
425
454
 
426
455
  function hasModelScope(ctx: ModelRegistryContext): boolean {
427
- return Array.isArray(ctx.scopedModels) && ctx.scopedModels.length > 0;
456
+ return Array.isArray(ctx.scopedModels) && ctx.scopedModels.length > 0;
428
457
  }
429
458
 
430
459
  function isModelInScope(ctx: ModelRegistryContext, model: PiModel): boolean {
431
- if (!hasModelScope(ctx)) return true;
432
- const key = modelKey(model);
433
- return ctx.scopedModels!.some((entry) => modelKey(entry.model) === key);
460
+ if (!hasModelScope(ctx)) return true;
461
+ const key = modelKey(model);
462
+ return ctx.scopedModels!.some((entry) => modelKey(entry.model) === key);
434
463
  }
435
464
 
436
465
  async function getAutoSelectableModels(ctx: ModelRegistryContext): Promise<PiModel[]> {
437
- const available = await ctx.modelRegistry.getAvailable();
438
- return hasModelScope(ctx) ? available.filter((model) => isModelInScope(ctx, model)) : available;
466
+ const available = await ctx.modelRegistry.getAvailable();
467
+ return hasModelScope(ctx) ? available.filter((model) => isModelInScope(ctx, model)) : available;
439
468
  }
440
469
 
441
470
  type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
442
471
  type CreateAgentSessionModel = CreateAgentSessionOptions["model"];
443
472
 
444
- function getModelRuntimeOption(ctx: { modelRegistry?: unknown }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
445
- const modelRuntime = (ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined)?.runtime;
446
- return modelRuntime ? { modelRuntime } : {};
473
+ function getModelRuntimeOption(ctx: {
474
+ modelRegistry?: unknown;
475
+ }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
476
+ const modelRuntime = (
477
+ ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined
478
+ )?.runtime;
479
+ return modelRuntime ? { modelRuntime } : {};
447
480
  }
448
481
 
449
482
  const MODEL_AVAILABILITY_ERROR_PATTERN =
450
- /\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;
483
+ /\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;
451
484
 
452
485
  function isModelAvailabilityError(message: string | undefined): boolean {
453
- if (!message) return false;
454
- return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
486
+ if (!message) return false;
487
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
455
488
  }
456
489
 
457
490
  function parseVersionScore(text: string): number {
458
- const matches = text.match(/\d+(?:[._-]\d+)*/g) ?? [];
459
- let best = 0;
460
- for (const rawMatch of matches) {
461
- const match = rawMatch.replace(/[_-]/g, ".");
462
- const [major = "0", minor = "0", patch = "0"] = match.split(".");
463
- const score = Number(major) * 1_000_000 + Number(minor) * 1_000 + Number(patch);
464
- if (score > best) best = score;
465
- }
466
- return best;
491
+ const matches = text.match(/\d+(?:[._-]\d+)*/g) ?? [];
492
+ let best = 0;
493
+ for (const rawMatch of matches) {
494
+ const match = rawMatch.replace(/[_-]/g, ".");
495
+ const [major = "0", minor = "0", patch = "0"] = match.split(".");
496
+ const score = Number(major) * 1_000_000 + Number(minor) * 1_000 + Number(patch);
497
+ if (score > best) best = score;
498
+ }
499
+ return best;
467
500
  }
468
501
 
469
502
  function rankModel(model: PiModel): number {
470
- const text = `${model.id} ${model.name ?? ""}`.toLowerCase();
471
- const has = (regex: RegExp): boolean => regex.test(text);
472
- let score = 0;
473
-
474
- if (model.reasoning) score += 10_000_000;
475
- score += parseVersionScore(text) * 1_000;
476
- score += Math.min(model.maxTokens ?? 0, 200_000);
477
- score += Math.floor(Math.min(model.contextWindow ?? 0, 1_000_000) / 100);
478
-
479
- if (has(/\bopus\b/)) score += 350_000;
480
- if (has(/\bpro\b/)) score += 180_000;
481
- if (has(/\bmax\b/)) score += 60_000;
482
- if (has(/\bultra\b/)) score += 150_000;
483
- if (has(/\bsonnet\b/)) score += 120_000;
484
- if (has(/\bcodex\b|\bcoder\b|\bcode\b/)) score += 40_000;
485
- if (has(/\breasoning\b|\bthink(?:ing)?\b/)) score += 60_000;
486
-
487
- if (has(/\bhaiku\b/)) score -= 420_000;
488
- if (has(/\bmini\b/)) score -= 520_000;
489
- if (has(/\bnano\b/)) score -= 700_000;
490
- if (has(/\bflash\b/)) score -= 520_000;
491
- if (has(/\bspark\b/)) score -= 650_000;
492
- if (has(/\blite\b|\bsmall\b|\bfast\b|\binstant\b/)) score -= 300_000;
493
-
494
- return score;
503
+ const text = `${model.id} ${model.name ?? ""}`.toLowerCase();
504
+ const has = (regex: RegExp): boolean => regex.test(text);
505
+ let score = 0;
506
+
507
+ if (model.reasoning) score += 10_000_000;
508
+ score += parseVersionScore(text) * 1_000;
509
+ score += Math.min(model.maxTokens ?? 0, 200_000);
510
+ score += Math.floor(Math.min(model.contextWindow ?? 0, 1_000_000) / 100);
511
+
512
+ if (has(/\bopus\b/)) score += 350_000;
513
+ if (has(/\bpro\b/)) score += 180_000;
514
+ if (has(/\bmax\b/)) score += 60_000;
515
+ if (has(/\bultra\b/)) score += 150_000;
516
+ if (has(/\bsonnet\b/)) score += 120_000;
517
+ if (has(/\bcodex\b|\bcoder\b|\bcode\b/)) score += 40_000;
518
+ if (has(/\breasoning\b|\bthink(?:ing)?\b/)) score += 60_000;
519
+
520
+ if (has(/\bhaiku\b/)) score -= 420_000;
521
+ if (has(/\bmini\b/)) score -= 520_000;
522
+ if (has(/\bnano\b/)) score -= 700_000;
523
+ if (has(/\bflash\b/)) score -= 520_000;
524
+ if (has(/\bspark\b/)) score -= 650_000;
525
+ if (has(/\blite\b|\bsmall\b|\bfast\b|\binstant\b/)) score -= 300_000;
526
+
527
+ return score;
495
528
  }
496
529
 
497
530
  function modelText(model: PiModel): string {
498
- return `${model.provider} ${model.id} ${model.name ?? ""}`.toLowerCase();
531
+ return `${model.provider} ${model.id} ${model.name ?? ""}`.toLowerCase();
499
532
  }
500
533
 
501
534
  function isAnthropicFamily(model: PiModel): boolean {
502
- return /\b(anthropic|claude|opus|sonnet|haiku|fable)\b/.test(modelText(model));
535
+ return /\b(anthropic|claude|opus|sonnet|haiku|fable)\b/.test(modelText(model));
503
536
  }
504
537
 
505
538
  function isOpenAiFamily(model: PiModel): boolean {
506
- return /\b(openai|codex|chatgpt|gpt[-_. ]?\d|o[1345](?:\b|-|_))\b/.test(modelText(model));
539
+ return /\b(openai|codex|chatgpt|gpt[-_. ]?\d|o[1345](?:\b|-|_))\b/.test(modelText(model));
507
540
  }
508
541
 
509
542
  function getProviderPreferenceList(provider: string | undefined): string[] | undefined {
510
- if (!provider) return undefined;
511
- return PROVIDER_MODEL_PREFERENCES[provider.toLowerCase()];
543
+ if (!provider) return undefined;
544
+ return PROVIDER_MODEL_PREFERENCES[provider.toLowerCase()];
512
545
  }
513
546
 
514
- function selectPreferredModel(models: PiModel[], provider: string | undefined): PiModel | undefined {
515
- const preferences = getProviderPreferenceList(provider);
516
- if (!preferences || preferences.length === 0) return undefined;
517
- const lowered = models.map((model) => ({ model, haystack: `${model.id} ${model.name ?? ""}`.toLowerCase() }));
518
- for (const pattern of preferences) {
519
- const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
520
- if (match) return match.model;
521
- }
522
- return undefined;
547
+ function selectPreferredModel(
548
+ models: PiModel[],
549
+ provider: string | undefined,
550
+ ): PiModel | undefined {
551
+ const preferences = getProviderPreferenceList(provider);
552
+ if (!preferences || preferences.length === 0) return undefined;
553
+ const lowered = models.map((model) => ({
554
+ model,
555
+ haystack: `${model.id} ${model.name ?? ""}`.toLowerCase(),
556
+ }));
557
+ for (const pattern of preferences) {
558
+ const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
559
+ if (match) return match.model;
560
+ }
561
+ return undefined;
523
562
  }
524
563
 
525
564
  function selectPreferredAcrossProviders(models: PiModel[]): PiModel | undefined {
526
- const lowered = models.map((model) => ({ model, haystack: `${model.id} ${model.name ?? ""}`.toLowerCase() }));
527
- for (const pattern of CODE_REVIEWER_MODEL_PREFERENCES) {
528
- const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
529
- if (match) return match.model;
530
- }
531
- for (const preferences of Object.values(PROVIDER_MODEL_PREFERENCES)) {
532
- for (const pattern of preferences) {
533
- const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
534
- if (match) return match.model;
535
- }
536
- }
537
- return undefined;
565
+ const lowered = models.map((model) => ({
566
+ model,
567
+ haystack: `${model.id} ${model.name ?? ""}`.toLowerCase(),
568
+ }));
569
+ for (const pattern of CODE_REVIEWER_MODEL_PREFERENCES) {
570
+ const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
571
+ if (match) return match.model;
572
+ }
573
+ for (const preferences of Object.values(PROVIDER_MODEL_PREFERENCES)) {
574
+ for (const pattern of preferences) {
575
+ const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
576
+ if (match) return match.model;
577
+ }
578
+ }
579
+ return undefined;
538
580
  }
539
581
 
540
582
  function orderPreferredModels(candidates: PiModel[], providerForPreferences?: string): PiModel[] {
541
- const sorted = [...candidates].sort((a, b) => rankModel(b) - rankModel(a));
542
- const preferred = providerForPreferences
543
- ? selectPreferredModel(candidates, providerForPreferences)
544
- : selectPreferredAcrossProviders(candidates);
545
- return preferred ? [preferred, ...sorted.filter((model) => model !== preferred)] : sorted;
583
+ const sorted = [...candidates].sort((a, b) => rankModel(b) - rankModel(a));
584
+ const preferred = providerForPreferences
585
+ ? selectPreferredModel(candidates, providerForPreferences)
586
+ : selectPreferredAcrossProviders(candidates);
587
+ return preferred ? [preferred, ...sorted.filter((model) => model !== preferred)] : sorted;
546
588
  }
547
589
 
548
590
  function getModelKey(model: PiModel): string {
549
- return `${model.provider}::${model.id}`;
591
+ return `${model.provider}::${model.id}`;
550
592
  }
551
593
 
552
594
  function appendOrderedCandidates(
553
- ordered: PiModel[],
554
- seen: Set<string>,
555
- candidates: PiModel[],
556
- providerForPreferences?: string,
595
+ ordered: PiModel[],
596
+ seen: Set<string>,
597
+ candidates: PiModel[],
598
+ providerForPreferences?: string,
557
599
  ): void {
558
- for (const model of orderPreferredModels(candidates, providerForPreferences)) {
559
- const key = getModelKey(model);
560
- if (seen.has(key)) continue;
561
- seen.add(key);
562
- ordered.push(model);
563
- }
600
+ for (const model of orderPreferredModels(candidates, providerForPreferences)) {
601
+ const key = getModelKey(model);
602
+ if (seen.has(key)) continue;
603
+ seen.add(key);
604
+ ordered.push(model);
605
+ }
564
606
  }
565
607
 
566
608
  async function selectCodeReviewerModel(
567
- ctx: ModelRegistryContext,
609
+ ctx: ModelRegistryContext,
568
610
  ): Promise<{ ok: true; selection: PiModel; ordered: PiModel[] } | { ok: false; error: string }> {
569
- const available = await getAutoSelectableModels(ctx);
570
- if (available.length === 0) {
571
- return {
572
- ok: false,
573
- error: hasModelScope(ctx)
574
- ? "No authenticated models are available in the current session model scope. Adjust the scope, log in, or configure an API key."
575
- : "No authenticated models are available. Log in or configure an API key first.",
576
- };
577
- }
578
-
579
- const currentModel = ctx.model;
580
- const currentProvider = currentModel?.provider;
581
- const sameProvider = currentProvider ? available.filter((model) => model.provider === currentProvider) : [];
582
- const sameProviderReasoning = sameProvider.filter((model) => model.reasoning);
583
- const allReasoning = available.filter((model) => model.reasoning);
584
- const oppositeFamily = currentModel
585
- ? isAnthropicFamily(currentModel)
586
- ? available.filter(isOpenAiFamily)
587
- : isOpenAiFamily(currentModel)
588
- ? available.filter(isAnthropicFamily)
589
- : []
590
- : [];
591
- const oppositeProvider = currentProvider ? available.filter((model) => model.provider !== currentProvider) : [];
592
- const oppositeProviderFamily = currentProvider
593
- ? oppositeFamily.filter((model) => model.provider !== currentProvider)
594
- : oppositeFamily;
595
- const sameProviderOppositeFamily = currentProvider
596
- ? oppositeFamily.filter((model) => model.provider === currentProvider)
597
- : [];
598
- const oppositeProviderFamilyReasoning = oppositeProviderFamily.filter((model) => model.reasoning);
599
- const sameProviderOppositeFamilyReasoning = sameProviderOppositeFamily.filter((model) => model.reasoning);
600
- const oppositeFamilyReasoning = oppositeFamily.filter((model) => model.reasoning);
601
- const oppositeProviderReasoning = oppositeProvider.filter((model) => model.reasoning);
602
-
603
- const ordered: PiModel[] = [];
604
- const seen = new Set<string>();
605
- appendOrderedCandidates(ordered, seen, oppositeProviderFamilyReasoning);
606
- appendOrderedCandidates(ordered, seen, oppositeProviderFamily);
607
- appendOrderedCandidates(ordered, seen, oppositeProviderReasoning);
608
- appendOrderedCandidates(ordered, seen, oppositeProvider);
609
- appendOrderedCandidates(ordered, seen, sameProviderOppositeFamilyReasoning);
610
- appendOrderedCandidates(ordered, seen, sameProviderOppositeFamily);
611
- appendOrderedCandidates(ordered, seen, oppositeFamilyReasoning);
612
- appendOrderedCandidates(ordered, seen, sameProviderReasoning, currentProvider);
613
- appendOrderedCandidates(ordered, seen, sameProvider, currentProvider);
614
- appendOrderedCandidates(ordered, seen, allReasoning);
615
- appendOrderedCandidates(ordered, seen, available);
616
-
617
- return { ok: true, selection: ordered[0], ordered };
611
+ const available = await getAutoSelectableModels(ctx);
612
+ if (available.length === 0) {
613
+ return {
614
+ ok: false,
615
+ error: hasModelScope(ctx)
616
+ ? "No authenticated models are available in the current session model scope. Adjust the scope, log in, or configure an API key."
617
+ : "No authenticated models are available. Log in or configure an API key first.",
618
+ };
619
+ }
620
+
621
+ const currentModel = ctx.model;
622
+ const currentProvider = currentModel?.provider;
623
+ const sameProvider = currentProvider
624
+ ? available.filter((model) => model.provider === currentProvider)
625
+ : [];
626
+ const sameProviderReasoning = sameProvider.filter((model) => model.reasoning);
627
+ const allReasoning = available.filter((model) => model.reasoning);
628
+ const oppositeFamily = currentModel
629
+ ? isAnthropicFamily(currentModel)
630
+ ? available.filter(isOpenAiFamily)
631
+ : isOpenAiFamily(currentModel)
632
+ ? available.filter(isAnthropicFamily)
633
+ : []
634
+ : [];
635
+ const oppositeProvider = currentProvider
636
+ ? available.filter((model) => model.provider !== currentProvider)
637
+ : [];
638
+ const oppositeProviderFamily = currentProvider
639
+ ? oppositeFamily.filter((model) => model.provider !== currentProvider)
640
+ : oppositeFamily;
641
+ const sameProviderOppositeFamily = currentProvider
642
+ ? oppositeFamily.filter((model) => model.provider === currentProvider)
643
+ : [];
644
+ const oppositeProviderFamilyReasoning = oppositeProviderFamily.filter((model) => model.reasoning);
645
+ const sameProviderOppositeFamilyReasoning = sameProviderOppositeFamily.filter(
646
+ (model) => model.reasoning,
647
+ );
648
+ const oppositeProviderReasoning = oppositeProvider.filter((model) => model.reasoning);
649
+
650
+ const ordered: PiModel[] = [];
651
+ const seen = new Set<string>();
652
+ appendOrderedCandidates(ordered, seen, oppositeProviderFamilyReasoning);
653
+ appendOrderedCandidates(ordered, seen, oppositeProviderFamily);
654
+ appendOrderedCandidates(ordered, seen, oppositeProviderReasoning);
655
+ appendOrderedCandidates(ordered, seen, oppositeProvider);
656
+ appendOrderedCandidates(ordered, seen, sameProviderOppositeFamilyReasoning);
657
+ appendOrderedCandidates(ordered, seen, sameProviderOppositeFamily);
658
+ appendOrderedCandidates(ordered, seen, sameProviderReasoning, currentProvider);
659
+ appendOrderedCandidates(ordered, seen, sameProvider, currentProvider);
660
+ appendOrderedCandidates(ordered, seen, allReasoning);
661
+ appendOrderedCandidates(ordered, seen, available);
662
+
663
+ return { ok: true, selection: ordered[0], ordered };
618
664
  }
619
665
 
620
666
  function normalizeThinkingLevel(value: unknown): ThinkingLevel | undefined {
621
- return typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel) ? (value as ThinkingLevel) : undefined;
667
+ return typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel)
668
+ ? (value as ThinkingLevel)
669
+ : undefined;
622
670
  }
623
671
 
624
672
  // Keep these local so the extension stays compatible with older pi peer installs that do not export clamp helpers.
625
673
  function isThinkingLevelSupported(model: PiModel, level: ThinkingLevel): boolean {
626
- if (!model.reasoning) return level === "off";
674
+ if (!model.reasoning) return level === "off";
627
675
 
628
- const map = model.thinkingLevelMap;
629
- if (level === "xhigh" || level === "max") {
630
- return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
631
- }
632
- return map?.[level] !== null;
676
+ const map = model.thinkingLevelMap;
677
+ if (level === "xhigh" || level === "max") {
678
+ return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
679
+ }
680
+ return map?.[level] !== null;
633
681
  }
634
682
 
635
683
  function clampThinkingLevel(model: PiModel, requested: ThinkingLevel): ThinkingLevel {
636
- if (isThinkingLevelSupported(model, requested)) return requested;
637
-
638
- const requestedIndex = THINKING_LEVELS.indexOf(requested);
639
- for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
640
- const level = THINKING_LEVELS[index];
641
- if (isThinkingLevelSupported(model, level)) return level;
642
- }
643
- for (let index = requestedIndex - 1; index >= 0; index -= 1) {
644
- const level = THINKING_LEVELS[index];
645
- if (isThinkingLevelSupported(model, level)) return level;
646
- }
647
-
648
- return "off";
684
+ if (isThinkingLevelSupported(model, requested)) return requested;
685
+
686
+ const requestedIndex = THINKING_LEVELS.indexOf(requested);
687
+ for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
688
+ const level = THINKING_LEVELS[index];
689
+ if (isThinkingLevelSupported(model, level)) return level;
690
+ }
691
+ for (let index = requestedIndex - 1; index >= 0; index -= 1) {
692
+ const level = THINKING_LEVELS[index];
693
+ if (isThinkingLevelSupported(model, level)) return level;
694
+ }
695
+
696
+ return "off";
649
697
  }
650
698
 
651
699
  function resolveThinkingLevel(
652
- model: PiModel | undefined,
653
- requestedThinkingLevel: ThinkingLevel | undefined,
700
+ model: PiModel | undefined,
701
+ requestedThinkingLevel: ThinkingLevel | undefined,
654
702
  ): { requested: ThinkingLevel; effective: ThinkingLevel; clamped: boolean; note: string } {
655
- const requested = requestedThinkingLevel ?? (model?.reasoning ? DEFAULT_THINKING_LEVEL : "off");
656
- const effective = model ? clampThinkingLevel(model, requested) : requested;
657
- const clamped = effective !== requested;
658
- if (clamped) {
659
- return {
660
- requested,
661
- effective,
662
- clamped,
663
- note: `requested ${requested}; clamped to ${effective}`,
664
- };
665
- }
666
- if (requestedThinkingLevel) {
667
- return { requested, effective, clamped, note: `requested ${requested}` };
668
- }
669
- return {
670
- requested,
671
- effective,
672
- clamped,
673
- note: model?.reasoning ? `defaulted to ${effective}` : `defaulted to off for non-reasoning model`,
674
- };
703
+ const requested = requestedThinkingLevel ?? (model?.reasoning ? DEFAULT_THINKING_LEVEL : "off");
704
+ const effective = model ? clampThinkingLevel(model, requested) : requested;
705
+ const clamped = effective !== requested;
706
+ if (clamped) {
707
+ return {
708
+ requested,
709
+ effective,
710
+ clamped,
711
+ note: `requested ${requested}; clamped to ${effective}`,
712
+ };
713
+ }
714
+ if (requestedThinkingLevel) {
715
+ return { requested, effective, clamped, note: `requested ${requested}` };
716
+ }
717
+ return {
718
+ requested,
719
+ effective,
720
+ clamped,
721
+ note: model?.reasoning
722
+ ? `defaulted to ${effective}`
723
+ : `defaulted to off for non-reasoning model`,
724
+ };
675
725
  }
676
726
 
677
- function applyModelAttemptDetails(details: ReviewDetails, model: PiModel, thinking: ReturnType<typeof resolveThinkingLevel>): void {
678
- details.modelRef = `${model.provider}/${model.id}`;
679
- details.modelName = model.name;
680
- details.requestedThinkingLevel = thinking.requested;
681
- details.effectiveThinkingLevel = thinking.effective;
682
- details.thinkingLevelClamped = thinking.clamped || undefined;
683
- details.thinkingLevelNote = thinking.note;
727
+ function applyModelAttemptDetails(
728
+ details: ReviewDetails,
729
+ model: PiModel,
730
+ thinking: ReturnType<typeof resolveThinkingLevel>,
731
+ ): void {
732
+ details.modelRef = `${model.provider}/${model.id}`;
733
+ details.modelName = model.name;
734
+ details.requestedThinkingLevel = thinking.requested;
735
+ details.effectiveThinkingLevel = thinking.effective;
736
+ details.thinkingLevelClamped = thinking.clamped || undefined;
737
+ details.thinkingLevelNote = thinking.note;
684
738
  }
685
739
 
686
740
  function createEmptyUsage(): Usage {
687
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
741
+ return {
742
+ input: 0,
743
+ output: 0,
744
+ cacheRead: 0,
745
+ cacheWrite: 0,
746
+ totalTokens: 0,
747
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
748
+ };
688
749
  }
689
750
 
690
751
  function addUsage(total: Usage, usage: Usage): void {
691
- total.input += usage.input || 0;
692
- total.output += usage.output || 0;
693
- total.cacheRead += usage.cacheRead || 0;
694
- total.cacheWrite += usage.cacheWrite || 0;
695
- if (usage.cacheWrite1h !== undefined) total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
696
- if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
697
- total.totalTokens += usage.totalTokens || 0;
698
- total.cost.input += usage.cost?.input || 0;
699
- total.cost.output += usage.cost?.output || 0;
700
- total.cost.cacheRead += usage.cost?.cacheRead || 0;
701
- total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
702
- total.cost.total += usage.cost?.total || 0;
752
+ total.input += usage.input || 0;
753
+ total.output += usage.output || 0;
754
+ total.cacheRead += usage.cacheRead || 0;
755
+ total.cacheWrite += usage.cacheWrite || 0;
756
+ if (usage.cacheWrite1h !== undefined)
757
+ total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
758
+ if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
759
+ total.totalTokens += usage.totalTokens || 0;
760
+ total.cost.input += usage.cost?.input || 0;
761
+ total.cost.output += usage.cost?.output || 0;
762
+ total.cost.cacheRead += usage.cost?.cacheRead || 0;
763
+ total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
764
+ total.cost.total += usage.cost?.total || 0;
703
765
  }
704
766
 
705
- type AssistantLikeMessage = { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown; usage?: unknown };
767
+ type AssistantLikeMessage = {
768
+ role?: string;
769
+ content?: unknown;
770
+ stopReason?: unknown;
771
+ errorMessage?: unknown;
772
+ usage?: unknown;
773
+ };
706
774
 
707
775
  function addAssistantMessageUsage(total: Usage, message: unknown): void {
708
- const assistant = message as AssistantLikeMessage;
709
- if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object") return;
710
- addUsage(total, assistant.usage as Usage);
776
+ const assistant = message as AssistantLikeMessage;
777
+ if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object")
778
+ return;
779
+ addUsage(total, assistant.usage as Usage);
711
780
  }
712
781
 
713
782
  function aggregateAssistantUsage(messages: unknown[]): Usage {
714
- const total = createEmptyUsage();
715
- for (const message of messages) addAssistantMessageUsage(total, message);
716
- return total;
783
+ const total = createEmptyUsage();
784
+ for (const message of messages) addAssistantMessageUsage(total, message);
785
+ return total;
717
786
  }
718
787
 
719
788
  function addSessionEventUsage(total: Usage, event: unknown): void {
720
- const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
721
- if (sessionEvent?.type === "message_end") return void addAssistantMessageUsage(total, sessionEvent.message);
722
- if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage) addUsage(total, sessionEvent.result.usage);
789
+ const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
790
+ if (sessionEvent?.type === "message_end")
791
+ return void addAssistantMessageUsage(total, sessionEvent.message);
792
+ if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage)
793
+ addUsage(total, sessionEvent.result.usage);
723
794
  }
724
795
 
725
- function inspectFinalAssistant(messages: unknown[]):
726
- | { ok: true; answer: string; stopReason?: string }
727
- | { ok: false; reason: string; stopReason?: string; errorMessage?: string } {
728
- for (let i = messages.length - 1; i >= 0; i -= 1) {
729
- const message = messages[i] as AssistantLikeMessage;
730
- if (message?.role !== "assistant") continue;
731
- const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
732
- const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
733
- const answer = Array.isArray(message.content)
734
- ? message.content
735
- .map((part) => (part && typeof part === "object" && (part as { type?: string }).type === "text" && typeof (part as { text?: unknown }).text === "string"
736
- ? (part as { text: string }).text
737
- : ""))
738
- .join("")
739
- .trim()
740
- : "";
741
- if (stopReason === "error") return { ok: false, reason: `code_reviewer subagent error: ${errorMessage || "provider/model error"}`, stopReason, errorMessage };
742
- if (stopReason === "aborted") return { ok: false, reason: "code_reviewer subagent aborted before producing a usable answer", stopReason };
743
- if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
744
- return { ok: false, reason: stopReason ? `code_reviewer subagent produced no final assistant text (stopReason: ${stopReason})` : "code_reviewer subagent produced no final assistant text", stopReason: stopReason || undefined, errorMessage: errorMessage || undefined };
745
- }
746
- return { ok: false, reason: "code_reviewer subagent produced no assistant message" };
796
+ function inspectFinalAssistant(
797
+ messages: unknown[],
798
+ ):
799
+ | { ok: true; answer: string; stopReason?: string }
800
+ | { ok: false; reason: string; stopReason?: string; errorMessage?: string } {
801
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
802
+ const message = messages[i] as AssistantLikeMessage;
803
+ if (message?.role !== "assistant") continue;
804
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
805
+ const errorMessage =
806
+ typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
807
+ const answer = Array.isArray(message.content)
808
+ ? message.content
809
+ .map((part) =>
810
+ part &&
811
+ typeof part === "object" &&
812
+ (part as { type?: string }).type === "text" &&
813
+ typeof (part as { text?: unknown }).text === "string"
814
+ ? (part as { text: string }).text
815
+ : "",
816
+ )
817
+ .join("")
818
+ .trim()
819
+ : "";
820
+ if (stopReason === "error")
821
+ return {
822
+ ok: false,
823
+ reason: `code_reviewer subagent error: ${errorMessage || "provider/model error"}`,
824
+ stopReason,
825
+ errorMessage,
826
+ };
827
+ if (stopReason === "aborted")
828
+ return {
829
+ ok: false,
830
+ reason: "code_reviewer subagent aborted before producing a usable answer",
831
+ stopReason,
832
+ };
833
+ if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
834
+ return {
835
+ ok: false,
836
+ reason: stopReason
837
+ ? `code_reviewer subagent produced no final assistant text (stopReason: ${stopReason})`
838
+ : "code_reviewer subagent produced no final assistant text",
839
+ stopReason: stopReason || undefined,
840
+ errorMessage: errorMessage || undefined,
841
+ };
842
+ }
843
+ return { ok: false, reason: "code_reviewer subagent produced no assistant message" };
747
844
  }
748
845
 
749
- function classifyRunFailure(error: unknown, aborted: boolean): { status: "aborted" | "error"; message: string; error?: string } {
750
- const message = aborted ? "Aborted" : error instanceof Error ? error.message : String(error);
751
- return { status: aborted ? "aborted" : "error", message, error: aborted ? undefined : message };
846
+ function classifyRunFailure(
847
+ error: unknown,
848
+ aborted: boolean,
849
+ ): { status: "aborted" | "error"; message: string; error?: string } {
850
+ const message = aborted ? "Aborted" : error instanceof Error ? error.message : String(error);
851
+ return { status: aborted ? "aborted" : "error", message, error: aborted ? undefined : message };
752
852
  }
753
853
 
754
854
  function isInside(parent: string, child: string): boolean {
755
- const parentResolved = path.resolve(parent);
756
- const childResolved = path.resolve(child);
757
- return childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`);
855
+ const parentResolved = path.resolve(parent);
856
+ const childResolved = path.resolve(child);
857
+ return (
858
+ childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`)
859
+ );
758
860
  }
759
861
 
760
862
  function resolveToolPath(cwd: string, rawPath: string | undefined): string {
761
- const input = rawPath?.trim() || ".";
762
- const normalized = input.startsWith("@") ? input.slice(1) : input;
763
- return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
863
+ const input = rawPath?.trim() || ".";
864
+ const normalized = input.startsWith("@") ? input.slice(1) : input;
865
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
764
866
  }
765
867
 
766
- async function assertToolPathInsideCwd(cwd: string, rawPath: unknown, toolName: string): Promise<string | undefined> {
767
- if (rawPath !== undefined && typeof rawPath !== "string") return `${toolName} path must be a string.`;
768
- if (typeof rawPath === "string") {
769
- const normalizedInput = rawPath.trim().startsWith("@") ? rawPath.trim().slice(1) : rawPath.trim();
770
- if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalizedInput)) {
771
- return `${toolName} path must not traverse outside the local checkout.`;
772
- }
773
- }
774
- const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
775
- const resolved = resolveToolPath(cwd, rawPath);
776
- const realPath = await fs.realpath(resolved).catch(() => resolved);
777
- if (!isInside(root, realPath)) return `${toolName} is limited to the local checkout: ${realPath}`;
778
- return undefined;
868
+ async function assertToolPathInsideCwd(
869
+ cwd: string,
870
+ rawPath: unknown,
871
+ toolName: string,
872
+ ): Promise<string | undefined> {
873
+ if (rawPath !== undefined && typeof rawPath !== "string")
874
+ return `${toolName} path must be a string.`;
875
+ if (typeof rawPath === "string") {
876
+ const normalizedInput = rawPath.trim().startsWith("@")
877
+ ? rawPath.trim().slice(1)
878
+ : rawPath.trim();
879
+ if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalizedInput)) {
880
+ return `${toolName} path must not traverse outside the local checkout.`;
881
+ }
882
+ }
883
+ const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
884
+ const resolved = resolveToolPath(cwd, rawPath);
885
+ const realPath = await fs.realpath(resolved).catch(() => resolved);
886
+ if (!isInside(root, realPath)) return `${toolName} is limited to the local checkout: ${realPath}`;
887
+ return undefined;
779
888
  }
780
889
 
781
890
  function getUnsafePathPatternReason(value: unknown, label: string): string | undefined {
782
- if (value === undefined) return undefined;
783
- if (typeof value !== "string") return `${label} must be a string.`;
784
- if (path.isAbsolute(value)) return `${label} must be relative to the local checkout.`;
785
- if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value)) return `${label} must not traverse outside the local checkout.`;
786
- return undefined;
891
+ if (value === undefined) return undefined;
892
+ if (typeof value !== "string") return `${label} must be a string.`;
893
+ if (path.isAbsolute(value)) return `${label} must be relative to the local checkout.`;
894
+ if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value))
895
+ return `${label} must not traverse outside the local checkout.`;
896
+ return undefined;
787
897
  }
788
898
 
789
899
  function stripTokenQuotes(token: string): string {
790
- if ((token.startsWith("'") && token.endsWith("'")) || (token.startsWith('"') && token.endsWith('"'))) {
791
- return token.slice(1, -1);
792
- }
793
- return token;
900
+ if (
901
+ (token.startsWith("'") && token.endsWith("'")) ||
902
+ (token.startsWith('"') && token.endsWith('"'))
903
+ ) {
904
+ return token.slice(1, -1);
905
+ }
906
+ return token;
794
907
  }
795
908
 
796
909
  type ShellTokenizeResult = {
797
- tokens: string[];
798
- reason?: string;
910
+ tokens: string[];
911
+ reason?: string;
799
912
  };
800
913
 
801
914
  function tokenizeShellCommand(command: string): ShellTokenizeResult {
802
- const tokens: string[] = [];
803
- let token = "";
804
- let tokenStarted = false;
805
- let inSingleQuote = false;
806
- let inDoubleQuote = false;
807
-
808
- const pushToken = () => {
809
- if (!tokenStarted) return;
810
- tokens.push(token);
811
- token = "";
812
- tokenStarted = false;
813
- };
814
-
815
- for (let index = 0; index < command.length; index += 1) {
816
- const char = command[index];
817
- const next = command[index + 1] ?? "";
818
-
819
- if (inSingleQuote) {
820
- if (char === "'") {
821
- inSingleQuote = false;
822
- continue;
823
- }
824
- token += char;
825
- tokenStarted = true;
826
- continue;
827
- }
828
-
829
- if (char === "\\") {
830
- return {
831
- tokens,
832
- reason: "Code Reviewer bash blocks shell escape sequences; pass plain git, gh, or pwd arguments without backslashes.",
833
- };
834
- }
835
-
836
- if (inDoubleQuote) {
837
- if (char === '"') {
838
- inDoubleQuote = false;
839
- continue;
840
- }
841
- if (char === "`" || char === "$") {
842
- return {
843
- tokens,
844
- reason:
845
- char === "`" || next === "("
846
- ? "Code Reviewer bash blocks command substitution."
847
- : "Code Reviewer bash blocks shell expansion in double quotes.",
848
- };
849
- }
850
- token += char;
851
- tokenStarted = true;
852
- continue;
853
- }
854
-
855
- if (char === "\n" || char === "\r") {
856
- return {
857
- tokens,
858
- reason:
859
- "Code Reviewer bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
860
- };
861
- }
862
- if (/\s/.test(char)) {
863
- pushToken();
864
- continue;
865
- }
866
- if (char === "'") {
867
- inSingleQuote = true;
868
- tokenStarted = true;
869
- continue;
870
- }
871
- if (char === '"') {
872
- inDoubleQuote = true;
873
- tokenStarted = true;
874
- continue;
875
- }
876
- if (char === "`") return { tokens, reason: "Code Reviewer bash blocks command substitution." };
877
- if (char === "$") {
878
- return {
879
- tokens,
880
- reason:
881
- next === "'" || next === '"'
882
- ? "Code Reviewer bash blocks ANSI-C and localized shell quotes."
883
- : next === "("
884
- ? "Code Reviewer bash blocks command substitution."
885
- : "Code Reviewer bash blocks shell expansion.",
886
- };
887
- }
888
- if (/[;&|()]/.test(char)) {
889
- return {
890
- tokens,
891
- reason:
892
- "Code Reviewer bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
893
- };
894
- }
895
- if (char === ">" || char === "<") {
896
- return { tokens, reason: "Code Reviewer bash blocks shell redirection to keep inspection read-only." };
897
- }
898
- if (char === "{" || char === "}") return { tokens, reason: "Code Reviewer bash blocks shell brace expansion." };
899
- if (char === "*" || char === "?" || char === "[" || char === "]") {
900
- return { tokens, reason: "Code Reviewer bash blocks shell glob expansion." };
901
- }
902
- if (char === "~" && !tokenStarted) return { tokens, reason: "Code Reviewer bash blocks shell home-directory expansion." };
903
-
904
- token += char;
905
- tokenStarted = true;
906
- }
907
-
908
- if (inSingleQuote || inDoubleQuote) return { tokens, reason: "Code Reviewer bash blocks unterminated or malformed shell quoting." };
909
- pushToken();
910
- return { tokens };
915
+ const tokens: string[] = [];
916
+ let token = "";
917
+ let tokenStarted = false;
918
+ let inSingleQuote = false;
919
+ let inDoubleQuote = false;
920
+
921
+ const pushToken = () => {
922
+ if (!tokenStarted) return;
923
+ tokens.push(token);
924
+ token = "";
925
+ tokenStarted = false;
926
+ };
927
+
928
+ for (let index = 0; index < command.length; index += 1) {
929
+ const char = command[index];
930
+ const next = command[index + 1] ?? "";
931
+
932
+ if (inSingleQuote) {
933
+ if (char === "'") {
934
+ inSingleQuote = false;
935
+ continue;
936
+ }
937
+ token += char;
938
+ tokenStarted = true;
939
+ continue;
940
+ }
941
+
942
+ if (char === "\\") {
943
+ return {
944
+ tokens,
945
+ reason:
946
+ "Code Reviewer bash blocks shell escape sequences; pass plain git, gh, or pwd arguments without backslashes.",
947
+ };
948
+ }
949
+
950
+ if (inDoubleQuote) {
951
+ if (char === '"') {
952
+ inDoubleQuote = false;
953
+ continue;
954
+ }
955
+ if (char === "`" || char === "$") {
956
+ return {
957
+ tokens,
958
+ reason:
959
+ char === "`" || next === "("
960
+ ? "Code Reviewer bash blocks command substitution."
961
+ : "Code Reviewer bash blocks shell expansion in double quotes.",
962
+ };
963
+ }
964
+ token += char;
965
+ tokenStarted = true;
966
+ continue;
967
+ }
968
+
969
+ if (char === "\n" || char === "\r") {
970
+ return {
971
+ tokens,
972
+ reason:
973
+ "Code Reviewer bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
974
+ };
975
+ }
976
+ if (/\s/.test(char)) {
977
+ pushToken();
978
+ continue;
979
+ }
980
+ if (char === "'") {
981
+ inSingleQuote = true;
982
+ tokenStarted = true;
983
+ continue;
984
+ }
985
+ if (char === '"') {
986
+ inDoubleQuote = true;
987
+ tokenStarted = true;
988
+ continue;
989
+ }
990
+ if (char === "`") return { tokens, reason: "Code Reviewer bash blocks command substitution." };
991
+ if (char === "$") {
992
+ return {
993
+ tokens,
994
+ reason:
995
+ next === "'" || next === '"'
996
+ ? "Code Reviewer bash blocks ANSI-C and localized shell quotes."
997
+ : next === "("
998
+ ? "Code Reviewer bash blocks command substitution."
999
+ : "Code Reviewer bash blocks shell expansion.",
1000
+ };
1001
+ }
1002
+ if (/[;&|()]/.test(char)) {
1003
+ return {
1004
+ tokens,
1005
+ reason:
1006
+ "Code Reviewer bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
1007
+ };
1008
+ }
1009
+ if (char === ">" || char === "<") {
1010
+ return {
1011
+ tokens,
1012
+ reason: "Code Reviewer bash blocks shell redirection to keep inspection read-only.",
1013
+ };
1014
+ }
1015
+ if (char === "{" || char === "}")
1016
+ return { tokens, reason: "Code Reviewer bash blocks shell brace expansion." };
1017
+ if (char === "*" || char === "?" || char === "[" || char === "]") {
1018
+ return { tokens, reason: "Code Reviewer bash blocks shell glob expansion." };
1019
+ }
1020
+ if (char === "~" && !tokenStarted)
1021
+ return { tokens, reason: "Code Reviewer bash blocks shell home-directory expansion." };
1022
+
1023
+ token += char;
1024
+ tokenStarted = true;
1025
+ }
1026
+
1027
+ if (inSingleQuote || inDoubleQuote)
1028
+ return { tokens, reason: "Code Reviewer bash blocks unterminated or malformed shell quoting." };
1029
+ pushToken();
1030
+ return { tokens };
911
1031
  }
912
1032
 
913
1033
  function tokenizeSegment(segment: string): string[] {
914
- return tokenizeShellCommand(segment).tokens;
1034
+ return tokenizeShellCommand(segment).tokens;
915
1035
  }
916
1036
 
917
1037
  function shellQuoteToken(token: string): string {
918
- return `'${token.replace(/'/g, `'"'"'`)}'`;
1038
+ return `'${token.replace(/'/g, `'"'"'`)}'`;
919
1039
  }
920
1040
 
921
1041
  function getExecutableName(token: string): string {
922
- return path.basename(token).toLowerCase();
1042
+ return path.basename(token).toLowerCase();
923
1043
  }
924
1044
 
925
1045
  function getShellSyntaxReason(command: string): string | undefined {
926
- return tokenizeShellCommand(command).reason;
1046
+ return tokenizeShellCommand(command).reason;
927
1047
  }
928
1048
 
929
1049
  function getUnsafeGitTokenPathReason(token: string): string | undefined {
930
- const valueParts = [token];
931
- const equalsIndex = token.indexOf("=");
932
- if (equalsIndex >= 0 && equalsIndex < token.length - 1) valueParts.push(token.slice(equalsIndex + 1));
933
- for (const value of valueParts) {
934
- const normalized = value.startsWith("@") ? value.slice(1) : value;
935
- if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
936
- return "Code Reviewer bash git arguments must not use home-directory paths; use built-in read/grep/find/ls for local files.";
937
- }
938
- if (path.isAbsolute(normalized)) {
939
- return "Code Reviewer bash git arguments must not use absolute filesystem paths; use built-in read/grep/find/ls for local files.";
940
- }
941
- if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
942
- return "Code Reviewer bash git arguments must not traverse outside the local checkout.";
943
- }
944
- }
945
- return undefined;
1050
+ const valueParts = [token];
1051
+ const equalsIndex = token.indexOf("=");
1052
+ if (equalsIndex >= 0 && equalsIndex < token.length - 1)
1053
+ valueParts.push(token.slice(equalsIndex + 1));
1054
+ for (const value of valueParts) {
1055
+ const normalized = value.startsWith("@") ? value.slice(1) : value;
1056
+ if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
1057
+ return "Code Reviewer bash git arguments must not use home-directory paths; use built-in read/grep/find/ls for local files.";
1058
+ }
1059
+ if (path.isAbsolute(normalized)) {
1060
+ return "Code Reviewer bash git arguments must not use absolute filesystem paths; use built-in read/grep/find/ls for local files.";
1061
+ }
1062
+ if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
1063
+ return "Code Reviewer bash git arguments must not traverse outside the local checkout.";
1064
+ }
1065
+ }
1066
+ return undefined;
946
1067
  }
947
1068
 
948
1069
  function getBlockedPwdReason(tokens: string[]): string | undefined {
949
- const args = tokens.slice(1);
950
- if (args.length === 0) return undefined;
951
- if (args.length === 1 && (args[0] === "-P" || args[0] === "-L")) return undefined;
952
- return "Code Reviewer bash allows pwd only with no arguments or -P/-L.";
1070
+ const args = tokens.slice(1);
1071
+ if (args.length === 0) return undefined;
1072
+ if (args.length === 1 && (args[0] === "-P" || args[0] === "-L")) return undefined;
1073
+ return "Code Reviewer bash allows pwd only with no arguments or -P/-L.";
953
1074
  }
954
1075
 
955
- function getGitSubcommand(tokens: string[]): { subcommand?: string; index: number; reason?: string } {
956
- let index = 1;
957
- while (index < tokens.length) {
958
- const token = tokens[index];
959
- if (!token.startsWith("-")) break;
960
- if (token === "--git-dir" || token.startsWith("--git-dir=")) {
961
- return { index, reason: "Code Reviewer bash blocks git --git-dir because it can inspect or mutate outside the checkout." };
962
- }
963
- if (token === "--work-tree" || token.startsWith("--work-tree=")) {
964
- return { index, reason: "Code Reviewer bash blocks git --work-tree because it can inspect or mutate outside the checkout." };
965
- }
966
- if (token === "-C") {
967
- const gitCwd = tokens[index + 1];
968
- if (gitCwd !== ".") return { index, reason: "Code Reviewer bash only allows git -C .; run git from the local checkout." };
969
- index += 2;
970
- continue;
971
- }
972
- if (token.startsWith("-C")) {
973
- if (token !== "-C.") return { index, reason: "Code Reviewer bash only allows git -C .; run git from the local checkout." };
974
- index += 1;
975
- continue;
976
- }
977
- if (token === "--paginate" || token === "-p") {
978
- return { index, reason: "Code Reviewer bash blocks git pagination because pagers can execute local utilities." };
979
- }
980
- if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
981
- index += 1;
982
- continue;
983
- }
984
- return { index, reason: `Code Reviewer bash blocks git global option ${token}; use direct read-only git inspection commands from the checkout.` };
985
- }
986
- return { subcommand: tokens[index]?.toLowerCase(), index };
1076
+ function getGitSubcommand(tokens: string[]): {
1077
+ subcommand?: string;
1078
+ index: number;
1079
+ reason?: string;
1080
+ } {
1081
+ let index = 1;
1082
+ while (index < tokens.length) {
1083
+ const token = tokens[index];
1084
+ if (!token.startsWith("-")) break;
1085
+ if (token === "--git-dir" || token.startsWith("--git-dir=")) {
1086
+ return {
1087
+ index,
1088
+ reason:
1089
+ "Code Reviewer bash blocks git --git-dir because it can inspect or mutate outside the checkout.",
1090
+ };
1091
+ }
1092
+ if (token === "--work-tree" || token.startsWith("--work-tree=")) {
1093
+ return {
1094
+ index,
1095
+ reason:
1096
+ "Code Reviewer bash blocks git --work-tree because it can inspect or mutate outside the checkout.",
1097
+ };
1098
+ }
1099
+ if (token === "-C") {
1100
+ const gitCwd = tokens[index + 1];
1101
+ if (gitCwd !== ".")
1102
+ return {
1103
+ index,
1104
+ reason: "Code Reviewer bash only allows git -C .; run git from the local checkout.",
1105
+ };
1106
+ index += 2;
1107
+ continue;
1108
+ }
1109
+ if (token.startsWith("-C")) {
1110
+ if (token !== "-C.")
1111
+ return {
1112
+ index,
1113
+ reason: "Code Reviewer bash only allows git -C .; run git from the local checkout.",
1114
+ };
1115
+ index += 1;
1116
+ continue;
1117
+ }
1118
+ if (token === "--paginate" || token === "-p") {
1119
+ return {
1120
+ index,
1121
+ reason:
1122
+ "Code Reviewer bash blocks git pagination because pagers can execute local utilities.",
1123
+ };
1124
+ }
1125
+ if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
1126
+ index += 1;
1127
+ continue;
1128
+ }
1129
+ return {
1130
+ index,
1131
+ reason: `Code Reviewer bash blocks git global option ${token}; use direct read-only git inspection commands from the checkout.`,
1132
+ };
1133
+ }
1134
+ return { subcommand: tokens[index]?.toLowerCase(), index };
987
1135
  }
988
1136
 
989
1137
  function isSafeGitBranchCommand(tokens: string[], subcommandIndex: number): boolean {
990
- const args = tokens.slice(subcommandIndex + 1);
991
- if (args.length === 0) return true;
992
- if (args.some((arg) => /^(?:-(?:d|D|m|M|c|C|f)|--(?:delete|move|copy|force|set-upstream-to|unset-upstream))$/.test(arg))) {
993
- return false;
994
- }
995
- return args.some((arg) => SAFE_GIT_BRANCH_FLAGS.has(arg) || arg.startsWith("--contains=") || arg.startsWith("--merged=") || arg.startsWith("--no-merged="));
1138
+ const args = tokens.slice(subcommandIndex + 1);
1139
+ if (args.length === 0) return true;
1140
+ if (
1141
+ args.some((arg) =>
1142
+ /^(?:-(?:d|D|m|M|c|C|f)|--(?:delete|move|copy|force|set-upstream-to|unset-upstream))$/.test(
1143
+ arg,
1144
+ ),
1145
+ )
1146
+ ) {
1147
+ return false;
1148
+ }
1149
+ return args.some(
1150
+ (arg) =>
1151
+ SAFE_GIT_BRANCH_FLAGS.has(arg) ||
1152
+ arg.startsWith("--contains=") ||
1153
+ arg.startsWith("--merged=") ||
1154
+ arg.startsWith("--no-merged="),
1155
+ );
996
1156
  }
997
1157
 
998
1158
  function isSafeGitRemoteCommand(tokens: string[], subcommandIndex: number): boolean {
999
- const args = tokens.slice(subcommandIndex + 1);
1000
- if (args.length === 0) return true;
1001
- if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
1002
- const remoteAction = args.find((arg) => !arg.startsWith("-"));
1003
- if (remoteAction !== "get-url") return false;
1004
- return true;
1159
+ const args = tokens.slice(subcommandIndex + 1);
1160
+ if (args.length === 0) return true;
1161
+ if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
1162
+ const remoteAction = args.find((arg) => !arg.startsWith("-"));
1163
+ if (remoteAction !== "get-url") return false;
1164
+ return true;
1005
1165
  }
1006
1166
 
1007
- function isBlockedGitLocalFileOption(token: string, flag: string, blockedPrefixes: readonly string[]): boolean {
1008
- if (!token.startsWith("--")) return false;
1009
- const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
1010
- if (option === flag) return true;
1011
- return blockedPrefixes.some((prefix) => option.startsWith(prefix));
1167
+ function isBlockedGitLocalFileOption(
1168
+ token: string,
1169
+ flag: string,
1170
+ blockedPrefixes: readonly string[],
1171
+ ): boolean {
1172
+ if (!token.startsWith("--")) return false;
1173
+ const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
1174
+ if (option === flag) return true;
1175
+ return blockedPrefixes.some((prefix) => option.startsWith(prefix));
1012
1176
  }
1013
1177
 
1014
- function getUnsafeGitArgumentReason(tokens: string[], parsed: { subcommand?: string; index: number }): string | undefined {
1015
- for (let index = 1; index < tokens.length; index += 1) {
1016
- const token = tokens[index];
1017
- const lowerToken = token.toLowerCase();
1018
- if (token === "--no-index" || token.startsWith("--no-index=")) {
1019
- return "Code Reviewer bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
1020
- }
1021
- if (token === "--paginate" || token.startsWith("--paginate=")) {
1022
- return "Code Reviewer bash blocks git --paginate because pagers can execute local utilities.";
1023
- }
1024
- if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) {
1025
- return "Code Reviewer bash blocks git --open-files-in-pager because it can execute local utilities.";
1026
- }
1027
- if (token === "-O" || token.startsWith("-O")) {
1028
- return "Code Reviewer bash blocks git -O because it can execute local utilities for some subcommands.";
1029
- }
1030
- if (token === "--ext-diff" || token.startsWith("--ext-diff=")) {
1031
- return "Code Reviewer bash blocks git --ext-diff because it can execute external diff helpers.";
1032
- }
1033
- if (token === "--textconv" || token.startsWith("--textconv=")) {
1034
- return "Code Reviewer bash blocks git --textconv because it can execute external text conversion filters.";
1035
- }
1036
- if (token === "--filters" || token.startsWith("--filters=")) {
1037
- return "Code Reviewer bash blocks git --filters because it can execute clean/smudge filters from local config.";
1038
- }
1039
- if (token === "--show-signature" || lowerToken.startsWith("--show-signat")) {
1040
- return "Code Reviewer bash blocks git signature verification flags because they can execute configured GPG helpers.";
1041
- }
1042
- if (token === "--help" || token.startsWith("--help=")) {
1043
- return "Code Reviewer bash blocks git help output because it can execute configured help, man, or browser helpers.";
1044
- }
1045
- if (token.includes("%G") || /%\((?:[^)]*signature[^)]*)\)/i.test(token)) {
1046
- return "Code Reviewer bash blocks git signature format atoms because they can execute configured GPG helpers.";
1047
- }
1048
- if (token === "--output" || token.startsWith("--output=")) {
1049
- return "Code Reviewer bash blocks git output-writing flags.";
1050
- }
1051
- for (const { flag, blockedPrefixes, reason } of GIT_OPTIONS_REQUIRING_LOCAL_FILE) {
1052
- if (isBlockedGitLocalFileOption(token, flag, blockedPrefixes)) return reason;
1053
- }
1054
- if (index > parsed.index) {
1055
- for (const option of GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE[parsed.subcommand ?? ""] ?? []) {
1056
- if (option.matches(token)) return option.reason;
1057
- }
1058
- }
1059
- const pathReason = getUnsafeGitTokenPathReason(token);
1060
- if (pathReason) return pathReason;
1061
- }
1062
- return undefined;
1178
+ function getUnsafeGitArgumentReason(
1179
+ tokens: string[],
1180
+ parsed: { subcommand?: string; index: number },
1181
+ ): string | undefined {
1182
+ for (let index = 1; index < tokens.length; index += 1) {
1183
+ const token = tokens[index];
1184
+ const lowerToken = token.toLowerCase();
1185
+ if (token === "--no-index" || token.startsWith("--no-index=")) {
1186
+ return "Code Reviewer bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
1187
+ }
1188
+ if (token === "--paginate" || token.startsWith("--paginate=")) {
1189
+ return "Code Reviewer bash blocks git --paginate because pagers can execute local utilities.";
1190
+ }
1191
+ if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) {
1192
+ return "Code Reviewer bash blocks git --open-files-in-pager because it can execute local utilities.";
1193
+ }
1194
+ if (token === "-O" || token.startsWith("-O")) {
1195
+ return "Code Reviewer bash blocks git -O because it can execute local utilities for some subcommands.";
1196
+ }
1197
+ if (token === "--ext-diff" || token.startsWith("--ext-diff=")) {
1198
+ return "Code Reviewer bash blocks git --ext-diff because it can execute external diff helpers.";
1199
+ }
1200
+ if (token === "--textconv" || token.startsWith("--textconv=")) {
1201
+ return "Code Reviewer bash blocks git --textconv because it can execute external text conversion filters.";
1202
+ }
1203
+ if (token === "--filters" || token.startsWith("--filters=")) {
1204
+ return "Code Reviewer bash blocks git --filters because it can execute clean/smudge filters from local config.";
1205
+ }
1206
+ if (token === "--show-signature" || lowerToken.startsWith("--show-signat")) {
1207
+ return "Code Reviewer bash blocks git signature verification flags because they can execute configured GPG helpers.";
1208
+ }
1209
+ if (token === "--help" || token.startsWith("--help=")) {
1210
+ return "Code Reviewer bash blocks git help output because it can execute configured help, man, or browser helpers.";
1211
+ }
1212
+ if (token.includes("%G") || /%\((?:[^)]*signature[^)]*)\)/i.test(token)) {
1213
+ return "Code Reviewer bash blocks git signature format atoms because they can execute configured GPG helpers.";
1214
+ }
1215
+ if (token === "--output" || token.startsWith("--output=")) {
1216
+ return "Code Reviewer bash blocks git output-writing flags.";
1217
+ }
1218
+ for (const { flag, blockedPrefixes, reason } of GIT_OPTIONS_REQUIRING_LOCAL_FILE) {
1219
+ if (isBlockedGitLocalFileOption(token, flag, blockedPrefixes)) return reason;
1220
+ }
1221
+ if (index > parsed.index) {
1222
+ for (const option of GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE[parsed.subcommand ?? ""] ??
1223
+ []) {
1224
+ if (option.matches(token)) return option.reason;
1225
+ }
1226
+ }
1227
+ const pathReason = getUnsafeGitTokenPathReason(token);
1228
+ if (pathReason) return pathReason;
1229
+ }
1230
+ return undefined;
1063
1231
  }
1064
1232
 
1065
1233
  function buildSafeGitCommand(tokens: string[]): string {
1066
- const parsed = getGitSubcommand(tokens);
1067
- const prefix = ["git", "--no-pager", "--no-optional-locks", ...SAFE_GIT_CONFIG_OVERRIDES.flatMap((value) => ["-c", value])];
1068
- const subcommand = parsed.subcommand;
1069
- if (!subcommand) return prefix.map(shellQuoteToken).join(" ");
1070
- const args = tokens.slice(parsed.index + 1);
1071
- const injectedFlags = (GIT_SUBCOMMAND_SAFE_FLAGS[subcommand] ?? []).filter((flag) => !args.includes(flag));
1072
- return [...prefix, subcommand, ...injectedFlags, ...args].map(shellQuoteToken).join(" ");
1234
+ const parsed = getGitSubcommand(tokens);
1235
+ const prefix = [
1236
+ "git",
1237
+ "--no-pager",
1238
+ "--no-optional-locks",
1239
+ ...SAFE_GIT_CONFIG_OVERRIDES.flatMap((value) => ["-c", value]),
1240
+ ];
1241
+ const subcommand = parsed.subcommand;
1242
+ if (!subcommand) return prefix.map(shellQuoteToken).join(" ");
1243
+ const args = tokens.slice(parsed.index + 1);
1244
+ const injectedFlags = (GIT_SUBCOMMAND_SAFE_FLAGS[subcommand] ?? []).filter(
1245
+ (flag) => !args.includes(flag),
1246
+ );
1247
+ return [...prefix, subcommand, ...injectedFlags, ...args].map(shellQuoteToken).join(" ");
1073
1248
  }
1074
1249
 
1075
1250
  function getBlockedGitReason(tokens: string[]): string | undefined {
1076
- if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
1077
- const parsed = getGitSubcommand(tokens);
1078
- if (parsed.reason) return parsed.reason;
1079
- if (parsed.subcommand === "help") {
1080
- return "Code Reviewer bash blocks git help because it can execute configured help, man, or browser helpers.";
1081
- }
1082
- const argumentReason = getUnsafeGitArgumentReason(tokens, parsed);
1083
- if (argumentReason) return argumentReason;
1084
- if (!parsed.subcommand) return undefined;
1085
- if (parsed.subcommand === "branch") {
1086
- if (!isSafeGitBranchCommand(tokens, parsed.index)) {
1087
- return "Code Reviewer bash allows git branch only for read-only listing/show-current/contains/merged queries.";
1088
- }
1089
- return undefined;
1090
- }
1091
- if (parsed.subcommand === "remote") {
1092
- if (!isSafeGitRemoteCommand(tokens, parsed.index)) {
1093
- return "Code Reviewer bash allows git remote only for read-only list or get-url queries.";
1094
- }
1095
- return undefined;
1096
- }
1097
- if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
1098
- return `Code Reviewer bash blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
1099
- }
1100
- return undefined;
1251
+ if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
1252
+ const parsed = getGitSubcommand(tokens);
1253
+ if (parsed.reason) return parsed.reason;
1254
+ if (parsed.subcommand === "help") {
1255
+ return "Code Reviewer bash blocks git help because it can execute configured help, man, or browser helpers.";
1256
+ }
1257
+ const argumentReason = getUnsafeGitArgumentReason(tokens, parsed);
1258
+ if (argumentReason) return argumentReason;
1259
+ if (!parsed.subcommand) return undefined;
1260
+ if (parsed.subcommand === "branch") {
1261
+ if (!isSafeGitBranchCommand(tokens, parsed.index)) {
1262
+ return "Code Reviewer bash allows git branch only for read-only listing/show-current/contains/merged queries.";
1263
+ }
1264
+ return undefined;
1265
+ }
1266
+ if (parsed.subcommand === "remote") {
1267
+ if (!isSafeGitRemoteCommand(tokens, parsed.index)) {
1268
+ return "Code Reviewer bash allows git remote only for read-only list or get-url queries.";
1269
+ }
1270
+ return undefined;
1271
+ }
1272
+ if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
1273
+ return `Code Reviewer bash blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
1274
+ }
1275
+ return undefined;
1101
1276
  }
1102
1277
 
1103
1278
  function getGhCommand(tokens: string[]): { command?: string; subcommand?: string } {
1104
- let index = 1;
1105
- while (index < tokens.length) {
1106
- const token = tokens[index];
1107
- if (!token.startsWith("-")) break;
1108
- if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
1109
- index += 2;
1110
- continue;
1111
- }
1112
- index += 1;
1113
- }
1114
- return { command: tokens[index]?.toLowerCase(), subcommand: tokens[index + 1]?.toLowerCase() };
1279
+ let index = 1;
1280
+ while (index < tokens.length) {
1281
+ const token = tokens[index];
1282
+ if (!token.startsWith("-")) break;
1283
+ if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
1284
+ index += 2;
1285
+ continue;
1286
+ }
1287
+ index += 1;
1288
+ }
1289
+ return { command: tokens[index]?.toLowerCase(), subcommand: tokens[index + 1]?.toLowerCase() };
1115
1290
  }
1116
1291
 
1117
1292
  function normalizeFlagValue(value: string | undefined): string | undefined {
1118
- return value ? stripTokenQuotes(value).trim() : undefined;
1293
+ return value ? stripTokenQuotes(value).trim() : undefined;
1119
1294
  }
1120
1295
 
1121
1296
  function getBlockedGhApiReason(tokens: string[]): string | undefined {
1122
- const parsed = getGhCommand(tokens);
1123
- if (parsed.command !== "api") return undefined;
1124
- if (tokens.find((token) => token.toLowerCase() === "graphql")) {
1125
- return "Code Reviewer bash blocks gh api graphql because it uses POST/body fields.";
1126
- }
1127
- for (let index = 1; index < tokens.length; index += 1) {
1128
- const token = tokens[index];
1129
- const lowerToken = token.toLowerCase();
1130
- if (
1131
- token === "-f" ||
1132
- token === "-F" ||
1133
- token === "--field" ||
1134
- token === "--raw-field" ||
1135
- token === "--input" ||
1136
- lowerToken.startsWith("-f") ||
1137
- lowerToken.startsWith("--field=") ||
1138
- lowerToken.startsWith("--raw-field=") ||
1139
- lowerToken.startsWith("--input=")
1140
- ) {
1141
- return "Code Reviewer bash allows read-only gh api calls only; request fields and input files are blocked.";
1142
- }
1143
- if (token === "--cache" || lowerToken.startsWith("--cache=")) {
1144
- return "Code Reviewer bash blocks gh api --cache because it writes local cache files.";
1145
- }
1146
-
1147
- let method: string | undefined;
1148
- if (lowerToken === "-x" || lowerToken === "--method") method = normalizeFlagValue(tokens[index + 1]);
1149
- else if (lowerToken.startsWith("-x") && token.length > 2) method = normalizeFlagValue(token.slice(2).replace(/^=/, ""));
1150
- else if (lowerToken.startsWith("--method=")) method = normalizeFlagValue(token.slice("--method=".length));
1151
- if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
1152
- return "Code Reviewer bash allows read-only gh api calls only; mutating methods are blocked.";
1153
- }
1154
- }
1155
- return undefined;
1297
+ const parsed = getGhCommand(tokens);
1298
+ if (parsed.command !== "api") return undefined;
1299
+ if (tokens.find((token) => token.toLowerCase() === "graphql")) {
1300
+ return "Code Reviewer bash blocks gh api graphql because it uses POST/body fields.";
1301
+ }
1302
+ for (let index = 1; index < tokens.length; index += 1) {
1303
+ const token = tokens[index];
1304
+ const lowerToken = token.toLowerCase();
1305
+ if (
1306
+ token === "-f" ||
1307
+ token === "-F" ||
1308
+ token === "--field" ||
1309
+ token === "--raw-field" ||
1310
+ token === "--input" ||
1311
+ lowerToken.startsWith("-f") ||
1312
+ lowerToken.startsWith("--field=") ||
1313
+ lowerToken.startsWith("--raw-field=") ||
1314
+ lowerToken.startsWith("--input=")
1315
+ ) {
1316
+ return "Code Reviewer bash allows read-only gh api calls only; request fields and input files are blocked.";
1317
+ }
1318
+ if (token === "--cache" || lowerToken.startsWith("--cache=")) {
1319
+ return "Code Reviewer bash blocks gh api --cache because it writes local cache files.";
1320
+ }
1321
+
1322
+ let method: string | undefined;
1323
+ if (lowerToken === "-x" || lowerToken === "--method")
1324
+ method = normalizeFlagValue(tokens[index + 1]);
1325
+ else if (lowerToken.startsWith("-x") && token.length > 2)
1326
+ method = normalizeFlagValue(token.slice(2).replace(/^=/, ""));
1327
+ else if (lowerToken.startsWith("--method="))
1328
+ method = normalizeFlagValue(token.slice("--method=".length));
1329
+ if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
1330
+ return "Code Reviewer bash allows read-only gh api calls only; mutating methods are blocked.";
1331
+ }
1332
+ }
1333
+ return undefined;
1156
1334
  }
1157
1335
 
1158
1336
  function isReadOnlyGhCommand(command: string | undefined, subcommand: string | undefined): boolean {
1159
- if (!command) return true;
1160
- if (command === "api") return true;
1161
- if (command === "search") return true;
1162
- if (command === "repo") return subcommand === "view" || subcommand === "list";
1163
- if (command === "pr") return subcommand === "view" || subcommand === "list" || subcommand === "diff" || subcommand === "status" || subcommand === "checks";
1164
- if (command === "issue") return subcommand === "view" || subcommand === "list" || subcommand === "status";
1165
- if (command === "release") return subcommand === "view" || subcommand === "list";
1166
- if (command === "run") return subcommand === "view" || subcommand === "list";
1167
- if (command === "workflow") return subcommand === "view" || subcommand === "list";
1168
- if (command === "label") return subcommand === "list";
1169
- if (command === "milestone") return subcommand === "list";
1170
- return false;
1337
+ if (!command) return true;
1338
+ if (command === "api") return true;
1339
+ if (command === "search") return true;
1340
+ if (command === "repo") return subcommand === "view" || subcommand === "list";
1341
+ if (command === "pr")
1342
+ return (
1343
+ subcommand === "view" ||
1344
+ subcommand === "list" ||
1345
+ subcommand === "diff" ||
1346
+ subcommand === "status" ||
1347
+ subcommand === "checks"
1348
+ );
1349
+ if (command === "issue")
1350
+ return subcommand === "view" || subcommand === "list" || subcommand === "status";
1351
+ if (command === "release") return subcommand === "view" || subcommand === "list";
1352
+ if (command === "run") return subcommand === "view" || subcommand === "list";
1353
+ if (command === "workflow") return subcommand === "view" || subcommand === "list";
1354
+ if (command === "label") return subcommand === "list";
1355
+ if (command === "milestone") return subcommand === "list";
1356
+ return false;
1171
1357
  }
1172
1358
 
1173
1359
  function getBlockedGhReason(command: string): string | undefined {
1174
- const tokens = tokenizeSegment(command);
1175
- if (getExecutableName(tokens[0] ?? "") !== "gh") return undefined;
1176
- if (/\bgh\s+auth\s+(?:login|logout|refresh|setup-git|token)\b/i.test(command)) {
1177
- return "Code Reviewer bash blocks gh auth commands and token inspection.";
1178
- }
1179
- if (
1180
- tokens.some((token) => {
1181
- const lowerToken = token.toLowerCase();
1182
- return lowerToken === "--web" || lowerToken === "-w" || lowerToken.startsWith("--web=") || lowerToken.startsWith("-w=");
1183
- })
1184
- ) {
1185
- return "Code Reviewer bash blocks gh --web because it can launch external browser helpers.";
1186
- }
1187
- const apiReason = getBlockedGhApiReason(tokens);
1188
- if (apiReason) return apiReason;
1189
- if (/\bgh\s+(?:repo\s+(?:archive|clone|create|delete|edit|fork|rename|sync)|pr\s+(?:checkout|close|comment|create|edit|lock|merge|ready|reopen|review|unlock)|issue\s+(?:close|comment|create|delete|edit|lock|reopen|transfer|unlock)|release\s+(?:create|delete|edit|upload)|workflow\s+run|run\s+(?:cancel|delete|rerun)|gist\s+(?:create|delete|edit))\b/i.test(command)) {
1190
- return "Code Reviewer bash blocks mutating gh commands.";
1191
- }
1192
- const parsed = getGhCommand(tokens);
1193
- if (!isReadOnlyGhCommand(parsed.command, parsed.subcommand)) {
1194
- return `Code Reviewer bash blocks gh ${[parsed.command, parsed.subcommand].filter(Boolean).join(" ")}; only known read-only gh commands are allowed.`;
1195
- }
1196
- return undefined;
1360
+ const tokens = tokenizeSegment(command);
1361
+ if (getExecutableName(tokens[0] ?? "") !== "gh") return undefined;
1362
+ if (/\bgh\s+auth\s+(?:login|logout|refresh|setup-git|token)\b/i.test(command)) {
1363
+ return "Code Reviewer bash blocks gh auth commands and token inspection.";
1364
+ }
1365
+ if (
1366
+ tokens.some((token) => {
1367
+ const lowerToken = token.toLowerCase();
1368
+ return (
1369
+ lowerToken === "--web" ||
1370
+ lowerToken === "-w" ||
1371
+ lowerToken.startsWith("--web=") ||
1372
+ lowerToken.startsWith("-w=")
1373
+ );
1374
+ })
1375
+ ) {
1376
+ return "Code Reviewer bash blocks gh --web because it can launch external browser helpers.";
1377
+ }
1378
+ const apiReason = getBlockedGhApiReason(tokens);
1379
+ if (apiReason) return apiReason;
1380
+ if (
1381
+ /\bgh\s+(?:repo\s+(?:archive|clone|create|delete|edit|fork|rename|sync)|pr\s+(?:checkout|close|comment|create|edit|lock|merge|ready|reopen|review|unlock)|issue\s+(?:close|comment|create|delete|edit|lock|reopen|transfer|unlock)|release\s+(?:create|delete|edit|upload)|workflow\s+run|run\s+(?:cancel|delete|rerun)|gist\s+(?:create|delete|edit))\b/i.test(
1382
+ command,
1383
+ )
1384
+ ) {
1385
+ return "Code Reviewer bash blocks mutating gh commands.";
1386
+ }
1387
+ const parsed = getGhCommand(tokens);
1388
+ if (!isReadOnlyGhCommand(parsed.command, parsed.subcommand)) {
1389
+ return `Code Reviewer bash blocks gh ${[parsed.command, parsed.subcommand].filter(Boolean).join(" ")}; only known read-only gh commands are allowed.`;
1390
+ }
1391
+ return undefined;
1197
1392
  }
1198
1393
 
1199
1394
  function getBlockedBashReason(command: string): string | undefined {
1200
- const trimmed = command.trim();
1201
- if (!trimmed) return "Code Reviewer bash requires a non-empty command.";
1202
- if (/`|\$\(/.test(trimmed)) return "Code Reviewer bash blocks command substitution.";
1203
- const syntaxReason = getShellSyntaxReason(trimmed);
1204
- if (syntaxReason) return syntaxReason;
1205
-
1206
- const tokens = tokenizeSegment(trimmed);
1207
- if (tokens.length === 0) return "Code Reviewer bash requires a non-empty command.";
1208
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) return "Code Reviewer bash blocks inline environment assignment.";
1209
- if (/[\\/]/.test(tokens[0])) {
1210
- return "Code Reviewer bash requires direct git, gh, or pwd invocation without path-qualified executables.";
1211
- }
1212
-
1213
- const executable = getExecutableName(tokens[0]);
1214
- if (!READ_ONLY_BASH_COMMANDS.has(executable)) {
1215
- return `Code Reviewer bash blocks ${executable || "this command"}; use built-in read/grep/find/ls for local files or read-only git/gh inspection commands.`;
1216
- }
1217
- if (executable === "pwd") return getBlockedPwdReason(tokens);
1218
- if (executable === "git") return getBlockedGitReason(tokens);
1219
- if (executable === "gh") return getBlockedGhReason(trimmed);
1220
- return undefined;
1395
+ const trimmed = command.trim();
1396
+ if (!trimmed) return "Code Reviewer bash requires a non-empty command.";
1397
+ if (/`|\$\(/.test(trimmed)) return "Code Reviewer bash blocks command substitution.";
1398
+ const syntaxReason = getShellSyntaxReason(trimmed);
1399
+ if (syntaxReason) return syntaxReason;
1400
+
1401
+ const tokens = tokenizeSegment(trimmed);
1402
+ if (tokens.length === 0) return "Code Reviewer bash requires a non-empty command.";
1403
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
1404
+ return "Code Reviewer bash blocks inline environment assignment.";
1405
+ if (/[\\/]/.test(tokens[0])) {
1406
+ return "Code Reviewer bash requires direct git, gh, or pwd invocation without path-qualified executables.";
1407
+ }
1408
+
1409
+ const executable = getExecutableName(tokens[0]);
1410
+ if (!READ_ONLY_BASH_COMMANDS.has(executable)) {
1411
+ return `Code Reviewer bash blocks ${executable || "this command"}; use built-in read/grep/find/ls for local files or read-only git/gh inspection commands.`;
1412
+ }
1413
+ if (executable === "pwd") return getBlockedPwdReason(tokens);
1414
+ if (executable === "git") return getBlockedGitReason(tokens);
1415
+ if (executable === "gh") return getBlockedGhReason(trimmed);
1416
+ return undefined;
1221
1417
  }
1222
1418
 
1223
- function createCodeReviewerRuntimeGuardExtension(options: { cwd: string; maxTurns: number }): ExtensionFactory {
1224
- return (pi) => {
1225
- let currentTurn = 0;
1226
-
1227
- pi.on("turn_start", async (event) => {
1228
- currentTurn = event.turnIndex;
1229
- });
1230
-
1231
- pi.on("tool_call", async (event) => {
1232
- if (!READ_ONLY_TOOL_NAMES.has(event.toolName)) {
1233
- return { block: true, reason: `code_reviewer exposes read-only tools only; ${event.toolName} is not allowed.` };
1234
- }
1235
-
1236
- if (currentTurn >= options.maxTurns - 1) {
1237
- return {
1238
- block: true,
1239
- reason: `Tool use is disabled on final code_reviewer turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
1240
- };
1241
- }
1242
-
1243
- if (event.toolName === "read") {
1244
- const reason = await assertToolPathInsideCwd(options.cwd, (event.input as { path?: unknown }).path, "read");
1245
- if (reason) return { block: true, reason };
1246
- }
1247
-
1248
- if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
1249
- const reason = await assertToolPathInsideCwd(options.cwd, (event.input as { path?: unknown }).path, event.toolName);
1250
- if (reason) return { block: true, reason };
1251
- if (event.toolName === "grep") {
1252
- const globReason = getUnsafePathPatternReason((event.input as { glob?: unknown }).glob, "grep glob");
1253
- if (globReason) return { block: true, reason: globReason };
1254
- }
1255
- if (event.toolName === "find") {
1256
- const patternReason = getUnsafePathPatternReason((event.input as { pattern?: unknown }).pattern, "find pattern");
1257
- if (patternReason) return { block: true, reason: patternReason };
1258
- }
1259
- }
1260
-
1261
- if (event.toolName === "bash") {
1262
- const input = event.input as { command?: unknown; timeout?: unknown };
1263
- if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
1264
- const command = typeof input.command === "string" ? input.command : "";
1265
- const reason = getBlockedBashReason(command);
1266
- if (reason) return { block: true, reason };
1267
- const tokens = tokenizeSegment(command.trim());
1268
- if (getExecutableName(tokens[0] ?? "") === "git") input.command = buildSafeGitCommand(tokens);
1269
- }
1270
-
1271
- return undefined;
1272
- });
1273
-
1274
- pi.on("tool_result", async (event) => ({
1275
- content: [
1276
- ...(event.content ?? []),
1277
- {
1278
- type: "text",
1279
- text: `\n\n[code_reviewer turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
1280
- },
1281
- ],
1282
- }));
1283
- };
1419
+ function createCodeReviewerRuntimeGuardExtension(options: {
1420
+ cwd: string;
1421
+ maxTurns: number;
1422
+ }): ExtensionFactory {
1423
+ return (pi) => {
1424
+ let currentTurn = 0;
1425
+
1426
+ pi.on("turn_start", async (event) => {
1427
+ currentTurn = event.turnIndex;
1428
+ });
1429
+
1430
+ pi.on("tool_call", async (event) => {
1431
+ if (!READ_ONLY_TOOL_NAMES.has(event.toolName)) {
1432
+ return {
1433
+ block: true,
1434
+ reason: `code_reviewer exposes read-only tools only; ${event.toolName} is not allowed.`,
1435
+ };
1436
+ }
1437
+
1438
+ if (currentTurn >= options.maxTurns - 1) {
1439
+ return {
1440
+ block: true,
1441
+ reason: `Tool use is disabled on final code_reviewer turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
1442
+ };
1443
+ }
1444
+
1445
+ if (event.toolName === "read") {
1446
+ const reason = await assertToolPathInsideCwd(
1447
+ options.cwd,
1448
+ (event.input as { path?: unknown }).path,
1449
+ "read",
1450
+ );
1451
+ if (reason) return { block: true, reason };
1452
+ }
1453
+
1454
+ if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
1455
+ const reason = await assertToolPathInsideCwd(
1456
+ options.cwd,
1457
+ (event.input as { path?: unknown }).path,
1458
+ event.toolName,
1459
+ );
1460
+ if (reason) return { block: true, reason };
1461
+ if (event.toolName === "grep") {
1462
+ const globReason = getUnsafePathPatternReason(
1463
+ (event.input as { glob?: unknown }).glob,
1464
+ "grep glob",
1465
+ );
1466
+ if (globReason) return { block: true, reason: globReason };
1467
+ }
1468
+ if (event.toolName === "find") {
1469
+ const patternReason = getUnsafePathPatternReason(
1470
+ (event.input as { pattern?: unknown }).pattern,
1471
+ "find pattern",
1472
+ );
1473
+ if (patternReason) return { block: true, reason: patternReason };
1474
+ }
1475
+ }
1476
+
1477
+ if (event.toolName === "bash") {
1478
+ const input = event.input as { command?: unknown; timeout?: unknown };
1479
+ if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
1480
+ const command = typeof input.command === "string" ? input.command : "";
1481
+ const reason = getBlockedBashReason(command);
1482
+ if (reason) return { block: true, reason };
1483
+ const tokens = tokenizeSegment(command.trim());
1484
+ if (getExecutableName(tokens[0] ?? "") === "git")
1485
+ input.command = buildSafeGitCommand(tokens);
1486
+ }
1487
+
1488
+ return undefined;
1489
+ });
1490
+
1491
+ pi.on("tool_result", async (event) => ({
1492
+ content: [
1493
+ ...(event.content ?? []),
1494
+ {
1495
+ type: "text",
1496
+ text: `\n\n[code_reviewer turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
1497
+ },
1498
+ ],
1499
+ }));
1500
+ };
1284
1501
  }
1285
1502
 
1286
- function buildSystemPrompt(options: { cwd: string; maxTurns: number; maxRunSeconds: number }): string {
1287
- return `You are Code Reviewer, an isolated read-only review agent running inside The Last Harness. Review the proposed change against the stated task and the local checkout, then return a concise evidence-based review report.\n\nWorking directory: ${options.cwd}\nTurn budget: ${options.maxTurns} turns total, including your final answer.\nWall-clock budget: ${options.maxRunSeconds} seconds.\n\nAvailable tools are intended to be read-only: read, grep, find, ls, and bash. Use the built-in read, grep, find, and ls tools for local file inspection. Use bash only for a single read-only git, gh, or pwd invocation, such as git status/diff/show/log/blame, gh pr view/diff/list/status/checks, gh api GET calls, or pwd. A runtime guard blocks write/edit tools, shell pipelines/control operators, redirection, mutating git/gh commands, npm/publish commands, path traversal outside the checkout, and other filesystem mutation.\n\nReview priorities, in order:\n1. Ticket fit: does the change actually satisfy the requested task and stay in scope?\n2. Diff accuracy: do the files and edits match what the task claims changed?\n3. Correctness: could the change fail, regress behavior, mishandle errors, or break edge cases?\n4. Security and safety: look for unsafe command use, path handling issues, secret exposure, injection risks, destructive behavior, and policy violations.\n5. Simplicity and maintainability: call out unnecessary complexity, unclear behavior, or avoidable duplication.\n6. Tests and validation: check whether meaningful verification exists and whether gaps matter for this task.\n\nNon-negotiable constraints:\n- Never implement changes. Never edit, write, move, delete, format, install, commit, checkout, reset, clean, push, publish, or mutate GitHub.\n- Treat the supplied task and diff/context as claims to verify, not as facts. Cite file paths, line ranges, git diff/status output, or supplied diff excerpts as evidence.\n- Prefer the built-in file tools over bash when local file inspection is enough.\n- If evidence is missing, stale, or contradictory, say so plainly.\n- Keep the final review concise and actionable. Report the most important findings first; do not pad with non-findings.\n\nOutput format, exact order:\n## Verdict\nOne short paragraph with the overall review outcome.\n\n## Findings\nUse bullets ordered by severity. For each finding include: severity ('blocker', 'major', or 'minor'), a short title, evidence, and why it matters. If there are no findings, write '- none'.\n\n## Validation\nList the read-only checks you performed, or '- none'.\n\n## Scope check\nState whether the change appears to match the requested task and note any missing or extra scope.\n\n## Run details\nList concise run details such as key files inspected, notable git/gh commands, and any uncertainty that limited the review.`;
1503
+ function buildSystemPrompt(options: {
1504
+ cwd: string;
1505
+ maxTurns: number;
1506
+ maxRunSeconds: number;
1507
+ }): string {
1508
+ return `You are Code Reviewer, an isolated read-only review agent running inside the code-reviewer package. Review the proposed change against the stated task and the local checkout, then return a concise evidence-based review report.\n\nWorking directory: ${options.cwd}\nTurn budget: ${options.maxTurns} turns total, including your final answer.\nWall-clock budget: ${options.maxRunSeconds} seconds.\n\nAvailable tools are intended to be read-only: read, grep, find, ls, and bash. Use the built-in read, grep, find, and ls tools for local file inspection. Use bash only for a single read-only git, gh, or pwd invocation, such as git status/diff/show/log/blame, gh pr view/diff/list/status/checks, gh api GET calls, or pwd. A runtime guard blocks write/edit tools, shell pipelines/control operators, redirection, mutating git/gh commands, npm/publish commands, path traversal outside the checkout, and other filesystem mutation.\n\nReview priorities, in order:\n1. Ticket fit: does the change actually satisfy the requested task and stay in scope?\n2. Diff accuracy: do the files and edits match what the task claims changed?\n3. Correctness: could the change fail, regress behavior, mishandle errors, or break edge cases?\n4. Security and safety: look for unsafe command use, path handling issues, secret exposure, injection risks, destructive behavior, and policy violations.\n5. Simplicity and maintainability: call out unnecessary complexity, unclear behavior, or avoidable duplication.\n6. Tests and validation: check whether meaningful verification exists and whether gaps matter for this task.\n\nNon-negotiable constraints:\n- Never implement changes. Never edit, write, move, delete, format, install, commit, checkout, reset, clean, push, publish, or mutate GitHub.\n- Treat the supplied task and diff/context as claims to verify, not as facts. Cite file paths, line ranges, git diff/status output, or supplied diff excerpts as evidence.\n- Prefer the built-in file tools over bash when local file inspection is enough.\n- If evidence is missing, stale, or contradictory, say so plainly.\n- Keep the final review concise and actionable. Report the most important findings first; do not pad with non-findings.\n\nOutput format, exact order:\n## Verdict\nOne short paragraph with the overall review outcome.\n\n## Findings\nUse bullets ordered by severity. For each finding include: severity ('blocker', 'major', or 'minor'), a short title, evidence, and why it matters. If there are no findings, write '- none'.\n\n## Validation\nList the read-only checks you performed, including key files inspected, notable git/gh commands, and any uncertainty that limited the review, or '- none'.\n\n## Scope check\nState whether the change appears to match the requested task and note any missing or extra scope.`;
1288
1509
  }
1289
1510
 
1290
- function buildUserPrompt(input: { task: string; diff?: string; context?: string }, cwd: string): string {
1291
- return `Task to review:\n${input.task}\n\nLocal checkout: ${cwd}\n\nOptional diff or patch:\n${input.diff?.trim() ? input.diff : "(not provided)"}\n\nOptional caller context:\n${input.context?.trim() ? input.context : "(not provided)"}\n\nInspect only as much as needed to review the change against the task. Do not implement anything. Respond using the required review format.`;
1511
+ function buildUserPrompt(
1512
+ input: { task: string; diff?: string; context?: string },
1513
+ cwd: string,
1514
+ ): string {
1515
+ return `Task to review:\n${input.task}\n\nLocal checkout: ${cwd}\n\nOptional diff or patch:\n${input.diff?.trim() ? input.diff : "(not provided)"}\n\nOptional caller context:\n${input.context?.trim() ? input.context : "(not provided)"}\n\nInspect only as much as needed to review the change against the task. Do not implement anything. Respond using the required review format.`;
1292
1516
  }
1293
1517
 
1294
1518
  function formatToolCall(call: ToolCall): string {
1295
- const args = call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
1296
- if (call.name === "read") {
1297
- const readPath = typeof args.path === "string" ? args.path : "";
1298
- const offset = typeof args.offset === "number" ? args.offset : undefined;
1299
- const limit = typeof args.limit === "number" ? args.limit : undefined;
1300
- const range = offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
1301
- return `read ${readPath}${range}`.trim();
1302
- }
1303
- if (call.name === "grep") {
1304
- const pattern = typeof args.pattern === "string" ? args.pattern : "";
1305
- const grepPath = typeof args.path === "string" ? args.path : ".";
1306
- return `grep ${pattern.slice(0, 50)} ${grepPath}`.trim();
1307
- }
1308
- if (call.name === "find") {
1309
- const pattern = typeof args.pattern === "string" ? args.pattern : "";
1310
- const findPath = typeof args.path === "string" ? args.path : ".";
1311
- return `find ${pattern.slice(0, 50)} ${findPath}`.trim();
1312
- }
1313
- if (call.name === "ls") return `ls ${typeof args.path === "string" ? args.path : "."}`.trim();
1314
- if (call.name === "bash") {
1315
- const command = typeof args.command === "string" ? args.command : "";
1316
- return `bash ${command.slice(0, 120)}`.trim();
1317
- }
1318
- return call.name;
1519
+ const args =
1520
+ call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
1521
+ if (call.name === "read") {
1522
+ const readPath = typeof args.path === "string" ? args.path : "";
1523
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
1524
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
1525
+ const range =
1526
+ offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
1527
+ return `read ${readPath}${range}`.trim();
1528
+ }
1529
+ if (call.name === "grep") {
1530
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
1531
+ const grepPath = typeof args.path === "string" ? args.path : ".";
1532
+ return `grep ${pattern.slice(0, 50)} ${grepPath}`.trim();
1533
+ }
1534
+ if (call.name === "find") {
1535
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
1536
+ const findPath = typeof args.path === "string" ? args.path : ".";
1537
+ return `find ${pattern.slice(0, 50)} ${findPath}`.trim();
1538
+ }
1539
+ if (call.name === "ls") return `ls ${typeof args.path === "string" ? args.path : "."}`.trim();
1540
+ if (call.name === "bash") {
1541
+ const command = typeof args.command === "string" ? args.command : "";
1542
+ return `bash ${command.slice(0, 120)}`.trim();
1543
+ }
1544
+ return call.name;
1319
1545
  }
1320
1546
 
1321
1547
  function formatDuration(ms: number): string {
1322
- if (ms < 1000) return `${ms}ms`;
1323
- const seconds = Math.round(ms / 100) / 10;
1324
- if (seconds < 60) return `${seconds}s`;
1325
- const minutes = Math.floor(seconds / 60);
1326
- const remainingSeconds = Math.round((seconds % 60) * 10) / 10;
1327
- return `${minutes}m ${remainingSeconds}s`;
1548
+ if (ms < 1000) return `${ms}ms`;
1549
+ const seconds = Math.round(ms / 100) / 10;
1550
+ if (seconds < 60) return `${seconds}s`;
1551
+ const minutes = Math.floor(seconds / 60);
1552
+ const remainingSeconds = Math.round((seconds % 60) * 10) / 10;
1553
+ return `${minutes}m ${remainingSeconds}s`;
1328
1554
  }
1329
1555
 
1330
1556
  function appendRunDetails(report: string, details: ReviewDetails): string {
1331
- const trimmed = report.trim();
1332
- const toolSummary = details.toolCalls.length > 0 ? details.toolCalls.map(formatToolCall).join("; ") : "none";
1333
- const duration = formatDuration((details.endedAt ?? Date.now()) - details.startedAt);
1334
- const modelSummary = details.modelRef ? `model ${details.modelRef}` : "model unknown";
1335
- const thinkingSummary = details.effectiveThinkingLevel
1336
- ? `thinking ${details.effectiveThinkingLevel}${details.thinkingLevelNote ? ` (${details.thinkingLevelNote})` : ""}`
1337
- : "thinking unknown";
1338
- const suffix = `\n\n---\nRun details: ${modelSummary}; ${thinkingSummary}; ${details.turns} turn(s); ${details.toolCalls.length} tool call(s); duration ${duration}; cwd ${details.cwd}; tools ${toolSummary}.`;
1339
- if (!trimmed) return `## Verdict\nNo review output was produced.\n\n## Findings\n- major — Missing review output. Evidence: the isolated code_reviewer session returned no assistant text. Why it matters: the review could not be completed.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.\n\n## Run details\n- ${suffix.trim()}`;
1340
- return `${trimmed}${suffix}`;
1557
+ const trimmed = report.trim();
1558
+ const toolSummary =
1559
+ details.toolCalls.length > 0 ? details.toolCalls.map(formatToolCall).join("; ") : "none";
1560
+ const duration = formatDuration((details.endedAt ?? Date.now()) - details.startedAt);
1561
+ const modelSummary = details.modelRef ? `model ${details.modelRef}` : "model unknown";
1562
+ const thinkingSummary = details.effectiveThinkingLevel
1563
+ ? `thinking ${details.effectiveThinkingLevel}${details.thinkingLevelNote ? ` (${details.thinkingLevelNote})` : ""}`
1564
+ : "thinking unknown";
1565
+ const suffix = `\n\n---\nRun details: ${modelSummary}; ${thinkingSummary}; ${details.turns} turn(s); ${details.toolCalls.length} tool call(s); duration ${duration}; cwd ${details.cwd}; tools ${toolSummary}.`;
1566
+ if (!trimmed)
1567
+ return `## Verdict\nNo review output was produced.\n\n## Findings\n- major — Missing review output. Evidence: the isolated code_reviewer session returned no assistant text. Why it matters: the review could not be completed.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.\n\n## Run details\n- ${suffix.trim()}`;
1568
+ return `${trimmed}${suffix}`;
1341
1569
  }
1342
1570
 
1343
1571
  export const __test__ = {
1344
- assertToolPathInsideCwd,
1345
- buildSystemPrompt,
1346
- buildUserPrompt,
1347
- createCodeReviewerRuntimeGuardExtension,
1348
- buildSafeGitCommand,
1349
- formatToolCall,
1350
- getBlockedBashReason,
1351
- isModelAvailabilityError,
1352
- normalizeThinkingLevel,
1353
- resolveThinkingLevel,
1354
- selectCodeReviewerModel,
1355
- aggregateAssistantUsage,
1356
- addAssistantMessageUsage,
1357
- addSessionEventUsage,
1358
- inspectFinalAssistant,
1359
- classifyRunFailure,
1572
+ assertToolPathInsideCwd,
1573
+ buildSystemPrompt,
1574
+ buildUserPrompt,
1575
+ createCodeReviewerRuntimeGuardExtension,
1576
+ buildSafeGitCommand,
1577
+ formatToolCall,
1578
+ getBlockedBashReason,
1579
+ isModelAvailabilityError,
1580
+ normalizeThinkingLevel,
1581
+ resolveThinkingLevel,
1582
+ selectCodeReviewerModel,
1583
+ aggregateAssistantUsage,
1584
+ addAssistantMessageUsage,
1585
+ addSessionEventUsage,
1586
+ inspectFinalAssistant,
1587
+ classifyRunFailure,
1360
1588
  };
1361
1589
 
1362
1590
  export default function codeReviewerExtension(pi: ExtensionAPI) {
1363
- pi.on("tool_result", async (event) => {
1364
- if (event.toolName !== "code_reviewer") return undefined;
1365
- const details = event.details as ReviewDetails | undefined;
1366
- if (details?.status !== "error") return undefined;
1367
- return { isError: true };
1368
- });
1369
-
1370
- pi.registerTool({
1371
- name: "code_reviewer",
1372
- label: "Code reviewer",
1373
- description:
1374
- "Read-only isolated reviewer that checks a proposed change for ticket fit, diff accuracy, correctness, security, simplicity, and validation gaps without implementing anything.",
1375
- promptSnippet:
1376
- "Run an isolated read-only code review against the local checkout and optional diff; returns concise findings with evidence and run details.",
1377
- promptGuidelines: [
1378
- "Use code_reviewer when you want an independent read-only review of a proposed change or task implementation.",
1379
- "Provide the requested task and any relevant diff/context. Do not use code_reviewer to implement or edit files.",
1380
- ],
1381
- parameters: CodeReviewerParams,
1382
-
1383
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1384
- const task = typeof params.task === "string" ? params.task.trim() : "";
1385
- if (!task) throw new Error("Invalid parameters: expected task to be a non-empty string.");
1386
-
1387
- const input = {
1388
- task,
1389
- diff: typeof params.diff === "string" ? params.diff : undefined,
1390
- context: typeof params.context === "string" ? params.context : undefined,
1391
- };
1392
- const cwd = path.resolve(ctx.cwd);
1393
- const details: ReviewDetails = {
1394
- status: "running",
1395
- cwd,
1396
- task,
1397
- turns: 0,
1398
- toolCalls: [],
1399
- startedAt: Date.now(),
1400
- };
1401
-
1402
- let lastContent = "(reviewing change...)";
1403
- let session: AgentSession | undefined;
1404
- let unsubscribe: (() => void) | undefined;
1405
- let runTimeout: NodeJS.Timeout | undefined;
1406
- let abortListenerAdded = false;
1407
- let callerAborted = Boolean(signal?.aborted);
1408
- const usage = createEmptyUsage();
1409
-
1410
- const emit = () => {
1411
- onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
1412
- };
1413
-
1414
- const abortForCleanup = () => {
1415
- void session?.abort();
1416
- };
1417
-
1418
- const abortFromCaller = () => {
1419
- callerAborted = true;
1420
- details.status = "aborted";
1421
- details.endedAt = Date.now();
1422
- lastContent = "Aborted";
1423
- emit();
1424
- abortForCleanup();
1425
- };
1426
-
1427
- if (signal?.aborted) abortFromCaller();
1428
- if (signal && !signal.aborted) {
1429
- signal.addEventListener("abort", abortFromCaller);
1430
- abortListenerAdded = true;
1431
- }
1432
-
1433
- try {
1434
- emit();
1435
-
1436
- const isolatedSettingsManager = SettingsManager.inMemory({});
1437
- const resourceLoader = new DefaultResourceLoader({
1438
- cwd,
1439
- agentDir: getAgentDir(),
1440
- settingsManager: isolatedSettingsManager,
1441
- noExtensions: true,
1442
- noSkills: true,
1443
- noPromptTemplates: true,
1444
- noThemes: true,
1445
- noContextFiles: true,
1446
- extensionFactories: [createCodeReviewerRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS })],
1447
- systemPromptOverride: () => buildSystemPrompt({ cwd, maxTurns: MAX_TURNS, maxRunSeconds: Math.round(MAX_RUN_MS / 1000) }),
1448
- appendSystemPromptOverride: () => [],
1449
- skillsOverride: () => ({ skills: [], diagnostics: [] }),
1450
- promptsOverride: () => ({ prompts: [], diagnostics: [] }),
1451
- themesOverride: () => ({ themes: [], diagnostics: [] }),
1452
- agentsFilesOverride: () => ({ agentsFiles: [] }),
1453
- });
1454
-
1455
- await resourceLoader.reload();
1456
-
1457
- const modelSelection = await selectCodeReviewerModel(ctx);
1458
- if (!modelSelection.ok) throw new Error(modelSelection.error);
1459
-
1460
- let answer = "";
1461
- let lastAttemptError: string | undefined;
1462
- const activeThinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel());
1463
- for (let index = 0; index < modelSelection.ordered.length; index += 1) {
1464
- const candidate = modelSelection.ordered[index];
1465
- const thinking = resolveThinkingLevel(candidate, activeThinkingLevel);
1466
- applyModelAttemptDetails(details, candidate, thinking);
1467
- let attemptSession: AgentSession | undefined;
1468
- let attemptUnsubscribe: (() => void) | undefined;
1469
- let succeeded = false;
1470
- lastContent = "(reviewing change...)";
1471
- try {
1472
- const created = await createAgentSession({
1473
- cwd,
1474
- ...getModelRuntimeOption(ctx),
1475
- resourceLoader,
1476
- settingsManager: isolatedSettingsManager,
1477
- sessionManager: SessionManager.inMemory(cwd),
1478
- model: candidate as CreateAgentSessionModel,
1479
- thinkingLevel: thinking.effective,
1480
- tools: ["read", "grep", "find", "ls", "bash"],
1481
- });
1482
- attemptSession = created.session;
1483
- session = attemptSession;
1484
- attemptUnsubscribe = attemptSession.subscribe((event) => {
1485
- addSessionEventUsage(usage, event);
1486
- switch (event.type) {
1487
- case "turn_end":
1488
- details.turns += 1;
1489
- emit();
1490
- break;
1491
- case "tool_execution_start":
1492
- details.toolCalls.push({
1493
- id: event.toolCallId,
1494
- name: event.toolName,
1495
- args: event.args,
1496
- startedAt: Date.now(),
1497
- });
1498
- if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1499
- details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1500
- }
1501
- emit();
1502
- break;
1503
- case "tool_execution_end": {
1504
- const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1505
- if (call) {
1506
- call.endedAt = Date.now();
1507
- call.isError = event.isError;
1508
- }
1509
- emit();
1510
- break;
1511
- }
1512
- case "message_update":
1513
- if (event.assistantMessageEvent?.type === "text_delta") {
1514
- lastContent += event.assistantMessageEvent.delta ?? "";
1515
- emit();
1516
- }
1517
- break;
1518
- }
1519
- });
1520
- unsubscribe = attemptUnsubscribe;
1521
-
1522
- if (!callerAborted) {
1523
- const promptPromise = attemptSession.prompt(buildUserPrompt(input, cwd), { expandPromptTemplates: false });
1524
- const timeoutPromise = new Promise<never>((_resolve, reject) => {
1525
- runTimeout = setTimeout(() => {
1526
- abortForCleanup();
1527
- reject(new Error(`code_reviewer timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1528
- }, MAX_RUN_MS);
1529
- });
1530
- await Promise.race([promptPromise, timeoutPromise]);
1531
- }
1532
-
1533
- const outcome = inspectFinalAssistant(attemptSession.state.messages);
1534
- if (outcome.ok) {
1535
- answer = outcome.answer;
1536
- succeeded = true;
1537
- break;
1538
- }
1539
- lastAttemptError = outcome.reason;
1540
- const canFallBack =
1541
- index < modelSelection.ordered.length - 1 &&
1542
- outcome.stopReason === "error" &&
1543
- isModelAvailabilityError(outcome.errorMessage || outcome.reason);
1544
- if (!canFallBack) break;
1545
- } catch (error) {
1546
- const message = error instanceof Error ? error.message : String(error);
1547
- lastAttemptError = message;
1548
- const canFallBack = index < modelSelection.ordered.length - 1 && isModelAvailabilityError(message);
1549
- if (!canFallBack) throw error;
1550
- } finally {
1551
- if (runTimeout) {
1552
- clearTimeout(runTimeout);
1553
- runTimeout = undefined;
1554
- }
1555
- if (attemptSession && !succeeded) {
1556
- attemptUnsubscribe?.();
1557
- if (unsubscribe === attemptUnsubscribe) unsubscribe = undefined;
1558
- attemptSession.dispose();
1559
- if (session === attemptSession) session = undefined;
1560
- }
1561
- }
1562
- }
1563
- if (!answer) {
1564
- if (callerAborted) {
1565
- lastContent = "Aborted";
1566
- details.status = "aborted";
1567
- } else {
1568
- throw new Error(lastAttemptError || "the isolated code_reviewer session finished without a usable answer");
1569
- }
1570
- } else {
1571
- lastContent = answer;
1572
- details.status = "done";
1573
- }
1574
- details.endedAt = Date.now();
1575
- const report = appendRunDetails(lastContent, details);
1576
- lastContent = report;
1577
- emit();
1578
- return { content: [{ type: "text", text: report }], details, usage };
1579
- } catch (error) {
1580
- const failure = classifyRunFailure(error, callerAborted);
1581
- const { message } = failure;
1582
- details.status = failure.status;
1583
- details.error = failure.error;
1584
- details.endedAt = Date.now();
1585
- lastContent = appendRunDetails(`## Verdict\nReview failed.\n\n## Findings\n- major — Review execution failed. Evidence: ${message}. Why it matters: the requested read-only review did not complete.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.\n\n## Run details\n- failure: ${message}`, details);
1586
- emit();
1587
- return { content: [{ type: "text", text: lastContent }], details, usage };
1588
- } finally {
1589
- if (runTimeout) clearTimeout(runTimeout);
1590
- if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
1591
- unsubscribe?.();
1592
- session?.dispose();
1593
- }
1594
- },
1595
- });
1591
+ pi.on("tool_result", async (event) => {
1592
+ if (event.toolName !== "code_reviewer") return undefined;
1593
+ const details = event.details as ReviewDetails | undefined;
1594
+ if (details?.status !== "error") return undefined;
1595
+ return { isError: true };
1596
+ });
1597
+
1598
+ pi.registerTool({
1599
+ name: "code_reviewer",
1600
+ label: "Code reviewer",
1601
+ description:
1602
+ "Read-only isolated reviewer that checks a proposed change for ticket fit, diff accuracy, correctness, security, simplicity, and validation gaps without implementing anything.",
1603
+ promptSnippet:
1604
+ "Run an isolated read-only code review against the local checkout and optional diff; returns concise findings with evidence and run details.",
1605
+ promptGuidelines: [
1606
+ "Use code_reviewer when you want an independent read-only review of a proposed change or task implementation.",
1607
+ "Provide the requested task and any relevant diff/context. Do not use code_reviewer to implement or edit files.",
1608
+ ],
1609
+ parameters: CodeReviewerParams,
1610
+
1611
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
1612
+ const task = typeof params.task === "string" ? params.task.trim() : "";
1613
+ if (!task) throw new Error("Invalid parameters: expected task to be a non-empty string.");
1614
+
1615
+ const input = {
1616
+ task,
1617
+ diff: typeof params.diff === "string" ? params.diff : undefined,
1618
+ context: typeof params.context === "string" ? params.context : undefined,
1619
+ };
1620
+ const cwd = path.resolve(ctx.cwd);
1621
+ const details: ReviewDetails = {
1622
+ status: "running",
1623
+ cwd,
1624
+ task,
1625
+ turns: 0,
1626
+ toolCalls: [],
1627
+ startedAt: Date.now(),
1628
+ };
1629
+
1630
+ let lastContent = "(reviewing change...)";
1631
+ let session: AgentSession | undefined;
1632
+ let unsubscribe: (() => void) | undefined;
1633
+ let runTimeout: NodeJS.Timeout | undefined;
1634
+ let abortListenerAdded = false;
1635
+ let callerAborted = Boolean(signal?.aborted);
1636
+ const usage = createEmptyUsage();
1637
+
1638
+ const emit = () => {
1639
+ onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
1640
+ };
1641
+
1642
+ const abortForCleanup = () => {
1643
+ void session?.abort();
1644
+ };
1645
+
1646
+ const abortFromCaller = () => {
1647
+ callerAborted = true;
1648
+ details.status = "aborted";
1649
+ details.endedAt = Date.now();
1650
+ lastContent = "Aborted";
1651
+ emit();
1652
+ abortForCleanup();
1653
+ };
1654
+
1655
+ if (signal?.aborted) abortFromCaller();
1656
+ if (signal && !signal.aborted) {
1657
+ signal.addEventListener("abort", abortFromCaller);
1658
+ abortListenerAdded = true;
1659
+ }
1660
+
1661
+ try {
1662
+ emit();
1663
+
1664
+ const isolatedSettingsManager = SettingsManager.inMemory({});
1665
+ const resourceLoader = new DefaultResourceLoader({
1666
+ cwd,
1667
+ agentDir: getAgentDir(),
1668
+ settingsManager: isolatedSettingsManager,
1669
+ noExtensions: true,
1670
+ noSkills: true,
1671
+ noPromptTemplates: true,
1672
+ noThemes: true,
1673
+ noContextFiles: true,
1674
+ extensionFactories: [
1675
+ createCodeReviewerRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS }),
1676
+ ],
1677
+ systemPromptOverride: () =>
1678
+ buildSystemPrompt({
1679
+ cwd,
1680
+ maxTurns: MAX_TURNS,
1681
+ maxRunSeconds: Math.round(MAX_RUN_MS / 1000),
1682
+ }),
1683
+ appendSystemPromptOverride: () => [],
1684
+ skillsOverride: () => ({ skills: [], diagnostics: [] }),
1685
+ promptsOverride: () => ({ prompts: [], diagnostics: [] }),
1686
+ themesOverride: () => ({ themes: [], diagnostics: [] }),
1687
+ agentsFilesOverride: () => ({ agentsFiles: [] }),
1688
+ });
1689
+
1690
+ await resourceLoader.reload();
1691
+
1692
+ const modelSelection = await selectCodeReviewerModel(ctx);
1693
+ if (!modelSelection.ok) throw new Error(modelSelection.error);
1694
+
1695
+ let answer = "";
1696
+ let lastAttemptError: string | undefined;
1697
+ const activeThinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel());
1698
+ for (let index = 0; index < modelSelection.ordered.length; index += 1) {
1699
+ const candidate = modelSelection.ordered[index];
1700
+ const thinking = resolveThinkingLevel(candidate, activeThinkingLevel);
1701
+ applyModelAttemptDetails(details, candidate, thinking);
1702
+ let attemptSession: AgentSession | undefined;
1703
+ let attemptUnsubscribe: (() => void) | undefined;
1704
+ let succeeded = false;
1705
+ lastContent = "(reviewing change...)";
1706
+ try {
1707
+ const created = await createAgentSession({
1708
+ cwd,
1709
+ ...getModelRuntimeOption(ctx),
1710
+ resourceLoader,
1711
+ settingsManager: isolatedSettingsManager,
1712
+ sessionManager: SessionManager.inMemory(cwd),
1713
+ model: candidate as CreateAgentSessionModel,
1714
+ thinkingLevel: thinking.effective,
1715
+ tools: ["read", "grep", "find", "ls", "bash"],
1716
+ });
1717
+ attemptSession = created.session;
1718
+ session = attemptSession;
1719
+ attemptUnsubscribe = attemptSession.subscribe((event) => {
1720
+ addSessionEventUsage(usage, event);
1721
+ switch (event.type) {
1722
+ case "turn_end":
1723
+ details.turns += 1;
1724
+ emit();
1725
+ break;
1726
+ case "tool_execution_start":
1727
+ details.toolCalls.push({
1728
+ id: event.toolCallId,
1729
+ name: event.toolName,
1730
+ args: event.args,
1731
+ startedAt: Date.now(),
1732
+ });
1733
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1734
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1735
+ }
1736
+ emit();
1737
+ break;
1738
+ case "tool_execution_end": {
1739
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1740
+ if (call) {
1741
+ call.endedAt = Date.now();
1742
+ call.isError = event.isError;
1743
+ }
1744
+ emit();
1745
+ break;
1746
+ }
1747
+ case "message_update":
1748
+ if (event.assistantMessageEvent?.type === "text_delta") {
1749
+ lastContent += event.assistantMessageEvent.delta ?? "";
1750
+ emit();
1751
+ }
1752
+ break;
1753
+ }
1754
+ });
1755
+ unsubscribe = attemptUnsubscribe;
1756
+
1757
+ if (!callerAborted) {
1758
+ const promptPromise = attemptSession.prompt(buildUserPrompt(input, cwd), {
1759
+ expandPromptTemplates: false,
1760
+ });
1761
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1762
+ runTimeout = setTimeout(() => {
1763
+ abortForCleanup();
1764
+ reject(
1765
+ new Error(
1766
+ `code_reviewer timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
1767
+ ),
1768
+ );
1769
+ }, MAX_RUN_MS);
1770
+ });
1771
+ await Promise.race([promptPromise, timeoutPromise]);
1772
+ }
1773
+
1774
+ const outcome = inspectFinalAssistant(attemptSession.state.messages);
1775
+ if (outcome.ok) {
1776
+ answer = outcome.answer;
1777
+ succeeded = true;
1778
+ break;
1779
+ }
1780
+ lastAttemptError = outcome.reason;
1781
+ const canFallBack =
1782
+ index < modelSelection.ordered.length - 1 &&
1783
+ outcome.stopReason === "error" &&
1784
+ isModelAvailabilityError(outcome.errorMessage || outcome.reason);
1785
+ if (!canFallBack) break;
1786
+ } catch (error) {
1787
+ const message = error instanceof Error ? error.message : String(error);
1788
+ lastAttemptError = message;
1789
+ const canFallBack =
1790
+ index < modelSelection.ordered.length - 1 && isModelAvailabilityError(message);
1791
+ if (!canFallBack) throw error;
1792
+ } finally {
1793
+ if (runTimeout) {
1794
+ clearTimeout(runTimeout);
1795
+ runTimeout = undefined;
1796
+ }
1797
+ if (attemptSession && !succeeded) {
1798
+ attemptUnsubscribe?.();
1799
+ if (unsubscribe === attemptUnsubscribe) unsubscribe = undefined;
1800
+ attemptSession.dispose();
1801
+ if (session === attemptSession) session = undefined;
1802
+ }
1803
+ }
1804
+ }
1805
+ if (!answer) {
1806
+ if (callerAborted) {
1807
+ lastContent = "Aborted";
1808
+ details.status = "aborted";
1809
+ } else {
1810
+ throw new Error(
1811
+ lastAttemptError ||
1812
+ "the isolated code_reviewer session finished without a usable answer",
1813
+ );
1814
+ }
1815
+ } else {
1816
+ lastContent = answer;
1817
+ details.status = "done";
1818
+ }
1819
+ details.endedAt = Date.now();
1820
+ const report = appendRunDetails(lastContent, details);
1821
+ lastContent = report;
1822
+ emit();
1823
+ return { content: [{ type: "text", text: report }], details, usage };
1824
+ } catch (error) {
1825
+ const failure = classifyRunFailure(error, callerAborted);
1826
+ const { message } = failure;
1827
+ details.status = failure.status;
1828
+ details.error = failure.error;
1829
+ details.endedAt = Date.now();
1830
+ lastContent = appendRunDetails(
1831
+ `## Verdict\nReview failed.\n\n## Findings\n- major — Review execution failed. Evidence: ${message}. Why it matters: the requested read-only review did not complete.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.\n\n## Run details\n- failure: ${message}`,
1832
+ details,
1833
+ );
1834
+ emit();
1835
+ return { content: [{ type: "text", text: lastContent }], details, usage };
1836
+ } finally {
1837
+ if (runTimeout) clearTimeout(runTimeout);
1838
+ if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
1839
+ unsubscribe?.();
1840
+ session?.dispose();
1841
+ }
1842
+ },
1843
+ });
1596
1844
  }