Files
my-pi/pi-permission-system/docs/plans/archive/0129-extract-permission-session.md
T

23 KiB

issue, issue_title
issue issue_title
129 refactor: extract PermissionSession class to encapsulate mutable session state

Extract PermissionSession class

Problem statement

HandlerDeps is a wide bag passed identically to every handler. Handlers reach through deps.session.permissionManager.checkPermission(...) and deps.session.sessionRules.getRuleset() (Law of Demeter violations), write back into deps.session.runtimeContext = ctx (output argument), and duplicate the same 4-field reset sequence across three lifecycle handlers. No single object owns the mutable session state, so callers must coordinate scattered mutations.

Goals

  • Extract a PermissionSession class that owns all mutable session state (PermissionManager, SessionRules, cache keys, skill entries, runtime context) and exposes operations instead of fields.
  • Eliminate LoD violations: callers call session.checkPermission(...) instead of deps.session.permissionManager.checkPermission(...).
  • Eliminate output arguments: session.activate(ctx) replaces deps.session.runtimeContext = ctx.
  • Consolidate scattered resets into resetForNewSession() and shutdown().
  • PermissionSession satisfies GateRunnerDeps directly — the adapter construction in handleToolCall becomes trivial.
  • Constructor takes 4 deps: ExtensionPaths, SessionLogger, PermissionPrompter, ForwardingManager.
  • No behavioral change — same permission decisions, same event emissions, same config loading.

Non-goals

  • Replacing HandlerDeps entirely — that is #130 (handler classes).
  • Refactoring PermissionManager or PermissionPrompter internals.
  • Changing the /permission-system slash command or config format.
  • Changing any default policy state.

Dependencies

  • #126 — ExtensionPaths (closed, implemented).
  • #127 — SessionLogger (closed, implemented).
  • #128 — ForwardingManager (closed, implemented).
  • PermissionPrompter (existing class, unchanged).

Background

Permission surfaces involved

None directly — pure internal refactor. All surfaces (tools, bash, mcp, skills, special, external_directory) are exercised by the handlers being refactored; integration tests validate correctness.

Current state

SessionState is a 7-field mutable interface defined in src/runtime.ts. ExtensionRuntime extends both ExtensionPaths and SessionState, adding config, lastConfigWarning, and logging methods. Handlers receive HandlerDeps with session: SessionState and 15+ additional fields for logging, config refresh, prompting, etc.

The scattered mutations in lifecycle handlers:

Handler Fields reset
handleSessionStart runtimeContext, permissionManager, activeSkillEntries, lastActiveToolsCacheKey, lastPromptStateCacheKey, lastKnownActiveAgentName
handleResourcesDiscover (reload) permissionManager, activeSkillEntries, lastActiveToolsCacheKey, lastPromptStateCacheKey
handleSessionShutdown runtimeContext, activeSkillEntries, lastActiveToolsCacheKey, lastPromptStateCacheKey, sessionRules.clear()

GateRunnerDeps alignment

GateRunnerDeps (defined in src/handlers/gates/descriptor.ts) has 7 leaf methods: checkPermission, getSessionRuleset, approveSessionRule, writeReviewLog, emitDecision, canConfirm, promptPermission. PermissionSession will expose matching methods so it can satisfy this interface directly (except emitDecision, canConfirm, and promptPermission which depend on ctx or the event bus — those remain as adapter closures).

Design overview

PermissionSession class shape

class PermissionSession {
  constructor(
    private readonly paths: ExtensionPaths,
    private readonly logger: SessionLogger,
    private readonly prompter: PermissionPrompterApi,
    private readonly forwarding: ForwardingController,
  );

  // Context lifecycle
  activate(ctx: ExtensionContext): void;
  deactivate(): void;

  // Permission checking (delegates to internal PermissionManager)
  checkPermission(surface: string, input: unknown, agentName?: string, rules?: Rule[]): PermissionCheckResult;
  getToolPermission(toolName: string, agentName?: string): PermissionState;
  getConfigIssues(agentName?: string): string[];
  getPolicyCacheStamp(agentName?: string): string;
  getComposedConfigRules(agentName?: string): unknown;
  getResolvedPolicyPaths(): unknown;

  // Session rules (delegates to internal SessionRules)
  getSessionRuleset(): Rule[];
  approveSessionRule(surface: string, pattern: string): void;

  // Session lifecycle (replaces scattered field resets)
  resetForNewSession(ctx: ExtensionContext): void;
  reload(): void;
  shutdown(): void;

  // Agent-start caching
  shouldUpdateActiveTools(cacheKey: string): boolean;
  commitActiveToolsCacheKey(cacheKey: string): void;
  shouldUpdatePromptState(cacheKey: string): boolean;
  commitPromptStateCacheKey(cacheKey: string): void;

  // Skill entries
  getActiveSkillEntries(): SkillPromptEntry[];
  setActiveSkillEntries(entries: SkillPromptEntry[]): void;

  // Agent name
  resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
  get lastKnownActiveAgentName(): string | null;

  // Config
  refreshConfig(ctx?: ExtensionContext): void;
  logResolvedConfigPaths(): void;
  get config(): PermissionSystemExtensionConfig;

  // Prompting
  canPrompt(ctx: ExtensionContext): boolean;
  prompt(ctx: ExtensionContext, details: PromptPermissionDetails): Promise<PermissionPromptDecision>;

  // Infrastructure paths
  getInfrastructureDirs(): string[];
  getInfrastructureReadPaths(): string[];

  // Forwarding
  startForwarding(ctx: ExtensionContext): void;
  stopForwarding(): void;
}

Key design points:

  1. 4 constructor deps, all real abstractions — not raw fields.
  2. activate(ctx) replaces deps.session.runtimeContext = ctx + deps.forwarding.start(ctx).
  3. checkPermission(...) delegates to internal PermissionManager — callers never see it.
  4. resetForNewSession(ctx) replaces the 4-field reset copy-paste.
  5. canPrompt(ctx) and prompt(ctx, details) still take ctx because the session does not hold the event bus and the context may change between calls.
  6. Internal PermissionManager is recreated in resetForNewSession() via createPermissionManagerForCwd().

Migration strategy: alongside, then swap

  1. Introduce PermissionSession as a new class in src/permission-session.ts.
  2. Keep SessionState and HandlerDeps unchanged initially.
  3. Wire PermissionSession in src/index.ts alongside the existing runtime.
  4. Migrate handlers one at a time to use PermissionSession instead of reaching through deps.session.* fields.
  5. Once all handlers are migrated, HandlerDeps.session changes type from SessionState to PermissionSession.
  6. SessionState interface can be removed (or retained as the test-mock interface for PermissionSession).

HandlerDeps evolution

After migration, HandlerDeps shrinks — many fields become unnecessary because PermissionSession encapsulates them:

Removed from HandlerDeps Absorbed by PermissionSession
createPermissionManagerForCwd Internal to resetForNewSession()
refreshExtensionConfig session.refreshConfig()
logResolvedConfigPaths session.logResolvedConfigPaths()
resolveAgentName session.resolveAgentName()
canRequestPermissionConfirmation session.canPrompt()
promptPermission session.prompt()
forwarding Internal, exposed via session.startForwarding()/session.stopForwarding()
piInfrastructureDirs session.getInfrastructureDirs()
getPiInfrastructureReadPaths session.getInfrastructureReadPaths()

Fields that remain on HandlerDeps (until #130 removes it entirely): events, stopPermissionRpcHandlers, getAllTools, setActiveTools, createPermissionRequestId.

GateRunnerDeps adapter simplification

Before (in handleToolCall):

const runnerDeps: GateRunnerDeps = {
  checkPermission: (s, i, a, r) => deps.session.permissionManager.checkPermission(s, i, a, r),
  getSessionRuleset: () => deps.session.sessionRules.getRuleset(),
  approveSessionRule: (s, p) => deps.session.sessionRules.approve(s, p),
  writeReviewLog: deps.logger.review,
  emitDecision: (e) => emitDecisionEvent(deps.events, e),
  canConfirm,
  promptPermission,
};

After:

const runnerDeps: GateRunnerDeps = {
  checkPermission: (s, i, a, r) => session.checkPermission(s, i, a, r),
  getSessionRuleset: () => session.getSessionRuleset(),
  approveSessionRule: (s, p) => session.approveSessionRule(s, p),
  writeReviewLog: (e, d) => session.logger.review(e, d),
  emitDecision: (e) => emitDecisionEvent(deps.events, e),
  canConfirm: () => session.canPrompt(ctx),
  promptPermission: (d) => session.prompt(ctx, d),
};

No more LoD violations — every call is one level deep.

Module-level changes

File Change
src/permission-session.ts New. PermissionSession class with 4 constructor deps.
src/runtime.ts SessionState retained for backward compat during migration. createPermissionManagerForCwd stays as a free function (used by PermissionSession internally).
src/handlers/types.ts HandlerDeps.session type changes from SessionState to PermissionSession. Remove fields absorbed by the session.
src/handlers/lifecycle.ts Replace scattered field resets with session.resetForNewSession(ctx) / session.shutdown(). Remove deps.createPermissionManagerForCwd / deps.refreshExtensionConfig calls.
src/handlers/before-agent-start.ts Replace deps.session.permissionManager.* with session.checkPermission(...) etc. Replace deps.resolveAgentName with session.resolveAgentName.
src/handlers/tool-call.ts Simplify gate adapter construction — delegate to session.* methods. Replace deps.forwarding.start(ctx) with session.startForwarding(ctx).
src/handlers/input.ts Replace deps.session.permissionManager.checkPermission with session.checkPermission. Replace deps.canRequestPermissionConfirmation with session.canPrompt.
src/index.ts Construct PermissionSession with ExtensionPaths, SessionLogger, PermissionPrompter, ForwardingManager. Pass it as deps.session. Remove fields from deps that are now on the session.
tests/permission-session.test.ts New. Unit tests for PermissionSession methods: activate, resetForNewSession, shutdown, checkPermission delegation, resolveAgentName, cache key methods.
tests/handlers/lifecycle.test.ts Replace makeSession() with PermissionSession mock. Simplify — no more direct field assertions on session state.
tests/handlers/before-agent-start.test.ts Replace LoD mock chains with flat session.checkPermission / session.getToolPermission stubs.
tests/handlers/tool-call.test.ts Simplify makeDeps() — session mock provides checkPermission, getSessionRuleset, etc. directly.
tests/handlers/tool-call-events.test.ts Same simplification.
tests/handlers/input.test.ts Replace deps.session.permissionManager.checkPermission with session.checkPermission.
tests/handlers/input-events.test.ts Same simplification.
docs/architecture/architecture.md Update module listing: add permission-session.ts, update handlers/types.ts description.

Test impact analysis

  1. New unit tests enabled: PermissionSession can be tested in isolation — resetForNewSession(), shutdown(), activate(), cache key logic, agent name resolution. These were previously untestable because the logic was scattered across handlers.
  2. Existing handler tests simplified: makeSession() factories shrink from 7+ fields with nested mocks (permissionManager: { checkPermission: vi.fn() }) to a flat mock of PermissionSession methods. as unknown as casts on SessionState["permissionManager"] disappear.
  3. Existing handler tests that must stay: Tests that verify handler orchestration logic (which gates are called, in what order, how results are handled) must remain. Tests that verify the scattered reset sequences can be simplified to assert session.resetForNewSession() / session.shutdown() was called once.
  4. Integration test unaffected: tests/permission-system.test.ts calls piPermissionSystemExtension(mockPi) and never constructs HandlerDeps — validates the wiring is correct.

TDD order

Phase 1: PermissionSession class (new code, no existing code changes)

  1. Write tests/permission-session.test.ts with tests for constructor, activate(ctx), deactivate(), checkPermission delegation, getToolPermission delegation, getSessionRuleset delegation, approveSessionRule delegation. Red — class does not exist yet. test: add PermissionSession unit tests (#129)

  2. Implement src/permission-session.ts with constructor and delegation methods. Tests go green. feat: PermissionSession class with delegation methods (#129)

  3. Add tests for resetForNewSession(ctx) — verifies new PermissionManager is created, cache keys are cleared, skill entries are cleared, forwarding is started. Red, then implement. test: PermissionSession resetForNewSession (#129)

  4. Add tests for shutdown() — verifies sessionRules.clear(), cache keys cleared, forwarding stopped, context deactivated. Red, then implement. test: PermissionSession shutdown (#129)

  5. Add tests for cache key methods (shouldUpdateActiveTools, commitActiveToolsCacheKey, shouldUpdatePromptState, commitPromptStateCacheKey). Red, then implement. test: PermissionSession cache key methods (#129)

  6. Add tests for resolveAgentName(ctx, systemPrompt?), refreshConfig(ctx?), logResolvedConfigPaths(), getInfrastructureDirs(), getInfrastructureReadPaths(), canPrompt(ctx), prompt(ctx, details). Red, then implement. test: PermissionSession config and prompt methods (#129)

Phase 2: Wire PermissionSession into index.ts (alongside existing)

  1. Construct PermissionSession in src/index.ts. Pass it as deps.session on HandlerDeps. Update HandlerDeps.session type from SessionState to PermissionSession. Remove absorbed fields from HandlerDeps. Existing handler tests break (type mismatch) — update makeDeps factories to use a PermissionSession mock. refactor: wire PermissionSession into HandlerDeps (#129)

Phase 3: Migrate handlers (one at a time)

  1. Migrate handleSessionStart and handleResourcesDiscover to use session.resetForNewSession(ctx) instead of scattered field writes. Migrate handleSessionShutdown to use session.shutdown(). Update lifecycle tests: assert session.resetForNewSession / session.shutdown called instead of checking individual field values. refactor: lifecycle handlers use PermissionSession (#129)

  2. Migrate handleBeforeAgentStart to use session.resolveAgentName(), session.getToolPermission(), session.refreshConfig(), session.startForwarding(), session.getPolicyCacheStamp(), session.setActiveSkillEntries(). Update before-agent-start tests. refactor: handleBeforeAgentStart uses PermissionSession (#129)

  3. Migrate handleToolCall to use session.checkPermission(), session.getSessionRuleset(), session.approveSessionRule(), session.startForwarding(), session.resolveAgentName(), session.getActiveSkillEntries(), session.getInfrastructureDirs(), session.getInfrastructureReadPaths(). Update tool-call and tool-call-events tests. refactor: handleToolCall uses PermissionSession (#129)

  4. Migrate handleInput to use session.checkPermission(), session.canPrompt(), session.prompt(), session.resolveAgentName(), session.startForwarding(). Update input and input-events tests. refactor: handleInput uses PermissionSession (#129)

Phase 4: Cleanup

  1. Remove SessionState interface from src/runtime.ts if no longer referenced. Remove absorbed free functions (resolveAgentName, logResolvedConfigPaths) from runtime.ts if they are now only used internally by PermissionSession. refactor: remove SessionState interface (#129)

  2. Update docs/architecture/architecture.md module listing. docs: update architecture for PermissionSession (#129)

Risks and mitigations

Risk Mitigation
Could this silently weaken a permission? Pure refactor — same checkPermission calls, same parameters, same gate evaluation order. PermissionSession.checkPermission delegates directly to PermissionManager.checkPermission. Integration tests validate end-to-end.
Large blast radius across 6 test files Handlers are migrated one at a time (phase 3). Each step leaves the repo green. PermissionSession mock is flat — no nested object chains.
PermissionSession becomes a god object It encapsulates state that is already coupled (permissionManager + sessionRules + caches + skillEntries all reset together). The class has ~20 methods but they are thin delegates — no business logic beyond lifecycle coordination.
ExtensionRuntime and PermissionSession overlap during migration ExtensionRuntime continues to exist as the internal composition root. PermissionSession wraps a subset of its state. After #130 (handler classes), ExtensionRuntime may be simplified further.
Test factories must be rewritten makeSession() factories change from 7 nested-mock fields to flat vi.fn() stubs on PermissionSession methods. This is mechanical and reduces test boilerplate.
refreshExtensionConfig and saveExtensionConfig live on runtime.ts and touch ExtensionRuntime directly PermissionSession.refreshConfig() delegates to the existing refreshExtensionConfig(runtime, ctx) free function. saveExtensionConfig stays on runtime.ts — it is only called from the /permission-system command, not from handlers.

Open questions

  • Should PermissionSession expose a logger property so handleToolCall can pass session.logger.review to GateRunnerDeps.writeReviewLog? Or should it expose a writeReviewLog method directly? Leaning toward exposing the SessionLogger since it is already a narrow interface — avoids duplicating method signatures.
  • Should canPrompt(ctx) and prompt(ctx, details) absorb ctx via activate() so they become zero-arg / one-arg? The issue suggests this but it adds temporal coupling (must call activate before canPrompt). Current plan keeps ctx explicit for safety — revisit in #130 if handler classes guarantee activate is always called first.
  • Should the config-modal.ts command (getComposedRules, getConfig) read from PermissionSession instead of runtime? Defer — the command is wired in index.ts with closures over runtime and does not flow through HandlerDeps.