@gotgenes/pi-permission-system 10.7.1 → 10.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,25 @@ 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
+ ## [10.8.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.2...pi-permission-system-v10.8.0) (2026-06-10)
9
+
10
+
11
+ ### Features
12
+
13
+ * add CacheKeyGate for agent-start cache keys ([#365](https://github.com/gotgenes/pi-packages/issues/365)) ([e99285c](https://github.com/gotgenes/pi-packages/commit/e99285c50fef3f6fd8ea7dac00080eeb9957adaa))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * mark Phase 5 Step 4 complete ([#365](https://github.com/gotgenes/pi-packages/issues/365)) ([4bd0e30](https://github.com/gotgenes/pi-packages/commit/4bd0e30fb4cb03d6cff76242f75955e9698c7d0d))
19
+
20
+ ## [10.7.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.1...pi-permission-system-v10.7.2) (2026-06-10)
21
+
22
+
23
+ ### Miscellaneous Chores
24
+
25
+ * **deps:** bump tooling dependencies to latest minor/patch ([8b9105d](https://github.com/gotgenes/pi-packages/commit/8b9105d4011816fe8085dfed3a3b9d7bc9918c56))
26
+
8
27
  ## [10.7.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.0...pi-permission-system-v10.7.1) (2026-06-09)
9
28
 
10
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "10.7.1",
3
+ "version": "10.8.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,17 +60,17 @@
60
60
  "@earendil-works/pi-tui": ">=0.75.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@biomejs/biome": "^2.4.14",
63
+ "@biomejs/biome": "^2.4.16",
64
64
  "@earendil-works/pi-coding-agent": "0.75.4",
65
65
  "@earendil-works/pi-tui": "0.75.4",
66
66
  "@types/node": "^22.15.3",
67
- "rumdl": "^0.1.93",
67
+ "rumdl": "^0.2.10",
68
68
  "typescript": "^6.0.3",
69
- "vitest": "^4.1.5"
69
+ "vitest": "^4.1.8"
70
70
  },
71
71
  "dependencies": {
72
72
  "tree-sitter-bash": "^0.25.1",
73
- "web-tree-sitter": "^0.26.8"
73
+ "web-tree-sitter": "^0.26.9"
74
74
  },
75
75
  "scripts": {
76
76
  "check": "tsc --noEmit",
@@ -35,10 +35,3 @@ export function createBeforeAgentStartPromptStateKey(
35
35
  normalizePrompt(input.systemPrompt),
36
36
  ]);
37
37
  }
38
-
39
- export function shouldApplyCachedAgentStartState(
40
- previousKey: string | null,
41
- nextKey: string,
42
- ): boolean {
43
- return previousKey !== nextKey;
44
- }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Owns a previous cache key and conditionally runs an effect when the key changes.
3
+ *
4
+ * Encapsulates the prev !== next comparison that previously lived in three places:
5
+ * the session's inline `!==`, the handler's ask-then-tell orchestration, and the
6
+ * (test-only-alive) `shouldApplyCachedAgentStartState` free function.
7
+ *
8
+ * Semantics:
9
+ * - On a changed key: runs `effect`, commits `nextKey`, returns the effect's value.
10
+ * - On an unchanged key: skips `effect`, returns `undefined`.
11
+ * - `reset()` re-arms the gate (used by session lifecycle: `resetForNewSession`,
12
+ * `shutdown`, `reload`).
13
+ *
14
+ * Commit ordering is run-then-commit: the key is saved only after `effect` returns.
15
+ * If `effect` throws, the key stays uncommitted and the next call retries.
16
+ */
17
+ export class CacheKeyGate {
18
+ private previousKey: string | null = null;
19
+
20
+ runIfChanged<T>(nextKey: string, effect: () => T): T | undefined {
21
+ if (this.previousKey === nextKey) {
22
+ return undefined;
23
+ }
24
+ const result = effect();
25
+ this.previousKey = nextKey;
26
+ return result;
27
+ }
28
+
29
+ reset(): void {
30
+ this.previousKey = null;
31
+ }
32
+ }
@@ -74,10 +74,9 @@ export class AgentPrepHandler {
74
74
  }
75
75
 
76
76
  const activeToolsCacheKey = createActiveToolsCacheKey(allowedTools);
77
- if (this.session.shouldUpdateActiveTools(activeToolsCacheKey)) {
77
+ this.session.activeToolsGate.runIfChanged(activeToolsCacheKey, () => {
78
78
  this.toolRegistry.setActive(allowedTools);
79
- this.session.commitActiveToolsCacheKey(activeToolsCacheKey);
80
- }
79
+ });
81
80
 
82
81
  const promptStateCacheKey = createBeforeAgentStartPromptStateKey({
83
82
  agentName,
@@ -89,28 +88,25 @@ export class AgentPrepHandler {
89
88
  allowedToolNames: allowedTools,
90
89
  });
91
90
 
92
- if (!this.session.shouldUpdatePromptState(promptStateCacheKey)) {
93
- return {};
94
- }
95
-
96
- this.session.commitPromptStateCacheKey(promptStateCacheKey);
97
-
98
- const toolPromptResult = sanitizeAvailableToolsSection(
99
- event.systemPrompt,
100
- allowedTools,
91
+ const promptResult = this.session.promptStateGate.runIfChanged(
92
+ promptStateCacheKey,
93
+ () => {
94
+ const toolPromptResult = sanitizeAvailableToolsSection(
95
+ event.systemPrompt,
96
+ allowedTools,
97
+ );
98
+ const skillPromptResult = resolveSkillPromptEntries(
99
+ toolPromptResult.prompt,
100
+ this.resolver,
101
+ agentName,
102
+ ctx.cwd,
103
+ );
104
+ this.session.setActiveSkillEntries(skillPromptResult.entries);
105
+ return skillPromptResult.prompt !== event.systemPrompt
106
+ ? { systemPrompt: skillPromptResult.prompt }
107
+ : {};
108
+ },
101
109
  );
102
- const skillPromptResult = resolveSkillPromptEntries(
103
- toolPromptResult.prompt,
104
- this.resolver,
105
- agentName,
106
- ctx.cwd,
107
- );
108
- this.session.setActiveSkillEntries(skillPromptResult.entries);
109
-
110
- if (skillPromptResult.prompt !== event.systemPrompt) {
111
- return { systemPrompt: skillPromptResult.prompt };
112
- }
113
-
114
- return {};
110
+ return promptResult ?? {};
115
111
  }
116
112
  }
@@ -3,6 +3,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import type { PermissionResolver } from "#src/permission-resolver";
4
4
  import type { PermissionSession } from "#src/permission-session";
5
5
  import type { ServiceLifecycle } from "#src/service-lifecycle";
6
+ import type { SessionLogger } from "#src/session-logger";
6
7
  import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/status";
7
8
 
8
9
  /** Minimal subset of SessionStartEvent used by this handler. */
@@ -24,12 +25,14 @@ interface ResourcesDiscoverPayload {
24
25
  * - `serviceLifecycle` — owns the process-global service publication;
25
26
  * `activate` publishes (skipped for registered subagent children) and emits
26
27
  * the ready event; `teardown` unsubscribes all session listeners and unpublishes
28
+ * - `logger` — injected directly; replaces the former `session.logger` reach-through
27
29
  */
28
30
  export class SessionLifecycleHandler {
29
31
  constructor(
30
32
  private readonly session: PermissionSession,
31
33
  private readonly resolver: PermissionResolver,
32
34
  private readonly serviceLifecycle: ServiceLifecycle,
35
+ private readonly logger: SessionLogger,
33
36
  ) {}
34
37
 
35
38
  handleSessionStart(
@@ -43,11 +46,11 @@ export class SessionLifecycleHandler {
43
46
  const agentName = this.session.resolveAgentName(ctx);
44
47
  const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
45
48
  for (const issue of policyIssues) {
46
- this.session.logger.warn(issue);
49
+ this.logger.warn(issue);
47
50
  }
48
51
 
49
52
  if (event.reason === "reload") {
50
- this.session.logger.debug("lifecycle.reload", {
53
+ this.logger.debug("lifecycle.reload", {
51
54
  triggeredBy: "session_start",
52
55
  reason: event.reason,
53
56
  cwd: ctx.cwd,
@@ -68,7 +71,7 @@ export class SessionLifecycleHandler {
68
71
  }
69
72
 
70
73
  this.session.reload();
71
- this.session.logger.debug("lifecycle.reload", {
74
+ this.logger.debug("lifecycle.reload", {
72
75
  triggeredBy: "resources_discover",
73
76
  reason: event.reason,
74
77
  cwd: this.session.getRuntimeContext()?.cwd ?? null,
package/src/index.ts CHANGED
@@ -28,7 +28,7 @@ import { PermissionSession } from "./permission-session";
28
28
  import { LocalPermissionsService } from "./permissions-service";
29
29
  import { PromptingGateway } from "./prompting-gateway";
30
30
  import { PermissionServiceLifecycle } from "./service-lifecycle";
31
- import { createSessionLogger } from "./session-logger";
31
+ import { PermissionSessionLogger } from "./session-logger";
32
32
  import { SessionRules } from "./session-rules";
33
33
  import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
34
34
  import { getSubagentSessionRegistry } from "./subagent-registry";
@@ -43,22 +43,19 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
43
43
  const formatterRegistry = new ToolInputFormatterRegistry();
44
44
  registerBuiltinToolInputFormatters(formatterRegistry);
45
45
 
46
- // Forward reference: configStore is declared before the logger so the
47
- // logger's getConfig thunk can close over the variable; assigned immediately
48
- // after. Typed via cast so the closure compiles without assertions.
49
- // The same null-at-init pattern used in the former createExtensionRuntime.
50
- let configStore = null as unknown as ConfigStore;
46
+ // Both `configStore` and `session` are forward-declared so the logger's
47
+ // lazy thunks can close over them without a cast or null-init holder.
48
+ // TypeScript exempts closure captures from definite-assignment analysis;
49
+ // all synchronous reads occur after the assignments below.
50
+ // eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
51
+ let configStore: ConfigStore;
52
+ // eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
53
+ let session: PermissionSession;
51
54
 
52
- // sessionNotify is a mutable holder so the logger's notify closure can
53
- // reach the UI once PermissionSession is constructed. Starts as null;
54
- // notify is a best-effort sink (no-op at factory-init when there is no UI).
55
- let sessionNotify: PermissionSession | null = null;
56
-
57
- const logger = createSessionLogger({
55
+ const logger = new PermissionSessionLogger({
58
56
  globalLogsDir: paths.globalLogsDir,
59
57
  getConfig: () => configStore.current(),
60
- notify: (message) =>
61
- sessionNotify?.getRuntimeContext()?.ui.notify(message, "warning"),
58
+ notify: (message) => session.notify(message),
62
59
  });
63
60
 
64
61
  configStore = new ConfigStore({
@@ -85,8 +82,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
85
82
  forwarder,
86
83
  });
87
84
 
88
- configStore.refresh();
89
-
90
85
  const gateway = new PromptingGateway({
91
86
  config: configStore,
92
87
  subagentSessionsDir: paths.subagentSessionsDir,
@@ -94,9 +89,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
94
89
  prompter,
95
90
  });
96
91
 
97
- const session = new PermissionSession(
92
+ session = new PermissionSession(
98
93
  paths,
99
- logger,
100
94
  new ForwardingManager(
101
95
  paths.subagentSessionsDir,
102
96
  forwarder,
@@ -108,8 +102,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
108
102
  gateway,
109
103
  );
110
104
 
111
- // Connect the notify sink now that session is available.
112
- sessionNotify = session;
105
+ // refresh() must run after `session` is assigned: a debug-write IO failure
106
+ // triggers the logger's notify sink — `session.notify(m)` — which no-ops
107
+ // on the null context but requires `session` to be bound.
108
+ configStore.refresh();
113
109
 
114
110
  const configPath = getGlobalConfigPath(agentDir);
115
111
  registerPermissionSystemCommand(pi, {
@@ -163,10 +159,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
163
159
  session,
164
160
  resolver,
165
161
  serviceLifecycle,
162
+ logger,
166
163
  );
167
164
  const agentPrep = new AgentPrepHandler(session, resolver, toolRegistry);
168
165
 
169
- const reporter = new GateDecisionReporter(session.logger, pi.events);
166
+ const reporter = new GateDecisionReporter(logger, pi.events);
170
167
  const gateRunner = new GateRunner(resolver, sessionRules, gateway, reporter);
171
168
  const toolCallGatePipeline = new ToolCallGatePipeline(
172
169
  resolver,
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
2
+ import { CacheKeyGate } from "#src/cache-key-gate";
3
3
  import {
4
4
  getActiveAgentName,
5
5
  getActiveAgentNameFromSystemPrompt,
@@ -13,7 +13,6 @@ import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipelin
13
13
  import type { ScopedPermissionManager } from "./permission-manager";
14
14
  import type { PromptingGatewayLifecycle } from "./prompting-gateway";
15
15
 
16
- import type { SessionLogger } from "./session-logger";
17
16
  import type { SessionRules } from "./session-rules";
18
17
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
19
18
  import {
@@ -31,7 +30,6 @@ import {
31
30
  *
32
31
  * Constructor deps:
33
32
  * - `ExtensionPaths` — immutable path constants
34
- * - `SessionLogger` — debug + review + warn
35
33
  * - `ForwardingController` — polling lifecycle
36
34
  * - `SessionConfigStore` — owns extension config; provides refresh, log, read
37
35
  * - `PromptingGatewayLifecycle` — prompting lifecycle forwarded via activate/deactivate
@@ -40,12 +38,11 @@ export class PermissionSession implements ToolCallGateInputs {
40
38
  private context: ExtensionContext | null = null;
41
39
  private skillEntries: SkillPromptEntry[] = [];
42
40
  private knownAgentName: string | null = null;
43
- private toolsCacheKey: string | null = null;
44
- private promptCacheKey: string | null = null;
41
+ readonly activeToolsGate = new CacheKeyGate();
42
+ readonly promptStateGate = new CacheKeyGate();
45
43
 
46
44
  constructor(
47
45
  private readonly paths: ExtensionPaths,
48
- readonly logger: SessionLogger,
49
46
  private readonly forwarding: ForwardingController,
50
47
  private readonly permissionManager: ScopedPermissionManager,
51
48
  private readonly sessionRules: SessionRules,
@@ -74,6 +71,13 @@ export class PermissionSession implements ToolCallGateInputs {
74
71
  return this.context;
75
72
  }
76
73
 
74
+ // ── UI notifications ────────────────────────────────────────────────────
75
+
76
+ /** Surface a warning message to the user via the active UI context, if any. */
77
+ notify(message: string): void {
78
+ this.context?.ui.notify(message, "warning");
79
+ }
80
+
77
81
  // ── Session lifecycle ────────────────────────────────────────────────────
78
82
 
79
83
  /**
@@ -85,8 +89,8 @@ export class PermissionSession implements ToolCallGateInputs {
85
89
  resetForNewSession(ctx: ExtensionContext): void {
86
90
  this.permissionManager.configureForCwd(ctx.cwd);
87
91
  this.skillEntries = [];
88
- this.toolsCacheKey = null;
89
- this.promptCacheKey = null;
92
+ this.activeToolsGate.reset();
93
+ this.promptStateGate.reset();
90
94
  this.activate(ctx);
91
95
  }
92
96
 
@@ -97,8 +101,8 @@ export class PermissionSession implements ToolCallGateInputs {
97
101
  shutdown(): void {
98
102
  this.sessionRules.clear();
99
103
  this.skillEntries = [];
100
- this.toolsCacheKey = null;
101
- this.promptCacheKey = null;
104
+ this.activeToolsGate.reset();
105
+ this.promptStateGate.reset();
102
106
  this.deactivate();
103
107
  }
104
108
 
@@ -109,26 +113,8 @@ export class PermissionSession implements ToolCallGateInputs {
109
113
  reload(): void {
110
114
  this.permissionManager.configureForCwd(this.context?.cwd);
111
115
  this.skillEntries = [];
112
- this.toolsCacheKey = null;
113
- this.promptCacheKey = null;
114
- }
115
-
116
- // ── Agent-start caching ────────────────────────────────────────────────
117
-
118
- shouldUpdateActiveTools(cacheKey: string): boolean {
119
- return this.toolsCacheKey !== cacheKey;
120
- }
121
-
122
- commitActiveToolsCacheKey(cacheKey: string): void {
123
- this.toolsCacheKey = cacheKey;
124
- }
125
-
126
- shouldUpdatePromptState(cacheKey: string): boolean {
127
- return this.promptCacheKey !== cacheKey;
128
- }
129
-
130
- commitPromptStateCacheKey(cacheKey: string): void {
131
- this.promptCacheKey = cacheKey;
116
+ this.activeToolsGate.reset();
117
+ this.promptStateGate.reset();
132
118
  }
133
119
 
134
120
  // ── Skill entries ──────────────────────────────────────────────────────
@@ -4,7 +4,10 @@ import {
4
4
  ensurePermissionSystemLogsDirectory,
5
5
  type PermissionSystemExtensionConfig,
6
6
  } from "./extension-config";
7
- import { createPermissionSystemLogger } from "./logging";
7
+ import {
8
+ createPermissionSystemLogger,
9
+ type PermissionSystemLogger,
10
+ } from "./logging";
8
11
 
9
12
  /**
10
13
  * Narrowest logging seam — consumers that only write review-log entries.
@@ -44,37 +47,45 @@ export interface SessionLoggerDeps {
44
47
  }
45
48
 
46
49
  /**
47
- * Create a SessionLogger from narrow dependencies.
50
+ * Concrete `SessionLogger` implementation.
48
51
  *
49
- * Composes the JSONL log writer, owns the IO-failure warning dedup Set,
50
- * and routes both IO-failure warnings and explicit warn() calls through
51
- * the injected notify sink. No ExtensionRuntime reference required.
52
+ * Composes the JSONL log writer, privately owns the IO-failure warning
53
+ * dedup Set, and routes both IO-failure warnings and explicit warn() calls
54
+ * through the injected notify sink. No ExtensionRuntime reference required.
52
55
  */
53
- export function createSessionLogger(deps: SessionLoggerDeps): SessionLogger {
54
- const writer = createPermissionSystemLogger({
55
- getConfig: deps.getConfig,
56
- debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
57
- reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
58
- ensureLogsDirectory: () =>
59
- ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
60
- });
56
+ export class PermissionSessionLogger implements SessionLogger {
57
+ private readonly writer: PermissionSystemLogger;
58
+ private readonly reported = new Set<string>();
59
+ private readonly notify: (message: string) => void;
60
+
61
+ constructor(deps: SessionLoggerDeps) {
62
+ this.writer = createPermissionSystemLogger({
63
+ getConfig: deps.getConfig,
64
+ debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
65
+ reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
66
+ ensureLogsDirectory: () =>
67
+ ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
68
+ });
69
+ this.notify = deps.notify;
70
+ }
71
+
72
+ debug(event: string, details?: Record<string, unknown>): void {
73
+ const warning = this.writer.debug(event, details);
74
+ if (warning) this.reportOnce(warning);
75
+ }
76
+
77
+ review(event: string, details?: Record<string, unknown>): void {
78
+ const warning = this.writer.review(event, details);
79
+ if (warning) this.reportOnce(warning);
80
+ }
61
81
 
62
- const reported = new Set<string>();
63
- const reportOnce = (warning: string): void => {
64
- if (reported.has(warning)) return;
65
- reported.add(warning);
66
- deps.notify(warning);
67
- };
82
+ warn(message: string): void {
83
+ this.notify(message);
84
+ }
68
85
 
69
- return {
70
- debug: (event, details) => {
71
- const warning = writer.debug(event, details);
72
- if (warning) reportOnce(warning);
73
- },
74
- review: (event, details) => {
75
- const warning = writer.review(event, details);
76
- if (warning) reportOnce(warning);
77
- },
78
- warn: (message) => deps.notify(message),
79
- };
86
+ private reportOnce(warning: string): void {
87
+ if (this.reported.has(warning)) return;
88
+ this.reported.add(warning);
89
+ this.notify(warning);
90
+ }
80
91
  }
@@ -1,33 +1,8 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { expect, test } from "vitest";
3
- import {
4
- createActiveToolsCacheKey,
5
- createBeforeAgentStartPromptStateKey,
6
- shouldApplyCachedAgentStartState,
7
- } from "#src/before-agent-start-cache";
3
+ import { createBeforeAgentStartPromptStateKey } from "#src/before-agent-start-cache";
8
4
  import { createManager } from "#test/helpers/manager-harness";
9
5
 
10
- test("Before-agent-start cache dedupes unchanged active-tool exposure and prompt state", () => {
11
- const allowedTools = ["read", "mcp"];
12
- const activeToolsKey = createActiveToolsCacheKey(allowedTools);
13
- const promptStateKey = createBeforeAgentStartPromptStateKey({
14
- agentName: "code",
15
- cwd: "C:/workspace/project",
16
- permissionStamp: "permissions-v1",
17
- systemPrompt: "Available tools:\n- read\n- mcp",
18
- allowedToolNames: allowedTools,
19
- });
20
-
21
- expect(shouldApplyCachedAgentStartState(null, activeToolsKey)).toBe(true);
22
- expect(shouldApplyCachedAgentStartState(activeToolsKey, activeToolsKey)).toBe(
23
- false,
24
- );
25
- expect(shouldApplyCachedAgentStartState(null, promptStateKey)).toBe(true);
26
- expect(shouldApplyCachedAgentStartState(promptStateKey, promptStateKey)).toBe(
27
- false,
28
- );
29
- });
30
-
31
6
  test("Before-agent-start prompt cache invalidates on permission changes while runtime enforcement stays authoritative", () => {
32
7
  const { manager, globalConfigPath, cleanup } = createManager({
33
8
  permission: { "*": "allow", write: "deny" },
@@ -43,9 +18,6 @@ test("Before-agent-start prompt cache invalidates on permission changes while ru
43
18
  allowedToolNames: ["read"],
44
19
  });
45
20
 
46
- expect(shouldApplyCachedAgentStartState(baselineKey, baselineKey)).toBe(
47
- false,
48
- );
49
21
  expect(manager.checkPermission("write", {}, undefined).state).toBe("deny");
50
22
 
51
23
  const updatedConfig = `${JSON.stringify(
@@ -79,9 +51,7 @@ test("Before-agent-start prompt cache invalidates on permission changes while ru
79
51
  allowedToolNames: ["read", "write"],
80
52
  });
81
53
 
82
- expect(shouldApplyCachedAgentStartState(baselineKey, invalidatedKey)).toBe(
83
- true,
84
- );
54
+ expect(invalidatedKey).not.toBe(baselineKey);
85
55
  expect(manager.checkPermission("write", {}, undefined).state).toBe("allow");
86
56
  } finally {
87
57
  cleanup();
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { CacheKeyGate } from "#src/cache-key-gate";
4
+
5
+ describe("CacheKeyGate", () => {
6
+ describe("runIfChanged", () => {
7
+ it("runs the effect and returns its value when the key is new (null previous)", () => {
8
+ const gate = new CacheKeyGate();
9
+ const effect = vi.fn(() => "result");
10
+
11
+ const result = gate.runIfChanged("key-a", effect);
12
+
13
+ expect(effect).toHaveBeenCalledOnce();
14
+ expect(result).toBe("result");
15
+ });
16
+
17
+ it("commits the key so a second call with the same key skips the effect", () => {
18
+ const gate = new CacheKeyGate();
19
+ const effect = vi.fn(() => "result");
20
+
21
+ gate.runIfChanged("key-a", effect);
22
+ const result = gate.runIfChanged("key-a", effect);
23
+
24
+ expect(effect).toHaveBeenCalledOnce();
25
+ expect(result).toBeUndefined();
26
+ });
27
+
28
+ it("runs the effect when the key changes", () => {
29
+ const gate = new CacheKeyGate();
30
+ const effect = vi.fn((n: number) => n);
31
+
32
+ gate.runIfChanged("key-a", () => effect(1));
33
+ const result = gate.runIfChanged("key-b", () => effect(2));
34
+
35
+ expect(effect).toHaveBeenCalledTimes(2);
36
+ expect(result).toBe(2);
37
+ });
38
+
39
+ it("returns undefined when the key is unchanged", () => {
40
+ const gate = new CacheKeyGate();
41
+ gate.runIfChanged("key-a", vi.fn());
42
+
43
+ const result = gate.runIfChanged("key-a", vi.fn());
44
+
45
+ expect(result).toBeUndefined();
46
+ });
47
+
48
+ it("does not commit the key if the effect throws", () => {
49
+ const gate = new CacheKeyGate();
50
+ const throwing = vi.fn(() => {
51
+ throw new Error("oops");
52
+ });
53
+ const fallback = vi.fn(() => "ok");
54
+
55
+ expect(() => gate.runIfChanged("key-a", throwing)).toThrow("oops");
56
+
57
+ // Same key should run again since the first call threw
58
+ gate.runIfChanged("key-a", fallback);
59
+ expect(fallback).toHaveBeenCalledOnce();
60
+ });
61
+ });
62
+
63
+ describe("reset", () => {
64
+ it("re-arms the gate so the same key runs again on the next call", () => {
65
+ const gate = new CacheKeyGate();
66
+ const effect = vi.fn(() => "ok");
67
+
68
+ gate.runIfChanged("key-a", effect);
69
+ gate.reset();
70
+ gate.runIfChanged("key-a", effect);
71
+
72
+ expect(effect).toHaveBeenCalledTimes(2);
73
+ });
74
+
75
+ it("is idempotent when called on a fresh gate", () => {
76
+ const gate = new CacheKeyGate();
77
+ gate.reset();
78
+ const effect = vi.fn(() => "ok");
79
+
80
+ gate.runIfChanged("key-a", effect);
81
+
82
+ expect(effect).toHaveBeenCalledOnce();
83
+ });
84
+ });
85
+ });
@@ -143,42 +143,24 @@ describe("AgentPrepHandler.handle", () => {
143
143
  expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
144
144
  });
145
145
 
146
- it("commits active-tools cache key after applying", async () => {
147
- const { handler, session } = makeSetup({
146
+ it("calls setActive once across repeated calls with the same allowed tools", async () => {
147
+ const { handler, toolRegistry } = makeSetup({
148
148
  toolRegistry: {
149
149
  getAll: vi.fn().mockReturnValue([{ name: "read" }]),
150
150
  },
151
151
  });
152
- const spy = vi.spyOn(session, "commitActiveToolsCacheKey");
153
152
  await handler.handle(makeEvent(), makeCtx());
154
- expect(spy).toHaveBeenCalled();
155
- });
156
-
157
- it("skips setActive when cache key is unchanged", async () => {
158
- const { handler, session, toolRegistry } = makeSetup({
159
- toolRegistry: {
160
- getAll: vi.fn().mockReturnValue([{ name: "read" }]),
161
- },
162
- });
163
- vi.spyOn(session, "shouldUpdateActiveTools").mockReturnValue(false);
164
153
  await handler.handle(makeEvent(), makeCtx());
165
- expect(toolRegistry.setActive).not.toHaveBeenCalled();
154
+ expect(toolRegistry.setActive).toHaveBeenCalledOnce();
166
155
  });
167
156
 
168
- it("returns empty object when prompt cache is unchanged", async () => {
169
- const { handler, session } = makeSetup();
170
- vi.spyOn(session, "shouldUpdatePromptState").mockReturnValue(false);
157
+ it("returns empty object on repeated calls with unchanged inputs", async () => {
158
+ const { handler } = makeSetup();
159
+ await handler.handle(makeEvent(), makeCtx());
171
160
  const result = await handler.handle(makeEvent(), makeCtx());
172
161
  expect(result).toEqual({});
173
162
  });
174
163
 
175
- it("commits prompt-state cache key and processes prompt when cache is new", async () => {
176
- const { handler, session } = makeSetup();
177
- const spy = vi.spyOn(session, "commitPromptStateCacheKey");
178
- await handler.handle(makeEvent(), makeCtx());
179
- expect(spy).toHaveBeenCalled();
180
- });
181
-
182
164
  it("stores resolved skill entries on the session", async () => {
183
165
  const { handler, session } = makeSetup();
184
166
  const spy = vi.spyOn(session, "setActiveSkillEntries");
@@ -191,14 +191,13 @@ describe("external_directory policy state — allow", () => {
191
191
  });
192
192
 
193
193
  it("does not write a block review-log entry when external_directory is allow", async () => {
194
- const { handler, session } = makeHandler({
194
+ const { handler, logger } = makeHandler({
195
195
  session: { checkPermission: makeExtDirCheck("allow") },
196
196
  tools: ALL_TOOLS,
197
197
  });
198
198
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
199
199
  await handler.handleToolCall(event, makeCtx());
200
- const reviewCalls = (session.logger.review as ReturnType<typeof vi.fn>).mock
201
- .calls;
200
+ const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
202
201
  const blockEntries = reviewCalls.filter(
203
202
  ([eventName]: string[]) => eventName === "permission_request.blocked",
204
203
  );
@@ -301,14 +300,13 @@ describe("external_directory policy state — deny", () => {
301
300
  });
302
301
 
303
302
  it("writes review-log entry with resolution policy_denied", async () => {
304
- const { handler, session } = makeHandler({
303
+ const { handler, logger } = makeHandler({
305
304
  session: { checkPermission: makeExtDirCheck("deny") },
306
305
  tools: ALL_TOOLS,
307
306
  });
308
307
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
309
308
  await handler.handleToolCall(event, makeCtx());
310
- const reviewCalls = (session.logger.review as ReturnType<typeof vi.fn>).mock
311
- .calls;
309
+ const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
312
310
  const blockEntries = reviewCalls.filter(
313
311
  ([eventName]: string[]) => eventName === "permission_request.blocked",
314
312
  );
@@ -458,7 +456,7 @@ describe("external_directory policy state — ask", () => {
458
456
  });
459
457
 
460
458
  it("writes review-log entry with confirmation_unavailable when no UI", async () => {
461
- const { handler, session } = makeHandler({
459
+ const { handler, logger } = makeHandler({
462
460
  session: { checkPermission: makeExtDirCheck("ask") },
463
461
  prompter: {
464
462
  canConfirm: vi.fn().mockReturnValue(false),
@@ -468,8 +466,7 @@ describe("external_directory policy state — ask", () => {
468
466
  });
469
467
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
470
468
  await handler.handleToolCall(event, makeCtx({ hasUI: false }));
471
- const reviewCalls = (session.logger.review as ReturnType<typeof vi.fn>).mock
472
- .calls;
469
+ const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
473
470
  const blockEntries = reviewCalls.filter(
474
471
  ([eventName]: string[]) => eventName === "permission_request.blocked",
475
472
  );
@@ -5,6 +5,7 @@ import type { ServiceLifecycle } from "#src/service-lifecycle";
5
5
 
6
6
  import { makeCtx } from "#test/helpers/handler-fixtures";
7
7
  import {
8
+ makeLogger,
8
9
  makeRealResolver,
9
10
  makeRealSession,
10
11
  } from "#test/helpers/session-fixtures";
@@ -19,14 +20,8 @@ vi.mock("../../src/status", () => ({
19
20
  // ── helpers ────────────────────────────────────────────────────────────────
20
21
 
21
22
  function makeSetup(opts?: { configIssues?: string[] }) {
22
- const {
23
- session,
24
- permissionManager,
25
- sessionRules,
26
- logger,
27
- forwarding,
28
- configStore,
29
- } = makeRealSession();
23
+ const { session, permissionManager, sessionRules, forwarding, configStore } =
24
+ makeRealSession();
30
25
  const { resolver } = makeRealResolver(permissionManager, sessionRules);
31
26
  if (opts?.configIssues) {
32
27
  vi.mocked(permissionManager.getConfigIssues).mockReturnValue(
@@ -37,10 +32,14 @@ function makeSetup(opts?: { configIssues?: string[] }) {
37
32
  activate: vi.fn<ServiceLifecycle["activate"]>(),
38
33
  teardown: vi.fn<ServiceLifecycle["teardown"]>(),
39
34
  };
35
+ // Use a session-independent logger so assertions verify direct injection,
36
+ // not reach-through to session.logger.
37
+ const logger = makeLogger();
40
38
  const handler = new SessionLifecycleHandler(
41
39
  session,
42
40
  resolver,
43
41
  serviceLifecycle,
42
+ logger,
44
43
  );
45
44
  return {
46
45
  handler,
@@ -25,7 +25,6 @@ import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
25
25
  import type { PermissionDecisionEvent } from "#src/permission-events";
26
26
  import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
27
27
  import type { Rule } from "#src/rule";
28
- import type { SessionLogger } from "#src/session-logger";
29
28
  import { SessionRules } from "#src/session-rules";
30
29
  import type { ToolRegistry } from "#src/tool-registry";
31
30
  import type { PermissionCheckResult, PermissionState } from "#src/types";
@@ -49,8 +48,6 @@ import {
49
48
  */
50
49
  export type MockGateHandlerSession = ToolCallGateInputs &
51
50
  SkillInputGateInputs & {
52
- /** Logger shape expected by GateDecisionReporter. */
53
- logger: SessionLogger;
54
51
  /** 4-arg form so surface-check mocks can receive optional rules. */
55
52
  checkPermission(
56
53
  surface: string,
@@ -296,6 +293,7 @@ export function makeHandler(overrides?: {
296
293
  handler,
297
294
  events,
298
295
  session,
296
+ logger,
299
297
  toolRegistry,
300
298
  prompter,
301
299
  recorder,
@@ -150,7 +150,6 @@ export function makeRealSession(overrides?: {
150
150
  const gateway = overrides?.gateway ?? makeGateway();
151
151
  const session = new PermissionSession(
152
152
  paths,
153
- logger,
154
153
  forwarding,
155
154
  permissionManager,
156
155
  sessionRules,
@@ -105,16 +105,19 @@ describe("PermissionSession", () => {
105
105
 
106
106
  it("clears cache keys", () => {
107
107
  const { session } = createSession();
108
- session.commitActiveToolsCacheKey("key-1");
109
- session.commitPromptStateCacheKey("key-2");
110
- expect(session.shouldUpdateActiveTools("key-1")).toBe(false);
111
- expect(session.shouldUpdatePromptState("key-2")).toBe(false);
108
+ // Prime both gates with a key
109
+ session.activeToolsGate.runIfChanged("key-1", () => {});
110
+ session.promptStateGate.runIfChanged("key-2", () => {});
112
111
 
113
112
  session.resetForNewSession(makeCtx());
114
113
 
115
- // After reset, same keys should be treated as new
116
- expect(session.shouldUpdateActiveTools("key-1")).toBe(true);
117
- expect(session.shouldUpdatePromptState("key-2")).toBe(true);
114
+ // After reset, the same keys should run the effect again
115
+ const toolsEffect = vi.fn();
116
+ const promptEffect = vi.fn();
117
+ session.activeToolsGate.runIfChanged("key-1", toolsEffect);
118
+ session.promptStateGate.runIfChanged("key-2", promptEffect);
119
+ expect(toolsEffect).toHaveBeenCalledOnce();
120
+ expect(promptEffect).toHaveBeenCalledOnce();
118
121
  });
119
122
 
120
123
  it("clears skill entries", () => {
@@ -162,13 +165,19 @@ describe("PermissionSession", () => {
162
165
 
163
166
  it("clears cache keys", () => {
164
167
  const { session } = createSession();
165
- session.commitActiveToolsCacheKey("k1");
166
- session.commitPromptStateCacheKey("k2");
168
+ // Prime both gates with a key
169
+ session.activeToolsGate.runIfChanged("k1", () => {});
170
+ session.promptStateGate.runIfChanged("k2", () => {});
167
171
 
168
172
  session.shutdown();
169
173
 
170
- expect(session.shouldUpdateActiveTools("k1")).toBe(true);
171
- expect(session.shouldUpdatePromptState("k2")).toBe(true);
174
+ // After shutdown, the same keys should run the effect again
175
+ const toolsEffect = vi.fn();
176
+ const promptEffect = vi.fn();
177
+ session.activeToolsGate.runIfChanged("k1", toolsEffect);
178
+ session.promptStateGate.runIfChanged("k2", promptEffect);
179
+ expect(toolsEffect).toHaveBeenCalledOnce();
180
+ expect(promptEffect).toHaveBeenCalledOnce();
172
181
  });
173
182
 
174
183
  it("clears skill entries", () => {
@@ -190,36 +199,6 @@ describe("PermissionSession", () => {
190
199
  });
191
200
  });
192
201
 
193
- describe("cache key methods", () => {
194
- it("shouldUpdateActiveTools returns true for new key", () => {
195
- const { session } = createSession();
196
- expect(session.shouldUpdateActiveTools("key-1")).toBe(true);
197
- });
198
-
199
- it("shouldUpdateActiveTools returns false for committed key", () => {
200
- const { session } = createSession();
201
- session.commitActiveToolsCacheKey("key-1");
202
- expect(session.shouldUpdateActiveTools("key-1")).toBe(false);
203
- });
204
-
205
- it("shouldUpdateActiveTools returns true for different key", () => {
206
- const { session } = createSession();
207
- session.commitActiveToolsCacheKey("key-1");
208
- expect(session.shouldUpdateActiveTools("key-2")).toBe(true);
209
- });
210
-
211
- it("shouldUpdatePromptState returns true for new key", () => {
212
- const { session } = createSession();
213
- expect(session.shouldUpdatePromptState("key-1")).toBe(true);
214
- });
215
-
216
- it("shouldUpdatePromptState returns false for committed key", () => {
217
- const { session } = createSession();
218
- session.commitPromptStateCacheKey("key-1");
219
- expect(session.shouldUpdatePromptState("key-1")).toBe(false);
220
- });
221
- });
222
-
223
202
  describe("skill entries", () => {
224
203
  it("get/set skill entries", () => {
225
204
  const { session } = createSession();
@@ -356,14 +335,20 @@ describe("PermissionSession", () => {
356
335
 
357
336
  it("clears caches and skill entries", () => {
358
337
  const { session } = createSession();
359
- session.commitActiveToolsCacheKey("k1");
360
- session.commitPromptStateCacheKey("k2");
338
+ // Prime both gates with a key
339
+ session.activeToolsGate.runIfChanged("k1", () => {});
340
+ session.promptStateGate.runIfChanged("k2", () => {});
361
341
  session.setActiveSkillEntries([makeSkillEntry("s")]);
362
342
 
363
343
  session.reload();
364
344
 
365
- expect(session.shouldUpdateActiveTools("k1")).toBe(true);
366
- expect(session.shouldUpdatePromptState("k2")).toBe(true);
345
+ // After reload, the same keys should run the effect again
346
+ const toolsEffect = vi.fn();
347
+ const promptEffect = vi.fn();
348
+ session.activeToolsGate.runIfChanged("k1", toolsEffect);
349
+ session.promptStateGate.runIfChanged("k2", promptEffect);
350
+ expect(toolsEffect).toHaveBeenCalledOnce();
351
+ expect(promptEffect).toHaveBeenCalledOnce();
367
352
  expect(session.getActiveSkillEntries()).toEqual([]);
368
353
  });
369
354
  });
@@ -388,4 +373,35 @@ describe("PermissionSession", () => {
388
373
  expect(session.getRuntimeContext()).toBeNull();
389
374
  });
390
375
  });
376
+
377
+ describe("notify", () => {
378
+ it("forwards the message to ctx.ui.notify with 'warning' severity after activation", () => {
379
+ const { session } = createSession();
380
+ const ctx = makeCtx();
381
+ session.activate(ctx);
382
+
383
+ session.notify("something went wrong");
384
+
385
+ expect(ctx.ui.notify).toHaveBeenCalledOnce();
386
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
387
+ "something went wrong",
388
+ "warning",
389
+ );
390
+ });
391
+
392
+ it("is a no-op and does not throw before activation", () => {
393
+ const { session } = createSession();
394
+
395
+ expect(() => session.notify("msg")).not.toThrow();
396
+ });
397
+
398
+ it("is a no-op and does not throw after deactivation", () => {
399
+ const { session } = createSession();
400
+ const ctx = makeCtx();
401
+ session.activate(ctx);
402
+ session.deactivate();
403
+
404
+ expect(() => session.notify("msg")).not.toThrow();
405
+ });
406
+ });
391
407
  });
@@ -8,7 +8,7 @@ import {
8
8
  type PermissionSystemExtensionConfig,
9
9
  } from "#src/extension-config";
10
10
  import type { SessionLoggerDeps } from "#src/session-logger";
11
- import { createSessionLogger } from "#src/session-logger";
11
+ import { PermissionSessionLogger } from "#src/session-logger";
12
12
 
13
13
  // ── helpers ────────────────────────────────────────────────────────────────
14
14
 
@@ -42,9 +42,9 @@ function makeBlockedLogsDir(): string {
42
42
  return join(barrier, "logs");
43
43
  }
44
44
 
45
- // ── createSessionLogger ────────────────────────────────────────────────────
45
+ // ── PermissionSessionLogger ────────────────────────────────────────────────────
46
46
 
47
- describe("createSessionLogger", () => {
47
+ describe("PermissionSessionLogger", () => {
48
48
  // ── debug ────────────────────────────────────────────────────────────────
49
49
 
50
50
  describe("debug", () => {
@@ -52,7 +52,7 @@ describe("createSessionLogger", () => {
52
52
  const deps = makeDeps({
53
53
  getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
54
54
  });
55
- const logger = createSessionLogger(deps);
55
+ const logger = new PermissionSessionLogger(deps);
56
56
 
57
57
  logger.debug("test.event", { key: "value" });
58
58
 
@@ -63,7 +63,7 @@ describe("createSessionLogger", () => {
63
63
  it("does not write to the debug log when debugLog is false", () => {
64
64
  // DEFAULT_EXTENSION_CONFIG.debugLog === false
65
65
  const deps = makeDeps();
66
- const logger = createSessionLogger(deps);
66
+ const logger = new PermissionSessionLogger(deps);
67
67
 
68
68
  logger.debug("test.event");
69
69
 
@@ -76,7 +76,7 @@ describe("createSessionLogger", () => {
76
76
  const deps = makeDeps({
77
77
  getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog }),
78
78
  });
79
- const logger = createSessionLogger(deps);
79
+ const logger = new PermissionSessionLogger(deps);
80
80
  debugLog = false;
81
81
 
82
82
  logger.debug("test.event");
@@ -91,7 +91,7 @@ describe("createSessionLogger", () => {
91
91
  it("writes a JSONL line to the review log file when permissionReviewLog is true", () => {
92
92
  // DEFAULT_EXTENSION_CONFIG.permissionReviewLog === true
93
93
  const deps = makeDeps();
94
- const logger = createSessionLogger(deps);
94
+ const logger = new PermissionSessionLogger(deps);
95
95
 
96
96
  logger.review("permission.granted", { agentName: "coder" });
97
97
 
@@ -106,7 +106,7 @@ describe("createSessionLogger", () => {
106
106
  permissionReviewLog: false,
107
107
  }),
108
108
  });
109
- const logger = createSessionLogger(deps);
109
+ const logger = new PermissionSessionLogger(deps);
110
110
 
111
111
  logger.review("permission.granted");
112
112
 
@@ -123,7 +123,7 @@ describe("createSessionLogger", () => {
123
123
  globalLogsDir: makeBlockedLogsDir(),
124
124
  getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
125
125
  });
126
- const logger = createSessionLogger(deps);
126
+ const logger = new PermissionSessionLogger(deps);
127
127
 
128
128
  logger.debug("test.event");
129
129
 
@@ -138,7 +138,7 @@ describe("createSessionLogger", () => {
138
138
  globalLogsDir: makeBlockedLogsDir(),
139
139
  getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
140
140
  });
141
- const logger = createSessionLogger(deps);
141
+ const logger = new PermissionSessionLogger(deps);
142
142
 
143
143
  logger.debug("event.one");
144
144
  logger.debug("event.two");
@@ -155,7 +155,7 @@ describe("createSessionLogger", () => {
155
155
  permissionReviewLog: true,
156
156
  }),
157
157
  });
158
- const logger = createSessionLogger(deps);
158
+ const logger = new PermissionSessionLogger(deps);
159
159
 
160
160
  logger.debug("event.one"); // emits warning
161
161
  logger.review("event.two"); // same error message → suppressed
@@ -169,7 +169,7 @@ describe("createSessionLogger", () => {
169
169
  describe("warn", () => {
170
170
  it("calls notify with the message directly", () => {
171
171
  const deps = makeDeps();
172
- const logger = createSessionLogger(deps);
172
+ const logger = new PermissionSessionLogger(deps);
173
173
 
174
174
  logger.warn("Something went wrong");
175
175
 
@@ -178,7 +178,7 @@ describe("createSessionLogger", () => {
178
178
 
179
179
  it("calls notify for every warn — not deduplicated", () => {
180
180
  const deps = makeDeps();
181
- const logger = createSessionLogger(deps);
181
+ const logger = new PermissionSessionLogger(deps);
182
182
 
183
183
  logger.warn("same message");
184
184
  logger.warn("same message");
@@ -192,7 +192,7 @@ describe("createSessionLogger", () => {
192
192
  getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG }),
193
193
  notify: () => {},
194
194
  };
195
- const logger = createSessionLogger(deps);
195
+ const logger = new PermissionSessionLogger(deps);
196
196
 
197
197
  expect(() => logger.warn("test")).not.toThrow();
198
198
  });