@erichll/pi-auto-review 0.9.0 → 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 +17 -0
- package/README.md +15 -11
- package/package.json +1 -1
- package/src/index.ts +154 -243
- package/src/user-feedback.ts +149 -202
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
3
20
|
## 0.9.0 - 2026-08-22
|
|
4
21
|
|
|
5
22
|
- Require `@gotgenes/pi-permission-system` `^27.0.0` and register through the
|
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. Version 0.
|
|
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
|
|
@@ -132,16 +132,20 @@ only a fixed, budget-checked schema correction.
|
|
|
132
132
|
|
|
133
133
|
## Operator feedback and exact retry
|
|
134
134
|
|
|
135
|
-
Interactive sessions show
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
145
149
|
|
|
146
150
|
In an interactive TUI, `/auto-review-approve` lists up to ten recent
|
|
147
151
|
non-critical model denials from the current session. Selecting one asks the
|
package/package.json
CHANGED
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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,15 +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
|
-
ReviewEntryBatcher,
|
|
46
45
|
renderUserReviewEntry,
|
|
47
46
|
reviewTargetFromRequest,
|
|
48
|
-
|
|
47
|
+
UserReviewWidgetController,
|
|
49
48
|
USER_REVIEW_ENTRY_TYPE,
|
|
50
49
|
type UserReviewOutcome,
|
|
51
50
|
type UserReviewUsage,
|
|
@@ -252,6 +251,7 @@ type ReviewResult = {
|
|
|
252
251
|
durationMs: number;
|
|
253
252
|
transcript: TranscriptResult;
|
|
254
253
|
summary: ReviewExecutionSummary;
|
|
254
|
+
unavailable?: boolean;
|
|
255
255
|
};
|
|
256
256
|
|
|
257
257
|
class ReviewExecutionError extends Error {
|
|
@@ -726,52 +726,6 @@ type PermissionsService = {
|
|
|
726
726
|
): () => void;
|
|
727
727
|
};
|
|
728
728
|
|
|
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;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
729
|
function boundaryRequest(
|
|
776
730
|
ctx: ExtensionContext,
|
|
777
731
|
details: PromptPermissionDetails,
|
|
@@ -1976,7 +1930,7 @@ export function createPiAutoReviewExtension(
|
|
|
1976
1930
|
const uiAutoConfirmer = new PermissionUiAutoConfirmer(
|
|
1977
1931
|
() => config.autoConfirmBoundedAllows,
|
|
1978
1932
|
);
|
|
1979
|
-
const
|
|
1933
|
+
const reviewWidget = new UserReviewWidgetController();
|
|
1980
1934
|
|
|
1981
1935
|
const emitTelemetry = (event: ReviewerTelemetryEvent): void => {
|
|
1982
1936
|
writeOptionalAuditFile(event);
|
|
@@ -2032,6 +1986,7 @@ export function createPiAutoReviewExtension(
|
|
|
2032
1986
|
durationMs: execution.summary.durationMs,
|
|
2033
1987
|
transcript: execution.summary.transcript,
|
|
2034
1988
|
summary: execution.summary,
|
|
1989
|
+
unavailable: true,
|
|
2035
1990
|
});
|
|
2036
1991
|
}
|
|
2037
1992
|
emitTelemetry(
|
|
@@ -2314,7 +2269,7 @@ export function createPiAutoReviewExtension(
|
|
|
2314
2269
|
disposeAuthorizer?.();
|
|
2315
2270
|
disposeAuthorizer = undefined;
|
|
2316
2271
|
registeredSessionId = undefined;
|
|
2317
|
-
|
|
2272
|
+
reviewWidget.clear(context ?? ctx);
|
|
2318
2273
|
disposeBrokerService?.();
|
|
2319
2274
|
broker?.clear();
|
|
2320
2275
|
reviewResults.clear();
|
|
@@ -2359,18 +2314,8 @@ export function createPiAutoReviewExtension(
|
|
|
2359
2314
|
});
|
|
2360
2315
|
|
|
2361
2316
|
pi.events.on("permissions:decision", (event) => {
|
|
2362
|
-
|
|
2363
|
-
});
|
|
2364
|
-
|
|
2365
|
-
pi.on("tool_execution_start", (event, ctx) => {
|
|
2366
|
-
reviewEntryBatcher.toolExecutionStarted(
|
|
2367
|
-
ctx.sessionManager.getSessionId(),
|
|
2368
|
-
event.toolCallId,
|
|
2369
|
-
event.args,
|
|
2370
|
-
);
|
|
2317
|
+
reviewWidget.permissionDecision(event);
|
|
2371
2318
|
});
|
|
2372
|
-
pi.on("turn_end", () => reviewEntryBatcher.flushAll());
|
|
2373
|
-
pi.on("agent_end", () => reviewEntryBatcher.flushAll());
|
|
2374
2319
|
|
|
2375
2320
|
pi.events.on("permissions:ready", (event) => {
|
|
2376
2321
|
const ready = event && typeof event === "object" && !Array.isArray(event)
|
|
@@ -2395,20 +2340,19 @@ export function createPiAutoReviewExtension(
|
|
|
2395
2340
|
registeredSessionId = undefined;
|
|
2396
2341
|
}
|
|
2397
2342
|
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
dispose = service.registerAuthorizer(
|
|
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(
|
|
2412
2356
|
EXTENSION_NAME,
|
|
2413
2357
|
async (details, query, log: AuthorizerLog) => {
|
|
2414
2358
|
const evidence = normalizePermissionEvidence(details);
|
|
@@ -2425,185 +2369,152 @@ export function createPiAutoReviewExtension(
|
|
|
2425
2369
|
surface,
|
|
2426
2370
|
rationale: reason,
|
|
2427
2371
|
};
|
|
2428
|
-
|
|
2429
|
-
pi,
|
|
2430
|
-
context,
|
|
2431
|
-
buildUserReviewNotice(unavailable),
|
|
2432
|
-
buildUserReviewEntryData(unavailable),
|
|
2433
|
-
);
|
|
2372
|
+
notifyUserReview(context, buildUserReviewNotice(unavailable));
|
|
2434
2373
|
return { kind: "deny", reason };
|
|
2435
2374
|
}
|
|
2436
2375
|
|
|
2437
2376
|
const request = boundaryRequest(context, details, query);
|
|
2438
2377
|
const target = reviewTargetFromRequest(request);
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
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),
|
|
2442
2432
|
);
|
|
2443
|
-
try {
|
|
2444
|
-
const decision = await broker.review(request, {
|
|
2445
|
-
sessionId: context.sessionManager.getSessionId(),
|
|
2446
|
-
scopeKey: currentTurnScope(context),
|
|
2447
|
-
issueGrant: false,
|
|
2448
|
-
});
|
|
2449
|
-
const result = reviewResults.get(request.id);
|
|
2450
|
-
reviewResults.delete(request.id);
|
|
2451
|
-
const allowCapped =
|
|
2452
|
-
decision.kind === "allow" && boundedRequest(surface);
|
|
2453
|
-
const autoConfirmQueued =
|
|
2454
|
-
allowCapped &&
|
|
2455
|
-
context.mode === "tui" &&
|
|
2456
|
-
context.hasUI &&
|
|
2457
|
-
uiAutoConfirmer.stage(request.id, surface);
|
|
2458
|
-
|
|
2459
|
-
let userOutcome: UserReviewOutcome;
|
|
2460
|
-
if (decision.kind === "deny" && decision.circuitBreakerTripped) {
|
|
2461
|
-
userOutcome = "circuit_breaker";
|
|
2462
|
-
} else if (allowCapped && autoConfirmQueued) {
|
|
2463
|
-
userOutcome = "auto_confirm";
|
|
2464
|
-
} else if (allowCapped) {
|
|
2465
|
-
userOutcome = "needs_confirmation";
|
|
2466
|
-
} else if (decision.kind === "allow") {
|
|
2467
|
-
userOutcome = "allow";
|
|
2468
|
-
} else if (decision.kind === "defer") {
|
|
2469
|
-
userOutcome = "defer";
|
|
2470
|
-
} else {
|
|
2471
|
-
userOutcome = "deny";
|
|
2472
|
-
}
|
|
2473
|
-
const reviewMeta = userReviewMetaFromResult(result, config.model);
|
|
2474
|
-
const noticeInput = {
|
|
2475
|
-
outcome: userOutcome,
|
|
2476
|
-
surface,
|
|
2477
|
-
target,
|
|
2478
|
-
rationale: decision.review.rationale,
|
|
2479
|
-
recoveryCommand:
|
|
2480
|
-
decision.kind === "deny"
|
|
2481
|
-
? decision.recoveryCommand
|
|
2482
|
-
: undefined,
|
|
2483
|
-
...reviewMeta,
|
|
2484
|
-
};
|
|
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
|
-
}
|
|
2510
|
-
|
|
2511
|
-
log.review("pi_auto_review_decision", {
|
|
2512
|
-
requestId: request.id,
|
|
2513
|
-
toolCallId: request.toolCallId,
|
|
2514
|
-
surface,
|
|
2515
|
-
model: config.model,
|
|
2516
|
-
reviewerModel: reviewMeta.model,
|
|
2517
|
-
outcome: allowCapped ? "defer" : decision.kind,
|
|
2518
|
-
reviewerOutcome: decision.review.outcome,
|
|
2519
|
-
riskLevel: decision.review.riskLevel,
|
|
2520
|
-
userAuthorization: decision.review.userAuthorization,
|
|
2521
|
-
rationale: decision.review.rationale,
|
|
2522
|
-
allowCapped,
|
|
2523
|
-
autoConfirmQueued,
|
|
2524
|
-
userOutcome,
|
|
2525
|
-
circuitBreakerTripped:
|
|
2526
|
-
decision.kind === "deny"
|
|
2527
|
-
? decision.circuitBreakerTripped
|
|
2528
|
-
: false,
|
|
2529
|
-
attempts: result?.attempts ?? 0,
|
|
2530
|
-
retryErrors: result?.retryErrors ?? [],
|
|
2531
|
-
durationMs: result?.durationMs,
|
|
2532
|
-
usageAvailability: reviewMeta.usage?.availability,
|
|
2533
|
-
usage: reviewMeta.usage,
|
|
2534
|
-
transcriptUserCharacters: result?.transcript.userCharacters,
|
|
2535
|
-
transcriptToolCharacters: result?.transcript.toolCharacters,
|
|
2536
|
-
transcriptRelevantResultCharacters:
|
|
2537
|
-
result?.transcript.relevantResultCharacters,
|
|
2538
|
-
transcriptTruncated: result?.transcript.truncated,
|
|
2539
|
-
command: request.command,
|
|
2540
|
-
path: request.path,
|
|
2541
|
-
resolvedPath: request.resolvedPath,
|
|
2542
|
-
destination: request.destination,
|
|
2543
|
-
agentName: request.agentName,
|
|
2544
|
-
requesterSessionId: request.requesterSessionId,
|
|
2545
|
-
accessIntent: request.accessIntent,
|
|
2546
|
-
authorization:
|
|
2547
|
-
decision.kind === "allow"
|
|
2548
|
-
? decision.authorization
|
|
2549
|
-
: undefined,
|
|
2550
|
-
});
|
|
2551
2433
|
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
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" };
|
|
2570
2477
|
}
|
|
2571
|
-
}
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
}`,
|
|
2590
|
-
);
|
|
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
|
+
};
|
|
2491
|
+
},
|
|
2492
|
+
);
|
|
2493
|
+
if (shuttingDown || epoch !== registrationEpoch || !context) {
|
|
2494
|
+
dispose();
|
|
2495
|
+
return;
|
|
2591
2496
|
}
|
|
2592
|
-
|
|
2593
|
-
|
|
2497
|
+
disposeAuthorizer?.();
|
|
2498
|
+
disposeAuthorizer = dispose;
|
|
2499
|
+
registeredSessionId = sessionId;
|
|
2500
|
+
writeOptionalAuditFile({
|
|
2501
|
+
type: "authorizer_registered",
|
|
2502
|
+
sessionId,
|
|
2503
|
+
});
|
|
2504
|
+
} catch (error) {
|
|
2505
|
+
dispose?.();
|
|
2594
2506
|
console.error(
|
|
2595
|
-
`${EXTENSION_NAME}:
|
|
2507
|
+
`${EXTENSION_NAME}: authorizer registration failed: ${
|
|
2596
2508
|
error instanceof Error ? error.message : String(error)
|
|
2597
2509
|
}`,
|
|
2598
2510
|
);
|
|
2599
|
-
}
|
|
2511
|
+
}
|
|
2600
2512
|
});
|
|
2601
2513
|
|
|
2602
2514
|
pi.on("session_shutdown", () => {
|
|
2603
2515
|
shuttingDown = true;
|
|
2604
2516
|
registrationEpoch++;
|
|
2605
|
-
|
|
2606
|
-
reviewEntryBatcher.flushAll();
|
|
2517
|
+
reviewWidget.clear(context);
|
|
2607
2518
|
disposeAuthorizer?.();
|
|
2608
2519
|
disposeAuthorizer = undefined;
|
|
2609
2520
|
registeredSessionId = undefined;
|
package/src/user-feedback.ts
CHANGED
|
@@ -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 =
|
|
71
|
+
const compact = truncateReviewText(target, 100);
|
|
66
72
|
return compact || undefined;
|
|
67
73
|
}
|
|
68
74
|
|
|
@@ -197,8 +203,8 @@ function reviewTargetRationaleLine(input: {
|
|
|
197
203
|
target?: string;
|
|
198
204
|
rationale?: string;
|
|
199
205
|
}): string | undefined {
|
|
200
|
-
const target =
|
|
201
|
-
const rationale =
|
|
206
|
+
const target = truncateReviewText(input.target, 100);
|
|
207
|
+
const rationale = truncateReviewText(input.rationale, 180);
|
|
202
208
|
if (target && rationale) return `${target} · ${rationale}`;
|
|
203
209
|
return target || rationale || undefined;
|
|
204
210
|
}
|
|
@@ -400,7 +406,6 @@ export function renderUserReviewQuoteLines(
|
|
|
400
406
|
const contentWidth = Math.max(1, width);
|
|
401
407
|
const verb = outcomeVerb(data.outcome);
|
|
402
408
|
const verbAnsi = theme.getFgAnsi(outcomeAccent(data.outcome));
|
|
403
|
-
const numberAnsi = theme.getFgAnsi("success");
|
|
404
409
|
const rendered: string[] = [];
|
|
405
410
|
for (const [index, line] of data.lines.entries()) {
|
|
406
411
|
for (const visual of wrapReviewDisplayText(line, contentWidth)) {
|
|
@@ -411,12 +416,6 @@ export function renderUserReviewQuoteLines(
|
|
|
411
416
|
`${verbAnsi}${verb}\x1b[0m`,
|
|
412
417
|
);
|
|
413
418
|
}
|
|
414
|
-
if (index === data.lines.length - 1) {
|
|
415
|
-
painted = painted.replace(
|
|
416
|
-
/(\d+(?:\.\d+)?[kM]?)(?=\s(?:toks|calls)|ms\b|s\b)/g,
|
|
417
|
-
`${numberAnsi}$1\x1b[0m`,
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
419
|
rendered.push(withQuoteStyle(theme, painted));
|
|
421
420
|
}
|
|
422
421
|
}
|
|
@@ -528,34 +527,6 @@ export function renderUserReviewEntry(
|
|
|
528
527
|
};
|
|
529
528
|
}
|
|
530
529
|
|
|
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
|
-
|
|
545
|
-
function presentUserReviewEntry(
|
|
546
|
-
pi: ExtensionAPI | undefined,
|
|
547
|
-
ctx: ExtensionContext | undefined,
|
|
548
|
-
data: UserReviewEntryData,
|
|
549
|
-
): boolean {
|
|
550
|
-
if (!pi || !ctx?.hasUI || ctx.mode !== "tui") return false;
|
|
551
|
-
try {
|
|
552
|
-
pi.appendEntry(USER_REVIEW_ENTRY_TYPE, data);
|
|
553
|
-
return true;
|
|
554
|
-
} catch {
|
|
555
|
-
return false;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
530
|
/** Best-effort toast; never throws into the authorizer path. */
|
|
560
531
|
export function notifyUserReview(
|
|
561
532
|
ctx: ExtensionContext | undefined,
|
|
@@ -569,184 +540,160 @@ export function notifyUserReview(
|
|
|
569
540
|
}
|
|
570
541
|
}
|
|
571
542
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
pi: ExtensionAPI | undefined,
|
|
575
|
-
ctx: ExtensionContext | undefined,
|
|
576
|
-
notice: UserReviewNotice,
|
|
577
|
-
data: UserReviewEntryData,
|
|
578
|
-
): void {
|
|
579
|
-
if (presentUserReviewEntry(pi, ctx, data)) return;
|
|
580
|
-
notifyUserReview(ctx, notice);
|
|
581
|
-
}
|
|
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>;
|
|
543
|
+
export type UserReviewWidgetData = UserReviewEntryData & {
|
|
544
|
+
phase: "reviewing" | "complete";
|
|
595
545
|
};
|
|
596
546
|
|
|
597
|
-
export
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
+
}
|
|
603
565
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
}
|
|
566
|
+
export function buildUserReviewWidgetData(
|
|
567
|
+
input: UserReviewNoticeInput,
|
|
568
|
+
): UserReviewWidgetData {
|
|
569
|
+
return {
|
|
570
|
+
phase: "complete",
|
|
571
|
+
...buildUserReviewEntryData(input),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
621
574
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
if (
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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);
|
|
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
|
+
);
|
|
669
597
|
}
|
|
670
|
-
|
|
671
|
-
presentUserReview(this.pi, input.ctx, input.notice, input.data);
|
|
598
|
+
rendered.push(painted);
|
|
672
599
|
}
|
|
673
600
|
}
|
|
601
|
+
return rendered;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function setWidget(
|
|
605
|
+
ctx: ExtensionContext | undefined,
|
|
606
|
+
data: UserReviewWidgetData | undefined,
|
|
607
|
+
): boolean {
|
|
608
|
+
if (!ctx?.hasUI || ctx.mode !== "tui") return false;
|
|
609
|
+
try {
|
|
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;
|
|
622
|
+
} catch {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
674
626
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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,
|
|
679
655
|
): void {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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);
|
|
656
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
657
|
+
notifyUserReview(ctx, notice);
|
|
658
|
+
return;
|
|
687
659
|
}
|
|
688
|
-
this.#
|
|
660
|
+
if (!this.#isCurrent(requestId, generation)) return;
|
|
661
|
+
this.#current = { requestId, generation, ctx, notice, data };
|
|
662
|
+
if (!setWidget(ctx, data)) notifyUserReview(ctx, notice);
|
|
689
663
|
}
|
|
690
664
|
|
|
691
665
|
permissionDecision(event: unknown): void {
|
|
692
666
|
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
|
693
667
|
const record = event as Record<string, unknown>;
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
#key(sessionId: string, toolCallId: string): string {
|
|
714
|
-
return `${sessionId}\u0000${toolCallId}`;
|
|
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);
|
|
715
686
|
}
|
|
716
687
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
this.#
|
|
721
|
-
|
|
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
|
-
}
|
|
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);
|
|
737
693
|
}
|
|
738
|
-
}
|
|
739
694
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
text: string | undefined,
|
|
744
|
-
key = USER_REVIEW_STATUS_KEY,
|
|
745
|
-
): void {
|
|
746
|
-
if (!ctx?.hasUI) return;
|
|
747
|
-
try {
|
|
748
|
-
ctx.ui.setStatus?.(key, text);
|
|
749
|
-
} catch {
|
|
750
|
-
// UI delivery is observational and must not change a decision.
|
|
695
|
+
#isCurrent(requestId: string, generation: number): boolean {
|
|
696
|
+
return this.#current?.requestId === requestId &&
|
|
697
|
+
this.#current.generation === generation;
|
|
751
698
|
}
|
|
752
699
|
}
|