@gotgenes/pi-permission-system 5.6.3 → 5.8.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/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.8.0](https://github.com/gotgenes/pi-permission-system/compare/v5.7.0...v5.8.0) (2026-05-08)
9
+
10
+
11
+ ### Features
12
+
13
+ * add SessionLogger interface and createSessionLogger factory ([#127](https://github.com/gotgenes/pi-permission-system/issues/127)) ([8765ab8](https://github.com/gotgenes/pi-permission-system/commit/8765ab8cfe461324fc2a89c80486d3dde190d9d9))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan SessionLogger extraction ([#127](https://github.com/gotgenes/pi-permission-system/issues/127)) ([b13ac62](https://github.com/gotgenes/pi-permission-system/commit/b13ac62513d4b233ee4fc3f554324a54518f75ba))
19
+ * **retro:** add retro notes for issue [#126](https://github.com/gotgenes/pi-permission-system/issues/126) ([3d8a38a](https://github.com/gotgenes/pi-permission-system/commit/3d8a38a09f9dfd2570178c856aec260ebdba89b1))
20
+ * update architecture doc for SessionLogger ([#127](https://github.com/gotgenes/pi-permission-system/issues/127)) ([8fa4123](https://github.com/gotgenes/pi-permission-system/commit/8fa41237dc1b43cbe4487ba7d0acf75dc768ad9c))
21
+
22
+ ## [5.7.0](https://github.com/gotgenes/pi-permission-system/compare/v5.6.3...v5.7.0) (2026-05-08)
23
+
24
+
25
+ ### Features
26
+
27
+ * extract ExtensionPaths value object ([#126](https://github.com/gotgenes/pi-permission-system/issues/126)) ([85bc347](https://github.com/gotgenes/pi-permission-system/commit/85bc347d3ed487210ffbed4c1c53616b5cf0d978))
28
+
29
+
30
+ ### Documentation
31
+
32
+ * add handler decomposition plan ([#126](https://github.com/gotgenes/pi-permission-system/issues/126), [#127](https://github.com/gotgenes/pi-permission-system/issues/127), [#128](https://github.com/gotgenes/pi-permission-system/issues/128), [#129](https://github.com/gotgenes/pi-permission-system/issues/129), [#130](https://github.com/gotgenes/pi-permission-system/issues/130)) ([5a116a6](https://github.com/gotgenes/pi-permission-system/commit/5a116a6cf2f6ef29f5e6550821bb26b6e1c3a90f))
33
+ * add structural design heuristics, design-review skill, and plan-issue hook ([d8e3233](https://github.com/gotgenes/pi-permission-system/commit/d8e32330baa25fa5a5abaf75f8e442ce650fe5a9))
34
+ * extract code-style, testing, and markdown-conventions skills from AGENTS.md ([9d5ba7a](https://github.com/gotgenes/pi-permission-system/commit/9d5ba7a4a4a7a9e9fbdfe869fb42840238351b81))
35
+ * plan ExtensionPaths value object extraction ([#126](https://github.com/gotgenes/pi-permission-system/issues/126)) ([d76e6cc](https://github.com/gotgenes/pi-permission-system/commit/d76e6cc255dc124a7a914e8d178113e5b7c8bddd))
36
+ * rename target-architecture to architecture, strip progress indicators ([9776550](https://github.com/gotgenes/pi-permission-system/commit/9776550351f3b59f96bdad614cc5129a7be52a51))
37
+ * **retro:** add retro notes for issue [#110](https://github.com/gotgenes/pi-permission-system/issues/110) ([5597de3](https://github.com/gotgenes/pi-permission-system/commit/5597de3c9c64c3d672a1fc77ba7910e952545824))
38
+
8
39
  ## [5.6.3](https://github.com/gotgenes/pi-permission-system/compare/v5.6.2...v5.6.3) (2026-05-07)
9
40
 
10
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.6.3",
3
+ "version": "5.8.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,55 @@
1
+ import { join } from "node:path";
2
+ import { getGlobalLogsDir } from "./config-paths";
3
+ import { discoverGlobalNodeModulesRoot } from "./node-modules-discovery";
4
+
5
+ /**
6
+ * Immutable path constants derived from `agentDir` at construction time.
7
+ *
8
+ * Computed once at startup in `computeExtensionPaths()` and embedded into
9
+ * `ExtensionRuntime`. Later refactorings (#129 PermissionSession, #130
10
+ * handler classes) consume this as a single dep instead of individual fields.
11
+ */
12
+ export interface ExtensionPaths {
13
+ readonly agentDir: string;
14
+ readonly sessionsDir: string;
15
+ readonly subagentSessionsDir: string;
16
+ readonly forwardingDir: string;
17
+ readonly globalLogsDir: string;
18
+ /**
19
+ * Static Pi infrastructure directories used for external-directory
20
+ * read auto-allow. Computed once from `agentDir` and
21
+ * `discoverGlobalNodeModulesRoot()`. Config-based extras
22
+ * (`piInfrastructureReadPaths`) are read from `runtime.config` at
23
+ * call time in the handler so they pick up config reloads.
24
+ */
25
+ readonly piInfrastructureDirs: readonly string[];
26
+ }
27
+
28
+ /**
29
+ * Compute all immutable path constants from `agentDir`.
30
+ *
31
+ * Calls `discoverGlobalNodeModulesRoot()` internally so the result is
32
+ * self-contained. Call this once at extension startup, not at module scope.
33
+ */
34
+ export function computeExtensionPaths(agentDir: string): ExtensionPaths {
35
+ const sessionsDir = join(agentDir, "sessions");
36
+ const subagentSessionsDir = join(agentDir, "subagent-sessions");
37
+ const forwardingDir = join(sessionsDir, "permission-forwarding");
38
+ const globalLogsDir = getGlobalLogsDir(agentDir);
39
+
40
+ const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
41
+ const piInfrastructureDirs: string[] = [
42
+ agentDir,
43
+ join(agentDir, "git"),
44
+ ...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
45
+ ];
46
+
47
+ return {
48
+ agentDir,
49
+ sessionsDir,
50
+ subagentSessionsDir,
51
+ forwardingDir,
52
+ globalLogsDir,
53
+ piInfrastructureDirs,
54
+ };
55
+ }
@@ -1,14 +1,14 @@
1
1
  import { getNonEmptyString, toRecord } from "../../common";
2
- import {
3
- extractExternalPathsFromBashCommand,
4
- formatBashExternalDirectoryAskPrompt,
5
- formatBashExternalDirectoryDenyReason,
6
- formatExternalDirectoryHardStopHint,
7
- } from "../../external-directory";
8
2
  import type { Rule } from "../../rule";
9
3
  import { deriveApprovalPattern } from "../../session-rules";
10
4
  import type { PermissionCheckResult } from "../../types";
5
+ import { extractExternalPathsFromBashCommand } from "./bash-path-extractor";
11
6
  import type { GateResult } from "./descriptor";
7
+ import {
8
+ formatBashExternalDirectoryAskPrompt,
9
+ formatBashExternalDirectoryDenyReason,
10
+ formatExternalDirectoryHardStopHint,
11
+ } from "./external-directory-messages";
12
12
  import type { ToolCallContext } from "./types";
13
13
 
14
14
  /** Function type for checkPermission used by the descriptor factory. */
@@ -4,7 +4,7 @@ import { basename } from "node:path";
4
4
  import {
5
5
  isPathOutsideWorkingDirectory,
6
6
  normalizePathForComparison,
7
- } from "./path-utils";
7
+ } from "../../path-utils";
8
8
 
9
9
  // ── tree-sitter-bash lazy parser ───────────────────────────────────────────
10
10
 
@@ -1,14 +1,16 @@
1
1
  import {
2
- formatExternalDirectoryAskPrompt,
3
- formatExternalDirectoryDenyReason,
4
- formatExternalDirectoryUserDeniedReason,
5
2
  getPathBearingToolPath,
6
3
  isPathOutsideWorkingDirectory,
7
4
  isPiInfrastructureRead,
8
- } from "../../external-directory";
9
- import { normalizePathForComparison } from "../../path-utils";
5
+ normalizePathForComparison,
6
+ } from "../../path-utils";
10
7
  import { deriveApprovalPattern } from "../../session-rules";
11
8
  import type { GateResult } from "./descriptor";
9
+ import {
10
+ formatExternalDirectoryAskPrompt,
11
+ formatExternalDirectoryDenyReason,
12
+ formatExternalDirectoryUserDeniedReason,
13
+ } from "./external-directory-messages";
12
14
  import type { ToolCallContext } from "./types";
13
15
 
14
16
  /**
@@ -1,4 +1,4 @@
1
- import { PATH_BEARING_TOOLS } from "../../external-directory";
1
+ import { PATH_BEARING_TOOLS } from "../../path-utils";
2
2
  import { suggestSessionPattern } from "../../pattern-suggest";
3
3
  import {
4
4
  formatAskPrompt,
@@ -82,7 +82,7 @@ export async function handleInput(
82
82
  skillInputAutoApproved = decision.autoApproved === true;
83
83
  return decision;
84
84
  },
85
- writeLog: deps.writeReviewLog,
85
+ writeLog: deps.logger.review,
86
86
  logContext: {
87
87
  source: "skill_input",
88
88
  skillName,
@@ -33,11 +33,11 @@ export async function handleSessionStart(
33
33
  const policyIssues =
34
34
  deps.session.permissionManager.getConfigIssues(agentName);
35
35
  for (const issue of policyIssues) {
36
- deps.notifyWarning(issue);
36
+ deps.logger.warn(issue);
37
37
  }
38
38
 
39
39
  if (event.reason === "reload") {
40
- deps.writeDebugLog("lifecycle.reload", {
40
+ deps.logger.debug("lifecycle.reload", {
41
41
  triggeredBy: "session_start",
42
42
  reason: event.reason,
43
43
  cwd: ctx.cwd,
@@ -60,7 +60,7 @@ export async function handleResourcesDiscover(
60
60
  deps.session.activeSkillEntries = [];
61
61
  deps.session.lastActiveToolsCacheKey = null;
62
62
  deps.session.lastPromptStateCacheKey = null;
63
- deps.writeDebugLog("lifecycle.reload", {
63
+ deps.logger.debug("lifecycle.reload", {
64
64
  triggeredBy: "resources_discover",
65
65
  reason: event.reason,
66
66
  cwd: runtimeContext?.cwd ?? null,
@@ -91,7 +91,7 @@ export async function handleToolCall(
91
91
  deps.promptPermission(ctx, details);
92
92
  const emitDecision: GateRunnerDeps["emitDecision"] = (e) =>
93
93
  emitDecisionEvent(deps.events, e);
94
- const { writeReviewLog } = deps;
94
+ const { review: writeReviewLog } = deps.logger;
95
95
  const checkPermission: GateRunnerDeps["checkPermission"] = (
96
96
  surface,
97
97
  input,
@@ -4,6 +4,7 @@ import type { PermissionPromptDecision } from "../permission-dialog";
4
4
  import type { PermissionEventBus } from "../permission-events";
5
5
  import type { PermissionManager } from "../permission-manager";
6
6
  import type { SessionState } from "../runtime";
7
+ import type { SessionLogger } from "../session-logger";
7
8
 
8
9
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
9
10
 
@@ -37,12 +38,11 @@ export interface HandlerDeps {
37
38
  /** Mutable session state: permissionManager, sessionRules, cache keys. */
38
39
  readonly session: SessionState;
39
40
 
40
- // ── Logging (promoted from runtime) ───────────────────────────────────
41
- writeDebugLog(event: string, details?: Record<string, unknown>): void;
42
- writeReviewLog(event: string, details?: Record<string, unknown>): void;
41
+ // ── Logging ────────────────────────────────────────────────────────────
42
+ readonly logger: SessionLogger;
43
43
 
44
44
  // ── Immutable infrastructure paths ───────────────────────────────────
45
- readonly piInfrastructureDirs: string[];
45
+ readonly piInfrastructureDirs: readonly string[];
46
46
  /** Returns config-derived infrastructure read paths (current at call time). */
47
47
  getPiInfrastructureReadPaths(): string[];
48
48
 
@@ -59,8 +59,6 @@ export interface HandlerDeps {
59
59
  // ── Config & lifecycle helpers ─────────────────────────────────────────
60
60
  /** Reload merged config from disk; optionally update the stored runtime context. */
61
61
  refreshExtensionConfig(ctx?: ExtensionContext): void;
62
- /** Show a warning notification to the user (no-op when no UI is available). */
63
- notifyWarning(message: string): void;
64
62
  /** Write the resolved config path set to the review and debug logs. */
65
63
  logResolvedConfigPaths(): void;
66
64
 
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  startForwardedPermissionPolling,
26
26
  stopForwardedPermissionPolling,
27
27
  } from "./runtime";
28
+ import { createSessionLogger } from "./session-logger";
28
29
  import { isSubagentExecutionContext } from "./subagent-context";
29
30
  import {
30
31
  canResolveAskPermissionRequest,
@@ -79,8 +80,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
79
80
 
80
81
  const deps: HandlerDeps = {
81
82
  session: runtime,
82
- writeDebugLog: (event, details) => runtime.writeDebugLog(event, details),
83
- writeReviewLog: (event, details) => runtime.writeReviewLog(event, details),
83
+ logger: createSessionLogger(runtime),
84
84
  piInfrastructureDirs: runtime.piInfrastructureDirs,
85
85
  getPiInfrastructureReadPaths: () =>
86
86
  runtime.config.piInfrastructureReadPaths ?? [],
@@ -88,8 +88,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
88
88
  createPermissionManagerForCwd: (cwd) =>
89
89
  createPermissionManagerForCwd(runtime.agentDir, cwd),
90
90
  refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
91
- notifyWarning: (message) =>
92
- runtime.runtimeContext?.ui.notify(message, "warning"),
93
91
  logResolvedConfigPaths: () => logResolvedConfigPaths(runtime),
94
92
  resolveAgentName: (ctx, systemPrompt) =>
95
93
  resolveAgentName(runtime, ctx, systemPrompt),
package/src/runtime.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  type ExtensionContext,
12
12
  getAgentDir,
13
13
  } from "@mariozechner/pi-coding-agent";
14
+
14
15
  import {
15
16
  getActiveAgentName,
16
17
  getActiveAgentNameFromSystemPrompt,
@@ -19,7 +20,6 @@ import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
19
20
  import {
20
21
  DEBUG_LOG_FILENAME,
21
22
  getGlobalConfigPath,
22
- getGlobalLogsDir,
23
23
  getLegacyExtensionConfigPath,
24
24
  getLegacyGlobalPolicyPath,
25
25
  getLegacyProjectPolicyPath,
@@ -34,7 +34,10 @@ import {
34
34
  normalizePermissionSystemConfig,
35
35
  type PermissionSystemExtensionConfig,
36
36
  } from "./extension-config";
37
- import { discoverGlobalNodeModulesRoot } from "./external-directory";
37
+ import { computeExtensionPaths, type ExtensionPaths } from "./extension-paths";
38
+
39
+ export type { ExtensionPaths } from "./extension-paths";
40
+
38
41
  import {
39
42
  type PermissionForwardingDeps,
40
43
  processForwardedPermissionRequests,
@@ -73,22 +76,7 @@ export interface SessionState {
73
76
  * Tests construct this via `createExtensionRuntime({ agentDir: tmpDir })`
74
77
  * without timing issues around `PI_CODING_AGENT_DIR`.
75
78
  */
76
- export interface ExtensionRuntime extends SessionState {
77
- // ── Immutable paths (derived from agentDir at construction) ───────────
78
- readonly agentDir: string;
79
- readonly sessionsDir: string;
80
- readonly subagentSessionsDir: string;
81
- readonly forwardingDir: string;
82
- readonly globalLogsDir: string;
83
- /**
84
- * Static Pi infrastructure directories used for external-directory
85
- * read auto-allow. Computed once at construction from `agentDir` and
86
- * `discoverGlobalNodeModulesRoot()`. Config-based extras
87
- * (`piInfrastructureReadPaths`) are read from `runtime.config` at
88
- * call time in the handler so they pick up config reloads.
89
- */
90
- readonly piInfrastructureDirs: string[];
91
-
79
+ export interface ExtensionRuntime extends ExtensionPaths, SessionState {
92
80
  // ── Mutable state (beyond SessionState) ───────────────────────────────────
93
81
  config: PermissionSystemExtensionConfig;
94
82
  lastConfigWarning: string | null;
@@ -353,27 +341,12 @@ export function createExtensionRuntime(options?: {
353
341
  agentDir?: string;
354
342
  }): ExtensionRuntime {
355
343
  const agentDir = options?.agentDir ?? getAgentDir();
356
- const sessionsDir = join(agentDir, "sessions");
357
- const subagentSessionsDir = join(agentDir, "subagent-sessions");
358
- const forwardingDir = join(sessionsDir, "permission-forwarding");
359
- const globalLogsDir = getGlobalLogsDir(agentDir);
360
-
361
- const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
362
- const piInfrastructureDirs: string[] = [
363
- agentDir,
364
- join(agentDir, "git"),
365
- ...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
366
- ];
344
+ const paths = computeExtensionPaths(agentDir);
367
345
 
368
346
  // Build a plain-object runtime first so the logger's `getConfig` closure
369
347
  // can reference `runtime.config` directly (always reads current value).
370
348
  const runtime: ExtensionRuntime = {
371
- agentDir,
372
- sessionsDir,
373
- subagentSessionsDir,
374
- forwardingDir,
375
- globalLogsDir,
376
- piInfrastructureDirs,
349
+ ...paths,
377
350
  config: { ...DEFAULT_EXTENSION_CONFIG },
378
351
  runtimeContext: null,
379
352
  permissionManager: createPermissionManagerForCwd(agentDir, undefined),
@@ -395,10 +368,10 @@ export function createExtensionRuntime(options?: {
395
368
  const logger = createPermissionSystemLogger({
396
369
  // Reads runtime.config at call time — always current.
397
370
  getConfig: () => runtime.config,
398
- debugLogPath: join(globalLogsDir, DEBUG_LOG_FILENAME),
399
- reviewLogPath: join(globalLogsDir, REVIEW_LOG_FILENAME),
371
+ debugLogPath: join(paths.globalLogsDir, DEBUG_LOG_FILENAME),
372
+ reviewLogPath: join(paths.globalLogsDir, REVIEW_LOG_FILENAME),
400
373
  ensureLogsDirectory: () =>
401
- ensurePermissionSystemLogsDirectory(globalLogsDir),
374
+ ensurePermissionSystemLogsDirectory(paths.globalLogsDir),
402
375
  });
403
376
 
404
377
  const reportLoggingWarning = (message: string): void => {
@@ -0,0 +1,29 @@
1
+ import type { ExtensionRuntime } from "./runtime";
2
+
3
+ /**
4
+ * Unified logging + notification surface for handler deps.
5
+ *
6
+ * Replaces three separate HandlerDeps fields (`writeDebugLog`,
7
+ * `writeReviewLog`, `notifyWarning`) with a single typed collaborator.
8
+ * This is an intermediate abstraction on the path to PermissionSession (#129).
9
+ */
10
+ export interface SessionLogger {
11
+ debug(event: string, details?: Record<string, unknown>): void;
12
+ review(event: string, details?: Record<string, unknown>): void;
13
+ warn(message: string): void;
14
+ }
15
+
16
+ /**
17
+ * Create a SessionLogger backed by an ExtensionRuntime.
18
+ *
19
+ * Captures `runtime` by reference so `warn` always reads the current
20
+ * `runtimeContext` at call time — matching the behavior of the inline
21
+ * closures it replaces in `src/index.ts`.
22
+ */
23
+ export function createSessionLogger(runtime: ExtensionRuntime): SessionLogger {
24
+ return {
25
+ debug: (event, details) => runtime.writeDebugLog(event, details),
26
+ review: (event, details) => runtime.writeReviewLog(event, details),
27
+ warn: (message) => runtime.runtimeContext?.ui.notify(message, "warning"),
28
+ };
29
+ }
@@ -9,11 +9,11 @@ vi.mock("node:os", () => {
9
9
  };
10
10
  });
11
11
 
12
+ import { extractExternalPathsFromBashCommand } from "../src/handlers/gates/bash-path-extractor";
12
13
  import {
13
- extractExternalPathsFromBashCommand,
14
14
  formatBashExternalDirectoryAskPrompt,
15
15
  formatBashExternalDirectoryDenyReason,
16
- } from "../src/external-directory";
16
+ } from "../src/handlers/gates/external-directory-messages";
17
17
 
18
18
  afterEach(() => {
19
19
  vi.restoreAllMocks();
@@ -0,0 +1,89 @@
1
+ import { join } from "node:path";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const { mockDiscoverGlobalNodeModulesRoot } = vi.hoisted(() => ({
5
+ mockDiscoverGlobalNodeModulesRoot: vi.fn<() => string | null>(),
6
+ }));
7
+
8
+ vi.mock("../src/node-modules-discovery", () => ({
9
+ discoverGlobalNodeModulesRoot: mockDiscoverGlobalNodeModulesRoot,
10
+ }));
11
+
12
+ import { getGlobalLogsDir } from "../src/config-paths";
13
+ import { computeExtensionPaths } from "../src/extension-paths";
14
+
15
+ describe("computeExtensionPaths", () => {
16
+ beforeEach(() => {
17
+ mockDiscoverGlobalNodeModulesRoot.mockReset();
18
+ mockDiscoverGlobalNodeModulesRoot.mockReturnValue(
19
+ "/mock/global/node_modules",
20
+ );
21
+ });
22
+
23
+ it("sets agentDir from argument", () => {
24
+ const paths = computeExtensionPaths("/test/agent");
25
+ expect(paths.agentDir).toBe("/test/agent");
26
+ });
27
+
28
+ it("derives sessionsDir as agentDir/sessions", () => {
29
+ const paths = computeExtensionPaths("/test/agent");
30
+ expect(paths.sessionsDir).toBe("/test/agent/sessions");
31
+ });
32
+
33
+ it("derives subagentSessionsDir as agentDir/subagent-sessions", () => {
34
+ const paths = computeExtensionPaths("/test/agent");
35
+ expect(paths.subagentSessionsDir).toBe("/test/agent/subagent-sessions");
36
+ });
37
+
38
+ it("derives forwardingDir as sessionsDir/permission-forwarding", () => {
39
+ const paths = computeExtensionPaths("/test/agent");
40
+ expect(paths.forwardingDir).toBe(
41
+ join("/test/agent/sessions", "permission-forwarding"),
42
+ );
43
+ });
44
+
45
+ it("derives globalLogsDir via getGlobalLogsDir(agentDir)", () => {
46
+ const paths = computeExtensionPaths("/test/agent");
47
+ expect(paths.globalLogsDir).toBe(getGlobalLogsDir("/test/agent"));
48
+ });
49
+
50
+ it("includes agentDir in piInfrastructureDirs", () => {
51
+ const paths = computeExtensionPaths("/test/agent");
52
+ expect(paths.piInfrastructureDirs).toContain("/test/agent");
53
+ });
54
+
55
+ it("includes agentDir/git in piInfrastructureDirs", () => {
56
+ const paths = computeExtensionPaths("/test/agent");
57
+ expect(paths.piInfrastructureDirs).toContain("/test/agent/git");
58
+ });
59
+
60
+ it("includes discovered global node_modules root in piInfrastructureDirs", () => {
61
+ const paths = computeExtensionPaths("/test/agent");
62
+ expect(paths.piInfrastructureDirs).toContain("/mock/global/node_modules");
63
+ });
64
+
65
+ it("omits global node_modules from piInfrastructureDirs when discovery returns null", () => {
66
+ mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
67
+ const paths = computeExtensionPaths("/test/agent");
68
+ expect(paths.piInfrastructureDirs).toHaveLength(2);
69
+ expect(paths.piInfrastructureDirs).toContain("/test/agent");
70
+ expect(paths.piInfrastructureDirs).toContain("/test/agent/git");
71
+ });
72
+
73
+ it("all entries in piInfrastructureDirs are strings (no null)", () => {
74
+ mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
75
+ const paths = computeExtensionPaths("/test/agent");
76
+ for (const dir of paths.piInfrastructureDirs) {
77
+ expect(typeof dir).toBe("string");
78
+ }
79
+ });
80
+
81
+ it("two calls with different agentDirs produce independent results", () => {
82
+ const a = computeExtensionPaths("/agent/a");
83
+ const b = computeExtensionPaths("/agent/b");
84
+ expect(a.agentDir).toBe("/agent/a");
85
+ expect(b.agentDir).toBe("/agent/b");
86
+ expect(a.sessionsDir).toBe("/agent/a/sessions");
87
+ expect(b.sessionsDir).toBe("/agent/b/sessions");
88
+ });
89
+ });
@@ -77,13 +77,11 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
77
77
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
78
78
  return {
79
79
  session: makeSession(),
80
- writeDebugLog: vi.fn(),
81
- writeReviewLog: vi.fn(),
80
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
82
81
  piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
83
82
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
84
83
  createPermissionManagerForCwd: vi.fn().mockReturnValue(makePm()),
85
84
  refreshExtensionConfig: vi.fn(),
86
- notifyWarning: vi.fn(),
87
85
  logResolvedConfigPaths: vi.fn(),
88
86
  resolveAgentName: vi.fn().mockReturnValue(null),
89
87
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
@@ -7,7 +7,7 @@ import {
7
7
  formatExternalDirectoryDenyReason,
8
8
  formatExternalDirectoryHardStopHint,
9
9
  formatExternalDirectoryUserDeniedReason,
10
- } from "../src/external-directory-messages";
10
+ } from "../../../src/handlers/gates/external-directory-messages";
11
11
 
12
12
  describe("formatExternalDirectoryHardStopHint", () => {
13
13
  test("returns the hard stop instruction string", () => {
@@ -68,14 +68,12 @@ function makeDeps(
68
68
  ): HandlerDeps {
69
69
  return {
70
70
  session: makeSession(state),
71
- writeDebugLog: vi.fn(),
72
- writeReviewLog: vi.fn(),
71
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
73
72
  piInfrastructureDirs: ["/test/agent"],
74
73
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
75
74
  events: makeEvents(),
76
75
  createPermissionManagerForCwd: vi.fn(),
77
76
  refreshExtensionConfig: vi.fn(),
78
- notifyWarning: vi.fn(),
79
77
  logResolvedConfigPaths: vi.fn(),
80
78
  resolveAgentName: vi.fn().mockReturnValue(null),
81
79
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
@@ -56,13 +56,11 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
56
56
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
57
57
  return {
58
58
  session: makeSession(),
59
- writeDebugLog: vi.fn(),
60
- writeReviewLog: vi.fn(),
59
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
61
60
  piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
62
61
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
63
62
  createPermissionManagerForCwd: vi.fn(),
64
63
  refreshExtensionConfig: vi.fn(),
65
- notifyWarning: vi.fn(),
66
64
  logResolvedConfigPaths: vi.fn(),
67
65
  resolveAgentName: vi.fn().mockReturnValue(null),
68
66
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
@@ -83,15 +83,13 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
83
83
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
84
84
  return {
85
85
  session: makeSession(),
86
- writeDebugLog: vi.fn(),
87
- writeReviewLog: vi.fn(),
86
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
88
87
  piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
89
88
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
90
89
  createPermissionManagerForCwd: vi
91
90
  .fn()
92
91
  .mockReturnValue(makePermissionManager()),
93
92
  refreshExtensionConfig: vi.fn(),
94
- notifyWarning: vi.fn(),
95
93
  logResolvedConfigPaths: vi.fn(),
96
94
  resolveAgentName: vi.fn().mockReturnValue(null),
97
95
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
@@ -189,21 +187,21 @@ describe("handleSessionStart", () => {
189
187
  createPermissionManagerForCwd: vi.fn().mockReturnValue(pm),
190
188
  });
191
189
  await handleSessionStart(deps, { reason: "startup" }, makeCtx());
192
- expect(deps.notifyWarning).toHaveBeenCalledWith("issue A");
193
- expect(deps.notifyWarning).toHaveBeenCalledWith("issue B");
190
+ expect(deps.logger.warn).toHaveBeenCalledWith("issue A");
191
+ expect(deps.logger.warn).toHaveBeenCalledWith("issue B");
194
192
  });
195
193
 
196
194
  it("does not call notifyWarning when there are no policy issues", async () => {
197
195
  const deps = makeDeps();
198
196
  await handleSessionStart(deps, { reason: "startup" }, makeCtx());
199
- expect(deps.notifyWarning).not.toHaveBeenCalled();
197
+ expect(deps.logger.warn).not.toHaveBeenCalled();
200
198
  });
201
199
 
202
200
  it("writes lifecycle.reload debug log when reason is reload", async () => {
203
201
  const ctx = makeCtx({ cwd: "/proj" });
204
202
  const deps = makeDeps();
205
203
  await handleSessionStart(deps, { reason: "reload" }, ctx);
206
- expect(deps.writeDebugLog).toHaveBeenCalledWith("lifecycle.reload", {
204
+ expect(deps.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
207
205
  triggeredBy: "session_start",
208
206
  reason: "reload",
209
207
  cwd: "/proj",
@@ -213,7 +211,7 @@ describe("handleSessionStart", () => {
213
211
  it("does not write lifecycle.reload debug log for non-reload reasons", async () => {
214
212
  const deps = makeDeps();
215
213
  await handleSessionStart(deps, { reason: "startup" }, makeCtx());
216
- expect(deps.writeDebugLog).not.toHaveBeenCalled();
214
+ expect(deps.logger.debug).not.toHaveBeenCalled();
217
215
  });
218
216
  });
219
217
 
@@ -224,7 +222,7 @@ describe("handleResourcesDiscover", () => {
224
222
  const deps = makeDeps();
225
223
  await handleResourcesDiscover(deps, { reason: "startup" });
226
224
  expect(deps.createPermissionManagerForCwd).not.toHaveBeenCalled();
227
- expect(deps.writeDebugLog).not.toHaveBeenCalled();
225
+ expect(deps.logger.debug).not.toHaveBeenCalled();
228
226
  });
229
227
 
230
228
  it("creates and stores a new PM using runtimeContext.cwd on reload", async () => {
@@ -259,7 +257,7 @@ describe("handleResourcesDiscover", () => {
259
257
  const ctx = makeCtx({ cwd: "/proj" });
260
258
  const deps = makeDeps({ session: makeSession({ runtimeContext: ctx }) });
261
259
  await handleResourcesDiscover(deps, { reason: "reload" });
262
- expect(deps.writeDebugLog).toHaveBeenCalledWith("lifecycle.reload", {
260
+ expect(deps.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
263
261
  triggeredBy: "resources_discover",
264
262
  reason: "reload",
265
263
  cwd: "/proj",
@@ -269,7 +267,7 @@ describe("handleResourcesDiscover", () => {
269
267
  it("logs cwd as null when runtimeContext is null on reload", async () => {
270
268
  const deps = makeDeps();
271
269
  await handleResourcesDiscover(deps, { reason: "reload" });
272
- expect(deps.writeDebugLog).toHaveBeenCalledWith("lifecycle.reload", {
270
+ expect(deps.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
273
271
  triggeredBy: "resources_discover",
274
272
  reason: "reload",
275
273
  cwd: null,
@@ -89,14 +89,12 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
89
89
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
90
90
  return {
91
91
  session: makeSession(),
92
- writeDebugLog: vi.fn(),
93
- writeReviewLog: vi.fn(),
92
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
94
93
  piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
95
94
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
96
95
  events: makeEvents(),
97
96
  createPermissionManagerForCwd: vi.fn(),
98
97
  refreshExtensionConfig: vi.fn(),
99
- notifyWarning: vi.fn(),
100
98
  logResolvedConfigPaths: vi.fn(),
101
99
  resolveAgentName: vi.fn().mockReturnValue(null),
102
100
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
@@ -77,13 +77,11 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
77
77
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
78
78
  return {
79
79
  session: makeSession(),
80
- writeDebugLog: vi.fn(),
81
- writeReviewLog: vi.fn(),
80
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
82
81
  piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
83
82
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
84
83
  createPermissionManagerForCwd: vi.fn(),
85
84
  refreshExtensionConfig: vi.fn(),
86
- notifyWarning: vi.fn(),
87
85
  logResolvedConfigPaths: vi.fn(),
88
86
  resolveAgentName: vi.fn().mockReturnValue(null),
89
87
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
@@ -14,10 +14,8 @@ vi.mock("node:child_process", () => ({
14
14
  default: { spawnSync: mockSpawnSync },
15
15
  }));
16
16
 
17
- import {
18
- discoverGlobalNodeModulesRoot,
19
- isPiInfrastructureRead,
20
- } from "../src/external-directory";
17
+ import { discoverGlobalNodeModulesRoot } from "../src/node-modules-discovery";
18
+ import { isPiInfrastructureRead } from "../src/path-utils";
21
19
 
22
20
  // ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
23
21
 
@@ -67,7 +67,7 @@ vi.mock("../src/subagent-context", () => ({
67
67
  isSubagentExecutionContext: vi.fn().mockReturnValue(false),
68
68
  }));
69
69
 
70
- vi.mock("../src/external-directory", () => ({
70
+ vi.mock("../src/node-modules-discovery", () => ({
71
71
  discoverGlobalNodeModulesRoot: mockDiscoverGlobalNodeModulesRoot,
72
72
  }));
73
73
 
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ExtensionRuntime } from "../src/runtime";
3
+ import { createSessionLogger } from "../src/session-logger";
4
+
5
+ // ── helpers ────────────────────────────────────────────────────────────────
6
+
7
+ function makeRuntime(
8
+ overrides: Partial<ExtensionRuntime> = {},
9
+ ): ExtensionRuntime {
10
+ return {
11
+ runtimeContext: null,
12
+ writeDebugLog: vi.fn(),
13
+ writeReviewLog: vi.fn(),
14
+ ...overrides,
15
+ } as unknown as ExtensionRuntime;
16
+ }
17
+
18
+ // ── createSessionLogger ────────────────────────────────────────────────────
19
+
20
+ describe("createSessionLogger", () => {
21
+ describe("debug", () => {
22
+ it("delegates to runtime.writeDebugLog with event and details", () => {
23
+ const runtime = makeRuntime();
24
+ const logger = createSessionLogger(runtime);
25
+
26
+ logger.debug("test.event", { key: "value" });
27
+
28
+ expect(runtime.writeDebugLog).toHaveBeenCalledWith("test.event", {
29
+ key: "value",
30
+ });
31
+ });
32
+
33
+ it("delegates to runtime.writeDebugLog with event and no details", () => {
34
+ const runtime = makeRuntime();
35
+ const logger = createSessionLogger(runtime);
36
+
37
+ logger.debug("test.event");
38
+
39
+ expect(runtime.writeDebugLog).toHaveBeenCalledWith(
40
+ "test.event",
41
+ undefined,
42
+ );
43
+ });
44
+ });
45
+
46
+ describe("review", () => {
47
+ it("delegates to runtime.writeReviewLog with event and details", () => {
48
+ const runtime = makeRuntime();
49
+ const logger = createSessionLogger(runtime);
50
+
51
+ logger.review("permission.granted", { agentName: "coder" });
52
+
53
+ expect(runtime.writeReviewLog).toHaveBeenCalledWith(
54
+ "permission.granted",
55
+ { agentName: "coder" },
56
+ );
57
+ });
58
+
59
+ it("delegates to runtime.writeReviewLog with event and no details", () => {
60
+ const runtime = makeRuntime();
61
+ const logger = createSessionLogger(runtime);
62
+
63
+ logger.review("permission.granted");
64
+
65
+ expect(runtime.writeReviewLog).toHaveBeenCalledWith(
66
+ "permission.granted",
67
+ undefined,
68
+ );
69
+ });
70
+ });
71
+
72
+ describe("warn", () => {
73
+ it("calls ui.notify with the message and 'warning' severity when runtimeContext is present", () => {
74
+ const notify = vi.fn();
75
+ const runtime = makeRuntime({
76
+ runtimeContext: {
77
+ ui: { notify, setStatus: vi.fn(), select: vi.fn(), input: vi.fn() },
78
+ } as unknown as ExtensionRuntime["runtimeContext"],
79
+ });
80
+ const logger = createSessionLogger(runtime);
81
+
82
+ logger.warn("Something went wrong");
83
+
84
+ expect(notify).toHaveBeenCalledWith("Something went wrong", "warning");
85
+ });
86
+
87
+ it("does not throw when runtimeContext is null", () => {
88
+ const runtime = makeRuntime({ runtimeContext: null });
89
+ const logger = createSessionLogger(runtime);
90
+
91
+ expect(() => logger.warn("no-op warning")).not.toThrow();
92
+ });
93
+
94
+ it("reads runtimeContext at call time, not at creation time", () => {
95
+ const runtime = makeRuntime({ runtimeContext: null });
96
+ const logger = createSessionLogger(runtime);
97
+
98
+ // runtimeContext is null at creation — warn should be a no-op now
99
+ logger.warn("early warning");
100
+
101
+ // Later runtimeContext is set
102
+ const notify = vi.fn();
103
+ runtime.runtimeContext = {
104
+ ui: { notify, setStatus: vi.fn(), select: vi.fn(), input: vi.fn() },
105
+ } as unknown as ExtensionRuntime["runtimeContext"];
106
+
107
+ logger.warn("late warning");
108
+
109
+ expect(notify).toHaveBeenCalledOnce();
110
+ expect(notify).toHaveBeenCalledWith("late warning", "warning");
111
+ });
112
+ });
113
+ });
@@ -1,24 +0,0 @@
1
- export {
2
- extractExternalPathsFromBashCommand,
3
- resetParserForTesting,
4
- } from "./bash-path-extractor";
5
- export {
6
- formatBashExternalDirectoryAskPrompt,
7
- formatBashExternalDirectoryDenyReason,
8
- formatExternalDirectoryAskPrompt,
9
- formatExternalDirectoryDenyReason,
10
- formatExternalDirectoryHardStopHint,
11
- formatExternalDirectoryUserDeniedReason,
12
- } from "./external-directory-messages";
13
- export { discoverGlobalNodeModulesRoot } from "./node-modules-discovery";
14
- export {
15
- getPathBearingToolPath,
16
- isPathOutsideWorkingDirectory,
17
- isPathWithinDirectory,
18
- isPiInfrastructureRead,
19
- isSafeSystemPath,
20
- normalizePathForComparison,
21
- PATH_BEARING_TOOLS,
22
- READ_ONLY_PATH_BEARING_TOOLS,
23
- SAFE_SYSTEM_PATHS,
24
- } from "./path-utils";