@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 +33 -0
- package/README.md +20 -4
- package/config-writer.ts +107 -0
- package/config.ts +43 -11
- package/index.ts +153 -13
- package/openai-codex-transport.ts +9 -0
- package/package.json +4 -2
- package/settings-menu.ts +438 -0
- package/openai-codex-provider.ts +0 -42
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
|
-
|
|
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.
|
|
122
|
-
2.
|
|
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
|
|
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:
|
package/config-writer.ts
ADDED
|
@@ -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
|
-
|
|
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(
|
|
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")
|
|
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 ?
|
|
236
|
-
if (
|
|
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 =
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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-
|
|
47
|
+
"openai-codex-transport.ts",
|
|
46
48
|
"subagent-context.ts",
|
|
47
49
|
"evaluation-log.ts",
|
|
48
50
|
"gates.ts",
|
package/settings-menu.ts
ADDED
|
@@ -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
|
+
}
|
package/openai-codex-provider.ts
DELETED
|
@@ -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
|
-
}
|