@erichll/pi-auto-review 0.8.1 → 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 +31 -0
- package/README.md +12 -5
- package/package.json +3 -3
- package/src/index.ts +249 -38
- package/src/user-feedback.ts +657 -52
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
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
|
+
|
|
25
|
+
## 0.8.2 - 2026-08-21
|
|
26
|
+
|
|
27
|
+
- Structure interactive review results as headline, target, rationale, and a
|
|
28
|
+
meta line with the reviewer model name, input/output tokens, duration, and extra
|
|
29
|
+
calls. The TUI renders them as markdown-style quote rows (`│` + `mdQuote`),
|
|
30
|
+
with allow/deny verbs and token counts tinted from the theme. The model id is
|
|
31
|
+
shown without a provider prefix; audit still records the full `provider/id`.
|
|
32
|
+
Local hard denies omit model and token fields because no model ran.
|
|
33
|
+
|
|
3
34
|
## 0.8.1 - 2026-08-21
|
|
4
35
|
|
|
5
36
|
- Restore TUI auto-confirm for capped `path` / `external_directory` allows on
|
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,9 +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
|
-
|
|
137
|
-
breaker outcomes.
|
|
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.
|
|
138
145
|
|
|
139
146
|
In an interactive TUI, `/auto-review-approve` lists up to ten recent
|
|
140
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
|
@@ -37,12 +37,18 @@ import {
|
|
|
37
37
|
} from "./broker/index.ts";
|
|
38
38
|
import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
|
|
39
39
|
import {
|
|
40
|
+
buildUserReviewEntryData,
|
|
40
41
|
buildUserReviewNotice,
|
|
41
42
|
buildUserReviewStatus,
|
|
42
43
|
notifyUserReview,
|
|
44
|
+
presentUserReview,
|
|
45
|
+
ReviewEntryBatcher,
|
|
46
|
+
renderUserReviewEntry,
|
|
43
47
|
reviewTargetFromRequest,
|
|
44
48
|
setUserReviewStatus,
|
|
49
|
+
USER_REVIEW_ENTRY_TYPE,
|
|
45
50
|
type UserReviewOutcome,
|
|
51
|
+
type UserReviewUsage,
|
|
46
52
|
} from "./user-feedback.ts";
|
|
47
53
|
|
|
48
54
|
export {
|
|
@@ -268,9 +274,6 @@ const USER_CONFIG_RELATIVE_PATH = join(
|
|
|
268
274
|
"pi-auto-review",
|
|
269
275
|
"config.json",
|
|
270
276
|
);
|
|
271
|
-
const PERMISSIONS_SERVICE_KEY = Symbol.for(
|
|
272
|
-
"@gotgenes/pi-permission-system:service",
|
|
273
|
-
);
|
|
274
277
|
const BOUNDED_SURFACES = new Set(["path", "external_directory"]);
|
|
275
278
|
const REVIEWER_FRAMING_RESERVE_TOKENS = 64;
|
|
276
279
|
const REVIEWER_RETRY_DELAY_MS = 250;
|
|
@@ -723,10 +726,50 @@ type PermissionsService = {
|
|
|
723
726
|
): () => void;
|
|
724
727
|
};
|
|
725
728
|
|
|
726
|
-
function
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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;
|
|
730
773
|
}
|
|
731
774
|
|
|
732
775
|
function boundaryRequest(
|
|
@@ -1330,6 +1373,33 @@ function aggregateUsage(attempts: readonly ReviewAttemptObservation[]): {
|
|
|
1330
1373
|
return { availability, usage };
|
|
1331
1374
|
}
|
|
1332
1375
|
|
|
1376
|
+
function userReviewMetaFromResult(
|
|
1377
|
+
result: ReviewResult | undefined,
|
|
1378
|
+
fallbackModel: string,
|
|
1379
|
+
): {
|
|
1380
|
+
model?: string;
|
|
1381
|
+
usage?: UserReviewUsage;
|
|
1382
|
+
durationMs?: number;
|
|
1383
|
+
attempts?: number;
|
|
1384
|
+
} {
|
|
1385
|
+
if (!result) return {};
|
|
1386
|
+
const attempts = result.summary.attempts;
|
|
1387
|
+
const aggregate = aggregateUsage(attempts);
|
|
1388
|
+
return {
|
|
1389
|
+
...(attempts.length > 0
|
|
1390
|
+
? {
|
|
1391
|
+
model: attempts.at(-1)?.model ?? fallbackModel,
|
|
1392
|
+
usage: {
|
|
1393
|
+
availability: aggregate.availability,
|
|
1394
|
+
...aggregate.usage,
|
|
1395
|
+
},
|
|
1396
|
+
}
|
|
1397
|
+
: {}),
|
|
1398
|
+
durationMs: result.durationMs,
|
|
1399
|
+
attempts: result.attempts,
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1333
1403
|
function completeTelemetry(
|
|
1334
1404
|
request: BoundaryRequest,
|
|
1335
1405
|
config: Readonly<Config>,
|
|
@@ -1885,9 +1955,17 @@ export function createPiAutoReviewExtension(
|
|
|
1885
1955
|
process.env.PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV === "1";
|
|
1886
1956
|
|
|
1887
1957
|
return (pi: ExtensionAPI): void => {
|
|
1958
|
+
try {
|
|
1959
|
+
pi.registerEntryRenderer(USER_REVIEW_ENTRY_TYPE, renderUserReviewEntry);
|
|
1960
|
+
} catch {
|
|
1961
|
+
// Renderer registration is observational.
|
|
1962
|
+
}
|
|
1888
1963
|
let context: ExtensionContext | undefined;
|
|
1889
1964
|
let config: Readonly<Config> = trustedConfig;
|
|
1890
1965
|
let disposeAuthorizer: (() => void) | undefined;
|
|
1966
|
+
let registeredSessionId: string | undefined;
|
|
1967
|
+
let registrationEpoch = 0;
|
|
1968
|
+
let shuttingDown = false;
|
|
1891
1969
|
let disposeBrokerService: (() => void) | undefined;
|
|
1892
1970
|
const reviewResults = new Map<string, ReviewResult>();
|
|
1893
1971
|
const telemetryCompleted = new Set<string>();
|
|
@@ -1898,6 +1976,7 @@ export function createPiAutoReviewExtension(
|
|
|
1898
1976
|
const uiAutoConfirmer = new PermissionUiAutoConfirmer(
|
|
1899
1977
|
() => config.autoConfirmBoundedAllows,
|
|
1900
1978
|
);
|
|
1979
|
+
const reviewEntryBatcher = new ReviewEntryBatcher(pi);
|
|
1901
1980
|
|
|
1902
1981
|
const emitTelemetry = (event: ReviewerTelemetryEvent): void => {
|
|
1903
1982
|
writeOptionalAuditFile(event);
|
|
@@ -1938,6 +2017,23 @@ export function createPiAutoReviewExtension(
|
|
|
1938
2017
|
const execution = error instanceof ReviewExecutionError
|
|
1939
2018
|
? error
|
|
1940
2019
|
: new ReviewExecutionError("unknown", noModelSummary());
|
|
2020
|
+
if (request.source === "permission-system") {
|
|
2021
|
+
reviewResults.set(request.id, {
|
|
2022
|
+
decision: {
|
|
2023
|
+
outcome: config.failureMode,
|
|
2024
|
+
risk_level: "high",
|
|
2025
|
+
user_authorization: "unknown",
|
|
2026
|
+
rationale: "Automatic review is unavailable.",
|
|
2027
|
+
},
|
|
2028
|
+
attempts: execution.summary.attempts.length,
|
|
2029
|
+
retryErrors: execution.summary.attempts
|
|
2030
|
+
.map((attempt) => attempt.errorClass)
|
|
2031
|
+
.filter((errorClass) => errorClass !== "none"),
|
|
2032
|
+
durationMs: execution.summary.durationMs,
|
|
2033
|
+
transcript: execution.summary.transcript,
|
|
2034
|
+
summary: execution.summary,
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
1941
2037
|
emitTelemetry(
|
|
1942
2038
|
completeTelemetry(
|
|
1943
2039
|
request,
|
|
@@ -2213,6 +2309,12 @@ export function createPiAutoReviewExtension(
|
|
|
2213
2309
|
});
|
|
2214
2310
|
|
|
2215
2311
|
pi.on("session_start", (_event, ctx) => {
|
|
2312
|
+
shuttingDown = false;
|
|
2313
|
+
registrationEpoch++;
|
|
2314
|
+
disposeAuthorizer?.();
|
|
2315
|
+
disposeAuthorizer = undefined;
|
|
2316
|
+
registeredSessionId = undefined;
|
|
2317
|
+
reviewEntryBatcher.flushAll();
|
|
2216
2318
|
disposeBrokerService?.();
|
|
2217
2319
|
broker?.clear();
|
|
2218
2320
|
reviewResults.clear();
|
|
@@ -2226,7 +2328,17 @@ export function createPiAutoReviewExtension(
|
|
|
2226
2328
|
);
|
|
2227
2329
|
context = ctx;
|
|
2228
2330
|
broker = createBroker();
|
|
2229
|
-
|
|
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
|
+
}
|
|
2230
2342
|
} catch (error) {
|
|
2231
2343
|
context = undefined;
|
|
2232
2344
|
broker = undefined;
|
|
@@ -2246,12 +2358,57 @@ export function createPiAutoReviewExtension(
|
|
|
2246
2358
|
if (context) uiAutoConfirmer.handlePrompt(event, context);
|
|
2247
2359
|
});
|
|
2248
2360
|
|
|
2249
|
-
pi.events.on("permissions:
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
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) {
|
|
2253
2393
|
disposeAuthorizer?.();
|
|
2254
|
-
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(
|
|
2255
2412
|
EXTENSION_NAME,
|
|
2256
2413
|
async (details, query, log: AuthorizerLog) => {
|
|
2257
2414
|
const evidence = normalizePermissionEvidence(details);
|
|
@@ -2263,13 +2420,16 @@ export function createPiAutoReviewExtension(
|
|
|
2263
2420
|
surface,
|
|
2264
2421
|
reason,
|
|
2265
2422
|
});
|
|
2266
|
-
|
|
2423
|
+
const unavailable = {
|
|
2424
|
+
outcome: "unavailable" as const,
|
|
2425
|
+
surface,
|
|
2426
|
+
rationale: reason,
|
|
2427
|
+
};
|
|
2428
|
+
presentUserReview(
|
|
2429
|
+
pi,
|
|
2267
2430
|
context,
|
|
2268
|
-
buildUserReviewNotice(
|
|
2269
|
-
|
|
2270
|
-
surface,
|
|
2271
|
-
rationale: reason,
|
|
2272
|
-
}),
|
|
2431
|
+
buildUserReviewNotice(unavailable),
|
|
2432
|
+
buildUserReviewEntryData(unavailable),
|
|
2273
2433
|
);
|
|
2274
2434
|
return { kind: "deny", reason };
|
|
2275
2435
|
}
|
|
@@ -2310,24 +2470,50 @@ export function createPiAutoReviewExtension(
|
|
|
2310
2470
|
} else {
|
|
2311
2471
|
userOutcome = "deny";
|
|
2312
2472
|
}
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
decision.
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
);
|
|
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
|
+
}
|
|
2326
2510
|
|
|
2327
2511
|
log.review("pi_auto_review_decision", {
|
|
2328
2512
|
requestId: request.id,
|
|
2513
|
+
toolCallId: request.toolCallId,
|
|
2329
2514
|
surface,
|
|
2330
2515
|
model: config.model,
|
|
2516
|
+
reviewerModel: reviewMeta.model,
|
|
2331
2517
|
outcome: allowCapped ? "defer" : decision.kind,
|
|
2332
2518
|
reviewerOutcome: decision.review.outcome,
|
|
2333
2519
|
riskLevel: decision.review.riskLevel,
|
|
@@ -2343,6 +2529,8 @@ export function createPiAutoReviewExtension(
|
|
|
2343
2529
|
attempts: result?.attempts ?? 0,
|
|
2344
2530
|
retryErrors: result?.retryErrors ?? [],
|
|
2345
2531
|
durationMs: result?.durationMs,
|
|
2532
|
+
usageAvailability: reviewMeta.usage?.availability,
|
|
2533
|
+
usage: reviewMeta.usage,
|
|
2346
2534
|
transcriptUserCharacters: result?.transcript.userCharacters,
|
|
2347
2535
|
transcriptToolCharacters: result?.transcript.toolCharacters,
|
|
2348
2536
|
transcriptRelevantResultCharacters:
|
|
@@ -2380,22 +2568,45 @@ export function createPiAutoReviewExtension(
|
|
|
2380
2568
|
} finally {
|
|
2381
2569
|
setUserReviewStatus(context, undefined);
|
|
2382
2570
|
}
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
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;
|
|
2387
2594
|
console.error(
|
|
2388
|
-
`${EXTENSION_NAME}:
|
|
2595
|
+
`${EXTENSION_NAME}: permission-system import failed: ${
|
|
2389
2596
|
error instanceof Error ? error.message : String(error)
|
|
2390
2597
|
}`,
|
|
2391
2598
|
);
|
|
2392
|
-
}
|
|
2599
|
+
});
|
|
2393
2600
|
});
|
|
2394
2601
|
|
|
2395
2602
|
pi.on("session_shutdown", () => {
|
|
2603
|
+
shuttingDown = true;
|
|
2604
|
+
registrationEpoch++;
|
|
2396
2605
|
setUserReviewStatus(context, undefined);
|
|
2606
|
+
reviewEntryBatcher.flushAll();
|
|
2397
2607
|
disposeAuthorizer?.();
|
|
2398
2608
|
disposeAuthorizer = undefined;
|
|
2609
|
+
registeredSessionId = undefined;
|
|
2399
2610
|
disposeBrokerService?.();
|
|
2400
2611
|
disposeBrokerService = undefined;
|
|
2401
2612
|
broker?.clear();
|
package/src/user-feedback.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
5
|
|
|
3
6
|
export const USER_REVIEW_STATUS_KEY = "pi-auto-review";
|
|
7
|
+
export const USER_REVIEW_ENTRY_TYPE = "pi-auto-review";
|
|
4
8
|
|
|
5
9
|
export type UserReviewOutcome =
|
|
6
10
|
| "allow"
|
|
@@ -11,6 +15,23 @@ export type UserReviewOutcome =
|
|
|
11
15
|
| "circuit_breaker"
|
|
12
16
|
| "unavailable";
|
|
13
17
|
|
|
18
|
+
export type UserReviewUsageAvailability =
|
|
19
|
+
| "reported"
|
|
20
|
+
| "estimated"
|
|
21
|
+
| "unavailable"
|
|
22
|
+
| "unknown_provenance";
|
|
23
|
+
|
|
24
|
+
export type UserReviewUsage = {
|
|
25
|
+
availability: UserReviewUsageAvailability;
|
|
26
|
+
input?: number;
|
|
27
|
+
output?: number;
|
|
28
|
+
cacheRead?: number;
|
|
29
|
+
cacheWrite?: number;
|
|
30
|
+
reasoning?: number;
|
|
31
|
+
totalTokens?: number;
|
|
32
|
+
observedInputTokens?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
14
35
|
export type UserReviewNotice = {
|
|
15
36
|
message: string;
|
|
16
37
|
type: "info" | "warning" | "error";
|
|
@@ -41,7 +62,7 @@ export function reviewTargetFromRequest(request: {
|
|
|
41
62
|
request.toolName ??
|
|
42
63
|
request.skillName ??
|
|
43
64
|
request.operation;
|
|
44
|
-
const compact = compactReviewText(target,
|
|
65
|
+
const compact = compactReviewText(target, 100);
|
|
45
66
|
return compact || undefined;
|
|
46
67
|
}
|
|
47
68
|
|
|
@@ -52,70 +73,486 @@ export function buildUserReviewStatus(
|
|
|
52
73
|
): string {
|
|
53
74
|
const compact = compactReviewText(target, 48);
|
|
54
75
|
return compact
|
|
55
|
-
? `auto-review
|
|
56
|
-
: `auto-review
|
|
76
|
+
? `auto-review · reviewing · ${surface} · ${compact}`
|
|
77
|
+
: `auto-review · reviewing · ${surface}`;
|
|
57
78
|
}
|
|
58
79
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
80
|
+
export function formatReviewTokenCount(count: number): string {
|
|
81
|
+
if (!Number.isFinite(count) || count < 0) return "0";
|
|
82
|
+
if (count < 1000) return String(Math.round(count));
|
|
83
|
+
if (count < 10_000) {
|
|
84
|
+
return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
85
|
+
}
|
|
86
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
87
|
+
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function formatReviewDuration(durationMs: number): string | undefined {
|
|
91
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) return undefined;
|
|
92
|
+
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
|
|
93
|
+
const seconds = durationMs / 1000;
|
|
94
|
+
return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatReviewUsage(usage: UserReviewUsage): string | undefined {
|
|
98
|
+
if (usage.availability === "unavailable") return undefined;
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
const input = usage.input ?? usage.observedInputTokens;
|
|
101
|
+
if (input !== undefined) {
|
|
102
|
+
const cache =
|
|
103
|
+
usage.cacheRead && usage.cacheRead > 0
|
|
104
|
+
? ` (${formatReviewTokenCount(usage.cacheRead)} toks cache)`
|
|
105
|
+
: "";
|
|
106
|
+
parts.push(`${formatReviewTokenCount(input)} toks in${cache}`);
|
|
107
|
+
}
|
|
108
|
+
if (usage.output !== undefined) {
|
|
109
|
+
parts.push(`${formatReviewTokenCount(usage.output)} toks out`);
|
|
110
|
+
}
|
|
111
|
+
if (parts.length === 0 && usage.totalTokens !== undefined) {
|
|
112
|
+
parts.push(`${formatReviewTokenCount(usage.totalTokens)} toks`);
|
|
113
|
+
}
|
|
114
|
+
if (parts.length === 0) return undefined;
|
|
115
|
+
const approx = usage.availability === "estimated" ? "~" : "";
|
|
116
|
+
return `${approx}${parts.join(" · ")}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Display-only model id; drop a leading provider segment when present. */
|
|
120
|
+
export function formatReviewModelName(model: unknown): string | undefined {
|
|
121
|
+
const raw = String(model ?? "")
|
|
122
|
+
.replace(/\s+/g, " ")
|
|
123
|
+
.trim();
|
|
124
|
+
if (!raw) return undefined;
|
|
125
|
+
const slash = raw.indexOf("/");
|
|
126
|
+
const name =
|
|
127
|
+
slash > 0 && slash < raw.length - 1 ? raw.slice(slash + 1) : raw;
|
|
128
|
+
return compactReviewText(name, 42) || undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function formatReviewMeta(input: {
|
|
132
|
+
model?: string;
|
|
133
|
+
usage?: UserReviewUsage;
|
|
134
|
+
durationMs?: number;
|
|
135
|
+
attempts?: number;
|
|
136
|
+
}): string | undefined {
|
|
137
|
+
const parts: string[] = [];
|
|
138
|
+
const model = formatReviewModelName(input.model);
|
|
139
|
+
if (model) parts.push(model);
|
|
140
|
+
if (input.usage) {
|
|
141
|
+
const usage = formatReviewUsage(input.usage);
|
|
142
|
+
if (usage) parts.push(usage);
|
|
143
|
+
}
|
|
144
|
+
const duration = input.durationMs !== undefined
|
|
145
|
+
? formatReviewDuration(input.durationMs)
|
|
146
|
+
: undefined;
|
|
147
|
+
if (duration) parts.push(duration);
|
|
148
|
+
if (input.attempts && input.attempts > 1) {
|
|
149
|
+
parts.push(`${input.attempts} calls`);
|
|
150
|
+
}
|
|
151
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function reviewHeadline(outcome: UserReviewOutcome, surface: string): string {
|
|
155
|
+
switch (outcome) {
|
|
156
|
+
case "allow":
|
|
157
|
+
return `Auto-review · allowed · ${surface}`;
|
|
158
|
+
case "auto_confirm":
|
|
159
|
+
return `Auto-review · allowed · auto-confirm · ${surface}`;
|
|
160
|
+
case "needs_confirmation":
|
|
161
|
+
return `Auto-review · allowed · confirm locally · ${surface}`;
|
|
162
|
+
case "defer":
|
|
163
|
+
return `Auto-review · deferred · ${surface}`;
|
|
164
|
+
case "deny":
|
|
165
|
+
return `Auto-review · denied · ${surface}`;
|
|
166
|
+
case "circuit_breaker":
|
|
167
|
+
return `Auto-review · stopped · ${surface}`;
|
|
168
|
+
case "unavailable":
|
|
169
|
+
return `Auto-review · unavailable · ${surface}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function recoveryLine(
|
|
174
|
+
outcome: UserReviewOutcome,
|
|
175
|
+
recoveryCommand?: "/auto-review-approve" | "/auto-review-break-glass" | false,
|
|
176
|
+
): string | undefined {
|
|
177
|
+
if (outcome === "circuit_breaker") {
|
|
178
|
+
return "use /auto-review-approve for one exact retry";
|
|
179
|
+
}
|
|
180
|
+
if (outcome !== "deny") return undefined;
|
|
181
|
+
if (recoveryCommand === false) {
|
|
182
|
+
return "local safety denials cannot be overridden";
|
|
183
|
+
}
|
|
184
|
+
if (recoveryCommand) {
|
|
185
|
+
return `use ${recoveryCommand} for one exact retry`;
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function joinNoticeLines(lines: Array<string | undefined>): string[] {
|
|
191
|
+
return lines
|
|
192
|
+
.map((line) => (typeof line === "string" ? line.trimEnd() : ""))
|
|
193
|
+
.filter((line) => line.length > 0);
|
|
194
|
+
}
|
|
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
|
+
|
|
206
|
+
export type UserReviewNoticeInput = {
|
|
64
207
|
outcome: UserReviewOutcome;
|
|
65
208
|
surface: string;
|
|
66
209
|
target?: string;
|
|
67
210
|
rationale?: string;
|
|
68
211
|
recoveryCommand?: "/auto-review-approve" | "/auto-review-break-glass" | false;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
212
|
+
model?: string;
|
|
213
|
+
usage?: UserReviewUsage;
|
|
214
|
+
durationMs?: number;
|
|
215
|
+
attempts?: number;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export type UserReviewEntryData = {
|
|
219
|
+
outcome: UserReviewOutcome;
|
|
220
|
+
type: UserReviewNotice["type"];
|
|
221
|
+
lines: string[];
|
|
222
|
+
};
|
|
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
|
+
|
|
242
|
+
type UserReviewTheme = {
|
|
243
|
+
fg(color: string, text: string): string;
|
|
244
|
+
italic(text: string): string;
|
|
245
|
+
getFgAnsi(color: string): string;
|
|
246
|
+
};
|
|
74
247
|
|
|
75
|
-
|
|
248
|
+
function noticeType(outcome: UserReviewOutcome): UserReviewNotice["type"] {
|
|
249
|
+
if (outcome === "unavailable") return "error";
|
|
250
|
+
if (outcome === "deny" || outcome === "circuit_breaker") return "warning";
|
|
251
|
+
return "info";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function outcomeAccent(
|
|
255
|
+
outcome: UserReviewOutcome,
|
|
256
|
+
): "success" | "warning" | "error" | "muted" {
|
|
257
|
+
switch (outcome) {
|
|
258
|
+
case "allow":
|
|
259
|
+
case "auto_confirm":
|
|
260
|
+
case "needs_confirmation":
|
|
261
|
+
return "success";
|
|
262
|
+
case "defer":
|
|
263
|
+
return "muted";
|
|
264
|
+
case "deny":
|
|
265
|
+
case "circuit_breaker":
|
|
266
|
+
return "warning";
|
|
267
|
+
case "unavailable":
|
|
268
|
+
return "error";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function outcomeVerb(outcome: UserReviewOutcome): string {
|
|
273
|
+
switch (outcome) {
|
|
76
274
|
case "allow":
|
|
77
|
-
return {
|
|
78
|
-
type: "info",
|
|
79
|
-
message: `Auto-review allowed ${subject}${why}`,
|
|
80
|
-
};
|
|
81
275
|
case "auto_confirm":
|
|
82
|
-
return {
|
|
83
|
-
type: "info",
|
|
84
|
-
message: `Auto-review allowed ${subject}; auto-confirming the local dialog${why}`,
|
|
85
|
-
};
|
|
86
276
|
case "needs_confirmation":
|
|
87
|
-
return
|
|
88
|
-
type: "info",
|
|
89
|
-
message: `Auto-review allowed ${subject}; local confirmation is still required${why}`,
|
|
90
|
-
};
|
|
277
|
+
return "allowed";
|
|
91
278
|
case "defer":
|
|
92
|
-
return
|
|
93
|
-
type: "info",
|
|
94
|
-
message: `Auto-review deferred ${subject} to you${why}`,
|
|
95
|
-
};
|
|
279
|
+
return "deferred";
|
|
96
280
|
case "deny":
|
|
97
|
-
return
|
|
98
|
-
type: "warning",
|
|
99
|
-
message: `Auto-review denied ${subject}${why}${
|
|
100
|
-
input.recoveryCommand === false
|
|
101
|
-
? " — local safety denials cannot be overridden"
|
|
102
|
-
: input.recoveryCommand
|
|
103
|
-
? ` — use ${input.recoveryCommand} for one exact retry`
|
|
104
|
-
: ""
|
|
105
|
-
}`,
|
|
106
|
-
};
|
|
281
|
+
return "denied";
|
|
107
282
|
case "circuit_breaker":
|
|
108
|
-
return
|
|
109
|
-
type: "warning",
|
|
110
|
-
message: `Auto-review stopped after repeated denials this turn${
|
|
111
|
-
why || " — use /auto-review-approve for one exact retry"
|
|
112
|
-
}`,
|
|
113
|
-
};
|
|
283
|
+
return "stopped";
|
|
114
284
|
case "unavailable":
|
|
115
|
-
return
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
285
|
+
return "unavailable";
|
|
286
|
+
}
|
|
287
|
+
}
|
|
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
|
+
|
|
300
|
+
export function buildUserReviewLines(input: UserReviewNoticeInput): string[] {
|
|
301
|
+
return joinNoticeLines([
|
|
302
|
+
reviewHeadline(input.outcome, input.surface),
|
|
303
|
+
reviewTargetRationaleLine(input),
|
|
304
|
+
recoveryLine(input.outcome, input.recoveryCommand),
|
|
305
|
+
formatReviewMeta({
|
|
306
|
+
model: input.model,
|
|
307
|
+
usage: input.usage,
|
|
308
|
+
durationMs: input.durationMs,
|
|
309
|
+
attempts: input.attempts,
|
|
310
|
+
}),
|
|
311
|
+
]);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* User-facing (not agent-facing) notice after a review decision.
|
|
316
|
+
* Structured for TUI wrapping: headline, target+rationale, then model/tokens.
|
|
317
|
+
*/
|
|
318
|
+
export function buildUserReviewNotice(
|
|
319
|
+
input: UserReviewNoticeInput,
|
|
320
|
+
): UserReviewNotice {
|
|
321
|
+
return {
|
|
322
|
+
type: noticeType(input.outcome),
|
|
323
|
+
message: buildUserReviewLines(input).join("\n"),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function buildUserReviewEntryData(
|
|
328
|
+
input: UserReviewNoticeInput,
|
|
329
|
+
): UserReviewEntryData {
|
|
330
|
+
return {
|
|
331
|
+
outcome: input.outcome,
|
|
332
|
+
type: noticeType(input.outcome),
|
|
333
|
+
lines: buildUserReviewLines(input),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function formatUserReviewQuoteMessage(message: string): string {
|
|
338
|
+
return message;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function displayWidth(text: string): number {
|
|
342
|
+
let width = 0;
|
|
343
|
+
for (const char of text) {
|
|
344
|
+
const code = char.codePointAt(0) ?? 0;
|
|
345
|
+
width += code > 0xff ? 2 : 1;
|
|
346
|
+
}
|
|
347
|
+
return width;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function wrapReviewDisplayText(text: string, width: number): string[] {
|
|
351
|
+
if (width <= 0) return [text];
|
|
352
|
+
const lines: string[] = [];
|
|
353
|
+
let current = "";
|
|
354
|
+
let currentWidth = 0;
|
|
355
|
+
const flush = () => {
|
|
356
|
+
if (current.length > 0) lines.push(current);
|
|
357
|
+
current = "";
|
|
358
|
+
currentWidth = 0;
|
|
359
|
+
};
|
|
360
|
+
for (const token of text.split(/(\s+)/)) {
|
|
361
|
+
const tokenWidth = displayWidth(token);
|
|
362
|
+
if (currentWidth + tokenWidth <= width) {
|
|
363
|
+
current += token;
|
|
364
|
+
currentWidth += tokenWidth;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (/^\s+$/.test(token)) {
|
|
368
|
+
flush();
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (currentWidth > 0) flush();
|
|
372
|
+
if (tokenWidth <= width) {
|
|
373
|
+
current = token;
|
|
374
|
+
currentWidth = tokenWidth;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
for (const char of token) {
|
|
378
|
+
const charWidth = (char.codePointAt(0) ?? 0) > 0xff ? 2 : 1;
|
|
379
|
+
if (currentWidth + charWidth > width) flush();
|
|
380
|
+
current += char;
|
|
381
|
+
currentWidth += charWidth;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
flush();
|
|
385
|
+
return lines.length > 0 ? lines : [""];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function withQuoteStyle(theme: UserReviewTheme, text: string): string {
|
|
389
|
+
const marker = "\u0000";
|
|
390
|
+
const prefix = theme.italic(theme.fg("mdQuote", marker)).split(marker)[0] ?? "";
|
|
391
|
+
const body = theme.italic(theme.fg("mdQuote", text));
|
|
392
|
+
return prefix ? body.replace(/\x1b\[0m/g, `\x1b[0m${prefix}`) : body;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function renderUserReviewQuoteLines(
|
|
396
|
+
data: UserReviewEntryData,
|
|
397
|
+
theme: UserReviewTheme,
|
|
398
|
+
width: number,
|
|
399
|
+
): string[] {
|
|
400
|
+
const contentWidth = Math.max(1, width);
|
|
401
|
+
const verb = outcomeVerb(data.outcome);
|
|
402
|
+
const verbAnsi = theme.getFgAnsi(outcomeAccent(data.outcome));
|
|
403
|
+
const numberAnsi = theme.getFgAnsi("success");
|
|
404
|
+
const rendered: string[] = [];
|
|
405
|
+
for (const [index, line] of data.lines.entries()) {
|
|
406
|
+
for (const visual of wrapReviewDisplayText(line, contentWidth)) {
|
|
407
|
+
let painted = visual;
|
|
408
|
+
if (index === 0) {
|
|
409
|
+
painted = painted.replace(
|
|
410
|
+
verb,
|
|
411
|
+
`${verbAnsi}${verb}\x1b[0m`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
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
|
+
rendered.push(withQuoteStyle(theme, painted));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return rendered;
|
|
424
|
+
}
|
|
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
|
+
|
|
489
|
+
export function renderUserReviewEntry(
|
|
490
|
+
entry: { data?: unknown },
|
|
491
|
+
_options: unknown,
|
|
492
|
+
theme: UserReviewTheme,
|
|
493
|
+
): { render(width: number): string[]; invalidate(): void } | undefined {
|
|
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
|
+
}
|
|
514
|
+
if (
|
|
515
|
+
!data ||
|
|
516
|
+
typeof data !== "object" ||
|
|
517
|
+
!Array.isArray((data as UserReviewEntryData).lines) ||
|
|
518
|
+
(data as UserReviewEntryData).lines.length === 0
|
|
519
|
+
) {
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
const parsed = data as UserReviewEntryData;
|
|
523
|
+
return {
|
|
524
|
+
render(width: number) {
|
|
525
|
+
return renderUserReviewQuoteLines(parsed, theme, width);
|
|
526
|
+
},
|
|
527
|
+
invalidate() {},
|
|
528
|
+
};
|
|
529
|
+
}
|
|
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
|
+
|
|
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;
|
|
119
556
|
}
|
|
120
557
|
}
|
|
121
558
|
|
|
@@ -126,12 +563,180 @@ export function notifyUserReview(
|
|
|
126
563
|
): void {
|
|
127
564
|
if (!ctx?.hasUI) return;
|
|
128
565
|
try {
|
|
129
|
-
ctx.ui.notify(notice.message, notice.type);
|
|
566
|
+
ctx.ui.notify(formatUserReviewQuoteMessage(notice.message), notice.type);
|
|
130
567
|
} catch {
|
|
131
568
|
// UI delivery is observational and must not change a decision.
|
|
132
569
|
}
|
|
133
570
|
}
|
|
134
571
|
|
|
572
|
+
/** TUI structured entry when possible; otherwise a plain notify toast. */
|
|
573
|
+
export function presentUserReview(
|
|
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>;
|
|
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
|
+
|
|
135
740
|
/** Best-effort footer status; pass undefined to clear. */
|
|
136
741
|
export function setUserReviewStatus(
|
|
137
742
|
ctx: ExtensionContext | undefined,
|