@hank-warren/pi-auto-permissions 0.1.2 → 0.2.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/README.md CHANGED
@@ -210,6 +210,29 @@ Each version 2 record contains the collected user request, exact command, compac
210
210
 
211
211
  Logging is disabled by default. Records can contain sensitive conversation text and shell commands, so keep the file private and out of repositories. Cancelled or interrupted prompts are not labeled or logged.
212
212
 
213
+ ## Reviewer usage sidecar
214
+
215
+ Guardian reviews are model calls made outside the Pi agent loop, so they never appear in the session transcript and are invisible to tools that total usage from session files. Auto Permissions therefore appends one content-free record per completed review:
216
+
217
+ ```json
218
+ {"v":1,"id":"3f2b…","ts":"2026-08-11T22:41:03.118Z","source":"auto-permissions","label":"guardian","provider":"anthropic","model":"claude-fable-5","usage":{"input":812,"output":96,"cacheRead":18442,"cacheWrite":0,"reasoning":48,"cost":0.0121}}
219
+ ```
220
+
221
+ The record carries identity, timing, and counters only. It never contains prompts, commands, reviewer evidence, verdicts, or responses, which is what makes it safe to keep on by default:
222
+
223
+ ```json
224
+ {
225
+ "usageLog": {
226
+ "enabled": true,
227
+ "path": "./usage.jsonl"
228
+ }
229
+ }
230
+ ```
231
+
232
+ The path defaults to `usage.jsonl` beside the Auto Permissions config and resolves relative to it. The file is created with mode `0600` and rotates to `usage.jsonl.1` once it passes 16 MB, keeping one previous generation. Writing is best effort: a failing sidecar never blocks or changes a permission decision.
233
+
234
+ [`@hank-warren/pi-stats`](../pi-stats) reads `<agent dir>/<extension>/usage.jsonl` sidecars and shows this usage as its own `provider/model (guardian)` row. Set `"enabled": false` to stop recording.
235
+
213
236
  ## Review display
214
237
 
215
238
  The default UI shows guardian progress in a temporary widget below the editor. Configure it with:
package/config.ts CHANGED
@@ -27,6 +27,10 @@ export interface AutoPermissionsConfig {
27
27
  enabled: boolean;
28
28
  path: string;
29
29
  };
30
+ usageLog: {
31
+ enabled: boolean;
32
+ path: string;
33
+ };
30
34
  rules: Gate[];
31
35
  ui: {
32
36
  enabled: boolean;
@@ -147,6 +151,26 @@ function resolveEvaluationLog(
147
151
  };
148
152
  }
149
153
 
154
+ function resolveUsageLog(
155
+ raw: Record<string, unknown>,
156
+ configFilePath: string,
157
+ ): AutoPermissionsConfig["usageLog"] {
158
+ const defaultPath = resolve(dirname(configFilePath), "usage.jsonl");
159
+ if (raw.usageLog === undefined) return { enabled: true, path: defaultPath };
160
+ if (!raw.usageLog || typeof raw.usageLog !== "object" || Array.isArray(raw.usageLog)) {
161
+ throw new Error("usageLog must be an object");
162
+ }
163
+ const usageLog = raw.usageLog as Record<string, unknown>;
164
+ if (usageLog.enabled !== undefined && typeof usageLog.enabled !== "boolean") {
165
+ throw new Error("usageLog.enabled must be boolean");
166
+ }
167
+ const enabled = usageLog.enabled !== false;
168
+ const configuredPath = optionalString(usageLog.path, "usageLog.path");
169
+ if (!configuredPath) return { enabled, path: defaultPath };
170
+ const expanded = configuredPath.startsWith("~/") ? join(homedir(), configuredPath.slice(2)) : configuredPath;
171
+ return { enabled, path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded) };
172
+ }
173
+
150
174
  function resolveUi(raw: Record<string, unknown>): AutoPermissionsConfig["ui"] {
151
175
  if (raw.ui === undefined) return { enabled: true, resultDisplayMs: 2500, placement: "widget" };
152
176
  if (!raw.ui || typeof raw.ui !== "object" || Array.isArray(raw.ui)) {
@@ -199,6 +223,7 @@ export function loadAutoPermissionsConfig(path = configPath()): AutoPermissionsC
199
223
  systemPrompt: resolvePrompt(raw, path),
200
224
  reviewEvidence: resolveReviewEvidence(raw),
201
225
  evaluationLog: resolveEvaluationLog(raw, path),
226
+ usageLog: resolveUsageLog(raw, path),
202
227
  rules: customRules,
203
228
  ui: resolveUi(raw),
204
229
  };
package/index.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type PromptEvaluationUserChoice,
17
17
  } from "./evaluation-log.js";
18
18
  import { findGates, type Gate } from "./gates.js";
19
+ import { appendUsageRecord, buildUsageLogRecord } from "./usage-log.js";
19
20
  import {
20
21
  buildReviewEnvelope,
21
22
  collectReviewEvidence,
@@ -357,6 +358,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
357
358
  if (response.stopReason === "error") {
358
359
  throw new Error(response.errorMessage ?? "reviewer request failed");
359
360
  }
361
+ recordReviewerUsage(config, model, response.usage);
360
362
  const verdict = parsePermissionVerdict(assistantText(response.content));
361
363
  if (!sessionActive || signal?.aborted || reviewerGeneration !== attemptGeneration) {
362
364
  throw new Error("review timed out or was cancelled");
@@ -379,6 +381,19 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
379
381
  }
380
382
  }
381
383
 
384
+ /**
385
+ * Reviewer calls never reach the session transcript, so their usage is invisible to
386
+ * tooling that reads session files. Record content-free counters in a sidecar instead.
387
+ */
388
+ function recordReviewerUsage(config: AutoPermissionsConfig, model: { provider: string; id: string }, usage: unknown): void {
389
+ if (!config.usageLog.enabled) return;
390
+ try {
391
+ appendUsageRecord(config.usageLog.path, buildUsageLogRecord(model.provider, model.id, usage));
392
+ } catch {
393
+ // Usage accounting is optional and must never block a permission decision.
394
+ }
395
+ }
396
+
382
397
  function untrustedMatches(command: string, config: AutoPermissionsConfig): Gate[] {
383
398
  return findGates(command, config.rules).filter((gate) => !trustedGroups.has(gate.group));
384
399
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-auto-permissions",
3
- "version": "0.1.2",
3
+ "version": "0.2.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": [
@@ -43,6 +43,7 @@
43
43
  "evaluation-log.ts",
44
44
  "gates.ts",
45
45
  "review.ts",
46
+ "usage-log.ts",
46
47
  "README.md",
47
48
  "LICENSE"
48
49
  ],
package/usage-log.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ 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;
7
+
8
+ export interface UsageLogTotals {
9
+ input: number;
10
+ output: number;
11
+ cacheRead: number;
12
+ cacheWrite: number;
13
+ reasoning: number;
14
+ cost: number;
15
+ }
16
+
17
+ /**
18
+ * One model call recorded outside the Pi session transcript. The record is deliberately
19
+ * content free: it carries identity, timing, and counters only, never prompts, commands,
20
+ * evidence, or responses.
21
+ */
22
+ export interface UsageLogRecord {
23
+ v: 1;
24
+ id: string;
25
+ ts: string;
26
+ source: string;
27
+ label: string;
28
+ provider: string;
29
+ model: string;
30
+ usage: UsageLogTotals;
31
+ }
32
+
33
+ function count(value: unknown): number {
34
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
35
+ }
36
+
37
+ /** Normalize a pi-ai usage payload, whose cost is an object with a total. */
38
+ export function usageLogTotals(usage: unknown): UsageLogTotals {
39
+ const record = (usage && typeof usage === "object" ? usage : {}) as Record<string, unknown>;
40
+ const cost = (record.cost && typeof record.cost === "object" ? record.cost : {}) as Record<string, unknown>;
41
+ return {
42
+ input: count(record.input),
43
+ output: count(record.output),
44
+ cacheRead: count(record.cacheRead),
45
+ cacheWrite: count(record.cacheWrite),
46
+ reasoning: count(record.reasoning),
47
+ cost: count(typeof record.cost === "number" ? record.cost : cost.total),
48
+ };
49
+ }
50
+
51
+ export function buildUsageLogRecord(
52
+ provider: string,
53
+ model: string,
54
+ usage: unknown,
55
+ label = "guardian",
56
+ source = "auto-permissions",
57
+ ): UsageLogRecord {
58
+ return {
59
+ v: 1,
60
+ id: randomUUID(),
61
+ ts: new Date().toISOString(),
62
+ source,
63
+ label,
64
+ provider,
65
+ model,
66
+ usage: usageLogTotals(usage),
67
+ };
68
+ }
69
+
70
+ function rotateIfLarge(path: string): void {
71
+ try {
72
+ if (statSync(path).size < USAGE_LOG_ROTATE_BYTES) return;
73
+ renameSync(path, `${path}.1`);
74
+ } catch {
75
+ // A missing or unrotatable sidecar simply keeps appending.
76
+ }
77
+ }
78
+
79
+ export function appendUsageRecord(path: string, record: UsageLogRecord): void {
80
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
81
+ rotateIfLarge(path);
82
+ appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
83
+ chmodSync(path, 0o600);
84
+ }