@diegopetrucci/pi-extensions 0.1.74 → 0.1.78

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