@hank-warren/pi-auto-permissions 0.9.0 → 0.11.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,38 @@
1
1
  # @hank-warren/pi-auto-permissions
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f93481e: add `/auto-permissions`, a settings menu for the reviewer configuration
8
+
9
+ The reviewer model, thinking level and timeout — plus the `enabled` flag — were
10
+ hand-edited JSON. They are the settings that change most often, and switching a
11
+ reviewer meant opening `~/.pi/agent/pi-auto-permissions/config.json` and getting
12
+ a provider id exactly right by memory.
13
+
14
+ `/auto-permissions` puts those four on a settings list, with the model picked
15
+ from the models you are actually signed in to (a configured but unavailable
16
+ reviewer is pinned first so opening the menu can never silently drop it). A
17
+ fifth read-only row reports where the active system prompt comes from — the
18
+ resolved `systemPromptFile` path, or the built-in or inline prompt.
19
+
20
+ The menu is a narrow, merging writer: it patches only `enabled` and `reviewer`
21
+ into whatever is on disk at save time, preserving rules, prompts, evidence
22
+ settings, log paths, unknown keys and the file's own indentation, so a
23
+ concurrent hand-edit survives. A config that fails validation is reported and
24
+ never rewritten. No restart is involved — the config is already re-read on every
25
+ guarded command, and the reviewer fingerprint already covers the model and
26
+ reasoning effort, so a save takes effect on the next command in every session.
27
+
28
+ ## 0.10.0
29
+
30
+ ### Minor Changes
31
+
32
+ - 0e9400e: Remove the built-in `openai-codex-auto-permissions` provider. Additional logins are now the job of `@hank-warren/pi-multi-login`, which adopts the existing credential on first run, so an existing `reviewer.provider` config keeps resolving with no edit.
33
+
34
+ Without that package installed the reviewer provider is missing; session start now warns once naming the provider instead of leaving the first guarded command to fail with a bare "review model not found".
35
+
3
36
  ## 0.9.0
4
37
 
5
38
  ### Minor Changes
package/README.md CHANGED
@@ -116,10 +116,10 @@ Set `enabled` to `false` to disable the extension. Invalid configuration fails c
116
116
 
117
117
  By default, the guardian uses Pi's active model with low reasoning effort and a 30-second timeout. You can select a separate low-cost model.
118
118
 
119
- For OpenAI Codex, this package registers a second provider named **OpenAI Codex - Auto Permissions**. Pi keys OAuth credentials by provider id, so this provider can use a different ChatGPT account without replacing the normal `openai-codex` login used by your main agent.
119
+ Giving the guardian its own account keeps reviews from competing with your interactive session for a subscription's rate limits. Pi keys OAuth credentials by provider id, so a second login needs a second provider id — which is what [`@hank-warren/pi-multi-login`](../pi-multi-login/README.md) exists to create. This package no longer registers one itself.
120
120
 
121
- 1. Run `/login` and select **OpenAI Codex - Auto Permissions**.
122
- 2. Complete OAuth with the account reserved for guardian reviews.
121
+ 1. `pi install npm:@hank-warren/pi-multi-login`
122
+ 2. Run `/multi-login` and add a login with base `openai-codex` and suffix `auto-permissions`; then run `/login`, pick the new provider, and complete OAuth with the account reserved for guardian reviews.
123
123
  3. Select the dedicated provider in the Auto Permissions config:
124
124
 
125
125
  ```json
@@ -133,10 +133,26 @@ For OpenAI Codex, this package registers a second provider named **OpenAI Codex
133
133
  }
134
134
  ```
135
135
 
136
- The separate credential is stored in Pi's normal `auth.json` under `openai-codex-auto-permissions`; the existing `openai-codex` credential is unchanged. The alias uses Pi's built-in OpenAI Codex OAuth flow, model catalog, and transport. Because it is a normal provider, its models also appear under the dedicated provider name in Pi's model selector after login.
136
+ The separate credential is stored in Pi's normal `auth.json` under `openai-codex-auto-permissions`; the existing `openai-codex` credential is unchanged. The alias uses Pi's built-in OpenAI Codex OAuth flow, model catalog, and transport. Because it is a normal provider, its models also appear in Pi's model selector and in `/login` after login.
137
+
138
+ If you already used the login this package registered in earlier versions, nothing changes: `pi-multi-login` adopts the existing `openai-codex-auto-permissions` credential on first run, so the config above keeps resolving. Without that package installed, the provider is missing and Auto Permissions warns once per session; point `reviewer.provider` at `openai-codex` (or any signed-in provider) to silence it.
137
139
 
138
140
  Other reviewer providers continue to work by setting their normal provider and model ids.
139
141
 
142
+ ### `/auto-permissions`
143
+
144
+ The reviewer settings that change most often are editable from a settings menu instead of the config file:
145
+
146
+ | Row | What it edits |
147
+ | --- | --- |
148
+ | Enabled | `enabled` — off lets every command run without guardian review |
149
+ | Reviewer model | `reviewer.provider` / `reviewer.model`, picked from the models you are signed in to |
150
+ | Thinking level | `reviewer.reasoningEffort` |
151
+ | Review timeout | `reviewer.timeoutMs`, entered as `30s` or `45000ms` |
152
+ | System prompt | read-only: the resolved path of the active `systemPromptFile`, or whether the built-in or an inline prompt is in use |
153
+
154
+ Saves are applied immediately — the config is re-read on every guarded command, so there is nothing to restart, in this session or any other. The menu is a narrow writer: it merges only the keys above into whatever is on disk, so rules, prompts, evidence settings and log paths stay exactly as you wrote them and remain file-only. A config that fails validation is never rewritten; the menu reports the error and refuses to open.
155
+
140
156
  ### Guardian prompt
141
157
 
142
158
  The bundled prompt evaluates authorization, command risk, and compliance with user constraints. Replace it inline with `systemPrompt`, or load a file:
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Narrow, merging writer for the auto-permissions config file.
3
+ *
4
+ * The config is hand-maintained: rules, the system prompt, evidence knobs and
5
+ * log paths all live in the same JSON object the `/auto-permissions` menu
6
+ * edits. So this writer never serializes a whole config snapshot -- it re-reads
7
+ * the file, patches only the keys one edit touched, and writes the result back
8
+ * atomically. Anything it does not know about (including keys a future version
9
+ * adds, and edits another session made a second ago) survives untouched.
10
+ */
11
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
12
+ import { dirname } from "node:path";
13
+ import { pid } from "node:process";
14
+ import { autoPermissionsConfigPath, type ReasoningEffort } from "./config.js";
15
+
16
+ export { autoPermissionsConfigPath };
17
+
18
+ /** The subset of the config the settings UI owns. */
19
+ export interface ReviewerBlock {
20
+ provider: string;
21
+ model: string;
22
+ reasoningEffort: ReasoningEffort;
23
+ timeoutMs: number;
24
+ }
25
+
26
+ export interface ConfigPatch {
27
+ enabled?: boolean;
28
+ reviewer?: ReviewerBlock;
29
+ }
30
+
31
+ const DEFAULT_INDENT = " ";
32
+
33
+ /**
34
+ * Keep the file looking like the human left it. Only the first indented line
35
+ * matters: JSON.stringify takes a single indent string for the whole document.
36
+ */
37
+ export function detectIndent(text: string): string {
38
+ const match = /\n([ \t]+)\S/.exec(text);
39
+ if (!match) return DEFAULT_INDENT;
40
+ const indent = match[1] ?? DEFAULT_INDENT;
41
+ return indent.startsWith("\t") ? "\t" : indent;
42
+ }
43
+
44
+ function readText(path: string): string {
45
+ try {
46
+ return readFileSync(path, "utf8");
47
+ } catch (error) {
48
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ function parseObject(text: string): Record<string, unknown> {
54
+ if (!text.trim()) return {};
55
+ const parsed: unknown = JSON.parse(text);
56
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
57
+ throw new Error("auto permissions config must be a JSON object");
58
+ }
59
+ return parsed as Record<string, unknown>;
60
+ }
61
+
62
+ /**
63
+ * Apply `patch` to the object currently on disk and rewrite the file.
64
+ *
65
+ * Sparse `enabled`: the loader reads a missing key as enabled, so turning the
66
+ * guardian back on deletes the key rather than writing `true`. That keeps a
67
+ * default flip reaching hosts that never touched the setting, matching how
68
+ * pi-statusline serializes its settings.
69
+ *
70
+ * Throws on an unparsable or non-object file rather than clobbering it; the
71
+ * caller surfaces that as a notification.
72
+ */
73
+ export function patchAutoPermissionsConfig(
74
+ path: string = autoPermissionsConfigPath(),
75
+ patch: ConfigPatch = {},
76
+ ): void {
77
+ const existing = readText(path);
78
+ const merged = parseObject(existing);
79
+
80
+ if (patch.enabled !== undefined) {
81
+ if (patch.enabled) delete merged.enabled;
82
+ else merged.enabled = false;
83
+ }
84
+ if (patch.reviewer !== undefined) {
85
+ merged.reviewer = {
86
+ provider: patch.reviewer.provider,
87
+ model: patch.reviewer.model,
88
+ reasoningEffort: patch.reviewer.reasoningEffort,
89
+ timeoutMs: patch.reviewer.timeoutMs,
90
+ };
91
+ }
92
+
93
+ const payload = `${JSON.stringify(merged, null, detectIndent(existing))}\n`;
94
+ const temporary = `${path}.${pid}.tmp`;
95
+ mkdirSync(dirname(path), { recursive: true });
96
+ try {
97
+ writeFileSync(temporary, payload, { mode: 0o600 });
98
+ renameSync(temporary, path);
99
+ } catch (error) {
100
+ try {
101
+ unlinkSync(temporary);
102
+ } catch {
103
+ // The temp file may never have been created; nothing to clean up.
104
+ }
105
+ throw error;
106
+ }
107
+ }
package/config.ts CHANGED
@@ -7,9 +7,25 @@ import { AUTO_PERMISSIONS_SYSTEM_PROMPT } from "./review.js";
7
7
 
8
8
  export const CONFIG_FILENAME = "pi-auto-permissions/config.json";
9
9
 
10
- const REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
10
+ export const REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
11
11
  export type ReasoningEffort = typeof REASONING_EFFORTS[number];
12
12
 
13
+ /** Loader defaults for an incomplete `reviewer` block, shared with the settings UI. */
14
+ export const DEFAULT_REVIEWER_REASONING_EFFORT: ReasoningEffort = "low";
15
+ export const DEFAULT_REVIEWER_TIMEOUT_MS = 30_000;
16
+ export const MIN_REVIEWER_TIMEOUT_MS = 1_000;
17
+ export const MAX_REVIEWER_TIMEOUT_MS = 300_000;
18
+
19
+ /**
20
+ * Where the active reviewer system prompt came from. The settings UI shows this
21
+ * (read-only) so the resolved `systemPromptFile` path is visible without having
22
+ * to re-derive the loader's relative-path resolution by hand.
23
+ */
24
+ export type SystemPromptSource =
25
+ | { kind: "builtin" }
26
+ | { kind: "inline" }
27
+ | { kind: "file"; path: string };
28
+
13
29
  export interface AutoPermissionsConfig {
14
30
  enabled: boolean;
15
31
  reviewer?: {
@@ -19,6 +35,7 @@ export interface AutoPermissionsConfig {
19
35
  timeoutMs: number;
20
36
  };
21
37
  systemPrompt: string;
38
+ systemPromptSource: SystemPromptSource;
22
39
  reviewEvidence: {
23
40
  projectInstructions: boolean;
24
41
  userAnswerTools: string[];
@@ -54,7 +71,12 @@ interface RuleInput {
54
71
  message?: unknown;
55
72
  }
56
73
 
57
- function configPath(): string {
74
+ /**
75
+ * The single config location, honouring the `PI_AUTO_PERMISSIONS_CONFIG`
76
+ * override. Exported so the settings writer can never target a different file
77
+ * than the loader reads.
78
+ */
79
+ export function autoPermissionsConfigPath(): string {
58
80
  return process.env.PI_AUTO_PERMISSIONS_CONFIG
59
81
  ? resolve(process.env.PI_AUTO_PERMISSIONS_CONFIG)
60
82
  : join(getAgentDir(), CONFIG_FILENAME);
@@ -102,18 +124,21 @@ function compileRule(value: unknown, index: number): Gate {
102
124
  };
103
125
  }
104
126
 
105
- function resolvePrompt(raw: Record<string, unknown>, path: string): string {
127
+ function resolvePrompt(
128
+ raw: Record<string, unknown>,
129
+ path: string,
130
+ ): { prompt: string; source: SystemPromptSource } {
106
131
  const inline = optionalString(raw.systemPrompt, "systemPrompt");
107
132
  const file = optionalString(raw.systemPromptFile, "systemPromptFile");
108
133
  if (inline && file) throw new Error("set only one of systemPrompt and systemPromptFile");
109
- if (inline) return inline;
110
- if (!file) return AUTO_PERMISSIONS_SYSTEM_PROMPT;
134
+ if (inline) return { prompt: inline, source: { kind: "inline" } };
135
+ if (!file) return { prompt: AUTO_PERMISSIONS_SYSTEM_PROMPT, source: { kind: "builtin" } };
111
136
 
112
137
  const expanded = file.startsWith("~/") ? join(homedir(), file.slice(2)) : file;
113
138
  const resolved = isAbsolute(expanded) ? expanded : resolve(dirname(path), expanded);
114
139
  const prompt = readFileSync(resolved, "utf8").trim();
115
140
  if (!prompt) throw new Error("systemPromptFile is empty");
116
- return prompt;
141
+ return { prompt, source: { kind: "file", path: resolved } };
117
142
  }
118
143
 
119
144
  const EVIDENCE_PRUNING_DEFAULTS = {
@@ -228,27 +253,34 @@ function resolveReviewer(raw: Record<string, unknown>): AutoPermissionsConfig["r
228
253
  const provider = optionalString(reviewer.provider, "reviewer.provider");
229
254
  const model = optionalString(reviewer.model, "reviewer.model");
230
255
  if (!provider || !model) throw new Error("reviewer requires both provider and model");
231
- const reasoningEffort = (optionalString(reviewer.reasoningEffort, "reviewer.reasoningEffort") ?? "low") as ReasoningEffort;
256
+ const reasoningEffort = (optionalString(reviewer.reasoningEffort, "reviewer.reasoningEffort")
257
+ ?? DEFAULT_REVIEWER_REASONING_EFFORT) as ReasoningEffort;
232
258
  if (!REASONING_EFFORTS.includes(reasoningEffort)) {
233
259
  throw new Error("reviewer.reasoningEffort is invalid");
234
260
  }
235
- const timeoutMs = reviewer.timeoutMs === undefined ? 30_000 : reviewer.timeoutMs;
236
- if (!Number.isInteger(timeoutMs) || Number(timeoutMs) < 1_000 || Number(timeoutMs) > 300_000) {
261
+ const timeoutMs = reviewer.timeoutMs === undefined ? DEFAULT_REVIEWER_TIMEOUT_MS : reviewer.timeoutMs;
262
+ if (
263
+ !Number.isInteger(timeoutMs)
264
+ || Number(timeoutMs) < MIN_REVIEWER_TIMEOUT_MS
265
+ || Number(timeoutMs) > MAX_REVIEWER_TIMEOUT_MS
266
+ ) {
237
267
  throw new Error("reviewer.timeoutMs must be an integer between 1000 and 300000");
238
268
  }
239
269
  return { provider, model, reasoningEffort, timeoutMs: Number(timeoutMs) };
240
270
  }
241
271
 
242
- export function loadAutoPermissionsConfig(path = configPath()): AutoPermissionsConfig {
272
+ export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): AutoPermissionsConfig {
243
273
  const raw = readObject(path);
244
274
  if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") throw new Error("enabled must be boolean");
245
275
  if (raw.rules !== undefined && !Array.isArray(raw.rules)) throw new Error("rules must be an array");
246
276
  const customRules = (raw.rules ?? []).map(compileRule);
277
+ const prompt = resolvePrompt(raw, path);
247
278
 
248
279
  return {
249
280
  enabled: raw.enabled !== false,
250
281
  reviewer: resolveReviewer(raw),
251
- systemPrompt: resolvePrompt(raw, path),
282
+ systemPrompt: prompt.prompt,
283
+ systemPromptSource: prompt.source,
252
284
  reviewEvidence: resolveReviewEvidence(raw),
253
285
  evaluationLog: resolveEvaluationLog(raw, path),
254
286
  usageLog: resolveUsageLog(raw, path),
package/index.ts CHANGED
@@ -1,18 +1,35 @@
1
1
  import { cleanupSessionResources, type Message } from "@earendil-works/pi-ai";
2
2
  import { resolveGuardianCompleteSimple } from "./guardian-transport.js";
3
- import {
4
- createAutoPermissionsOpenAICodexProvider,
5
- isOpenAICodexModel,
6
- } from "./openai-codex-provider.js";
3
+ import { isOpenAICodexModel } from "./openai-codex-transport.js";
7
4
  import { detectSubagentContext } from "./subagent-context.js";
8
5
  import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
9
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ getSelectListTheme,
8
+ getSettingsListTheme,
9
+ type ExtensionAPI,
10
+ type ExtensionContext,
11
+ } from "@earendil-works/pi-coding-agent";
10
12
  import { Type } from "typebox";
11
- import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
13
+ import { Container, SettingsList, Text, truncateToWidth } from "@earendil-works/pi-tui";
12
14
  import { randomBytes, randomUUID } from "node:crypto";
13
15
  import { existsSync, readFileSync } from "node:fs";
14
16
  import { join } from "node:path";
15
- import { loadAutoPermissionsConfig, type AutoPermissionsConfig } from "./config.js";
17
+ import {
18
+ autoPermissionsConfigPath,
19
+ loadAutoPermissionsConfig,
20
+ type AutoPermissionsConfig,
21
+ } from "./config.js";
22
+ import { patchAutoPermissionsConfig } from "./config-writer.js";
23
+ import {
24
+ applySettingChange,
25
+ buildSettingItems,
26
+ createModelSubmenu,
27
+ createTimeoutSubmenu,
28
+ type MenuModel,
29
+ type ReviewerSettings,
30
+ type SettingChange,
31
+ type SubmenuHost,
32
+ } from "./settings-menu.js";
16
33
  import {
17
34
  appendPromptEvaluation,
18
35
  classifyPromptChoice,
@@ -272,6 +289,29 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
272
289
  }
273
290
  }
274
291
 
292
+ /**
293
+ * The dedicated `openai-codex-auto-permissions` login used to be registered
294
+ * here. It now belongs to @hank-warren/pi-multi-login, which adopts the
295
+ * existing credential, so an existing reviewer config keeps working — but only
296
+ * if that package is installed. Say so once per session instead of letting the
297
+ * first guarded command fail with a bare "review model not found".
298
+ */
299
+ function warnAboutMissingReviewerProvider(ctx: ExtensionContext): void {
300
+ let provider: string | undefined;
301
+ try {
302
+ provider = currentConfig(ctx).reviewer?.provider;
303
+ } catch {
304
+ return; // The config error was already reported by currentConfig().
305
+ }
306
+ if (!provider || ctx.modelRegistry.getProvider(provider)) return;
307
+
308
+ const message =
309
+ `reviewer provider "${provider}" is not registered. Install @hank-warren/pi-multi-login to` +
310
+ ` provide additional logins such as this one, or point reviewer.provider at a signed-in provider.`;
311
+ console.warn(`[pi-auto-permissions] ${message}`);
312
+ ctx.ui.notify(`Auto Permissions: ${message}`, "warning");
313
+ }
314
+
275
315
  async function reviewCommand(
276
316
  gate: Gate,
277
317
  toolName: string,
@@ -985,6 +1025,111 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
985
1025
  },
986
1026
  });
987
1027
 
1028
+ /**
1029
+ * `/auto-permissions`: edit the reviewer model, thinking level, timeout and
1030
+ * the enabled flag without hand-editing config.json.
1031
+ *
1032
+ * There is no live-apply step: every tool call re-reads the config through
1033
+ * currentConfig(), so a saved edit is in force immediately -- in this session
1034
+ * and in every other one. reviewerFingerprint() covers provider, model and
1035
+ * reasoning effort, so the cached reviewer lineage self-invalidates too.
1036
+ */
1037
+ pi.registerCommand("auto-permissions", {
1038
+ description: "Configure Auto Permissions",
1039
+ handler: async (_args, ctx) => {
1040
+ if (ctx.mode !== "tui") {
1041
+ ctx.ui.notify("/auto-permissions requires interactive TUI mode", "warning");
1042
+ return;
1043
+ }
1044
+
1045
+ const path = autoPermissionsConfigPath();
1046
+ let settings: ReviewerSettings;
1047
+ try {
1048
+ const config = loadAutoPermissionsConfig(path);
1049
+ settings = {
1050
+ enabled: config.enabled,
1051
+ reviewer: config.reviewer,
1052
+ systemPromptSource: config.systemPromptSource,
1053
+ };
1054
+ } catch (error) {
1055
+ // Editing a file we cannot validate is the one way this writer could
1056
+ // quietly make things worse, so refuse rather than open.
1057
+ const message = error instanceof Error ? error.message : String(error);
1058
+ ctx.ui.notify(`Auto Permissions config error: ${message} \u2014 fix ${path} first`, "warning");
1059
+ return;
1060
+ }
1061
+
1062
+ /** Persist one accepted edit, then report anything the save surfaced. */
1063
+ const commit = (change: SettingChange): void => {
1064
+ if (change.kind === "ignored") return;
1065
+ if (change.kind === "error") {
1066
+ ctx.ui.notify(change.message, "warning");
1067
+ return;
1068
+ }
1069
+ const previous = settings;
1070
+ settings = change.settings;
1071
+ try {
1072
+ patchAutoPermissionsConfig(path, { enabled: settings.enabled, reviewer: settings.reviewer });
1073
+ } catch (error) {
1074
+ settings = previous;
1075
+ const message = error instanceof Error ? error.message : String(error);
1076
+ ctx.ui.notify(`Could not save Auto Permissions settings: ${message}`, "warning");
1077
+ return;
1078
+ }
1079
+ if (change.kind === "warn") ctx.ui.notify(change.message, "warning");
1080
+ // A breakage elsewhere in the file would otherwise surface at the next
1081
+ // bash call, looking like this edit caused it.
1082
+ try {
1083
+ loadAutoPermissionsConfig(path);
1084
+ } catch (error) {
1085
+ const message = error instanceof Error ? error.message : String(error);
1086
+ ctx.ui.notify(`Auto Permissions config error: ${message}`, "warning");
1087
+ }
1088
+ };
1089
+
1090
+ const reviewerModel = (): MenuModel | undefined => {
1091
+ if (!settings.reviewer) return undefined;
1092
+ return ctx.modelRegistry.find(settings.reviewer.provider, settings.reviewer.model) as MenuModel | undefined;
1093
+ };
1094
+
1095
+ await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
1096
+ const host: SubmenuHost = {
1097
+ getSettings: () => settings,
1098
+ commit,
1099
+ availableModels: () => ctx.modelRegistry.getAvailable() as MenuModel[],
1100
+ requestRender: () => tui.requestRender(),
1101
+ settingsTheme: getSettingsListTheme(),
1102
+ selectTheme: getSelectListTheme(),
1103
+ };
1104
+ const list = new SettingsList(
1105
+ buildSettingItems(settings, {
1106
+ reviewerModel: createModelSubmenu(host),
1107
+ timeout: createTimeoutSubmenu(host),
1108
+ }),
1109
+ 10,
1110
+ host.settingsTheme,
1111
+ (id, value) => {
1112
+ commit(applySettingChange(settings, id, value, reviewerModel()));
1113
+ tui.requestRender();
1114
+ },
1115
+ () => done(undefined),
1116
+ { enableSearch: true },
1117
+ );
1118
+ return {
1119
+ invalidate(): void {
1120
+ list.invalidate();
1121
+ },
1122
+ handleInput(data: string): void {
1123
+ list.handleInput(data);
1124
+ },
1125
+ render(width: number): string[] {
1126
+ return list.render(width);
1127
+ },
1128
+ };
1129
+ });
1130
+ },
1131
+ });
1132
+
988
1133
  pi.on("session_shutdown", async (_event, ctx) => {
989
1134
  sessionActive = false;
990
1135
  reviewerLifecycleController.abort();
@@ -997,12 +1142,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
997
1142
  });
998
1143
 
999
1144
  pi.on("session_start", async (_event, ctx) => {
1000
- const openaiCodex = ctx.modelRegistry.getProvider("openai-codex");
1001
- if (openaiCodex?.auth.oauth) {
1002
- pi.registerProvider(createAutoPermissionsOpenAICodexProvider(openaiCodex));
1003
- } else {
1004
- console.warn("[pi-auto-permissions] OpenAI Codex OAuth is unavailable; the dedicated Auto Permissions login was not registered.");
1005
- }
1145
+ warnAboutMissingReviewerProvider(ctx);
1006
1146
 
1007
1147
  reviewerLifecycleController.abort();
1008
1148
  discardReviewerLineage();
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Codex models are reached over a websocket session rather than plain HTTP, and
3
+ * the guardian has to pick that transport itself. The check is on the model's
4
+ * api rather than its provider id so it keeps working for a reviewer pointed at
5
+ * an aliased Codex login (see @hank-warren/pi-multi-login).
6
+ */
7
+ export function isOpenAICodexModel(model: { api?: string }): boolean {
8
+ return model.api === "openai-codex-responses";
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-auto-permissions",
3
- "version": "0.9.0",
3
+ "version": "0.11.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": [
@@ -41,8 +41,10 @@
41
41
  "index.ts",
42
42
  "approval-pulse.ts",
43
43
  "config.ts",
44
+ "config-writer.ts",
45
+ "settings-menu.ts",
44
46
  "guardian-transport.ts",
45
- "openai-codex-provider.ts",
47
+ "openai-codex-transport.ts",
46
48
  "subagent-context.ts",
47
49
  "evaluation-log.ts",
48
50
  "gates.ts",
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Row model and pure edit logic behind `/auto-permissions`.
3
+ *
4
+ * Everything that decides *what* a row shows or what an edit means lives here,
5
+ * free of pi's runtime, so it is testable without a TUI. index.ts owns only the
6
+ * wiring: read config, render a SettingsList, persist through config-writer.
7
+ */
8
+ import { homedir } from "node:os";
9
+ import {
10
+ type Component,
11
+ getKeybindings,
12
+ Input,
13
+ type SelectItem,
14
+ SelectList,
15
+ type SelectListTheme,
16
+ type SettingItem,
17
+ type SettingsListTheme,
18
+ truncateToWidth,
19
+ } from "@earendil-works/pi-tui";
20
+ import {
21
+ DEFAULT_REVIEWER_REASONING_EFFORT,
22
+ DEFAULT_REVIEWER_TIMEOUT_MS,
23
+ MAX_REVIEWER_TIMEOUT_MS,
24
+ MIN_REVIEWER_TIMEOUT_MS,
25
+ REASONING_EFFORTS,
26
+ type ReasoningEffort,
27
+ type SystemPromptSource,
28
+ } from "./config.js";
29
+ import type { ReviewerBlock } from "./config-writer.js";
30
+
31
+ export const ENABLED_ID = "enabled";
32
+ export const REVIEWER_MODEL_ID = "reviewerModel";
33
+ export const THINKING_LEVEL_ID = "reasoningEffort";
34
+ export const TIMEOUT_ID = "timeoutMs";
35
+ export const SYSTEM_PROMPT_ID = "systemPrompt";
36
+
37
+ export const ON = "on";
38
+ export const OFF = "off";
39
+ const TOGGLE_VALUES = [ON, OFF];
40
+
41
+ export const UNSET_MODEL = "(unset)";
42
+ const UNAVAILABLE_SUFFIX = "(configured, unavailable)";
43
+ const NO_REVIEWER_MESSAGE = "Select a reviewer model first";
44
+
45
+ /** The slice of the config the menu edits, plus the read-only prompt source. */
46
+ export interface ReviewerSettings {
47
+ enabled: boolean;
48
+ reviewer?: ReviewerBlock;
49
+ systemPromptSource: SystemPromptSource;
50
+ }
51
+
52
+ /** The subset of a pi model the menu needs; keeps tests free of pi-ai types. */
53
+ export interface MenuModel {
54
+ provider: string;
55
+ id: string;
56
+ name?: string;
57
+ contextWindow?: number;
58
+ thinkingLevelMap?: Partial<Record<ReasoningEffort, string | null>>;
59
+ }
60
+
61
+ export function modelKey(model: { provider: string; id: string }): string {
62
+ return `${model.provider}/${model.id}`;
63
+ }
64
+
65
+ /** Inverse of {@link modelKey}; the provider itself never contains a slash. */
66
+ export function parseModelKey(value: string): { provider: string; model: string } | undefined {
67
+ const slash = value.indexOf("/");
68
+ if (slash <= 0 || slash === value.length - 1) return undefined;
69
+ return { provider: value.slice(0, slash), model: value.slice(slash + 1) };
70
+ }
71
+
72
+ export function reviewerValue(settings: ReviewerSettings): string {
73
+ return settings.reviewer ? modelKey({ provider: settings.reviewer.provider, id: settings.reviewer.model }) : UNSET_MODEL;
74
+ }
75
+
76
+ export function toggleValue(enabled: boolean): string {
77
+ return enabled ? ON : OFF;
78
+ }
79
+
80
+ export function thinkingValue(settings: ReviewerSettings): ReasoningEffort {
81
+ return settings.reviewer?.reasoningEffort ?? DEFAULT_REVIEWER_REASONING_EFFORT;
82
+ }
83
+
84
+ export function timeoutValue(settings: ReviewerSettings): number {
85
+ return settings.reviewer?.timeoutMs ?? DEFAULT_REVIEWER_TIMEOUT_MS;
86
+ }
87
+
88
+ /** `~`-collapse a path so a long prompt path still fits the value column. */
89
+ export function collapseHome(path: string, home: string = homedir()): string {
90
+ const base = home || homedir();
91
+ if (!base) return path;
92
+ if (path === base) return "~";
93
+ return path.startsWith(`${base}/`) ? `~/${path.slice(base.length + 1)}` : path;
94
+ }
95
+
96
+ /** Value column for the read-only system-prompt row. */
97
+ export function systemPromptValue(source: SystemPromptSource, home: string = homedir()): string {
98
+ if (source.kind === "file") return collapseHome(source.path, home);
99
+ return source.kind === "inline" ? "(inline in config)" : "(built-in default)";
100
+ }
101
+
102
+ function systemPromptDescription(source: SystemPromptSource): string {
103
+ if (source.kind === "file") return "Active reviewer system prompt, from systemPromptFile. Edit the file to change it.";
104
+ if (source.kind === "inline") return "Active reviewer system prompt, from the systemPrompt key in config.json.";
105
+ return "No systemPrompt or systemPromptFile is set, so the packaged reviewer prompt is used.";
106
+ }
107
+
108
+ /** Human-readable timeout: seconds, or `m s` past a minute. */
109
+ export function formatTimeout(timeoutMs: number): string {
110
+ const seconds = timeoutMs / 1000;
111
+ if (seconds < 60) return `${Number(seconds.toFixed(1))}s`;
112
+ const minutes = Math.floor(seconds / 60);
113
+ const rest = Number((seconds - minutes * 60).toFixed(1));
114
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
115
+ }
116
+
117
+ export type TimeoutParse = { timeoutMs: number } | { error: string };
118
+
119
+ /**
120
+ * Parse a timeout the user typed. A bare number means seconds, because that is
121
+ * what the row displays; `ms` is available when an exact millisecond value
122
+ * matters. The accepted window mirrors the loader's validation exactly.
123
+ */
124
+ export function parseTimeoutInput(input: string): TimeoutParse {
125
+ const match = /^([0-9]+(?:\.[0-9]+)?)\s*(ms|s)?$/i.exec(input.trim());
126
+ if (!match) return { error: "Enter a timeout like 30s or 45000ms" };
127
+ const amount = Number(match[1]);
128
+ if (!Number.isFinite(amount)) return { error: "Enter a timeout like 30s or 45000ms" };
129
+ const timeoutMs = match[2]?.toLowerCase() === "ms" ? amount : amount * 1000;
130
+ if (!Number.isInteger(timeoutMs)) return { error: "Review timeout must be a whole number of milliseconds" };
131
+ if (timeoutMs < MIN_REVIEWER_TIMEOUT_MS || timeoutMs > MAX_REVIEWER_TIMEOUT_MS) {
132
+ return { error: "Review timeout must be between 1s and 300s" };
133
+ }
134
+ return { timeoutMs };
135
+ }
136
+
137
+ export interface SettingSubmenus {
138
+ reviewerModel?: SettingItem["submenu"];
139
+ timeout?: SettingItem["submenu"];
140
+ }
141
+
142
+ /** Build the `/auto-permissions` rows for a settings snapshot. */
143
+ export function buildSettingItems(
144
+ settings: ReviewerSettings,
145
+ submenus: SettingSubmenus = {},
146
+ home: string = homedir(),
147
+ ): SettingItem[] {
148
+ return [
149
+ {
150
+ id: ENABLED_ID,
151
+ label: "Enabled",
152
+ description: "Review guarded commands. Off lets every command run without guardian review.",
153
+ currentValue: toggleValue(settings.enabled),
154
+ values: TOGGLE_VALUES,
155
+ },
156
+ {
157
+ id: REVIEWER_MODEL_ID,
158
+ label: "Reviewer model",
159
+ description: "Model the guardian reviews guarded commands with.",
160
+ currentValue: reviewerValue(settings),
161
+ ...(submenus.reviewerModel ? { submenu: submenus.reviewerModel } : {}),
162
+ },
163
+ {
164
+ id: THINKING_LEVEL_ID,
165
+ label: "Thinking level",
166
+ description: "Reasoning effort requested from the reviewer model.",
167
+ currentValue: thinkingValue(settings),
168
+ values: [...REASONING_EFFORTS],
169
+ },
170
+ {
171
+ id: TIMEOUT_ID,
172
+ label: "Review timeout",
173
+ description: "How long one review may take before the command is denied.",
174
+ currentValue: formatTimeout(timeoutValue(settings)),
175
+ ...(submenus.timeout ? { submenu: submenus.timeout } : {}),
176
+ },
177
+ {
178
+ id: SYSTEM_PROMPT_ID,
179
+ label: "System prompt",
180
+ description: systemPromptDescription(settings.systemPromptSource),
181
+ currentValue: systemPromptValue(settings.systemPromptSource, home),
182
+ },
183
+ ];
184
+ }
185
+
186
+ export type SettingChange =
187
+ | { kind: "settings"; settings: ReviewerSettings }
188
+ /** Saved, but the value is worth flagging (e.g. an unsupported thinking level). */
189
+ | { kind: "warn"; settings: ReviewerSettings; message: string }
190
+ | { kind: "error"; message: string }
191
+ | { kind: "ignored" };
192
+
193
+ /**
194
+ * Map one row's new display value onto the settings snapshot.
195
+ *
196
+ * `model` is the currently configured reviewer model, when the registry knows
197
+ * it; it is only consulted to warn about unsupported thinking levels.
198
+ */
199
+ export function applySettingChange(
200
+ settings: ReviewerSettings,
201
+ id: string,
202
+ value: string,
203
+ model?: MenuModel,
204
+ ): SettingChange {
205
+ if (id === ENABLED_ID) {
206
+ if (value !== ON && value !== OFF) return { kind: "error", message: `Unknown value for ${id}: ${value}` };
207
+ const enabled = value === ON;
208
+ if (enabled === settings.enabled) return { kind: "ignored" };
209
+ return { kind: "settings", settings: { ...settings, enabled } };
210
+ }
211
+
212
+ if (id === THINKING_LEVEL_ID) {
213
+ if (!REASONING_EFFORTS.includes(value as ReasoningEffort)) {
214
+ return { kind: "error", message: `Unknown thinking level: ${value}` };
215
+ }
216
+ const reasoningEffort = value as ReasoningEffort;
217
+ // A reviewer block without provider and model is rejected by the loader, so
218
+ // there is nothing valid to write until a model is chosen.
219
+ if (!settings.reviewer) return { kind: "error", message: NO_REVIEWER_MESSAGE };
220
+ if (settings.reviewer.reasoningEffort === reasoningEffort) return { kind: "ignored" };
221
+ const next = { ...settings, reviewer: { ...settings.reviewer, reasoningEffort } };
222
+ if (model?.thinkingLevelMap && model.thinkingLevelMap[reasoningEffort] === null) {
223
+ return {
224
+ kind: "warn",
225
+ settings: next,
226
+ message: `${modelKey(model)} does not support thinking level "${reasoningEffort}"`,
227
+ };
228
+ }
229
+ return { kind: "settings", settings: next };
230
+ }
231
+
232
+ // The model and timeout submenus commit their own edit and hand back display
233
+ // text, so there is nothing left to map here.
234
+ return { kind: "ignored" };
235
+ }
236
+
237
+ /** Adopt a picked model, creating the reviewer block when there was none. */
238
+ export function applyModelSelection(settings: ReviewerSettings, value: string): SettingChange {
239
+ const parsed = parseModelKey(value);
240
+ if (!parsed) return { kind: "error", message: `Unknown model: ${value}` };
241
+ if (settings.reviewer?.provider === parsed.provider && settings.reviewer.model === parsed.model) {
242
+ return { kind: "ignored" };
243
+ }
244
+ return {
245
+ kind: "settings",
246
+ settings: {
247
+ ...settings,
248
+ reviewer: {
249
+ provider: parsed.provider,
250
+ model: parsed.model,
251
+ reasoningEffort: settings.reviewer?.reasoningEffort ?? DEFAULT_REVIEWER_REASONING_EFFORT,
252
+ timeoutMs: settings.reviewer?.timeoutMs ?? DEFAULT_REVIEWER_TIMEOUT_MS,
253
+ },
254
+ },
255
+ };
256
+ }
257
+
258
+ export function applyTimeoutInput(settings: ReviewerSettings, input: string): SettingChange {
259
+ if (!settings.reviewer) return { kind: "error", message: NO_REVIEWER_MESSAGE };
260
+ const parsed = parseTimeoutInput(input);
261
+ if ("error" in parsed) return { kind: "error", message: parsed.error };
262
+ if (parsed.timeoutMs === settings.reviewer.timeoutMs) return { kind: "ignored" };
263
+ return { kind: "settings", settings: { ...settings, reviewer: { ...settings.reviewer, timeoutMs: parsed.timeoutMs } } };
264
+ }
265
+
266
+ function modelItem(model: MenuModel, unavailable = false): SelectItem {
267
+ const key = modelKey(model);
268
+ const details = [model.name, model.contextWindow ? `${Math.round(model.contextWindow / 1000)}k ctx` : undefined]
269
+ .filter((part): part is string => Boolean(part))
270
+ .join(" · ");
271
+ return {
272
+ value: key,
273
+ label: unavailable ? `${key} ${UNAVAILABLE_SUFFIX}` : key,
274
+ description: unavailable ? `${details ? `${details} · ` : ""}provider not signed in or not registered` : details,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Available models, provider then id, with a configured-but-unavailable
280
+ * reviewer pinned first so opening the menu can never silently drop it.
281
+ */
282
+ export function buildModelItems(available: readonly MenuModel[], settings: ReviewerSettings): SelectItem[] {
283
+ const sorted = [...available].sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id));
284
+ const items = sorted.map((model) => modelItem(model));
285
+ const reviewer = settings.reviewer;
286
+ if (reviewer && !sorted.some((model) => model.provider === reviewer.provider && model.id === reviewer.model)) {
287
+ items.unshift(modelItem({ provider: reviewer.provider, id: reviewer.model }, true));
288
+ }
289
+ return items;
290
+ }
291
+
292
+ /**
293
+ * Substring filter over the picker rows.
294
+ *
295
+ * `SelectList.setFilter` is a prefix match on `value` alone, which for
296
+ * `provider/model-id` rows means typing "gpt" matches nothing. Filtering here
297
+ * instead keeps "gpt", "luna" and "codex" all useful.
298
+ */
299
+ export function filterModelItems(items: readonly SelectItem[], query: string): SelectItem[] {
300
+ const needle = query.trim().toLowerCase();
301
+ if (!needle) return [...items];
302
+ return items.filter((item) => `${item.label} ${item.description ?? ""}`.toLowerCase().includes(needle));
303
+ }
304
+
305
+ export interface SubmenuHost {
306
+ /** Read the live settings; the menu edits a single shared snapshot. */
307
+ getSettings(): ReviewerSettings;
308
+ /** Commit an edit: validates, persists, and repaints. */
309
+ commit(change: SettingChange): void;
310
+ availableModels(): MenuModel[];
311
+ requestRender(): void;
312
+ settingsTheme: SettingsListTheme;
313
+ selectTheme: SelectListTheme;
314
+ home?: string;
315
+ }
316
+
317
+ /** Single-line text prompt, mirroring the one behind `/statusline`'s path rows. */
318
+ export class PromptComponent implements Component {
319
+ private readonly input = new Input();
320
+
321
+ constructor(
322
+ private readonly title: string,
323
+ initialValue: string,
324
+ private readonly hint: (text: string) => string,
325
+ onSubmit: (value: string) => void,
326
+ onCancel: () => void,
327
+ ) {
328
+ this.input.setValue(initialValue);
329
+ this.input.focused = true;
330
+ this.input.onSubmit = onSubmit;
331
+ this.input.onEscape = onCancel;
332
+ }
333
+
334
+ invalidate(): void {
335
+ this.input.invalidate();
336
+ }
337
+
338
+ render(width: number): string[] {
339
+ return [
340
+ truncateToWidth(this.hint(` ${this.title}`), width),
341
+ "",
342
+ ...this.input.render(width),
343
+ "",
344
+ truncateToWidth(this.hint(" Enter to save · Esc to cancel"), width),
345
+ ];
346
+ }
347
+
348
+ handleInput(data: string): void {
349
+ this.input.handleInput(data);
350
+ }
351
+ }
352
+
353
+ const LIST_KEYS = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const;
354
+
355
+ /** Searchable reviewer-model picker. */
356
+ class ModelSubmenu implements Component {
357
+ private readonly items: SelectItem[];
358
+ private list: SelectList;
359
+ private readonly search = new Input();
360
+ private query = "";
361
+
362
+ constructor(
363
+ private readonly host: SubmenuHost,
364
+ private readonly done: (value?: string) => void,
365
+ ) {
366
+ const settings = host.getSettings();
367
+ this.items = buildModelItems(host.availableModels(), settings);
368
+ this.list = this.buildList(this.items, this.items.findIndex((item) => item.value === reviewerValue(settings)));
369
+ this.search.focused = true;
370
+ }
371
+
372
+ private buildList(items: SelectItem[], selectedIndex: number): SelectList {
373
+ const list = new SelectList(items, 10, this.host.selectTheme);
374
+ if (selectedIndex > 0) list.setSelectedIndex(selectedIndex);
375
+ list.onCancel = () => this.done(undefined);
376
+ list.onSelect = (item) => {
377
+ const change = applyModelSelection(this.host.getSettings(), item.value);
378
+ this.host.commit(change);
379
+ this.done(change.kind === "error" ? undefined : item.value);
380
+ };
381
+ return list;
382
+ }
383
+
384
+ invalidate(): void {
385
+ this.search.invalidate();
386
+ this.list.invalidate();
387
+ }
388
+
389
+ render(width: number): string[] {
390
+ return [
391
+ truncateToWidth(this.host.settingsTheme.hint(" Reviewer model"), width),
392
+ "",
393
+ ...this.search.render(width),
394
+ "",
395
+ ...this.list.render(width),
396
+ "",
397
+ truncateToWidth(this.host.settingsTheme.hint(" Type to filter · Enter to select · Esc to go back"), width),
398
+ ];
399
+ }
400
+
401
+ handleInput(data: string): void {
402
+ const kb = getKeybindings();
403
+ // Navigation, Enter and Esc belong to the list; everything else is filter text.
404
+ if (LIST_KEYS.some((key) => kb.matches(data, key))) {
405
+ this.list.handleInput(data);
406
+ return;
407
+ }
408
+ this.search.handleInput(data);
409
+ const query = this.search.getValue();
410
+ if (query === this.query) return;
411
+ this.query = query;
412
+ this.list = this.buildList(filterModelItems(this.items, query), 0);
413
+ this.host.requestRender();
414
+ }
415
+ }
416
+
417
+ export function createModelSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
418
+ return (_currentValue, done) => new ModelSubmenu(host, done);
419
+ }
420
+
421
+ export function createTimeoutSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
422
+ return (currentValue, done) =>
423
+ new PromptComponent(
424
+ "Review timeout (e.g. 30s or 45000ms)",
425
+ currentValue,
426
+ host.settingsTheme.hint,
427
+ (value) => {
428
+ const change = applyTimeoutInput(host.getSettings(), value);
429
+ host.commit(change);
430
+ if (change.kind === "error") {
431
+ done(undefined);
432
+ return;
433
+ }
434
+ done(formatTimeout(timeoutValue(host.getSettings())));
435
+ },
436
+ () => done(undefined),
437
+ );
438
+ }
@@ -1,42 +0,0 @@
1
- import type { Provider } from "@earendil-works/pi-ai";
2
-
3
- export const AUTO_PERMISSIONS_OPENAI_CODEX_PROVIDER_ID = "openai-codex-auto-permissions";
4
- export const AUTO_PERMISSIONS_OPENAI_CODEX_PROVIDER_NAME = "OpenAI Codex - Auto Permissions";
5
-
6
- /**
7
- * Registering a second provider id gives Pi a second auth.json credential slot.
8
- * The transport and model catalog still come from Pi's built-in OpenAI Codex
9
- * provider, so the alias follows the host Pi version instead of duplicating its
10
- * OAuth or Responses implementation here. The effective catalog is snapshotted
11
- * when the session starts; the source provider remains the sole owner of any
12
- * dynamic catalog refresh state.
13
- */
14
- export function createAutoPermissionsOpenAICodexProvider(source: Provider): Provider {
15
- const models = source.getModels().map((model) => ({
16
- ...model,
17
- provider: AUTO_PERMISSIONS_OPENAI_CODEX_PROVIDER_ID,
18
- }));
19
- const oauth = source.auth.oauth;
20
-
21
- if (!oauth) {
22
- throw new Error("Pi's OpenAI Codex provider does not expose OAuth authentication");
23
- }
24
-
25
- return {
26
- ...source,
27
- id: AUTO_PERMISSIONS_OPENAI_CODEX_PROVIDER_ID,
28
- name: AUTO_PERMISSIONS_OPENAI_CODEX_PROVIDER_NAME,
29
- auth: {
30
- oauth: {
31
- ...oauth,
32
- name: AUTO_PERMISSIONS_OPENAI_CODEX_PROVIDER_NAME,
33
- },
34
- },
35
- getModels: () => models,
36
- refreshModels: undefined,
37
- };
38
- }
39
-
40
- export function isOpenAICodexModel(model: { api?: string }): boolean {
41
- return model.api === "openai-codex-responses";
42
- }