@hank-warren/pi-auto-permissions 0.16.2 → 0.17.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,13 +1,6 @@
1
- import { detectLoopContext } from "./loop-context.js";
2
1
  import { createReviewQueue } from "./review-queue.js";
3
- import {
4
- LoopReviseBudget,
5
- loopBlockReason,
6
- type LoopReviseCharge,
7
- } from "./loop-revise-budget.js";
8
2
  import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
9
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { Type } from "typebox";
11
4
  import { readFileSync } from "node:fs";
12
5
  import { join } from "node:path";
13
6
  import { loadAutoPermissionsConfig, type AutoPermissionsConfig } from "./config.js";
@@ -22,11 +15,10 @@ import {
22
15
  classifyPromptChoice,
23
16
  expectedDecisionForChoice,
24
17
  permissionPromptOptions,
25
- shouldOfferStandingApproval,
26
18
  type PromptEvaluationUserChoice,
27
19
  } from "./evaluation-log.js";
28
20
  import type { Gate } from "./gates.js";
29
- import { classifyCommand, untrustedMatches } from "./classify.js";
21
+ import { classifyCommand } from "./classify.js";
30
22
  import type { ReviewEvidenceRecord } from "./review.js";
31
23
  import { promptSelect, setHerdrBlocked } from "./prompt-select.js";
32
24
  import { createReviewDisplay } from "./review-display.js";
@@ -75,14 +67,15 @@ function loadTrustedGroups(cwd: string): Set<string> {
75
67
  }
76
68
 
77
69
  function denyReason(gate: Gate): string {
78
- 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 with request_override, trusted groups, or user approval. Choose a different approach.`;
70
+ 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
+ }
72
+
73
+ function reviewCancelledResult(): BlockResult {
74
+ return { block: true, reason: "Auto Permissions review cancelled" };
79
75
  }
80
76
 
81
- function conventionReason(gate: Gate, command: string): string {
82
- let reason = `Convention violation: ${gate.label}\n\n${gate.message ?? "Use the configured project tooling."}`;
83
- const suggestion = gate.suggest?.(command);
84
- if (suggestion && suggestion !== command) reason += `\n\nSuggested command:\n ${suggestion}`;
85
- return `${reason}\n\nIf this is a legitimate edge case, explain why and call \`request_override\` with the exact command.`;
77
+ function withLifecycle(signal: AbortSignal | undefined, lifecycle: AbortSignal): AbortSignal {
78
+ return signal ? AbortSignal.any([signal, lifecycle]) : lifecycle;
86
79
  }
87
80
 
88
81
  export default function autoPermissionsExtension(pi: ExtensionAPI) {
@@ -90,21 +83,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
90
83
  let trustedGroups = new Set<string>();
91
84
  let lastConfigError: string | undefined;
92
85
  let lastEvaluationLogError: string | undefined;
93
- let sessionActive = true;
94
- /**
95
- * Revision rounds spent per guardian concern while a loop is active.
96
- *
97
- * Session-scoped and held here rather than in the review closure, so a
98
- * context compaction (which rewrites the conversation, not this process)
99
- * cannot reset it. Restored from the session branch at session_start so a
100
- * resume cannot reset it either.
101
- */
102
- const loopReviseBudget = new LoopReviseBudget();
103
- /** The loop these counts belong to, so a new loop starts with a clean budget. */
104
- let loopReviseBudgetLoopId: string | undefined;
105
86
  const guardianReviewQueue = createReviewQueue();
106
- const display = createReviewDisplay(pi, { isSessionActive: () => sessionActive });
107
- const reviewer = createGuardianReviewer({ isSessionActive: () => sessionActive, overrides });
87
+ // The session is active exactly while the reviewer lifecycle is unaborted:
88
+ // session_shutdown aborts it, session_start synchronously aborts and replaces
89
+ // it, and nothing else touches it.
90
+ const reviewer = createGuardianReviewer({ overrides });
91
+ const display = createReviewDisplay({ isSessionActive: () => !reviewer.lifecycleSignal.aborted });
108
92
 
109
93
  /**
110
94
  * Record a non-approved outcome: a `pi.events` emit (the PermissionDenied
@@ -182,13 +166,8 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
182
166
  * if that package is installed. Say so once per session instead of letting the
183
167
  * first guarded command fail with a bare "review model not found".
184
168
  */
185
- function warnAboutMissingReviewerProvider(ctx: ExtensionContext): void {
186
- let provider: string | undefined;
187
- try {
188
- provider = currentConfig(ctx).reviewer?.provider;
189
- } catch {
190
- return; // The config error was already reported by currentConfig().
191
- }
169
+ function warnAboutMissingReviewerProvider(ctx: ExtensionContext, config: AutoPermissionsConfig): void {
170
+ const provider = config.reviewer?.provider;
192
171
  if (!provider || ctx.modelRegistry.getProvider(provider)) return;
193
172
 
194
173
  const message =
@@ -199,7 +178,15 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
199
178
  }
200
179
 
201
180
  function reviewCancelled(signal: AbortSignal | undefined): boolean {
202
- return !sessionActive || signal?.aborted === true;
181
+ return signal?.aborted === true;
182
+ }
183
+
184
+ function cancelledAfterAwait(scope: ReviewScope, lifecycleSignal: AbortSignal): BlockResult | undefined {
185
+ if (reviewer.isStale(lifecycleSignal)) return reviewCancelledResult();
186
+ if (!reviewCancelled(scope.ctx.signal)) return undefined;
187
+ reviewer.discardLineage();
188
+ display.clear(scope);
189
+ return reviewCancelledResult();
203
190
  }
204
191
 
205
192
  function logPromptEvaluation(
@@ -243,33 +230,6 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
243
230
  }
244
231
  }
245
232
 
246
- /**
247
- * Charge one revision round against a guardian concern and persist the
248
- * budget. A loop id different from the one the counts belong to means a new
249
- * loop: it inherits nothing from the last one's arguments with the guardian.
250
- */
251
- function chargeLoopRevision(
252
- loopId: string | undefined,
253
- gateLabel: string,
254
- reason: string,
255
- ): LoopReviseCharge {
256
- if (loopReviseBudgetLoopId !== loopId) {
257
- loopReviseBudget.clear();
258
- loopReviseBudgetLoopId = loopId;
259
- }
260
- const charge = loopReviseBudget.charge(gateLabel, reason);
261
- try {
262
- pi.appendEntry(LoopReviseBudget.entryType, {
263
- ...loopReviseBudget.snapshot(),
264
- ...(loopId ? { loopId } : {}),
265
- });
266
- } catch {
267
- // Persistence is what survives a restore; the in-memory count is what
268
- // survives a compaction. Losing the former never widens the bound now.
269
- }
270
- return charge;
271
- }
272
-
273
233
  async function askUser(
274
234
  scope: ReviewScope,
275
235
  detail: string,
@@ -279,8 +239,8 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
279
239
  const { ctx, config, gate, command } = scope;
280
240
  const signal = ctx.signal;
281
241
  const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
282
- const promptSignal = signal ? AbortSignal.any([signal, lifecycleSignal]) : lifecycleSignal;
283
- const cancelled = (): BlockResult => ({ block: true, reason: "Auto Permissions review cancelled" });
242
+ const promptSignal = withLifecycle(signal, lifecycleSignal);
243
+ const cancelled = reviewCancelledResult;
284
244
  if (lifecycleStale() || reviewCancelled(signal)) {
285
245
  if (!lifecycleStale()) reviewer.discardLineage();
286
246
  return cancelled();
@@ -290,7 +250,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
290
250
  return settle(scope, {
291
251
  display: "blocked",
292
252
  verdict: "block",
293
- source: decisionSource === "guardian" ? "guardian" : "review_failure",
253
+ source: decisionSource,
294
254
  reason: detail,
295
255
  block: `${gate.label} requires user approval: ${detail}\nThis session has no interactive user to ask. Prefer an approach that avoids the gated operation, or report this blocker in your final output instead of retrying the same command.`,
296
256
  });
@@ -305,32 +265,24 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
305
265
  pi,
306
266
  ctx,
307
267
  `${gate.label} — Auto Permissions needs approval\n\n${detail}\n\n${command}`,
308
- permissionPromptOptions(
309
- config.evaluationLog.enabled,
310
- shouldOfferStandingApproval(decisionSource, config.standingApprovals.enabled),
311
- ),
268
+ permissionPromptOptions(config.evaluationLog.enabled),
312
269
  promptSignal,
313
- { allowComment: true },
314
270
  );
315
271
  } catch (error) {
316
272
  if (!lifecycleStale() && !reviewCancelled(signal)) throw error;
317
273
  if (!lifecycleStale()) reviewer.discardLineage();
318
274
  return cancelled();
319
275
  }
320
- if (lifecycleStale()) return cancelled();
321
- if (reviewCancelled(signal)) {
322
- reviewer.discardLineage();
323
- if (sessionActive) display.clear(scope);
324
- return cancelled();
325
- }
276
+ const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
277
+ if (cancelledResult) return cancelledResult;
326
278
  const classification = classifyPromptChoice(choice);
327
279
  // Feed the user's decision back to the guardian as session-scoped
328
280
  // user-source evidence. review_failure prompts are excluded: their
329
281
  // "concern" is an infrastructure error, not a guardian judgment.
330
282
  if (decisionSource === "guardian" && classification) {
331
- overrides.recordPromptDecision(scope, classification, detail, reviewer.lastEvidenceKey);
283
+ overrides.recordPromptDecision(gate, command, classification, detail, reviewer.lastEvidenceKey);
332
284
  }
333
- if (config.evaluationLog.enabled && classification?.userChoice) {
285
+ if (classification?.userChoice) {
334
286
  logPromptEvaluation(scope, detail, evaluationContext, decisionSource, classification.userChoice);
335
287
  }
336
288
  if (classification?.allowsExecution) {
@@ -364,7 +316,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
364
316
  }
365
317
  if (!config.enabled) return;
366
318
  const target: ReviewTarget = { toolName: event.toolName, toolCallId: event.toolCallId };
367
- const classified = classifyCommand(command, config, trustedGroups, overrides.allowedConventionCommands);
319
+ const classified = classifyCommand(command, config, trustedGroups);
368
320
  if (classified.kind === "pass") return;
369
321
  const gate = classified.gate;
370
322
  const scope: ReviewScope = { ctx, config, gate, command, target };
@@ -376,15 +328,6 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
376
328
  block: denyReason(gate),
377
329
  });
378
330
  }
379
- if (classified.kind === "convention") {
380
- if (ctx.hasUI) overrides.activateOverrideTool();
381
- return settle(scope, {
382
- verdict: "block",
383
- source: "convention",
384
- reason: gate.message ?? gate.label,
385
- block: conventionReason(gate, command),
386
- });
387
- }
388
331
 
389
332
  // Show the queued row *before* waiting, and only when there is actually
390
333
  // something to wait for: the critical section spans the human prompt, so a
@@ -397,55 +340,25 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
397
340
  // Same composite the ask path builds, so Esc and a reviewer-lifecycle reset
398
341
  // both release a queued command instead of stranding it behind a review it
399
342
  // is no longer waiting for.
400
- const queueSignal = ctx.signal ? AbortSignal.any([ctx.signal, lifecycleSignal]) : lifecycleSignal;
343
+ const queueSignal = withLifecycle(ctx.signal, lifecycleSignal);
401
344
  let releaseReviewSlot: () => void;
402
345
  try {
403
346
  releaseReviewSlot = await guardianReviewQueue.acquire(queueSignal);
404
347
  } catch {
405
- return { block: true, reason: "Auto Permissions review cancelled" };
348
+ return reviewCancelledResult();
406
349
  }
407
350
  try {
408
351
  if (lifecycleStale() || reviewCancelled(ctx.signal)) {
409
- return { block: true, reason: "Auto Permissions review cancelled" };
352
+ return reviewCancelledResult();
410
353
  }
411
354
  const signal = ctx.signal;
412
355
  display.show(scope, "waiting");
413
356
  try {
414
357
  const verdict = await reviewer.review(scope, event.input as Record<string, unknown>);
415
- if (lifecycleStale()) {
416
- return { block: true, reason: "Auto Permissions review cancelled" };
417
- }
418
- if (reviewCancelled(signal)) {
419
- reviewer.discardLineage();
420
- if (sessionActive) display.clear(scope);
421
- return { block: true, reason: "Auto Permissions review cancelled" };
422
- }
358
+ const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
359
+ if (cancelledResult) return cancelledResult;
423
360
  if (verdict.decision === "approve") {
424
- const approved = settle(scope, { display: "approved", reason: verdict.reason });
425
- // An approval ends the argument at this gate: the per-gate bound exists
426
- // to stop an agent grinding, not to ration a long loop's whole session.
427
- loopReviseBudget.settle(gate.label);
428
- return approved;
429
- }
430
- // An unattended loop cannot answer a modal and must not be handed an
431
- // unbounded retry loop either, so both non-approving verdicts come back
432
- // as one bounded block. The verdict itself is unchanged: nothing is
433
- // approved here that would not have been approved with a user present.
434
- const loopContext = detectLoopContext();
435
- if (loopContext) {
436
- const charge = chargeLoopRevision(loopContext.loopId, gate.label, verdict.reason);
437
- return settle(scope, {
438
- display: verdict.decision === "revise" ? "revise" : "blocked",
439
- verdict: verdict.decision === "revise" ? "revise" : "block",
440
- source: "loop",
441
- reason: verdict.reason,
442
- block: loopBlockReason({
443
- gateLabel: gate.label,
444
- reason: verdict.reason,
445
- decision: verdict.decision,
446
- ...charge,
447
- }),
448
- });
361
+ return settle(scope, { display: "approved", reason: verdict.reason });
449
362
  }
450
363
  if (verdict.decision === "revise") {
451
364
  return settle(scope, {
@@ -459,22 +372,10 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
459
372
  return askUser(scope, verdict.reason, lifecycleSignal, "guardian");
460
373
  } catch (error) {
461
374
  if (lifecycleStale() || reviewCancelled(signal)) {
462
- if (!lifecycleStale() && sessionActive) display.clear(scope);
463
- return { block: true, reason: "Auto Permissions review cancelled" };
375
+ if (!lifecycleStale()) display.clear(scope);
376
+ return reviewCancelledResult();
464
377
  }
465
378
  const reason = error instanceof Error ? error.message : String(error);
466
- // A review that failed is an infrastructure fault, not a guardian
467
- // judgment, so it charges no revision round — but it still must not open
468
- // a modal in a session with nobody to answer it.
469
- if (detectLoopContext()) {
470
- return settle(scope, {
471
- display: "blocked",
472
- verdict: "block",
473
- source: "review_failure",
474
- reason,
475
- block: `${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.`,
476
- });
477
- }
478
379
  return askUser(scope, `Automatic review failed: ${reason}`, lifecycleSignal, "review_failure");
479
380
  }
480
381
  } finally {
@@ -482,130 +383,25 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
482
383
  }
483
384
  });
484
385
 
485
- pi.registerTool({
486
- name: "request_override",
487
- executionMode: "sequential",
488
- label: "Request Override",
489
- description: "Request a one-session exception for a command that violates a tooling convention. This cannot bypass guarded commands or deny rules.",
490
- parameters: Type.Object({
491
- command: Type.String({ description: "Exact command to allow" }),
492
- reason: Type.String({ description: "Why the convention does not apply" }),
493
- }),
494
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
495
- let config: AutoPermissionsConfig;
496
- try {
497
- config = currentConfig(ctx);
498
- } catch (error) {
499
- return {
500
- content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
501
- details: { success: false },
502
- };
503
- }
504
- const matches = untrustedMatches(params.command, config, trustedGroups);
505
- if (matches.some((gate) => gate.level === "deny")) {
506
- return {
507
- content: [{ type: "text", text: "The command matches a deny rule, which is a hard policy boundary. Deny rules cannot be bypassed with request_override; choose a different approach." }],
508
- details: { success: false },
509
- };
510
- }
511
- if (!matches.length || matches.some((gate) => gate.level === "guarded")) {
512
- return {
513
- content: [{ type: "text", text: "The command is not a convention violation. Guarded commands cannot be bypassed with request_override." }],
514
- details: { success: false },
515
- };
516
- }
517
- const gate = matches[0];
518
- const lifecycleSignal = reviewer.lifecycleSignal;
519
- const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
520
- const promptSignal = signal ? AbortSignal.any([signal, lifecycleSignal]) : lifecycleSignal;
521
- const cancelled = () => ({
522
- content: [{ type: "text" as const, text: "Override cancelled." }],
523
- details: { success: false },
524
- });
525
- if (lifecycleStale() || reviewCancelled(signal)) return cancelled();
526
- if (!ctx.hasUI) {
527
- return {
528
- content: [{ type: "text", text: "Cannot request an override without an interactive UI." }],
529
- details: { success: false },
530
- };
531
- }
532
-
533
- setHerdrBlocked(pi, true, gate.label);
534
- try {
535
- let choice: string | undefined;
536
- try {
537
- // The override prompt never had Tab-to-comment (the monkey patch only
538
- // enabled it on titles matching /needs approval/i), so keep parity.
539
- choice = await promptSelect(
540
- pi,
541
- ctx,
542
- `Convention override: ${gate.label}\n\n${params.reason}\n\n${params.command}`,
543
- ["Allow for this session", "Keep blocked"],
544
- promptSignal,
545
- { allowComment: false },
546
- );
547
- } catch (error) {
548
- if (!lifecycleStale() && !reviewCancelled(signal)) throw error;
549
- return cancelled();
550
- }
551
- if (lifecycleStale() || reviewCancelled(signal)) return cancelled();
552
- if (choice === "Allow for this session") {
553
- overrides.allowConvention(params.command);
554
- return {
555
- content: [{ type: "text", text: `Override granted for this session:\n ${params.command}` }],
556
- details: { success: true, command: params.command },
557
- };
558
- }
559
- return {
560
- content: [{ type: "text", text: gate.message ?? "Use the configured project tooling." }],
561
- details: { success: false },
562
- };
563
- } finally {
564
- if (!lifecycleStale()) setHerdrBlocked(pi, false);
565
- }
566
- },
567
- });
568
-
569
- // The override schema is useful only after a convention denial has named an
570
- // exact command. It is kept registered for replay and narrowed out of the
571
- // active set at session_start — never here, because Pi refuses action methods
572
- // during extension loading.
573
-
574
386
  registerSettingsCommand(pi, { overrides, reviewer });
575
387
 
576
388
  pi.on("session_shutdown", async (_event, ctx) => {
577
- sessionActive = false;
578
- reviewer.abortLifecycle();
579
- reviewer.discardLineage();
389
+ reviewer.endSession();
580
390
  display.shutdown(ctx);
581
391
  setHerdrBlocked(pi, false);
582
392
  });
583
393
 
584
394
  pi.on("session_start", async (_event, ctx) => {
585
- overrides.resetForSession();
586
- warnAboutMissingReviewerProvider(ctx);
587
-
588
- reviewer.abortLifecycle();
589
- reviewer.discardLineage();
590
- reviewer.resetLifecycle();
591
- sessionActive = true;
592
- overrides.restore(ctx.sessionManager.getBranch());
593
- // Rebuild the revise budget before the first tool call: a resumed session
594
- // that forgot its spent rounds would hand the agent a fresh set of them.
595
- loopReviseBudget.restore(ctx.sessionManager.getBranch());
596
- loopReviseBudgetLoopId = detectLoopContext()?.loopId;
597
- trustedGroups = ctx.isProjectTrusted() ? loadTrustedGroups(ctx.cwd) : new Set();
598
- // Snapshot the trust baseline at session start: remotes configured now are
599
- // inside the boundary, anything added or repointed later is not. Re-capture
600
- // on every session_start (resume, branch switch) so the baseline follows
601
- // the session the reviews belong to; the fingerprint covers the change.
602
- reviewer.captureEnvironment(ctx.cwd);
395
+ let config: AutoPermissionsConfig | undefined;
603
396
  try {
604
- const config = currentConfig(ctx);
605
- overrides.loadStanding(config, ctx);
606
- if (config.ui.placement === "toolRow") display.registerGuardedBash(ctx);
397
+ config = currentConfig(ctx);
607
398
  } catch {
608
- // The first bash call will fail closed with the configuration error.
399
+ // Reported by currentConfig; the first bash call fails closed.
609
400
  }
401
+ if (config) warnAboutMissingReviewerProvider(ctx, config);
402
+
403
+ reviewer.startSession(ctx.cwd);
404
+ overrides.restore(ctx.sessionManager.getBranch());
405
+ trustedGroups = ctx.isProjectTrusted() ? loadTrustedGroups(ctx.cwd) : new Set();
610
406
  });
611
407
  }
@@ -0,0 +1,22 @@
1
+ import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ /** Rotate once a sidecar grows past this size; one previous generation is kept. */
5
+ export const SIDECAR_ROTATE_BYTES = 16 * 1024 * 1024;
6
+
7
+ function rotateIfLarge(path: string, rotateBytes: number): void {
8
+ try {
9
+ if (statSync(path).size < rotateBytes) return;
10
+ renameSync(path, `${path}.1`);
11
+ } catch {
12
+ // A missing or unrotatable sidecar simply keeps appending.
13
+ }
14
+ }
15
+
16
+ /** Append one JSON line to an owner-only sidecar, rotating first when `rotateBytes` is set. */
17
+ export function appendJsonlRecord(path: string, record: unknown, rotateBytes?: number): void {
18
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
19
+ if (rotateBytes !== undefined) rotateIfLarge(path, rotateBytes);
20
+ appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
21
+ chmodSync(path, 0o600);
22
+ }
@@ -6,6 +6,8 @@ import type { ReviewEvidenceRecord } from "./review.js";
6
6
  * evidence stream as user-source records so the guardian can treat them as
7
7
  * authorization (allows) or standing constraints (blocks).
8
8
  */
9
+ export const PERMISSION_OVERRIDE_CHOICES = ["allow", "allow_unnecessary", "allow_appropriate", "block"] as const;
10
+
9
11
  export interface PermissionOverride {
10
12
  seq: number;
11
13
  /**
@@ -18,13 +20,7 @@ export interface PermissionOverride {
18
20
  gateLabel: string;
19
21
  command: string;
20
22
  reviewerReason: string;
21
- choice: "allow" | "allow_unnecessary" | "allow_appropriate" | "block";
22
- /** Present only for a user-scoped ledger approval loaded at session start. */
23
- standing?: {
24
- grantedAt: string;
25
- project: string;
26
- gateGroup: string;
27
- };
23
+ choice: (typeof PERMISSION_OVERRIDE_CHOICES)[number];
28
24
  }
29
25
 
30
26
  /** Anchor value meaning "before every collected record". */
@@ -43,16 +39,6 @@ export function overrideEvidenceRecord(override: PermissionOverride): ReviewEvid
43
39
  const command = JSON.stringify(commandPreview(override.command));
44
40
  const gate = JSON.stringify(override.gateLabel);
45
41
  const concern = JSON.stringify(commandPreview(override.reviewerReason));
46
- if (override.standing) {
47
- const granted = override.standing.grantedAt.slice(0, 10);
48
- const project = JSON.stringify(commandPreview(override.standing.project));
49
- const group = JSON.stringify(override.standing.gateGroup);
50
- return {
51
- key: `override:${override.seq}`,
52
- source: "user",
53
- text: `USER (standing permission override, granted ${granted} in ${project}): allowed gated command ${command} (gate ${gate}, group ${group}) despite reviewer concern ${concern}. Treat comparable actions in any project as authorized unless a later user statement or block contradicts this. This never authorizes an action of a materially higher risk class.`,
54
- };
55
- }
56
42
  let text: string;
57
43
  switch (override.choice) {
58
44
  case "allow_unnecessary":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-auto-permissions",
3
- "version": "0.16.2",
3
+ "version": "0.17.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": [
@@ -43,24 +43,20 @@
43
43
  "NOTICE.md",
44
44
  "auto-permissions-setup",
45
45
  "index.ts",
46
- "approval-pulse.ts",
47
46
  "config.ts",
48
47
  "config-writer.ts",
49
48
  "default-rules.ts",
50
49
  "denial-log.ts",
51
50
  "settings-menu.ts",
52
51
  "guardian-transport.ts",
53
- "openai-codex-transport.ts",
54
52
  "subagent-context.ts",
55
- "loop-context.ts",
56
- "loop-revise-budget.ts",
57
53
  "evaluation-log.ts",
58
54
  "gates.ts",
55
+ "jsonl-sidecar.ts",
59
56
  "override-evidence.ts",
60
57
  "review.ts",
61
58
  "review-queue.ts",
62
59
  "session-environment.ts",
63
- "standing-overrides.ts",
64
60
  "usage-log.ts",
65
61
  "widget-status.ts",
66
62
  "classify.ts",
@@ -80,7 +76,6 @@
80
76
  "peerDependencies": {
81
77
  "@earendil-works/pi-ai": "*",
82
78
  "@earendil-works/pi-coding-agent": "*",
83
- "@earendil-works/pi-tui": "*",
84
- "typebox": "*"
79
+ "@earendil-works/pi-tui": "*"
85
80
  }
86
81
  }
package/prompt-select.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { OptionSelector } from "@hank-warren/pi-permission-selector/selector.ts";
3
- import { PulsingApprovalIndicator } from "./approval-pulse.js";
4
3
 
5
4
  /**
6
5
  * Tell Herdr this pane is waiting on a human, so a supervising agent can see
@@ -33,23 +32,20 @@ export function promptSelect(
33
32
  title: string,
34
33
  values: string[],
35
34
  signal: AbortSignal,
36
- opts: { allowComment: boolean },
37
35
  ): Promise<string | undefined> {
38
- let indicator: PulsingApprovalIndicator | undefined;
39
36
  return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
40
37
  let finished = false;
41
38
  const onAbort = () => finish(undefined);
42
39
  const finish = (result: string | undefined) => {
43
40
  if (finished) return;
44
41
  finished = true;
45
- indicator?.stop();
46
42
  signal.removeEventListener("abort", onAbort);
47
43
  done(result);
48
44
  };
49
45
  const selector = new OptionSelector({
50
- title,
46
+ title: `${theme.fg("warning", theme.bold("●"))} ${title}`,
51
47
  options: values.map((value) => ({ value, label: value })),
52
- allowComment: opts.allowComment,
48
+ allowComment: true,
53
49
  theme,
54
50
  onSelect: (option, comment) => {
55
51
  if (comment) {
@@ -64,17 +60,10 @@ export function promptSelect(
64
60
  onCancel: () => finish(undefined),
65
61
  requestRender: () => tui.requestRender(),
66
62
  });
67
- indicator = new PulsingApprovalIndicator(
68
- title.split("\n", 1)[0] ?? "Auto Permissions needs approval",
69
- selector,
70
- theme,
71
- () => tui.requestRender(),
72
- );
73
63
  signal.addEventListener("abort", onAbort, { once: true });
74
64
  // `done` before the factory resolves is safe: pi marks the dialog closed
75
65
  // and never mounts the component.
76
66
  if (signal.aborted) onAbort();
77
- else indicator.start();
78
- return indicator;
79
- }).finally(() => indicator?.stop());
67
+ return selector;
68
+ });
80
69
  }