@erichll/pi-auto-review 0.8.2 → 0.10.0

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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 - 2026-08-22
4
+
5
+ - Replace the review footer and newly appended transcript entries with one
6
+ above-editor `pi-auto-review` widget. Each permission check independently
7
+ replaces the previous display, shows the configured reviewer model while it
8
+ runs, and updates in place with its final outcome and metadata. The latest
9
+ result remains until the next check and is cleared on session change or
10
+ shutdown.
11
+ - Prevent late concurrent completions and permission decisions from overwriting
12
+ a newer check. A current local denial adds `Local confirmation · denied` to
13
+ that result only. Widget failures fall back to notifications without changing
14
+ authorization behavior.
15
+ - Remove tool-call grouping, timers, capacity eviction, and tool/turn flush
16
+ wiring for new feedback while retaining the historical entry renderer. Bound
17
+ target and rationale text with visible ellipses, preserve width-aware wrapping,
18
+ and keep token, duration, and call-count numbers in the normal metadata color.
19
+
20
+ ## 0.9.0 - 2026-08-22
21
+
22
+ - Require `@gotgenes/pi-permission-system` `^27.0.0` and register through the
23
+ session-keyed `getPermissionsService(sessionId)` locator. Repeated ready
24
+ broadcasts are idempotent; session changes, shutdown, and late asynchronous
25
+ imports cannot retain or revive a stale authorizer registration. In-process
26
+ child nodes register against their own service without replacing the
27
+ parent's process-local broker capability.
28
+ - Group local TUI review results by `(sessionId, toolCallId)` into one
29
+ transcript entry while preserving independent asks, model calls, verdicts,
30
+ grants, confirmations, and audit records. Denials flush immediately; tool
31
+ start, terminal permission denial, turn/agent/session end, a 60-second TTL,
32
+ and bounded-capacity eviction provide lossless fallback flushes. Forwarded,
33
+ uncorrelated, and non-TUI reviews remain immediate.
34
+ - Add `toolCallId` to each `pi_auto_review_decision` audit entry and retain
35
+ compatibility rendering for historical single-result transcript entries.
36
+ - Remove the review entry's left quote bar and combine each target with its
37
+ rationale and member surface/outcome on one line, keeping model/token/duration
38
+ metadata separate.
39
+ - Label every displayed model token count with `toks` in both grouped and
40
+ single-result output.
41
+
3
42
  ## 0.8.2 - 2026-08-21
4
43
 
5
44
  - Structure interactive review results as headline, target, rationale, and a
package/README.md CHANGED
@@ -16,7 +16,7 @@ that dependency is a hard prerequisite (see [Install and enable](#install-and-en
16
16
  > **Prerequisite:** pi-auto-review is an authorizer inside
17
17
  > `@gotgenes/pi-permission-system`. Pi does not auto-install peer packages, so
18
18
  > install the permission system separately (once per machine) before this
19
- > extension:
19
+ > extension. Version 0.10.0 requires permission-system 27.x:
20
20
 
21
21
  ```bash
22
22
  pi install npm:@gotgenes/pi-permission-system
@@ -49,7 +49,7 @@ adapters must consume the exact grant before retrying an operation. Changing
49
49
  the command, path, resolved path, destination, cwd, agent, or tool input
50
50
  invalidates that grant.
51
51
 
52
- Permission-system v24 downgrades authorizer allows on `path` and
52
+ Permission-system downgrades authorizer allows on `path` and
53
53
  `external_directory` to `defer`. In an interactive TUI,
54
54
  `autoConfirmBoundedAllows` can bind the exact model allow to the immediately
55
55
  following recognized permission dialog. The bridge is request-ID-bound,
@@ -132,13 +132,20 @@ only a fixed, budget-checked schema correction.
132
132
 
133
133
  ## Operator feedback and exact retry
134
134
 
135
- Interactive sessions show a best-effort footer while review is running and a
136
- structured result row for allow, local confirmation, defer, deny, or circuit
137
- breaker outcomes. In the TUI this is a quote-style entry (`│`, muted quote
138
- color) that separates the outcome and surface from the target and rationale,
139
- then shows the reviewer model name (without provider prefix), input/output
140
- tokens, and duration when a model call ran. UI delivery never changes the
141
- authorization result.
135
+ Interactive sessions show the current permission check in a single widget
136
+ above the editor. Each check first shows its surface, compact target, and the
137
+ dynamically configured reviewer model, then replaces that content in place
138
+ with the outcome, target and rationale, model, token usage, duration, and any
139
+ extra call count. A new check replaces the previous result; the latest result
140
+ remains visible until then and is cleared when the session changes or shuts
141
+ down. Concurrent older checks cannot overwrite the most recently started one.
142
+
143
+ Every request still has its own model call, verdict, grant, local confirmation,
144
+ and audit record. No new review-result transcript entries are written. Existing
145
+ `pi-auto-review` entries in historical sessions remain renderable. Non-TUI
146
+ modes retain best-effort notifications, and a failed TUI widget update falls
147
+ back to the same notification path. UI delivery never changes the authorization
148
+ result.
142
149
 
143
150
  In an interactive TUI, `/auto-review-approve` lists up to ten recent
144
151
  non-critical model denials from the current session. Selecting one asks the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.8.2",
3
+ "version": "0.10.0",
4
4
  "description": "Model-backed boundary approval broker for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,12 +49,12 @@
49
49
  "peerDependencies": {
50
50
  "@earendil-works/pi-ai": "^0.84.1",
51
51
  "@earendil-works/pi-coding-agent": "^0.84.1",
52
- "@gotgenes/pi-permission-system": "^26.0.0"
52
+ "@gotgenes/pi-permission-system": "^27.0.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@earendil-works/pi-ai": "^0.84.1",
56
56
  "@earendil-works/pi-coding-agent": "^0.84.1",
57
- "@gotgenes/pi-permission-system": "^26.0.0",
57
+ "@gotgenes/pi-permission-system": "^27.0.0",
58
58
  "@types/node": "^26.2.0",
59
59
  "typescript": "^6.0.3"
60
60
  }
package/src/index.ts CHANGED
@@ -11,11 +11,12 @@ import {
11
11
  completeSimple,
12
12
  type ApiStreamSimpleFunction,
13
13
  } from "@earendil-works/pi-ai/compat";
14
- import type {
15
- AuthorizerLog,
16
- AuthorizerVerdict,
17
- PermissionQuery,
18
- PromptPermissionDetails,
14
+ import {
15
+ getPermissionsService,
16
+ type AuthorizerLog,
17
+ type AuthorizerVerdict,
18
+ type PermissionQuery,
19
+ type PromptPermissionDetails,
19
20
  } from "@gotgenes/pi-permission-system";
20
21
  import {
21
22
  buildClassifierTranscript,
@@ -37,14 +38,13 @@ import {
37
38
  } from "./broker/index.ts";
38
39
  import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
39
40
  import {
40
- buildUserReviewEntryData,
41
41
  buildUserReviewNotice,
42
42
  buildUserReviewStatus,
43
+ buildUserReviewWidgetData,
43
44
  notifyUserReview,
44
- presentUserReview,
45
45
  renderUserReviewEntry,
46
46
  reviewTargetFromRequest,
47
- setUserReviewStatus,
47
+ UserReviewWidgetController,
48
48
  USER_REVIEW_ENTRY_TYPE,
49
49
  type UserReviewOutcome,
50
50
  type UserReviewUsage,
@@ -251,6 +251,7 @@ type ReviewResult = {
251
251
  durationMs: number;
252
252
  transcript: TranscriptResult;
253
253
  summary: ReviewExecutionSummary;
254
+ unavailable?: boolean;
254
255
  };
255
256
 
256
257
  class ReviewExecutionError extends Error {
@@ -273,9 +274,6 @@ const USER_CONFIG_RELATIVE_PATH = join(
273
274
  "pi-auto-review",
274
275
  "config.json",
275
276
  );
276
- const PERMISSIONS_SERVICE_KEY = Symbol.for(
277
- "@gotgenes/pi-permission-system:service",
278
- );
279
277
  const BOUNDED_SURFACES = new Set(["path", "external_directory"]);
280
278
  const REVIEWER_FRAMING_RESERVE_TOKENS = 64;
281
279
  const REVIEWER_RETRY_DELAY_MS = 250;
@@ -728,12 +726,6 @@ type PermissionsService = {
728
726
  ): () => void;
729
727
  };
730
728
 
731
- function permissionsService(): PermissionsService | undefined {
732
- return (globalThis as Record<symbol, unknown>)[
733
- PERMISSIONS_SERVICE_KEY
734
- ] as PermissionsService | undefined;
735
- }
736
-
737
729
  function boundaryRequest(
738
730
  ctx: ExtensionContext,
739
731
  details: PromptPermissionDetails,
@@ -1925,6 +1917,9 @@ export function createPiAutoReviewExtension(
1925
1917
  let context: ExtensionContext | undefined;
1926
1918
  let config: Readonly<Config> = trustedConfig;
1927
1919
  let disposeAuthorizer: (() => void) | undefined;
1920
+ let registeredSessionId: string | undefined;
1921
+ let registrationEpoch = 0;
1922
+ let shuttingDown = false;
1928
1923
  let disposeBrokerService: (() => void) | undefined;
1929
1924
  const reviewResults = new Map<string, ReviewResult>();
1930
1925
  const telemetryCompleted = new Set<string>();
@@ -1935,6 +1930,7 @@ export function createPiAutoReviewExtension(
1935
1930
  const uiAutoConfirmer = new PermissionUiAutoConfirmer(
1936
1931
  () => config.autoConfirmBoundedAllows,
1937
1932
  );
1933
+ const reviewWidget = new UserReviewWidgetController();
1938
1934
 
1939
1935
  const emitTelemetry = (event: ReviewerTelemetryEvent): void => {
1940
1936
  writeOptionalAuditFile(event);
@@ -1990,6 +1986,7 @@ export function createPiAutoReviewExtension(
1990
1986
  durationMs: execution.summary.durationMs,
1991
1987
  transcript: execution.summary.transcript,
1992
1988
  summary: execution.summary,
1989
+ unavailable: true,
1993
1990
  });
1994
1991
  }
1995
1992
  emitTelemetry(
@@ -2267,6 +2264,12 @@ export function createPiAutoReviewExtension(
2267
2264
  });
2268
2265
 
2269
2266
  pi.on("session_start", (_event, ctx) => {
2267
+ shuttingDown = false;
2268
+ registrationEpoch++;
2269
+ disposeAuthorizer?.();
2270
+ disposeAuthorizer = undefined;
2271
+ registeredSessionId = undefined;
2272
+ reviewWidget.clear(context ?? ctx);
2270
2273
  disposeBrokerService?.();
2271
2274
  broker?.clear();
2272
2275
  reviewResults.clear();
@@ -2280,7 +2283,17 @@ export function createPiAutoReviewExtension(
2280
2283
  );
2281
2284
  context = ctx;
2282
2285
  broker = createBroker();
2283
- disposeBrokerService = publishBoundaryBroker(broker);
2286
+ try {
2287
+ disposeBrokerService = publishBoundaryBroker(broker);
2288
+ } catch (error) {
2289
+ if (!(error instanceof Error) ||
2290
+ error.message !== "pi-auto-review boundary broker is already published") {
2291
+ throw error;
2292
+ }
2293
+ // In-process child nodes still need their own reviewer/authorizer.
2294
+ // The process-global broker capability remains owned by the parent.
2295
+ disposeBrokerService = undefined;
2296
+ }
2284
2297
  } catch (error) {
2285
2298
  context = undefined;
2286
2299
  broker = undefined;
@@ -2300,12 +2313,46 @@ export function createPiAutoReviewExtension(
2300
2313
  if (context) uiAutoConfirmer.handlePrompt(event, context);
2301
2314
  });
2302
2315
 
2303
- pi.events.on("permissions:ready", () => {
2304
- try {
2305
- const service = permissionsService();
2306
- if (!service) return;
2316
+ pi.events.on("permissions:decision", (event) => {
2317
+ reviewWidget.permissionDecision(event);
2318
+ });
2319
+
2320
+ pi.events.on("permissions:ready", (event) => {
2321
+ const ready = event && typeof event === "object" && !Array.isArray(event)
2322
+ ? event as Record<string, unknown>
2323
+ : undefined;
2324
+ const sessionId = typeof ready?.sessionId === "string" &&
2325
+ ready.sessionId.trim()
2326
+ ? ready.sessionId
2327
+ : undefined;
2328
+ if (!sessionId) {
2329
+ console.error(
2330
+ `${EXTENSION_NAME}: ignored permissions:ready without a session id`,
2331
+ );
2332
+ return;
2333
+ }
2334
+ if (registeredSessionId === sessionId && disposeAuthorizer) return;
2335
+
2336
+ const epoch = ++registrationEpoch;
2337
+ if (registeredSessionId && registeredSessionId !== sessionId) {
2307
2338
  disposeAuthorizer?.();
2308
- disposeAuthorizer = service.registerAuthorizer(
2339
+ disposeAuthorizer = undefined;
2340
+ registeredSessionId = undefined;
2341
+ }
2342
+
2343
+ if (shuttingDown || epoch !== registrationEpoch || !context) return;
2344
+ const service = getPermissionsService(sessionId) as
2345
+ | PermissionsService
2346
+ | undefined;
2347
+ if (!service) {
2348
+ console.error(
2349
+ `${EXTENSION_NAME}: permissions service unavailable for session ${sessionId}`,
2350
+ );
2351
+ return;
2352
+ }
2353
+ let dispose: (() => void) | undefined;
2354
+ try {
2355
+ dispose = service.registerAuthorizer(
2309
2356
  EXTENSION_NAME,
2310
2357
  async (details, query, log: AuthorizerLog) => {
2311
2358
  const evidence = normalizePermissionEvidence(details);
@@ -2322,133 +2369,140 @@ export function createPiAutoReviewExtension(
2322
2369
  surface,
2323
2370
  rationale: reason,
2324
2371
  };
2325
- presentUserReview(
2326
- pi,
2327
- context,
2328
- buildUserReviewNotice(unavailable),
2329
- buildUserReviewEntryData(unavailable),
2330
- );
2372
+ notifyUserReview(context, buildUserReviewNotice(unavailable));
2331
2373
  return { kind: "deny", reason };
2332
2374
  }
2333
2375
 
2334
2376
  const request = boundaryRequest(context, details, query);
2335
2377
  const target = reviewTargetFromRequest(request);
2336
- setUserReviewStatus(
2337
- context,
2338
- buildUserReviewStatus(surface, target),
2378
+ const reviewContext = context;
2379
+ const widgetGeneration = reviewWidget.begin(request.id, reviewContext, {
2380
+ surface,
2381
+ target,
2382
+ model: config.model,
2383
+ });
2384
+ const decision = await broker.review(request, {
2385
+ sessionId: reviewContext.sessionManager.getSessionId(),
2386
+ scopeKey: currentTurnScope(reviewContext),
2387
+ issueGrant: false,
2388
+ });
2389
+ const result = reviewResults.get(request.id);
2390
+ reviewResults.delete(request.id);
2391
+ const allowCapped =
2392
+ decision.kind === "allow" && boundedRequest(surface);
2393
+ const autoConfirmQueued =
2394
+ allowCapped &&
2395
+ reviewContext.mode === "tui" &&
2396
+ reviewContext.hasUI &&
2397
+ uiAutoConfirmer.stage(request.id, surface);
2398
+
2399
+ let userOutcome: UserReviewOutcome;
2400
+ if (decision.kind === "deny" && decision.circuitBreakerTripped) {
2401
+ userOutcome = "circuit_breaker";
2402
+ } else if (allowCapped && autoConfirmQueued) {
2403
+ userOutcome = "auto_confirm";
2404
+ } else if (allowCapped) {
2405
+ userOutcome = "needs_confirmation";
2406
+ } else if (decision.kind === "allow") {
2407
+ userOutcome = "allow";
2408
+ } else if (decision.kind === "defer") {
2409
+ userOutcome = "defer";
2410
+ } else {
2411
+ userOutcome = "deny";
2412
+ }
2413
+ const reviewMeta = userReviewMetaFromResult(result, config.model);
2414
+ const noticeInput = {
2415
+ outcome: result?.unavailable ? "unavailable" as const : userOutcome,
2416
+ surface,
2417
+ target,
2418
+ rationale: decision.review.rationale,
2419
+ recoveryCommand:
2420
+ decision.kind === "deny"
2421
+ ? decision.recoveryCommand
2422
+ : undefined,
2423
+ ...reviewMeta,
2424
+ };
2425
+ const notice = buildUserReviewNotice(noticeInput);
2426
+ reviewWidget.complete(
2427
+ request.id,
2428
+ widgetGeneration,
2429
+ reviewContext,
2430
+ notice,
2431
+ buildUserReviewWidgetData(noticeInput),
2339
2432
  );
2340
- try {
2341
- const decision = await broker.review(request, {
2342
- sessionId: context.sessionManager.getSessionId(),
2343
- scopeKey: currentTurnScope(context),
2344
- issueGrant: false,
2345
- });
2346
- const result = reviewResults.get(request.id);
2347
- reviewResults.delete(request.id);
2348
- const allowCapped =
2349
- decision.kind === "allow" && boundedRequest(surface);
2350
- const autoConfirmQueued =
2351
- allowCapped &&
2352
- context.mode === "tui" &&
2353
- context.hasUI &&
2354
- uiAutoConfirmer.stage(request.id, surface);
2355
-
2356
- let userOutcome: UserReviewOutcome;
2357
- if (decision.kind === "deny" && decision.circuitBreakerTripped) {
2358
- userOutcome = "circuit_breaker";
2359
- } else if (allowCapped && autoConfirmQueued) {
2360
- userOutcome = "auto_confirm";
2361
- } else if (allowCapped) {
2362
- userOutcome = "needs_confirmation";
2363
- } else if (decision.kind === "allow") {
2364
- userOutcome = "allow";
2365
- } else if (decision.kind === "defer") {
2366
- userOutcome = "defer";
2367
- } else {
2368
- userOutcome = "deny";
2369
- }
2370
- const reviewMeta = userReviewMetaFromResult(result, config.model);
2371
- const noticeInput = {
2372
- outcome: userOutcome,
2373
- surface,
2374
- target,
2375
- rationale: decision.review.rationale,
2376
- recoveryCommand:
2377
- decision.kind === "deny"
2378
- ? decision.recoveryCommand
2379
- : undefined,
2380
- ...reviewMeta,
2381
- };
2382
- presentUserReview(
2383
- pi,
2384
- context,
2385
- buildUserReviewNotice(noticeInput),
2386
- buildUserReviewEntryData(noticeInput),
2387
- );
2388
-
2389
- log.review("pi_auto_review_decision", {
2390
- requestId: request.id,
2391
- surface,
2392
- model: config.model,
2393
- reviewerModel: reviewMeta.model,
2394
- outcome: allowCapped ? "defer" : decision.kind,
2395
- reviewerOutcome: decision.review.outcome,
2396
- riskLevel: decision.review.riskLevel,
2397
- userAuthorization: decision.review.userAuthorization,
2398
- rationale: decision.review.rationale,
2399
- allowCapped,
2400
- autoConfirmQueued,
2401
- userOutcome,
2402
- circuitBreakerTripped:
2403
- decision.kind === "deny"
2404
- ? decision.circuitBreakerTripped
2405
- : false,
2406
- attempts: result?.attempts ?? 0,
2407
- retryErrors: result?.retryErrors ?? [],
2408
- durationMs: result?.durationMs,
2409
- usageAvailability: reviewMeta.usage?.availability,
2410
- usage: reviewMeta.usage,
2411
- transcriptUserCharacters: result?.transcript.userCharacters,
2412
- transcriptToolCharacters: result?.transcript.toolCharacters,
2413
- transcriptRelevantResultCharacters:
2414
- result?.transcript.relevantResultCharacters,
2415
- transcriptTruncated: result?.transcript.truncated,
2416
- command: request.command,
2417
- path: request.path,
2418
- resolvedPath: request.resolvedPath,
2419
- destination: request.destination,
2420
- agentName: request.agentName,
2421
- requesterSessionId: request.requesterSessionId,
2422
- accessIntent: request.accessIntent,
2423
- authorization:
2424
- decision.kind === "allow"
2425
- ? decision.authorization
2426
- : undefined,
2427
- });
2428
2433
 
2429
- if (allowCapped || decision.kind === "defer") {
2430
- return { kind: "defer" };
2431
- }
2432
- if (decision.kind === "allow") return { kind: "allow" };
2433
- const denyInstruction =
2434
- decision.denialSource === "hard-deny"
2435
- ? LOCAL_HARD_DENY_AGENT_INSTRUCTION
2436
- : decision.recoveryCommand === "/auto-review-break-glass"
2437
- ? REVIEWER_CRITICAL_DENY_AGENT_INSTRUCTION
2438
- : decision.recoveryCommand === "/auto-review-approve"
2439
- ? REVIEWER_NONCRITICAL_DENY_AGENT_INSTRUCTION
2440
- : "Automatic policy critically denied this action and break-glass authorization is disabled. Do not retry, rephrase, or circumvent it.";
2441
- return {
2442
- kind: "deny",
2443
- reason: `${decision.review.rationale} ${denyInstruction}`,
2444
- };
2445
- } finally {
2446
- setUserReviewStatus(context, undefined);
2434
+ log.review("pi_auto_review_decision", {
2435
+ requestId: request.id,
2436
+ toolCallId: request.toolCallId,
2437
+ surface,
2438
+ model: config.model,
2439
+ reviewerModel: reviewMeta.model,
2440
+ outcome: allowCapped ? "defer" : decision.kind,
2441
+ reviewerOutcome: decision.review.outcome,
2442
+ riskLevel: decision.review.riskLevel,
2443
+ userAuthorization: decision.review.userAuthorization,
2444
+ rationale: decision.review.rationale,
2445
+ allowCapped,
2446
+ autoConfirmQueued,
2447
+ userOutcome,
2448
+ circuitBreakerTripped:
2449
+ decision.kind === "deny"
2450
+ ? decision.circuitBreakerTripped
2451
+ : false,
2452
+ attempts: result?.attempts ?? 0,
2453
+ retryErrors: result?.retryErrors ?? [],
2454
+ durationMs: result?.durationMs,
2455
+ usageAvailability: reviewMeta.usage?.availability,
2456
+ usage: reviewMeta.usage,
2457
+ transcriptUserCharacters: result?.transcript.userCharacters,
2458
+ transcriptToolCharacters: result?.transcript.toolCharacters,
2459
+ transcriptRelevantResultCharacters:
2460
+ result?.transcript.relevantResultCharacters,
2461
+ transcriptTruncated: result?.transcript.truncated,
2462
+ command: request.command,
2463
+ path: request.path,
2464
+ resolvedPath: request.resolvedPath,
2465
+ destination: request.destination,
2466
+ agentName: request.agentName,
2467
+ requesterSessionId: request.requesterSessionId,
2468
+ accessIntent: request.accessIntent,
2469
+ authorization:
2470
+ decision.kind === "allow"
2471
+ ? decision.authorization
2472
+ : undefined,
2473
+ });
2474
+
2475
+ if (allowCapped || decision.kind === "defer") {
2476
+ return { kind: "defer" };
2447
2477
  }
2478
+ if (decision.kind === "allow") return { kind: "allow" };
2479
+ const denyInstruction =
2480
+ decision.denialSource === "hard-deny"
2481
+ ? LOCAL_HARD_DENY_AGENT_INSTRUCTION
2482
+ : decision.recoveryCommand === "/auto-review-break-glass"
2483
+ ? REVIEWER_CRITICAL_DENY_AGENT_INSTRUCTION
2484
+ : decision.recoveryCommand === "/auto-review-approve"
2485
+ ? REVIEWER_NONCRITICAL_DENY_AGENT_INSTRUCTION
2486
+ : "Automatic policy critically denied this action and break-glass authorization is disabled. Do not retry, rephrase, or circumvent it.";
2487
+ return {
2488
+ kind: "deny",
2489
+ reason: `${decision.review.rationale} ${denyInstruction}`,
2490
+ };
2448
2491
  },
2449
2492
  );
2450
- writeOptionalAuditFile({ type: "authorizer_registered" });
2493
+ if (shuttingDown || epoch !== registrationEpoch || !context) {
2494
+ dispose();
2495
+ return;
2496
+ }
2497
+ disposeAuthorizer?.();
2498
+ disposeAuthorizer = dispose;
2499
+ registeredSessionId = sessionId;
2500
+ writeOptionalAuditFile({
2501
+ type: "authorizer_registered",
2502
+ sessionId,
2503
+ });
2451
2504
  } catch (error) {
2505
+ dispose?.();
2452
2506
  console.error(
2453
2507
  `${EXTENSION_NAME}: authorizer registration failed: ${
2454
2508
  error instanceof Error ? error.message : String(error)
@@ -2458,9 +2512,12 @@ export function createPiAutoReviewExtension(
2458
2512
  });
2459
2513
 
2460
2514
  pi.on("session_shutdown", () => {
2461
- setUserReviewStatus(context, undefined);
2515
+ shuttingDown = true;
2516
+ registrationEpoch++;
2517
+ reviewWidget.clear(context);
2462
2518
  disposeAuthorizer?.();
2463
2519
  disposeAuthorizer = undefined;
2520
+ registeredSessionId = undefined;
2464
2521
  disposeBrokerService?.();
2465
2522
  disposeBrokerService = undefined;
2466
2523
  broker?.clear();
@@ -1,10 +1,8 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
2
 
6
3
  export const USER_REVIEW_STATUS_KEY = "pi-auto-review";
7
4
  export const USER_REVIEW_ENTRY_TYPE = "pi-auto-review";
5
+ export const USER_REVIEW_WIDGET_KEY = "pi-auto-review";
8
6
 
9
7
  export type UserReviewOutcome =
10
8
  | "allow"
@@ -45,6 +43,14 @@ export function compactReviewText(value: unknown, max = 90): string {
45
43
  .slice(0, max);
46
44
  }
47
45
 
46
+ /** Collapse whitespace and visibly truncate bounded widget text. */
47
+ export function truncateReviewText(value: unknown, max: number): string {
48
+ const compact = String(value ?? "").replace(/\s+/g, " ").trim();
49
+ if (compact.length <= max) return compact;
50
+ if (max <= 1) return "…".slice(0, max);
51
+ return `${compact.slice(0, max - 1).trimEnd()}…`;
52
+ }
53
+
48
54
  export function reviewTargetFromRequest(request: {
49
55
  resolvedPath?: string;
50
56
  path?: string;
@@ -62,7 +68,7 @@ export function reviewTargetFromRequest(request: {
62
68
  request.toolName ??
63
69
  request.skillName ??
64
70
  request.operation;
65
- const compact = compactReviewText(target, 100);
71
+ const compact = truncateReviewText(target, 100);
66
72
  return compact || undefined;
67
73
  }
68
74
 
@@ -101,15 +107,15 @@ export function formatReviewUsage(usage: UserReviewUsage): string | undefined {
101
107
  if (input !== undefined) {
102
108
  const cache =
103
109
  usage.cacheRead && usage.cacheRead > 0
104
- ? ` (${formatReviewTokenCount(usage.cacheRead)} cache)`
110
+ ? ` (${formatReviewTokenCount(usage.cacheRead)} toks cache)`
105
111
  : "";
106
- parts.push(`${formatReviewTokenCount(input)} in${cache}`);
112
+ parts.push(`${formatReviewTokenCount(input)} toks in${cache}`);
107
113
  }
108
114
  if (usage.output !== undefined) {
109
- parts.push(`${formatReviewTokenCount(usage.output)} out`);
115
+ parts.push(`${formatReviewTokenCount(usage.output)} toks out`);
110
116
  }
111
117
  if (parts.length === 0 && usage.totalTokens !== undefined) {
112
- parts.push(`${formatReviewTokenCount(usage.totalTokens)} tok`);
118
+ parts.push(`${formatReviewTokenCount(usage.totalTokens)} toks`);
113
119
  }
114
120
  if (parts.length === 0) return undefined;
115
121
  const approx = usage.availability === "estimated" ? "~" : "";
@@ -193,6 +199,16 @@ function joinNoticeLines(lines: Array<string | undefined>): string[] {
193
199
  .filter((line) => line.length > 0);
194
200
  }
195
201
 
202
+ function reviewTargetRationaleLine(input: {
203
+ target?: string;
204
+ rationale?: string;
205
+ }): string | undefined {
206
+ const target = truncateReviewText(input.target, 100);
207
+ const rationale = truncateReviewText(input.rationale, 180);
208
+ if (target && rationale) return `${target} · ${rationale}`;
209
+ return target || rationale || undefined;
210
+ }
211
+
196
212
  export type UserReviewNoticeInput = {
197
213
  outcome: UserReviewOutcome;
198
214
  surface: string;
@@ -211,6 +227,24 @@ export type UserReviewEntryData = {
211
227
  lines: string[];
212
228
  };
213
229
 
230
+ export type UserReviewGroupMember = UserReviewNoticeInput & {
231
+ requestId: string;
232
+ permissionResult?: "allow" | "deny";
233
+ };
234
+
235
+ export type UserReviewGroupEntryData = {
236
+ kind: "group";
237
+ type: UserReviewNotice["type"];
238
+ sessionId: string;
239
+ toolCallId: string;
240
+ fullCommand?: string;
241
+ members: UserReviewGroupMember[];
242
+ };
243
+
244
+ export type AnyUserReviewEntryData =
245
+ | UserReviewEntryData
246
+ | UserReviewGroupEntryData;
247
+
214
248
  type UserReviewTheme = {
215
249
  fg(color: string, text: string): string;
216
250
  italic(text: string): string;
@@ -258,11 +292,21 @@ function outcomeVerb(outcome: UserReviewOutcome): string {
258
292
  }
259
293
  }
260
294
 
295
+ function memberOutcomeText(outcome: UserReviewOutcome): string {
296
+ switch (outcome) {
297
+ case "auto_confirm":
298
+ return "allowed · auto-confirm";
299
+ case "needs_confirmation":
300
+ return "allowed · confirm locally";
301
+ default:
302
+ return outcomeVerb(outcome);
303
+ }
304
+ }
305
+
261
306
  export function buildUserReviewLines(input: UserReviewNoticeInput): string[] {
262
307
  return joinNoticeLines([
263
308
  reviewHeadline(input.outcome, input.surface),
264
- compactReviewText(input.target, 100) || undefined,
265
- compactReviewText(input.rationale, 180) || undefined,
309
+ reviewTargetRationaleLine(input),
266
310
  recoveryLine(input.outcome, input.recoveryCommand),
267
311
  formatReviewMeta({
268
312
  model: input.model,
@@ -275,7 +319,7 @@ export function buildUserReviewLines(input: UserReviewNoticeInput): string[] {
275
319
 
276
320
  /**
277
321
  * User-facing (not agent-facing) notice after a review decision.
278
- * Structured for TUI wrapping: headline, target, rationale, then model/tokens.
322
+ * Structured for TUI wrapping: headline, target+rationale, then model/tokens.
279
323
  */
280
324
  export function buildUserReviewNotice(
281
325
  input: UserReviewNoticeInput,
@@ -297,10 +341,7 @@ export function buildUserReviewEntryData(
297
341
  }
298
342
 
299
343
  export function formatUserReviewQuoteMessage(message: string): string {
300
- return message
301
- .split("\n")
302
- .map((line) => `│ ${line}`)
303
- .join("\n");
344
+ return message;
304
345
  }
305
346
 
306
347
  function displayWidth(text: string): number {
@@ -362,11 +403,9 @@ export function renderUserReviewQuoteLines(
362
403
  theme: UserReviewTheme,
363
404
  width: number,
364
405
  ): string[] {
365
- const contentWidth = Math.max(1, width - 2);
366
- const border = theme.fg("mdQuoteBorder", "│ ");
406
+ const contentWidth = Math.max(1, width);
367
407
  const verb = outcomeVerb(data.outcome);
368
408
  const verbAnsi = theme.getFgAnsi(outcomeAccent(data.outcome));
369
- const numberAnsi = theme.getFgAnsi("success");
370
409
  const rendered: string[] = [];
371
410
  for (const [index, line] of data.lines.entries()) {
372
411
  for (const visual of wrapReviewDisplayText(line, contentWidth)) {
@@ -377,24 +416,100 @@ export function renderUserReviewQuoteLines(
377
416
  `${verbAnsi}${verb}\x1b[0m`,
378
417
  );
379
418
  }
380
- if (index === data.lines.length - 1) {
381
- painted = painted.replace(
382
- /(\d+(?:\.\d+)?[kM]?)(?=\s(?:in|out|tok|cache|calls)|ms\b|s\b)/g,
383
- `${numberAnsi}$1\x1b[0m`,
384
- );
385
- }
386
- rendered.push(border + withQuoteStyle(theme, painted));
419
+ rendered.push(withQuoteStyle(theme, painted));
387
420
  }
388
421
  }
389
422
  return rendered;
390
423
  }
391
424
 
425
+ function groupOutcome(members: readonly UserReviewGroupMember[]): UserReviewOutcome {
426
+ if (members.some((member) => member.permissionResult === "deny")) return "deny";
427
+ for (const outcome of [
428
+ "circuit_breaker",
429
+ "deny",
430
+ "unavailable",
431
+ "defer",
432
+ "needs_confirmation",
433
+ "auto_confirm",
434
+ ] as const) {
435
+ if (members.some((member) => member.outcome === outcome)) return outcome;
436
+ }
437
+ return "allow";
438
+ }
439
+
440
+ function groupHeadline(data: UserReviewGroupEntryData): string {
441
+ const outcome = groupOutcome(data.members);
442
+ const result = outcome === "deny" || outcome === "circuit_breaker" ||
443
+ outcome === "unavailable"
444
+ ? "denied"
445
+ : outcome === "defer" || outcome === "needs_confirmation"
446
+ ? "deferred"
447
+ : "allowed";
448
+ return `Auto-review · ${result} · ${data.members.length} checks`;
449
+ }
450
+
451
+ export function buildUserReviewGroupLines(
452
+ data: UserReviewGroupEntryData,
453
+ ): UserReviewEntryData {
454
+ if (data.members.length === 1) {
455
+ const member = data.members[0];
456
+ const lines = buildUserReviewLines(member);
457
+ if (member.permissionResult === "deny" && member.outcome !== "deny") {
458
+ lines.push("Local confirmation · denied");
459
+ }
460
+ return { outcome: groupOutcome(data.members), type: data.type, lines };
461
+ }
462
+
463
+ const lines: string[] = [groupHeadline(data)];
464
+ const command = String(data.fullCommand ?? "")
465
+ .replace(/\s+/g, " ")
466
+ .trim();
467
+ if (command) lines.push(command);
468
+ for (const member of data.members) {
469
+ const summary = joinNoticeLines([
470
+ member.surface,
471
+ memberOutcomeText(member.outcome),
472
+ reviewTargetRationaleLine(member),
473
+ ]).join(" · ");
474
+ lines.push(
475
+ summary,
476
+ ...joinNoticeLines([
477
+ recoveryLine(member.outcome, member.recoveryCommand),
478
+ member.permissionResult === "deny" && member.outcome !== "deny"
479
+ ? "Local confirmation · denied"
480
+ : undefined,
481
+ formatReviewMeta(member),
482
+ ]),
483
+ );
484
+ }
485
+ return { outcome: groupOutcome(data.members), type: data.type, lines };
486
+ }
487
+
392
488
  export function renderUserReviewEntry(
393
489
  entry: { data?: unknown },
394
490
  _options: unknown,
395
491
  theme: UserReviewTheme,
396
492
  ): { render(width: number): string[]; invalidate(): void } | undefined {
397
493
  const data = entry.data;
494
+ if (
495
+ data &&
496
+ typeof data === "object" &&
497
+ (data as UserReviewGroupEntryData).kind === "group" &&
498
+ Array.isArray((data as UserReviewGroupEntryData).members) &&
499
+ (data as UserReviewGroupEntryData).members.length > 0
500
+ ) {
501
+ const parsed = data as UserReviewGroupEntryData;
502
+ return {
503
+ render(width: number) {
504
+ return renderUserReviewQuoteLines(
505
+ buildUserReviewGroupLines(parsed),
506
+ theme,
507
+ width,
508
+ );
509
+ },
510
+ invalidate() {},
511
+ };
512
+ }
398
513
  if (
399
514
  !data ||
400
515
  typeof data !== "object" ||
@@ -412,20 +527,6 @@ export function renderUserReviewEntry(
412
527
  };
413
528
  }
414
529
 
415
- function presentUserReviewEntry(
416
- pi: ExtensionAPI | undefined,
417
- ctx: ExtensionContext | undefined,
418
- data: UserReviewEntryData,
419
- ): boolean {
420
- if (!pi || !ctx?.hasUI || ctx.mode !== "tui") return false;
421
- try {
422
- pi.appendEntry(USER_REVIEW_ENTRY_TYPE, data);
423
- return true;
424
- } catch {
425
- return false;
426
- }
427
- }
428
-
429
530
  /** Best-effort toast; never throws into the authorizer path. */
430
531
  export function notifyUserReview(
431
532
  ctx: ExtensionContext | undefined,
@@ -439,27 +540,160 @@ export function notifyUserReview(
439
540
  }
440
541
  }
441
542
 
442
- /** TUI quote-style entry when possible; otherwise a quoted notify toast. */
443
- export function presentUserReview(
444
- pi: ExtensionAPI | undefined,
445
- ctx: ExtensionContext | undefined,
446
- notice: UserReviewNotice,
447
- data: UserReviewEntryData,
448
- ): void {
449
- if (presentUserReviewEntry(pi, ctx, data)) return;
450
- notifyUserReview(ctx, notice);
543
+ export type UserReviewWidgetData = UserReviewEntryData & {
544
+ phase: "reviewing" | "complete";
545
+ };
546
+
547
+ export function buildUserReviewingWidgetData(input: {
548
+ surface: string;
549
+ target?: string;
550
+ model?: string;
551
+ }): UserReviewWidgetData {
552
+ const target = truncateReviewText(input.target, 100);
553
+ const model = formatReviewModelName(input.model) ?? "reviewer";
554
+ return {
555
+ phase: "reviewing",
556
+ outcome: "defer",
557
+ type: "info",
558
+ lines: joinNoticeLines([
559
+ `Auto-review · reviewing · ${input.surface}`,
560
+ target,
561
+ `Waiting for ${model}…`,
562
+ ]),
563
+ };
564
+ }
565
+
566
+ export function buildUserReviewWidgetData(
567
+ input: UserReviewNoticeInput,
568
+ ): UserReviewWidgetData {
569
+ return {
570
+ phase: "complete",
571
+ ...buildUserReviewEntryData(input),
572
+ };
573
+ }
574
+
575
+ export function renderUserReviewWidgetLines(
576
+ data: UserReviewWidgetData,
577
+ theme: UserReviewTheme,
578
+ width: number,
579
+ ): string[] {
580
+ const rendered: string[] = [];
581
+ const verb = data.phase === "reviewing"
582
+ ? "reviewing"
583
+ : outcomeVerb(data.outcome);
584
+ const accent = data.phase === "reviewing"
585
+ ? "muted"
586
+ : outcomeAccent(data.outcome);
587
+ const verbAnsi = theme.getFgAnsi(accent);
588
+ const mutedAnsi = theme.getFgAnsi("muted");
589
+ for (const [index, line] of data.lines.entries()) {
590
+ for (const visual of wrapReviewDisplayText(line, Math.max(1, width))) {
591
+ let painted = `${mutedAnsi}${visual}\x1b[0m`;
592
+ if (index === 0) {
593
+ painted = painted.replace(
594
+ verb,
595
+ `${verbAnsi}${verb}\x1b[0m${mutedAnsi}`,
596
+ );
597
+ }
598
+ rendered.push(painted);
599
+ }
600
+ }
601
+ return rendered;
451
602
  }
452
603
 
453
- /** Best-effort footer status; pass undefined to clear. */
454
- export function setUserReviewStatus(
604
+ function setWidget(
455
605
  ctx: ExtensionContext | undefined,
456
- text: string | undefined,
457
- key = USER_REVIEW_STATUS_KEY,
458
- ): void {
459
- if (!ctx?.hasUI) return;
606
+ data: UserReviewWidgetData | undefined,
607
+ ): boolean {
608
+ if (!ctx?.hasUI || ctx.mode !== "tui") return false;
460
609
  try {
461
- ctx.ui.setStatus?.(key, text);
610
+ ctx.ui.setWidget(
611
+ USER_REVIEW_WIDGET_KEY,
612
+ data
613
+ ? (_tui, theme) => ({
614
+ render: (width: number) =>
615
+ renderUserReviewWidgetLines(data, theme, width),
616
+ invalidate() {},
617
+ })
618
+ : undefined,
619
+ { placement: "aboveEditor" },
620
+ );
621
+ return true;
462
622
  } catch {
463
- // UI delivery is observational and must not change a decision.
623
+ return false;
624
+ }
625
+ }
626
+
627
+ /** Owns the single live widget and rejects stale concurrent completions. */
628
+ export class UserReviewWidgetController {
629
+ #generation = 0;
630
+ #current?: {
631
+ requestId: string;
632
+ generation: number;
633
+ ctx: ExtensionContext;
634
+ data?: UserReviewWidgetData;
635
+ notice?: UserReviewNotice;
636
+ };
637
+
638
+ begin(
639
+ requestId: string,
640
+ ctx: ExtensionContext,
641
+ input: { surface: string; target?: string; model?: string },
642
+ ): number {
643
+ const generation = ++this.#generation;
644
+ this.#current = { requestId, generation, ctx };
645
+ setWidget(ctx, buildUserReviewingWidgetData(input));
646
+ return generation;
647
+ }
648
+
649
+ complete(
650
+ requestId: string,
651
+ generation: number,
652
+ ctx: ExtensionContext,
653
+ notice: UserReviewNotice,
654
+ data: UserReviewWidgetData,
655
+ ): void {
656
+ if (!ctx.hasUI || ctx.mode !== "tui") {
657
+ notifyUserReview(ctx, notice);
658
+ return;
659
+ }
660
+ if (!this.#isCurrent(requestId, generation)) return;
661
+ this.#current = { requestId, generation, ctx, notice, data };
662
+ if (!setWidget(ctx, data)) notifyUserReview(ctx, notice);
663
+ }
664
+
665
+ permissionDecision(event: unknown): void {
666
+ if (!event || typeof event !== "object" || Array.isArray(event)) return;
667
+ const record = event as Record<string, unknown>;
668
+ const current = this.#current;
669
+ if (
670
+ record.result !== "deny" ||
671
+ typeof record.requestId !== "string" ||
672
+ record.requestId !== current?.requestId ||
673
+ !current.data ||
674
+ current.data.lines.includes("Local confirmation · denied")
675
+ ) return;
676
+ const data = {
677
+ ...current.data,
678
+ lines: [...current.data.lines, "Local confirmation · denied"],
679
+ };
680
+ const notice = {
681
+ type: current.notice?.type ?? data.type,
682
+ message: data.lines.join("\n"),
683
+ };
684
+ this.#current = { ...current, data, notice };
685
+ if (!setWidget(current.ctx, data)) notifyUserReview(current.ctx, notice);
686
+ }
687
+
688
+ clear(ctx?: ExtensionContext): void {
689
+ ++this.#generation;
690
+ const current = this.#current;
691
+ this.#current = undefined;
692
+ if (current) setWidget(ctx ?? current.ctx, undefined);
693
+ }
694
+
695
+ #isCurrent(requestId: string, generation: number): boolean {
696
+ return this.#current?.requestId === requestId &&
697
+ this.#current.generation === generation;
464
698
  }
465
699
  }