@hank-warren/pi-auto-permissions 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @hank-warren/pi-auto-permissions
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Rework the approval prompt so a long command can no longer push Allow/Block off-screen. The dialog shows the command (cut to a preview past a quarter of the terminal; Ctrl+O opens the full command in a scrolling viewer inside the prompt, which matters for commands a codemode script built, since the transcript shows only the script), then the guardian's note behind a `▌` bar in a fixed strong yellow, then the options, which stay pinned while you scroll the session. The review widget steps aside while the prompt is open instead of repeating it, and a settled result's reason now carries a `▌` bar in the outcome's color.
8
+
9
+ ## 0.19.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Make subagents revise a command before the human is interrupted, and make approval prompts work over RPC.
14
+
15
+ - A subagent session (`PI_SUBAGENT_CHILD=1`, set by `@hank-warren/pi-agents`) is told it is a subagent: a short section appended to its system prompt says that commands needing approval pause the supervising session, so it should prefer in-scope and read-only commands and revise when asked to. On Pi 1.x the section is a named prompt section (`auto_permissions_subagent`), so it never replaces the prompt that Pi and other extensions build, such as the parent's instruction files pi-agents adds to a child.
16
+ - In a subagent child with a UI, an `ask_user` verdict or a failed review first blocks the command with a revise-first reason instead of prompting. Issuing the same command again unchanged in a later turn escalates it to the human as an ordinary approval prompt, once: a later identical attempt is revised first again. Duplicates within the same turn, such as sibling calls or a codemode `Promise.all`, are refused too, because the model has not seen the first refusal yet. Children without a UI keep the "no interactive user" block.
17
+ - In RPC mode the approval prompt goes through `ctx.ui.select`. Before this, every approval forwarded over RPC was silently denied, because `custom()` returns nothing there. This applies to any RPC session, not only subagents: a client that never answers the prompt now leaves the command waiting until the turn is cancelled, where it used to be denied at once.
18
+ - The guardian's subagent section says what an `ask_user` verdict does for that child: revise first and then a human when its prompts surface in a parent, a hard block when it has no UI.
19
+ - A revise-first refusal is recorded in the denial log and the `auto-permissions:denied` event with `"reviseFirst": true`, keeping the `decisionSource` of the verdict behind it, and the recent denials view labels it "revise first".
20
+
3
21
  ## 0.18.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -243,7 +243,7 @@ The guardian must return one of three decisions:
243
243
 
244
244
  - `approve`: execute the command
245
245
  - `revise`: block it and tell the main agent what to correct
246
- - `ask_user`: open an approval prompt (rendered with [`@hank-warren/pi-permission-selector`](../pi-permission-selector)'s `OptionSelector`): numbered options with `1`–`9` hotkeys, Tab to attach a note that is delivered to the agent as a steering user message, Esc to cancel — which blocks the command
246
+ - `ask_user`: open an approval prompt (rendered with [`@hank-warren/pi-permission-selector`](../pi-permission-selector)'s `OptionSelector`): the command, then the guardian's note behind a `▌` bar in a fixed strong yellow (gold on dark themes, dark goldenrod on light ones, regardless of the theme's `warning` color), then numbered options. A command taller than a quarter of the terminal is cut to a preview, and the prompt stays pinned while you scroll the session above. Ctrl+O opens the full command in a scrolling viewer inside the prompt (Shift+↑/↓ or the mouse wheel; PgUp/PgDn outside fullscreen) and closes it again — the only complete copy when a codemode script built the command, since the transcript then shows the script. Numbered options with `1`–`9` hotkeys, Tab to attach a note that is delivered to the agent as a steering user message, Esc to cancel — which blocks the command. In RPC mode, as in a pi-agents child whose prompts the parent shows, the prompt is Pi's plain select dialog instead: Allow or Block, without Tab notes. It waits for the RPC client's answer, so a client that never answers holds the command until the turn is cancelled
247
247
 
248
248
  ## Conversation context and caching
249
249
 
@@ -403,7 +403,7 @@ The default UI shows guardian progress as a single animated status line in a tem
403
403
  auto permissions · Git commit · ✶ waiting for openai-codex-auto-permissions/gpt-5.6-luna
404
404
  ```
405
405
 
406
- A sparkle spinner (`✶ ✸ ✻ ✽`) cycles while the guardian is reviewing and resolves to `✓ approved`, `↻ revision requested`, `? waiting for your approval`, or `✗ blocked`. When approval is needed, a static warning-colored `●` precedes the prompt heading so the active prompt remains visually distinct from the transcript. The guardian's reason, when present, appears on a dim second line; the command itself is not repeated because it is already visible in the Bash tool box. Configure the widget with:
406
+ A sparkle spinner (`✶ ✸ ✻ ✽`) cycles while the guardian is reviewing and resolves to `✓ approved`, `↻ revision requested`, or `✗ blocked`. When approval is needed, the widget row steps aside for the approval dialog, whose static yellow `●` heading keeps it visually distinct from the transcript; with several commands under review, the summary line still counts the ones `waiting for your approval`. The reason, when present, appears on a second line behind a `▌` bar in the outcome's color; the command itself is not repeated because it is already visible in the Bash tool box. Configure the widget with:
407
407
 
408
408
  ```json
409
409
  {
@@ -443,9 +443,18 @@ Group names come from your configured rules. A trusted group bypasses guarded re
443
443
 
444
444
  ## Subagent sessions
445
445
 
446
- When a session is a [pi-subagents](https://github.com/nicobailon/pi-subagents) child (`PI_SUBAGENT_CHILD=1`), the guardian receives additional execution facts with each review — run id, nesting depth, whether the cwd is a linked git worktree, and the checked-out branch — plus a prompt section telling it to judge risk by effect scope and reversibility relative to the subagent's own workspace instead of by command name. Mutations confined to the subagent's isolated worktree, its own feature branch, or resources it created are approvable when they serve the delegated task; `ask_user` is reserved for effects that escape that scope (shared or default branches, host-level configuration, production systems, credentials, data leaving the machine).
446
+ When a session is a subagent child (`PI_SUBAGENT_CHILD=1`, set by `@hank-warren/pi-agents` and [pi-subagents](https://github.com/nicobailon/pi-subagents)), the guardian receives additional execution facts with each review — run id, nesting depth, whether the cwd is a linked git worktree, and the checked-out branch — plus a prompt section telling it to judge risk by effect scope and reversibility relative to the subagent's own workspace instead of by command name. Mutations confined to the subagent's isolated worktree, its own feature branch, or resources it created are approvable when they serve the delegated task; `ask_user` is reserved for effects that escape that scope (shared or default branches, host-level configuration, production systems, credentials, data leaving the machine).
447
447
 
448
- Subagent sessions have no interactive user, so an `ask_user` verdict blocks the command immediately with a reason instructing the child to route around the gated operation or report the blocker. Reviewer usage records from subagent sessions carry `"subagent": true` in the usage sidecar.
448
+ The child itself is told it is a subagent: a short section appended to its system prompt says that commands needing approval pause the supervising session, so it should prefer in-scope and read-only commands and revise when asked to. On Pi 1.x the section is a named prompt section (`auto_permissions_subagent`), so it never replaces the prompt that Pi and other extensions build, such as the parent's instruction files pi-agents adds to a child.
449
+
450
+ An `ask_user` verdict in a subagent, or a failed review (timeout, unavailable reviewer, unparsable verdict), never interrupts the human on the first try:
451
+
452
+ - **Child with a UI** (an RPC child whose prompts surface in the parent, as with pi-agents): the command is blocked with a revise-first reason. Issuing the *same* command again unchanged in a later turn escalates it to the human as an ordinary approval prompt, once: after that, a later identical attempt is revised first again. Duplicates within the same turn (sibling calls, or a codemode `Promise.all`) are refused as well, because the model has not seen the first refusal yet. Any other command gets its own revise-first turn.
453
+ - **Child without a UI**: the command is blocked with a reason instructing the child to route around the gated operation or report the blocker.
454
+
455
+ The guardian's subagent section says which of these applies, so a child without a UI is never told a human may approve later. A revise-first refusal is recorded in the denial log and the `auto-permissions:denied` event with `"reviseFirst": true`, keeping the `decisionSource` of the verdict behind it, so it stays distinguishable from a guardian's own revise.
456
+
457
+ Reviewer usage records from subagent sessions carry `"subagent": true` in the usage sidecar.
449
458
 
450
459
  ## Guardian dispatch
451
460
 
@@ -0,0 +1,171 @@
1
+ import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ import type { OptionSelector, SelectorTheme } from "@hank-warren/pi-permission-selector/selector.ts";
3
+
4
+ /** What an approval prompt shows, top to bottom above the options. */
5
+ export interface ApprovalPromptContent {
6
+ header: string;
7
+ command: string;
8
+ /** Who wrote `note`: "Guardian", or "Review failed" for an infrastructure error. */
9
+ noteLabel: string;
10
+ note: string;
11
+ /**
12
+ * True when the transcript shows this exact command (a model-issued call).
13
+ * A call a codemode script built shows only the script there.
14
+ */
15
+ inTranscript: boolean;
16
+ }
17
+
18
+ export interface ApprovalPromptTheme extends SelectorTheme {
19
+ bold(text: string): string;
20
+ /** Pi 1.x themes report the background they are designed for. */
21
+ readonly appearance?: string;
22
+ }
23
+
24
+ /** The slice of pi-tui's mouse event this reads; older pi-tui releases do not export the type. */
25
+ export interface WheelEvent {
26
+ type: string;
27
+ wheelDelta?: number;
28
+ }
29
+
30
+ const MIN_PREVIEW_ROWS = 3;
31
+ /** Rows left for the rest of Pi's input dock when the full command is open. */
32
+ const RESERVED_ROWS = 6;
33
+ const MIN_VIEWER_ROWS = 3;
34
+ /** The note's height while the viewer is open, so a long note cannot crowd the command out. */
35
+ const VIEWER_NOTE_ROWS = 2;
36
+
37
+ // Fixed rather than the theme's `warning`, which some themes make green or dim:
38
+ // the approval note must read as a caution in every theme.
39
+ const NOTE_YELLOW_DARK = "\x1b[38;2;255;215;0m";
40
+ const NOTE_YELLOW_LIGHT = "\x1b[38;2;184;134;11m";
41
+ const FG_RESET = "\x1b[39m";
42
+
43
+ export function noteYellow(theme: Pick<ApprovalPromptTheme, "appearance">): (text: string) => string {
44
+ const color = theme.appearance === "light" ? NOTE_YELLOW_LIGHT : NOTE_YELLOW_DARK;
45
+ return (text) => `${color}${text}${FG_RESET}`;
46
+ }
47
+
48
+ /** Plain-text form for hosts that render the title themselves (RPC `ui.select`). */
49
+ export function approvalPromptText(content: Omit<ApprovalPromptContent, "inTranscript">): string {
50
+ return `${content.header}\n\n${content.command}\n\n${content.noteLabel}: ${content.note}`;
51
+ }
52
+
53
+ function wrapBlock(text: string, width: number): string[] {
54
+ const lines: string[] = [];
55
+ for (const line of text.split("\n")) {
56
+ if (line.length === 0) lines.push("");
57
+ else lines.push(...wrapTextWithAnsi(line, width));
58
+ }
59
+ return lines;
60
+ }
61
+
62
+ /**
63
+ * The approval dialog: header, the command, the reviewer's note, then the
64
+ * options. It stays compact so the session transcript above keeps its room: a
65
+ * long command is cut to a preview. Ctrl+O opens the full command in a
66
+ * scrolling viewer inside the dialog, because the transcript does not always
67
+ * hold it (a codemode script's calls show only the script).
68
+ */
69
+ export class ApprovalPrompt {
70
+ private expanded = false;
71
+ private scrollTop = 0;
72
+ private maxScroll = 0;
73
+ private viewportRows = 0;
74
+
75
+ constructor(
76
+ private readonly opts: {
77
+ content: ApprovalPromptContent;
78
+ selector: OptionSelector;
79
+ theme: ApprovalPromptTheme;
80
+ terminalRows: () => number;
81
+ requestRender: () => void;
82
+ },
83
+ ) {}
84
+
85
+ invalidate(): void {
86
+ this.opts.selector.invalidate();
87
+ }
88
+
89
+ handleInput(data: string): void {
90
+ if (matchesKey(data, Key.ctrl("o"))) {
91
+ this.expanded = !this.expanded;
92
+ this.scrollTop = 0;
93
+ this.opts.requestRender();
94
+ return;
95
+ }
96
+ if (this.expanded) {
97
+ const page = Math.max(1, this.viewportRows - 1);
98
+ if (matchesKey(data, Key.pageUp)) return this.scrollBy(-page);
99
+ if (matchesKey(data, Key.pageDown)) return this.scrollBy(page);
100
+ if (matchesKey(data, Key.shift("up"))) return this.scrollBy(-1);
101
+ if (matchesKey(data, Key.shift("down"))) return this.scrollBy(1);
102
+ }
103
+ this.opts.selector.handleInput(data);
104
+ }
105
+
106
+ /** Scrolls the open viewer; otherwise the wheel falls through to the session. */
107
+ handleMouse(event: WheelEvent): { handled: true } | undefined {
108
+ if (!this.expanded || event.type !== "wheel" || !event.wheelDelta || this.maxScroll === 0) return undefined;
109
+ this.scrollBy(event.wheelDelta);
110
+ return { handled: true };
111
+ }
112
+
113
+ private scrollBy(lines: number): void {
114
+ const next = Math.max(0, Math.min(this.maxScroll, this.scrollTop + lines));
115
+ if (next === this.scrollTop) return;
116
+ this.scrollTop = next;
117
+ this.opts.requestRender();
118
+ }
119
+
120
+ private noteLines(width: number): string[] {
121
+ const { theme, content } = this.opts;
122
+ const yellow = noteYellow(theme);
123
+ const text = `${theme.bold(`${content.noteLabel}:`)} ${content.note}`;
124
+ const inner = Math.max(1, width - 2);
125
+ let lines = wrapBlock(text, inner);
126
+ if (this.expanded && lines.length > VIEWER_NOTE_ROWS) {
127
+ lines = lines.slice(0, VIEWER_NOTE_ROWS);
128
+ // Reflow the last kept row so the ellipsis always fits.
129
+ lines[VIEWER_NOTE_ROWS - 1] = truncateToWidth(`${lines[VIEWER_NOTE_ROWS - 1]} …`, inner, "…");
130
+ }
131
+ return lines.map((line) => `${yellow("▌ ")}${yellow(line)}`);
132
+ }
133
+
134
+ private preview(command: string[], width: number): string[] {
135
+ const { theme, content } = this.opts;
136
+ this.maxScroll = 0;
137
+ const cap = Math.max(MIN_PREVIEW_ROWS, Math.floor(this.opts.terminalRows() / 4));
138
+ if (command.length <= cap) return command;
139
+ const shown = cap - 1;
140
+ const where = content.inTranscript ? "ctrl+o view all, or scroll the session above" : "ctrl+o view all";
141
+ const more = `… ${command.length - shown} more lines · ${where}`;
142
+ return [...command.slice(0, shown), theme.fg("dim", truncateToWidth(more, width))];
143
+ }
144
+
145
+ private viewer(command: string[], width: number, fixedRows: number): string[] {
146
+ const budget = Math.max(MIN_PREVIEW_ROWS + fixedRows, this.opts.terminalRows() - RESERVED_ROWS);
147
+ // One row goes to the status line.
148
+ this.viewportRows = Math.max(MIN_VIEWER_ROWS, budget - fixedRows - 1);
149
+ this.maxScroll = Math.max(0, command.length - this.viewportRows);
150
+ this.scrollTop = Math.min(this.scrollTop, this.maxScroll);
151
+ const last = Math.min(command.length, this.scrollTop + this.viewportRows);
152
+ const status = `── lines ${this.scrollTop + 1}-${last} of ${command.length} · shift+↑↓/wheel scroll · ctrl+o collapse ──`;
153
+ return [...command.slice(this.scrollTop, last), this.opts.theme.fg("dim", truncateToWidth(status, width))];
154
+ }
155
+
156
+ render(width: number): string[] {
157
+ const w = Math.max(1, width);
158
+ const { theme, content } = this.opts;
159
+ const header = wrapBlock(`${noteYellow(theme)(theme.bold("●"))} ${content.header}`, w);
160
+ const note = this.noteLines(w);
161
+ const options = this.opts.selector.render(w);
162
+ const command = wrapBlock(content.command, w);
163
+ const fixedRows = header.length + 1 + 1 + note.length + 1 + options.length;
164
+ const commandRows = this.expanded ? this.viewer(command, w, fixedRows) : this.preview(command, w);
165
+ const lines = [...header, "", ...commandRows, "", ...note, "", ...options];
166
+ // A dock too short for the dialog clips from the top, keeping the options
167
+ // visible: Pi's layout scrolls a clipped component to its cursor line.
168
+ lines[lines.length - 1] = `${CURSOR_MARKER}${lines[lines.length - 1]}`;
169
+ return lines;
170
+ }
171
+ }
package/denial-log.ts CHANGED
@@ -28,6 +28,12 @@ export interface DenialRecord {
28
28
  verdict: DenialVerdict;
29
29
  reason: string;
30
30
  decisionSource: DenialSource;
31
+ /**
32
+ * A subagent's revise-first refusal: `decisionSource` would have gone to the
33
+ * human (an ask_user verdict or a failed review), and the subagent was told
34
+ * to revise first. Absent on every other denial.
35
+ */
36
+ reviseFirst?: true;
31
37
  }
32
38
 
33
39
  export function buildDenialRecord(input: {
@@ -39,6 +45,7 @@ export function buildDenialRecord(input: {
39
45
  verdict: DenialVerdict;
40
46
  reason: string;
41
47
  decisionSource: DenialSource;
48
+ reviseFirst?: boolean;
42
49
  }): DenialRecord {
43
50
  return {
44
51
  v: 1,
@@ -52,6 +59,7 @@ export function buildDenialRecord(input: {
52
59
  verdict: input.verdict,
53
60
  reason: input.reason,
54
61
  decisionSource: input.decisionSource,
62
+ ...(input.reviseFirst ? { reviseFirst: true as const } : {}),
55
63
  };
56
64
  }
57
65
 
@@ -24,7 +24,7 @@ import {
24
24
  OVERRIDE_FEEDBACK_SYSTEM_PROMPT,
25
25
  parsePermissionVerdict,
26
26
  SCRIPT_EVIDENCE_SYSTEM_PROMPT,
27
- SUBAGENT_CONTEXT_SYSTEM_PROMPT,
27
+ subagentContextSystemPrompt,
28
28
  type PermissionVerdict,
29
29
  type ReviewEvidenceRecord,
30
30
  } from "./review.js";
@@ -317,7 +317,7 @@ export function createGuardianReviewer(
317
317
  const subagentContext = detectSubagentContext(ctx.cwd);
318
318
  const basePolicyPrompt = [
319
319
  config.systemPrompt,
320
- ...(subagentContext ? [SUBAGENT_CONTEXT_SYSTEM_PROMPT] : []),
320
+ ...(subagentContext ? [subagentContextSystemPrompt(ctx.hasUI)] : []),
321
321
  ].join("\n\n");
322
322
  // Appended outside config.systemPrompt so sessions using a customized
323
323
  // systemPromptFile still learn how to weigh override records. The injected
@@ -22,12 +22,11 @@ import { completeSimple } from "@earendil-works/pi-ai/compat";
22
22
  * future pi release renames the seam — degrading to the historical behavior
23
23
  * instead of crashing.
24
24
  *
25
- * This file was duplicated verbatim into packages/pi-herdr-auto-title until
26
- * that package was removed; `DUPLICATED_SOURCES` in `scripts/validate.py`
27
- * policed the copies. It has one home again. If a second extension package
28
- * ever needs it, copy it rather than importing across packages by relative
29
- * path, and register the pair in `DUPLICATED_SOURCES` — which is why every
30
- * package-specific detail stays behind a parameter (`packageLabel`).
25
+ * This file is duplicated verbatim in packages/pi-auto-permissions and
26
+ * packages/pi-auto-name; `DUPLICATED_SOURCES` in `scripts/validate.py` keeps
27
+ * the copies byte-identical. Edit one and copy it over the other. Every
28
+ * package-specific detail stays behind a parameter (`packageLabel`) so the
29
+ * copies never need to differ.
31
30
  * The "guardian" in the names is historical: pi-auto-permissions' guardian
32
31
  * reviewer was the first caller.
33
32
  */
package/index.ts CHANGED
@@ -26,6 +26,7 @@ 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
  }
@@ -169,6 +188,14 @@ function assistantToolCalls(message: unknown): AssistantToolCall[] {
169
188
  export default function autoPermissionsExtension(pi: ExtensionAPI) {
170
189
  const overrides = createSessionOverrides(pi);
171
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>();
172
199
  let lastConfigError: string | undefined;
173
200
  let lastEvaluationLogError: string | undefined;
174
201
  // Guardian calls run in parallel up to config.reviewConcurrency; applying
@@ -242,6 +269,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
242
269
  verdict: DenialVerdict,
243
270
  reason: string,
244
271
  decisionSource: DenialSource,
272
+ reviseFirst = false,
245
273
  ): void {
246
274
  const { ctx, config, gate, command, target } = scope;
247
275
  try {
@@ -253,6 +281,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
253
281
  verdict,
254
282
  reason,
255
283
  decisionSource,
284
+ ...(reviseFirst ? { reviseFirst: true } : {}),
256
285
  });
257
286
  } catch {
258
287
  // Event fan-out is observability, never part of the decision.
@@ -268,6 +297,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
268
297
  verdict,
269
298
  reason,
270
299
  decisionSource,
300
+ reviseFirst,
271
301
  }));
272
302
  } catch {
273
303
  // The denial log is best-effort observability.
@@ -277,7 +307,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
277
307
  /** The one place a decision is recorded, rendered and returned. */
278
308
  function settle(scope: ReviewScope, outcome: ReviewOutcome): BlockResult | undefined {
279
309
  if (outcome.verdict && outcome.source) {
280
- recordDenial(scope, outcome.verdict, outcome.reason, outcome.source);
310
+ recordDenial(scope, outcome.verdict, outcome.reason, outcome.source, outcome.reviseFirst === true);
281
311
  }
282
312
  if (outcome.display) {
283
313
  display.show(scope, outcome.display, outcome.detail ?? outcome.reason, true);
@@ -415,7 +445,13 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
415
445
  choice = await promptSelect(
416
446
  pi,
417
447
  ctx,
418
- `${gate.label} — Auto Permissions needs approval\n\n${detail}\n\n${command}`,
448
+ {
449
+ header: `${gate.label} — Auto Permissions needs approval`,
450
+ command,
451
+ noteLabel: decisionSource === "guardian" ? "Guardian" : "Review failed",
452
+ note: detail,
453
+ inTranscript: scope.target.scriptToolCallId === undefined,
454
+ },
419
455
  permissionPromptOptions(config.evaluationLog.enabled),
420
456
  promptSignal,
421
457
  );
@@ -520,6 +556,41 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
520
556
  }
521
557
  }
522
558
 
559
+ /**
560
+ * In a subagent with a UI, the first time a command would go to the human it
561
+ * is refused with a revise-first reason instead. Same-turn duplicates are
562
+ * refused too; an unchanged retry in a later turn returns undefined, and the
563
+ * caller asks the human.
564
+ */
565
+ function reviseFirstBlock(
566
+ scope: ReviewScope,
567
+ reason: string,
568
+ source: "guardian" | "review_failure",
569
+ lifecycleSignal: AbortSignal,
570
+ ): BlockResult | undefined {
571
+ if (!subagentSession || !scope.ctx.hasUI) return undefined;
572
+ // As askUser does: a refusal the model will never see must not count as
573
+ // its revise-first turn, or its retry would go straight to the human.
574
+ const cancelled = cancelledAfterAwait(scope, lifecycleSignal);
575
+ if (cancelled) return cancelled;
576
+ const key = `${scope.gate.label}\0${scope.command}`;
577
+ const refusedIn = reviseFirst.get(key);
578
+ if (refusedIn !== undefined && refusedIn !== turnGeneration) {
579
+ // Escalated now: a later identical attempt is revised first again.
580
+ reviseFirst.delete(key);
581
+ return undefined;
582
+ }
583
+ if (refusedIn === undefined) reviseFirst.set(key, turnGeneration);
584
+ return settle(scope, {
585
+ display: "revise",
586
+ verdict: "revise",
587
+ source,
588
+ reason,
589
+ block: subagentReviseFirstReason(scope.gate.label, reason),
590
+ reviseFirst: true,
591
+ });
592
+ }
593
+
523
594
  /** Apply one settled review. Runs holding the decision slot. */
524
595
  function applyReview(
525
596
  scope: ReviewScope,
@@ -527,7 +598,8 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
527
598
  lifecycleSignal: AbortSignal,
528
599
  ): Promise<BlockResult | undefined> | BlockResult | undefined {
529
600
  if (outcome.kind === "failed") {
530
- return askUser(scope, `Automatic review failed: ${outcome.reason}`, lifecycleSignal, "review_failure");
601
+ const reason = `Automatic review failed: ${outcome.reason}`;
602
+ return reviseFirstBlock(scope, reason, "review_failure", lifecycleSignal) ?? askUser(scope, reason, lifecycleSignal, "review_failure");
531
603
  }
532
604
  const { verdict } = outcome;
533
605
  if (verdict.decision === "approve") {
@@ -542,7 +614,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
542
614
  block: `Auto Permissions requested revision: ${verdict.reason}\nRevise the command and try again.`,
543
615
  });
544
616
  }
545
- return askUser(scope, verdict.reason, lifecycleSignal, "guardian");
617
+ return reviseFirstBlock(scope, verdict.reason, "guardian", lifecycleSignal) ?? askUser(scope, verdict.reason, lifecycleSignal, "guardian");
546
618
  }
547
619
 
548
620
  /**
@@ -677,6 +749,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
677
749
  });
678
750
 
679
751
  pi.on("turn_end", () => {
752
+ turnGeneration += 1;
680
753
  lastAssistantCalls = [];
681
754
  abandonSiblingReviews();
682
755
  endScripts();
@@ -755,6 +828,25 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
755
828
  return decide(scope, input, lifecycleSignal, early ?? runReview(scope, input, lifecycleSignal));
756
829
  });
757
830
 
831
+ pi.on("before_agent_start", (event) => {
832
+ if (!subagentSession) return;
833
+ try {
834
+ if (!loadAutoPermissionsConfig().enabled) return;
835
+ } catch {
836
+ // An invalid config fails closed on the first bash call; the guidance still applies.
837
+ }
838
+ // On Pi 1.x a returned systemPrompt forces the whole prompt for the run,
839
+ // dropping what later extensions change in its options (pi-agents adds
840
+ // the parent's instruction files there), so the guidance goes into a
841
+ // named section instead. Older Pi takes the prompt as a string.
842
+ const sections = (event as { systemPromptOptions?: { sections?: Record<string, string> } }).systemPromptOptions?.sections;
843
+ if (sections) {
844
+ sections[SUBAGENT_PROMPT_SECTION] = SUBAGENT_PERMISSIONS_PROMPT;
845
+ return;
846
+ }
847
+ return { systemPrompt: `${event.systemPrompt}\n\n${SUBAGENT_PERMISSIONS_PROMPT}` };
848
+ });
849
+
758
850
  registerSettingsCommand(pi, { overrides, reviewer });
759
851
 
760
852
  function resetCallTracking(): void {
@@ -782,6 +874,8 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
782
874
  if (config) warnAboutMissingReviewerProvider(ctx, config);
783
875
 
784
876
  resetCallTracking();
877
+ subagentSession = detectSubagentContext(ctx.cwd) !== undefined;
878
+ reviseFirst.clear();
785
879
  reviewer.startSession(ctx.cwd);
786
880
  overrides.restore(ctx.sessionManager.getBranch());
787
881
  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.18.0",
3
+ "version": "0.20.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": [
@@ -61,6 +61,7 @@
61
61
  "widget-status.ts",
62
62
  "classify.ts",
63
63
  "prompt-select.ts",
64
+ "approval-prompt.ts",
64
65
  "review-display.ts",
65
66
  "review-scope.ts",
66
67
  "session-overrides.ts",
package/prompt-select.ts CHANGED
@@ -1,5 +1,6 @@
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 { ApprovalPrompt, type ApprovalPromptContent, approvalPromptText } from "./approval-prompt.js";
3
4
 
4
5
  /**
5
6
  * Tell Herdr this pane is waiting on a human, so a supervising agent can see
@@ -11,9 +12,9 @@ export function setHerdrBlocked(pi: ExtensionAPI, active: boolean, label?: strin
11
12
  }
12
13
 
13
14
  /**
14
- * Render an approval prompt with the shared `OptionSelector` through
15
- * `ctx.ui.custom()` (non-overlay, so it swaps into the editor area exactly
16
- * where `ctx.ui.select` used to render).
15
+ * Render an approval prompt — an `ApprovalPrompt` wrapping the shared
16
+ * `OptionSelector` — through `ctx.ui.custom()` (non-overlay, so it swaps into
17
+ * the editor area exactly where `ctx.ui.select` used to render).
17
18
  *
18
19
  * Cancellation parity with `ctx.ui.select` — all of these resolve
19
20
  * `undefined`, which every caller must treat as deny/cancel, never allow:
@@ -22,6 +23,10 @@ export function setHerdrBlocked(pi: ExtensionAPI, active: boolean, label?: strin
22
23
  * idempotent and performs its own teardown, so nothing else is needed)
23
24
  * - a host that reports `hasUI` but cannot render custom components
24
25
  *
26
+ * In RPC mode (`hasUI` is true but `custom()` returns undefined) the prompt
27
+ * goes through `ctx.ui.select`, which the RPC client renders: that is how a
28
+ * subagent child's approval reaches the human in the parent session.
29
+ *
25
30
  * A Tab-typed note is delivered to the agent as a steering user message,
26
31
  * matching what the retired `ExtensionSelectorComponent` monkey patch in
27
32
  * pi-permission-selector did.
@@ -29,10 +34,11 @@ export function setHerdrBlocked(pi: ExtensionAPI, active: boolean, label?: strin
29
34
  export function promptSelect(
30
35
  pi: ExtensionAPI,
31
36
  ctx: ExtensionContext,
32
- title: string,
37
+ content: ApprovalPromptContent,
33
38
  values: string[],
34
39
  signal: AbortSignal,
35
40
  ): Promise<string | undefined> {
41
+ if (ctx.mode === "rpc") return ctx.ui.select(approvalPromptText(content), values, { signal });
36
42
  return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
37
43
  let finished = false;
38
44
  const onAbort = () => finish(undefined);
@@ -43,7 +49,6 @@ export function promptSelect(
43
49
  done(result);
44
50
  };
45
51
  const selector = new OptionSelector({
46
- title: `${theme.fg("warning", theme.bold("●"))} ${title}`,
47
52
  options: values.map((value) => ({ value, label: value })),
48
53
  allowComment: true,
49
54
  theme,
@@ -64,6 +69,12 @@ export function promptSelect(
64
69
  // `done` before the factory resolves is safe: pi marks the dialog closed
65
70
  // and never mounts the component.
66
71
  if (signal.aborted) onAbort();
67
- return selector;
72
+ return new ApprovalPrompt({
73
+ content,
74
+ selector,
75
+ theme,
76
+ terminalRows: () => tui.terminal.rows,
77
+ requestRender: () => tui.requestRender(),
78
+ });
68
79
  });
69
80
  }
package/review-display.ts CHANGED
@@ -76,7 +76,8 @@ export function createReviewDisplay(deps: { isSessionActive: () => boolean }): R
76
76
  if (ctx.mode !== "tui") return;
77
77
  const rows = [...active.values()];
78
78
  const shown = rows.length === 1 ? rows[0] : rows.length === 0 ? settled : undefined;
79
- if (rows.length === 0 && !settled) {
79
+ // A lone review waiting on the human is shown by the approval dialog itself.
80
+ if ((rows.length === 0 && !settled) || shown?.state === "ask_user") {
80
81
  ctx.ui.setWidget(WIDGET_KEY, undefined);
81
82
  return;
82
83
  }
package/review.ts CHANGED
@@ -174,13 +174,24 @@ Examples:
174
174
  Return strict JSON only with this shape:
175
175
  {"decision":"approve"|"revise"|"ask_user","reason":"one concise sentence"}`;
176
176
 
177
+ /** What an "ask_user" verdict does in a subagent, which depends on whether its prompts can reach a human. */
178
+ const SUBAGENT_ASK_USER = {
179
+ withUI: `An "ask_user" decision does not reach a human directly: the subagent is first told to revise the command, and the step stays blocked unless a human later approves the same command.`,
180
+ withoutUI: `An "ask_user" decision cannot reach a human: this subagent has no interactive user, so the command is blocked outright and the subagent must route around it or report the blocker.`,
181
+ };
182
+
177
183
  /**
178
184
  * Appended to the reviewer system prompt only when the session is a subagent
179
185
  * child (detectSubagentContext). Facts arrive in the request's "execution"
180
- * object; this section tells the reviewer how to weigh them.
186
+ * object; this section tells the reviewer how to weigh them. Whether the child
187
+ * has a UI is fixed for its session, so the prompt stays cacheable.
181
188
  */
182
- export const SUBAGENT_CONTEXT_SYSTEM_PROMPT = `SUBAGENT EXECUTION CONTEXT
183
- This session is an autonomous subagent executing a task delegated by a supervising agent session. There is no interactive user: an "ask_user" decision does not open a prompt, it aborts this step with a block. The latest proposed action carries an "execution" object with runtime facts (isolated worktree, checked-out branch, nesting depth).
189
+ export function subagentContextSystemPrompt(hasUI: boolean): string {
190
+ return SUBAGENT_CONTEXT_TEMPLATE.replace("{ASK_USER}", hasUI ? SUBAGENT_ASK_USER.withUI : SUBAGENT_ASK_USER.withoutUI);
191
+ }
192
+
193
+ const SUBAGENT_CONTEXT_TEMPLATE = `SUBAGENT EXECUTION CONTEXT
194
+ This session is an autonomous subagent executing a task delegated by a supervising agent session. {ASK_USER} The latest proposed action carries an "execution" object with runtime facts (isolated worktree, checked-out branch, nesting depth).
184
195
  The delegated task arrives as this session's USER evidence, but it was authored by the supervising agent, not typed by a human. Delegation authorizes medium-risk work scoped to the subagent's own workspace. It does not blanket-authorize higher risk: approve a high- or critical-risk operation only when the delegated task names the exact operation and target — the same explicit-intent standard a human user's message is held to — and treat irreversible destruction outside the subagent's scope as never authorized by delegation alone, whatever the task's general wording.
185
196
  Judge risk by effect scope and reversibility relative to the subagent's own workspace, not by command name:
186
197
  - Mutations confined to the subagent's isolated worktree, or to resources the evidence shows the subagent created (scratch files, branches it created or first pushed, containers, images, test databases), are at most medium risk; approve them when they serve the delegated task.
@@ -136,7 +136,7 @@ export function registerSettingsCommand(
136
136
  ts: record.ts,
137
137
  gateLabel: record.gate.label,
138
138
  command: record.command,
139
- verdict: record.verdict,
139
+ verdict: record.reviseFirst ? "revise first" : record.verdict,
140
140
  reason: record.reason,
141
141
  }));
142
142
  } catch {
@@ -4,16 +4,16 @@ import { isAbsolute, join } from "node:path";
4
4
  /**
5
5
  * Runtime facts about a subagent child session, forwarded to the guardian as
6
6
  * evidence. Facts only — the guardian judges what they imply; see
7
- * SUBAGENT_CONTEXT_SYSTEM_PROMPT in review.ts.
7
+ * subagentContextSystemPrompt() in review.ts.
8
8
  *
9
- * Detection is based on the environment contract of `pi-subagents`
10
- * (nicobailon/pi-subagents), which spawns children with `PI_SUBAGENT_CHILD=1`
11
- * plus run id and nesting depth. Absent that env, sessions are treated as
12
- * ordinary sessions and review behavior is unchanged.
9
+ * Detection is based on the environment contract that `@hank-warren/pi-agents`
10
+ * and `pi-subagents` (nicobailon/pi-subagents) both use: children are spawned
11
+ * with `PI_SUBAGENT_CHILD=1` plus run id and nesting depth. Absent that env,
12
+ * sessions are treated as ordinary sessions and review behavior is unchanged.
13
13
  */
14
14
  export interface SubagentExecutionContext {
15
15
  subagent: true;
16
- /** pi-subagents run id (PI_SUBAGENT_RUN_ID), when present. */
16
+ /** The subagent's run id (PI_SUBAGENT_RUN_ID), when present. */
17
17
  runId?: string;
18
18
  /** Nesting depth for nested subagents (PI_SUBAGENT_DEPTH), when > 0. */
19
19
  depth?: number;
package/widget-status.ts CHANGED
@@ -84,8 +84,9 @@ export interface ReviewLinePalette {
84
84
  }
85
85
 
86
86
  /**
87
- * Render the widget content: one status line, plus a dim detail line only
88
- * when a guardian reason is present. The command is intentionally omitted —
87
+ * Render the widget content: one status line, plus a detail line behind a
88
+ * `▌` bar in the outcome's tone (the approval dialog's note style) only when
89
+ * a reason is present. The command is intentionally omitted —
89
90
  * it is already visible in the bash tool box above.
90
91
  */
91
92
  export function reviewStatusLines(
@@ -101,7 +102,7 @@ export function reviewStatusLines(
101
102
  const lines = [
102
103
  `${palette.header("auto permissions")} ${palette.muted(`· ${gateLabel} ·`)} ${status}`,
103
104
  ];
104
- if (detail) lines.push(palette.muted(detail));
105
+ if (detail) lines.push(`${palette[frame.tone]("▌")} ${palette.muted(detail)}`);
105
106
  return lines;
106
107
  }
107
108