@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 +22 -0
- package/README.md +12 -9
- package/package.json +3 -3
- package/src/index.ts +171 -25
- package/src/user-feedback.ts +303 -16
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
|
|
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
|
|
136
|
-
structured
|
|
137
|
-
breaker outcomes.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
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
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
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:
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
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 =
|
|
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
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
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
|
-
|
|
2451
|
-
|
|
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}:
|
|
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();
|
package/src/user-feedback.ts
CHANGED
|
@@ -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)}
|
|
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
|
-
|
|
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
|
|
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
|
|
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(?:
|
|
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(
|
|
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
|
|
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,
|