@hank-warren/pi-auto-permissions 0.16.2 → 0.18.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.
@@ -1,87 +1,53 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { AutoPermissionsConfig } from "./config.js";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
2
  import type { PromptChoiceClassification } from "./evaluation-log.js";
4
- import type { PermissionOverride } from "./override-evidence.js";
5
- import type { ReviewScope } from "./review-scope.js";
3
+ import type { Gate } from "./gates.js";
4
+ import { PERMISSION_OVERRIDE_CHOICES, type PermissionOverride } from "./override-evidence.js";
6
5
  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
6
 
14
7
  /**
15
- * Overrides persist as custom session entries the way the loop budget does,
8
+ * Overrides persist as custom session entries,
16
9
  * so a resumed session keeps the user's prompt decisions instead of
17
10
  * forgetting every allow and standing block constraint.
18
11
  */
19
12
  const OVERRIDES_ENTRY_TYPE = "auto-permissions-overrides";
20
13
 
21
14
  export interface SessionOverrides {
22
- /** Commands a `request_override` grant cleared for the rest of this session. */
23
- readonly allowedConventionCommands: ReadonlySet<string>;
24
15
  /** The override records, live, for merging into reviewer evidence. */
25
16
  list(): readonly PermissionOverride[];
26
- allowConvention(command: string): void;
27
- activateOverrideTool(): void;
28
- reconcileOverrideTool(): void;
29
- persist(): void;
30
17
  restore(branch: readonly unknown[]): void;
31
- removeStanding(matches: (override: PermissionOverride) => boolean): void;
32
- loadStanding(config: AutoPermissionsConfig, ctx: ExtensionContext): void;
33
18
  recordPromptDecision(
34
- scope: ReviewScope,
19
+ gate: Pick<Gate, "label">,
20
+ command: string,
35
21
  classification: PromptChoiceClassification,
36
22
  detail: string,
37
23
  anchorKey: string | undefined,
38
24
  ): void;
39
25
  allowRetry(denial: DenialSummary, anchorKey: string | undefined): void;
40
- resetForSession(): void;
41
26
  }
42
27
 
43
28
  /**
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.
29
+ * The user's own permission decisions for this session: the override records
30
+ * the guardian is shown as user-source evidence.
47
31
  *
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.
32
+ * One owner, because the pieces are one fact seen two ways — the in-memory
33
+ * record and the session entry that survives a resume.
51
34
  */
52
35
  export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
53
- const allowedConventionCommands = new Set<string>();
54
36
  const permissionOverrides: PermissionOverride[] = [];
55
37
  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
38
 
71
39
  function persist(): void {
72
40
  try {
73
41
  pi.appendEntry(OVERRIDES_ENTRY_TYPE, {
74
42
  seq: overrideSeq,
75
- overrides: permissionOverrides
76
- .filter((override) => !override.standing)
77
- .map((override) => ({ ...override })),
43
+ overrides: permissionOverrides.map((override) => ({ ...override })),
78
44
  });
79
45
  } catch {
80
46
  // Persistence is best-effort; the in-memory records still apply now.
81
47
  }
82
48
  }
83
49
 
84
- /** Fail-open like the loop budget: unreadable state means no overrides. */
50
+ /** Fail-open: unreadable state means no overrides. */
85
51
  function restoreOverrides(entries: readonly unknown[]): void {
86
52
  permissionOverrides.length = 0;
87
53
  overrideSeq = 0;
@@ -98,7 +64,7 @@ export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
98
64
  || typeof candidate.gateLabel !== "string"
99
65
  || typeof candidate.command !== "string"
100
66
  || typeof candidate.reviewerReason !== "string"
101
- || !["allow", "allow_unnecessary", "allow_appropriate", "block"].includes(candidate.choice)
67
+ || !PERMISSION_OVERRIDE_CHOICES.includes(candidate.choice)
102
68
  ) {
103
69
  continue;
104
70
  }
@@ -119,52 +85,24 @@ export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
119
85
  }
120
86
  }
121
87
 
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);
88
+ function addOverride(
89
+ gateLabel: string,
90
+ command: string,
91
+ reviewerReason: string,
92
+ choice: PermissionOverride["choice"],
93
+ anchorKey: string | undefined,
94
+ ): void {
95
+ permissionOverrides.push({ seq: overrideSeq++, anchorKey, gateLabel, command, reviewerReason, choice });
96
+ persist();
126
97
  }
127
98
 
128
99
  return {
129
- allowedConventionCommands,
130
100
  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
101
 
142
102
  restore(branch: readonly unknown[]): void {
143
103
  // Restore prompt decisions from the branch: a resumed session keeps its
144
- // allows and standing block constraints (parity with the loop budget).
104
+ // allows and standing block constraints.
145
105
  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
106
  },
169
107
 
170
108
  /**
@@ -173,56 +111,15 @@ export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
173
111
  * a guardian judgment, which is the one case this may be called for.
174
112
  */
175
113
  recordPromptDecision(
176
- scope: ReviewScope,
114
+ gate: Pick<Gate, "label">,
115
+ command: string,
177
116
  classification: PromptChoiceClassification,
178
117
  detail: string,
179
118
  anchorKey: string | undefined,
180
119
  ): void {
181
- const { ctx, config, gate, command } = scope;
182
120
  const overrideChoice = classification.userChoice ?? (classification.allowsExecution ? "allow" as const : undefined);
183
121
  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();
122
+ addOverride(gate.label, command, detail, overrideChoice, anchorKey);
226
123
  },
227
124
 
228
125
  /**
@@ -233,15 +130,7 @@ export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
233
130
  * may retry. No new authorization pathway.
234
131
  */
235
132
  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();
133
+ addOverride(denial.gateLabel, denial.command, denial.reason, "allow", anchorKey);
245
134
  try {
246
135
  // The same channel a prompt note uses. This is a real user decision
247
136
  // made in the menu, so a user message is honest provenance — and it
@@ -253,12 +142,5 @@ export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
253
142
  // The override itself is already in force; the nudge is best-effort.
254
143
  }
255
144
  },
256
-
257
- resetForSession(): void {
258
- overrideToolActivated = false;
259
- reconcileOverrideTool();
260
- standingApprovalCapNotified = false;
261
- allowedConventionCommands.clear();
262
- },
263
145
  };
264
146
  }
@@ -18,18 +18,14 @@ import {
18
18
  buildSettingItems,
19
19
  createDenialsSubmenu,
20
20
  createModelSubmenu,
21
- createStandingApprovalsSubmenu,
22
21
  createTimeoutSubmenu,
23
22
  type DenialsSubmenuHost,
24
23
  type DenialSummary,
25
- type StandingApprovalsSubmenuHost,
26
- type StandingApprovalSummary,
27
24
  type MenuModel,
28
25
  type ReviewerSettings,
29
26
  type SettingChange,
30
27
  type SubmenuHost,
31
28
  } from "./settings-menu.js";
32
- import { readStandingApprovals, revokeStandingApproval } from "./standing-overrides.js";
33
29
 
34
30
  /**
35
31
  * What `/auto-permissions setup` says on the user's behalf. Named after the
@@ -44,8 +40,8 @@ export function registerSettingsCommand(
44
40
  { overrides, reviewer }: { overrides: SessionOverrides; reviewer: GuardianReviewer },
45
41
  ): void {
46
42
  /**
47
- * `/auto-permissions`: edit the reviewer model, thinking level, timeout and
48
- * the enabled flag without hand-editing config.json.
43
+ * `/auto-permissions`: edit the reviewer model, thinking level, timeout,
44
+ * parallel reviews and the enabled flag without hand-editing config.json.
49
45
  *
50
46
  * There is no live-apply step: every tool call re-reads the config through
51
47
  * currentConfig(), so a saved edit is in force immediately -- in this session
@@ -75,16 +71,15 @@ export function registerSettingsCommand(
75
71
  const path = autoPermissionsConfigPath();
76
72
  let settings: ReviewerSettings;
77
73
  let denialLog: AutoPermissionsConfig["denialLog"];
78
- let standingApprovalsConfig: AutoPermissionsConfig["standingApprovals"];
79
74
  try {
80
75
  const config = loadAutoPermissionsConfig(path);
81
76
  settings = {
82
77
  enabled: config.enabled,
83
78
  reviewer: config.reviewer,
79
+ reviewConcurrency: config.reviewConcurrency,
84
80
  systemPromptSource: config.systemPromptSource,
85
81
  };
86
82
  denialLog = config.denialLog;
87
- standingApprovalsConfig = config.standingApprovals;
88
83
  } catch (error) {
89
84
  // Editing a file we cannot validate is the one way this writer could
90
85
  // quietly make things worse, so refuse rather than open.
@@ -103,7 +98,14 @@ export function registerSettingsCommand(
103
98
  const previous = settings;
104
99
  settings = change.settings;
105
100
  try {
106
- patchAutoPermissionsConfig(path, { enabled: settings.enabled, reviewer: settings.reviewer });
101
+ patchAutoPermissionsConfig(path, {
102
+ enabled: settings.enabled,
103
+ reviewer: settings.reviewer,
104
+ // Only when this edit changed it, so a hand-written value is never rewritten by an unrelated save.
105
+ ...(settings.reviewConcurrency !== previous.reviewConcurrency
106
+ ? { reviewConcurrency: settings.reviewConcurrency }
107
+ : {}),
108
+ });
107
109
  } catch (error) {
108
110
  settings = previous;
109
111
  const message = error instanceof Error ? error.message : String(error);
@@ -142,34 +144,6 @@ export function registerSettingsCommand(
142
144
  }
143
145
  };
144
146
 
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
147
  /**
174
148
  * Closes the settings dialog. Assigned when `ctx.ui.custom` builds the
175
149
  * component, so it is only ever called from inside the dialog it closes.
@@ -213,13 +187,6 @@ export function registerSettingsCommand(
213
187
  selectTheme: getSelectListTheme(),
214
188
  requestRender: () => tui.requestRender(),
215
189
  };
216
- const standingHost: StandingApprovalsSubmenuHost = {
217
- standingApprovals: standingApprovalSummaries,
218
- revokeStandingApproval: revokeApproval,
219
- settingsTheme: getSettingsListTheme(),
220
- selectTheme: getSelectListTheme(),
221
- requestRender: () => tui.requestRender(),
222
- };
223
190
  const list = new SettingsList(
224
191
  buildSettingItems(
225
192
  settings,
@@ -227,13 +194,9 @@ export function registerSettingsCommand(
227
194
  reviewerModel: createModelSubmenu(host),
228
195
  timeout: createTimeoutSubmenu(host),
229
196
  ...(denialLog.enabled ? { recentDenials: createDenialsSubmenu(denialsHost) } : {}),
230
- ...(standingApprovalsConfig.enabled
231
- ? { standingApprovals: createStandingApprovalsSubmenu(standingHost) }
232
- : {}),
233
197
  },
234
198
  undefined,
235
199
  denialLog.enabled ? recentDenials().length : undefined,
236
- standingApprovalsConfig.enabled ? standingApprovalSummaries().length : undefined,
237
200
  ),
238
201
  10,
239
202
  host.settingsTheme,
package/settings-menu.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * Row model and pure edit logic behind `/auto-permissions`.
3
3
  *
4
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.
5
+ * free of pi's runtime, so it is testable without a TUI. settings-command.ts owns
6
+ * only the wiring: read config, render a SettingsList, persist through config-writer.
7
7
  */
8
8
  import { homedir } from "node:os";
9
9
  import {
@@ -20,22 +20,22 @@ import {
20
20
  import {
21
21
  DEFAULT_REVIEWER_REASONING_EFFORT,
22
22
  DEFAULT_REVIEWER_TIMEOUT_MS,
23
+ MAX_REVIEW_CONCURRENCY,
23
24
  MAX_REVIEWER_TIMEOUT_MS,
24
25
  MIN_REVIEWER_TIMEOUT_MS,
25
26
  REASONING_EFFORTS,
26
27
  type ReasoningEffort,
28
+ type ReviewerConfig,
27
29
  type SystemPromptSource,
28
30
  } from "./config.js";
29
- import type { ReviewerBlock } from "./config-writer.js";
30
- import type { StandingApprovalRecord } from "./standing-overrides.js";
31
31
 
32
32
  const ENABLED_ID = "enabled";
33
33
  const REVIEWER_MODEL_ID = "reviewerModel";
34
34
  const THINKING_LEVEL_ID = "reasoningEffort";
35
35
  const TIMEOUT_ID = "timeoutMs";
36
+ const REVIEW_CONCURRENCY_ID = "reviewConcurrency";
36
37
  const SYSTEM_PROMPT_ID = "systemPrompt";
37
38
  export const RECENT_DENIALS_ID = "recentDenials";
38
- export const STANDING_APPROVALS_ID = "standingApprovals";
39
39
 
40
40
  export const ON = "on";
41
41
  export const OFF = "off";
@@ -48,10 +48,21 @@ const NO_REVIEWER_MESSAGE = "Select a reviewer model first";
48
48
  /** The slice of the config the menu edits, plus the read-only prompt source. */
49
49
  export interface ReviewerSettings {
50
50
  enabled: boolean;
51
- reviewer?: ReviewerBlock;
51
+ reviewer?: ReviewerConfig;
52
+ reviewConcurrency: number;
52
53
  systemPromptSource: SystemPromptSource;
53
54
  }
54
55
 
56
+ const CONCURRENCY_PRESETS = [1, 2, 4, 8, 16];
57
+
58
+ /**
59
+ * The values the parallel-reviews row cycles through. A hand-written value
60
+ * outside the presets is kept in the cycle, so opening the menu never moves it.
61
+ */
62
+ export function concurrencyValues(current: number): string[] {
63
+ return [...new Set([...CONCURRENCY_PRESETS, current])].sort((a, b) => a - b).map(String);
64
+ }
65
+
55
66
  /** The subset of a pi model the menu needs; keeps tests free of pi-ai types. */
56
67
  export interface MenuModel {
57
68
  provider: string;
@@ -141,7 +152,6 @@ export interface SettingSubmenus {
141
152
  reviewerModel?: SettingItem["submenu"];
142
153
  timeout?: SettingItem["submenu"];
143
154
  recentDenials?: SettingItem["submenu"];
144
- standingApprovals?: SettingItem["submenu"];
145
155
  }
146
156
 
147
157
  /** Build the `/auto-permissions` rows for a settings snapshot. */
@@ -150,13 +160,12 @@ export function buildSettingItems(
150
160
  submenus: SettingSubmenus = {},
151
161
  home: string = homedir(),
152
162
  denialCount?: number,
153
- standingApprovalCount?: number,
154
163
  ): SettingItem[] {
155
164
  return [
156
165
  {
157
166
  id: ENABLED_ID,
158
167
  label: "Enabled",
159
- description: "Review guarded commands. Off lets every command run without guardian review.",
168
+ description: "Review guarded commands. Off disables all gating, including deny rules.",
160
169
  currentValue: toggleValue(settings.enabled),
161
170
  values: TOGGLE_VALUES,
162
171
  },
@@ -181,6 +190,13 @@ export function buildSettingItems(
181
190
  currentValue: formatTimeout(timeoutValue(settings)),
182
191
  ...(submenus.timeout ? { submenu: submenus.timeout } : {}),
183
192
  },
193
+ {
194
+ id: REVIEW_CONCURRENCY_ID,
195
+ label: "Parallel reviews",
196
+ description: "How many guardian reviews may run at once. 1 reviews one command at a time, none ahead of its turn.",
197
+ currentValue: String(settings.reviewConcurrency),
198
+ values: concurrencyValues(settings.reviewConcurrency),
199
+ },
184
200
  {
185
201
  id: SYSTEM_PROMPT_ID,
186
202
  label: "System prompt",
@@ -196,15 +212,6 @@ export function buildSettingItems(
196
212
  submenu: submenus.recentDenials,
197
213
  }]
198
214
  : []),
199
- ...(submenus.standingApprovals
200
- ? [{
201
- id: STANDING_APPROVALS_ID,
202
- label: "Standing approvals",
203
- description: "Comparable-command approvals shared across projects. Select one to revoke it.",
204
- currentValue: standingApprovalCount === undefined ? "" : String(standingApprovalCount),
205
- submenu: submenus.standingApprovals,
206
- }]
207
- : []),
208
215
  ];
209
216
  }
210
217
 
@@ -254,6 +261,15 @@ export function applySettingChange(
254
261
  return { kind: "settings", settings: next };
255
262
  }
256
263
 
264
+ if (id === REVIEW_CONCURRENCY_ID) {
265
+ const reviewConcurrency = Number(value);
266
+ if (!Number.isInteger(reviewConcurrency) || reviewConcurrency < 1 || reviewConcurrency > MAX_REVIEW_CONCURRENCY) {
267
+ return { kind: "error", message: `Parallel reviews must be a whole number from 1 to ${MAX_REVIEW_CONCURRENCY}` };
268
+ }
269
+ if (reviewConcurrency === settings.reviewConcurrency) return { kind: "ignored" };
270
+ return { kind: "settings", settings: { ...settings, reviewConcurrency } };
271
+ }
272
+
257
273
  // The model and timeout submenus commit their own edit and hand back display
258
274
  // text, so there is nothing left to map here.
259
275
  return { kind: "ignored" };
@@ -568,120 +584,6 @@ export function createDenialsSubmenu(host: DenialsSubmenuHost): NonNullable<Sett
568
584
  return (_currentValue, done) => new DenialsSubmenu(host, done);
569
585
  }
570
586
 
571
- export interface StandingApprovalSummary {
572
- id: string;
573
- record: StandingApprovalRecord;
574
- }
575
-
576
- export interface StandingApprovalsSubmenuHost {
577
- standingApprovals(): StandingApprovalSummary[];
578
- revokeStandingApproval(approval: StandingApprovalSummary): void;
579
- settingsTheme: SettingsListTheme;
580
- selectTheme: SelectListTheme;
581
- requestRender(): void;
582
- }
583
-
584
- export function standingApprovalItem(approval: StandingApprovalSummary): SelectItem {
585
- const command = approval.record.command.length > COMMAND_LABEL_MAX
586
- ? `${approval.record.command.slice(0, COMMAND_LABEL_MAX - 1)}…`
587
- : approval.record.command;
588
- return {
589
- value: approval.id,
590
- label: command,
591
- description: `${approval.record.gate.label} · granted ${approval.record.ts.slice(0, 10)} · ${approval.record.project}`,
592
- };
593
- }
594
-
595
- class StandingApprovalsSubmenu implements Component {
596
- private readonly approvals: StandingApprovalSummary[];
597
- private list: SelectList;
598
- private selected?: StandingApprovalSummary;
599
-
600
- constructor(
601
- private readonly host: StandingApprovalsSubmenuHost,
602
- private readonly done: (value?: string) => void,
603
- ) {
604
- this.approvals = host.standingApprovals();
605
- this.list = this.buildApprovalList();
606
- }
607
-
608
- private buildApprovalList(): SelectList {
609
- const items = this.approvals.length
610
- ? this.approvals.map(standingApprovalItem)
611
- : [{ value: "", label: "(no standing approvals)", description: "Comparable-command approvals will appear here." }];
612
- const list = new SelectList(items, 10, this.host.selectTheme);
613
- list.onCancel = () => this.done(undefined);
614
- list.onSelect = (item) => {
615
- const approval = this.approvals.find((candidate) => candidate.id === item.value);
616
- if (!approval) {
617
- this.done(undefined);
618
- return;
619
- }
620
- this.selected = approval;
621
- this.list = this.buildConfirmList();
622
- this.host.requestRender();
623
- };
624
- return list;
625
- }
626
-
627
- private buildConfirmList(): SelectList {
628
- const list = new SelectList(
629
- [
630
- {
631
- value: "revoke",
632
- label: "Revoke",
633
- description: "Removes this approval from the user-scoped standing ledger.",
634
- },
635
- { value: "back", label: "Back" },
636
- ],
637
- 10,
638
- this.host.selectTheme,
639
- );
640
- const back = () => {
641
- this.selected = undefined;
642
- this.list = this.buildApprovalList();
643
- this.host.requestRender();
644
- };
645
- list.onCancel = back;
646
- list.onSelect = (item) => {
647
- if (item.value !== "revoke" || !this.selected) {
648
- back();
649
- return;
650
- }
651
- this.host.revokeStandingApproval(this.selected);
652
- this.done(undefined);
653
- };
654
- return list;
655
- }
656
-
657
- invalidate(): void {
658
- this.list.invalidate();
659
- }
660
-
661
- render(width: number): string[] {
662
- const title = this.selected
663
- ? ` Revoke standing approval? ${truncateToWidth(this.selected.record.command, Math.max(1, width - 30))}`
664
- : " Standing approvals";
665
- return [
666
- truncateToWidth(this.host.settingsTheme.hint(title), width),
667
- "",
668
- ...this.list.render(width),
669
- "",
670
- truncateToWidth(this.host.settingsTheme.hint(" Enter to select · Esc to go back"), width),
671
- ];
672
- }
673
-
674
- handleInput(data: string): void {
675
- this.list.handleInput(data);
676
- }
677
- }
678
-
679
- export function createStandingApprovalsSubmenu(
680
- host: StandingApprovalsSubmenuHost,
681
- ): NonNullable<SettingItem["submenu"]> {
682
- return (_currentValue, done) => new StandingApprovalsSubmenu(host, done);
683
- }
684
-
685
587
  export function createTimeoutSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
686
588
  return (currentValue, done) =>
687
589
  new PromptComponent(
package/usage-log.ts CHANGED
@@ -1,9 +1,5 @@
1
- import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from "node:fs";
2
1
  import { randomUUID } from "node:crypto";
3
- import { dirname } from "node:path";
4
-
5
- /** Rotate once the sidecar grows past this size; one previous generation is kept. */
6
- export const USAGE_LOG_ROTATE_BYTES = 16 * 1024 * 1024;
2
+ import { appendJsonlRecord, SIDECAR_ROTATE_BYTES } from "./jsonl-sidecar.js";
7
3
 
8
4
  interface UsageLogTotals {
9
5
  input: number;
@@ -55,14 +51,13 @@ export function buildUsageLogRecord(
55
51
  model: string,
56
52
  usage: unknown,
57
53
  label = "guardian",
58
- source = "auto-permissions",
59
54
  subagent = false,
60
55
  ): UsageLogRecord {
61
56
  return {
62
57
  v: 1,
63
58
  id: randomUUID(),
64
59
  ts: new Date().toISOString(),
65
- source,
60
+ source: "auto-permissions",
66
61
  label,
67
62
  provider,
68
63
  model,
@@ -71,18 +66,6 @@ export function buildUsageLogRecord(
71
66
  };
72
67
  }
73
68
 
74
- function rotateIfLarge(path: string): void {
75
- try {
76
- if (statSync(path).size < USAGE_LOG_ROTATE_BYTES) return;
77
- renameSync(path, `${path}.1`);
78
- } catch {
79
- // A missing or unrotatable sidecar simply keeps appending.
80
- }
81
- }
82
-
83
69
  export function appendUsageRecord(path: string, record: UsageLogRecord): void {
84
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
85
- rotateIfLarge(path);
86
- appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
87
- chmodSync(path, 0o600);
70
+ appendJsonlRecord(path, record, SIDECAR_ROTATE_BYTES);
88
71
  }