@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.
@@ -40,8 +40,8 @@ export function registerSettingsCommand(
40
40
  { overrides, reviewer }: { overrides: SessionOverrides; reviewer: GuardianReviewer },
41
41
  ): void {
42
42
  /**
43
- * `/auto-permissions`: edit the reviewer model, thinking level, timeout and
44
- * the enabled flag without hand-editing config.json.
43
+ * `/auto-permissions`: edit the reviewer model, thinking level, timeout,
44
+ * parallel reviews and the enabled flag without hand-editing config.json.
45
45
  *
46
46
  * There is no live-apply step: every tool call re-reads the config through
47
47
  * currentConfig(), so a saved edit is in force immediately -- in this session
@@ -76,6 +76,7 @@ export function registerSettingsCommand(
76
76
  settings = {
77
77
  enabled: config.enabled,
78
78
  reviewer: config.reviewer,
79
+ reviewConcurrency: config.reviewConcurrency,
79
80
  systemPromptSource: config.systemPromptSource,
80
81
  };
81
82
  denialLog = config.denialLog;
@@ -97,7 +98,14 @@ export function registerSettingsCommand(
97
98
  const previous = settings;
98
99
  settings = change.settings;
99
100
  try {
100
- patchAutoPermissionsConfig(path, { enabled: settings.enabled, reviewer: settings.reviewer });
101
+ patchAutoPermissionsConfig(path, {
102
+ enabled: settings.enabled,
103
+ reviewer: settings.reviewer,
104
+ // Only when this edit changed it, so a hand-written value is never rewritten by an unrelated save.
105
+ ...(settings.reviewConcurrency !== previous.reviewConcurrency
106
+ ? { reviewConcurrency: settings.reviewConcurrency }
107
+ : {}),
108
+ });
101
109
  } catch (error) {
102
110
  settings = previous;
103
111
  const message = error instanceof Error ? error.message : String(error);
@@ -128,7 +136,7 @@ export function registerSettingsCommand(
128
136
  ts: record.ts,
129
137
  gateLabel: record.gate.label,
130
138
  command: record.command,
131
- verdict: record.verdict,
139
+ verdict: record.reviseFirst ? "revise first" : record.verdict,
132
140
  reason: record.reason,
133
141
  }));
134
142
  } catch {
package/settings-menu.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  import {
21
21
  DEFAULT_REVIEWER_REASONING_EFFORT,
22
22
  DEFAULT_REVIEWER_TIMEOUT_MS,
23
+ MAX_REVIEW_CONCURRENCY,
23
24
  MAX_REVIEWER_TIMEOUT_MS,
24
25
  MIN_REVIEWER_TIMEOUT_MS,
25
26
  REASONING_EFFORTS,
@@ -32,6 +33,7 @@ const ENABLED_ID = "enabled";
32
33
  const REVIEWER_MODEL_ID = "reviewerModel";
33
34
  const THINKING_LEVEL_ID = "reasoningEffort";
34
35
  const TIMEOUT_ID = "timeoutMs";
36
+ const REVIEW_CONCURRENCY_ID = "reviewConcurrency";
35
37
  const SYSTEM_PROMPT_ID = "systemPrompt";
36
38
  export const RECENT_DENIALS_ID = "recentDenials";
37
39
 
@@ -47,9 +49,20 @@ const NO_REVIEWER_MESSAGE = "Select a reviewer model first";
47
49
  export interface ReviewerSettings {
48
50
  enabled: boolean;
49
51
  reviewer?: ReviewerConfig;
52
+ reviewConcurrency: number;
50
53
  systemPromptSource: SystemPromptSource;
51
54
  }
52
55
 
56
+ const CONCURRENCY_PRESETS = [1, 2, 4, 8, 16];
57
+
58
+ /**
59
+ * The values the parallel-reviews row cycles through. A hand-written value
60
+ * outside the presets is kept in the cycle, so opening the menu never moves it.
61
+ */
62
+ export function concurrencyValues(current: number): string[] {
63
+ return [...new Set([...CONCURRENCY_PRESETS, current])].sort((a, b) => a - b).map(String);
64
+ }
65
+
53
66
  /** The subset of a pi model the menu needs; keeps tests free of pi-ai types. */
54
67
  export interface MenuModel {
55
68
  provider: string;
@@ -177,6 +190,13 @@ export function buildSettingItems(
177
190
  currentValue: formatTimeout(timeoutValue(settings)),
178
191
  ...(submenus.timeout ? { submenu: submenus.timeout } : {}),
179
192
  },
193
+ {
194
+ id: REVIEW_CONCURRENCY_ID,
195
+ label: "Parallel reviews",
196
+ description: "How many guardian reviews may run at once. 1 reviews one command at a time, none ahead of its turn.",
197
+ currentValue: String(settings.reviewConcurrency),
198
+ values: concurrencyValues(settings.reviewConcurrency),
199
+ },
180
200
  {
181
201
  id: SYSTEM_PROMPT_ID,
182
202
  label: "System prompt",
@@ -241,6 +261,15 @@ export function applySettingChange(
241
261
  return { kind: "settings", settings: next };
242
262
  }
243
263
 
264
+ if (id === REVIEW_CONCURRENCY_ID) {
265
+ const reviewConcurrency = Number(value);
266
+ if (!Number.isInteger(reviewConcurrency) || reviewConcurrency < 1 || reviewConcurrency > MAX_REVIEW_CONCURRENCY) {
267
+ return { kind: "error", message: `Parallel reviews must be a whole number from 1 to ${MAX_REVIEW_CONCURRENCY}` };
268
+ }
269
+ if (reviewConcurrency === settings.reviewConcurrency) return { kind: "ignored" };
270
+ return { kind: "settings", settings: { ...settings, reviewConcurrency } };
271
+ }
272
+
244
273
  // The model and timeout submenus commit their own edit and hand back display
245
274
  // text, so there is nothing left to map here.
246
275
  return { kind: "ignored" };
@@ -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
@@ -30,10 +30,10 @@ export function reviewStatusFrame(
30
30
  ): ReviewStatusFrame {
31
31
  const index = Math.max(0, Math.floor(frameIndex));
32
32
  switch (state) {
33
- // Static rather than animated: the row is replaced by `waiting` the moment
34
- // the queue hands over, so a spinner here would promise progress the
35
- // command is not making. It exists so a second guarded command in one turn
36
- // renders *something* instead of a blank gap while it waits its turn.
33
+ // Static rather than animated: the row is replaced the moment the command
34
+ // gets its slot, so a spinner here would promise progress the command is
35
+ // not making. It exists so a guarded command waiting for a review slot or
36
+ // for another command's decision renders *something* instead of a blank gap.
37
37
  case "queued":
38
38
  return {
39
39
  glyph: "⋯",
@@ -61,6 +61,19 @@ export function reviewFrameIntervalMs(state: ReviewDisplayState): number | undef
61
61
  return state === "waiting" ? WAITING_FRAME_INTERVAL_MS : undefined;
62
62
  }
63
63
 
64
+ /** The states a review is still in while it has not settled. */
65
+ export type ActiveReviewState = Extract<ReviewDisplayState, "queued" | "waiting" | "ask_user">;
66
+
67
+ export function isActiveReviewState(state: ReviewDisplayState): state is ActiveReviewState {
68
+ return state === "queued" || state === "waiting" || state === "ask_user";
69
+ }
70
+
71
+ export interface ReviewSummaryCounts {
72
+ waiting: number;
73
+ queued: number;
74
+ askUser: number;
75
+ }
76
+
64
77
  export interface ReviewLinePalette {
65
78
  header: (text: string) => string;
66
79
  muted: (text: string) => string;
@@ -91,3 +104,27 @@ export function reviewStatusLines(
91
104
  if (detail) lines.push(palette.muted(detail));
92
105
  return lines;
93
106
  }
107
+
108
+ /**
109
+ * One line for several unsettled reviews at once. Per-command detail is left
110
+ * out: each command is visible in its own tool box, and an approval prompt
111
+ * carries its own reason.
112
+ */
113
+ export function reviewSummaryLines(
114
+ counts: ReviewSummaryCounts,
115
+ reviewer: string,
116
+ frameIndex: number,
117
+ palette: ReviewLinePalette,
118
+ ): string[] {
119
+ const total = counts.waiting + counts.queued + counts.askUser;
120
+ const parts: string[] = [];
121
+ if (counts.waiting > 0) {
122
+ const frame = reviewStatusFrame("waiting", reviewer, frameIndex);
123
+ parts.push(palette.warning(`${frame.glyph} ${counts.waiting} ${frame.label}`));
124
+ }
125
+ if (counts.queued > 0) parts.push(palette.muted(`⋯ ${counts.queued} queued`));
126
+ if (counts.askUser > 0) parts.push(palette.accent(`? ${counts.askUser} waiting for your approval`));
127
+ return [
128
+ `${palette.header("auto permissions")} ${palette.muted(`· ${total} commands ·`)} ${parts.join(palette.muted(" · "))}`,
129
+ ];
130
+ }