@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 +12 -0
- package/index.ts +67 -14
- package/package.json +2 -1
- package/review-queue.ts +103 -0
- package/widget-status.ts +19 -2
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
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
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.
|
|
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",
|
package/review-queue.ts
ADDED
|
@@ -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 =
|
|
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],
|