@hank-warren/pi-auto-permissions 0.17.0 → 0.19.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,13 +19,14 @@ 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";
26
26
  import { createGuardianReviewer } from "./guardian-reviewer.js";
27
27
  import { registerSettingsCommand } from "./settings-command.js";
28
28
  import type { BlockResult, ReviewScope, ReviewTarget } from "./review-scope.js";
29
+ import { detectSubagentContext } from "./subagent-context.js";
29
30
  import type { ReviewDisplayState } from "./widget-status.js";
30
31
 
31
32
  /**
@@ -48,6 +49,8 @@ interface ReviewOutcome {
48
49
  reason: string;
49
50
  /** What the agent is told; absent means the command runs. */
50
51
  block?: string;
52
+ /** A subagent's revise-first refusal, marked so the denial log tells it from a guardian revise. */
53
+ reviseFirst?: boolean;
51
54
  }
52
55
 
53
56
  const PROJECT_CONFIG_DIR_NAME = (PiCodingAgent as { CONFIG_DIR_NAME?: string }).CONFIG_DIR_NAME ?? ".pi";
@@ -70,6 +73,22 @@ function denyReason(gate: Gate): string {
70
73
  return `Blocked by policy: ${gate.label}\n\n${gate.message ?? "This operation is denied by rule."}\n\nThis is a deny rule: it cannot be overridden by trusted groups or user approval. Choose a different approach.`;
71
74
  }
72
75
 
76
+ /**
77
+ * Appended to a subagent child's system prompt. A child that does have a UI
78
+ * (an RPC child whose prompts surface in the parent) still costs the human an
79
+ * interruption per prompt, so it is told to revise before it escalates.
80
+ */
81
+ /** The system prompt section that carries SUBAGENT_PERMISSIONS_PROMPT on Pi versions with a structured prompt. */
82
+ export const SUBAGENT_PROMPT_SECTION = "auto_permissions_subagent";
83
+
84
+ export const SUBAGENT_PERMISSIONS_PROMPT = `## Auto Permissions (subagent)
85
+ You are running as a subagent. Bash commands that Auto Permissions cannot approve on its own need a human, and asking one pauses the supervising session until they answer. Prefer commands that stay inside your task and working directory, and read-only alternatives where they answer the question. When Auto Permissions asks you to revise a command, change the approach instead of retrying it; re-run a command unchanged only when it is genuinely required and a human should decide.`;
86
+
87
+ /** What a subagent is told the first time a command would need human approval. */
88
+ export function subagentReviseFirstReason(gateLabel: string, reason: string): string {
89
+ return `${gateLabel} needs human approval: ${reason}\nYou are a subagent: asking the human pauses the supervising session until they answer. Revise first: get the same result with a command that stays inside your task and working directory (read-only, a narrower target, local instead of remote). If this exact command is genuinely required, run it again unchanged and the request goes to the human.`;
90
+ }
91
+
73
92
  function reviewCancelledResult(): BlockResult {
74
93
  return { block: true, reason: "Auto Permissions review cancelled" };
75
94
  }
@@ -78,12 +97,162 @@ function withLifecycle(signal: AbortSignal | undefined, lifecycle: AbortSignal):
78
97
  return signal ? AbortSignal.any([signal, lifecycle]) : lifecycle;
79
98
  }
80
99
 
100
+ /**
101
+ * Settle with `promise`, or reject as soon as `signal` aborts. The promise is
102
+ * left to finish on its own; its outcome is then ignored.
103
+ */
104
+ function untilAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
105
+ if (signal.aborted) {
106
+ promise.catch(() => undefined);
107
+ return Promise.reject(signal.reason);
108
+ }
109
+ return new Promise<T>((resolve, reject) => {
110
+ const onAbort = () => reject(signal.reason);
111
+ signal.addEventListener("abort", onAbort, { once: true });
112
+ promise.then(
113
+ (value) => {
114
+ signal.removeEventListener("abort", onAbort);
115
+ resolve(value);
116
+ },
117
+ (error) => {
118
+ signal.removeEventListener("abort", onAbort);
119
+ reject(error);
120
+ },
121
+ );
122
+ });
123
+ }
124
+
125
+ /** Aborts when the turn ends or, for a codemode call, when its script does. */
126
+ function callSignalOf(scope: ReviewScope): AbortSignal | undefined {
127
+ const { ctx, callSignal } = scope;
128
+ if (!callSignal) return ctx.signal;
129
+ return ctx.signal ? AbortSignal.any([ctx.signal, callSignal]) : callSignal;
130
+ }
131
+
132
+ /** How one guardian review ended, before its verdict is applied. */
133
+ type ReviewAttempt =
134
+ | { kind: "verdict"; verdict: PermissionVerdict; evidenceKeys: string[] }
135
+ | { kind: "failed"; reason: string; evidenceKeys: string[] }
136
+ | { kind: "cancelled" };
137
+
138
+ /**
139
+ * A verdict whose evidence changed while it waited (the user answered another
140
+ * command's prompt, a tool result landed) is reviewed again before it is
141
+ * applied. Bounded so evidence that never settles cannot loop forever: the
142
+ * last review runs holding the decision slot, so no other prompt can be
143
+ * answered while it runs, which is the staleness window a lone review has
144
+ * always had.
145
+ */
146
+ const MAX_REVIEWS_PER_COMMAND = 3;
147
+
148
+ /** How many finished call ids are remembered for calls a script left behind. */
149
+ const MAX_FINISHED_CALLS = 1024;
150
+
151
+ /** Abort reason for an early review replaced by a fresh one for the same call. */
152
+ const SUPERSEDED_REVIEW = "auto-permissions: superseded review";
153
+
154
+ /** A review started early for a later bash call in the same assistant message. */
155
+ interface SiblingReview {
156
+ scope: ReviewScope;
157
+ input: string;
158
+ gateLabel: string;
159
+ attempt: Promise<ReviewAttempt>;
160
+ controller: AbortController;
161
+ }
162
+
163
+ interface AssistantToolCall {
164
+ id: string;
165
+ name: string;
166
+ arguments: unknown;
167
+ }
168
+
169
+ function isBashTool(name: string): boolean {
170
+ return name === "bash" || name.endsWith(".bash");
171
+ }
172
+
173
+ function sameKeys(a: readonly string[], b: readonly string[]): boolean {
174
+ return a.length === b.length && a.every((key, index) => key === b[index]);
175
+ }
176
+
177
+ function assistantToolCalls(message: unknown): AssistantToolCall[] {
178
+ const candidate = message as { role?: unknown; content?: unknown } | undefined;
179
+ if (candidate?.role !== "assistant" || !Array.isArray(candidate.content)) return [];
180
+ return candidate.content.flatMap((block) => {
181
+ const call = block as { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown } | undefined;
182
+ return call?.type === "toolCall" && typeof call.id === "string" && typeof call.name === "string"
183
+ ? [{ id: call.id, name: call.name, arguments: call.arguments }]
184
+ : [];
185
+ });
186
+ }
187
+
81
188
  export default function autoPermissionsExtension(pi: ExtensionAPI) {
82
189
  const overrides = createSessionOverrides(pi);
83
190
  let trustedGroups = new Set<string>();
191
+ // Set per session: a subagent child (PI_SUBAGENT_CHILD=1) revises before it
192
+ // asks. `reviseFirst` maps each command it was told to revise to the turn
193
+ // it was told in; issuing it again unchanged in a *later* turn escalates it
194
+ // to the human, once: the entry goes with it. Same-turn duplicates (sibling calls, codemode Promise.all)
195
+ // are refused too, since the model has not yet seen the first refusal.
196
+ let subagentSession = false;
197
+ let turnGeneration = 0;
198
+ const reviseFirst = new Map<string, number>();
84
199
  let lastConfigError: string | undefined;
85
200
  let lastEvaluationLogError: string | undefined;
86
- const guardianReviewQueue = createReviewQueue();
201
+ // Guardian calls run in parallel up to config.reviewConcurrency; applying
202
+ // verdicts and prompting the user stay one command at a time.
203
+ const reviewSlots = createReviewLimiter();
204
+ const decisionQueue = createReviewQueue();
205
+ // Pi runs tool_call handlers for one assistant message's calls one after
206
+ // another, so the later bash calls' reviews are started early from here.
207
+ let lastAssistantCalls: AssistantToolCall[] = [];
208
+ const siblingReviews = new Map<string, SiblingReview>();
209
+ // One controller per tool call (a codemode script) that has made bash calls,
210
+ // aborted when Pi reports that call finished: its pending calls can no
211
+ // longer run, so their reviews and prompts are released.
212
+ const scriptsEnded = new Map<string, AbortController>();
213
+ // Calls that already finished, newest last. A script can end before Pi runs
214
+ // the tool_call hook of a call it fired without awaiting, and with sequential
215
+ // tool execution that hook can run after the turn is over, so this outlives
216
+ // the turn. Bounded: only a recently finished call can still have one pending.
217
+ const finishedCalls = new Set<string>();
218
+ // Nested call id → the model-issued call it descends from, so a call made
219
+ // several levels down still names the script whose SCRIPT record it shares.
220
+ const scriptRoots = new Map<string, string>();
221
+
222
+ function rememberFinished(toolCallId: string): void {
223
+ finishedCalls.delete(toolCallId);
224
+ finishedCalls.add(toolCallId);
225
+ if (finishedCalls.size > MAX_FINISHED_CALLS) {
226
+ finishedCalls.delete(finishedCalls.values().next().value!);
227
+ }
228
+ }
229
+
230
+ function callEndedSignal(toolCallId: string): AbortSignal {
231
+ if (finishedCalls.has(toolCallId)) return AbortSignal.abort();
232
+ let controller = scriptsEnded.get(toolCallId);
233
+ if (!controller) {
234
+ controller = new AbortController();
235
+ scriptsEnded.set(toolCallId, controller);
236
+ }
237
+ return controller.signal;
238
+ }
239
+
240
+ /**
241
+ * Aborts when the call that made this one ends, or the script at the top of
242
+ * the chain does: a tool in between may keep awaiting this call after its
243
+ * script has gone.
244
+ */
245
+ function scriptEndedSignal(parentToolCallId: string, scriptToolCallId: string): AbortSignal {
246
+ const parent = callEndedSignal(parentToolCallId);
247
+ return scriptToolCallId === parentToolCallId
248
+ ? parent
249
+ : AbortSignal.any([parent, callEndedSignal(scriptToolCallId)]);
250
+ }
251
+
252
+ function endScripts(): void {
253
+ for (const controller of scriptsEnded.values()) controller.abort();
254
+ scriptsEnded.clear();
255
+ }
87
256
  // The session is active exactly while the reviewer lifecycle is unaborted:
88
257
  // session_shutdown aborts it, session_start synchronously aborts and replaces
89
258
  // it, and nothing else touches it.
@@ -100,6 +269,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
100
269
  verdict: DenialVerdict,
101
270
  reason: string,
102
271
  decisionSource: DenialSource,
272
+ reviseFirst = false,
103
273
  ): void {
104
274
  const { ctx, config, gate, command, target } = scope;
105
275
  try {
@@ -111,6 +281,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
111
281
  verdict,
112
282
  reason,
113
283
  decisionSource,
284
+ ...(reviseFirst ? { reviseFirst: true } : {}),
114
285
  });
115
286
  } catch {
116
287
  // Event fan-out is observability, never part of the decision.
@@ -126,6 +297,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
126
297
  verdict,
127
298
  reason,
128
299
  decisionSource,
300
+ reviseFirst,
129
301
  }));
130
302
  } catch {
131
303
  // The denial log is best-effort observability.
@@ -135,7 +307,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
135
307
  /** The one place a decision is recorded, rendered and returned. */
136
308
  function settle(scope: ReviewScope, outcome: ReviewOutcome): BlockResult | undefined {
137
309
  if (outcome.verdict && outcome.source) {
138
- recordDenial(scope, outcome.verdict, outcome.reason, outcome.source);
310
+ recordDenial(scope, outcome.verdict, outcome.reason, outcome.source, outcome.reviseFirst === true);
139
311
  }
140
312
  if (outcome.display) {
141
313
  display.show(scope, outcome.display, outcome.detail ?? outcome.reason, true);
@@ -181,10 +353,19 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
181
353
  return signal?.aborted === true;
182
354
  }
183
355
 
356
+ /**
357
+ * A cancelled turn resets the reviewer lineage. A codemode script ending
358
+ * does not: the reviewer conversation is intact, and dropping it would make
359
+ * the next reviews start cold.
360
+ */
361
+ function discardLineageIfTurnCancelled(scope: ReviewScope): void {
362
+ if (reviewCancelled(scope.ctx.signal)) reviewer.discardLineage();
363
+ }
364
+
184
365
  function cancelledAfterAwait(scope: ReviewScope, lifecycleSignal: AbortSignal): BlockResult | undefined {
185
366
  if (reviewer.isStale(lifecycleSignal)) return reviewCancelledResult();
186
- if (!reviewCancelled(scope.ctx.signal)) return undefined;
187
- reviewer.discardLineage();
367
+ if (!reviewCancelled(callSignalOf(scope))) return undefined;
368
+ discardLineageIfTurnCancelled(scope);
188
369
  display.clear(scope);
189
370
  return reviewCancelledResult();
190
371
  }
@@ -237,12 +418,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
237
418
  decisionSource: "guardian" | "review_failure",
238
419
  ): Promise<BlockResult | undefined> {
239
420
  const { ctx, config, gate, command } = scope;
240
- const signal = ctx.signal;
421
+ const signal = callSignalOf(scope);
241
422
  const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
242
423
  const promptSignal = withLifecycle(signal, lifecycleSignal);
243
424
  const cancelled = reviewCancelledResult;
244
425
  if (lifecycleStale() || reviewCancelled(signal)) {
245
- if (!lifecycleStale()) reviewer.discardLineage();
426
+ if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
246
427
  return cancelled();
247
428
  }
248
429
  display.show(scope, "ask_user", detail);
@@ -270,7 +451,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
270
451
  );
271
452
  } catch (error) {
272
453
  if (!lifecycleStale() && !reviewCancelled(signal)) throw error;
273
- if (!lifecycleStale()) reviewer.discardLineage();
454
+ if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
274
455
  return cancelled();
275
456
  }
276
457
  const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
@@ -301,11 +482,298 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
301
482
  }
302
483
  }
303
484
 
485
+ function evidenceKeys(scope: ReviewScope): string[] {
486
+ return reviewer.collectEvidence(scope).map((record) => record.key);
487
+ }
488
+
489
+ /**
490
+ * One guardian review, run as soon as a review slot is free. Never applies
491
+ * the verdict: that waits for the decision slot in `decide`.
492
+ *
493
+ * `abandon` is set for a sibling review started early. Aborting it drops the
494
+ * verdict; its row is cleared too, unless the abort reason is
495
+ * `SUPERSEDED_REVIEW`, meaning a fresh review for the same call owns the row.
496
+ */
497
+ async function runReview(
498
+ scope: ReviewScope,
499
+ input: Record<string, unknown>,
500
+ lifecycleSignal: AbortSignal,
501
+ abandon?: AbortSignal,
502
+ ): Promise<ReviewAttempt> {
503
+ const { ctx, config } = scope;
504
+ const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
505
+ const abandoned = () => abandon?.aborted === true;
506
+ const drop = (): ReviewAttempt => {
507
+ if (!lifecycleStale() && abandon?.reason !== SUPERSEDED_REVIEW) display.clear(scope);
508
+ return { kind: "cancelled" };
509
+ };
510
+ const callSignal = callSignalOf(scope);
511
+ const slotSignal = AbortSignal.any([
512
+ lifecycleSignal,
513
+ ...(callSignal ? [callSignal] : []),
514
+ ...(abandon ? [abandon] : []),
515
+ ]);
516
+ if (reviewSlots.wouldWait(config.reviewConcurrency)) display.show(scope, "queued");
517
+ let releaseSlot: () => void;
518
+ try {
519
+ releaseSlot = await reviewSlots.acquire(config.reviewConcurrency, slotSignal);
520
+ } catch {
521
+ return drop();
522
+ }
523
+ try {
524
+ if (lifecycleStale() || reviewCancelled(callSignal) || abandoned()) return drop();
525
+ display.show(scope, "waiting");
526
+ // Collected synchronously right before the review collects the same
527
+ // stream, so the keys describe exactly what the guardian judged.
528
+ const keys = evidenceKeys(scope);
529
+ let attempt: ReviewAttempt;
530
+ try {
531
+ // The reviewer already follows the turn signal. A codemode call whose
532
+ // script ended, or an early review nobody will take, cancels its
533
+ // request and gives its slot back at once.
534
+ const stop = scope.callSignal && abandon
535
+ ? AbortSignal.any([scope.callSignal, abandon])
536
+ : scope.callSignal ?? abandon;
537
+ const review = reviewer.review(scope, input, stop);
538
+ const verdict = await (stop ? untilAborted(review, stop) : review);
539
+ if (!lifecycleStale() && reviewCancelled(ctx.signal)) reviewer.discardLineage();
540
+ attempt = { kind: "verdict", verdict, evidenceKeys: keys };
541
+ } catch (error) {
542
+ attempt = { kind: "failed", reason: error instanceof Error ? error.message : String(error), evidenceKeys: keys };
543
+ }
544
+ if (lifecycleStale() || reviewCancelled(callSignal) || abandoned()) return drop();
545
+ // An early sibling review now waits for Pi to reach its call.
546
+ if (abandon) display.show(scope, "queued");
547
+ return attempt;
548
+ } finally {
549
+ releaseSlot();
550
+ }
551
+ }
552
+
553
+ /**
554
+ * In a subagent with a UI, the first time a command would go to the human it
555
+ * is refused with a revise-first reason instead. Same-turn duplicates are
556
+ * refused too; an unchanged retry in a later turn returns undefined, and the
557
+ * caller asks the human.
558
+ */
559
+ function reviseFirstBlock(
560
+ scope: ReviewScope,
561
+ reason: string,
562
+ source: "guardian" | "review_failure",
563
+ lifecycleSignal: AbortSignal,
564
+ ): BlockResult | undefined {
565
+ if (!subagentSession || !scope.ctx.hasUI) return undefined;
566
+ // As askUser does: a refusal the model will never see must not count as
567
+ // its revise-first turn, or its retry would go straight to the human.
568
+ const cancelled = cancelledAfterAwait(scope, lifecycleSignal);
569
+ if (cancelled) return cancelled;
570
+ const key = `${scope.gate.label}\0${scope.command}`;
571
+ const refusedIn = reviseFirst.get(key);
572
+ if (refusedIn !== undefined && refusedIn !== turnGeneration) {
573
+ // Escalated now: a later identical attempt is revised first again.
574
+ reviseFirst.delete(key);
575
+ return undefined;
576
+ }
577
+ if (refusedIn === undefined) reviseFirst.set(key, turnGeneration);
578
+ return settle(scope, {
579
+ display: "revise",
580
+ verdict: "revise",
581
+ source,
582
+ reason,
583
+ block: subagentReviseFirstReason(scope.gate.label, reason),
584
+ reviseFirst: true,
585
+ });
586
+ }
587
+
588
+ /** Apply one settled review. Runs holding the decision slot. */
589
+ function applyReview(
590
+ scope: ReviewScope,
591
+ outcome: Exclude<ReviewAttempt, { kind: "cancelled" }>,
592
+ lifecycleSignal: AbortSignal,
593
+ ): Promise<BlockResult | undefined> | BlockResult | undefined {
594
+ if (outcome.kind === "failed") {
595
+ const reason = `Automatic review failed: ${outcome.reason}`;
596
+ return reviseFirstBlock(scope, reason, "review_failure", lifecycleSignal) ?? askUser(scope, reason, lifecycleSignal, "review_failure");
597
+ }
598
+ const { verdict } = outcome;
599
+ if (verdict.decision === "approve") {
600
+ return settle(scope, { display: "approved", reason: verdict.reason });
601
+ }
602
+ if (verdict.decision === "revise") {
603
+ return settle(scope, {
604
+ display: "revise",
605
+ verdict: "revise",
606
+ source: "guardian",
607
+ reason: verdict.reason,
608
+ block: `Auto Permissions requested revision: ${verdict.reason}\nRevise the command and try again.`,
609
+ });
610
+ }
611
+ return reviseFirstBlock(scope, verdict.reason, "guardian", lifecycleSignal) ?? askUser(scope, verdict.reason, lifecycleSignal, "guardian");
612
+ }
613
+
614
+ /**
615
+ * Wait for a review, then apply it in the decision slot, one command at a
616
+ * time. The slot spans any approval prompt, so a verdict reached while the
617
+ * user was answering another command's prompt is checked against the
618
+ * evidence that answer added, and reviewed again when it changed. The last
619
+ * review `MAX_REVIEWS_PER_COMMAND` allows runs inside the slot.
620
+ */
621
+ async function decide(
622
+ scope: ReviewScope,
623
+ input: Record<string, unknown>,
624
+ lifecycleSignal: AbortSignal,
625
+ firstAttempt: Promise<ReviewAttempt>,
626
+ ): Promise<BlockResult | undefined> {
627
+ const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
628
+ const callSignal = callSignalOf(scope);
629
+ // Same composite the ask path builds, so Esc, a reviewer-lifecycle reset
630
+ // and the end of a codemode script all release a waiting command instead
631
+ // of stranding it behind a prompt it is no longer waiting for.
632
+ const queueSignal = withLifecycle(callSignal, lifecycleSignal);
633
+ let attempt = firstAttempt;
634
+ for (let reviews = 1; ; reviews += 1) {
635
+ const outcome = await attempt;
636
+ if (outcome.kind === "cancelled") return reviewCancelledResult();
637
+ if (decisionQueue.busy) display.show(scope, "queued");
638
+ let releaseDecision: () => void;
639
+ try {
640
+ releaseDecision = await decisionQueue.acquire(queueSignal);
641
+ } catch {
642
+ if (!lifecycleStale()) display.clear(scope);
643
+ return reviewCancelledResult();
644
+ }
645
+ try {
646
+ if (lifecycleStale() || reviewCancelled(callSignal)) {
647
+ if (!lifecycleStale()) display.clear(scope);
648
+ return reviewCancelledResult();
649
+ }
650
+ if (sameKeys(outcome.evidenceKeys, evidenceKeys(scope))) {
651
+ return await applyReview(scope, outcome, lifecycleSignal);
652
+ }
653
+ if (reviews + 1 < MAX_REVIEWS_PER_COMMAND) {
654
+ attempt = runReview(scope, input, lifecycleSignal);
655
+ continue;
656
+ }
657
+ const last = await runReview(scope, input, lifecycleSignal);
658
+ if (last.kind === "cancelled") return reviewCancelledResult();
659
+ return await applyReview(scope, last, lifecycleSignal);
660
+ } finally {
661
+ releaseDecision();
662
+ }
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Start reviews for the guarded bash calls that follow `toolCallId` in the
668
+ * assistant message that issued it. Pi calls tool_call handlers for one
669
+ * message's calls one at a time, so without this their reviews could never
670
+ * overlap. Each result is taken by the call's own handler, and only when the
671
+ * input it finally receives is exactly the input that was reviewed.
672
+ */
673
+ function startSiblingReviews(
674
+ toolCallId: string,
675
+ ctx: ExtensionContext,
676
+ config: AutoPermissionsConfig,
677
+ lifecycleSignal: AbortSignal,
678
+ ): void {
679
+ if (config.reviewConcurrency <= 1) return;
680
+ const index = lastAssistantCalls.findIndex((call) => call.id === toolCallId);
681
+ if (index < 0) return;
682
+ for (const call of lastAssistantCalls.slice(index + 1)) {
683
+ if (!isBashTool(call.name) || siblingReviews.has(call.id)) continue;
684
+ const input = call.arguments as Record<string, unknown> | undefined;
685
+ const command = input?.command;
686
+ if (typeof command !== "string") continue;
687
+ const classified = classifyCommand(command, config, trustedGroups);
688
+ if (classified.kind !== "review") continue;
689
+ const controller = new AbortController();
690
+ const scope: ReviewScope = {
691
+ ctx,
692
+ config,
693
+ gate: classified.gate,
694
+ command,
695
+ target: { toolName: call.name, toolCallId: call.id },
696
+ };
697
+ siblingReviews.set(call.id, {
698
+ scope,
699
+ input: JSON.stringify(input),
700
+ gateLabel: classified.gate.label,
701
+ attempt: runReview(scope, input!, lifecycleSignal, controller.signal),
702
+ controller,
703
+ });
704
+ }
705
+ }
706
+
707
+ /**
708
+ * The early review of this call, when it reviewed exactly this input under
709
+ * this gate. `gateLabel` is undefined when the call no longer needs review
710
+ * (another handler changed it), in which case the early review is dropped.
711
+ */
712
+ function takeSiblingReview(
713
+ toolCallId: string,
714
+ input: unknown,
715
+ gateLabel: string | undefined,
716
+ ): Promise<ReviewAttempt> | undefined {
717
+ const sibling = siblingReviews.get(toolCallId);
718
+ if (!sibling) return undefined;
719
+ siblingReviews.delete(toolCallId);
720
+ if (gateLabel === undefined) {
721
+ sibling.controller.abort();
722
+ display.clear(sibling.scope);
723
+ return undefined;
724
+ }
725
+ if (sibling.input === JSON.stringify(input) && sibling.gateLabel === gateLabel) return sibling.attempt;
726
+ // Changed after it was reviewed early: the fresh review owns the row.
727
+ sibling.controller.abort(SUPERSEDED_REVIEW);
728
+ return undefined;
729
+ }
730
+
731
+ /** Drop early reviews whose calls never reached this extension. */
732
+ function abandonSiblingReviews(): void {
733
+ for (const sibling of siblingReviews.values()) {
734
+ sibling.controller.abort();
735
+ display.clear(sibling.scope);
736
+ }
737
+ siblingReviews.clear();
738
+ }
739
+
740
+ pi.on("message_end", (event) => {
741
+ const calls = assistantToolCalls((event as { message?: unknown }).message);
742
+ if (calls.length > 0) lastAssistantCalls = calls;
743
+ });
744
+
745
+ pi.on("turn_end", () => {
746
+ turnGeneration += 1;
747
+ lastAssistantCalls = [];
748
+ abandonSiblingReviews();
749
+ endScripts();
750
+ scriptRoots.clear();
751
+ });
752
+
753
+ pi.on("tool_execution_start", (event) => {
754
+ const { toolCallId, parentToolCallId } = event as { toolCallId?: unknown; parentToolCallId?: unknown };
755
+ if (typeof toolCallId !== "string") return;
756
+ // A provider may reuse a call id in a later turn; that call is live again.
757
+ finishedCalls.delete(toolCallId);
758
+ if (typeof parentToolCallId === "string") {
759
+ scriptRoots.set(toolCallId, scriptRoots.get(parentToolCallId) ?? parentToolCallId);
760
+ }
761
+ });
762
+
763
+ pi.on("tool_execution_end", (event) => {
764
+ const { toolCallId } = event as { toolCallId?: unknown };
765
+ if (typeof toolCallId !== "string") return;
766
+ rememberFinished(toolCallId);
767
+ const controller = scriptsEnded.get(toolCallId);
768
+ if (!controller) return;
769
+ scriptsEnded.delete(toolCallId);
770
+ controller.abort();
771
+ });
772
+
304
773
  pi.on("tool_call", async (event, ctx) => {
305
- if (event.toolName !== "bash" && !event.toolName.endsWith(".bash")) return;
774
+ if (!isBashTool(event.toolName)) return;
306
775
  const command = (event.input as { command: string }).command;
307
776
  const lifecycleSignal = reviewer.lifecycleSignal;
308
- const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
309
777
 
310
778
  let config: AutoPermissionsConfig;
311
779
  try {
@@ -315,11 +783,33 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
315
783
  return { block: true, reason: error instanceof Error ? error.message : String(error) };
316
784
  }
317
785
  if (!config.enabled) return;
318
- const target: ReviewTarget = { toolName: event.toolName, toolCallId: event.toolCallId };
786
+ const parentToolCallId = (event as { parentToolCallId?: string }).parentToolCallId;
787
+ if (!parentToolCallId) startSiblingReviews(event.toolCallId, ctx, config, lifecycleSignal);
788
+ const scriptToolCallId = parentToolCallId ? scriptRoots.get(parentToolCallId) ?? parentToolCallId : undefined;
789
+ const target: ReviewTarget = {
790
+ toolName: event.toolName,
791
+ toolCallId: event.toolCallId,
792
+ ...(scriptToolCallId ? { scriptToolCallId } : {}),
793
+ };
794
+ const input = event.input as Record<string, unknown>;
319
795
  const classified = classifyCommand(command, config, trustedGroups);
796
+ const early = takeSiblingReview(
797
+ event.toolCallId,
798
+ input,
799
+ classified.kind === "review" ? classified.gate.label : undefined,
800
+ );
320
801
  if (classified.kind === "pass") return;
321
802
  const gate = classified.gate;
322
- const scope: ReviewScope = { ctx, config, gate, command, target };
803
+ const scope: ReviewScope = {
804
+ ctx,
805
+ config,
806
+ gate,
807
+ command,
808
+ target,
809
+ ...(parentToolCallId && scriptToolCallId
810
+ ? { callSignal: scriptEndedSignal(parentToolCallId, scriptToolCallId) }
811
+ : {}),
812
+ };
323
813
  if (classified.kind === "deny") {
324
814
  return settle(scope, {
325
815
  verdict: "block",
@@ -329,63 +819,40 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
329
819
  });
330
820
  }
331
821
 
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;
822
+ return decide(scope, input, lifecycleSignal, early ?? runReview(scope, input, lifecycleSignal));
823
+ });
824
+
825
+ pi.on("before_agent_start", (event) => {
826
+ if (!subagentSession) return;
345
827
  try {
346
- releaseReviewSlot = await guardianReviewQueue.acquire(queueSignal);
828
+ if (!loadAutoPermissionsConfig().enabled) return;
347
829
  } catch {
348
- return reviewCancelledResult();
830
+ // An invalid config fails closed on the first bash call; the guidance still applies.
349
831
  }
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();
832
+ // On Pi 1.x a returned systemPrompt forces the whole prompt for the run,
833
+ // dropping what later extensions change in its options (pi-agents adds
834
+ // the parent's instruction files there), so the guidance goes into a
835
+ // named section instead. Older Pi takes the prompt as a string.
836
+ const sections = (event as { systemPromptOptions?: { sections?: Record<string, string> } }).systemPromptOptions?.sections;
837
+ if (sections) {
838
+ sections[SUBAGENT_PROMPT_SECTION] = SUBAGENT_PERMISSIONS_PROMPT;
839
+ return;
383
840
  }
841
+ return { systemPrompt: `${event.systemPrompt}\n\n${SUBAGENT_PERMISSIONS_PROMPT}` };
384
842
  });
385
843
 
386
844
  registerSettingsCommand(pi, { overrides, reviewer });
387
845
 
846
+ function resetCallTracking(): void {
847
+ lastAssistantCalls = [];
848
+ abandonSiblingReviews();
849
+ endScripts();
850
+ finishedCalls.clear();
851
+ scriptRoots.clear();
852
+ }
853
+
388
854
  pi.on("session_shutdown", async (_event, ctx) => {
855
+ resetCallTracking();
389
856
  reviewer.endSession();
390
857
  display.shutdown(ctx);
391
858
  setHerdrBlocked(pi, false);
@@ -400,6 +867,9 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
400
867
  }
401
868
  if (config) warnAboutMissingReviewerProvider(ctx, config);
402
869
 
870
+ resetCallTracking();
871
+ subagentSession = detectSubagentContext(ctx.cwd) !== undefined;
872
+ reviseFirst.clear();
403
873
  reviewer.startSession(ctx.cwd);
404
874
  overrides.restore(ctx.sessionManager.getBranch());
405
875
  trustedGroups = ctx.isProjectTrusted() ? loadTrustedGroups(ctx.cwd) : new Set();