@hank-warren/pi-auto-permissions 0.17.0 → 0.18.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/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createReviewQueue } from "./review-queue.js";
1
+ import { createReviewLimiter, createReviewQueue } from "./review-queue.js";
2
2
  import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { readFileSync } from "node:fs";
@@ -19,7 +19,7 @@ import {
19
19
  } from "./evaluation-log.js";
20
20
  import type { Gate } from "./gates.js";
21
21
  import { classifyCommand } from "./classify.js";
22
- import type { ReviewEvidenceRecord } from "./review.js";
22
+ import type { PermissionVerdict, ReviewEvidenceRecord } from "./review.js";
23
23
  import { promptSelect, setHerdrBlocked } from "./prompt-select.js";
24
24
  import { createReviewDisplay } from "./review-display.js";
25
25
  import { createSessionOverrides } from "./session-overrides.js";
@@ -78,12 +78,154 @@ function withLifecycle(signal: AbortSignal | undefined, lifecycle: AbortSignal):
78
78
  return signal ? AbortSignal.any([signal, lifecycle]) : lifecycle;
79
79
  }
80
80
 
81
+ /**
82
+ * Settle with `promise`, or reject as soon as `signal` aborts. The promise is
83
+ * left to finish on its own; its outcome is then ignored.
84
+ */
85
+ function untilAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
86
+ if (signal.aborted) {
87
+ promise.catch(() => undefined);
88
+ return Promise.reject(signal.reason);
89
+ }
90
+ return new Promise<T>((resolve, reject) => {
91
+ const onAbort = () => reject(signal.reason);
92
+ signal.addEventListener("abort", onAbort, { once: true });
93
+ promise.then(
94
+ (value) => {
95
+ signal.removeEventListener("abort", onAbort);
96
+ resolve(value);
97
+ },
98
+ (error) => {
99
+ signal.removeEventListener("abort", onAbort);
100
+ reject(error);
101
+ },
102
+ );
103
+ });
104
+ }
105
+
106
+ /** Aborts when the turn ends or, for a codemode call, when its script does. */
107
+ function callSignalOf(scope: ReviewScope): AbortSignal | undefined {
108
+ const { ctx, callSignal } = scope;
109
+ if (!callSignal) return ctx.signal;
110
+ return ctx.signal ? AbortSignal.any([ctx.signal, callSignal]) : callSignal;
111
+ }
112
+
113
+ /** How one guardian review ended, before its verdict is applied. */
114
+ type ReviewAttempt =
115
+ | { kind: "verdict"; verdict: PermissionVerdict; evidenceKeys: string[] }
116
+ | { kind: "failed"; reason: string; evidenceKeys: string[] }
117
+ | { kind: "cancelled" };
118
+
119
+ /**
120
+ * A verdict whose evidence changed while it waited (the user answered another
121
+ * command's prompt, a tool result landed) is reviewed again before it is
122
+ * applied. Bounded so evidence that never settles cannot loop forever: the
123
+ * last review runs holding the decision slot, so no other prompt can be
124
+ * answered while it runs, which is the staleness window a lone review has
125
+ * always had.
126
+ */
127
+ const MAX_REVIEWS_PER_COMMAND = 3;
128
+
129
+ /** How many finished call ids are remembered for calls a script left behind. */
130
+ const MAX_FINISHED_CALLS = 1024;
131
+
132
+ /** Abort reason for an early review replaced by a fresh one for the same call. */
133
+ const SUPERSEDED_REVIEW = "auto-permissions: superseded review";
134
+
135
+ /** A review started early for a later bash call in the same assistant message. */
136
+ interface SiblingReview {
137
+ scope: ReviewScope;
138
+ input: string;
139
+ gateLabel: string;
140
+ attempt: Promise<ReviewAttempt>;
141
+ controller: AbortController;
142
+ }
143
+
144
+ interface AssistantToolCall {
145
+ id: string;
146
+ name: string;
147
+ arguments: unknown;
148
+ }
149
+
150
+ function isBashTool(name: string): boolean {
151
+ return name === "bash" || name.endsWith(".bash");
152
+ }
153
+
154
+ function sameKeys(a: readonly string[], b: readonly string[]): boolean {
155
+ return a.length === b.length && a.every((key, index) => key === b[index]);
156
+ }
157
+
158
+ function assistantToolCalls(message: unknown): AssistantToolCall[] {
159
+ const candidate = message as { role?: unknown; content?: unknown } | undefined;
160
+ if (candidate?.role !== "assistant" || !Array.isArray(candidate.content)) return [];
161
+ return candidate.content.flatMap((block) => {
162
+ const call = block as { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown } | undefined;
163
+ return call?.type === "toolCall" && typeof call.id === "string" && typeof call.name === "string"
164
+ ? [{ id: call.id, name: call.name, arguments: call.arguments }]
165
+ : [];
166
+ });
167
+ }
168
+
81
169
  export default function autoPermissionsExtension(pi: ExtensionAPI) {
82
170
  const overrides = createSessionOverrides(pi);
83
171
  let trustedGroups = new Set<string>();
84
172
  let lastConfigError: string | undefined;
85
173
  let lastEvaluationLogError: string | undefined;
86
- const guardianReviewQueue = createReviewQueue();
174
+ // Guardian calls run in parallel up to config.reviewConcurrency; applying
175
+ // verdicts and prompting the user stay one command at a time.
176
+ const reviewSlots = createReviewLimiter();
177
+ const decisionQueue = createReviewQueue();
178
+ // Pi runs tool_call handlers for one assistant message's calls one after
179
+ // another, so the later bash calls' reviews are started early from here.
180
+ let lastAssistantCalls: AssistantToolCall[] = [];
181
+ const siblingReviews = new Map<string, SiblingReview>();
182
+ // One controller per tool call (a codemode script) that has made bash calls,
183
+ // aborted when Pi reports that call finished: its pending calls can no
184
+ // longer run, so their reviews and prompts are released.
185
+ const scriptsEnded = new Map<string, AbortController>();
186
+ // Calls that already finished, newest last. A script can end before Pi runs
187
+ // the tool_call hook of a call it fired without awaiting, and with sequential
188
+ // tool execution that hook can run after the turn is over, so this outlives
189
+ // the turn. Bounded: only a recently finished call can still have one pending.
190
+ const finishedCalls = new Set<string>();
191
+ // Nested call id → the model-issued call it descends from, so a call made
192
+ // several levels down still names the script whose SCRIPT record it shares.
193
+ const scriptRoots = new Map<string, string>();
194
+
195
+ function rememberFinished(toolCallId: string): void {
196
+ finishedCalls.delete(toolCallId);
197
+ finishedCalls.add(toolCallId);
198
+ if (finishedCalls.size > MAX_FINISHED_CALLS) {
199
+ finishedCalls.delete(finishedCalls.values().next().value!);
200
+ }
201
+ }
202
+
203
+ function callEndedSignal(toolCallId: string): AbortSignal {
204
+ if (finishedCalls.has(toolCallId)) return AbortSignal.abort();
205
+ let controller = scriptsEnded.get(toolCallId);
206
+ if (!controller) {
207
+ controller = new AbortController();
208
+ scriptsEnded.set(toolCallId, controller);
209
+ }
210
+ return controller.signal;
211
+ }
212
+
213
+ /**
214
+ * Aborts when the call that made this one ends, or the script at the top of
215
+ * the chain does: a tool in between may keep awaiting this call after its
216
+ * script has gone.
217
+ */
218
+ function scriptEndedSignal(parentToolCallId: string, scriptToolCallId: string): AbortSignal {
219
+ const parent = callEndedSignal(parentToolCallId);
220
+ return scriptToolCallId === parentToolCallId
221
+ ? parent
222
+ : AbortSignal.any([parent, callEndedSignal(scriptToolCallId)]);
223
+ }
224
+
225
+ function endScripts(): void {
226
+ for (const controller of scriptsEnded.values()) controller.abort();
227
+ scriptsEnded.clear();
228
+ }
87
229
  // The session is active exactly while the reviewer lifecycle is unaborted:
88
230
  // session_shutdown aborts it, session_start synchronously aborts and replaces
89
231
  // it, and nothing else touches it.
@@ -181,10 +323,19 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
181
323
  return signal?.aborted === true;
182
324
  }
183
325
 
326
+ /**
327
+ * A cancelled turn resets the reviewer lineage. A codemode script ending
328
+ * does not: the reviewer conversation is intact, and dropping it would make
329
+ * the next reviews start cold.
330
+ */
331
+ function discardLineageIfTurnCancelled(scope: ReviewScope): void {
332
+ if (reviewCancelled(scope.ctx.signal)) reviewer.discardLineage();
333
+ }
334
+
184
335
  function cancelledAfterAwait(scope: ReviewScope, lifecycleSignal: AbortSignal): BlockResult | undefined {
185
336
  if (reviewer.isStale(lifecycleSignal)) return reviewCancelledResult();
186
- if (!reviewCancelled(scope.ctx.signal)) return undefined;
187
- reviewer.discardLineage();
337
+ if (!reviewCancelled(callSignalOf(scope))) return undefined;
338
+ discardLineageIfTurnCancelled(scope);
188
339
  display.clear(scope);
189
340
  return reviewCancelledResult();
190
341
  }
@@ -237,12 +388,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
237
388
  decisionSource: "guardian" | "review_failure",
238
389
  ): Promise<BlockResult | undefined> {
239
390
  const { ctx, config, gate, command } = scope;
240
- const signal = ctx.signal;
391
+ const signal = callSignalOf(scope);
241
392
  const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
242
393
  const promptSignal = withLifecycle(signal, lifecycleSignal);
243
394
  const cancelled = reviewCancelledResult;
244
395
  if (lifecycleStale() || reviewCancelled(signal)) {
245
- if (!lifecycleStale()) reviewer.discardLineage();
396
+ if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
246
397
  return cancelled();
247
398
  }
248
399
  display.show(scope, "ask_user", detail);
@@ -270,7 +421,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
270
421
  );
271
422
  } catch (error) {
272
423
  if (!lifecycleStale() && !reviewCancelled(signal)) throw error;
273
- if (!lifecycleStale()) reviewer.discardLineage();
424
+ if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
274
425
  return cancelled();
275
426
  }
276
427
  const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
@@ -301,11 +452,261 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
301
452
  }
302
453
  }
303
454
 
455
+ function evidenceKeys(scope: ReviewScope): string[] {
456
+ return reviewer.collectEvidence(scope).map((record) => record.key);
457
+ }
458
+
459
+ /**
460
+ * One guardian review, run as soon as a review slot is free. Never applies
461
+ * the verdict: that waits for the decision slot in `decide`.
462
+ *
463
+ * `abandon` is set for a sibling review started early. Aborting it drops the
464
+ * verdict; its row is cleared too, unless the abort reason is
465
+ * `SUPERSEDED_REVIEW`, meaning a fresh review for the same call owns the row.
466
+ */
467
+ async function runReview(
468
+ scope: ReviewScope,
469
+ input: Record<string, unknown>,
470
+ lifecycleSignal: AbortSignal,
471
+ abandon?: AbortSignal,
472
+ ): Promise<ReviewAttempt> {
473
+ const { ctx, config } = scope;
474
+ const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
475
+ const abandoned = () => abandon?.aborted === true;
476
+ const drop = (): ReviewAttempt => {
477
+ if (!lifecycleStale() && abandon?.reason !== SUPERSEDED_REVIEW) display.clear(scope);
478
+ return { kind: "cancelled" };
479
+ };
480
+ const callSignal = callSignalOf(scope);
481
+ const slotSignal = AbortSignal.any([
482
+ lifecycleSignal,
483
+ ...(callSignal ? [callSignal] : []),
484
+ ...(abandon ? [abandon] : []),
485
+ ]);
486
+ if (reviewSlots.wouldWait(config.reviewConcurrency)) display.show(scope, "queued");
487
+ let releaseSlot: () => void;
488
+ try {
489
+ releaseSlot = await reviewSlots.acquire(config.reviewConcurrency, slotSignal);
490
+ } catch {
491
+ return drop();
492
+ }
493
+ try {
494
+ if (lifecycleStale() || reviewCancelled(callSignal) || abandoned()) return drop();
495
+ display.show(scope, "waiting");
496
+ // Collected synchronously right before the review collects the same
497
+ // stream, so the keys describe exactly what the guardian judged.
498
+ const keys = evidenceKeys(scope);
499
+ let attempt: ReviewAttempt;
500
+ try {
501
+ // The reviewer already follows the turn signal. A codemode call whose
502
+ // script ended, or an early review nobody will take, cancels its
503
+ // request and gives its slot back at once.
504
+ const stop = scope.callSignal && abandon
505
+ ? AbortSignal.any([scope.callSignal, abandon])
506
+ : scope.callSignal ?? abandon;
507
+ const review = reviewer.review(scope, input, stop);
508
+ const verdict = await (stop ? untilAborted(review, stop) : review);
509
+ if (!lifecycleStale() && reviewCancelled(ctx.signal)) reviewer.discardLineage();
510
+ attempt = { kind: "verdict", verdict, evidenceKeys: keys };
511
+ } catch (error) {
512
+ attempt = { kind: "failed", reason: error instanceof Error ? error.message : String(error), evidenceKeys: keys };
513
+ }
514
+ if (lifecycleStale() || reviewCancelled(callSignal) || abandoned()) return drop();
515
+ // An early sibling review now waits for Pi to reach its call.
516
+ if (abandon) display.show(scope, "queued");
517
+ return attempt;
518
+ } finally {
519
+ releaseSlot();
520
+ }
521
+ }
522
+
523
+ /** Apply one settled review. Runs holding the decision slot. */
524
+ function applyReview(
525
+ scope: ReviewScope,
526
+ outcome: Exclude<ReviewAttempt, { kind: "cancelled" }>,
527
+ lifecycleSignal: AbortSignal,
528
+ ): Promise<BlockResult | undefined> | BlockResult | undefined {
529
+ if (outcome.kind === "failed") {
530
+ return askUser(scope, `Automatic review failed: ${outcome.reason}`, lifecycleSignal, "review_failure");
531
+ }
532
+ const { verdict } = outcome;
533
+ if (verdict.decision === "approve") {
534
+ return settle(scope, { display: "approved", reason: verdict.reason });
535
+ }
536
+ if (verdict.decision === "revise") {
537
+ return settle(scope, {
538
+ display: "revise",
539
+ verdict: "revise",
540
+ source: "guardian",
541
+ reason: verdict.reason,
542
+ block: `Auto Permissions requested revision: ${verdict.reason}\nRevise the command and try again.`,
543
+ });
544
+ }
545
+ return askUser(scope, verdict.reason, lifecycleSignal, "guardian");
546
+ }
547
+
548
+ /**
549
+ * Wait for a review, then apply it in the decision slot, one command at a
550
+ * time. The slot spans any approval prompt, so a verdict reached while the
551
+ * user was answering another command's prompt is checked against the
552
+ * evidence that answer added, and reviewed again when it changed. The last
553
+ * review `MAX_REVIEWS_PER_COMMAND` allows runs inside the slot.
554
+ */
555
+ async function decide(
556
+ scope: ReviewScope,
557
+ input: Record<string, unknown>,
558
+ lifecycleSignal: AbortSignal,
559
+ firstAttempt: Promise<ReviewAttempt>,
560
+ ): Promise<BlockResult | undefined> {
561
+ const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
562
+ const callSignal = callSignalOf(scope);
563
+ // Same composite the ask path builds, so Esc, a reviewer-lifecycle reset
564
+ // and the end of a codemode script all release a waiting command instead
565
+ // of stranding it behind a prompt it is no longer waiting for.
566
+ const queueSignal = withLifecycle(callSignal, lifecycleSignal);
567
+ let attempt = firstAttempt;
568
+ for (let reviews = 1; ; reviews += 1) {
569
+ const outcome = await attempt;
570
+ if (outcome.kind === "cancelled") return reviewCancelledResult();
571
+ if (decisionQueue.busy) display.show(scope, "queued");
572
+ let releaseDecision: () => void;
573
+ try {
574
+ releaseDecision = await decisionQueue.acquire(queueSignal);
575
+ } catch {
576
+ if (!lifecycleStale()) display.clear(scope);
577
+ return reviewCancelledResult();
578
+ }
579
+ try {
580
+ if (lifecycleStale() || reviewCancelled(callSignal)) {
581
+ if (!lifecycleStale()) display.clear(scope);
582
+ return reviewCancelledResult();
583
+ }
584
+ if (sameKeys(outcome.evidenceKeys, evidenceKeys(scope))) {
585
+ return await applyReview(scope, outcome, lifecycleSignal);
586
+ }
587
+ if (reviews + 1 < MAX_REVIEWS_PER_COMMAND) {
588
+ attempt = runReview(scope, input, lifecycleSignal);
589
+ continue;
590
+ }
591
+ const last = await runReview(scope, input, lifecycleSignal);
592
+ if (last.kind === "cancelled") return reviewCancelledResult();
593
+ return await applyReview(scope, last, lifecycleSignal);
594
+ } finally {
595
+ releaseDecision();
596
+ }
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Start reviews for the guarded bash calls that follow `toolCallId` in the
602
+ * assistant message that issued it. Pi calls tool_call handlers for one
603
+ * message's calls one at a time, so without this their reviews could never
604
+ * overlap. Each result is taken by the call's own handler, and only when the
605
+ * input it finally receives is exactly the input that was reviewed.
606
+ */
607
+ function startSiblingReviews(
608
+ toolCallId: string,
609
+ ctx: ExtensionContext,
610
+ config: AutoPermissionsConfig,
611
+ lifecycleSignal: AbortSignal,
612
+ ): void {
613
+ if (config.reviewConcurrency <= 1) return;
614
+ const index = lastAssistantCalls.findIndex((call) => call.id === toolCallId);
615
+ if (index < 0) return;
616
+ for (const call of lastAssistantCalls.slice(index + 1)) {
617
+ if (!isBashTool(call.name) || siblingReviews.has(call.id)) continue;
618
+ const input = call.arguments as Record<string, unknown> | undefined;
619
+ const command = input?.command;
620
+ if (typeof command !== "string") continue;
621
+ const classified = classifyCommand(command, config, trustedGroups);
622
+ if (classified.kind !== "review") continue;
623
+ const controller = new AbortController();
624
+ const scope: ReviewScope = {
625
+ ctx,
626
+ config,
627
+ gate: classified.gate,
628
+ command,
629
+ target: { toolName: call.name, toolCallId: call.id },
630
+ };
631
+ siblingReviews.set(call.id, {
632
+ scope,
633
+ input: JSON.stringify(input),
634
+ gateLabel: classified.gate.label,
635
+ attempt: runReview(scope, input!, lifecycleSignal, controller.signal),
636
+ controller,
637
+ });
638
+ }
639
+ }
640
+
641
+ /**
642
+ * The early review of this call, when it reviewed exactly this input under
643
+ * this gate. `gateLabel` is undefined when the call no longer needs review
644
+ * (another handler changed it), in which case the early review is dropped.
645
+ */
646
+ function takeSiblingReview(
647
+ toolCallId: string,
648
+ input: unknown,
649
+ gateLabel: string | undefined,
650
+ ): Promise<ReviewAttempt> | undefined {
651
+ const sibling = siblingReviews.get(toolCallId);
652
+ if (!sibling) return undefined;
653
+ siblingReviews.delete(toolCallId);
654
+ if (gateLabel === undefined) {
655
+ sibling.controller.abort();
656
+ display.clear(sibling.scope);
657
+ return undefined;
658
+ }
659
+ if (sibling.input === JSON.stringify(input) && sibling.gateLabel === gateLabel) return sibling.attempt;
660
+ // Changed after it was reviewed early: the fresh review owns the row.
661
+ sibling.controller.abort(SUPERSEDED_REVIEW);
662
+ return undefined;
663
+ }
664
+
665
+ /** Drop early reviews whose calls never reached this extension. */
666
+ function abandonSiblingReviews(): void {
667
+ for (const sibling of siblingReviews.values()) {
668
+ sibling.controller.abort();
669
+ display.clear(sibling.scope);
670
+ }
671
+ siblingReviews.clear();
672
+ }
673
+
674
+ pi.on("message_end", (event) => {
675
+ const calls = assistantToolCalls((event as { message?: unknown }).message);
676
+ if (calls.length > 0) lastAssistantCalls = calls;
677
+ });
678
+
679
+ pi.on("turn_end", () => {
680
+ lastAssistantCalls = [];
681
+ abandonSiblingReviews();
682
+ endScripts();
683
+ scriptRoots.clear();
684
+ });
685
+
686
+ pi.on("tool_execution_start", (event) => {
687
+ const { toolCallId, parentToolCallId } = event as { toolCallId?: unknown; parentToolCallId?: unknown };
688
+ if (typeof toolCallId !== "string") return;
689
+ // A provider may reuse a call id in a later turn; that call is live again.
690
+ finishedCalls.delete(toolCallId);
691
+ if (typeof parentToolCallId === "string") {
692
+ scriptRoots.set(toolCallId, scriptRoots.get(parentToolCallId) ?? parentToolCallId);
693
+ }
694
+ });
695
+
696
+ pi.on("tool_execution_end", (event) => {
697
+ const { toolCallId } = event as { toolCallId?: unknown };
698
+ if (typeof toolCallId !== "string") return;
699
+ rememberFinished(toolCallId);
700
+ const controller = scriptsEnded.get(toolCallId);
701
+ if (!controller) return;
702
+ scriptsEnded.delete(toolCallId);
703
+ controller.abort();
704
+ });
705
+
304
706
  pi.on("tool_call", async (event, ctx) => {
305
- if (event.toolName !== "bash" && !event.toolName.endsWith(".bash")) return;
707
+ if (!isBashTool(event.toolName)) return;
306
708
  const command = (event.input as { command: string }).command;
307
709
  const lifecycleSignal = reviewer.lifecycleSignal;
308
- const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
309
710
 
310
711
  let config: AutoPermissionsConfig;
311
712
  try {
@@ -315,11 +716,33 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
315
716
  return { block: true, reason: error instanceof Error ? error.message : String(error) };
316
717
  }
317
718
  if (!config.enabled) return;
318
- const target: ReviewTarget = { toolName: event.toolName, toolCallId: event.toolCallId };
719
+ const parentToolCallId = (event as { parentToolCallId?: string }).parentToolCallId;
720
+ if (!parentToolCallId) startSiblingReviews(event.toolCallId, ctx, config, lifecycleSignal);
721
+ const scriptToolCallId = parentToolCallId ? scriptRoots.get(parentToolCallId) ?? parentToolCallId : undefined;
722
+ const target: ReviewTarget = {
723
+ toolName: event.toolName,
724
+ toolCallId: event.toolCallId,
725
+ ...(scriptToolCallId ? { scriptToolCallId } : {}),
726
+ };
727
+ const input = event.input as Record<string, unknown>;
319
728
  const classified = classifyCommand(command, config, trustedGroups);
729
+ const early = takeSiblingReview(
730
+ event.toolCallId,
731
+ input,
732
+ classified.kind === "review" ? classified.gate.label : undefined,
733
+ );
320
734
  if (classified.kind === "pass") return;
321
735
  const gate = classified.gate;
322
- const scope: ReviewScope = { ctx, config, gate, command, target };
736
+ const scope: ReviewScope = {
737
+ ctx,
738
+ config,
739
+ gate,
740
+ command,
741
+ target,
742
+ ...(parentToolCallId && scriptToolCallId
743
+ ? { callSignal: scriptEndedSignal(parentToolCallId, scriptToolCallId) }
744
+ : {}),
745
+ };
323
746
  if (classified.kind === "deny") {
324
747
  return settle(scope, {
325
748
  verdict: "block",
@@ -329,63 +752,21 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
329
752
  });
330
753
  }
331
754
 
332
- // Show the queued row *before* waiting, and only when there is actually
333
- // something to wait for: the critical section spans the human prompt, so a
334
- // second guarded command in the same turn can sit here for as long as it
335
- // takes someone to answer. Rendering nothing for that whole time reads as a
336
- // hang. A lone review never flashes this, because the queue is idle.
337
- if (guardianReviewQueue.busy) {
338
- display.show(scope, "queued");
339
- }
340
- // Same composite the ask path builds, so Esc and a reviewer-lifecycle reset
341
- // both release a queued command instead of stranding it behind a review it
342
- // is no longer waiting for.
343
- const queueSignal = withLifecycle(ctx.signal, lifecycleSignal);
344
- let releaseReviewSlot: () => void;
345
- try {
346
- releaseReviewSlot = await guardianReviewQueue.acquire(queueSignal);
347
- } catch {
348
- return reviewCancelledResult();
349
- }
350
- try {
351
- if (lifecycleStale() || reviewCancelled(ctx.signal)) {
352
- return reviewCancelledResult();
353
- }
354
- const signal = ctx.signal;
355
- display.show(scope, "waiting");
356
- try {
357
- const verdict = await reviewer.review(scope, event.input as Record<string, unknown>);
358
- const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
359
- if (cancelledResult) return cancelledResult;
360
- if (verdict.decision === "approve") {
361
- return settle(scope, { display: "approved", reason: verdict.reason });
362
- }
363
- if (verdict.decision === "revise") {
364
- return settle(scope, {
365
- display: "revise",
366
- verdict: "revise",
367
- source: "guardian",
368
- reason: verdict.reason,
369
- block: `Auto Permissions requested revision: ${verdict.reason}\nRevise the command and try again.`,
370
- });
371
- }
372
- return askUser(scope, verdict.reason, lifecycleSignal, "guardian");
373
- } catch (error) {
374
- if (lifecycleStale() || reviewCancelled(signal)) {
375
- if (!lifecycleStale()) display.clear(scope);
376
- return reviewCancelledResult();
377
- }
378
- const reason = error instanceof Error ? error.message : String(error);
379
- return askUser(scope, `Automatic review failed: ${reason}`, lifecycleSignal, "review_failure");
380
- }
381
- } finally {
382
- releaseReviewSlot();
383
- }
755
+ return decide(scope, input, lifecycleSignal, early ?? runReview(scope, input, lifecycleSignal));
384
756
  });
385
757
 
386
758
  registerSettingsCommand(pi, { overrides, reviewer });
387
759
 
760
+ function resetCallTracking(): void {
761
+ lastAssistantCalls = [];
762
+ abandonSiblingReviews();
763
+ endScripts();
764
+ finishedCalls.clear();
765
+ scriptRoots.clear();
766
+ }
767
+
388
768
  pi.on("session_shutdown", async (_event, ctx) => {
769
+ resetCallTracking();
389
770
  reviewer.endSession();
390
771
  display.shutdown(ctx);
391
772
  setHerdrBlocked(pi, false);
@@ -400,6 +781,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
400
781
  }
401
782
  if (config) warnAboutMissingReviewerProvider(ctx, config);
402
783
 
784
+ resetCallTracking();
403
785
  reviewer.startSession(ctx.cwd);
404
786
  overrides.restore(ctx.sessionManager.getBranch());
405
787
  trustedGroups = ctx.isProjectTrusted() ? loadTrustedGroups(ctx.cwd) : new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-auto-permissions",
3
- "version": "0.17.0",
3
+ "version": "0.18.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": [