@hank-warren/pi-auto-permissions 0.15.0 → 0.16.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @hank-warren/pi-auto-permissions
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c079c51: Serialize guardian-backed reviews, show a queued command instead of a blank gap, and stage `request_override`.
8
+
9
+ Two guarded commands issued in the same assistant turn used to open two reviewer conversations at once and race for the same widget and bash review row, letting the second verdict land against the first command's display. Guardian reviews now run one at a time behind a FIFO queue. Denies, convention blocks, trusted commands, and already-approved execution never enter it, so the ordinary path is unchanged.
10
+
11
+ Because the critical section spans the human approval prompt, a queued command now renders a `queued behind another review` row as soon as it starts waiting, and the wait is cancellable: `Esc` or a reviewer-lifecycle reset releases it immediately instead of stranding it behind a review it is no longer waiting for. An aborted waiter never lets the command behind it overtake the review still in progress.
12
+
13
+ `request_override` activates only after a convention denial in an interactive session, so a session that is never blocked does not carry its schema. The tool stays registered for transcript replay.
14
+
3
15
  ## 0.15.0
4
16
 
5
17
  ### Minor Changes
package/index.ts CHANGED
@@ -3,6 +3,7 @@ import { resolveGuardianCompleteSimple } from "./guardian-transport.js";
3
3
  import { isOpenAICodexModel } from "./openai-codex-transport.js";
4
4
  import { detectSubagentContext } from "./subagent-context.js";
5
5
  import { detectLoopContext } from "./loop-context.js";
6
+ import { createReviewQueue } from "./review-queue.js";
6
7
  import {
7
8
  LoopReviseBudget,
8
9
  loopBlockReason,
@@ -318,9 +319,28 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
318
319
  const permissionOverrides: PermissionOverride[] = [];
319
320
  let overrideSeq = 0;
320
321
  let standingApprovalCapNotified = false;
322
+ let overrideToolActivated = false;
323
+ const guardianReviewQueue = createReviewQueue();
321
324
  /** Captured once per session; see session-environment.ts for why once. */
322
325
  let sessionEnvironment: SessionEnvironmentSnapshot | undefined;
323
326
 
327
+ function reconcileOverrideTool(): void {
328
+ if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
329
+ const active = pi.getActiveTools();
330
+ const present = active.includes("request_override");
331
+ if (overrideToolActivated === present) return;
332
+ pi.setActiveTools(
333
+ overrideToolActivated
334
+ ? [...active, "request_override"]
335
+ : active.filter((name) => name !== "request_override"),
336
+ );
337
+ }
338
+
339
+ function activateOverrideTool(): void {
340
+ overrideToolActivated = true;
341
+ reconcileOverrideTool();
342
+ }
343
+
324
344
  /**
325
345
  * Overrides persist as custom session entries the way the loop budget does,
326
346
  * so a resumed session keeps the user's prompt decisions instead of
@@ -1276,6 +1296,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1276
1296
  }
1277
1297
  const convention = matches.find((gate) => gate.level === "convention");
1278
1298
  if (convention && !allowedConventionCommands.has(command)) {
1299
+ if (ctx.hasUI) activateOverrideTool();
1279
1300
  recordDenial(
1280
1301
  config,
1281
1302
  ctx,
@@ -1299,10 +1320,32 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1299
1320
  }
1300
1321
  if (!gate) return;
1301
1322
 
1302
- const signal = ctx.signal;
1303
- showReviewDisplay(ctx, config, gate, command, target, "waiting");
1323
+ // Show the queued row *before* waiting, and only when there is actually
1324
+ // something to wait for: the critical section spans the human prompt, so a
1325
+ // second guarded command in the same turn can sit here for as long as it
1326
+ // takes someone to answer. Rendering nothing for that whole time reads as a
1327
+ // hang. A lone review never flashes this, because the queue is idle.
1328
+ if (guardianReviewQueue.busy) {
1329
+ showReviewDisplay(ctx, config, gate, command, target, "queued");
1330
+ }
1331
+ // Same composite the ask path builds, so Esc and a reviewer-lifecycle reset
1332
+ // both release a queued command instead of stranding it behind a review it
1333
+ // is no longer waiting for.
1334
+ const queueSignal = ctx.signal ? AbortSignal.any([ctx.signal, lifecycleSignal]) : lifecycleSignal;
1335
+ let releaseReviewSlot: () => void;
1336
+ try {
1337
+ releaseReviewSlot = await guardianReviewQueue.acquire(queueSignal);
1338
+ } catch {
1339
+ return { block: true, reason: "Auto Permissions review cancelled" };
1340
+ }
1304
1341
  try {
1305
- const verdict = await reviewCommand(
1342
+ if (lifecycleStale() || reviewCancelled(ctx.signal)) {
1343
+ return { block: true, reason: "Auto Permissions review cancelled" };
1344
+ }
1345
+ const signal = ctx.signal;
1346
+ showReviewDisplay(ctx, config, gate, command, target, "waiting");
1347
+ try {
1348
+ const verdict = await reviewCommand(
1306
1349
  gate,
1307
1350
  event.toolName,
1308
1351
  event.toolCallId,
@@ -1389,17 +1432,20 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1389
1432
  reason: `${gate.label} could not be reviewed (${reason}), and this session is running an unattended /loop, so there is no one to ask. Do not retry the same command hoping the reviewer recovers: call loop_wait naming the reviewer failure, or advance the objective another way.`,
1390
1433
  };
1391
1434
  }
1392
- return askUser(
1393
- gate,
1394
- command,
1395
- `Automatic review failed: ${reason}`,
1396
- config,
1397
- signal,
1398
- ctx,
1399
- target,
1400
- lifecycleSignal,
1401
- "review_failure",
1402
- );
1435
+ return askUser(
1436
+ gate,
1437
+ command,
1438
+ `Automatic review failed: ${reason}`,
1439
+ config,
1440
+ signal,
1441
+ ctx,
1442
+ target,
1443
+ lifecycleSignal,
1444
+ "review_failure",
1445
+ );
1446
+ }
1447
+ } finally {
1448
+ releaseReviewSlot();
1403
1449
  }
1404
1450
  });
1405
1451
 
@@ -1491,6 +1537,11 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1491
1537
  },
1492
1538
  });
1493
1539
 
1540
+ // The override schema is useful only after a convention denial has named an
1541
+ // exact command. It is kept registered for replay and narrowed out of the
1542
+ // active set at session_start — never here, because Pi refuses action methods
1543
+ // during extension loading.
1544
+
1494
1545
  /**
1495
1546
  * `/auto-permissions`: edit the reviewer model, thinking level, timeout and
1496
1547
  * the enabled flag without hand-editing config.json.
@@ -1722,6 +1773,8 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1722
1773
  });
1723
1774
 
1724
1775
  pi.on("session_start", async (_event, ctx) => {
1776
+ overrideToolActivated = false;
1777
+ reconcileOverrideTool();
1725
1778
  warnAboutMissingReviewerProvider(ctx);
1726
1779
 
1727
1780
  reviewerLifecycleController.abort();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-auto-permissions",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Fork of @ogulcancelik/pi-auto-permissions with dialog-answer user evidence: context-aware Bash permissions for Pi with automated guardian review.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -58,6 +58,7 @@
58
58
  "gates.ts",
59
59
  "override-evidence.ts",
60
60
  "review.ts",
61
+ "review-queue.ts",
61
62
  "session-environment.ts",
62
63
  "standing-overrides.ts",
63
64
  "usage-log.ts",
@@ -0,0 +1,103 @@
1
+ /**
2
+ * FIFO serialization for guardian-backed reviews.
3
+ *
4
+ * Two guarded commands issued in the same assistant turn used to open two
5
+ * reviewer conversations at once, which raced for the same widget/bash review
6
+ * row and let the second verdict land against the first command's display. The
7
+ * queue serializes the *decisions* only: denies, convention blocks, trusted
8
+ * commands and already-approved execution never enter it.
9
+ *
10
+ * Every acquirer releases in a `finally`, and a release that throws (or is
11
+ * never observed) must not poison later requests — so the tail is always a
12
+ * settled-or-settling promise that swallows rejection.
13
+ *
14
+ * The critical section spans the human prompt as well as the model call, which
15
+ * is deliberate: two approval dialogs fighting over one surface is the thing
16
+ * this exists to prevent. That makes the wait unbounded in wall-clock time, so
17
+ * it is cancellable (`acquire(signal)`) and visible (`busy`, which the caller
18
+ * uses to render a "queued" row instead of a blank gap).
19
+ */
20
+ export interface ReviewQueue {
21
+ /**
22
+ * Waits for the queue, then returns the release function for this slot.
23
+ *
24
+ * Rejects with the signal's reason if `signal` aborts first. An aborted
25
+ * waiter never takes the slot, and never lets the next waiter overtake the
26
+ * live holder — see the catch in the implementation.
27
+ */
28
+ acquire(signal?: AbortSignal): Promise<() => void>;
29
+ /** Slots currently waiting, excluding the one holding the queue. */
30
+ readonly waiting: number;
31
+ /** True while a slot is held, so a caller can show a queued state. */
32
+ readonly busy: boolean;
33
+ }
34
+
35
+ export function createReviewQueue(): ReviewQueue {
36
+ let tail: Promise<void> = Promise.resolve();
37
+ let waiting = 0;
38
+ let held = 0;
39
+
40
+ return {
41
+ async acquire(signal?: AbortSignal): Promise<() => void> {
42
+ signal?.throwIfAborted();
43
+ const previous = tail;
44
+ let release = () => {};
45
+ tail = new Promise<void>((resolve) => {
46
+ release = resolve;
47
+ });
48
+ let released = false;
49
+ const releaseOnce = () => {
50
+ if (released) return;
51
+ released = true;
52
+ held -= 1;
53
+ release();
54
+ };
55
+ waiting += 1;
56
+ try {
57
+ await (signal ? raceAbort(previous, signal) : previous.catch(() => undefined));
58
+ } catch (error) {
59
+ // Keep the chain intact: our slot resolves when the one ahead of us
60
+ // does, so an aborted waiter never lets the next one overtake the live
61
+ // holder. Resolving `release()` here instead would hand the queue to
62
+ // the next waiter while the current holder is still inside its
63
+ // critical section — silently undoing the mutual exclusion this class
64
+ // exists to provide.
65
+ released = true;
66
+ void previous.catch(() => undefined).then(release);
67
+ throw error;
68
+ } finally {
69
+ waiting -= 1;
70
+ }
71
+ held += 1;
72
+ return releaseOnce;
73
+ },
74
+ get waiting() {
75
+ return waiting;
76
+ },
77
+ get busy() {
78
+ return held > 0;
79
+ },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Resolve when `previous` settles, or reject when `signal` aborts.
85
+ *
86
+ * The listener is always removed: a session-lifetime signal outlives many
87
+ * reviews, and one dangling listener per queued command is a slow leak that
88
+ * would only show up under exactly the load this queue was added to handle.
89
+ */
90
+ function raceAbort(previous: Promise<void>, signal: AbortSignal): Promise<void> {
91
+ return new Promise<void>((resolve, reject) => {
92
+ const onAbort = () => {
93
+ cleanup();
94
+ reject(signal.reason);
95
+ };
96
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
97
+ signal.addEventListener("abort", onAbort, { once: true });
98
+ previous.catch(() => undefined).then(() => {
99
+ cleanup();
100
+ resolve();
101
+ }, cleanup);
102
+ });
103
+ }
package/widget-status.ts CHANGED
@@ -4,9 +4,15 @@
4
4
  * free of TUI dependencies so they are unit-testable.
5
5
  */
6
6
 
7
- export type ReviewDisplayState = "waiting" | "approved" | "revise" | "ask_user" | "blocked";
7
+ export type ReviewDisplayState =
8
+ | "queued"
9
+ | "waiting"
10
+ | "approved"
11
+ | "revise"
12
+ | "ask_user"
13
+ | "blocked";
8
14
 
9
- export type ReviewStatusTone = "warning" | "success" | "accent" | "error";
15
+ export type ReviewStatusTone = "muted" | "warning" | "success" | "accent" | "error";
10
16
 
11
17
  export interface ReviewStatusFrame {
12
18
  glyph: string;
@@ -26,6 +32,17 @@ export function reviewStatusFrame(
26
32
  ): ReviewStatusFrame {
27
33
  const index = Math.max(0, Math.floor(frameIndex));
28
34
  switch (state) {
35
+ // Static rather than animated: the row is replaced by `waiting` the moment
36
+ // the queue hands over, so a spinner here would promise progress the
37
+ // command is not making. It exists so a second guarded command in one turn
38
+ // renders *something* instead of a blank gap while it waits its turn.
39
+ case "queued":
40
+ return {
41
+ glyph: "⋯",
42
+ label: "queued behind another review",
43
+ tone: "muted",
44
+ done: true,
45
+ };
29
46
  case "waiting":
30
47
  return {
31
48
  glyph: WAITING_FRAMES[index % WAITING_FRAMES.length],