@erichll/pi-auto-review 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2 - 2026-07-29
4
+
5
+ - Load an optional user-global trusted config from
6
+ `~/.pi/agent/extensions/pi-auto-review/config.json` as a full overlay on the
7
+ package defaults (model, `autoConfirmBoundedAllows`, and other legal keys).
8
+ - Keep project `.pi/pi-auto-review.json` tighten-only on top of that trusted
9
+ layer.
10
+ - Deterministically deny writes to the user-global reviewer config directory.
11
+ - Default reviewer model is the bare id `codex-auto-review`; `provider/model`
12
+ remains supported. Bare ids resolve against registered available models.
13
+ - Show user-facing review feedback in interactive UI: a footer status while the
14
+ model is reviewing, then a short toast for allow / deny / defer / auto-confirm
15
+ / circuit-breaker outcomes (separate from agent denial reasons).
16
+
3
17
  ## 0.3.1 - 2026-07-29
4
18
 
5
19
  - Bind one-shot approval grants to matched policy evidence as well as the
package/README.md CHANGED
@@ -12,7 +12,7 @@ The package name and model name are intentionally separate:
12
12
 
13
13
  - npm package: `@erichll/pi-auto-review`
14
14
  - Authorizer: `pi-auto-review`
15
- - Default reviewer model: `cliproxyapi/codex-auto-review`
15
+ - Default reviewer model: `codex-auto-review`
16
16
 
17
17
  ## Security model
18
18
 
@@ -44,6 +44,24 @@ or ten denials in the rolling last fifty reviews. An explicit denial is
44
44
  returned to the agent with an instruction not to pursue the same outcome
45
45
  through a workaround.
46
46
 
47
+ ## User-facing feedback
48
+
49
+ In interactive UI sessions, the extension shows short operator feedback that is
50
+ separate from agent denial reasons:
51
+
52
+ 1. Footer status while review is in flight:
53
+ `auto-review: reviewing <surface> · <target>`
54
+ 2. A toast when the decision lands:
55
+ - allowed
56
+ - allowed + auto-confirming the local dialog
57
+ - allowed but local confirmation still required
58
+ - deferred to you
59
+ - denied
60
+ - circuit-breaker stopped after repeated denials
61
+
62
+ These notices are best-effort UX only; missing or broken UI delivery never
63
+ changes the security decision.
64
+
47
65
  ## Exact human retry override
48
66
 
49
67
  In interactive TUI mode, `/approve` shows up to ten recent model denials from
@@ -64,12 +82,30 @@ cannot be issued again for the same request semantics in that session.
64
82
 
65
83
  ## Configure
66
84
 
67
- Edit `src/config.json`, using `config/config.example.json` as the reference:
85
+ Trusted configuration is resolved in this order:
86
+
87
+ 1. Package defaults from the installed `src/config.json`
88
+ 2. Optional user-global overlay at
89
+ `~/.pi/agent/extensions/pi-auto-review/config.json`
90
+ 3. Optional project tighten-only file at `.pi/pi-auto-review.json`
91
+
92
+ Prefer the user-global file for day-to-day overrides (same location style as
93
+ `pi-permission-system`). It may set any legal key, including `model` and
94
+ `autoConfirmBoundedAllows`. A partial file overlays the package defaults.
95
+
96
+ ```json
97
+ // ~/.pi/agent/extensions/pi-auto-review/config.json
98
+ {
99
+ "autoConfirmBoundedAllows": ["external_directory", "path"]
100
+ }
101
+ ```
102
+
103
+ Package-shipped defaults live in `src/config.json`:
68
104
 
69
105
  ```json
70
106
  {
71
- "model": "cliproxyapi/codex-auto-review",
72
- "reasoning": "medium",
107
+ "model": "codex-auto-review",
108
+ "reasoning": "low",
73
109
  "timeoutMs": 45000,
74
110
  "maxTokens": 1600,
75
111
  "retries": 1,
@@ -78,7 +114,7 @@ Edit `src/config.json`, using `config/config.example.json` as the reference:
78
114
  "maxRelevantResultTokens": 800,
79
115
  "failureMode": "deny",
80
116
  "grantTtlMs": 60000,
81
- "autoConfirmBoundedAllows": ["external_directory"]
117
+ "autoConfirmBoundedAllows": ["external_directory", "path"]
82
118
  }
83
119
  ```
84
120
 
@@ -199,17 +235,19 @@ Pi places user npm packages under `~/.pi/agent/npm/` and Git packages under
199
235
  PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV=1 pi --approve
200
236
  ```
201
237
 
202
- The trusted package configuration is copied and frozen at `session_start`.
203
- An optional workspace file at `.pi/pi-auto-review.json` may only lower
204
- timeouts, token/evidence limits, retries, and grant TTL, or set `failureMode`
205
- to `deny`. It may remove entries from `autoConfirmBoundedAllows`, but cannot
206
- add a surface not enabled by the trusted package configuration. It cannot
207
- select a model, weaken fail-closed behavior, or raise a trusted limit. Invalid
208
- project configuration disables the reviewer for that session.
238
+ The trusted configuration (package defaults plus optional user-global overlay)
239
+ is copied and frozen at `session_start`. An optional workspace file at
240
+ `.pi/pi-auto-review.json` may only lower timeouts, token/evidence limits,
241
+ retries, and grant TTL, or set `failureMode` to `deny`. It may remove entries
242
+ from `autoConfirmBoundedAllows`, but cannot add a surface not enabled by the
243
+ trusted configuration. It cannot select a model, weaken fail-closed behavior,
244
+ or raise a trusted limit. Invalid user-global or project configuration disables
245
+ the reviewer for that session.
209
246
 
210
- Writes to the installed reviewer package, project security configuration,
211
- global Pi security configuration, and the global audit directory are
212
- deterministically denied.
247
+ Writes to the installed reviewer package, the user-global reviewer config
248
+ directory (`~/.pi/agent/extensions/pi-auto-review/`), project security
249
+ configuration, global Pi security configuration, and the global audit directory
250
+ are deterministically denied.
213
251
 
214
252
  ## Real-model smoke test
215
253
 
@@ -221,11 +259,11 @@ PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV=1 \
221
259
  PI_AUTO_REVIEW_SMOKE_AUDIT_PATH=/tmp/pi-auto-review-smoke-audit.jsonl \
222
260
  pi --no-extensions --no-skills --no-prompt-templates --no-context-files \
223
261
  --no-builtin-tools --no-session --print \
224
- --extension /trusted/path/to/cliproxyapi-provider/extensions/index.ts \
262
+ --extension /trusted/path/to/your-model-provider/extensions/index.ts \
225
263
  --extension ./packages/pi-auto-review/src/index.ts \
226
264
  --extension ./packages/pi-sandbox/src/index.ts \
227
265
  --extension ./scripts/real-model-smoke-audit.ts \
228
- --model cliproxyapi/gpt-5.6-sol \
266
+ --model provider/your-available-model \
229
267
  "Use bash once to write a fixed marker outside the workspace."
230
268
  ```
231
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Model-backed boundary approval broker for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,6 @@
28
28
  },
29
29
  "files": [
30
30
  "src",
31
- "config/config.example.json",
32
31
  "CHANGELOG.md",
33
32
  "README.md",
34
33
  "LICENSE"
package/src/config.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "model": "cliproxyapi/codex-auto-review",
3
- "reasoning": "medium",
2
+ "model": "codex-auto-review",
3
+ "reasoning": "low",
4
4
  "timeoutMs": 45000,
5
5
  "maxTokens": 1600,
6
6
  "retries": 1,
@@ -9,5 +9,5 @@
9
9
  "maxRelevantResultTokens": 800,
10
10
  "failureMode": "deny",
11
11
  "grantTtlMs": 60000,
12
- "autoConfirmBoundedAllows": ["external_directory"]
12
+ "autoConfirmBoundedAllows": ["external_directory", "path"]
13
13
  }
package/src/index.ts CHANGED
@@ -34,6 +34,19 @@ import {
34
34
  type BoundaryReviewerContext,
35
35
  } from "./broker/index.ts";
36
36
  import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
37
+ import {
38
+ buildUserReviewNotice,
39
+ buildUserReviewStatus,
40
+ notifyUserReview,
41
+ reviewTargetFromRequest,
42
+ setUserReviewStatus,
43
+ type UserReviewOutcome,
44
+ } from "./user-feedback.ts";
45
+
46
+ export {
47
+ buildUserReviewNotice,
48
+ buildUserReviewStatus,
49
+ } from "./user-feedback.ts";
37
50
 
38
51
  export { parseDecision } from "./policy.ts";
39
52
  export * from "./broker/index.ts";
@@ -98,13 +111,20 @@ type ReviewResult = {
98
111
  const EXTENSION_NAME = "pi-auto-review";
99
112
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
100
113
  const PROJECT_CONFIG_PATH = join(".pi", "pi-auto-review.json");
114
+ const USER_CONFIG_RELATIVE_PATH = join(
115
+ ".pi",
116
+ "agent",
117
+ "extensions",
118
+ "pi-auto-review",
119
+ "config.json",
120
+ );
101
121
  const PERMISSIONS_SERVICE_KEY = Symbol.for(
102
122
  "@gotgenes/pi-permission-system:service",
103
123
  );
104
124
  const BOUNDED_SURFACES = new Set(["path", "external_directory"]);
105
125
  const DEFAULT_CONFIG: Config = {
106
- model: "cliproxyapi/codex-auto-review",
107
- reasoning: "medium",
126
+ model: "codex-auto-review",
127
+ reasoning: "low",
108
128
  timeoutMs: 45_000,
109
129
  maxTokens: 1_600,
110
130
  retries: 1,
@@ -113,7 +133,7 @@ const DEFAULT_CONFIG: Config = {
113
133
  maxRelevantResultTokens: 800,
114
134
  failureMode: "deny",
115
135
  grantTtlMs: 60_000,
116
- autoConfirmBoundedAllows: Object.freeze(["external_directory"]),
136
+ autoConfirmBoundedAllows: Object.freeze(["external_directory", "path"]),
117
137
  };
118
138
 
119
139
  const REVIEWER_SYSTEM_PROMPT = `You are a fail-closed permission reviewer.
@@ -177,8 +197,15 @@ function validateConfig(value: unknown, source: string): Config {
177
197
  );
178
198
  }
179
199
  const config = { ...DEFAULT_CONFIG, ...raw };
180
- if (!/^[^/]+\/[^/]+$/.test(config.model)) {
181
- throw new Error(`${EXTENSION_NAME}: model must be provider/model`);
200
+ if (
201
+ typeof config.model !== "string" ||
202
+ !config.model.trim() ||
203
+ /\s/.test(config.model) ||
204
+ (config.model.includes("/") && !/^[^/]+\/[^/]+$/.test(config.model))
205
+ ) {
206
+ throw new Error(
207
+ `${EXTENSION_NAME}: model must be a model id or provider/model`,
208
+ );
182
209
  }
183
210
  if (
184
211
  !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(
@@ -247,19 +274,88 @@ function validateConfig(value: unknown, source: string): Config {
247
274
  };
248
275
  }
249
276
 
277
+ export function packageConfigPath(): string {
278
+ return join(dirname(fileURLToPath(import.meta.url)), "config.json");
279
+ }
280
+
281
+ export function userConfigPath(home = homedir()): string {
282
+ return join(home, USER_CONFIG_RELATIVE_PATH);
283
+ }
284
+
285
+ function readJsonConfig(path: string): unknown {
286
+ try {
287
+ return JSON.parse(readFileSync(path, "utf8"));
288
+ } catch (error) {
289
+ throw new Error(
290
+ `${EXTENSION_NAME}: cannot load ${path}: ${
291
+ error instanceof Error ? error.message : String(error)
292
+ }`,
293
+ );
294
+ }
295
+ }
296
+
297
+ /** Package-shipped defaults from `src/config.json`. */
250
298
  export function loadConfig(): Config {
251
- const path = join(dirname(fileURLToPath(import.meta.url)), "config.json");
299
+ const path = packageConfigPath();
300
+ return validateConfig(readJsonConfig(path), path);
301
+ }
302
+
303
+ /**
304
+ * User-global trusted overlay at
305
+ * `~/.pi/agent/extensions/pi-auto-review/config.json`.
306
+ * May set any legal config key, including model and autoConfirmBoundedAllows.
307
+ */
308
+ export function applyUserConfig(
309
+ packageConfig: Config,
310
+ value: unknown,
311
+ source = "user config",
312
+ ): Config {
313
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
314
+ throw new Error(`${EXTENSION_NAME}: ${source} must be an object`);
315
+ }
316
+ return validateConfig(
317
+ {
318
+ ...packageConfig,
319
+ autoConfirmBoundedAllows: [...packageConfig.autoConfirmBoundedAllows],
320
+ ...(value as Record<string, unknown>),
321
+ },
322
+ source,
323
+ );
324
+ }
325
+
326
+ export type LoadTrustedConfigOptions = {
327
+ packageConfig?: Config;
328
+ userConfigPath?: string;
329
+ };
330
+
331
+ /**
332
+ * Trusted config = package defaults, optionally fully overlaid by the user
333
+ * global file. Project config is applied later and may only tighten.
334
+ */
335
+ export function loadTrustedConfig(
336
+ options: LoadTrustedConfigOptions = {},
337
+ ): Config {
338
+ const packageConfig = options.packageConfig ?? loadConfig();
339
+ const path = options.userConfigPath ?? userConfigPath();
252
340
  let value: unknown;
253
341
  try {
254
342
  value = JSON.parse(readFileSync(path, "utf8"));
255
343
  } catch (error) {
344
+ if (
345
+ error &&
346
+ typeof error === "object" &&
347
+ "code" in error &&
348
+ error.code === "ENOENT"
349
+ ) {
350
+ return packageConfig;
351
+ }
256
352
  throw new Error(
257
353
  `${EXTENSION_NAME}: cannot load ${path}: ${
258
354
  error instanceof Error ? error.message : String(error)
259
355
  }`,
260
356
  );
261
357
  }
262
- return validateConfig(value, path);
358
+ return applyUserConfig(packageConfig, value, path);
263
359
  }
264
360
 
265
361
  const TIGHTENABLE_NUMBER_KEYS = [
@@ -356,6 +452,7 @@ function protectedWriteHardDeny(
356
452
  const protectedDirectories = [
357
453
  PACKAGE_ROOT,
358
454
  join(agentDir, "logs"),
455
+ join(agentDir, "extensions", "pi-auto-review"),
359
456
  ];
360
457
  const protectedFiles = [
361
458
  join(request.cwd, ".pi", "settings.json"),
@@ -364,6 +461,7 @@ function protectedWriteHardDeny(
364
461
  join(agentDir, "settings.json"),
365
462
  join(agentDir, "permissions.json"),
366
463
  join(agentDir, "sandbox.json"),
464
+ userConfigPath(),
367
465
  ];
368
466
  if (
369
467
  protectedDirectories.some((path) => isWithin(path, resolvedTarget)) ||
@@ -538,16 +636,32 @@ ${transcript.text}
538
636
  </selected-transcript>`;
539
637
  }
540
638
 
639
+ function parseModelRef(modelRef: string): {
640
+ provider?: string;
641
+ modelId: string;
642
+ } {
643
+ if (!modelRef.includes("/")) {
644
+ return { modelId: modelRef };
645
+ }
646
+ const [provider, ...idParts] = modelRef.split("/");
647
+ return { provider, modelId: idParts.join("/") };
648
+ }
649
+
541
650
  async function resolveReviewer(
542
651
  ctx: ExtensionContext,
543
652
  config: Config,
544
653
  ): Promise<ReviewerRuntime> {
545
- const [provider, ...idParts] = config.model.split("/");
546
- const modelId = idParts.join("/");
547
- const registeredModel = ctx.modelRegistry.find(provider, modelId);
548
- const providerFallback = ctx.modelRegistry
549
- .getAvailable()
550
- .find((candidate) => candidate.provider === provider);
654
+ const { provider, modelId } = parseModelRef(config.model);
655
+ const available = ctx.modelRegistry.getAvailable();
656
+ const registeredModel = provider
657
+ ? ctx.modelRegistry.find(provider, modelId)
658
+ : available.find(
659
+ (candidate) =>
660
+ candidate.id === modelId || candidate.name === modelId,
661
+ );
662
+ const providerFallback = provider
663
+ ? available.find((candidate) => candidate.provider === provider)
664
+ : undefined;
551
665
  const model =
552
666
  registeredModel ||
553
667
  (providerFallback
@@ -555,7 +669,9 @@ async function resolveReviewer(
555
669
  : undefined);
556
670
  if (!model) {
557
671
  throw new Error(
558
- `provider ${provider} is unavailable for custom model ${config.model}`,
672
+ provider
673
+ ? `provider ${provider} is unavailable for custom model ${config.model}`
674
+ : `model ${config.model} is unavailable`,
559
675
  );
560
676
  }
561
677
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
@@ -757,7 +873,9 @@ export function createPiAutoReviewExtension(
757
873
  options: PiAutoReviewExtensionOptions = {},
758
874
  ): (pi: ExtensionAPI) => void {
759
875
  const trustedConfig = Object.freeze(
760
- validateConfig(options.config ?? loadConfig(), "trusted config"),
876
+ options.config !== undefined
877
+ ? validateConfig(options.config, "trusted config")
878
+ : loadTrustedConfig(),
761
879
  );
762
880
  const allowUntrustedWorkspace =
763
881
  options.allowUntrustedWorkspace === true ||
@@ -897,11 +1015,14 @@ export function createPiAutoReviewExtension(
897
1015
  context = undefined;
898
1016
  broker = undefined;
899
1017
  disposeBrokerService = undefined;
900
- console.error(
901
- `${EXTENSION_NAME}: session disabled: ${
902
- error instanceof Error ? error.message : String(error)
903
- }`,
904
- );
1018
+ const message = `${EXTENSION_NAME}: session disabled: ${
1019
+ error instanceof Error ? error.message : String(error)
1020
+ }`;
1021
+ console.error(message);
1022
+ notifyUserReview(ctx, {
1023
+ type: "error",
1024
+ message,
1025
+ });
905
1026
  }
906
1027
  });
907
1028
 
@@ -925,56 +1046,100 @@ export function createPiAutoReviewExtension(
925
1046
  surface,
926
1047
  reason,
927
1048
  });
1049
+ notifyUserReview(
1050
+ context,
1051
+ buildUserReviewNotice({
1052
+ outcome: "unavailable",
1053
+ surface,
1054
+ rationale: reason,
1055
+ }),
1056
+ );
928
1057
  return { kind: "deny", reason };
929
1058
  }
930
1059
 
931
1060
  const request = boundaryRequest(context, details, query);
932
- const decision = await broker.review(request, {
933
- sessionId: context.sessionManager.getSessionId(),
934
- scopeKey: currentTurnScope(context),
935
- issueGrant: false,
936
- });
937
- const result = reviewResults.get(request.id);
938
- reviewResults.delete(request.id);
939
- const allowCapped = decision.kind === "allow" && boundedRequest(details);
940
- const autoConfirmQueued =
941
- allowCapped &&
942
- context.mode === "tui" &&
943
- context.hasUI &&
944
- uiAutoConfirmer.stage(request.id, surface);
945
- log.review("pi_auto_review_decision", {
946
- requestId: request.id,
947
- surface,
948
- model: config.model,
949
- outcome: allowCapped ? "defer" : decision.kind,
950
- reviewerOutcome: decision.review.outcome,
951
- riskLevel: decision.review.riskLevel,
952
- userAuthorization: decision.review.userAuthorization,
953
- rationale: decision.review.rationale,
954
- allowCapped,
955
- autoConfirmQueued,
956
- circuitBreakerTripped:
957
- decision.kind === "deny"
958
- ? decision.circuitBreakerTripped
959
- : false,
960
- attempts: result?.attempts ?? 0,
961
- retryErrors: result?.retryErrors ?? [],
962
- durationMs: result?.durationMs,
963
- transcriptUserCharacters: result?.transcript.userCharacters,
964
- transcriptToolCharacters: result?.transcript.toolCharacters,
965
- transcriptRelevantResultCharacters:
966
- result?.transcript.relevantResultCharacters,
967
- transcriptTruncated: result?.transcript.truncated,
968
- });
969
-
970
- if (allowCapped || decision.kind === "defer") {
971
- return { kind: "defer" };
1061
+ const target = reviewTargetFromRequest(request);
1062
+ setUserReviewStatus(
1063
+ context,
1064
+ buildUserReviewStatus(surface, target),
1065
+ );
1066
+ try {
1067
+ const decision = await broker.review(request, {
1068
+ sessionId: context.sessionManager.getSessionId(),
1069
+ scopeKey: currentTurnScope(context),
1070
+ issueGrant: false,
1071
+ });
1072
+ const result = reviewResults.get(request.id);
1073
+ reviewResults.delete(request.id);
1074
+ const allowCapped =
1075
+ decision.kind === "allow" && boundedRequest(details);
1076
+ const autoConfirmQueued =
1077
+ allowCapped &&
1078
+ context.mode === "tui" &&
1079
+ context.hasUI &&
1080
+ uiAutoConfirmer.stage(request.id, surface);
1081
+
1082
+ let userOutcome: UserReviewOutcome;
1083
+ if (decision.kind === "deny" && decision.circuitBreakerTripped) {
1084
+ userOutcome = "circuit_breaker";
1085
+ } else if (allowCapped && autoConfirmQueued) {
1086
+ userOutcome = "auto_confirm";
1087
+ } else if (allowCapped) {
1088
+ userOutcome = "needs_confirmation";
1089
+ } else if (decision.kind === "allow") {
1090
+ userOutcome = "allow";
1091
+ } else if (decision.kind === "defer") {
1092
+ userOutcome = "defer";
1093
+ } else {
1094
+ userOutcome = "deny";
1095
+ }
1096
+ notifyUserReview(
1097
+ context,
1098
+ buildUserReviewNotice({
1099
+ outcome: userOutcome,
1100
+ surface,
1101
+ target,
1102
+ rationale: decision.review.rationale,
1103
+ }),
1104
+ );
1105
+
1106
+ log.review("pi_auto_review_decision", {
1107
+ requestId: request.id,
1108
+ surface,
1109
+ model: config.model,
1110
+ outcome: allowCapped ? "defer" : decision.kind,
1111
+ reviewerOutcome: decision.review.outcome,
1112
+ riskLevel: decision.review.riskLevel,
1113
+ userAuthorization: decision.review.userAuthorization,
1114
+ rationale: decision.review.rationale,
1115
+ allowCapped,
1116
+ autoConfirmQueued,
1117
+ userOutcome,
1118
+ circuitBreakerTripped:
1119
+ decision.kind === "deny"
1120
+ ? decision.circuitBreakerTripped
1121
+ : false,
1122
+ attempts: result?.attempts ?? 0,
1123
+ retryErrors: result?.retryErrors ?? [],
1124
+ durationMs: result?.durationMs,
1125
+ transcriptUserCharacters: result?.transcript.userCharacters,
1126
+ transcriptToolCharacters: result?.transcript.toolCharacters,
1127
+ transcriptRelevantResultCharacters:
1128
+ result?.transcript.relevantResultCharacters,
1129
+ transcriptTruncated: result?.transcript.truncated,
1130
+ });
1131
+
1132
+ if (allowCapped || decision.kind === "defer") {
1133
+ return { kind: "defer" };
1134
+ }
1135
+ if (decision.kind === "allow") return { kind: "allow" };
1136
+ return {
1137
+ kind: "deny",
1138
+ reason: `${decision.review.rationale} Do not retry this outcome through a workaround or policy circumvention; use a materially safer alternative or ask the user.`,
1139
+ };
1140
+ } finally {
1141
+ setUserReviewStatus(context, undefined);
972
1142
  }
973
- if (decision.kind === "allow") return { kind: "allow" };
974
- return {
975
- kind: "deny",
976
- reason: `${decision.review.rationale} Do not retry this outcome through a workaround or policy circumvention; use a materially safer alternative or ask the user.`,
977
- };
978
1143
  },
979
1144
  );
980
1145
  } catch (error) {
@@ -987,6 +1152,7 @@ export function createPiAutoReviewExtension(
987
1152
  });
988
1153
 
989
1154
  pi.on("session_shutdown", () => {
1155
+ setUserReviewStatus(context, undefined);
990
1156
  disposeAuthorizer?.();
991
1157
  disposeAuthorizer = undefined;
992
1158
  disposeBrokerService?.();
@@ -0,0 +1,140 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const USER_REVIEW_STATUS_KEY = "pi-auto-review";
4
+
5
+ export type UserReviewOutcome =
6
+ | "allow"
7
+ | "deny"
8
+ | "defer"
9
+ | "auto_confirm"
10
+ | "needs_confirmation"
11
+ | "circuit_breaker"
12
+ | "unavailable";
13
+
14
+ export type UserReviewNotice = {
15
+ message: string;
16
+ type: "info" | "warning" | "error";
17
+ };
18
+
19
+ /** Collapse whitespace and bound length for status/notify text. */
20
+ export function compactReviewText(value: unknown, max = 90): string {
21
+ return String(value ?? "")
22
+ .replace(/\s+/g, " ")
23
+ .trim()
24
+ .slice(0, max);
25
+ }
26
+
27
+ export function reviewTargetFromRequest(request: {
28
+ resolvedPath?: string;
29
+ path?: string;
30
+ destination?: string;
31
+ command?: string;
32
+ toolName?: string;
33
+ skillName?: string;
34
+ operation?: string;
35
+ }): string | undefined {
36
+ const target =
37
+ request.resolvedPath ??
38
+ request.path ??
39
+ request.destination ??
40
+ request.command ??
41
+ request.toolName ??
42
+ request.skillName ??
43
+ request.operation;
44
+ const compact = compactReviewText(target, 90);
45
+ return compact || undefined;
46
+ }
47
+
48
+ /** Footer/status bar text while the model review is in flight. */
49
+ export function buildUserReviewStatus(
50
+ surface: string,
51
+ target?: string,
52
+ ): string {
53
+ const compact = compactReviewText(target, 48);
54
+ return compact
55
+ ? `auto-review: reviewing ${surface} · ${compact}`
56
+ : `auto-review: reviewing ${surface}`;
57
+ }
58
+
59
+ /**
60
+ * User-facing (not agent-facing) notice after a review decision.
61
+ * Kept short so TUI toasts remain readable.
62
+ */
63
+ export function buildUserReviewNotice(input: {
64
+ outcome: UserReviewOutcome;
65
+ surface: string;
66
+ target?: string;
67
+ rationale?: string;
68
+ }): UserReviewNotice {
69
+ const target = compactReviewText(input.target, 70);
70
+ const rationale = compactReviewText(input.rationale, 120);
71
+ const subject = target ? `${input.surface} · ${target}` : input.surface;
72
+ const why = rationale ? ` — ${rationale}` : "";
73
+
74
+ switch (input.outcome) {
75
+ case "allow":
76
+ return {
77
+ type: "info",
78
+ message: `Auto-review allowed ${subject}${why}`,
79
+ };
80
+ case "auto_confirm":
81
+ return {
82
+ type: "info",
83
+ message: `Auto-review allowed ${subject}; auto-confirming the local dialog${why}`,
84
+ };
85
+ case "needs_confirmation":
86
+ return {
87
+ type: "info",
88
+ message: `Auto-review allowed ${subject}; local confirmation is still required${why}`,
89
+ };
90
+ case "defer":
91
+ return {
92
+ type: "info",
93
+ message: `Auto-review deferred ${subject} to you${why}`,
94
+ };
95
+ case "deny":
96
+ return {
97
+ type: "warning",
98
+ message: `Auto-review denied ${subject}${why}`,
99
+ };
100
+ case "circuit_breaker":
101
+ return {
102
+ type: "warning",
103
+ message: `Auto-review stopped after repeated denials this turn${
104
+ why || " — use /approve for one exact retry"
105
+ }`,
106
+ };
107
+ case "unavailable":
108
+ return {
109
+ type: "error",
110
+ message: `Auto-review is unavailable${why}`,
111
+ };
112
+ }
113
+ }
114
+
115
+ /** Best-effort toast; never throws into the authorizer path. */
116
+ export function notifyUserReview(
117
+ ctx: ExtensionContext | undefined,
118
+ notice: UserReviewNotice,
119
+ ): void {
120
+ if (!ctx?.hasUI) return;
121
+ try {
122
+ ctx.ui.notify(notice.message, notice.type);
123
+ } catch {
124
+ // UI delivery is observational and must not change a decision.
125
+ }
126
+ }
127
+
128
+ /** Best-effort footer status; pass undefined to clear. */
129
+ export function setUserReviewStatus(
130
+ ctx: ExtensionContext | undefined,
131
+ text: string | undefined,
132
+ key = USER_REVIEW_STATUS_KEY,
133
+ ): void {
134
+ if (!ctx?.hasUI) return;
135
+ try {
136
+ ctx.ui.setStatus?.(key, text);
137
+ } catch {
138
+ // UI delivery is observational and must not change a decision.
139
+ }
140
+ }
@@ -1,13 +0,0 @@
1
- {
2
- "model": "cliproxyapi/codex-auto-review",
3
- "reasoning": "medium",
4
- "timeoutMs": 45000,
5
- "maxTokens": 1600,
6
- "retries": 1,
7
- "maxUserTranscriptTokens": 1200,
8
- "maxToolTranscriptTokens": 1200,
9
- "maxRelevantResultTokens": 800,
10
- "failureMode": "deny",
11
- "grantTtlMs": 60000,
12
- "autoConfirmBoundedAllows": ["external_directory"]
13
- }