@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 +23 -0
- package/config.ts +25 -0
- package/index.ts +15 -0
- package/package.json +2 -1
- package/usage-log.ts +84 -0
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.
|
|
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
|
+
}
|