@erichll/pi-auto-review 0.1.1 → 0.3.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/README.md CHANGED
@@ -20,14 +20,24 @@ The broker applies the following order:
20
20
 
21
21
  1. Deterministic hard denies
22
22
  2. Model review
23
- 3. Human terminal when the model returns `defer` or `failureMode` is `defer`
23
+ 3. Local terminal when the authorizer chain requires one
24
24
  4. Exact, expiring, one-use grants for external sandbox adapters
25
25
 
26
26
  Model approval cannot override `pi-permission-system`'s bounded-delegation
27
27
  checkpoint. In version 24, an authorizer's `allow` on `path` or
28
- `external_directory` is still downgraded to `defer` and reaches the human
29
- terminal. Sandbox adapters should use concrete surfaces such as
30
- `filesystem-read`, `filesystem-write`, and `network`.
28
+ `external_directory` is still downgraded to `defer`. In an interactive v24 TUI,
29
+ `autoConfirmBoundedAllows` can bind that exact model allow to the immediately
30
+ following local permission dialog and complete it as `auto_approved`. The
31
+ default enables this bridge only for `external_directory`; `path`, non-TUI
32
+ modes, UI mismatches, and reviewer defers still reach the human terminal.
33
+ Sandbox adapters should use concrete surfaces such as `filesystem-read`,
34
+ `filesystem-write`, and `network`.
35
+
36
+ The TUI bridge is request-ID-bound, expires after ten seconds, is consumed
37
+ once, and recognizes the v24 inline permission component before completing it.
38
+ Any event ordering, request, mode, component, or shared-UI wrapper mismatch
39
+ fails closed to the original dialog. This is a compatibility bridge around the
40
+ v24 delegation envelope, not permission-system authority-chain API.
31
41
 
32
42
  The broker stops automatic review for a turn after three consecutive denials
33
43
  or ten denials in the rolling last fifty reviews. An explicit denial is
@@ -67,13 +77,17 @@ Edit `src/config.json`, using `config/config.example.json` as the reference:
67
77
  "maxToolTranscriptTokens": 1200,
68
78
  "maxRelevantResultTokens": 800,
69
79
  "failureMode": "deny",
70
- "grantTtlMs": 60000
80
+ "grantTtlMs": 60000,
81
+ "autoConfirmBoundedAllows": ["external_directory"]
71
82
  }
72
83
  ```
73
84
 
74
85
  The configured provider must be registered in Pi and must support the
75
86
  configured model. `failureMode: "deny"` is the fail-closed default;
76
87
  `failureMode: "defer"` falls through to the normal human terminal.
88
+ Set `autoConfirmBoundedAllows` to `[]` to keep every bounded allow manual, or
89
+ include `"path"` as well to opt into the same TUI bridge for the cross-cutting
90
+ path surface.
77
91
 
78
92
  Add the link to the permission-system configuration:
79
93
 
@@ -100,8 +114,8 @@ import {
100
114
  } from "@erichll/pi-auto-review";
101
115
 
102
116
  const request: BoundaryRequest = {
103
- id: "landstrip-query-id",
104
- source: "landstrip",
117
+ id: "sandbox-runtime-query-id",
118
+ source: "sandbox-runtime",
105
119
  surface: "network",
106
120
  operation: "connect",
107
121
  cwd: "/workspace/project",
@@ -144,7 +158,7 @@ may additionally receive only these bounded result classes:
144
158
  a pending deletion target.
145
159
  - `git remote`, branch, status, revision, and remote configuration results for
146
160
  a pending Git push.
147
- - The current structured Landstrip trap.
161
+ - The current structured Sandbox Runtime trap.
148
162
 
149
163
  Unrelated tool results and assistant prose remain excluded. Selected results
150
164
  have a separate `maxRelevantResultTokens` budget, common secret/token forms are
@@ -152,19 +166,18 @@ redacted, and markup characters are escaped before the evidence is sent to the
152
166
  reviewer. Results remain explicitly labeled as untrusted and possibly
153
167
  incomplete.
154
168
 
155
- ## Landstrip status
169
+ ## Sandbox adapter status
156
170
 
157
171
  The broker-facing contract is implemented, but this package does not intercept
158
- Landstrip prompts by itself. A Landstrip adapter must translate its structured
159
- filesystem/network trap into `BoundaryRequest`, consume the exact grant, and
160
- then use a supported Landstrip retry or dynamic-grant mechanism.
172
+ OS sandbox events by itself. Adapters translate a concrete boundary into a
173
+ `BoundaryRequest` and must consume the exact grant before allowing the
174
+ operation.
161
175
 
162
- This monorepo also contains `pi-sandbox`, a Linux Landstrip and macOS Seatbelt
163
- integration that owns Bash and process-backed subagent execution, provides
164
- domain-based proxy approval, and consumes this broker service directly. Its
165
- Linux native paths are tested. The macOS implementation has deterministic
166
- orchestration and policy tests, but still awaits native smoke coverage on real
167
- x64/arm64 hosts. Do not load `pi-sandbox` and `pi-landstrip` together.
176
+ This monorepo's `pi-sandbox` uses Anthropic Sandbox Runtime. Filesystem policy
177
+ is static and fails closed; unmatched public network destinations use the
178
+ broker for per-connection approval. Each Bash command or built-in subagent
179
+ session owns an independent Sandbox Runtime broker process. Adapters should use
180
+ the `./sandbox` export.
168
181
 
169
182
  Do not implement the adapter by writing broad permanent rules to
170
183
  `.pi/sandbox.json`.
@@ -189,9 +202,10 @@ PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV=1 pi --approve
189
202
  The trusted package configuration is copied and frozen at `session_start`.
190
203
  An optional workspace file at `.pi/pi-auto-review.json` may only lower
191
204
  timeouts, token/evidence limits, retries, and grant TTL, or set `failureMode`
192
- to `deny`. It cannot select a model, weaken fail-closed behavior, or raise a
193
- trusted limit. Invalid project configuration disables the reviewer for that
194
- session.
205
+ to `deny`. It may remove entries from `autoConfirmBoundedAllows`, but cannot
206
+ add a surface not enabled by the trusted package configuration. It cannot
207
+ select a model, weaken fail-closed behavior, or raise a trusted limit. Invalid
208
+ project configuration disables the reviewer for that session.
195
209
 
196
210
  Writes to the installed reviewer package, project security configuration,
197
211
  global Pi security configuration, and the global audit directory are
@@ -8,5 +8,6 @@
8
8
  "maxToolTranscriptTokens": 1200,
9
9
  "maxRelevantResultTokens": 800,
10
10
  "failureMode": "deny",
11
- "grantTtlMs": 60000
11
+ "grantTtlMs": 60000,
12
+ "autoConfirmBoundedAllows": ["external_directory"]
12
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Model-backed boundary approval broker for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "exports": {
36
36
  ".": "./src/index.ts",
37
37
  "./broker": "./src/broker/index.ts",
38
- "./landstrip": "./src/integrations/landstrip.ts"
38
+ "./sandbox": "./src/integrations/sandbox.ts"
39
39
  },
40
40
  "pi": {
41
41
  "extensions": [
@@ -9,7 +9,7 @@ export type BoundarySurface =
9
9
 
10
10
  export type BoundarySource =
11
11
  | "permission-system"
12
- | "landstrip"
12
+ | "sandbox-runtime"
13
13
  | (string & {});
14
14
 
15
15
  export type BoundaryRequest = {
package/src/config.json CHANGED
@@ -8,5 +8,6 @@
8
8
  "maxToolTranscriptTokens": 1200,
9
9
  "maxRelevantResultTokens": 800,
10
10
  "failureMode": "deny",
11
- "grantTtlMs": 60000
11
+ "grantTtlMs": 60000,
12
+ "autoConfirmBoundedAllows": ["external_directory"]
12
13
  }
package/src/index.ts CHANGED
@@ -33,16 +33,17 @@ import {
33
33
  type BoundaryReview,
34
34
  type BoundaryReviewerContext,
35
35
  } from "./broker/index.ts";
36
+ import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
36
37
 
37
38
  export { parseDecision } from "./policy.ts";
38
39
  export * from "./broker/index.ts";
39
40
  export {
40
- landstripTrapToBoundaryRequest,
41
- type LandstripBoundaryTrap,
42
- type LandstripFilesystemTrap,
43
- type LandstripNetworkTrap,
44
- type LandstripRequestContext,
45
- } from "./integrations/landstrip.ts";
41
+ sandboxTrapToBoundaryRequest,
42
+ type SandboxBoundaryTrap,
43
+ type SandboxFilesystemTrap,
44
+ type SandboxNetworkTrap,
45
+ type SandboxRequestContext,
46
+ } from "./integrations/sandbox.ts";
46
47
 
47
48
  type ReasoningLevel =
48
49
  | "off"
@@ -53,6 +54,8 @@ type ReasoningLevel =
53
54
  | "xhigh"
54
55
  | "max";
55
56
 
57
+ type BoundedSurface = "external_directory" | "path";
58
+
56
59
  export type Config = {
57
60
  model: string;
58
61
  reasoning: ReasoningLevel;
@@ -64,6 +67,7 @@ export type Config = {
64
67
  maxRelevantResultTokens: number;
65
68
  failureMode: "deny" | "defer";
66
69
  grantTtlMs: number;
70
+ autoConfirmBoundedAllows: readonly BoundedSurface[];
67
71
  };
68
72
 
69
73
  type CompletionMessage = {
@@ -109,6 +113,7 @@ const DEFAULT_CONFIG: Config = {
109
113
  maxRelevantResultTokens: 800,
110
114
  failureMode: "deny",
111
115
  grantTtlMs: 60_000,
116
+ autoConfirmBoundedAllows: Object.freeze(["external_directory"]),
112
117
  };
113
118
 
114
119
  const REVIEWER_SYSTEM_PROMPT = `You are a fail-closed permission reviewer.
@@ -163,6 +168,7 @@ function validateConfig(value: unknown, source: string): Config {
163
168
  "maxRelevantResultTokens",
164
169
  "failureMode",
165
170
  "grantTtlMs",
171
+ "autoConfirmBoundedAllows",
166
172
  ]);
167
173
  const unknownKeys = Object.keys(raw).filter((key) => !allowedKeys.has(key));
168
174
  if (unknownKeys.length > 0) {
@@ -212,6 +218,18 @@ function validateConfig(value: unknown, source: string): Config {
212
218
  ) {
213
219
  throw new Error(`${EXTENSION_NAME}: grantTtlMs must be 1000..300000`);
214
220
  }
221
+ if (
222
+ !Array.isArray(config.autoConfirmBoundedAllows) ||
223
+ config.autoConfirmBoundedAllows.some(
224
+ (surface) => !BOUNDED_SURFACES.has(surface),
225
+ ) ||
226
+ new Set(config.autoConfirmBoundedAllows).size !==
227
+ config.autoConfirmBoundedAllows.length
228
+ ) {
229
+ throw new Error(
230
+ `${EXTENSION_NAME}: autoConfirmBoundedAllows must contain unique external_directory/path entries`,
231
+ );
232
+ }
215
233
  for (const [name, entry] of [
216
234
  ["maxUserTranscriptTokens", config.maxUserTranscriptTokens],
217
235
  ["maxToolTranscriptTokens", config.maxToolTranscriptTokens],
@@ -221,7 +239,12 @@ function validateConfig(value: unknown, source: string): Config {
221
239
  throw new Error(`${EXTENSION_NAME}: ${name} must be 32..8000`);
222
240
  }
223
241
  }
224
- return config;
242
+ return {
243
+ ...config,
244
+ autoConfirmBoundedAllows: Object.freeze([
245
+ ...config.autoConfirmBoundedAllows,
246
+ ]),
247
+ };
225
248
  }
226
249
 
227
250
  export function loadConfig(): Config {
@@ -260,6 +283,7 @@ export function applyProjectConfig(
260
283
  const allowed = new Set<string>([
261
284
  ...TIGHTENABLE_NUMBER_KEYS,
262
285
  "failureMode",
286
+ "autoConfirmBoundedAllows",
263
287
  ]);
264
288
  const forbidden = Object.keys(raw).filter((key) => !allowed.has(key));
265
289
  if (forbidden.length > 0) {
@@ -289,6 +313,25 @@ export function applyProjectConfig(
289
313
  }
290
314
  merged.failureMode = "deny";
291
315
  }
316
+ if (raw.autoConfirmBoundedAllows !== undefined) {
317
+ if (
318
+ !Array.isArray(raw.autoConfirmBoundedAllows) ||
319
+ raw.autoConfirmBoundedAllows.some(
320
+ (surface) =>
321
+ typeof surface !== "string" ||
322
+ !trusted.autoConfirmBoundedAllows.includes(
323
+ surface as BoundedSurface,
324
+ ),
325
+ )
326
+ ) {
327
+ throw new Error(
328
+ `${EXTENSION_NAME}: project autoConfirmBoundedAllows may only remove trusted surfaces`,
329
+ );
330
+ }
331
+ merged.autoConfirmBoundedAllows = [
332
+ ...new Set(raw.autoConfirmBoundedAllows as BoundedSurface[]),
333
+ ];
334
+ }
292
335
  return Object.freeze(validateConfig(merged, "effective project config"));
293
336
  }
294
337
 
@@ -376,6 +419,12 @@ function sessionConfig(
376
419
  }
377
420
 
378
421
  function surfaceOf(details: PromptPermissionDetails): string {
422
+ if (
423
+ typeof details.accessIntent?.surface === "string" &&
424
+ details.accessIntent.surface
425
+ ) {
426
+ return details.accessIntent.surface;
427
+ }
379
428
  if (typeof details.surface === "string" && details.surface) {
380
429
  return details.surface;
381
430
  }
@@ -721,6 +770,9 @@ export function createPiAutoReviewExtension(
721
770
  let disposeBrokerService: (() => void) | undefined;
722
771
  const reviewResults = new Map<string, ReviewResult>();
723
772
  let broker: BoundaryApprovalBroker | undefined;
773
+ const uiAutoConfirmer = new PermissionUiAutoConfirmer(
774
+ () => config.autoConfirmBoundedAllows,
775
+ );
724
776
 
725
777
  const createBroker = (): BoundaryApprovalBroker =>
726
778
  new BoundaryApprovalBroker({
@@ -831,6 +883,7 @@ export function createPiAutoReviewExtension(
831
883
  disposeBrokerService?.();
832
884
  broker?.clear();
833
885
  reviewResults.clear();
886
+ uiAutoConfirmer.clear();
834
887
  try {
835
888
  config = sessionConfig(
836
889
  ctx.cwd,
@@ -852,6 +905,10 @@ export function createPiAutoReviewExtension(
852
905
  }
853
906
  });
854
907
 
908
+ pi.events.on("permissions:ui_prompt", (event) => {
909
+ if (context) uiAutoConfirmer.handlePrompt(event, context);
910
+ });
911
+
855
912
  pi.events.on("permissions:ready", () => {
856
913
  try {
857
914
  const service = permissionsService();
@@ -880,6 +937,11 @@ export function createPiAutoReviewExtension(
880
937
  const result = reviewResults.get(request.id);
881
938
  reviewResults.delete(request.id);
882
939
  const allowCapped = decision.kind === "allow" && boundedRequest(details);
940
+ const autoConfirmQueued =
941
+ allowCapped &&
942
+ context.mode === "tui" &&
943
+ context.hasUI &&
944
+ uiAutoConfirmer.stage(request.id, surface);
883
945
  log.review("pi_auto_review_decision", {
884
946
  requestId: request.id,
885
947
  surface,
@@ -890,6 +952,7 @@ export function createPiAutoReviewExtension(
890
952
  userAuthorization: decision.review.userAuthorization,
891
953
  rationale: decision.review.rationale,
892
954
  allowCapped,
955
+ autoConfirmQueued,
893
956
  circuitBreakerTripped:
894
957
  decision.kind === "deny"
895
958
  ? decision.circuitBreakerTripped
@@ -931,6 +994,7 @@ export function createPiAutoReviewExtension(
931
994
  broker?.clear();
932
995
  broker = undefined;
933
996
  reviewResults.clear();
997
+ uiAutoConfirmer.clear();
934
998
  context = undefined;
935
999
  });
936
1000
  };
@@ -1,13 +1,12 @@
1
1
  import type { BoundaryRequest } from "../broker/types.ts";
2
2
 
3
- export type LandstripFilesystemTrap = {
3
+ export type SandboxFilesystemTrap = {
4
4
  kind: "filesystem";
5
- code: "FILESYSTEM_DENIED";
6
- query_id?: string;
7
5
  operation: "read" | "write";
8
6
  path: string;
9
7
  requested_path?: string;
10
8
  reason?: string;
9
+ query_id?: string;
11
10
  process?: {
12
11
  pid?: number;
13
12
  exe?: string | null;
@@ -15,12 +14,11 @@ export type LandstripFilesystemTrap = {
15
14
  };
16
15
  };
17
16
 
18
- export type LandstripNetworkTrap = {
17
+ export type SandboxNetworkTrap = {
19
18
  kind: "network";
20
- code: "NETWORK_DENIED";
21
- query_id?: string;
22
19
  operation: "connect" | "bind";
23
20
  target: string;
21
+ query_id?: string;
24
22
  process?: {
25
23
  pid?: number;
26
24
  exe?: string | null;
@@ -28,29 +26,29 @@ export type LandstripNetworkTrap = {
28
26
  };
29
27
  };
30
28
 
31
- export type LandstripBoundaryTrap =
32
- | LandstripFilesystemTrap
33
- | LandstripNetworkTrap;
29
+ export type SandboxBoundaryTrap =
30
+ | SandboxFilesystemTrap
31
+ | SandboxNetworkTrap;
34
32
 
35
- export type LandstripRequestContext = {
33
+ export type SandboxRequestContext = {
36
34
  command?: string;
37
35
  cwd: string;
38
36
  agentName?: string;
39
37
  };
40
38
 
41
- export function landstripTrapToBoundaryRequest(
42
- trap: LandstripBoundaryTrap,
43
- context: LandstripRequestContext,
39
+ export function sandboxTrapToBoundaryRequest(
40
+ trap: SandboxBoundaryTrap,
41
+ context: SandboxRequestContext,
44
42
  ): BoundaryRequest {
45
43
  const processSuffix =
46
44
  trap.process?.pid === undefined ? "" : `:${trap.process.pid}`;
47
45
  const id = trap.query_id
48
- ? `landstrip:${trap.query_id}`
49
- : `landstrip:${trap.kind}:${trap.operation}${processSuffix}`;
46
+ ? `sandbox-runtime:${trap.query_id}`
47
+ : `sandbox-runtime:${trap.kind}:${trap.operation}${processSuffix}`;
50
48
  if (trap.kind === "filesystem") {
51
49
  return {
52
50
  id,
53
- source: "landstrip",
51
+ source: "sandbox-runtime",
54
52
  surface:
55
53
  trap.operation === "read"
56
54
  ? "filesystem-read"
@@ -70,7 +68,7 @@ export function landstripTrapToBoundaryRequest(
70
68
  }
71
69
  return {
72
70
  id,
73
- source: "landstrip",
71
+ source: "sandbox-runtime",
74
72
  surface: "network",
75
73
  operation: trap.operation,
76
74
  cwd: trap.process?.cwd || context.cwd,
package/src/policy.ts CHANGED
@@ -582,7 +582,7 @@ function relevantResultEvidence(
582
582
  function sandboxTrapEvidence(
583
583
  request: RelevantBoundaryRequest,
584
584
  ): string | undefined {
585
- if (request.source !== "landstrip") return;
585
+ if (request.source !== "sandbox-runtime") return;
586
586
  return `<sandbox-trap>
587
587
  ${boundedString({
588
588
  surface: request.surface,
@@ -0,0 +1,323 @@
1
+ import type {
2
+ ExtensionContext,
3
+ ExtensionUIContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ type UiCustom = ExtensionUIContext["custom"];
7
+
8
+ type PermissionUiPromptEvent = {
9
+ requestId: string;
10
+ message: string;
11
+ };
12
+
13
+ type PendingApproval = {
14
+ surface: string;
15
+ expiresAt: number;
16
+ };
17
+
18
+ type PromptAttempt = {
19
+ controller: AbortController;
20
+ settled: boolean;
21
+ recognized: boolean;
22
+ autoApprove?: () => void;
23
+ denyForUiConflict?: () => void;
24
+ restore?: () => void;
25
+ };
26
+
27
+ const AUTO_APPROVED_DECISION = {
28
+ approved: true,
29
+ state: "approved",
30
+ autoApproved: true,
31
+ } as const;
32
+
33
+ const UI_CONFLICT_DENIED_DECISION = {
34
+ approved: false,
35
+ state: "denied",
36
+ } as const;
37
+
38
+ const DEFAULT_PENDING_TTL_MS = 10_000;
39
+
40
+ /**
41
+ * Bridges a model allow that the permission-system delegation envelope capped
42
+ * to defer into the immediately following local TUI terminal.
43
+ *
44
+ * The bridge is deliberately request-bound and one-shot. Any timing, event, UI,
45
+ * component, or wrapper mismatch leaves the normal human dialog in control.
46
+ */
47
+ export class PermissionUiAutoConfirmer {
48
+ private readonly pending = new Map<string, PendingApproval>();
49
+ private activeAttempt: PromptAttempt | undefined;
50
+
51
+ constructor(
52
+ private readonly enabledSurfaces: () => readonly string[],
53
+ private readonly pendingTtlMs = DEFAULT_PENDING_TTL_MS,
54
+ ) {}
55
+
56
+ stage(requestId: string, surface: string): boolean {
57
+ this.pruneExpired();
58
+ if (!this.enabledSurfaces().includes(surface)) return false;
59
+ this.pending.set(requestId, {
60
+ surface,
61
+ expiresAt: Date.now() + this.pendingTtlMs,
62
+ });
63
+ return true;
64
+ }
65
+
66
+ handlePrompt(raw: unknown, ctx: ExtensionContext): void {
67
+ const event = parsePermissionUiPromptEvent(raw);
68
+ if (!event || ctx.mode !== "tui" || !ctx.hasUI) return;
69
+
70
+ this.pruneExpired();
71
+ const pending = this.pending.get(event.requestId);
72
+ if (!pending) {
73
+ // A staged allow is valid only for the immediately following prompt.
74
+ // Never leave it latent after a different request reaches the terminal.
75
+ this.pending.clear();
76
+ this.settleActiveForConflict();
77
+ return;
78
+ }
79
+ this.pending.delete(event.requestId);
80
+
81
+ if (!this.enabledSurfaces().includes(pending.surface)) return;
82
+ this.settleActiveForConflict();
83
+
84
+ const attempt: PromptAttempt = {
85
+ controller: new AbortController(),
86
+ settled: false,
87
+ recognized: false,
88
+ };
89
+ this.activeAttempt = attempt;
90
+ if (!this.installOneShotInterceptor(ctx.ui, event, attempt)) {
91
+ attempt.controller.abort();
92
+ attempt.settled = true;
93
+ this.activeAttempt = undefined;
94
+ }
95
+ }
96
+
97
+ clear(): void {
98
+ this.pending.clear();
99
+ const attempt = this.activeAttempt;
100
+ this.activeAttempt = undefined;
101
+ if (!attempt) return;
102
+ attempt.restore?.();
103
+ attempt.controller.abort();
104
+ attempt.settled = true;
105
+ }
106
+
107
+ private installOneShotInterceptor(
108
+ ui: ExtensionUIContext,
109
+ event: PermissionUiPromptEvent,
110
+ attempt: PromptAttempt,
111
+ ): boolean {
112
+ const previousCustom = ui.custom;
113
+ let invoked = false;
114
+ let wrapper: UiCustom;
115
+
116
+ const release = () => {
117
+ if (this.activeAttempt === attempt) this.activeAttempt = undefined;
118
+ };
119
+ const restore = () => {
120
+ try {
121
+ if (ui.custom === wrapper) ui.custom = previousCustom;
122
+ } catch {
123
+ // A shared-UI wrapper conflict degrades to the normal human dialog.
124
+ }
125
+ };
126
+
127
+ wrapper = ((factory: (...args: any[]) => any, options?: any) => {
128
+ if (invoked) {
129
+ return invokeUiCustom(previousCustom, ui, factory, options);
130
+ }
131
+ invoked = true;
132
+ restore();
133
+
134
+ if (options?.overlay !== false) {
135
+ this.releaseUnrecognized(attempt, release);
136
+ return invokeUiCustom(previousCustom, ui, factory, options);
137
+ }
138
+
139
+ const wrappedFactory = (
140
+ tui: unknown,
141
+ theme: unknown,
142
+ keybindings: unknown,
143
+ done: (result: unknown) => void,
144
+ ) => {
145
+ let uiFinished = false;
146
+ const finish = (result: unknown) => {
147
+ if (uiFinished) return;
148
+ uiFinished = true;
149
+ if (!attempt.settled) {
150
+ attempt.settled = true;
151
+ attempt.controller.abort();
152
+ release();
153
+ }
154
+ done(result);
155
+ };
156
+
157
+ attempt.autoApprove = () => finish(AUTO_APPROVED_DECISION);
158
+ attempt.denyForUiConflict = () => finish(UI_CONFLICT_DENIED_DECISION);
159
+
160
+ let produced: unknown;
161
+ try {
162
+ produced = factory(tui, theme, keybindings, finish);
163
+ } catch (error) {
164
+ this.releaseUnrecognized(attempt, release);
165
+ throw error;
166
+ }
167
+
168
+ if (isPromiseLike(produced)) {
169
+ return Promise.resolve(produced).then(
170
+ (component) =>
171
+ this.recognizeComponent(component, event, attempt, release),
172
+ (error) => {
173
+ this.releaseUnrecognized(attempt, release);
174
+ throw error;
175
+ },
176
+ );
177
+ }
178
+ return this.recognizeComponent(produced, event, attempt, release);
179
+ };
180
+
181
+ return invokeUiCustom(previousCustom, ui, wrappedFactory, options);
182
+ }) as UiCustom;
183
+
184
+ attempt.restore = restore;
185
+ try {
186
+ ui.custom = wrapper;
187
+ } catch {
188
+ return false;
189
+ }
190
+
191
+ // v24 opens the permission component synchronously after the event. Never
192
+ // leave a wrapper installed long enough to catch an unrelated custom UI.
193
+ queueMicrotask(() => {
194
+ if (invoked) return;
195
+ restore();
196
+ this.releaseUnrecognized(attempt, release);
197
+ });
198
+ return true;
199
+ }
200
+
201
+ private recognizeComponent(
202
+ component: unknown,
203
+ event: PermissionUiPromptEvent,
204
+ attempt: PromptAttempt,
205
+ release: () => void,
206
+ ): unknown {
207
+ if (!isPermissionPromptComponent(component, event)) {
208
+ this.releaseUnrecognized(attempt, release);
209
+ return component;
210
+ }
211
+ attempt.recognized = true;
212
+ queueMicrotask(() => {
213
+ try {
214
+ if (!attempt.settled && attempt.recognized) {
215
+ attempt.autoApprove?.();
216
+ }
217
+ } catch {
218
+ attempt.controller.abort();
219
+ attempt.settled = true;
220
+ release();
221
+ }
222
+ });
223
+ return component;
224
+ }
225
+
226
+ private releaseUnrecognized(
227
+ attempt: PromptAttempt,
228
+ release: () => void,
229
+ ): void {
230
+ if (attempt.recognized || attempt.settled) return;
231
+ attempt.restore?.();
232
+ attempt.controller.abort();
233
+ attempt.settled = true;
234
+ release();
235
+ }
236
+
237
+ private settleActiveForConflict(): void {
238
+ const attempt = this.activeAttempt;
239
+ if (!attempt || attempt.settled) return;
240
+ try {
241
+ attempt.denyForUiConflict?.();
242
+ } catch {
243
+ attempt.controller.abort();
244
+ attempt.settled = true;
245
+ this.activeAttempt = undefined;
246
+ }
247
+ }
248
+
249
+ private pruneExpired(): void {
250
+ const now = Date.now();
251
+ for (const [requestId, pending] of this.pending) {
252
+ if (pending.expiresAt < now) this.pending.delete(requestId);
253
+ }
254
+ }
255
+ }
256
+
257
+ function parsePermissionUiPromptEvent(
258
+ raw: unknown,
259
+ ): PermissionUiPromptEvent | undefined {
260
+ if (!isRecord(raw)) return;
261
+ if (
262
+ typeof raw.requestId !== "string" ||
263
+ typeof raw.message !== "string"
264
+ ) {
265
+ return;
266
+ }
267
+ return { requestId: raw.requestId, message: raw.message };
268
+ }
269
+
270
+ function isPermissionPromptComponent(
271
+ value: unknown,
272
+ event: PermissionUiPromptEvent,
273
+ ): boolean {
274
+ if (!isRecord(value) || typeof value.render !== "function") return false;
275
+ const constructorName =
276
+ typeof value.constructor === "function" ? value.constructor.name : "";
277
+ if (constructorName !== "PermissionPromptComponent") return false;
278
+
279
+ try {
280
+ const rendered = value.render(2_000);
281
+ if (!Array.isArray(rendered)) return false;
282
+ const plain = normalizeWhitespace(stripAnsi(rendered.join("\n")));
283
+ const messagePrefix = normalizeWhitespace(event.message).slice(0, 120);
284
+ return (
285
+ plain.includes("Permission Required") &&
286
+ messagePrefix.length > 0 &&
287
+ plain.includes(messagePrefix)
288
+ );
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ function invokeUiCustom(
295
+ method: UiCustom,
296
+ ui: ExtensionUIContext,
297
+ factory: (...args: any[]) => any,
298
+ options: any,
299
+ ): Promise<any> {
300
+ return Reflect.apply(method as (...args: any[]) => Promise<any>, ui, [
301
+ factory,
302
+ options,
303
+ ]);
304
+ }
305
+
306
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
307
+ return isRecord(value) && typeof value.then === "function";
308
+ }
309
+
310
+ function isRecord(value: unknown): value is Record<string, any> {
311
+ return typeof value === "object" && value !== null;
312
+ }
313
+
314
+ function stripAnsi(value: string): string {
315
+ return value.replace(
316
+ /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g,
317
+ "",
318
+ );
319
+ }
320
+
321
+ function normalizeWhitespace(value: string): string {
322
+ return value.replace(/\s+/g, " ").trim();
323
+ }