@erichll/pi-auto-review 0.8.2 → 0.9.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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 - 2026-08-22
4
+
5
+ - Require `@gotgenes/pi-permission-system` `^27.0.0` and register through the
6
+ session-keyed `getPermissionsService(sessionId)` locator. Repeated ready
7
+ broadcasts are idempotent; session changes, shutdown, and late asynchronous
8
+ imports cannot retain or revive a stale authorizer registration. In-process
9
+ child nodes register against their own service without replacing the
10
+ parent's process-local broker capability.
11
+ - Group local TUI review results by `(sessionId, toolCallId)` into one
12
+ transcript entry while preserving independent asks, model calls, verdicts,
13
+ grants, confirmations, and audit records. Denials flush immediately; tool
14
+ start, terminal permission denial, turn/agent/session end, a 60-second TTL,
15
+ and bounded-capacity eviction provide lossless fallback flushes. Forwarded,
16
+ uncorrelated, and non-TUI reviews remain immediate.
17
+ - Add `toolCallId` to each `pi_auto_review_decision` audit entry and retain
18
+ compatibility rendering for historical single-result transcript entries.
19
+ - Remove the review entry's left quote bar and combine each target with its
20
+ rationale and member surface/outcome on one line, keeping model/token/duration
21
+ metadata separate.
22
+ - Label every displayed model token count with `toks` in both grouped and
23
+ single-result output.
24
+
3
25
  ## 0.8.2 - 2026-08-21
4
26
 
5
27
  - 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.9.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,16 @@ 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 a best-effort footer while review is running and
136
+ structured results for allow, local confirmation, defer, deny, or circuit
137
+ breaker outcomes. Local permission-system asks that belong to the same tool
138
+ call are grouped into one TUI entry; each request still has its own model call,
139
+ verdict, grant, local confirmation, and audit record. Forwarded asks and
140
+ non-TUI modes remain immediate and ungrouped. Group members retain their own
141
+ surface, outcome, target, rationale, model, token usage, and duration. UI
142
+ entries use unbordered muted text and place each target and rationale together
143
+ on the same line as that member's surface and outcome. UI delivery never
144
+ changes the authorization result.
142
145
 
143
146
  In an interactive TUI, `/auto-review-approve` lists up to ten recent
144
147
  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.9.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
@@ -42,6 +42,7 @@ import {
42
42
  buildUserReviewStatus,
43
43
  notifyUserReview,
44
44
  presentUserReview,
45
+ ReviewEntryBatcher,
45
46
  renderUserReviewEntry,
46
47
  reviewTargetFromRequest,
47
48
  setUserReviewStatus,
@@ -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,10 +726,50 @@ 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;
729
+ function promptPayloadRecord(
730
+ details: PromptPermissionDetails,
731
+ ): Record<string, unknown> | undefined {
732
+ const payload = (details as unknown as Record<string, unknown>).payload;
733
+ return payload && typeof payload === "object" && !Array.isArray(payload)
734
+ ? payload as Record<string, unknown>
735
+ : undefined;
736
+ }
737
+
738
+ function promptIsForwarded(details: PromptPermissionDetails): boolean {
739
+ if ((details as unknown as Record<string, unknown>).forwarding) return true;
740
+ const request = promptPayloadRecord(details)?.request;
741
+ if (!request || typeof request !== "object" || Array.isArray(request)) {
742
+ return false;
743
+ }
744
+ const requester = (request as Record<string, unknown>).requester;
745
+ return Boolean(
746
+ requester && typeof requester === "object" && !Array.isArray(requester) &&
747
+ (requester as Record<string, unknown>).forwarded === true
748
+ );
749
+ }
750
+
751
+ function reliableFullCommand(
752
+ details: PromptPermissionDetails,
753
+ ): string | undefined {
754
+ const payload = promptPayloadRecord(details);
755
+ const evidence = payload?.evidence;
756
+ if (Array.isArray(evidence)) {
757
+ for (const item of evidence) {
758
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
759
+ const entry = item as Record<string, unknown>;
760
+ if (entry.label === "full command" && typeof entry.text === "string" &&
761
+ entry.text.trim()) {
762
+ return entry.text;
763
+ }
764
+ }
765
+ }
766
+ if (payload?.kind !== "bash_external_directory") return undefined;
767
+ const request = payload.request;
768
+ if (!request || typeof request !== "object" || Array.isArray(request)) {
769
+ return undefined;
770
+ }
771
+ const value = (request as Record<string, unknown>).value;
772
+ return typeof value === "string" && value.trim() ? value : undefined;
735
773
  }
736
774
 
737
775
  function boundaryRequest(
@@ -1925,6 +1963,9 @@ export function createPiAutoReviewExtension(
1925
1963
  let context: ExtensionContext | undefined;
1926
1964
  let config: Readonly<Config> = trustedConfig;
1927
1965
  let disposeAuthorizer: (() => void) | undefined;
1966
+ let registeredSessionId: string | undefined;
1967
+ let registrationEpoch = 0;
1968
+ let shuttingDown = false;
1928
1969
  let disposeBrokerService: (() => void) | undefined;
1929
1970
  const reviewResults = new Map<string, ReviewResult>();
1930
1971
  const telemetryCompleted = new Set<string>();
@@ -1935,6 +1976,7 @@ export function createPiAutoReviewExtension(
1935
1976
  const uiAutoConfirmer = new PermissionUiAutoConfirmer(
1936
1977
  () => config.autoConfirmBoundedAllows,
1937
1978
  );
1979
+ const reviewEntryBatcher = new ReviewEntryBatcher(pi);
1938
1980
 
1939
1981
  const emitTelemetry = (event: ReviewerTelemetryEvent): void => {
1940
1982
  writeOptionalAuditFile(event);
@@ -2267,6 +2309,12 @@ export function createPiAutoReviewExtension(
2267
2309
  });
2268
2310
 
2269
2311
  pi.on("session_start", (_event, ctx) => {
2312
+ shuttingDown = false;
2313
+ registrationEpoch++;
2314
+ disposeAuthorizer?.();
2315
+ disposeAuthorizer = undefined;
2316
+ registeredSessionId = undefined;
2317
+ reviewEntryBatcher.flushAll();
2270
2318
  disposeBrokerService?.();
2271
2319
  broker?.clear();
2272
2320
  reviewResults.clear();
@@ -2280,7 +2328,17 @@ export function createPiAutoReviewExtension(
2280
2328
  );
2281
2329
  context = ctx;
2282
2330
  broker = createBroker();
2283
- disposeBrokerService = publishBoundaryBroker(broker);
2331
+ try {
2332
+ disposeBrokerService = publishBoundaryBroker(broker);
2333
+ } catch (error) {
2334
+ if (!(error instanceof Error) ||
2335
+ error.message !== "pi-auto-review boundary broker is already published") {
2336
+ throw error;
2337
+ }
2338
+ // In-process child nodes still need their own reviewer/authorizer.
2339
+ // The process-global broker capability remains owned by the parent.
2340
+ disposeBrokerService = undefined;
2341
+ }
2284
2342
  } catch (error) {
2285
2343
  context = undefined;
2286
2344
  broker = undefined;
@@ -2300,12 +2358,57 @@ export function createPiAutoReviewExtension(
2300
2358
  if (context) uiAutoConfirmer.handlePrompt(event, context);
2301
2359
  });
2302
2360
 
2303
- pi.events.on("permissions:ready", () => {
2304
- try {
2305
- const service = permissionsService();
2306
- if (!service) return;
2361
+ pi.events.on("permissions:decision", (event) => {
2362
+ reviewEntryBatcher.permissionDecision(event);
2363
+ });
2364
+
2365
+ pi.on("tool_execution_start", (event, ctx) => {
2366
+ reviewEntryBatcher.toolExecutionStarted(
2367
+ ctx.sessionManager.getSessionId(),
2368
+ event.toolCallId,
2369
+ event.args,
2370
+ );
2371
+ });
2372
+ pi.on("turn_end", () => reviewEntryBatcher.flushAll());
2373
+ pi.on("agent_end", () => reviewEntryBatcher.flushAll());
2374
+
2375
+ pi.events.on("permissions:ready", (event) => {
2376
+ const ready = event && typeof event === "object" && !Array.isArray(event)
2377
+ ? event as Record<string, unknown>
2378
+ : undefined;
2379
+ const sessionId = typeof ready?.sessionId === "string" &&
2380
+ ready.sessionId.trim()
2381
+ ? ready.sessionId
2382
+ : undefined;
2383
+ if (!sessionId) {
2384
+ console.error(
2385
+ `${EXTENSION_NAME}: ignored permissions:ready without a session id`,
2386
+ );
2387
+ return;
2388
+ }
2389
+ if (registeredSessionId === sessionId && disposeAuthorizer) return;
2390
+
2391
+ const epoch = ++registrationEpoch;
2392
+ if (registeredSessionId && registeredSessionId !== sessionId) {
2307
2393
  disposeAuthorizer?.();
2308
- disposeAuthorizer = service.registerAuthorizer(
2394
+ disposeAuthorizer = undefined;
2395
+ registeredSessionId = undefined;
2396
+ }
2397
+
2398
+ void import("@gotgenes/pi-permission-system").then((module) => {
2399
+ if (shuttingDown || epoch !== registrationEpoch || !context) return;
2400
+ const service = module.getPermissionsService(sessionId) as
2401
+ | PermissionsService
2402
+ | undefined;
2403
+ if (!service) {
2404
+ console.error(
2405
+ `${EXTENSION_NAME}: permissions service unavailable for session ${sessionId}`,
2406
+ );
2407
+ return;
2408
+ }
2409
+ let dispose: (() => void) | undefined;
2410
+ try {
2411
+ dispose = service.registerAuthorizer(
2309
2412
  EXTENSION_NAME,
2310
2413
  async (details, query, log: AuthorizerLog) => {
2311
2414
  const evidence = normalizePermissionEvidence(details);
@@ -2379,15 +2482,35 @@ export function createPiAutoReviewExtension(
2379
2482
  : undefined,
2380
2483
  ...reviewMeta,
2381
2484
  };
2382
- presentUserReview(
2383
- pi,
2384
- context,
2385
- buildUserReviewNotice(noticeInput),
2386
- buildUserReviewEntryData(noticeInput),
2387
- );
2485
+ const notice = buildUserReviewNotice(noticeInput);
2486
+ const entryData = buildUserReviewEntryData(noticeInput);
2487
+ const localSessionId = context.sessionManager.getSessionId();
2488
+ if (
2489
+ context.mode === "tui" &&
2490
+ context.hasUI &&
2491
+ request.toolCallId &&
2492
+ localSessionId &&
2493
+ !promptIsForwarded(details)
2494
+ ) {
2495
+ reviewEntryBatcher.enqueue({
2496
+ sessionId: localSessionId,
2497
+ toolCallId: request.toolCallId,
2498
+ fullCommand: reliableFullCommand(details),
2499
+ member: {
2500
+ requestId: request.id,
2501
+ ...noticeInput,
2502
+ },
2503
+ notice,
2504
+ data: entryData,
2505
+ ctx: context,
2506
+ });
2507
+ } else {
2508
+ presentUserReview(pi, context, notice, entryData);
2509
+ }
2388
2510
 
2389
2511
  log.review("pi_auto_review_decision", {
2390
2512
  requestId: request.id,
2513
+ toolCallId: request.toolCallId,
2391
2514
  surface,
2392
2515
  model: config.model,
2393
2516
  reviewerModel: reviewMeta.model,
@@ -2445,22 +2568,45 @@ export function createPiAutoReviewExtension(
2445
2568
  } finally {
2446
2569
  setUserReviewStatus(context, undefined);
2447
2570
  }
2448
- },
2449
- );
2450
- writeOptionalAuditFile({ type: "authorizer_registered" });
2451
- } catch (error) {
2571
+ },
2572
+ );
2573
+ if (shuttingDown || epoch !== registrationEpoch || !context) {
2574
+ dispose();
2575
+ return;
2576
+ }
2577
+ disposeAuthorizer?.();
2578
+ disposeAuthorizer = dispose;
2579
+ registeredSessionId = sessionId;
2580
+ writeOptionalAuditFile({
2581
+ type: "authorizer_registered",
2582
+ sessionId,
2583
+ });
2584
+ } catch (error) {
2585
+ dispose?.();
2586
+ console.error(
2587
+ `${EXTENSION_NAME}: authorizer registration failed: ${
2588
+ error instanceof Error ? error.message : String(error)
2589
+ }`,
2590
+ );
2591
+ }
2592
+ }).catch((error) => {
2593
+ if (shuttingDown || epoch !== registrationEpoch) return;
2452
2594
  console.error(
2453
- `${EXTENSION_NAME}: authorizer registration failed: ${
2595
+ `${EXTENSION_NAME}: permission-system import failed: ${
2454
2596
  error instanceof Error ? error.message : String(error)
2455
2597
  }`,
2456
2598
  );
2457
- }
2599
+ });
2458
2600
  });
2459
2601
 
2460
2602
  pi.on("session_shutdown", () => {
2603
+ shuttingDown = true;
2604
+ registrationEpoch++;
2461
2605
  setUserReviewStatus(context, undefined);
2606
+ reviewEntryBatcher.flushAll();
2462
2607
  disposeAuthorizer?.();
2463
2608
  disposeAuthorizer = undefined;
2609
+ registeredSessionId = undefined;
2464
2610
  disposeBrokerService?.();
2465
2611
  disposeBrokerService = undefined;
2466
2612
  broker?.clear();
@@ -101,15 +101,15 @@ export function formatReviewUsage(usage: UserReviewUsage): string | undefined {
101
101
  if (input !== undefined) {
102
102
  const cache =
103
103
  usage.cacheRead && usage.cacheRead > 0
104
- ? ` (${formatReviewTokenCount(usage.cacheRead)} cache)`
104
+ ? ` (${formatReviewTokenCount(usage.cacheRead)} toks cache)`
105
105
  : "";
106
- parts.push(`${formatReviewTokenCount(input)} in${cache}`);
106
+ parts.push(`${formatReviewTokenCount(input)} toks in${cache}`);
107
107
  }
108
108
  if (usage.output !== undefined) {
109
- parts.push(`${formatReviewTokenCount(usage.output)} out`);
109
+ parts.push(`${formatReviewTokenCount(usage.output)} toks out`);
110
110
  }
111
111
  if (parts.length === 0 && usage.totalTokens !== undefined) {
112
- parts.push(`${formatReviewTokenCount(usage.totalTokens)} tok`);
112
+ parts.push(`${formatReviewTokenCount(usage.totalTokens)} toks`);
113
113
  }
114
114
  if (parts.length === 0) return undefined;
115
115
  const approx = usage.availability === "estimated" ? "~" : "";
@@ -193,6 +193,16 @@ function joinNoticeLines(lines: Array<string | undefined>): string[] {
193
193
  .filter((line) => line.length > 0);
194
194
  }
195
195
 
196
+ function reviewTargetRationaleLine(input: {
197
+ target?: string;
198
+ rationale?: string;
199
+ }): string | undefined {
200
+ const target = compactReviewText(input.target, 100);
201
+ const rationale = compactReviewText(input.rationale, 180);
202
+ if (target && rationale) return `${target} · ${rationale}`;
203
+ return target || rationale || undefined;
204
+ }
205
+
196
206
  export type UserReviewNoticeInput = {
197
207
  outcome: UserReviewOutcome;
198
208
  surface: string;
@@ -211,6 +221,24 @@ export type UserReviewEntryData = {
211
221
  lines: string[];
212
222
  };
213
223
 
224
+ export type UserReviewGroupMember = UserReviewNoticeInput & {
225
+ requestId: string;
226
+ permissionResult?: "allow" | "deny";
227
+ };
228
+
229
+ export type UserReviewGroupEntryData = {
230
+ kind: "group";
231
+ type: UserReviewNotice["type"];
232
+ sessionId: string;
233
+ toolCallId: string;
234
+ fullCommand?: string;
235
+ members: UserReviewGroupMember[];
236
+ };
237
+
238
+ export type AnyUserReviewEntryData =
239
+ | UserReviewEntryData
240
+ | UserReviewGroupEntryData;
241
+
214
242
  type UserReviewTheme = {
215
243
  fg(color: string, text: string): string;
216
244
  italic(text: string): string;
@@ -258,11 +286,21 @@ function outcomeVerb(outcome: UserReviewOutcome): string {
258
286
  }
259
287
  }
260
288
 
289
+ function memberOutcomeText(outcome: UserReviewOutcome): string {
290
+ switch (outcome) {
291
+ case "auto_confirm":
292
+ return "allowed · auto-confirm";
293
+ case "needs_confirmation":
294
+ return "allowed · confirm locally";
295
+ default:
296
+ return outcomeVerb(outcome);
297
+ }
298
+ }
299
+
261
300
  export function buildUserReviewLines(input: UserReviewNoticeInput): string[] {
262
301
  return joinNoticeLines([
263
302
  reviewHeadline(input.outcome, input.surface),
264
- compactReviewText(input.target, 100) || undefined,
265
- compactReviewText(input.rationale, 180) || undefined,
303
+ reviewTargetRationaleLine(input),
266
304
  recoveryLine(input.outcome, input.recoveryCommand),
267
305
  formatReviewMeta({
268
306
  model: input.model,
@@ -275,7 +313,7 @@ export function buildUserReviewLines(input: UserReviewNoticeInput): string[] {
275
313
 
276
314
  /**
277
315
  * User-facing (not agent-facing) notice after a review decision.
278
- * Structured for TUI wrapping: headline, target, rationale, then model/tokens.
316
+ * Structured for TUI wrapping: headline, target+rationale, then model/tokens.
279
317
  */
280
318
  export function buildUserReviewNotice(
281
319
  input: UserReviewNoticeInput,
@@ -297,10 +335,7 @@ export function buildUserReviewEntryData(
297
335
  }
298
336
 
299
337
  export function formatUserReviewQuoteMessage(message: string): string {
300
- return message
301
- .split("\n")
302
- .map((line) => `│ ${line}`)
303
- .join("\n");
338
+ return message;
304
339
  }
305
340
 
306
341
  function displayWidth(text: string): number {
@@ -362,8 +397,7 @@ export function renderUserReviewQuoteLines(
362
397
  theme: UserReviewTheme,
363
398
  width: number,
364
399
  ): string[] {
365
- const contentWidth = Math.max(1, width - 2);
366
- const border = theme.fg("mdQuoteBorder", "│ ");
400
+ const contentWidth = Math.max(1, width);
367
401
  const verb = outcomeVerb(data.outcome);
368
402
  const verbAnsi = theme.getFgAnsi(outcomeAccent(data.outcome));
369
403
  const numberAnsi = theme.getFgAnsi("success");
@@ -379,22 +413,104 @@ export function renderUserReviewQuoteLines(
379
413
  }
380
414
  if (index === data.lines.length - 1) {
381
415
  painted = painted.replace(
382
- /(\d+(?:\.\d+)?[kM]?)(?=\s(?:in|out|tok|cache|calls)|ms\b|s\b)/g,
416
+ /(\d+(?:\.\d+)?[kM]?)(?=\s(?:toks|calls)|ms\b|s\b)/g,
383
417
  `${numberAnsi}$1\x1b[0m`,
384
418
  );
385
419
  }
386
- rendered.push(border + withQuoteStyle(theme, painted));
420
+ rendered.push(withQuoteStyle(theme, painted));
387
421
  }
388
422
  }
389
423
  return rendered;
390
424
  }
391
425
 
426
+ function groupOutcome(members: readonly UserReviewGroupMember[]): UserReviewOutcome {
427
+ if (members.some((member) => member.permissionResult === "deny")) return "deny";
428
+ for (const outcome of [
429
+ "circuit_breaker",
430
+ "deny",
431
+ "unavailable",
432
+ "defer",
433
+ "needs_confirmation",
434
+ "auto_confirm",
435
+ ] as const) {
436
+ if (members.some((member) => member.outcome === outcome)) return outcome;
437
+ }
438
+ return "allow";
439
+ }
440
+
441
+ function groupHeadline(data: UserReviewGroupEntryData): string {
442
+ const outcome = groupOutcome(data.members);
443
+ const result = outcome === "deny" || outcome === "circuit_breaker" ||
444
+ outcome === "unavailable"
445
+ ? "denied"
446
+ : outcome === "defer" || outcome === "needs_confirmation"
447
+ ? "deferred"
448
+ : "allowed";
449
+ return `Auto-review · ${result} · ${data.members.length} checks`;
450
+ }
451
+
452
+ export function buildUserReviewGroupLines(
453
+ data: UserReviewGroupEntryData,
454
+ ): UserReviewEntryData {
455
+ if (data.members.length === 1) {
456
+ const member = data.members[0];
457
+ const lines = buildUserReviewLines(member);
458
+ if (member.permissionResult === "deny" && member.outcome !== "deny") {
459
+ lines.push("Local confirmation · denied");
460
+ }
461
+ return { outcome: groupOutcome(data.members), type: data.type, lines };
462
+ }
463
+
464
+ const lines: string[] = [groupHeadline(data)];
465
+ const command = String(data.fullCommand ?? "")
466
+ .replace(/\s+/g, " ")
467
+ .trim();
468
+ if (command) lines.push(command);
469
+ for (const member of data.members) {
470
+ const summary = joinNoticeLines([
471
+ member.surface,
472
+ memberOutcomeText(member.outcome),
473
+ reviewTargetRationaleLine(member),
474
+ ]).join(" · ");
475
+ lines.push(
476
+ summary,
477
+ ...joinNoticeLines([
478
+ recoveryLine(member.outcome, member.recoveryCommand),
479
+ member.permissionResult === "deny" && member.outcome !== "deny"
480
+ ? "Local confirmation · denied"
481
+ : undefined,
482
+ formatReviewMeta(member),
483
+ ]),
484
+ );
485
+ }
486
+ return { outcome: groupOutcome(data.members), type: data.type, lines };
487
+ }
488
+
392
489
  export function renderUserReviewEntry(
393
490
  entry: { data?: unknown },
394
491
  _options: unknown,
395
492
  theme: UserReviewTheme,
396
493
  ): { render(width: number): string[]; invalidate(): void } | undefined {
397
494
  const data = entry.data;
495
+ if (
496
+ data &&
497
+ typeof data === "object" &&
498
+ (data as UserReviewGroupEntryData).kind === "group" &&
499
+ Array.isArray((data as UserReviewGroupEntryData).members) &&
500
+ (data as UserReviewGroupEntryData).members.length > 0
501
+ ) {
502
+ const parsed = data as UserReviewGroupEntryData;
503
+ return {
504
+ render(width: number) {
505
+ return renderUserReviewQuoteLines(
506
+ buildUserReviewGroupLines(parsed),
507
+ theme,
508
+ width,
509
+ );
510
+ },
511
+ invalidate() {},
512
+ };
513
+ }
398
514
  if (
399
515
  !data ||
400
516
  typeof data !== "object" ||
@@ -412,6 +528,20 @@ export function renderUserReviewEntry(
412
528
  };
413
529
  }
414
530
 
531
+ function presentUserReviewGroupEntry(
532
+ pi: ExtensionAPI | undefined,
533
+ ctx: ExtensionContext | undefined,
534
+ data: UserReviewGroupEntryData,
535
+ ): boolean {
536
+ if (!pi || !ctx?.hasUI || ctx.mode !== "tui") return false;
537
+ try {
538
+ pi.appendEntry(USER_REVIEW_ENTRY_TYPE, data);
539
+ return true;
540
+ } catch {
541
+ return false;
542
+ }
543
+ }
544
+
415
545
  function presentUserReviewEntry(
416
546
  pi: ExtensionAPI | undefined,
417
547
  ctx: ExtensionContext | undefined,
@@ -439,7 +569,7 @@ export function notifyUserReview(
439
569
  }
440
570
  }
441
571
 
442
- /** TUI quote-style entry when possible; otherwise a quoted notify toast. */
572
+ /** TUI structured entry when possible; otherwise a plain notify toast. */
443
573
  export function presentUserReview(
444
574
  pi: ExtensionAPI | undefined,
445
575
  ctx: ExtensionContext | undefined,
@@ -450,6 +580,163 @@ export function presentUserReview(
450
580
  notifyUserReview(ctx, notice);
451
581
  }
452
582
 
583
+ type PendingReviewGroup = {
584
+ sessionId: string;
585
+ toolCallId: string;
586
+ fullCommand?: string;
587
+ members: Map<string, {
588
+ member: UserReviewGroupMember;
589
+ notice: UserReviewNotice;
590
+ data: UserReviewEntryData;
591
+ }>;
592
+ ctx: ExtensionContext;
593
+ createdAt: number;
594
+ timer: ReturnType<typeof setTimeout>;
595
+ };
596
+
597
+ export type ReviewEntryBatcherOptions = {
598
+ maxGroups?: number;
599
+ maxMembers?: number;
600
+ ttlMs?: number;
601
+ now?: () => number;
602
+ };
603
+
604
+ /** Buffers only transcript presentation; permission requests remain independent. */
605
+ export class ReviewEntryBatcher {
606
+ readonly #groups = new Map<string, PendingReviewGroup>();
607
+ readonly #maxGroups: number;
608
+ readonly #maxMembers: number;
609
+ readonly #ttlMs: number;
610
+ readonly #now: () => number;
611
+
612
+ constructor(
613
+ private readonly pi: ExtensionAPI,
614
+ options: ReviewEntryBatcherOptions = {},
615
+ ) {
616
+ this.#maxGroups = options.maxGroups ?? 32;
617
+ this.#maxMembers = options.maxMembers ?? 8;
618
+ this.#ttlMs = options.ttlMs ?? 60_000;
619
+ this.#now = options.now ?? Date.now;
620
+ }
621
+
622
+ enqueue(input: {
623
+ sessionId: string;
624
+ toolCallId: string;
625
+ fullCommand?: string;
626
+ member: UserReviewGroupMember;
627
+ notice: UserReviewNotice;
628
+ data: UserReviewEntryData;
629
+ ctx: ExtensionContext;
630
+ }): void {
631
+ try {
632
+ const key = this.#key(input.sessionId, input.toolCallId);
633
+ let group = this.#groups.get(key);
634
+ if (group?.members.has(input.member.requestId)) return;
635
+ if (group && group.members.size >= this.#maxMembers) {
636
+ this.#flushKey(key);
637
+ group = undefined;
638
+ }
639
+ if (!group) {
640
+ if (this.#groups.size >= this.#maxGroups) {
641
+ const oldest = [...this.#groups.entries()].sort(
642
+ (left, right) => left[1].createdAt - right[1].createdAt,
643
+ )[0]?.[0];
644
+ if (oldest) this.#flushKey(oldest);
645
+ }
646
+ const timer = setTimeout(() => this.#flushKey(key), this.#ttlMs);
647
+ timer.unref?.();
648
+ group = {
649
+ sessionId: input.sessionId,
650
+ toolCallId: input.toolCallId,
651
+ fullCommand: input.fullCommand,
652
+ members: new Map(),
653
+ ctx: input.ctx,
654
+ createdAt: this.#now(),
655
+ timer,
656
+ };
657
+ this.#groups.set(key, group);
658
+ } else if (!group.fullCommand && input.fullCommand) {
659
+ group.fullCommand = input.fullCommand;
660
+ }
661
+ group.members.set(input.member.requestId, {
662
+ member: input.member,
663
+ notice: input.notice,
664
+ data: input.data,
665
+ });
666
+ if (input.member.outcome === "deny" ||
667
+ input.member.outcome === "circuit_breaker") {
668
+ this.#flushKey(key);
669
+ }
670
+ } catch {
671
+ presentUserReview(this.pi, input.ctx, input.notice, input.data);
672
+ }
673
+ }
674
+
675
+ toolExecutionStarted(
676
+ sessionId: string,
677
+ toolCallId: string,
678
+ args: unknown,
679
+ ): void {
680
+ const key = this.#key(sessionId, toolCallId);
681
+ const group = this.#groups.get(key);
682
+ if (!group) return;
683
+ if (!group.fullCommand && args && typeof args === "object" &&
684
+ !Array.isArray(args) &&
685
+ typeof (args as Record<string, unknown>).command === "string") {
686
+ group.fullCommand = String((args as Record<string, unknown>).command);
687
+ }
688
+ this.#flushKey(key);
689
+ }
690
+
691
+ permissionDecision(event: unknown): void {
692
+ if (!event || typeof event !== "object" || Array.isArray(event)) return;
693
+ const record = event as Record<string, unknown>;
694
+ if (typeof record.requestId !== "string" ||
695
+ (record.result !== "allow" && record.result !== "deny")) return;
696
+ for (const [key, group] of this.#groups) {
697
+ const pending = group.members.get(record.requestId);
698
+ if (!pending) continue;
699
+ pending.member.permissionResult = record.result;
700
+ if (record.result === "deny") this.#flushKey(key);
701
+ return;
702
+ }
703
+ }
704
+
705
+ flushAll(): void {
706
+ for (const key of [...this.#groups.keys()]) this.#flushKey(key);
707
+ }
708
+
709
+ get pendingGroups(): number {
710
+ return this.#groups.size;
711
+ }
712
+
713
+ #key(sessionId: string, toolCallId: string): string {
714
+ return `${sessionId}\u0000${toolCallId}`;
715
+ }
716
+
717
+ #flushKey(key: string): void {
718
+ const group = this.#groups.get(key);
719
+ if (!group) return;
720
+ this.#groups.delete(key);
721
+ clearTimeout(group.timer);
722
+ const pending = [...group.members.values()];
723
+ if (pending.length === 0) return;
724
+ const members = pending.map((entry) => entry.member);
725
+ const data: UserReviewGroupEntryData = {
726
+ kind: "group",
727
+ type: noticeType(groupOutcome(members)),
728
+ sessionId: group.sessionId,
729
+ toolCallId: group.toolCallId,
730
+ ...(group.fullCommand ? { fullCommand: group.fullCommand } : {}),
731
+ members,
732
+ };
733
+ if (presentUserReviewGroupEntry(this.pi, group.ctx, data)) return;
734
+ for (const entry of pending) {
735
+ presentUserReview(this.pi, group.ctx, entry.notice, entry.data);
736
+ }
737
+ }
738
+ }
739
+
453
740
  /** Best-effort footer status; pass undefined to clear. */
454
741
  export function setUserReviewStatus(
455
742
  ctx: ExtensionContext | undefined,