@erichll/pi-auto-review 0.9.0 → 0.11.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 +28 -0
- package/README.md +34 -13
- package/package.json +2 -2
- package/src/broker/grants.ts +3 -0
- package/src/broker/types.ts +3 -0
- package/src/index.ts +159 -243
- package/src/integrations/sandbox.ts +18 -0
- package/src/policy.ts +6 -0
- package/src/user-feedback.ts +152 -203
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0 - 2026-08-24
|
|
4
|
+
|
|
5
|
+
- **Structured network destination.** Network approval requests now carry
|
|
6
|
+
separate host, port, and protocol fields alongside the existing combined
|
|
7
|
+
destination string. One-shot grants bind each component independently, so a
|
|
8
|
+
grant issued for one host-port-protocol combination cannot be reused for a
|
|
9
|
+
different one.
|
|
10
|
+
- **Deferred reviews are more visible.** The review widget now highlights
|
|
11
|
+
deferred verdicts with a warning accent instead of the previous muted style,
|
|
12
|
+
making pending human decisions easier to spot.
|
|
13
|
+
|
|
14
|
+
## 0.10.0 - 2026-08-22
|
|
15
|
+
|
|
16
|
+
- Replace the review footer and newly appended transcript entries with one
|
|
17
|
+
above-editor `pi-auto-review` widget. Each permission check independently
|
|
18
|
+
replaces the previous display, shows the configured reviewer model while it
|
|
19
|
+
runs, and updates in place with its final outcome and metadata. The latest
|
|
20
|
+
result remains until the next check and is cleared on session change or
|
|
21
|
+
shutdown.
|
|
22
|
+
- Prevent late concurrent completions and permission decisions from overwriting
|
|
23
|
+
a newer check. A current local denial adds `Local confirmation · denied` to
|
|
24
|
+
that result only. Widget failures fall back to notifications without changing
|
|
25
|
+
authorization behavior.
|
|
26
|
+
- Remove tool-call grouping, timers, capacity eviction, and tool/turn flush
|
|
27
|
+
wiring for new feedback while retaining the historical entry renderer. Bound
|
|
28
|
+
target and rationale text with visible ellipses, preserve width-aware wrapping,
|
|
29
|
+
and keep token, duration, and call-count numbers in the normal metadata color.
|
|
30
|
+
|
|
3
31
|
## 0.9.0 - 2026-08-22
|
|
4
32
|
|
|
5
33
|
- Require `@gotgenes/pi-permission-system` `^27.0.0` and register through the
|
package/README.md
CHANGED
|
@@ -11,19 +11,34 @@ that dependency is a hard prerequisite (see [Install and enable](#install-and-en
|
|
|
11
11
|
- authorizer: `pi-auto-review`
|
|
12
12
|
- default reviewer model: `codex-auto-review`
|
|
13
13
|
|
|
14
|
+
## Contents
|
|
15
|
+
|
|
16
|
+
- [Install and enable](#install-and-enable)
|
|
17
|
+
- [Security model](#security-model)
|
|
18
|
+
- [Configuration](#configuration)
|
|
19
|
+
- [Deadlines and retries](#deadlines-and-retries)
|
|
20
|
+
- [Operator feedback and exact retry](#operator-feedback-and-exact-retry)
|
|
21
|
+
- [Boundary broker API](#boundary-broker-api)
|
|
22
|
+
- [Reviewer context and token budgets](#reviewer-context-and-token-budgets)
|
|
23
|
+
- [Sandbox integration](#sandbox-integration)
|
|
24
|
+
- [Trust boundary](#trust-boundary)
|
|
25
|
+
- [Telemetry](#telemetry)
|
|
26
|
+
- [Real-model smoke test](#real-model-smoke-test)
|
|
27
|
+
|
|
14
28
|
## Install and enable
|
|
15
29
|
|
|
16
30
|
> **Prerequisite:** pi-auto-review is an authorizer inside
|
|
17
31
|
> `@gotgenes/pi-permission-system`. Pi does not auto-install peer packages, so
|
|
18
32
|
> install the permission system separately (once per machine) before this
|
|
19
|
-
> extension. Version 0.
|
|
33
|
+
> extension. Version 0.10.0 requires permission-system 27.x:
|
|
20
34
|
|
|
21
35
|
```bash
|
|
22
36
|
pi install npm:@gotgenes/pi-permission-system
|
|
23
37
|
pi install npm:@erichll/pi-auto-review
|
|
24
38
|
```
|
|
25
39
|
|
|
26
|
-
Install the package outside the agent-writable workspace
|
|
40
|
+
Install the package outside the agent-writable workspace (see
|
|
41
|
+
[Trust boundary](#trust-boundary)):
|
|
27
42
|
|
|
28
43
|
Add it to the permission-system authorizer chain:
|
|
29
44
|
|
|
@@ -132,16 +147,20 @@ only a fixed, budget-checked schema correction.
|
|
|
132
147
|
|
|
133
148
|
## Operator feedback and exact retry
|
|
134
149
|
|
|
135
|
-
Interactive sessions show
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
150
|
+
Interactive sessions show the current permission check in a single widget
|
|
151
|
+
above the editor. Each check first shows its surface, compact target, and the
|
|
152
|
+
dynamically configured reviewer model, then replaces that content in place
|
|
153
|
+
with the outcome, target and rationale, model, token usage, duration, and any
|
|
154
|
+
extra call count. A new check replaces the previous result; the latest result
|
|
155
|
+
remains visible until then and is cleared when the session changes or shuts
|
|
156
|
+
down. Concurrent older checks cannot overwrite the most recently started one.
|
|
157
|
+
|
|
158
|
+
Every request still has its own model call, verdict, grant, local confirmation,
|
|
159
|
+
and audit record. No new review-result transcript entries are written. Existing
|
|
160
|
+
`pi-auto-review` entries in historical sessions remain renderable. Non-TUI
|
|
161
|
+
modes retain best-effort notifications, and a failed TUI widget update falls
|
|
162
|
+
back to the same notification path. UI delivery never changes the authorization
|
|
163
|
+
result.
|
|
145
164
|
|
|
146
165
|
In an interactive TUI, `/auto-review-approve` lists up to ten recent
|
|
147
166
|
non-critical model denials from the current session. Selecting one asks the
|
|
@@ -288,7 +307,7 @@ Writes to the installed reviewer package, its user-global configuration,
|
|
|
288
307
|
project and global security configuration, and the global audit directory are
|
|
289
308
|
deterministically denied.
|
|
290
309
|
|
|
291
|
-
## Telemetry
|
|
310
|
+
## Telemetry
|
|
292
311
|
|
|
293
312
|
Every actual model call emits an internal `review_attempt`; each approval emits
|
|
294
313
|
one `review_complete`. Events contain stable status/error classes, timings,
|
|
@@ -297,6 +316,8 @@ prompt or response text, provider errors, credentials, headers, or URL query
|
|
|
297
316
|
values. Usage is marked `unknown_provenance` when pi-ai cannot distinguish
|
|
298
317
|
provider counters from initialized values, and `unavailable` when absent.
|
|
299
318
|
|
|
319
|
+
## Real-model smoke test
|
|
320
|
+
|
|
300
321
|
For a controlled real-model smoke test, load only the provider, reviewer,
|
|
301
322
|
sandbox, and audit listener:
|
|
302
323
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-auto-review",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Fail-closed, model-backed approval broker for Pi: deterministically hard-denies dangerous operations, reviews dangerous boundaries with a reviewer model, and issues one-shot expiring grants that OS sandbox adapters must consume before retrying.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/broker/grants.ts
CHANGED
|
@@ -22,6 +22,9 @@ export function boundaryRequestHash(request: BoundaryRequest): string {
|
|
|
22
22
|
path: request.path,
|
|
23
23
|
resolvedPath: request.resolvedPath,
|
|
24
24
|
destination: request.destination,
|
|
25
|
+
destinationHost: request.destinationHost,
|
|
26
|
+
destinationPort: request.destinationPort,
|
|
27
|
+
destinationProtocol: request.destinationProtocol,
|
|
25
28
|
toolCallId: request.toolCallId,
|
|
26
29
|
toolName: request.toolName,
|
|
27
30
|
skillName: request.skillName,
|
package/src/broker/types.ts
CHANGED
|
@@ -22,6 +22,9 @@ export type BoundaryRequest = {
|
|
|
22
22
|
path?: string;
|
|
23
23
|
resolvedPath?: string;
|
|
24
24
|
destination?: string;
|
|
25
|
+
destinationHost?: string;
|
|
26
|
+
destinationPort?: number;
|
|
27
|
+
destinationProtocol?: string;
|
|
25
28
|
toolCallId?: string;
|
|
26
29
|
toolName?: string;
|
|
27
30
|
skillName?: string;
|
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,
|
|
@@ -65,6 +64,8 @@ export {
|
|
|
65
64
|
type SandboxNetworkTrap,
|
|
66
65
|
type SandboxRequestContext,
|
|
67
66
|
} from "./integrations/sandbox.ts";
|
|
67
|
+
import { parseHostPort } from "./integrations/sandbox.ts";
|
|
68
|
+
export { parseHostPort };
|
|
68
69
|
|
|
69
70
|
type ReasoningLevel =
|
|
70
71
|
| "off"
|
|
@@ -252,6 +253,7 @@ type ReviewResult = {
|
|
|
252
253
|
durationMs: number;
|
|
253
254
|
transcript: TranscriptResult;
|
|
254
255
|
summary: ReviewExecutionSummary;
|
|
256
|
+
unavailable?: boolean;
|
|
255
257
|
};
|
|
256
258
|
|
|
257
259
|
class ReviewExecutionError extends Error {
|
|
@@ -726,52 +728,6 @@ type PermissionsService = {
|
|
|
726
728
|
): () => void;
|
|
727
729
|
};
|
|
728
730
|
|
|
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
731
|
function boundaryRequest(
|
|
776
732
|
ctx: ExtensionContext,
|
|
777
733
|
details: PromptPermissionDetails,
|
|
@@ -795,6 +751,7 @@ function boundaryRequest(
|
|
|
795
751
|
typeof deterministicPolicy === "string"
|
|
796
752
|
? deterministicPolicy
|
|
797
753
|
: JSON.stringify(deterministicPolicy);
|
|
754
|
+
const destParsed = parseHostPort(evidence.destination);
|
|
798
755
|
return {
|
|
799
756
|
id: details.requestId,
|
|
800
757
|
source: "permission-system",
|
|
@@ -805,6 +762,8 @@ function boundaryRequest(
|
|
|
805
762
|
path: evidence.path,
|
|
806
763
|
resolvedPath: evidence.resolvedPath,
|
|
807
764
|
destination: evidence.destination,
|
|
765
|
+
destinationHost: destParsed?.host,
|
|
766
|
+
destinationPort: destParsed?.port,
|
|
808
767
|
toolCallId:
|
|
809
768
|
typeof (details as unknown as Record<string, unknown>).toolCallId ===
|
|
810
769
|
"string"
|
|
@@ -1976,7 +1935,7 @@ export function createPiAutoReviewExtension(
|
|
|
1976
1935
|
const uiAutoConfirmer = new PermissionUiAutoConfirmer(
|
|
1977
1936
|
() => config.autoConfirmBoundedAllows,
|
|
1978
1937
|
);
|
|
1979
|
-
const
|
|
1938
|
+
const reviewWidget = new UserReviewWidgetController();
|
|
1980
1939
|
|
|
1981
1940
|
const emitTelemetry = (event: ReviewerTelemetryEvent): void => {
|
|
1982
1941
|
writeOptionalAuditFile(event);
|
|
@@ -2032,6 +1991,7 @@ export function createPiAutoReviewExtension(
|
|
|
2032
1991
|
durationMs: execution.summary.durationMs,
|
|
2033
1992
|
transcript: execution.summary.transcript,
|
|
2034
1993
|
summary: execution.summary,
|
|
1994
|
+
unavailable: true,
|
|
2035
1995
|
});
|
|
2036
1996
|
}
|
|
2037
1997
|
emitTelemetry(
|
|
@@ -2314,7 +2274,7 @@ export function createPiAutoReviewExtension(
|
|
|
2314
2274
|
disposeAuthorizer?.();
|
|
2315
2275
|
disposeAuthorizer = undefined;
|
|
2316
2276
|
registeredSessionId = undefined;
|
|
2317
|
-
|
|
2277
|
+
reviewWidget.clear(context ?? ctx);
|
|
2318
2278
|
disposeBrokerService?.();
|
|
2319
2279
|
broker?.clear();
|
|
2320
2280
|
reviewResults.clear();
|
|
@@ -2359,18 +2319,8 @@ export function createPiAutoReviewExtension(
|
|
|
2359
2319
|
});
|
|
2360
2320
|
|
|
2361
2321
|
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
|
-
);
|
|
2322
|
+
reviewWidget.permissionDecision(event);
|
|
2371
2323
|
});
|
|
2372
|
-
pi.on("turn_end", () => reviewEntryBatcher.flushAll());
|
|
2373
|
-
pi.on("agent_end", () => reviewEntryBatcher.flushAll());
|
|
2374
2324
|
|
|
2375
2325
|
pi.events.on("permissions:ready", (event) => {
|
|
2376
2326
|
const ready = event && typeof event === "object" && !Array.isArray(event)
|
|
@@ -2395,20 +2345,19 @@ export function createPiAutoReviewExtension(
|
|
|
2395
2345
|
registeredSessionId = undefined;
|
|
2396
2346
|
}
|
|
2397
2347
|
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
dispose = service.registerAuthorizer(
|
|
2348
|
+
if (shuttingDown || epoch !== registrationEpoch || !context) return;
|
|
2349
|
+
const service = getPermissionsService(sessionId) as
|
|
2350
|
+
| PermissionsService
|
|
2351
|
+
| undefined;
|
|
2352
|
+
if (!service) {
|
|
2353
|
+
console.error(
|
|
2354
|
+
`${EXTENSION_NAME}: permissions service unavailable for session ${sessionId}`,
|
|
2355
|
+
);
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
let dispose: (() => void) | undefined;
|
|
2359
|
+
try {
|
|
2360
|
+
dispose = service.registerAuthorizer(
|
|
2412
2361
|
EXTENSION_NAME,
|
|
2413
2362
|
async (details, query, log: AuthorizerLog) => {
|
|
2414
2363
|
const evidence = normalizePermissionEvidence(details);
|
|
@@ -2425,185 +2374,152 @@ export function createPiAutoReviewExtension(
|
|
|
2425
2374
|
surface,
|
|
2426
2375
|
rationale: reason,
|
|
2427
2376
|
};
|
|
2428
|
-
|
|
2429
|
-
pi,
|
|
2430
|
-
context,
|
|
2431
|
-
buildUserReviewNotice(unavailable),
|
|
2432
|
-
buildUserReviewEntryData(unavailable),
|
|
2433
|
-
);
|
|
2377
|
+
notifyUserReview(context, buildUserReviewNotice(unavailable));
|
|
2434
2378
|
return { kind: "deny", reason };
|
|
2435
2379
|
}
|
|
2436
2380
|
|
|
2437
2381
|
const request = boundaryRequest(context, details, query);
|
|
2438
2382
|
const target = reviewTargetFromRequest(request);
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2383
|
+
const reviewContext = context;
|
|
2384
|
+
const widgetGeneration = reviewWidget.begin(request.id, reviewContext, {
|
|
2385
|
+
surface,
|
|
2386
|
+
target,
|
|
2387
|
+
model: config.model,
|
|
2388
|
+
});
|
|
2389
|
+
const decision = await broker.review(request, {
|
|
2390
|
+
sessionId: reviewContext.sessionManager.getSessionId(),
|
|
2391
|
+
scopeKey: currentTurnScope(reviewContext),
|
|
2392
|
+
issueGrant: false,
|
|
2393
|
+
});
|
|
2394
|
+
const result = reviewResults.get(request.id);
|
|
2395
|
+
reviewResults.delete(request.id);
|
|
2396
|
+
const allowCapped =
|
|
2397
|
+
decision.kind === "allow" && boundedRequest(surface);
|
|
2398
|
+
const autoConfirmQueued =
|
|
2399
|
+
allowCapped &&
|
|
2400
|
+
reviewContext.mode === "tui" &&
|
|
2401
|
+
reviewContext.hasUI &&
|
|
2402
|
+
uiAutoConfirmer.stage(request.id, surface);
|
|
2403
|
+
|
|
2404
|
+
let userOutcome: UserReviewOutcome;
|
|
2405
|
+
if (decision.kind === "deny" && decision.circuitBreakerTripped) {
|
|
2406
|
+
userOutcome = "circuit_breaker";
|
|
2407
|
+
} else if (allowCapped && autoConfirmQueued) {
|
|
2408
|
+
userOutcome = "auto_confirm";
|
|
2409
|
+
} else if (allowCapped) {
|
|
2410
|
+
userOutcome = "needs_confirmation";
|
|
2411
|
+
} else if (decision.kind === "allow") {
|
|
2412
|
+
userOutcome = "allow";
|
|
2413
|
+
} else if (decision.kind === "defer") {
|
|
2414
|
+
userOutcome = "defer";
|
|
2415
|
+
} else {
|
|
2416
|
+
userOutcome = "deny";
|
|
2417
|
+
}
|
|
2418
|
+
const reviewMeta = userReviewMetaFromResult(result, config.model);
|
|
2419
|
+
const noticeInput = {
|
|
2420
|
+
outcome: result?.unavailable ? "unavailable" as const : userOutcome,
|
|
2421
|
+
surface,
|
|
2422
|
+
target,
|
|
2423
|
+
rationale: decision.review.rationale,
|
|
2424
|
+
recoveryCommand:
|
|
2425
|
+
decision.kind === "deny"
|
|
2426
|
+
? decision.recoveryCommand
|
|
2427
|
+
: undefined,
|
|
2428
|
+
...reviewMeta,
|
|
2429
|
+
};
|
|
2430
|
+
const notice = buildUserReviewNotice(noticeInput);
|
|
2431
|
+
reviewWidget.complete(
|
|
2432
|
+
request.id,
|
|
2433
|
+
widgetGeneration,
|
|
2434
|
+
reviewContext,
|
|
2435
|
+
notice,
|
|
2436
|
+
buildUserReviewWidgetData(noticeInput),
|
|
2442
2437
|
);
|
|
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
2438
|
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2439
|
+
log.review("pi_auto_review_decision", {
|
|
2440
|
+
requestId: request.id,
|
|
2441
|
+
toolCallId: request.toolCallId,
|
|
2442
|
+
surface,
|
|
2443
|
+
model: config.model,
|
|
2444
|
+
reviewerModel: reviewMeta.model,
|
|
2445
|
+
outcome: allowCapped ? "defer" : decision.kind,
|
|
2446
|
+
reviewerOutcome: decision.review.outcome,
|
|
2447
|
+
riskLevel: decision.review.riskLevel,
|
|
2448
|
+
userAuthorization: decision.review.userAuthorization,
|
|
2449
|
+
rationale: decision.review.rationale,
|
|
2450
|
+
allowCapped,
|
|
2451
|
+
autoConfirmQueued,
|
|
2452
|
+
userOutcome,
|
|
2453
|
+
circuitBreakerTripped:
|
|
2454
|
+
decision.kind === "deny"
|
|
2455
|
+
? decision.circuitBreakerTripped
|
|
2456
|
+
: false,
|
|
2457
|
+
attempts: result?.attempts ?? 0,
|
|
2458
|
+
retryErrors: result?.retryErrors ?? [],
|
|
2459
|
+
durationMs: result?.durationMs,
|
|
2460
|
+
usageAvailability: reviewMeta.usage?.availability,
|
|
2461
|
+
usage: reviewMeta.usage,
|
|
2462
|
+
transcriptUserCharacters: result?.transcript.userCharacters,
|
|
2463
|
+
transcriptToolCharacters: result?.transcript.toolCharacters,
|
|
2464
|
+
transcriptRelevantResultCharacters:
|
|
2465
|
+
result?.transcript.relevantResultCharacters,
|
|
2466
|
+
transcriptTruncated: result?.transcript.truncated,
|
|
2467
|
+
command: request.command,
|
|
2468
|
+
path: request.path,
|
|
2469
|
+
resolvedPath: request.resolvedPath,
|
|
2470
|
+
destination: request.destination,
|
|
2471
|
+
agentName: request.agentName,
|
|
2472
|
+
requesterSessionId: request.requesterSessionId,
|
|
2473
|
+
accessIntent: request.accessIntent,
|
|
2474
|
+
authorization:
|
|
2475
|
+
decision.kind === "allow"
|
|
2476
|
+
? decision.authorization
|
|
2477
|
+
: undefined,
|
|
2478
|
+
});
|
|
2479
|
+
|
|
2480
|
+
if (allowCapped || decision.kind === "defer") {
|
|
2481
|
+
return { kind: "defer" };
|
|
2570
2482
|
}
|
|
2571
|
-
}
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
}`,
|
|
2590
|
-
);
|
|
2483
|
+
if (decision.kind === "allow") return { kind: "allow" };
|
|
2484
|
+
const denyInstruction =
|
|
2485
|
+
decision.denialSource === "hard-deny"
|
|
2486
|
+
? LOCAL_HARD_DENY_AGENT_INSTRUCTION
|
|
2487
|
+
: decision.recoveryCommand === "/auto-review-break-glass"
|
|
2488
|
+
? REVIEWER_CRITICAL_DENY_AGENT_INSTRUCTION
|
|
2489
|
+
: decision.recoveryCommand === "/auto-review-approve"
|
|
2490
|
+
? REVIEWER_NONCRITICAL_DENY_AGENT_INSTRUCTION
|
|
2491
|
+
: "Automatic policy critically denied this action and break-glass authorization is disabled. Do not retry, rephrase, or circumvent it.";
|
|
2492
|
+
return {
|
|
2493
|
+
kind: "deny",
|
|
2494
|
+
reason: `${decision.review.rationale} ${denyInstruction}`,
|
|
2495
|
+
};
|
|
2496
|
+
},
|
|
2497
|
+
);
|
|
2498
|
+
if (shuttingDown || epoch !== registrationEpoch || !context) {
|
|
2499
|
+
dispose();
|
|
2500
|
+
return;
|
|
2591
2501
|
}
|
|
2592
|
-
|
|
2593
|
-
|
|
2502
|
+
disposeAuthorizer?.();
|
|
2503
|
+
disposeAuthorizer = dispose;
|
|
2504
|
+
registeredSessionId = sessionId;
|
|
2505
|
+
writeOptionalAuditFile({
|
|
2506
|
+
type: "authorizer_registered",
|
|
2507
|
+
sessionId,
|
|
2508
|
+
});
|
|
2509
|
+
} catch (error) {
|
|
2510
|
+
dispose?.();
|
|
2594
2511
|
console.error(
|
|
2595
|
-
`${EXTENSION_NAME}:
|
|
2512
|
+
`${EXTENSION_NAME}: authorizer registration failed: ${
|
|
2596
2513
|
error instanceof Error ? error.message : String(error)
|
|
2597
2514
|
}`,
|
|
2598
2515
|
);
|
|
2599
|
-
}
|
|
2516
|
+
}
|
|
2600
2517
|
});
|
|
2601
2518
|
|
|
2602
2519
|
pi.on("session_shutdown", () => {
|
|
2603
2520
|
shuttingDown = true;
|
|
2604
2521
|
registrationEpoch++;
|
|
2605
|
-
|
|
2606
|
-
reviewEntryBatcher.flushAll();
|
|
2522
|
+
reviewWidget.clear(context);
|
|
2607
2523
|
disposeAuthorizer?.();
|
|
2608
2524
|
disposeAuthorizer = undefined;
|
|
2609
2525
|
registeredSessionId = undefined;
|
|
@@ -36,6 +36,21 @@ export type SandboxRequestContext = {
|
|
|
36
36
|
agentName?: string;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
export function parseHostPort(target: string | undefined): {
|
|
40
|
+
host: string;
|
|
41
|
+
port: number | undefined;
|
|
42
|
+
} | undefined {
|
|
43
|
+
if (!target) return undefined;
|
|
44
|
+
const lastColon = target.lastIndexOf(":");
|
|
45
|
+
if (lastColon <= 0) return { host: target, port: undefined };
|
|
46
|
+
const portStr = target.slice(lastColon + 1);
|
|
47
|
+
const port = Number(portStr);
|
|
48
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
49
|
+
return { host: target, port: undefined };
|
|
50
|
+
}
|
|
51
|
+
return { host: target.slice(0, lastColon), port };
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
export function sandboxTrapToBoundaryRequest(
|
|
40
55
|
trap: SandboxBoundaryTrap,
|
|
41
56
|
context: SandboxRequestContext,
|
|
@@ -66,6 +81,7 @@ export function sandboxTrapToBoundaryRequest(
|
|
|
66
81
|
},
|
|
67
82
|
};
|
|
68
83
|
}
|
|
84
|
+
const parsed = parseHostPort(trap.target);
|
|
69
85
|
return {
|
|
70
86
|
id,
|
|
71
87
|
source: "sandbox-runtime",
|
|
@@ -74,6 +90,8 @@ export function sandboxTrapToBoundaryRequest(
|
|
|
74
90
|
cwd: trap.process?.cwd || context.cwd,
|
|
75
91
|
command: context.command,
|
|
76
92
|
destination: trap.target,
|
|
93
|
+
destinationHost: parsed?.host,
|
|
94
|
+
destinationPort: parsed?.port,
|
|
77
95
|
toolName: trap.process?.exe || undefined,
|
|
78
96
|
agentName: context.agentName,
|
|
79
97
|
matchedPolicy: { decision: "ask" },
|
package/src/policy.ts
CHANGED
|
@@ -156,6 +156,9 @@ export type RelevantBoundaryRequest = {
|
|
|
156
156
|
path?: string;
|
|
157
157
|
resolvedPath?: string;
|
|
158
158
|
destination?: string;
|
|
159
|
+
destinationHost?: string;
|
|
160
|
+
destinationPort?: number;
|
|
161
|
+
destinationProtocol?: string;
|
|
159
162
|
toolCallId?: string;
|
|
160
163
|
toolName?: string;
|
|
161
164
|
toolInputPreview?: string;
|
|
@@ -1098,6 +1101,9 @@ function toolArgumentCoveredByRequest(
|
|
|
1098
1101
|
path: request.path,
|
|
1099
1102
|
resolvedPath: request.resolvedPath,
|
|
1100
1103
|
destination: request.destination,
|
|
1104
|
+
destinationHost: request.destinationHost,
|
|
1105
|
+
destinationPort: request.destinationPort,
|
|
1106
|
+
destinationProtocol: request.destinationProtocol,
|
|
1101
1107
|
cwd: request.cwd,
|
|
1102
1108
|
agentName: request.agentName,
|
|
1103
1109
|
requesterSessionId: request.requesterSessionId,
|
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
|
}
|
|
@@ -259,8 +265,10 @@ function outcomeAccent(
|
|
|
259
265
|
case "auto_confirm":
|
|
260
266
|
case "needs_confirmation":
|
|
261
267
|
return "success";
|
|
268
|
+
// Deferred also warns: it awaits a human decision, so it must be clearly
|
|
269
|
+
// visible (theme warning) and stand out until resolved.
|
|
262
270
|
case "defer":
|
|
263
|
-
return "
|
|
271
|
+
return "warning";
|
|
264
272
|
case "deny":
|
|
265
273
|
case "circuit_breaker":
|
|
266
274
|
return "warning";
|
|
@@ -400,7 +408,6 @@ export function renderUserReviewQuoteLines(
|
|
|
400
408
|
const contentWidth = Math.max(1, width);
|
|
401
409
|
const verb = outcomeVerb(data.outcome);
|
|
402
410
|
const verbAnsi = theme.getFgAnsi(outcomeAccent(data.outcome));
|
|
403
|
-
const numberAnsi = theme.getFgAnsi("success");
|
|
404
411
|
const rendered: string[] = [];
|
|
405
412
|
for (const [index, line] of data.lines.entries()) {
|
|
406
413
|
for (const visual of wrapReviewDisplayText(line, contentWidth)) {
|
|
@@ -411,12 +418,6 @@ export function renderUserReviewQuoteLines(
|
|
|
411
418
|
`${verbAnsi}${verb}\x1b[0m`,
|
|
412
419
|
);
|
|
413
420
|
}
|
|
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
421
|
rendered.push(withQuoteStyle(theme, painted));
|
|
421
422
|
}
|
|
422
423
|
}
|
|
@@ -528,34 +529,6 @@ export function renderUserReviewEntry(
|
|
|
528
529
|
};
|
|
529
530
|
}
|
|
530
531
|
|
|
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
532
|
/** Best-effort toast; never throws into the authorizer path. */
|
|
560
533
|
export function notifyUserReview(
|
|
561
534
|
ctx: ExtensionContext | undefined,
|
|
@@ -569,184 +542,160 @@ export function notifyUserReview(
|
|
|
569
542
|
}
|
|
570
543
|
}
|
|
571
544
|
|
|
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>;
|
|
545
|
+
export type UserReviewWidgetData = UserReviewEntryData & {
|
|
546
|
+
phase: "reviewing" | "complete";
|
|
595
547
|
};
|
|
596
548
|
|
|
597
|
-
export
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
549
|
+
export function buildUserReviewingWidgetData(input: {
|
|
550
|
+
surface: string;
|
|
551
|
+
target?: string;
|
|
552
|
+
model?: string;
|
|
553
|
+
}): UserReviewWidgetData {
|
|
554
|
+
const target = truncateReviewText(input.target, 100);
|
|
555
|
+
const model = formatReviewModelName(input.model) ?? "reviewer";
|
|
556
|
+
return {
|
|
557
|
+
phase: "reviewing",
|
|
558
|
+
outcome: "defer",
|
|
559
|
+
type: "info",
|
|
560
|
+
lines: joinNoticeLines([
|
|
561
|
+
`Auto-review · reviewing · ${input.surface}`,
|
|
562
|
+
target,
|
|
563
|
+
`Waiting for ${model}…`,
|
|
564
|
+
]),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
603
567
|
|
|
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
|
-
}
|
|
568
|
+
export function buildUserReviewWidgetData(
|
|
569
|
+
input: UserReviewNoticeInput,
|
|
570
|
+
): UserReviewWidgetData {
|
|
571
|
+
return {
|
|
572
|
+
phase: "complete",
|
|
573
|
+
...buildUserReviewEntryData(input),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
621
576
|
|
|
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);
|
|
577
|
+
export function renderUserReviewWidgetLines(
|
|
578
|
+
data: UserReviewWidgetData,
|
|
579
|
+
theme: UserReviewTheme,
|
|
580
|
+
width: number,
|
|
581
|
+
): string[] {
|
|
582
|
+
const rendered: string[] = [];
|
|
583
|
+
const verb = data.phase === "reviewing"
|
|
584
|
+
? "reviewing"
|
|
585
|
+
: outcomeVerb(data.outcome);
|
|
586
|
+
const accent = data.phase === "reviewing"
|
|
587
|
+
? "muted"
|
|
588
|
+
: outcomeAccent(data.outcome);
|
|
589
|
+
const verbAnsi = theme.getFgAnsi(accent);
|
|
590
|
+
const mutedAnsi = theme.getFgAnsi("muted");
|
|
591
|
+
for (const [index, line] of data.lines.entries()) {
|
|
592
|
+
for (const visual of wrapReviewDisplayText(line, Math.max(1, width))) {
|
|
593
|
+
let painted = `${mutedAnsi}${visual}\x1b[0m`;
|
|
594
|
+
if (index === 0) {
|
|
595
|
+
painted = painted.replace(
|
|
596
|
+
verb,
|
|
597
|
+
`${verbAnsi}${verb}\x1b[0m${mutedAnsi}`,
|
|
598
|
+
);
|
|
669
599
|
}
|
|
670
|
-
|
|
671
|
-
presentUserReview(this.pi, input.ctx, input.notice, input.data);
|
|
600
|
+
rendered.push(painted);
|
|
672
601
|
}
|
|
673
602
|
}
|
|
603
|
+
return rendered;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function setWidget(
|
|
607
|
+
ctx: ExtensionContext | undefined,
|
|
608
|
+
data: UserReviewWidgetData | undefined,
|
|
609
|
+
): boolean {
|
|
610
|
+
if (!ctx?.hasUI || ctx.mode !== "tui") return false;
|
|
611
|
+
try {
|
|
612
|
+
ctx.ui.setWidget(
|
|
613
|
+
USER_REVIEW_WIDGET_KEY,
|
|
614
|
+
data
|
|
615
|
+
? (_tui, theme) => ({
|
|
616
|
+
render: (width: number) =>
|
|
617
|
+
renderUserReviewWidgetLines(data, theme, width),
|
|
618
|
+
invalidate() {},
|
|
619
|
+
})
|
|
620
|
+
: undefined,
|
|
621
|
+
{ placement: "aboveEditor" },
|
|
622
|
+
);
|
|
623
|
+
return true;
|
|
624
|
+
} catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
674
628
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
629
|
+
/** Owns the single live widget and rejects stale concurrent completions. */
|
|
630
|
+
export class UserReviewWidgetController {
|
|
631
|
+
#generation = 0;
|
|
632
|
+
#current?: {
|
|
633
|
+
requestId: string;
|
|
634
|
+
generation: number;
|
|
635
|
+
ctx: ExtensionContext;
|
|
636
|
+
data?: UserReviewWidgetData;
|
|
637
|
+
notice?: UserReviewNotice;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
begin(
|
|
641
|
+
requestId: string,
|
|
642
|
+
ctx: ExtensionContext,
|
|
643
|
+
input: { surface: string; target?: string; model?: string },
|
|
644
|
+
): number {
|
|
645
|
+
const generation = ++this.#generation;
|
|
646
|
+
this.#current = { requestId, generation, ctx };
|
|
647
|
+
setWidget(ctx, buildUserReviewingWidgetData(input));
|
|
648
|
+
return generation;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
complete(
|
|
652
|
+
requestId: string,
|
|
653
|
+
generation: number,
|
|
654
|
+
ctx: ExtensionContext,
|
|
655
|
+
notice: UserReviewNotice,
|
|
656
|
+
data: UserReviewWidgetData,
|
|
679
657
|
): 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);
|
|
658
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
659
|
+
notifyUserReview(ctx, notice);
|
|
660
|
+
return;
|
|
687
661
|
}
|
|
688
|
-
this.#
|
|
662
|
+
if (!this.#isCurrent(requestId, generation)) return;
|
|
663
|
+
this.#current = { requestId, generation, ctx, notice, data };
|
|
664
|
+
if (!setWidget(ctx, data)) notifyUserReview(ctx, notice);
|
|
689
665
|
}
|
|
690
666
|
|
|
691
667
|
permissionDecision(event: unknown): void {
|
|
692
668
|
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
|
693
669
|
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}`;
|
|
670
|
+
const current = this.#current;
|
|
671
|
+
if (
|
|
672
|
+
record.result !== "deny" ||
|
|
673
|
+
typeof record.requestId !== "string" ||
|
|
674
|
+
record.requestId !== current?.requestId ||
|
|
675
|
+
!current.data ||
|
|
676
|
+
current.data.lines.includes("Local confirmation · denied")
|
|
677
|
+
) return;
|
|
678
|
+
const data = {
|
|
679
|
+
...current.data,
|
|
680
|
+
lines: [...current.data.lines, "Local confirmation · denied"],
|
|
681
|
+
};
|
|
682
|
+
const notice = {
|
|
683
|
+
type: current.notice?.type ?? data.type,
|
|
684
|
+
message: data.lines.join("\n"),
|
|
685
|
+
};
|
|
686
|
+
this.#current = { ...current, data, notice };
|
|
687
|
+
if (!setWidget(current.ctx, data)) notifyUserReview(current.ctx, notice);
|
|
715
688
|
}
|
|
716
689
|
|
|
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
|
-
}
|
|
690
|
+
clear(ctx?: ExtensionContext): void {
|
|
691
|
+
++this.#generation;
|
|
692
|
+
const current = this.#current;
|
|
693
|
+
this.#current = undefined;
|
|
694
|
+
if (current) setWidget(ctx ?? current.ctx, undefined);
|
|
737
695
|
}
|
|
738
|
-
}
|
|
739
696
|
|
|
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.
|
|
697
|
+
#isCurrent(requestId: string, generation: number): boolean {
|
|
698
|
+
return this.#current?.requestId === requestId &&
|
|
699
|
+
this.#current.generation === generation;
|
|
751
700
|
}
|
|
752
701
|
}
|