@czottmann/pi-automode 1.6.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
@@ -52,14 +52,15 @@ AM● a:12 d:2 ca:5 cd:1
52
52
  ```
53
53
 
54
54
  - `AM` — auto-mode prefix; `●` when enabled, `○` when disabled (via config or `/automode off`).
55
- - `a:` — actions allowed so far (checked minus blocked).
56
- - `d:` — actions blocked 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 blocks, so `d:` is always `>= cd:`.
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
58
 
59
59
  ## Docs
60
60
 
61
61
  - [Defaults and rule-list behavior](docs/defaults.md)
62
62
  - [Auto-mode classifier flow](docs/automode-classifier-flow.md)
63
+ - [Observability logging](docs/observability-logging.md)
63
64
 
64
65
  ## Configuration
65
66
 
@@ -73,6 +74,18 @@ It reads `autoMode` from Pi-owned config only:
73
74
 
74
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`.
75
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
+
76
89
  Set a global default classifier model in `~/.pi/agent/automode.json`; override it per project in `.pi/automode.local.json`.
77
90
 
78
91
  Example:
@@ -107,6 +120,23 @@ Example:
107
120
 
108
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.
109
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
+
110
140
  ### Permission patterns
111
141
 
112
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
@@ -101,6 +134,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
101
134
  function block(
102
135
  ctx: ExtensionContext,
103
136
  denial: DenialRecord,
137
+ logCtx: LogCtx,
104
138
  ): { block: true; reason: string } {
105
139
  state.blockedActions += 1;
106
140
  state.lastDecision = "block";
@@ -108,6 +142,21 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
108
142
  pushDenial(state, denial);
109
143
  persist();
110
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
+ }
111
160
  if (ctx.hasUI) {
112
161
  ctx.ui.notify(
113
162
  `Auto mode blocked ${denial.toolName}: ${denial.reason}`,
@@ -117,6 +166,36 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
117
166
  return { block: true, reason: `[pi-automode] ${denial.reason}` };
118
167
  }
119
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
+
120
199
  pi.on("session_start", (_event, ctx) => {
121
200
  loadResult = loadConfigWithDiagnostics(ctx.cwd);
122
201
  config = loadResult.config;
@@ -147,6 +226,17 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
147
226
  const input = event.input as Record<string, unknown>;
148
227
  const summary = actionSummary(event.toolName, input);
149
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
+ };
150
240
 
151
241
  for (const pattern of cfg.permissionDeny) {
152
242
  if (matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
@@ -156,7 +246,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
156
246
  reason: `Blocked by permissions.deny: ${pattern.raw}`,
157
247
  action: summary,
158
248
  kind: "permissions.deny",
159
- });
249
+ }, logCtx);
160
250
  }
161
251
  }
162
252
 
@@ -172,7 +262,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
172
262
  `Matched permissions.ask (${pattern.raw}) but no UI is available`,
173
263
  action: summary,
174
264
  kind: "permissions.ask",
175
- });
265
+ }, logCtx);
176
266
  }
177
267
  const allowed = await ctx.ui.confirm(
178
268
  "Auto mode permission ask",
@@ -186,7 +276,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
186
276
  reason: `Declined permissions.ask: ${pattern.raw}`,
187
277
  action: summary,
188
278
  kind: "permissions.ask",
189
- });
279
+ }, logCtx);
190
280
  }
191
281
  }
192
282
 
@@ -202,15 +292,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
202
292
  reason: deterministicReason,
203
293
  action: summary,
204
294
  kind: "deterministic-hard-deny",
205
- });
295
+ }, logCtx);
206
296
  }
207
297
 
208
298
  if (READ_ONLY_TOOLS.has(event.toolName)) {
209
- state.lastDecision = "allow";
210
- state.lastReason = `Read-only built-in tool: ${event.toolName}`;
211
- persist();
212
- updateUi(ctx);
213
- 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
+ );
214
307
  }
215
308
 
216
309
  // Protected paths go to the classifier regardless of allow rules.
@@ -218,13 +311,17 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
218
311
  const path = resolveInputPath(ctx.cwd, input.path);
219
312
  if (path && isProtectedPath(path, ctx.cwd, cfg.protectedPaths)) {
220
313
  const decision = await classify(ctx, cfg, summary, loadedContext);
314
+ logClassifierIo(decision, logCtx);
221
315
  if (decision.decision === "allow") {
222
316
  state.classifierAllowed += 1;
223
- state.lastDecision = "allow";
224
- state.lastReason = decision.reason;
225
- persist();
226
- updateUi(ctx);
227
- return undefined;
317
+ return allow(
318
+ ctx,
319
+ "classifier",
320
+ decision.reason,
321
+ event.toolName,
322
+ summary,
323
+ logCtx,
324
+ );
228
325
  }
229
326
  state.classifierDenied += 1;
230
327
  return block(ctx, {
@@ -233,18 +330,22 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
233
330
  reason: decision.reason,
234
331
  action: summary,
235
332
  kind: "classifier",
236
- });
333
+ }, logCtx);
237
334
  }
238
335
  }
239
336
 
240
337
  const decision = await classify(ctx, cfg, summary, loadedContext);
338
+ logClassifierIo(decision, logCtx);
241
339
  if (decision.decision === "allow") {
242
340
  state.classifierAllowed += 1;
243
- state.lastDecision = "allow";
244
- state.lastReason = decision.reason;
245
- persist();
246
- updateUi(ctx);
247
- return undefined;
341
+ return allow(
342
+ ctx,
343
+ "classifier",
344
+ decision.reason,
345
+ event.toolName,
346
+ summary,
347
+ logCtx,
348
+ );
248
349
  }
249
350
 
250
351
  state.classifierDenied += 1;
@@ -254,7 +355,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
254
355
  reason: decision.reason,
255
356
  action: summary,
256
357
  kind: "classifier",
257
- });
358
+ }, logCtx);
258
359
  });
259
360
 
260
361
  async function handleAutomodeCommand(
@@ -328,9 +429,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
328
429
  return;
329
430
  }
330
431
  if (command === "config") {
432
+ const logFile = resolveLogPath(
433
+ ctx.sessionManager.getSessionFile?.(),
434
+ ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
435
+ ctx.sessionManager.getSessionId?.() ?? "unknown",
436
+ );
331
437
  ctx.ui.notify(
332
438
  safeJson(
333
- { config: effectiveConfig(), diagnostics: configDiagnostics },
439
+ {
440
+ config: effectiveConfig(),
441
+ logFile,
442
+ diagnostics: configDiagnostics,
443
+ },
334
444
  16000,
335
445
  ),
336
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
+ }
@@ -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 = {
@@ -70,12 +79,35 @@ export type DenialRecord = {
70
79
  | "setup";
71
80
  };
72
81
 
82
+ /** Denial kind plus the read-only fast path, used for decision log entries. */
83
+ export type DecisionKind = DenialRecord["kind"] | "read-only";
84
+
73
85
  export type ClassificationDecision = {
74
86
  decision: "allow" | "block";
75
87
  tier: "hard_deny" | "soft_deny" | "allow" | "explicit_intent" | "none";
76
88
  reason: string;
77
89
  };
78
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
+
79
111
  export type SettingsSources = {
80
112
  globalSettings?: SettingsFile[];
81
113
  projectLocalSettings?: SettingsFile[];
@@ -93,4 +125,4 @@ export type ClassifyAction = (
93
125
  config: EffectiveConfig,
94
126
  action: string,
95
127
  loadedContext: string,
96
- ) => 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.6.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"