@hank-warren/pi-auto-permissions 0.16.0 → 0.16.2
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 +17 -0
- package/README.md +5 -1
- package/approval-pulse.ts +1 -1
- package/classify.ts +66 -0
- package/config-writer.ts +1 -1
- package/config.ts +2 -2
- package/denial-log.ts +1 -1
- package/evaluation-log.ts +2 -2
- package/guardian-reviewer.ts +547 -0
- package/guardian-transport.ts +1 -1
- package/index.ts +186 -1388
- package/loop-revise-budget.ts +1 -1
- package/package.json +8 -1
- package/prompt-select.ts +80 -0
- package/review-display.ts +267 -0
- package/review-queue.ts +1 -1
- package/review-scope.ts +26 -0
- package/review.ts +7 -7
- package/session-overrides.ts +264 -0
- package/settings-command.ts +261 -0
- package/settings-menu.ts +11 -11
- package/standing-overrides.ts +3 -3
- package/usage-log.ts +1 -1
- package/widget-status.ts +2 -2
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AutoPermissionsConfig } from "./config.js";
|
|
3
|
+
import type { PromptChoiceClassification } from "./evaluation-log.js";
|
|
4
|
+
import type { PermissionOverride } from "./override-evidence.js";
|
|
5
|
+
import type { ReviewScope } from "./review-scope.js";
|
|
6
|
+
import type { DenialSummary } from "./settings-menu.js";
|
|
7
|
+
import {
|
|
8
|
+
grantStandingApproval,
|
|
9
|
+
readStandingApprovals,
|
|
10
|
+
STANDING_APPROVAL_LIMIT,
|
|
11
|
+
standingApprovalsToPermissionOverrides,
|
|
12
|
+
} from "./standing-overrides.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Overrides persist as custom session entries the way the loop budget does,
|
|
16
|
+
* so a resumed session keeps the user's prompt decisions instead of
|
|
17
|
+
* forgetting every allow and standing block constraint.
|
|
18
|
+
*/
|
|
19
|
+
const OVERRIDES_ENTRY_TYPE = "auto-permissions-overrides";
|
|
20
|
+
|
|
21
|
+
export interface SessionOverrides {
|
|
22
|
+
/** Commands a `request_override` grant cleared for the rest of this session. */
|
|
23
|
+
readonly allowedConventionCommands: ReadonlySet<string>;
|
|
24
|
+
/** The override records, live, for merging into reviewer evidence. */
|
|
25
|
+
list(): readonly PermissionOverride[];
|
|
26
|
+
allowConvention(command: string): void;
|
|
27
|
+
activateOverrideTool(): void;
|
|
28
|
+
reconcileOverrideTool(): void;
|
|
29
|
+
persist(): void;
|
|
30
|
+
restore(branch: readonly unknown[]): void;
|
|
31
|
+
removeStanding(matches: (override: PermissionOverride) => boolean): void;
|
|
32
|
+
loadStanding(config: AutoPermissionsConfig, ctx: ExtensionContext): void;
|
|
33
|
+
recordPromptDecision(
|
|
34
|
+
scope: ReviewScope,
|
|
35
|
+
classification: PromptChoiceClassification,
|
|
36
|
+
detail: string,
|
|
37
|
+
anchorKey: string | undefined,
|
|
38
|
+
): void;
|
|
39
|
+
allowRetry(denial: DenialSummary, anchorKey: string | undefined): void;
|
|
40
|
+
resetForSession(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The user's own permission decisions for this session: the exception grants
|
|
45
|
+
* that let a convention-blocked command run, and the override records the
|
|
46
|
+
* guardian is shown as user-source evidence.
|
|
47
|
+
*
|
|
48
|
+
* One owner, because the pieces are one fact seen three ways — the in-memory
|
|
49
|
+
* record, the session entry that survives a resume, and (for standing
|
|
50
|
+
* approvals) the cross-project ledger.
|
|
51
|
+
*/
|
|
52
|
+
export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
|
|
53
|
+
const allowedConventionCommands = new Set<string>();
|
|
54
|
+
const permissionOverrides: PermissionOverride[] = [];
|
|
55
|
+
let overrideSeq = 0;
|
|
56
|
+
let standingApprovalCapNotified = false;
|
|
57
|
+
let overrideToolActivated = false;
|
|
58
|
+
|
|
59
|
+
function reconcileOverrideTool(): void {
|
|
60
|
+
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
61
|
+
const active = pi.getActiveTools();
|
|
62
|
+
const present = active.includes("request_override");
|
|
63
|
+
if (overrideToolActivated === present) return;
|
|
64
|
+
pi.setActiveTools(
|
|
65
|
+
overrideToolActivated
|
|
66
|
+
? [...active, "request_override"]
|
|
67
|
+
: active.filter((name) => name !== "request_override"),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function persist(): void {
|
|
72
|
+
try {
|
|
73
|
+
pi.appendEntry(OVERRIDES_ENTRY_TYPE, {
|
|
74
|
+
seq: overrideSeq,
|
|
75
|
+
overrides: permissionOverrides
|
|
76
|
+
.filter((override) => !override.standing)
|
|
77
|
+
.map((override) => ({ ...override })),
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
// Persistence is best-effort; the in-memory records still apply now.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Fail-open like the loop budget: unreadable state means no overrides. */
|
|
85
|
+
function restoreOverrides(entries: readonly unknown[]): void {
|
|
86
|
+
permissionOverrides.length = 0;
|
|
87
|
+
overrideSeq = 0;
|
|
88
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
89
|
+
const entry = entries[index] as { type?: string; customType?: string; data?: unknown } | undefined;
|
|
90
|
+
if (entry?.type !== "custom" || entry.customType !== OVERRIDES_ENTRY_TYPE) continue;
|
|
91
|
+
const data = entry.data as { seq?: unknown; overrides?: unknown } | undefined;
|
|
92
|
+
if (Array.isArray(data?.overrides)) {
|
|
93
|
+
for (const raw of data.overrides) {
|
|
94
|
+
if (!raw || typeof raw !== "object") continue;
|
|
95
|
+
const candidate = raw as PermissionOverride;
|
|
96
|
+
if (
|
|
97
|
+
typeof candidate.seq !== "number"
|
|
98
|
+
|| typeof candidate.gateLabel !== "string"
|
|
99
|
+
|| typeof candidate.command !== "string"
|
|
100
|
+
|| typeof candidate.reviewerReason !== "string"
|
|
101
|
+
|| !["allow", "allow_unnecessary", "allow_appropriate", "block"].includes(candidate.choice)
|
|
102
|
+
) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
permissionOverrides.push({
|
|
106
|
+
seq: candidate.seq,
|
|
107
|
+
...(typeof candidate.anchorKey === "string" ? { anchorKey: candidate.anchorKey } : {}),
|
|
108
|
+
gateLabel: candidate.gateLabel,
|
|
109
|
+
command: candidate.command,
|
|
110
|
+
reviewerReason: candidate.reviewerReason,
|
|
111
|
+
choice: candidate.choice,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (typeof data?.seq === "number" && Number.isSafeInteger(data.seq) && data.seq >= 0) {
|
|
116
|
+
overrideSeq = data.seq;
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Drop matching ledger-backed evidence in place; the array is shared by reference. */
|
|
123
|
+
function removeStanding(matches: (override: PermissionOverride) => boolean): void {
|
|
124
|
+
const kept = permissionOverrides.filter((override) => !override.standing || !matches(override));
|
|
125
|
+
permissionOverrides.splice(0, permissionOverrides.length, ...kept);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
allowedConventionCommands,
|
|
130
|
+
list: () => permissionOverrides,
|
|
131
|
+
allowConvention(command: string): void {
|
|
132
|
+
allowedConventionCommands.add(command);
|
|
133
|
+
},
|
|
134
|
+
reconcileOverrideTool,
|
|
135
|
+
activateOverrideTool(): void {
|
|
136
|
+
overrideToolActivated = true;
|
|
137
|
+
reconcileOverrideTool();
|
|
138
|
+
},
|
|
139
|
+
persist,
|
|
140
|
+
removeStanding,
|
|
141
|
+
|
|
142
|
+
restore(branch: readonly unknown[]): void {
|
|
143
|
+
// Restore prompt decisions from the branch: a resumed session keeps its
|
|
144
|
+
// allows and standing block constraints (parity with the loop budget).
|
|
145
|
+
restoreOverrides(branch);
|
|
146
|
+
for (const entry of branch as Array<{
|
|
147
|
+
type?: string;
|
|
148
|
+
message?: { role?: string; toolName?: string; details?: unknown };
|
|
149
|
+
}>) {
|
|
150
|
+
if (entry.type !== "message" || entry.message?.role !== "toolResult") continue;
|
|
151
|
+
if (entry.message.toolName !== "request_override") continue;
|
|
152
|
+
const details = entry.message.details as { success?: boolean; command?: string } | undefined;
|
|
153
|
+
if (details?.success && details.command) allowedConventionCommands.add(details.command);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/** Replace only ledger-backed evidence; session prompt decisions stay put. */
|
|
158
|
+
loadStanding(config: AutoPermissionsConfig, ctx: ExtensionContext): void {
|
|
159
|
+
removeStanding(() => true);
|
|
160
|
+
if (!config.standingApprovals.enabled) return;
|
|
161
|
+
try {
|
|
162
|
+
const records = readStandingApprovals(config.standingApprovals.path);
|
|
163
|
+
permissionOverrides.push(...standingApprovalsToPermissionOverrides(records));
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
+
ctx.ui.notify(`Could not load standing Auto Permissions approvals: ${message}`, "warning");
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Feed the user's decision back to the guardian as session-scoped
|
|
172
|
+
* user-source evidence. Only the caller knows whether the prompt came from
|
|
173
|
+
* a guardian judgment, which is the one case this may be called for.
|
|
174
|
+
*/
|
|
175
|
+
recordPromptDecision(
|
|
176
|
+
scope: ReviewScope,
|
|
177
|
+
classification: PromptChoiceClassification,
|
|
178
|
+
detail: string,
|
|
179
|
+
anchorKey: string | undefined,
|
|
180
|
+
): void {
|
|
181
|
+
const { ctx, config, gate, command } = scope;
|
|
182
|
+
const overrideChoice = classification.userChoice ?? (classification.allowsExecution ? "allow" as const : undefined);
|
|
183
|
+
if (!overrideChoice) return;
|
|
184
|
+
let recordedStanding = false;
|
|
185
|
+
if (classification.standingApproval && config.standingApprovals.enabled) {
|
|
186
|
+
try {
|
|
187
|
+
const { evicted, override } = grantStandingApproval(
|
|
188
|
+
config.standingApprovals.path,
|
|
189
|
+
{
|
|
190
|
+
gate: { label: gate.label, group: gate.group },
|
|
191
|
+
command,
|
|
192
|
+
project: ctx.cwd,
|
|
193
|
+
reason: detail,
|
|
194
|
+
},
|
|
195
|
+
overrideSeq++,
|
|
196
|
+
anchorKey,
|
|
197
|
+
);
|
|
198
|
+
removeStanding(
|
|
199
|
+
(existing) => existing.standing?.gateGroup === gate.group && existing.command === command,
|
|
200
|
+
);
|
|
201
|
+
permissionOverrides.push(override);
|
|
202
|
+
recordedStanding = true;
|
|
203
|
+
if (evicted > 0 && !standingApprovalCapNotified) {
|
|
204
|
+
standingApprovalCapNotified = true;
|
|
205
|
+
ctx.ui.notify(
|
|
206
|
+
`Standing approvals reached ${STANDING_APPROVAL_LIMIT} entries; the oldest approval was removed.`,
|
|
207
|
+
"warning",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
212
|
+
ctx.ui.notify(`Could not save standing approval: ${message}`, "warning");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!recordedStanding) {
|
|
216
|
+
permissionOverrides.push({
|
|
217
|
+
seq: overrideSeq++,
|
|
218
|
+
anchorKey,
|
|
219
|
+
gateLabel: gate.label,
|
|
220
|
+
command,
|
|
221
|
+
reviewerReason: detail,
|
|
222
|
+
choice: overrideChoice,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
persist();
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* "Allow on retry": the existing override machinery, driven from the
|
|
230
|
+
* denial ledger. An exact-command allow override (which already
|
|
231
|
+
* generalizes correctly and survives via evidence re-injection and the
|
|
232
|
+
* session entry), plus a visible injected message telling the agent it
|
|
233
|
+
* may retry. No new authorization pathway.
|
|
234
|
+
*/
|
|
235
|
+
allowRetry(denial: DenialSummary, anchorKey: string | undefined): void {
|
|
236
|
+
permissionOverrides.push({
|
|
237
|
+
seq: overrideSeq++,
|
|
238
|
+
anchorKey,
|
|
239
|
+
gateLabel: denial.gateLabel,
|
|
240
|
+
command: denial.command,
|
|
241
|
+
reviewerReason: denial.reason,
|
|
242
|
+
choice: "allow",
|
|
243
|
+
});
|
|
244
|
+
persist();
|
|
245
|
+
try {
|
|
246
|
+
// The same channel a prompt note uses. This is a real user decision
|
|
247
|
+
// made in the menu, so a user message is honest provenance — and it
|
|
248
|
+
// gives the agent a turn to actually retry.
|
|
249
|
+
pi.sendUserMessage(
|
|
250
|
+
`Auto Permissions: I reviewed the denied command in /auto-permissions and allowed it on retry:\n\n ${denial.command}\n\nYou may run this exact command again; a session override now authorizes it.`,
|
|
251
|
+
);
|
|
252
|
+
} catch {
|
|
253
|
+
// The override itself is already in force; the nudge is best-effort.
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
resetForSession(): void {
|
|
258
|
+
overrideToolActivated = false;
|
|
259
|
+
reconcileOverrideTool();
|
|
260
|
+
standingApprovalCapNotified = false;
|
|
261
|
+
allowedConventionCommands.clear();
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSelectListTheme,
|
|
3
|
+
getSettingsListTheme,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { SettingsList } from "@earendil-works/pi-tui";
|
|
7
|
+
import {
|
|
8
|
+
autoPermissionsConfigPath,
|
|
9
|
+
loadAutoPermissionsConfig,
|
|
10
|
+
type AutoPermissionsConfig,
|
|
11
|
+
} from "./config.js";
|
|
12
|
+
import { patchAutoPermissionsConfig } from "./config-writer.js";
|
|
13
|
+
import { readRecentDenials } from "./denial-log.js";
|
|
14
|
+
import type { GuardianReviewer } from "./guardian-reviewer.js";
|
|
15
|
+
import type { SessionOverrides } from "./session-overrides.js";
|
|
16
|
+
import {
|
|
17
|
+
applySettingChange,
|
|
18
|
+
buildSettingItems,
|
|
19
|
+
createDenialsSubmenu,
|
|
20
|
+
createModelSubmenu,
|
|
21
|
+
createStandingApprovalsSubmenu,
|
|
22
|
+
createTimeoutSubmenu,
|
|
23
|
+
type DenialsSubmenuHost,
|
|
24
|
+
type DenialSummary,
|
|
25
|
+
type StandingApprovalsSubmenuHost,
|
|
26
|
+
type StandingApprovalSummary,
|
|
27
|
+
type MenuModel,
|
|
28
|
+
type ReviewerSettings,
|
|
29
|
+
type SettingChange,
|
|
30
|
+
type SubmenuHost,
|
|
31
|
+
} from "./settings-menu.js";
|
|
32
|
+
import { readStandingApprovals, revokeStandingApproval } from "./standing-overrides.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What `/auto-permissions setup` says on the user's behalf. Named after the
|
|
36
|
+
* bundled skill rather than a `/skill:` invocation, because skill commands can
|
|
37
|
+
* be turned off in settings while the skill itself stays discoverable.
|
|
38
|
+
*/
|
|
39
|
+
const SETUP_SKILL_HANDOFF_MESSAGE =
|
|
40
|
+
"Use the auto-permissions-setup skill to set up my Auto Permissions policy.";
|
|
41
|
+
|
|
42
|
+
export function registerSettingsCommand(
|
|
43
|
+
pi: ExtensionAPI,
|
|
44
|
+
{ overrides, reviewer }: { overrides: SessionOverrides; reviewer: GuardianReviewer },
|
|
45
|
+
): void {
|
|
46
|
+
/**
|
|
47
|
+
* `/auto-permissions`: edit the reviewer model, thinking level, timeout and
|
|
48
|
+
* the enabled flag without hand-editing config.json.
|
|
49
|
+
*
|
|
50
|
+
* There is no live-apply step: every tool call re-reads the config through
|
|
51
|
+
* currentConfig(), so a saved edit is in force immediately -- in this session
|
|
52
|
+
* and in every other one. reviewerFingerprint() covers provider, model and
|
|
53
|
+
* reasoning effort, so the cached reviewer lineage self-invalidates too.
|
|
54
|
+
*
|
|
55
|
+
* `/auto-permissions setup` is a pointer, not a wizard: it hands the job to
|
|
56
|
+
* the bundled auto-permissions-setup skill. A conversational setup can ask
|
|
57
|
+
* about an ambiguous host, weigh history-wide friction and back out of a bad
|
|
58
|
+
* suggestion; a fixed accept-or-discard draft could do none of that, so the
|
|
59
|
+
* two paths were never equal and keeping both only split the maintenance.
|
|
60
|
+
*/
|
|
61
|
+
pi.registerCommand("auto-permissions", {
|
|
62
|
+
description: "Configure Auto Permissions (\"setup\" hands off to the auto-permissions-setup skill)",
|
|
63
|
+
handler: async (args, ctx) => {
|
|
64
|
+
if ((args ?? "").trim() === "setup") {
|
|
65
|
+
// Dispatch rather than instruct: the point of the pointer is that the
|
|
66
|
+
// interview starts on this keystroke.
|
|
67
|
+
pi.sendUserMessage(SETUP_SKILL_HANDOFF_MESSAGE);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (ctx.mode !== "tui") {
|
|
71
|
+
ctx.ui.notify("/auto-permissions requires interactive TUI mode", "warning");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const path = autoPermissionsConfigPath();
|
|
76
|
+
let settings: ReviewerSettings;
|
|
77
|
+
let denialLog: AutoPermissionsConfig["denialLog"];
|
|
78
|
+
let standingApprovalsConfig: AutoPermissionsConfig["standingApprovals"];
|
|
79
|
+
try {
|
|
80
|
+
const config = loadAutoPermissionsConfig(path);
|
|
81
|
+
settings = {
|
|
82
|
+
enabled: config.enabled,
|
|
83
|
+
reviewer: config.reviewer,
|
|
84
|
+
systemPromptSource: config.systemPromptSource,
|
|
85
|
+
};
|
|
86
|
+
denialLog = config.denialLog;
|
|
87
|
+
standingApprovalsConfig = config.standingApprovals;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// Editing a file we cannot validate is the one way this writer could
|
|
90
|
+
// quietly make things worse, so refuse rather than open.
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
ctx.ui.notify(`Auto Permissions config error: ${message} \u2014 fix ${path} first`, "warning");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Persist one accepted edit, then report anything the save surfaced. */
|
|
97
|
+
const commit = (change: SettingChange): void => {
|
|
98
|
+
if (change.kind === "ignored") return;
|
|
99
|
+
if (change.kind === "error") {
|
|
100
|
+
ctx.ui.notify(change.message, "warning");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const previous = settings;
|
|
104
|
+
settings = change.settings;
|
|
105
|
+
try {
|
|
106
|
+
patchAutoPermissionsConfig(path, { enabled: settings.enabled, reviewer: settings.reviewer });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
settings = previous;
|
|
109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
110
|
+
ctx.ui.notify(`Could not save Auto Permissions settings: ${message}`, "warning");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (change.kind === "warn") ctx.ui.notify(change.message, "warning");
|
|
114
|
+
// A breakage elsewhere in the file would otherwise surface at the next
|
|
115
|
+
// bash call, looking like this edit caused it.
|
|
116
|
+
try {
|
|
117
|
+
loadAutoPermissionsConfig(path);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
120
|
+
ctx.ui.notify(`Auto Permissions config error: ${message}`, "warning");
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const reviewerModel = (): MenuModel | undefined => {
|
|
125
|
+
if (!settings.reviewer) return undefined;
|
|
126
|
+
return ctx.modelRegistry.find(settings.reviewer.provider, settings.reviewer.model) as MenuModel | undefined;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const recentDenials = (): DenialSummary[] => {
|
|
130
|
+
if (!denialLog.enabled) return [];
|
|
131
|
+
try {
|
|
132
|
+
return readRecentDenials(denialLog.path, 20).map((record) => ({
|
|
133
|
+
id: record.id,
|
|
134
|
+
ts: record.ts,
|
|
135
|
+
gateLabel: record.gate.label,
|
|
136
|
+
command: record.command,
|
|
137
|
+
verdict: record.verdict,
|
|
138
|
+
reason: record.reason,
|
|
139
|
+
}));
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const standingApprovalSummaries = (): StandingApprovalSummary[] => {
|
|
146
|
+
if (!standingApprovalsConfig.enabled) return [];
|
|
147
|
+
try {
|
|
148
|
+
return readStandingApprovals(standingApprovalsConfig.path).map((record, index) => ({
|
|
149
|
+
id: `${index}:${record.ts}:${record.gate.label}`,
|
|
150
|
+
record,
|
|
151
|
+
}));
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const revokeApproval = (approval: StandingApprovalSummary): void => {
|
|
158
|
+
try {
|
|
159
|
+
if (!revokeStandingApproval(standingApprovalsConfig.path, approval.record)) {
|
|
160
|
+
ctx.ui.notify("That standing approval is no longer in the ledger.", "warning");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const config = loadAutoPermissionsConfig(path);
|
|
164
|
+
overrides.loadStanding(config, ctx);
|
|
165
|
+
reviewer.discardLineage();
|
|
166
|
+
ctx.ui.notify("Standing approval revoked.", "info");
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
+
ctx.ui.notify(`Could not revoke standing approval: ${message}`, "warning");
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Closes the settings dialog. Assigned when `ctx.ui.custom` builds the
|
|
175
|
+
* component, so it is only ever called from inside the dialog it closes.
|
|
176
|
+
*/
|
|
177
|
+
let closeSettings: (() => void) | undefined;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* "Allow on retry": the existing override machinery, driven from the
|
|
181
|
+
* denial ledger. An exact-command allow override (which already
|
|
182
|
+
* generalizes correctly and survives via evidence re-injection and the
|
|
183
|
+
* session entry), plus a visible injected message telling the agent it
|
|
184
|
+
* may retry. No new authorization pathway.
|
|
185
|
+
*
|
|
186
|
+
* The dialog is closed *before* the message is dispatched, and the order
|
|
187
|
+
* is load-bearing. The retry hits the same gate, so the approval prompt
|
|
188
|
+
* opens another `ctx.ui.custom` — stacked on a still-open settings
|
|
189
|
+
* dialog, that orphans this one: it stops rendering but keeps consuming
|
|
190
|
+
* editor input, and the composer never submits again. Observed live
|
|
191
|
+
* twice; the only escape was restarting pi.
|
|
192
|
+
*/
|
|
193
|
+
const allowRetry = (denial: DenialSummary): void => {
|
|
194
|
+
closeSettings?.();
|
|
195
|
+
overrides.allowRetry(denial, reviewer.lastEvidenceKey);
|
|
196
|
+
ctx.ui.notify("Override added for the exact command; the agent may retry it.", "info");
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
|
200
|
+
closeSettings = () => done(undefined);
|
|
201
|
+
const host: SubmenuHost = {
|
|
202
|
+
getSettings: () => settings,
|
|
203
|
+
commit,
|
|
204
|
+
availableModels: () => ctx.modelRegistry.getAvailable() as MenuModel[],
|
|
205
|
+
requestRender: () => tui.requestRender(),
|
|
206
|
+
settingsTheme: getSettingsListTheme(),
|
|
207
|
+
selectTheme: getSelectListTheme(),
|
|
208
|
+
};
|
|
209
|
+
const denialsHost: DenialsSubmenuHost = {
|
|
210
|
+
recentDenials,
|
|
211
|
+
allowRetry,
|
|
212
|
+
settingsTheme: getSettingsListTheme(),
|
|
213
|
+
selectTheme: getSelectListTheme(),
|
|
214
|
+
requestRender: () => tui.requestRender(),
|
|
215
|
+
};
|
|
216
|
+
const standingHost: StandingApprovalsSubmenuHost = {
|
|
217
|
+
standingApprovals: standingApprovalSummaries,
|
|
218
|
+
revokeStandingApproval: revokeApproval,
|
|
219
|
+
settingsTheme: getSettingsListTheme(),
|
|
220
|
+
selectTheme: getSelectListTheme(),
|
|
221
|
+
requestRender: () => tui.requestRender(),
|
|
222
|
+
};
|
|
223
|
+
const list = new SettingsList(
|
|
224
|
+
buildSettingItems(
|
|
225
|
+
settings,
|
|
226
|
+
{
|
|
227
|
+
reviewerModel: createModelSubmenu(host),
|
|
228
|
+
timeout: createTimeoutSubmenu(host),
|
|
229
|
+
...(denialLog.enabled ? { recentDenials: createDenialsSubmenu(denialsHost) } : {}),
|
|
230
|
+
...(standingApprovalsConfig.enabled
|
|
231
|
+
? { standingApprovals: createStandingApprovalsSubmenu(standingHost) }
|
|
232
|
+
: {}),
|
|
233
|
+
},
|
|
234
|
+
undefined,
|
|
235
|
+
denialLog.enabled ? recentDenials().length : undefined,
|
|
236
|
+
standingApprovalsConfig.enabled ? standingApprovalSummaries().length : undefined,
|
|
237
|
+
),
|
|
238
|
+
10,
|
|
239
|
+
host.settingsTheme,
|
|
240
|
+
(id, value) => {
|
|
241
|
+
commit(applySettingChange(settings, id, value, reviewerModel()));
|
|
242
|
+
tui.requestRender();
|
|
243
|
+
},
|
|
244
|
+
() => done(undefined),
|
|
245
|
+
{ enableSearch: true },
|
|
246
|
+
);
|
|
247
|
+
return {
|
|
248
|
+
invalidate(): void {
|
|
249
|
+
list.invalidate();
|
|
250
|
+
},
|
|
251
|
+
handleInput(data: string): void {
|
|
252
|
+
list.handleInput(data);
|
|
253
|
+
},
|
|
254
|
+
render(width: number): string[] {
|
|
255
|
+
return list.render(width);
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
package/settings-menu.ts
CHANGED
|
@@ -29,11 +29,11 @@ import {
|
|
|
29
29
|
import type { ReviewerBlock } from "./config-writer.js";
|
|
30
30
|
import type { StandingApprovalRecord } from "./standing-overrides.js";
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
const ENABLED_ID = "enabled";
|
|
33
|
+
const REVIEWER_MODEL_ID = "reviewerModel";
|
|
34
|
+
const THINKING_LEVEL_ID = "reasoningEffort";
|
|
35
|
+
const TIMEOUT_ID = "timeoutMs";
|
|
36
|
+
const SYSTEM_PROMPT_ID = "systemPrompt";
|
|
37
37
|
export const RECENT_DENIALS_ID = "recentDenials";
|
|
38
38
|
export const STANDING_APPROVALS_ID = "standingApprovals";
|
|
39
39
|
|
|
@@ -41,7 +41,7 @@ export const ON = "on";
|
|
|
41
41
|
export const OFF = "off";
|
|
42
42
|
const TOGGLE_VALUES = [ON, OFF];
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
const UNSET_MODEL = "(unset)";
|
|
45
45
|
const UNAVAILABLE_SUFFIX = "(configured, unavailable)";
|
|
46
46
|
const NO_REVIEWER_MESSAGE = "Select a reviewer model first";
|
|
47
47
|
|
|
@@ -66,13 +66,13 @@ export function modelKey(model: { provider: string; id: string }): string {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** Inverse of {@link modelKey}; the provider itself never contains a slash. */
|
|
69
|
-
|
|
69
|
+
function parseModelKey(value: string): { provider: string; model: string } | undefined {
|
|
70
70
|
const slash = value.indexOf("/");
|
|
71
71
|
if (slash <= 0 || slash === value.length - 1) return undefined;
|
|
72
72
|
return { provider: value.slice(0, slash), model: value.slice(slash + 1) };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
function reviewerValue(settings: ReviewerSettings): string {
|
|
76
76
|
return settings.reviewer ? modelKey({ provider: settings.reviewer.provider, id: settings.reviewer.model }) : UNSET_MODEL;
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -80,11 +80,11 @@ export function toggleValue(enabled: boolean): string {
|
|
|
80
80
|
return enabled ? ON : OFF;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
function thinkingValue(settings: ReviewerSettings): ReasoningEffort {
|
|
84
84
|
return settings.reviewer?.reasoningEffort ?? DEFAULT_REVIEWER_REASONING_EFFORT;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
function timeoutValue(settings: ReviewerSettings): number {
|
|
88
88
|
return settings.reviewer?.timeoutMs ?? DEFAULT_REVIEWER_TIMEOUT_MS;
|
|
89
89
|
}
|
|
90
90
|
|
|
@@ -117,7 +117,7 @@ export function formatTimeout(timeoutMs: number): string {
|
|
|
117
117
|
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
type TimeoutParse = { timeoutMs: number } | { error: string };
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
123
|
* Parse a timeout the user typed. A bare number means seconds, because that is
|
package/standing-overrides.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { dirname } from "node:path";
|
|
|
13
13
|
import { pid } from "node:process";
|
|
14
14
|
import { OVERRIDE_ANCHOR_START, type PermissionOverride } from "./override-evidence.js";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
const STANDING_APPROVAL_SCOPE = "comparable" as const;
|
|
17
17
|
export const STANDING_APPROVAL_LIMIT = 200;
|
|
18
18
|
const STALE_LEDGER_LOCK_MS = 60_000;
|
|
19
19
|
|
|
@@ -30,7 +30,7 @@ export interface StandingApprovalRecord {
|
|
|
30
30
|
reason: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
interface StandingApprovalInput {
|
|
34
34
|
gate: StandingApprovalRecord["gate"];
|
|
35
35
|
command: string;
|
|
36
36
|
project: string;
|
|
@@ -200,7 +200,7 @@ export function appendStandingApproval(
|
|
|
200
200
|
});
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
function standingApprovalToPermissionOverride(
|
|
204
204
|
record: StandingApprovalRecord,
|
|
205
205
|
seq: number,
|
|
206
206
|
anchorKey: string = OVERRIDE_ANCHOR_START,
|
package/usage-log.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname } from "node:path";
|
|
|
5
5
|
/** Rotate once the sidecar grows past this size; one previous generation is kept. */
|
|
6
6
|
export const USAGE_LOG_ROTATE_BYTES = 16 * 1024 * 1024;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
interface UsageLogTotals {
|
|
9
9
|
input: number;
|
|
10
10
|
output: number;
|
|
11
11
|
cacheRead: number;
|
package/widget-status.ts
CHANGED
|
@@ -12,9 +12,9 @@ export type ReviewDisplayState =
|
|
|
12
12
|
| "ask_user"
|
|
13
13
|
| "blocked";
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
type ReviewStatusTone = "muted" | "warning" | "success" | "accent" | "error";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
interface ReviewStatusFrame {
|
|
18
18
|
glyph: string;
|
|
19
19
|
label: string;
|
|
20
20
|
tone: ReviewStatusTone;
|