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