@czottmann/pi-automode 1.5.0 → 1.7.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
@@ -35,7 +35,7 @@ pi -e ./extensions/auto-mode.ts
35
35
  /automode reload # reload config from disk
36
36
  /automode reset # reset denial counters only
37
37
  /automode defaults # print the built-in rule lists
38
- /automode config # current effective config + diagnostics
38
+ /automode config # effective config, resolved log file path, + diagnostics
39
39
  /automode denials # denial history for this session
40
40
  /automode model # open classifier model selector and save to ~/.pi/agent/automode.json
41
41
  /automode model provider/model-id # save classifier model to ~/.pi/agent/automode.json
@@ -43,10 +43,24 @@ pi -e ./extensions/auto-mode.ts
43
43
 
44
44
  `/auto-mode` is an alias.
45
45
 
46
+ ## Status line
47
+
48
+ When the Pi TUI is available, the extension renders a persistent status line:
49
+
50
+ ```text
51
+ AM● a:12 d:2 ca:5 cd:1
52
+ ```
53
+
54
+ - `AM` — auto-mode prefix; `●` when enabled, `○` when disabled (via config or `/automode off`).
55
+ - `a:` — actions allowed so far (checked minus denied).
56
+ - `d:` — actions denied so far, for any reason (permission rule, deterministic hard-deny, or classifier).
57
+ - `ca:` / `cd:` — classifier decisions split into allowed vs denied. These segments appear only after the classifier has run at least once; `d:` counts all denials, so `d:` is always `>= cd:`.
58
+
46
59
  ## Docs
47
60
 
48
61
  - [Defaults and rule-list behavior](docs/defaults.md)
49
62
  - [Auto-mode classifier flow](docs/automode-classifier-flow.md)
63
+ - [Observability logging](docs/observability-logging.md)
50
64
 
51
65
  ## Configuration
52
66
 
@@ -60,6 +74,18 @@ It reads `autoMode` from Pi-owned config only:
60
74
 
61
75
  It deliberately does not read `autoMode` from shared project `.pi/automode.json`, because a checked-in repo should not be able to weaken auto-mode rules. Shared project config may still contribute `permissions.deny` and `permissions.ask`.
62
76
 
77
+ To disable pi-automode for the current project, create or edit `.pi/automode.local.json`:
78
+
79
+ ```json
80
+ {
81
+ "autoMode": {
82
+ "enabled": false
83
+ }
84
+ }
85
+ ```
86
+
87
+ This is project-local and should not be committed. Shared project `.pi/automode.json` cannot disable auto-mode.
88
+
63
89
  Set a global default classifier model in `~/.pi/agent/automode.json`; override it per project in `.pi/automode.local.json`.
64
90
 
65
91
  Example:
@@ -94,6 +120,23 @@ Example:
94
120
 
95
121
  See [Defaults and rule-list behavior](docs/defaults.md) for built-in `environment`, `allow`, `protectedPaths`, `soft_deny`, and `hard_deny` entries, plus replacement behavior when `$defaults` is omitted.
96
122
 
123
+ ### Observability logging
124
+
125
+ Auto mode can write a JSONL decision log next to the current Pi session file, so you can see what it allowed or blocked and how. It is off by default.
126
+
127
+ ```json
128
+ {
129
+ "autoMode": {
130
+ "log": {
131
+ "enabled": true,
132
+ "classifierIo": false
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ See [Observability logging](docs/observability-logging.md) for the log file location, entry schema, and the `classifierIo` privacy tradeoff. Run `/automode config` to see the resolved log file path.
139
+
97
140
  ### Permission patterns
98
141
 
99
142
  Permission patterns use Pi tool names, for example `bash(...)`, `write(...)`, `edit(...)`, `read(...)`. The parser accepts capitalized names like `Bash(...)` for convenience, but the documented form is lowercase because Pi tool names are lowercase.
@@ -80,6 +80,18 @@ The effective config combines these sources:
80
80
 
81
81
  Shared project `.pi/automode.json` cannot change `autoMode` rules. That is deliberate: a checked-in repo must not be able to weaken auto-mode. It may still add Pi permission rules.
82
82
 
83
+ To disable pi-automode for the current project, set `autoMode.enabled` to `false` in `.pi/automode.local.json`:
84
+
85
+ ```json
86
+ {
87
+ "autoMode": {
88
+ "enabled": false
89
+ }
90
+ }
91
+ ```
92
+
93
+ This affects only that project-local config. Shared project `.pi/automode.json` cannot disable auto-mode.
94
+
83
95
  List settings such as `allow`, `soft_deny`, `hard_deny`, `environment`, and `protectedPaths` support `$defaults`. Omitting `$defaults` replaces the built-ins for that section only. See [Defaults and rule-list behavior](defaults.md).
84
96
 
85
97
  ## Context captured before classification
@@ -157,7 +169,7 @@ At the moment, all non-read-only actions reach the classifier anyway, so the pro
157
169
 
158
170
  The classifier call is made by `defaultClassifyAction`.
159
171
 
160
- The model receives a system prompt and one user message.
172
+ The model receives a system prompt and one user message. To inspect exactly what's sent on each call — and the model's raw response — enable `autoMode.log.classifierIo`; see [Observability logging](observability-logging.md).
161
173
 
162
174
  ### System prompt
163
175
 
@@ -0,0 +1,93 @@
1
+ # Observability logging
2
+
3
+ Auto mode can write a JSONL decision log next to the current Pi session file, so you can see what it allowed or blocked, and how. It is off by default and fail-open: a write error never changes an allow/block decision.
4
+
5
+ ## Enabling
6
+
7
+ Set `autoMode.log` in any Pi-owned config source (`~/.pi/agent/automode.json`, `.pi/automode.local.json`, or `PI_AUTOMODE_SETTINGS_JSON`):
8
+
9
+ ```json
10
+ {
11
+ "autoMode": {
12
+ "log": {
13
+ "enabled": true,
14
+ "classifierIo": false
15
+ }
16
+ }
17
+ }
18
+ ```
19
+
20
+ - `enabled` — write one `decision` line per tool-call decision.
21
+ - `classifierIo` — also write the classifier's prompt, raw model responses, and parsed decision for classifier-routed actions. Off by default; see [Privacy](#privacy) below.
22
+
23
+ Fields merge independently across config scopes (set `enabled` globally and `classifierIo` per project, for example). Shared project `.pi/automode.json` cannot set `log` — it follows the same `autoMode` exclusion as the rest of the config. Shape is validated and reported by `/automode config`.
24
+
25
+ Logging only writes entries while auto-mode is **enabled**. With auto-mode off, no tool calls reach the hook, so no entries are produced.
26
+
27
+ ## Log file location
28
+
29
+ The log file is colocated with the current Pi session file, with `-pi-automode` inserted before the extension:
30
+
31
+ ```text
32
+ <session-file> → <dir>/<id>.jsonl
33
+ <session-file>-pi-automode.jsonl → <dir>/<id>-pi-automode.jsonl
34
+ ```
35
+
36
+ For example: `~/.pi/agent/sessions/<slug>/<id>-pi-automode.jsonl`. If no session file is set, it falls back to `<sessionDir>/<sessionId>-pi-automode.jsonl`. Run `/automode config` to see the resolved path.
37
+
38
+ There is one combined file per session. Each line is one JSON object with a `type` discriminator. Entries for the same tool call share a `decisionId`.
39
+
40
+ ## Entry types
41
+
42
+ ### `decision`
43
+
44
+ One per tool-call decision. Every allow and every block goes through exactly one of these.
45
+
46
+ | field | meaning |
47
+ | --- | --- |
48
+ | `ts` | ISO timestamp |
49
+ | `decisionId` | links to the `classifier` entry (if any) for the same call |
50
+ | `sessionId` | Pi session id |
51
+ | `cwd` | working directory |
52
+ | `tool` | tool name, e.g. `bash`, `write` |
53
+ | `summary` | `actionSummary` — tool name + input JSON (truncated) |
54
+ | `kind` | enforcement path: `permissions.deny`, `permissions.ask`, `deterministic-hard-deny`, `classifier`, or `read-only` |
55
+ | `outcome` | `allow` or `block` |
56
+ | `reason` | the reason string (classifier reason, or the deterministic/permission reason) |
57
+ | `classifierModel` | the configured classifier model, when relevant |
58
+
59
+ ### `classifier`
60
+
61
+ Written only for classifier-routed actions, and only when `classifierIo: true`. It precedes the matching `decision` line in the file.
62
+
63
+ | field | meaning |
64
+ | --- | --- |
65
+ | `ts` | ISO timestamp |
66
+ | `decisionId` | matches the `decision` entry for the same call |
67
+ | `model` | classifier model used, e.g. `anthropic/claude-haiku-4` |
68
+ | `prompt.system` | the full system prompt with `environment`/`allow`/`soft_deny`/`hard_deny` rules interpolated |
69
+ | `prompt.user` | the full user message: loaded project instructions + transcript + action |
70
+ | `attempts` | one entry per classifier attempt (see below) |
71
+ | `durationMs` | total classifier time |
72
+ | `parsed` | the final decision that was acted on (`{ decision, tier, reason }`) |
73
+
74
+ Each `attempts[]` entry is `{ attempt, response?, parsed?, error?, durationMs }`:
75
+
76
+ - `response` — `{ stopReason, text }`, the raw model output for that attempt.
77
+ - `parsed` — the decision parsed from the response, or absent if it did not parse.
78
+ - `error` — present when the call threw (network/auth); `response` is then absent.
79
+
80
+ This records retries and fail-closed cases verbatim: a call that returned garbage, retried, then succeeded shows two attempts with the first `parsed` absent.
81
+
82
+ ## Privacy
83
+
84
+ `prompt.user` is **exactly what is sent to the classifier model** — loaded project instructions, the recent transcript, and the action being classified. See [Auto-mode classifier flow → What is sent to the classifier](automode-classifier-flow.md#what-is-sent-to-the-classifier) for how that message is assembled and truncated.
85
+
86
+ The log records this payload locally, but the same data is also sent to the classifier model endpoint on every classifier-routed call. If `classifierModel` points at a cloud provider, that payload leaves the machine. Enable `classifierIo` when debugging classifier behavior or tuning rules; leave it off for routine outcome logging.
87
+
88
+ ## Sizing
89
+
90
+ - `decision` line: ~0.4–2 KB (driven by `summary`, which carries the tool input, capped at 6 KB).
91
+ - `classifier` line: ~5 KB fixed (the system prompt) plus the transcript, which is variable. In a long session the transcript dominates — roughly 30–40 KB per classifier-routed action.
92
+
93
+ The transcript is bounded by `autoMode.maxTranscriptLines` (default 80). Lowering it shrinks both the `classifier` log line and what the classifier receives, at the cost of less context for the classifier's decision.
@@ -6,11 +6,13 @@ import type {
6
6
  } from "@earendil-works/pi-ai";
7
7
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { CLASSIFIER_SYSTEM_PROMPT } from "./constants.ts";
9
- import { parseModelSpec } from "./model.ts";
9
+ import { formatModelSpec, parseModelSpec } from "./model.ts";
10
10
  import { buildTranscript } from "./transcript.ts";
11
11
  import type {
12
12
  ClassificationDecision,
13
13
  ClassifyAction,
14
+ ClassifierIoAttempt,
15
+ ClassifyResult,
14
16
  EffectiveConfig,
15
17
  } from "./types.ts";
16
18
 
@@ -71,19 +73,26 @@ export type RetryOptions = {
71
73
  maxAttempts?: number;
72
74
  maxTokens?: number;
73
75
  temperature?: number;
76
+ /** Receives each attempt's raw response (or error) and parsed decision, for observability logging. */
77
+ onAttempt?: (attempt: ClassifierIoAttempt) => void;
74
78
  };
75
79
 
76
- /** Parse the classifier's JSON-only response. Invalid output is handled fail-closed by the caller. */
77
- export function parseClassifierDecision(
78
- message: AssistantMessage,
79
- ): ClassificationDecision | undefined {
80
- const text = message.content
80
+ /** Concatenate all text blocks of an assistant message into a single string. */
81
+ function extractAssistantText(message: AssistantMessage): string {
82
+ return message.content
81
83
  .filter(
82
84
  (block): block is { type: "text"; text: string } => block.type === "text",
83
85
  )
84
86
  .map((block) => block.text)
85
87
  .join("\n")
86
88
  .trim();
89
+ }
90
+
91
+ /** Parse the classifier's JSON-only response. Invalid output is handled fail-closed by the caller. */
92
+ export function parseClassifierDecision(
93
+ message: AssistantMessage,
94
+ ): ClassificationDecision | undefined {
95
+ const text = extractAssistantText(message);
87
96
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
88
97
  const candidates = [fenced, text, text.match(/\{[\s\S]*\}/)?.[0]].filter(
89
98
  Boolean,
@@ -133,9 +142,11 @@ export async function classifyWithRetry(
133
142
  const maxAttempts = options.maxAttempts ?? 2;
134
143
  const maxTokens = options.maxTokens ?? 1200;
135
144
  const temperature = options.temperature ?? 0;
145
+ const onAttempt = options.onAttempt;
136
146
  let lastReason =
137
147
  "Classifier response was not valid decision JSON; auto mode fails closed.";
138
148
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
149
+ const started = Date.now();
139
150
  let response: AssistantMessage;
140
151
  try {
141
152
  response = await completeFn(
@@ -150,15 +161,29 @@ export async function classifyWithRetry(
150
161
  },
151
162
  );
152
163
  } catch (error) {
164
+ const message = error instanceof Error ? error.message : String(error);
165
+ onAttempt?.({
166
+ attempt: attempt + 1,
167
+ error: message,
168
+ durationMs: Date.now() - started,
169
+ });
153
170
  return {
154
171
  decision: "block",
155
172
  tier: "none",
156
- reason: `Classifier failed; auto mode fails closed: ${
157
- error instanceof Error ? error.message : String(error)
158
- }`,
173
+ reason: `Classifier failed; auto mode fails closed: ${message}`,
159
174
  };
160
175
  }
176
+ const durationMs = Date.now() - started;
161
177
  const decision = parseClassifierDecision(response);
178
+ onAttempt?.({
179
+ attempt: attempt + 1,
180
+ response: {
181
+ stopReason: response.stopReason,
182
+ text: extractAssistantText(response),
183
+ },
184
+ parsed: decision,
185
+ durationMs,
186
+ });
162
187
  if (decision) return decision;
163
188
  lastReason =
164
189
  response.stopReason === "length"
@@ -173,7 +198,7 @@ export const defaultClassifyAction: ClassifyAction = async (
173
198
  config,
174
199
  action,
175
200
  loadedContext,
176
- ): Promise<ClassificationDecision> => {
201
+ ): Promise<ClassifyResult> => {
177
202
  const classifier = await resolveClassifier(ctx, config);
178
203
  if (!classifier) {
179
204
  return {
@@ -183,25 +208,35 @@ export const defaultClassifyAction: ClassifyAction = async (
183
208
  };
184
209
  }
185
210
 
211
+ const systemPrompt = buildClassifierPrompt(config);
212
+ const userText = `<loaded-project-instructions>\n${
213
+ loadedContext || "(none)"
214
+ }\n</loaded-project-instructions>\n\n<transcript>\n${
215
+ buildTranscript(ctx, config.maxTranscriptLines) || "(none)"
216
+ }\n</transcript>\n\nLatest action to classify:\n${action}`;
186
217
  const userMessage: UserMessage = {
187
218
  role: "user",
188
- content: [
189
- {
190
- type: "text",
191
- text: `<loaded-project-instructions>\n${
192
- loadedContext || "(none)"
193
- }\n</loaded-project-instructions>\n\n<transcript>\n${
194
- buildTranscript(ctx, config.maxTranscriptLines) || "(none)"
195
- }\n</transcript>\n\nLatest action to classify:\n${action}`,
196
- },
197
- ],
219
+ content: [{ type: "text", text: userText }],
198
220
  timestamp: Date.now(),
199
221
  };
200
222
 
201
- return classifyWithRetry(
223
+ const attempts: ClassifierIoAttempt[] = [];
224
+ const started = Date.now();
225
+ const decision = await classifyWithRetry(
202
226
  complete,
203
227
  classifier,
204
- { systemPrompt: buildClassifierPrompt(config), messages: [userMessage] },
228
+ { systemPrompt, messages: [userMessage] },
205
229
  ctx.signal,
230
+ { onAttempt: (attempt) => attempts.push(attempt) },
206
231
  );
232
+
233
+ return {
234
+ ...decision,
235
+ io: {
236
+ model: formatModelSpec(classifier.model),
237
+ prompt: { system: systemPrompt, user: userText },
238
+ attempts,
239
+ durationMs: Date.now() - started,
240
+ },
241
+ };
207
242
  };
@@ -4,6 +4,7 @@ import {
4
4
  DEFAULT_ALLOW,
5
5
  DEFAULT_ENVIRONMENT,
6
6
  DEFAULT_HARD_DENY,
7
+ DEFAULT_LOG_CONFIG,
7
8
  DEFAULT_MAX_TRANSCRIPT_LINES,
8
9
  DEFAULT_PROTECTED_PATHS,
9
10
  DEFAULT_SOFT_DENY,
@@ -17,6 +18,7 @@ import type {
17
18
  ConfigLoadResult,
18
19
  EffectiveConfig,
19
20
  LoadedSettingsFile,
21
+ LogConfig,
20
22
  SettingsFile,
21
23
  SettingsSources,
22
24
  ToolPattern,
@@ -102,6 +104,7 @@ export function validateSettingsFile(
102
104
  "softDeny",
103
105
  "hard_deny",
104
106
  "hardDeny",
107
+ "log",
105
108
  ]);
106
109
  for (const key of Object.keys(autoMode)) {
107
110
  if (!knownAutoMode.has(key)) {
@@ -160,6 +163,9 @@ export function validateSettingsFile(
160
163
  "autoMode.hard_deny",
161
164
  diagnostics,
162
165
  );
166
+ if (hasOwn(autoMode, "log")) {
167
+ validateLogSetting(autoMode.log, source, diagnostics);
168
+ }
163
169
  }
164
170
  }
165
171
 
@@ -228,6 +234,37 @@ function finalizeRuleSetting(accumulator: RuleAccumulator): string[] {
228
234
  return [...new Set([...base, ...accumulator.entries])];
229
235
  }
230
236
 
237
+ function validateLogSetting(
238
+ value: unknown,
239
+ source: string,
240
+ diagnostics: string[],
241
+ ): void {
242
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
243
+ diagnostics.push(`${source}: autoMode.log must be an object`);
244
+ return;
245
+ }
246
+ const log = value as Record<string, unknown>;
247
+ if (hasOwn(log, "enabled") && typeof log.enabled !== "boolean") {
248
+ diagnostics.push(`${source}: autoMode.log.enabled must be a boolean`);
249
+ }
250
+ if (
251
+ hasOwn(log, "classifierIo") && typeof log.classifierIo !== "boolean"
252
+ ) {
253
+ diagnostics.push(`${source}: autoMode.log.classifierIo must be a boolean`);
254
+ }
255
+ }
256
+
257
+ function mergeLog(
258
+ base: LogConfig,
259
+ patch: Partial<LogConfig> | undefined,
260
+ ): LogConfig {
261
+ if (!patch) return base;
262
+ return {
263
+ enabled: patch.enabled ?? base.enabled,
264
+ classifierIo: patch.classifierIo ?? base.classifierIo,
265
+ };
266
+ }
267
+
231
268
  function applyAutoModeScalars(
232
269
  base: EffectiveConfig,
233
270
  settings: AutoModeSettings | undefined,
@@ -238,6 +275,7 @@ function applyAutoModeScalars(
238
275
  enabled: settings.enabled ?? base.enabled,
239
276
  classifierModel: settings.classifierModel ?? base.classifierModel,
240
277
  maxTranscriptLines: settings.maxTranscriptLines ?? base.maxTranscriptLines,
278
+ log: mergeLog(base.log, settings.log),
241
279
  };
242
280
  }
243
281
 
@@ -276,6 +314,7 @@ export function buildEffectiveConfigFromSources(
276
314
  hardDeny: [...DEFAULT_HARD_DENY],
277
315
  permissionDeny: [],
278
316
  permissionAsk: [],
317
+ log: { ...DEFAULT_LOG_CONFIG },
279
318
  };
280
319
 
281
320
  const globalSettings = sources.globalSettings ?? [];
@@ -166,3 +166,9 @@ export const PROFILE_FILES = new Set([
166
166
  ]);
167
167
 
168
168
  export const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
169
+
170
+ /** Default observability log config: off, classifier I/O off. */
171
+ export const DEFAULT_LOG_CONFIG = {
172
+ enabled: false,
173
+ classifierIo: false,
174
+ };
@@ -19,6 +19,12 @@ import {
19
19
  writeGlobalClassifierModel,
20
20
  } from "./config.ts";
21
21
  import { deterministicHardDeny } from "./hard-deny.ts";
22
+ import {
23
+ createLogger,
24
+ newDecisionId,
25
+ resolveLogPath,
26
+ type Logger,
27
+ } from "./log.ts";
22
28
  import { formatModelSpec, parseModelSpec } from "./model.ts";
23
29
  import { promptForClassifierModel } from "./model-selector.ts";
24
30
  import { matchesToolPattern } from "./permissions.ts";
@@ -35,7 +41,9 @@ import { loadedContextFromSystemPromptOptions } from "./transcript.ts";
35
41
  import type {
36
42
  AutoModeState,
37
43
  ClassifyAction,
44
+ ClassifyResult,
38
45
  ConfigLoadResult,
46
+ DecisionKind,
39
47
  DenialRecord,
40
48
  EffectiveConfig,
41
49
  } from "./types.ts";
@@ -50,6 +58,31 @@ export type PiAutomodeOptions = {
50
58
  saveClassifierModel?: (classifierModel: string) => void;
51
59
  };
52
60
 
61
+ type LogCtx = {
62
+ logger: Logger;
63
+ decisionId: string;
64
+ classifierModel?: string;
65
+ };
66
+
67
+ /** Append a classifier I/O entry when classifier logging is enabled. */
68
+ function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
69
+ if (!log.logger.enabled || !log.logger.classifierIo || !decision.io) return;
70
+ log.logger.append({
71
+ type: "classifier",
72
+ ts: new Date().toISOString(),
73
+ decisionId: log.decisionId,
74
+ model: decision.io.model,
75
+ prompt: decision.io.prompt,
76
+ attempts: decision.io.attempts,
77
+ durationMs: decision.io.durationMs,
78
+ parsed: {
79
+ decision: decision.decision,
80
+ tier: decision.tier,
81
+ reason: decision.reason,
82
+ },
83
+ });
84
+ }
85
+
53
86
  /** Create a Pi extension instance. Default export uses production dependencies. */
54
87
  export function createPiAutomode(options: PiAutomodeOptions = {}) {
55
88
  const loadConfigWithDiagnostics = options.loadConfig
@@ -69,7 +102,8 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
69
102
  let state: AutoModeState = {
70
103
  checkedActions: 0,
71
104
  blockedActions: 0,
72
- classifierChecks: 0,
105
+ classifierAllowed: 0,
106
+ classifierDenied: 0,
73
107
  recentDenials: [],
74
108
  };
75
109
  let loadedContext = "";
@@ -100,6 +134,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
100
134
  function block(
101
135
  ctx: ExtensionContext,
102
136
  denial: DenialRecord,
137
+ logCtx: LogCtx,
103
138
  ): { block: true; reason: string } {
104
139
  state.blockedActions += 1;
105
140
  state.lastDecision = "block";
@@ -107,6 +142,21 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
107
142
  pushDenial(state, denial);
108
143
  persist();
109
144
  updateUi(ctx);
145
+ if (logCtx.logger.enabled) {
146
+ logCtx.logger.append({
147
+ type: "decision",
148
+ ts: new Date().toISOString(),
149
+ decisionId: logCtx.decisionId,
150
+ sessionId: ctx.sessionManager.getSessionId?.(),
151
+ cwd: ctx.cwd,
152
+ tool: denial.toolName,
153
+ summary: denial.action,
154
+ kind: denial.kind,
155
+ outcome: "block",
156
+ reason: denial.reason,
157
+ classifierModel: logCtx.classifierModel,
158
+ });
159
+ }
110
160
  if (ctx.hasUI) {
111
161
  ctx.ui.notify(
112
162
  `Auto mode blocked ${denial.toolName}: ${denial.reason}`,
@@ -116,6 +166,36 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
116
166
  return { block: true, reason: `[pi-automode] ${denial.reason}` };
117
167
  }
118
168
 
169
+ function allow(
170
+ ctx: ExtensionContext,
171
+ kind: DecisionKind,
172
+ reason: string,
173
+ toolName: string,
174
+ summary: string,
175
+ logCtx: LogCtx,
176
+ ): undefined {
177
+ state.lastDecision = "allow";
178
+ state.lastReason = reason;
179
+ persist();
180
+ updateUi(ctx);
181
+ if (logCtx.logger.enabled) {
182
+ logCtx.logger.append({
183
+ type: "decision",
184
+ ts: new Date().toISOString(),
185
+ decisionId: logCtx.decisionId,
186
+ sessionId: ctx.sessionManager.getSessionId?.(),
187
+ cwd: ctx.cwd,
188
+ tool: toolName,
189
+ summary,
190
+ kind,
191
+ outcome: "allow",
192
+ reason,
193
+ classifierModel: logCtx.classifierModel,
194
+ });
195
+ }
196
+ return undefined;
197
+ }
198
+
119
199
  pi.on("session_start", (_event, ctx) => {
120
200
  loadResult = loadConfigWithDiagnostics(ctx.cwd);
121
201
  config = loadResult.config;
@@ -146,6 +226,17 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
146
226
  const input = event.input as Record<string, unknown>;
147
227
  const summary = actionSummary(event.toolName, input);
148
228
  state.checkedActions += 1;
229
+ const logCtx: LogCtx = {
230
+ logger: createLogger({
231
+ enabled: cfg.log.enabled,
232
+ classifierIo: cfg.log.classifierIo,
233
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
234
+ sessionDir: ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
235
+ sessionId: ctx.sessionManager.getSessionId?.() ?? "unknown",
236
+ }),
237
+ decisionId: newDecisionId(),
238
+ classifierModel: cfg.classifierModel,
239
+ };
149
240
 
150
241
  for (const pattern of cfg.permissionDeny) {
151
242
  if (matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
@@ -155,7 +246,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
155
246
  reason: `Blocked by permissions.deny: ${pattern.raw}`,
156
247
  action: summary,
157
248
  kind: "permissions.deny",
158
- });
249
+ }, logCtx);
159
250
  }
160
251
  }
161
252
 
@@ -171,7 +262,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
171
262
  `Matched permissions.ask (${pattern.raw}) but no UI is available`,
172
263
  action: summary,
173
264
  kind: "permissions.ask",
174
- });
265
+ }, logCtx);
175
266
  }
176
267
  const allowed = await ctx.ui.confirm(
177
268
  "Auto mode permission ask",
@@ -185,7 +276,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
185
276
  reason: `Declined permissions.ask: ${pattern.raw}`,
186
277
  action: summary,
187
278
  kind: "permissions.ask",
188
- });
279
+ }, logCtx);
189
280
  }
190
281
  }
191
282
 
@@ -201,57 +292,70 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
201
292
  reason: deterministicReason,
202
293
  action: summary,
203
294
  kind: "deterministic-hard-deny",
204
- });
295
+ }, logCtx);
205
296
  }
206
297
 
207
298
  if (READ_ONLY_TOOLS.has(event.toolName)) {
208
- state.lastDecision = "allow";
209
- state.lastReason = `Read-only built-in tool: ${event.toolName}`;
210
- persist();
211
- updateUi(ctx);
212
- return undefined;
299
+ return allow(
300
+ ctx,
301
+ "read-only",
302
+ `Read-only built-in tool: ${event.toolName}`,
303
+ event.toolName,
304
+ summary,
305
+ logCtx,
306
+ );
213
307
  }
214
308
 
215
309
  // Protected paths go to the classifier regardless of allow rules.
216
310
  if (event.toolName === "write" || event.toolName === "edit") {
217
311
  const path = resolveInputPath(ctx.cwd, input.path);
218
312
  if (path && isProtectedPath(path, ctx.cwd, cfg.protectedPaths)) {
219
- state.classifierChecks += 1;
220
313
  const decision = await classify(ctx, cfg, summary, loadedContext);
314
+ logClassifierIo(decision, logCtx);
221
315
  if (decision.decision === "allow") {
222
- state.lastDecision = "allow";
223
- state.lastReason = decision.reason;
224
- persist();
225
- updateUi(ctx);
226
- return undefined;
316
+ state.classifierAllowed += 1;
317
+ return allow(
318
+ ctx,
319
+ "classifier",
320
+ decision.reason,
321
+ event.toolName,
322
+ summary,
323
+ logCtx,
324
+ );
227
325
  }
326
+ state.classifierDenied += 1;
228
327
  return block(ctx, {
229
328
  timestamp: Date.now(),
230
329
  toolName: event.toolName,
231
330
  reason: decision.reason,
232
331
  action: summary,
233
332
  kind: "classifier",
234
- });
333
+ }, logCtx);
235
334
  }
236
335
  }
237
336
 
238
- state.classifierChecks += 1;
239
337
  const decision = await classify(ctx, cfg, summary, loadedContext);
338
+ logClassifierIo(decision, logCtx);
240
339
  if (decision.decision === "allow") {
241
- state.lastDecision = "allow";
242
- state.lastReason = decision.reason;
243
- persist();
244
- updateUi(ctx);
245
- return undefined;
340
+ state.classifierAllowed += 1;
341
+ return allow(
342
+ ctx,
343
+ "classifier",
344
+ decision.reason,
345
+ event.toolName,
346
+ summary,
347
+ logCtx,
348
+ );
246
349
  }
247
350
 
351
+ state.classifierDenied += 1;
248
352
  return block(ctx, {
249
353
  timestamp: Date.now(),
250
354
  toolName: event.toolName,
251
355
  reason: decision.reason,
252
356
  action: summary,
253
357
  kind: "classifier",
254
- });
358
+ }, logCtx);
255
359
  });
256
360
 
257
361
  async function handleAutomodeCommand(
@@ -298,7 +402,8 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
298
402
  state = {
299
403
  checkedActions: 0,
300
404
  blockedActions: 0,
301
- classifierChecks: 0,
405
+ classifierAllowed: 0,
406
+ classifierDenied: 0,
302
407
  recentDenials: [],
303
408
  enabledOverride: state.enabledOverride,
304
409
  };
@@ -324,9 +429,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
324
429
  return;
325
430
  }
326
431
  if (command === "config") {
432
+ const logFile = resolveLogPath(
433
+ ctx.sessionManager.getSessionFile?.(),
434
+ ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
435
+ ctx.sessionManager.getSessionId?.() ?? "unknown",
436
+ );
327
437
  ctx.ui.notify(
328
438
  safeJson(
329
- { config: effectiveConfig(), diagnostics: configDiagnostics },
439
+ {
440
+ config: effectiveConfig(),
441
+ logFile,
442
+ diagnostics: configDiagnostics,
443
+ },
330
444
  16000,
331
445
  ),
332
446
  configDiagnostics.length > 0 ? "warning" : "info",
@@ -0,0 +1,100 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { basename, dirname, extname, join } from "node:path";
4
+ import type {
5
+ ClassifierIoAttempt,
6
+ ClassificationDecision,
7
+ DecisionKind,
8
+ } from "./types.ts";
9
+
10
+ /** A final allow/block decision for a tool call. */
11
+ export type DecisionLogEntry = {
12
+ type: "decision";
13
+ ts: string;
14
+ decisionId: string;
15
+ sessionId?: string;
16
+ cwd: string;
17
+ tool: string;
18
+ summary: string;
19
+ kind: DecisionKind;
20
+ outcome: "allow" | "block";
21
+ reason: string;
22
+ classifierModel?: string;
23
+ };
24
+
25
+ /** The classifier prompt, raw responses, and parsed decision for one action. */
26
+ export type ClassifierLogEntry = {
27
+ type: "classifier";
28
+ ts: string;
29
+ decisionId: string;
30
+ model: string;
31
+ prompt: { system: string; user: string };
32
+ attempts: ClassifierIoAttempt[];
33
+ durationMs: number;
34
+ parsed: ClassificationDecision;
35
+ };
36
+
37
+ export type LogEntry = DecisionLogEntry | ClassifierLogEntry;
38
+
39
+ export type Logger = {
40
+ enabled: boolean;
41
+ classifierIo: boolean;
42
+ append(entry: LogEntry): void;
43
+ };
44
+
45
+ export type LoggerOptions = {
46
+ enabled: boolean;
47
+ classifierIo: boolean;
48
+ sessionFile?: string;
49
+ sessionDir: string;
50
+ sessionId: string;
51
+ };
52
+
53
+ /** Short id linking a classifier entry to its decision entry in the same file. */
54
+ export function newDecisionId(): string {
55
+ return randomBytes(4).toString("hex");
56
+ }
57
+
58
+ /**
59
+ * Derive the log file path from the current session: the session file's
60
+ * directory with `-pi-automode` inserted before the extension. Falls back to
61
+ * `<sessionDir>/<sessionId>-pi-automode.jsonl` when no session file is set.
62
+ */
63
+ export function resolveLogPath(
64
+ sessionFile: string | undefined,
65
+ sessionDir: string,
66
+ sessionId: string,
67
+ ): string {
68
+ if (sessionFile) {
69
+ const ext = extname(sessionFile);
70
+ const stem = ext ? basename(sessionFile, ext) : basename(sessionFile);
71
+ return join(dirname(sessionFile), `${stem}-pi-automode${ext}`);
72
+ }
73
+ return join(sessionDir, `${sessionId}-pi-automode.jsonl`);
74
+ }
75
+
76
+ /** Append one JSON object as a line. Failures are swallowed: logging must
77
+ * never change a safety decision. */
78
+ function appendJsonl(path: string, entry: unknown): void {
79
+ try {
80
+ mkdirSync(dirname(path), { recursive: true });
81
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
82
+ } catch {
83
+ // Fail open.
84
+ }
85
+ }
86
+
87
+ /** Build a logger bound to one session's log path. No-ops when disabled. */
88
+ export function createLogger(opts: LoggerOptions): Logger {
89
+ const { enabled, classifierIo } = opts;
90
+ const path = resolveLogPath(opts.sessionFile, opts.sessionDir, opts.sessionId);
91
+ return {
92
+ enabled,
93
+ classifierIo,
94
+ append(entry) {
95
+ if (!enabled) return;
96
+ if (entry.type === "classifier" && !classifierIo) return;
97
+ appendJsonl(path, entry);
98
+ },
99
+ };
100
+ }
@@ -17,8 +17,8 @@ export function statusLine(
17
17
  const enabled = state.enabledOverride ?? config.enabled;
18
18
  const circle = enabled ? "●" : "○";
19
19
  const allowed = state.checkedActions - state.blockedActions;
20
- const classifier = state.classifierChecks > 0
21
- ? ` c:${state.classifierChecks}`
20
+ const classifier = state.classifierAllowed > 0 || state.classifierDenied > 0
21
+ ? ` ca:${state.classifierAllowed} cd:${state.classifierDenied}`
22
22
  : "";
23
23
  return `AM${circle} a:${allowed} d:${state.blockedActions}${classifier}`;
24
24
  }
@@ -32,7 +32,8 @@ export function statusText(
32
32
  `classifier: ${config.classifierModel ?? "current session model"}`,
33
33
  `checked actions: ${state.checkedActions}`,
34
34
  `blocked actions: ${state.blockedActions}`,
35
- `classifier checks: ${state.classifierChecks}`,
35
+ `classifier allowed: ${state.classifierAllowed}`,
36
+ `classifier denied: ${state.classifierDenied}`,
36
37
  `permissions.deny rules: ${config.permissionDeny.length}`,
37
38
  `permissions.ask rules: ${config.permissionAsk.length}`,
38
39
  `environment entries: ${config.environment.length}`,
@@ -88,11 +89,12 @@ export function restoreState(ctx: ExtensionContext): AutoModeState {
88
89
  lastReason: entry.data.lastReason,
89
90
  checkedActions: entry.data.checkedActions ?? 0,
90
91
  blockedActions: entry.data.blockedActions ?? 0,
91
- classifierChecks: entry.data.classifierChecks ?? 0,
92
+ classifierAllowed: entry.data.classifierAllowed ?? 0,
93
+ classifierDenied: entry.data.classifierDenied ?? 0,
92
94
  recentDenials: Array.isArray(entry.data.recentDenials)
93
95
  ? entry.data.recentDenials.slice(-DENIAL_HISTORY_LIMIT)
94
96
  : [],
95
97
  };
96
98
  }
97
- return { checkedActions: 0, blockedActions: 0, classifierChecks: 0, recentDenials: [] };
99
+ return { checkedActions: 0, blockedActions: 0, classifierAllowed: 0, classifierDenied: 0, recentDenials: [] };
98
100
  }
@@ -1,5 +1,12 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ /** Observability log configuration. Off by default. */
4
+ export type LogConfig = {
5
+ enabled: boolean;
6
+ /** When true, also log classifier prompt/response payloads. */
7
+ classifierIo: boolean;
8
+ };
9
+
3
10
  export type AutoModeSettings = {
4
11
  enabled?: boolean;
5
12
  classifierModel?: string;
@@ -11,6 +18,7 @@ export type AutoModeSettings = {
11
18
  softDeny?: unknown;
12
19
  hard_deny?: unknown;
13
20
  hardDeny?: unknown;
21
+ log?: Partial<LogConfig>;
14
22
  };
15
23
 
16
24
  export type SettingsFile = {
@@ -44,6 +52,7 @@ export type EffectiveConfig = {
44
52
  hardDeny: string[];
45
53
  permissionDeny: ToolPattern[];
46
54
  permissionAsk: ToolPattern[];
55
+ log: LogConfig;
47
56
  };
48
57
 
49
58
  export type AutoModeState = {
@@ -52,7 +61,8 @@ export type AutoModeState = {
52
61
  lastReason?: string;
53
62
  checkedActions: number;
54
63
  blockedActions: number;
55
- classifierChecks: number;
64
+ classifierAllowed: number;
65
+ classifierDenied: number;
56
66
  recentDenials: DenialRecord[];
57
67
  };
58
68
 
@@ -69,12 +79,35 @@ export type DenialRecord = {
69
79
  | "setup";
70
80
  };
71
81
 
82
+ /** Denial kind plus the read-only fast path, used for decision log entries. */
83
+ export type DecisionKind = DenialRecord["kind"] | "read-only";
84
+
72
85
  export type ClassificationDecision = {
73
86
  decision: "allow" | "block";
74
87
  tier: "hard_deny" | "soft_deny" | "allow" | "explicit_intent" | "none";
75
88
  reason: string;
76
89
  };
77
90
 
91
+ /** One classifier attempt: the raw model response (or error) and parsed decision. */
92
+ export type ClassifierIoAttempt = {
93
+ attempt: number;
94
+ response?: { stopReason?: string; text: string };
95
+ parsed?: ClassificationDecision;
96
+ error?: string;
97
+ durationMs: number;
98
+ };
99
+
100
+ /** Full classifier I/O for an action, surfaced for optional observability logging. */
101
+ export type ClassifierIo = {
102
+ model: string;
103
+ prompt: { system: string; user: string };
104
+ attempts: ClassifierIoAttempt[];
105
+ durationMs: number;
106
+ };
107
+
108
+ /** Classification decision plus the I/O that produced it (when available). */
109
+ export type ClassifyResult = ClassificationDecision & { io?: ClassifierIo };
110
+
78
111
  export type SettingsSources = {
79
112
  globalSettings?: SettingsFile[];
80
113
  projectLocalSettings?: SettingsFile[];
@@ -92,4 +125,4 @@ export type ClassifyAction = (
92
125
  config: EffectiveConfig,
93
126
  action: string,
94
127
  loadedContext: string,
95
- ) => Promise<ClassificationDecision>;
128
+ ) => Promise<ClassifyResult>;
@@ -11,6 +11,7 @@ export * from "./auto-mode/config.ts";
11
11
  export * from "./auto-mode/constants.ts";
12
12
  export * from "./auto-mode/extension.ts";
13
13
  export * from "./auto-mode/hard-deny.ts";
14
+ export * from "./auto-mode/log.ts";
14
15
  export * from "./auto-mode/model.ts";
15
16
  export * from "./auto-mode/model-selector.ts";
16
17
  export * from "./auto-mode/paths.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@czottmann/pi-automode",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Claude Code-style auto mode guardrail for pi.",
5
5
  "repository": {
6
6
  "url": "https://github.com/czottmann/pi-automode"