@czottmann/pi-automode 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,17 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
1
4
  import type {
2
5
  ExtensionAPI,
3
6
  ExtensionCommandContext,
4
7
  ExtensionContext,
5
8
  } from "@earendil-works/pi-coding-agent";
9
+ import { StringEnum } from "@earendil-works/pi-ai";
10
+ import { Type } from "typebox";
6
11
  import {
7
12
  classifierReasoningForConfig,
8
13
  defaultClassifyAction,
14
+ serializeClassifierAction,
9
15
  } from "./classifier.ts";
10
16
  import {
11
17
  AUTO_MODE_GUIDANCE,
@@ -18,7 +24,6 @@ import {
18
24
  READ_ONLY_TOOLS,
19
25
  } from "./constants.ts";
20
26
  import {
21
- loadEffectiveConfig,
22
27
  loadEffectiveConfigWithDiagnostics,
23
28
  writeGlobalClassifierModel,
24
29
  } from "./config.ts";
@@ -31,14 +36,17 @@ import {
31
36
  } from "./log.ts";
32
37
  import { formatModelSpec, parseModelSpec } from "./model.ts";
33
38
  import { promptForClassifierModel } from "./model-selector.ts";
34
- import { matchesDeniedPath, matchesToolPattern } from "./permissions.ts";
35
39
  import {
36
- expandHomePattern,
40
+ matchesDeniedPath,
41
+ matchesToolPattern,
42
+ recursiveSearchMayReachDeniedPath,
43
+ } from "./permissions.ts";
44
+ import {
37
45
  extractInputPath,
38
46
  isInside,
39
47
  isProtectedPath,
40
- resolveInputPath,
41
48
  resolvePathForPolicy,
49
+ resolveToolInputPath,
42
50
  } from "./paths.ts";
43
51
  import {
44
52
  actionSummary,
@@ -61,13 +69,45 @@ import type {
61
69
  } from "./types.ts";
62
70
  import { safeJson } from "./utils.ts";
63
71
 
72
+ const INSPECT_TOOL = "automode_inspect";
73
+ const INSPECTION_ACTIONS = ["status", "config", "defaults", "denials"] as const;
74
+ type InspectionAction = (typeof INSPECTION_ACTIONS)[number];
75
+
76
+ function canonicalPath(path: string): string {
77
+ try {
78
+ return realpathSync(path);
79
+ } catch {
80
+ return resolve(path);
81
+ }
82
+ }
83
+
84
+ const EXTENSION_PATH = canonicalPath(fileURLToPath(import.meta.url));
85
+ const EXTENSION_ENTRY_PATH = canonicalPath(
86
+ resolve(dirname(EXTENSION_PATH), "../auto-mode.ts"),
87
+ );
88
+
89
+ export function modelVisibleConfigDiagnostics(
90
+ diagnostics: string[],
91
+ ): string[] {
92
+ return diagnostics.map((diagnostic) =>
93
+ diagnostic.replace(
94
+ /invalid JSON \([\s\S]*\)$/,
95
+ "invalid JSON (parser details omitted from model-visible output)",
96
+ )
97
+ );
98
+ }
99
+
64
100
  export type PiAutomodeOptions = {
65
101
  /** Override config loading in tests. Runtime code uses Pi-owned disk settings. */
66
- loadConfig?: (cwd: string) => EffectiveConfig;
102
+ loadConfig?: (cwd: string, projectTrusted: boolean) => EffectiveConfig;
67
103
  /** Override classifier calls in tests so unit tests never need a real LLM/API key. */
68
104
  classifyAction?: ClassifyAction;
69
105
  /** Override classifier-model persistence in tests. Runtime code writes ~/.pi/agent/automode.json. */
70
106
  saveClassifierModel?: (classifierModel: string) => void;
107
+ /** Override the application-owned observability log root in tests. */
108
+ logRoot?: string;
109
+ /** Override the observability log clock in tests. */
110
+ now?: () => Date;
71
111
  };
72
112
 
73
113
  type LogCtx = {
@@ -118,17 +158,18 @@ function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
118
158
  /** Create a Pi extension instance. Default export uses production dependencies. */
119
159
  export function createPiAutomode(options: PiAutomodeOptions = {}) {
120
160
  const loadConfigWithDiagnostics = options.loadConfig
121
- ? (cwd: string): ConfigLoadResult => ({
122
- config: options.loadConfig?.(cwd) ?? loadEffectiveConfig(cwd),
161
+ ? (cwd: string, projectTrusted: boolean): ConfigLoadResult => ({
162
+ config: options.loadConfig!(cwd, projectTrusted),
123
163
  diagnostics: [],
124
164
  })
125
165
  : loadEffectiveConfigWithDiagnostics;
126
166
  const classify = options.classifyAction ?? defaultClassifyAction;
127
167
  const saveClassifierModel = options.saveClassifierModel ??
128
168
  writeGlobalClassifierModel;
169
+ const now = options.now ?? (() => new Date());
129
170
 
130
171
  return function piAutomode(pi: ExtensionAPI) {
131
- let loadResult = loadConfigWithDiagnostics(process.cwd());
172
+ let loadResult = loadConfigWithDiagnostics(process.cwd(), false);
132
173
  let config: EffectiveConfig = loadResult.config;
133
174
  let configDiagnostics: string[] = loadResult.diagnostics;
134
175
  let state: AutoModeState = {
@@ -147,6 +188,13 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
147
188
  };
148
189
  }
149
190
 
191
+ function ownsInspectionTool(): boolean {
192
+ const tool = pi.getAllTools().find(({ name }) => name === INSPECT_TOOL);
193
+ if (!tool) return false;
194
+ const sourcePath = canonicalPath(tool.sourceInfo.path);
195
+ return sourcePath === EXTENSION_PATH || sourcePath === EXTENSION_ENTRY_PATH;
196
+ }
197
+
150
198
  function persist(): void {
151
199
  pi.appendEntry("pi-automode-state", state);
152
200
  }
@@ -163,6 +211,77 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
163
211
  );
164
212
  }
165
213
 
214
+ function inspectAutomode(
215
+ action: InspectionAction,
216
+ ctx: ExtensionContext,
217
+ ): unknown {
218
+ const cfg = effectiveConfig();
219
+ if (action === "status") {
220
+ const status = [
221
+ `enabled: ${cfg.enabled ? "yes" : "no"}`,
222
+ `classifier: ${cfg.classifierModel ?? "current session model"}`,
223
+ `classifier reasoning: ${cfg.classifierReasoningLevel ?? "server default"}`,
224
+ `checked actions: ${state.checkedActions}`,
225
+ `blocked actions: ${state.blockedActions}`,
226
+ `classifier allowed: ${state.classifierAllowed}`,
227
+ `classifier denied: ${state.classifierDenied}`,
228
+ `permissions.deny rules: ${cfg.permissionDeny.length}`,
229
+ `permissions.ask rules: ${cfg.permissionAsk.length}`,
230
+ `environment entries: ${cfg.environment.length}`,
231
+ `allow entries: ${cfg.allow.length}`,
232
+ `soft_deny entries: ${cfg.softDeny.length}`,
233
+ `hard_deny entries: ${cfg.hardDeny.length}`,
234
+ `last decision: ${state.lastDecision ?? "none"}`,
235
+ "last reason: omitted from model-visible inspection",
236
+ ].join("\n");
237
+ return {
238
+ status,
239
+ state: {
240
+ enabledOverride: state.enabledOverride,
241
+ lastDecision: state.lastDecision,
242
+ checkedActions: state.checkedActions,
243
+ blockedActions: state.blockedActions,
244
+ classifierAllowed: state.classifierAllowed,
245
+ classifierDenied: state.classifierDenied,
246
+ },
247
+ };
248
+ }
249
+ if (action === "config") {
250
+ return {
251
+ config: cfg,
252
+ logFile: resolveLogPath(
253
+ ctx.sessionManager.getSessionFile?.(),
254
+ ctx.sessionManager.getSessionDir?.() ?? "",
255
+ ctx.sessionManager.getSessionId?.() ?? "unknown",
256
+ ctx.cwd,
257
+ options.logRoot,
258
+ now(),
259
+ ),
260
+ diagnostics: modelVisibleConfigDiagnostics(configDiagnostics),
261
+ };
262
+ }
263
+ if (action === "defaults") {
264
+ return {
265
+ environment: DEFAULT_ENVIRONMENT,
266
+ allow: DEFAULT_ALLOW,
267
+ protectedPaths: DEFAULT_PROTECTED_PATHS,
268
+ soft_deny: DEFAULT_SOFT_DENY,
269
+ hard_deny: DEFAULT_HARD_DENY,
270
+ };
271
+ }
272
+ const denials = state.recentDenials.slice().reverse().map((denial) => ({
273
+ timestamp: denial.timestamp,
274
+ kind: denial.kind,
275
+ toolName: denial.toolName,
276
+ }));
277
+ return {
278
+ summary: denials.length === 0
279
+ ? "No recent auto-mode denials."
280
+ : `${denials.length} recent auto-mode denial(s). Reasons and action payloads are omitted.`,
281
+ denials,
282
+ };
283
+ }
284
+
166
285
  function block(
167
286
  ctx: ExtensionContext,
168
287
  denial: DenialRecord,
@@ -231,7 +350,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
231
350
  }
232
351
 
233
352
  pi.on("session_start", (_event, ctx) => {
234
- loadResult = loadConfigWithDiagnostics(ctx.cwd);
353
+ loadResult = loadConfigWithDiagnostics(
354
+ ctx.cwd,
355
+ ctx.isProjectTrusted(),
356
+ );
235
357
  config = loadResult.config;
236
358
  configDiagnostics = loadResult.diagnostics;
237
359
  state = restoreState(ctx);
@@ -251,22 +373,30 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
251
373
  // Enforcement order:
252
374
  // 1. permission deny/ask rules,
253
375
  // 2. deterministic hard-deny checks that never consult the model,
254
- // 3. read-only built-in fast path (skipped when classifyReadOnlyTools is set),
255
- // 4. classifier for every remaining action, fail-closed on setup/parse errors.
376
+ // 3. extension-owned read-only inspection tool,
377
+ // 4. deterministic path denials,
378
+ // 5. accepted ask rules force classifier review and skip all allow tiers,
379
+ // 6. inside-CWD, permissions.allow, and read-only allow tiers,
380
+ // 7. classifier for every remaining action, fail-closed on setup/parse errors.
256
381
  const cfg = effectiveConfig();
257
382
  if (!cfg.enabled) return undefined;
258
383
  if (ctx.signal?.aborted) return { block: true, reason: "Cancelled" };
259
384
 
385
+ const isOwnedInspection = event.toolName === INSPECT_TOOL &&
386
+ ownsInspectionTool();
260
387
  const input = event.input as Record<string, unknown>;
261
388
  const summary = actionSummary(event.toolName, input);
262
- state.checkedActions += 1;
389
+ if (!isOwnedInspection) state.checkedActions += 1;
263
390
  const logCtx: LogCtx = {
264
391
  logger: createLogger({
265
392
  enabled: cfg.log.enabled,
266
393
  classifierIo: cfg.log.classifierIo,
267
394
  sessionFile: ctx.sessionManager.getSessionFile?.(),
268
- sessionDir: ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
395
+ sessionDir: ctx.sessionManager.getSessionDir?.() ?? "",
396
+ sessionCwd: ctx.cwd,
269
397
  sessionId: ctx.sessionManager.getSessionId?.() ?? "unknown",
398
+ logRoot: options.logRoot,
399
+ now: now(),
270
400
  }),
271
401
  decisionId: newDecisionId(),
272
402
  classifierModel: cfg.classifierModel,
@@ -275,6 +405,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
275
405
 
276
406
  for (const pattern of cfg.permissionDeny) {
277
407
  if (matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
408
+ if (isOwnedInspection) state.checkedActions += 1;
278
409
  return block(ctx, {
279
410
  timestamp: Date.now(),
280
411
  toolName: event.toolName,
@@ -285,11 +416,13 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
285
416
  }
286
417
  }
287
418
 
419
+ let askRequiresClassifier = false;
288
420
  for (const pattern of cfg.permissionAsk) {
289
421
  if (!matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
290
422
  continue;
291
423
  }
292
424
  if (!ctx.hasUI) {
425
+ if (isOwnedInspection) state.checkedActions += 1;
293
426
  return block(ctx, {
294
427
  timestamp: Date.now(),
295
428
  toolName: event.toolName,
@@ -305,6 +438,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
305
438
  { signal: ctx.signal },
306
439
  );
307
440
  if (!allowed) {
441
+ if (isOwnedInspection) state.checkedActions += 1;
308
442
  return block(ctx, {
309
443
  timestamp: Date.now(),
310
444
  toolName: event.toolName,
@@ -313,6 +447,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
313
447
  kind: "permissions.ask",
314
448
  }, logCtx);
315
449
  }
450
+ askRequiresClassifier = true;
316
451
  }
317
452
 
318
453
  const deterministicReason = deterministicHardDeny(
@@ -321,6 +456,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
321
456
  ctx.cwd,
322
457
  );
323
458
  if (deterministicReason) {
459
+ if (isOwnedInspection) state.checkedActions += 1;
324
460
  return block(ctx, {
325
461
  timestamp: Date.now(),
326
462
  toolName: event.toolName,
@@ -330,6 +466,9 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
330
466
  }, logCtx);
331
467
  }
332
468
 
469
+ if (isOwnedInspection && !askRequiresClassifier) return undefined;
470
+ if (isOwnedInspection) state.checkedActions += 1;
471
+
333
472
  // Deterministic path gate for file tools.
334
473
  //
335
474
  // `deniedPaths` always applies: a matching path is hard-denied before any
@@ -343,15 +482,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
343
482
  // The gate is skipped entirely when both features are off, so the
344
483
  // default configuration costs no extra filesystem calls.
345
484
  let readOnlyFastPath =
346
- !cfg.classifyReadOnlyTools && READ_ONLY_TOOLS.has(event.toolName);
485
+ !askRequiresClassifier &&
486
+ !cfg.classifyReadOnlyTools &&
487
+ READ_ONLY_TOOLS.has(event.toolName);
347
488
  if (
348
489
  (cfg.deniedPaths.length > 0 || cfg.allowInsideWorkingDirectory) &&
349
490
  PATH_BEARING_TOOLS.has(event.toolName)
350
491
  ) {
351
492
  const inputPath = extractInputPath(event.toolName, input);
352
493
  if (inputPath !== undefined) {
353
- const expanded = expandHomePattern(inputPath);
354
- const resolved = resolveInputPath(ctx.cwd, expanded) ?? expanded;
494
+ const resolved =
495
+ resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
496
+ inputPath;
355
497
  const policyPath = resolvePathForPolicy(resolved) ?? resolved;
356
498
  const denied =
357
499
  cfg.deniedPaths.length > 0 &&
@@ -366,19 +508,42 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
366
508
  kind: "deterministic-path-deny",
367
509
  }, logCtx);
368
510
  }
511
+ let recursiveSearch =
512
+ event.toolName === "grep" || event.toolName === "find";
513
+ if (recursiveSearch) {
514
+ try {
515
+ recursiveSearch = statSync(policyPath).isDirectory();
516
+ } catch {
517
+ // A missing search root will fail in the tool. Treat it as a
518
+ // directory here so a denied scope cannot fail open in a race.
519
+ }
520
+ }
521
+ const deniedSearchScope =
522
+ recursiveSearch &&
523
+ cfg.deniedPaths.length > 0 &&
524
+ (recursiveSearchMayReachDeniedPath(resolved, cfg.deniedPaths) ||
525
+ recursiveSearchMayReachDeniedPath(
526
+ policyPath,
527
+ cfg.deniedPaths,
528
+ ));
529
+ if (deniedSearchScope) {
530
+ return block(ctx, {
531
+ timestamp: Date.now(),
532
+ toolName: event.toolName,
533
+ reason: `Search scope can contain a path denied by policy: ${policyPath}`,
534
+ action: summary,
535
+ kind: "deterministic-path-deny",
536
+ }, logCtx);
537
+ }
369
538
  if (cfg.allowInsideWorkingDirectory) {
370
539
  const policyCwd = resolvePathForPolicy(ctx.cwd) ?? ctx.cwd;
371
540
  if (isInside(policyPath, policyCwd)) {
372
- // Protected in-tree writes must still reach the classifier;
373
- // otherwise the allow tier bypasses the protected-path policy
374
- // for sensitive repository content such as .git/hooks/*, .pi/*,
375
- // .husky/*, or .gitignore.
376
- if (
541
+ // Protected in-tree writes and accepted ask rules must still
542
+ // reach the classifier. They cannot use the inside-CWD tier.
543
+ const protectedWrite =
377
544
  (event.toolName === "write" || event.toolName === "edit") &&
378
- isProtectedPath(policyPath, policyCwd, cfg.protectedPaths)
379
- ) {
380
- readOnlyFastPath = false;
381
- } else {
545
+ isProtectedPath(policyPath, policyCwd, cfg.protectedPaths);
546
+ if (!askRequiresClassifier && !protectedWrite) {
382
547
  return allow(
383
548
  ctx,
384
549
  "inside-working-directory",
@@ -389,13 +554,54 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
389
554
  );
390
555
  }
391
556
  }
392
- // Outside the working directory: the read-only fast path must not
393
- // apply; the classifier reviews this call.
557
+ // Outside the working directory, protected writes, and accepted
558
+ // ask rules must not use the read-only fast path.
394
559
  readOnlyFastPath = false;
395
560
  }
396
561
  }
397
562
  }
398
563
 
564
+ // Deterministic allow tier. It runs after every deterministic denial.
565
+ // Accepted ask rules skip this tier and always reach the classifier.
566
+ if (!askRequiresClassifier) {
567
+ for (const pattern of cfg.permissionAllow) {
568
+ if (
569
+ !matchesToolPattern(
570
+ pattern,
571
+ event.toolName,
572
+ input,
573
+ ctx.cwd,
574
+ "no-match",
575
+ )
576
+ ) {
577
+ continue;
578
+ }
579
+ // A protected-path write/edit is never covered by permissions.allow;
580
+ // it stays on the classifier path (same rule as the inside-CWD tier).
581
+ if (event.toolName === "write" || event.toolName === "edit") {
582
+ const inputPath = extractInputPath(event.toolName, input);
583
+ const resolved = inputPath === undefined
584
+ ? undefined
585
+ : resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
586
+ inputPath;
587
+ if (
588
+ resolved !== undefined &&
589
+ isProtectedPath(resolved, ctx.cwd, cfg.protectedPaths)
590
+ ) {
591
+ break;
592
+ }
593
+ }
594
+ return allow(
595
+ ctx,
596
+ "permissions.allow",
597
+ `Allowed by permissions.allow: ${pattern.raw}`,
598
+ event.toolName,
599
+ summary,
600
+ logCtx,
601
+ );
602
+ }
603
+ }
604
+
399
605
  if (readOnlyFastPath) {
400
606
  return allow(
401
607
  ctx,
@@ -407,7 +613,12 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
407
613
  );
408
614
  }
409
615
 
410
- const decision = await classify(ctx, cfg, summary, loadedContext);
616
+ const decision = await classify(
617
+ ctx,
618
+ cfg,
619
+ serializeClassifierAction(event.toolName, input),
620
+ loadedContext,
621
+ );
411
622
  logClassifierIo(decision, logCtx);
412
623
  if (decision.decision === "allow") {
413
624
  state.classifierAllowed += 1;
@@ -431,6 +642,28 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
431
642
  }, logCtx);
432
643
  });
433
644
 
645
+ pi.registerTool({
646
+ name: INSPECT_TOOL,
647
+ label: "Inspect Auto Mode",
648
+ description:
649
+ "Inspect the active pi-automode status, effective config, built-in defaults, or recent denial metadata. This tool is read-only and cannot enable, disable, reload, reset, or reconfigure auto mode. Its output is sent to the current model; denial reasons and action payloads are omitted.",
650
+ promptSnippet:
651
+ "Inspect active pi-automode state and diagnostic information without changing it",
652
+ parameters: Type.Object({
653
+ action: StringEnum(INSPECTION_ACTIONS, {
654
+ description: "The read-only auto-mode view to return",
655
+ }),
656
+ }),
657
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
658
+ if (signal?.aborted) throw new Error("Auto-mode inspection cancelled");
659
+ const result = inspectAutomode(params.action, ctx);
660
+ return {
661
+ content: [{ type: "text", text: safeJson(result, 16000) }],
662
+ details: result,
663
+ };
664
+ },
665
+ });
666
+
434
667
  async function handleAutomodeCommand(
435
668
  args: string,
436
669
  ctx: ExtensionCommandContext,
@@ -460,7 +693,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
460
693
  return;
461
694
  }
462
695
  if (command === "reload") {
463
- loadResult = loadConfigWithDiagnostics(ctx.cwd);
696
+ loadResult = loadConfigWithDiagnostics(
697
+ ctx.cwd,
698
+ ctx.isProjectTrusted(),
699
+ );
464
700
  config = loadResult.config;
465
701
  configDiagnostics = loadResult.diagnostics;
466
702
  persist();
@@ -504,8 +740,11 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
504
740
  if (command === "config") {
505
741
  const logFile = resolveLogPath(
506
742
  ctx.sessionManager.getSessionFile?.(),
507
- ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
743
+ ctx.sessionManager.getSessionDir?.() ?? "",
508
744
  ctx.sessionManager.getSessionId?.() ?? "unknown",
745
+ ctx.cwd,
746
+ options.logRoot,
747
+ now(),
509
748
  );
510
749
  ctx.ui.notify(
511
750
  safeJson(
@@ -561,7 +800,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
561
800
  );
562
801
  return;
563
802
  }
564
- loadResult = loadConfigWithDiagnostics(ctx.cwd);
803
+ loadResult = loadConfigWithDiagnostics(
804
+ ctx.cwd,
805
+ ctx.isProjectTrusted(),
806
+ );
565
807
  config = loadResult.config;
566
808
  configDiagnostics = loadResult.diagnostics;
567
809
  persist();
@@ -1,6 +1,7 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { appendFileSync, mkdirSync } from "node:fs";
3
- import { basename, dirname, extname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
4
5
  import type {
5
6
  ClassifierIo,
6
7
  ClassifierIoAttempt,
@@ -65,9 +66,32 @@ export type LoggerOptions = {
65
66
  classifierIo: boolean;
66
67
  sessionFile?: string;
67
68
  sessionDir: string;
69
+ /** Effective cwd for an in-memory session. */
70
+ sessionCwd?: string;
68
71
  sessionId: string;
72
+ /** Test/embedder override. Runtime uses ~/.pi/agent/extensions/pi-automode/logs. */
73
+ logRoot?: string;
74
+ /** Test clock used for the UTC date partition. */
75
+ now?: Date;
69
76
  };
70
77
 
78
+ export const DEFAULT_AUTOMODE_LOG_ROOT = join(
79
+ homedir(),
80
+ ".pi/agent/extensions/pi-automode/logs",
81
+ );
82
+
83
+ const VALID_SESSION_ID =
84
+ /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
85
+
86
+ function safeLogSessionId(sessionId: string): string {
87
+ if (VALID_SESSION_ID.test(sessionId)) return sessionId;
88
+ const digest = createHash("sha256")
89
+ .update(sessionId)
90
+ .digest("hex")
91
+ .slice(0, 16);
92
+ return `invalid-${digest}`;
93
+ }
94
+
71
95
  /** Short id linking a classifier entry to its decision entry in the same file. */
72
96
  export function newDecisionId(): string {
73
97
  return randomBytes(4).toString("hex");
@@ -76,19 +100,43 @@ export function newDecisionId(): string {
76
100
  /**
77
101
  * Derive the log file path from the current session: the session file's
78
102
  * directory with `-pi-automode` inserted before the extension. Falls back to
79
- * `<sessionDir>/<sessionId>-pi-automode.jsonl` when no session file is set.
103
+ * an absolute session directory when one is available. In-memory sessions use
104
+ * an application-owned, project- and date-partitioned directory instead of a
105
+ * relative path resolved against the launching process cwd.
80
106
  */
81
107
  export function resolveLogPath(
82
108
  sessionFile: string | undefined,
83
109
  sessionDir: string,
84
110
  sessionId: string,
111
+ sessionCwd = process.cwd(),
112
+ logRoot = DEFAULT_AUTOMODE_LOG_ROOT,
113
+ now = new Date(),
85
114
  ): string {
86
115
  if (sessionFile) {
87
116
  const ext = extname(sessionFile);
88
117
  const stem = ext ? basename(sessionFile, ext) : basename(sessionFile);
89
118
  return join(dirname(sessionFile), `${stem}-pi-automode${ext}`);
90
119
  }
91
- return join(sessionDir, `${sessionId}-pi-automode.jsonl`);
120
+
121
+ const logFile = `${safeLogSessionId(sessionId)}-pi-automode.jsonl`;
122
+ if (isAbsolute(sessionDir)) {
123
+ return join(sessionDir, logFile);
124
+ }
125
+
126
+ const resolvedCwd = resolve(sessionCwd);
127
+ const projectDir = `--${
128
+ resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")
129
+ }--`;
130
+ const dateDir = now.toISOString().slice(0, 10);
131
+ const resolvedLogRoot = isAbsolute(logRoot)
132
+ ? logRoot
133
+ : DEFAULT_AUTOMODE_LOG_ROOT;
134
+ return join(
135
+ resolvedLogRoot,
136
+ projectDir,
137
+ dateDir,
138
+ logFile,
139
+ );
92
140
  }
93
141
 
94
142
  /** Append one JSON object as a line. Failures are swallowed: logging must
@@ -105,7 +153,14 @@ function appendJsonl(path: string, entry: unknown): void {
105
153
  /** Build a logger bound to one session's log path. No-ops when disabled. */
106
154
  export function createLogger(opts: LoggerOptions): Logger {
107
155
  const { enabled, classifierIo } = opts;
108
- const path = resolveLogPath(opts.sessionFile, opts.sessionDir, opts.sessionId);
156
+ const path = resolveLogPath(
157
+ opts.sessionFile,
158
+ opts.sessionDir,
159
+ opts.sessionId,
160
+ opts.sessionCwd,
161
+ opts.logRoot,
162
+ opts.now,
163
+ );
109
164
  return {
110
165
  enabled,
111
166
  classifierIo,