Files
my-pi/pi-permission-system/docs/plans/archive/0130-handler-classes.md
T

26 KiB
Raw Blame History

issue, issue_title
issue issue_title
130 refactor: replace HandlerDeps with handler classes using narrow constructor injection

Replace HandlerDeps with handler classes

Problem statement

After PermissionSession exists (#129), HandlerDeps still acts as a monolithic bag. Free-function handlers all receive the same 8-field interface even though each uses a different subset. This makes dependency requirements invisible and test factories (makeDeps()) unnecessarily wide — every test file builds the same full bag regardless of which fields the handler under test touches.

Goals

  • Replace HandlerDeps + free-function handlers with three handler classes, each with narrow constructor injection:
    1. SessionLifecycleHandler (2 deps: session, cleanupRpc)
    2. AgentPrepHandler (2 deps: session, toolRegistry)
    3. PermissionGateHandler (3 deps: session, events, toolRegistry)
  • Extract a ToolRegistry interface wrapping pi.getAllTools() and pi.setActiveTools().
  • Absorb canRequestPermissionConfirmation and promptPermission into PermissionSession (deferred from #129, see its open questions).
  • Delete HandlerDeps and src/handlers/types.ts.
  • Move PromptPermissionDetails and PermissionReviewSource to a shared location before their host file is deleted.
  • No behavioral change — same permission decisions, same event emissions, same config loading.

Non-goals

  • Refactoring PermissionManager or PermissionPrompter internals.
  • Changing the /permission-system slash command or config format.
  • Changing any default policy state.
  • Migrating registerPermissionRpcHandlers or registerPermissionSystemCommand to use PermissionSession — they wire through runtime directly in index.ts and are unaffected.
  • Extracting handleInput's permission logic into a gate descriptor (future follow-up).

Dependencies

  • #129 — PermissionSession (closed, implemented).
  • #126 — ExtensionPaths (closed, implemented).
  • #127 — SessionLogger (closed, implemented).
  • #128 — ForwardingManager (closed, implemented).

Part of the handler decomposition series — see plan doc.

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 after #129

HandlerDeps has 8 fields:

Field Used by
session: PermissionSession all handlers
events: PermissionEventBus tool-call, input (for emitDecisionEvent)
canRequestPermissionConfirmation(ctx) tool-call, input
promptPermission(ctx, details) tool-call, input
createPermissionRequestId(prefix) input only
stopPermissionRpcHandlers() lifecycle shutdown only
getAllTools() before-agent-start, tool-call
setActiveTools(names) before-agent-start only

Each handler uses a different subset:

  • lifecycle: session + stopPermissionRpcHandlers
  • before-agent-start: session + getAllTools + setActiveTools
  • tool-call: session + events + getAllTools + canRequestPermissionConfirmation + promptPermission
  • input: session + events + canRequestPermissionConfirmation + promptPermission + createPermissionRequestId

Types that must relocate

src/handlers/types.ts currently defines PromptPermissionDetails and PermissionReviewSource. These are imported by src/permission-prompter.ts and src/handlers/gates/descriptor.ts. They must move to a shared location before the file is deleted.

Design overview

ToolRegistry interface

interface ToolRegistry {
  getAll(): unknown[];
  setActive(names: string[]): void;
}

Defined in src/tool-registry.ts (alongside existing getToolNameFromValue and checkRequestedToolRegistration). Constructed in index.ts wrapping pi.getAllTools() and pi.setActiveTools().

Absorb prompting into PermissionSession

PermissionSession gains three new methods and two new runtime deps:

// Added to PermissionSessionRuntimeDeps
canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
promptPermission(
  ctx: ExtensionContext,
  details: PromptPermissionDetails,
): Promise<PermissionPromptDecision>;

// Added to PermissionSession
canPrompt(ctx: ExtensionContext): boolean;
prompt(
  ctx: ExtensionContext,
  details: PromptPermissionDetails,
): Promise<PermissionPromptDecision>;
createPermissionRequestId(prefix: string): string;

canPrompt delegates to runtimeDeps.canRequestPermissionConfirmation. prompt delegates to runtimeDeps.promptPermission. createPermissionRequestId is a self-contained ID generator (moved from index.ts closure).

This completes the migration that #129 deferred: handler classes call session.canPrompt(ctx) and session.prompt(ctx, details) instead of reaching through deps.*.

Handler classes

// src/handlers/lifecycle.ts
class SessionLifecycleHandler {
  constructor(
    private readonly session: PermissionSession,
    private readonly cleanupRpc: () => void,
  ) {}

  handleSessionStart(event: SessionStartPayload, ctx: ExtensionContext): Promise<void>;
  handleResourcesDiscover(event: ResourcesDiscoverPayload): Promise<void>;
  handleSessionShutdown(): Promise<void>;
}
// src/handlers/before-agent-start.ts
class AgentPrepHandler {
  constructor(
    private readonly session: PermissionSession,
    private readonly toolRegistry: ToolRegistry,
  ) {}

  handle(event: BeforeAgentStartPayload, ctx: ExtensionContext): Promise<BeforeAgentStartEventResult>;
}
// src/handlers/tool-call.ts  (handleToolCall method)
// src/handlers/input.ts      (handleInput method)
class PermissionGateHandler {
  constructor(
    private readonly session: PermissionSession,
    private readonly events: PermissionEventBus,
    private readonly toolRegistry: ToolRegistry,
  ) {}

  handleToolCall(event: unknown, ctx: ExtensionContext): Promise<{ block?: true; reason?: string }>;
  handleInput(event: InputPayload, ctx: ExtensionContext): Promise<InputEventResult>;
}

Wiring in index.ts

const session = new PermissionSession(paths, logger, forwarding, runtimeDeps);
const toolRegistry: ToolRegistry = {
  getAll: () => pi.getAllTools(),
  setActive: (names) => pi.setActiveTools(names),
};

const lifecycle = new SessionLifecycleHandler(session, () => {
  rpcHandles.unsubCheck();
  rpcHandles.unsubPrompt();
});
const agentPrep = new AgentPrepHandler(session, toolRegistry);
const gates = new PermissionGateHandler(session, pi.events, toolRegistry);

pi.on("session_start", (e, ctx) => lifecycle.handleSessionStart(e, ctx));
pi.on("resources_discover", (e) => lifecycle.handleResourcesDiscover(e));
pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
pi.on("before_agent_start", (e, ctx) => agentPrep.handle(e, ctx));
pi.on("input", (e, ctx) => gates.handleInput(e, ctx));
pi.on("tool_call", (e, ctx) => gates.handleToolCall(e, ctx));

PromptPermissionDetails relocation

Move PromptPermissionDetails and PermissionReviewSource from src/handlers/types.ts to src/permission-prompter.ts (collocated with the PermissionPrompterApi interface that already depends on it). Update imports in src/handlers/gates/descriptor.ts, src/handlers/tool-call.ts, src/handlers/input.ts, src/handlers/index.ts.

GateRunnerDeps construction after refactoring

Inside PermissionGateHandler.handleToolCall:

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

Same pattern but with this.session / this.events instead of deps.session / deps.events.

Pure helpers that survive the class migration

shouldExposeTool (pure helper in before-agent-start.ts), extractSkillNameFromInput (pure helper in input.ts), getEventInput (pure helper in tool-call.ts) remain as exported free functions alongside their handler classes. Tests for these pure helpers do not change.

Module-level changes

File Change
src/permission-session.ts Add canPrompt(ctx), prompt(ctx, details), createPermissionRequestId(prefix). Expand PermissionSessionRuntimeDeps with two new delegates.
src/permission-prompter.ts Receives PromptPermissionDetails and PermissionReviewSource type definitions (moved from src/handlers/types.ts).
src/tool-registry.ts Add ToolRegistry interface (alongside existing exports).
src/handlers/types.ts Deleted. Types moved; HandlerDeps removed.
src/handlers/lifecycle.ts Free functions → SessionLifecycleHandler class. Constructor takes (session, cleanupRpc). Methods unchanged in logic.
src/handlers/before-agent-start.ts Free function → AgentPrepHandler class. Constructor takes (session, toolRegistry). shouldExposeTool stays as exported free function.
src/handlers/tool-call.ts handleToolCall free function → PermissionGateHandler.handleToolCall method. Constructor takes (session, events, toolRegistry). getEventInput stays as exported free function. Prompt closures use this.session.canPrompt(ctx) / this.session.prompt(ctx, details). Tool registration check uses this.toolRegistry.getAll().
src/handlers/input.ts handleInput free function → PermissionGateHandler.handleInput method (same class as tool-call). extractSkillNameFromInput stays as exported free function. Uses this.session.canPrompt(ctx) / this.session.prompt(ctx, details) / this.session.createPermissionRequestId(prefix).
src/handlers/index.ts Update re-exports: export classes + pure helpers. Remove HandlerDeps, PromptPermissionDetails, PermissionReviewSource re-exports.
src/handlers/gates/descriptor.ts Update PromptPermissionDetails import path to src/permission-prompter.ts.
src/index.ts Replace deps: HandlerDeps construction with handler class instantiation. Wire events to class methods. Expand PermissionSessionRuntimeDeps construction with canRequestPermissionConfirmation and promptPermission.
tests/permission-session.test.ts Add tests for canPrompt, prompt, createPermissionRequestId.
tests/handlers/lifecycle.test.ts Replace makeDeps() with new SessionLifecycleHandler(mockSession, mockCleanup). Remove unused fields from factory.
tests/handlers/before-agent-start.test.ts Replace makeDeps() with new AgentPrepHandler(mockSession, mockToolRegistry). Remove unused fields.
tests/handlers/tool-call.test.ts Replace makeDeps() with new PermissionGateHandler(mockSession, mockEvents, mockToolRegistry). Remove unused fields. Add canPrompt/prompt to session mock.
tests/handlers/tool-call-events.test.ts Same pattern as tool-call.test.ts.
tests/handlers/input.test.ts Replace makeDeps() with new PermissionGateHandler(mockSession, mockEvents, mockToolRegistry). Add canPrompt/prompt/createPermissionRequestId to session mock.
tests/handlers/input-events.test.ts Same pattern as input.test.ts.
docs/architecture/architecture.md Update module listing: remove types.ts entry, update handler file descriptions to mention classes, add ToolRegistry to tool-registry.ts description.

Test impact analysis

  1. New unit tests enabled: PermissionSession.canPrompt, .prompt, .createPermissionRequestId can be tested in isolation — these were previously untestable closures in index.ts.
  2. Existing tests simplified: All 6 handler test files lose their makeDeps() factory. Each test constructs the specific handler class with only the deps it needs. The session mock gains canPrompt/prompt/createPermissionRequestId but this is mechanical — and the mock is still flat vi.fn() stubs.
  3. Existing tests that must stay: Tests for handler orchestration logic (gate ordering, cache key checks, prompt flow) stay intact — they test the same class methods. Tests for pure helpers (shouldExposeTool, extractSkillNameFromInput, getEventInput) are completely unchanged.
  4. Integration test unaffected: tests/permission-system.test.ts calls piPermissionSystemExtension(mockPi) and never sees handler internals.

TDD order

Step 1: Relocate PromptPermissionDetails and PermissionReviewSource

Move types from src/handlers/types.ts to src/permission-prompter.ts. Update all import paths. HandlerDeps stays temporarily in src/handlers/types.ts.

refactor: move PromptPermissionDetails to permission-prompter (#130)

Step 2: Add ToolRegistry interface

Add ToolRegistry interface to src/tool-registry.ts. No consumers yet — this is a type-only addition. Run pnpm run build to verify.

feat: add ToolRegistry interface (#130)

Step 3: Add canPrompt, prompt, createPermissionRequestId to PermissionSession

  1. Add tests to tests/permission-session.test.ts for the three new methods.
  2. Expand PermissionSessionRuntimeDeps with canRequestPermissionConfirmation and promptPermission.
  3. Implement the methods on PermissionSession.
  4. Update tests/permission-session.test.ts helper (makeRuntimeDeps) to include new delegates.
  5. Run pnpm run build to verify types.

feat: PermissionSession absorbs prompting methods (#130)

Step 4: Convert lifecycle handlers to SessionLifecycleHandler

  1. Convert handleSessionStart, handleResourcesDiscover, handleSessionShutdown from free functions to methods on SessionLifecycleHandler class.
  2. Keep the free functions as deprecated re-exports temporarily (or just update callers).
  3. Update tests/handlers/lifecycle.test.ts: replace makeDeps() + handleSessionStart(deps, ...) with new SessionLifecycleHandler(mockSession, mockCleanup) + handler.handleSessionStart(...).
  4. Update src/index.ts to construct SessionLifecycleHandler and wire lifecycle events.
  5. Remove lifecycle handler fields from HandlerDeps that are no longer needed (just stopPermissionRpcHandlers).

refactor: SessionLifecycleHandler class (#130)

Step 5: Convert before-agent-start handler to AgentPrepHandler

  1. Convert handleBeforeAgentStart to AgentPrepHandler.handle method.
  2. Update tests/handlers/before-agent-start.test.ts: replace makeDeps() with new AgentPrepHandler(mockSession, mockToolRegistry).
  3. Update src/index.ts to construct AgentPrepHandler and wire before_agent_start.
  4. shouldExposeTool stays as an exported free function in the same file.

refactor: AgentPrepHandler class (#130)

Step 6: Convert tool-call and input handlers to PermissionGateHandler

  1. Create PermissionGateHandler class with handleToolCall and handleInput methods.
  2. handleToolCall moves from src/handlers/tool-call.ts into the class. getEventInput stays as exported free function.
  3. handleInput moves from src/handlers/input.ts into the class. extractSkillNameFromInput stays as exported free function.
  4. Update tests/handlers/tool-call.test.ts and tests/handlers/tool-call-events.test.ts: replace makeDeps() with new PermissionGateHandler(mockSession, mockEvents, mockToolRegistry). Add canPrompt/prompt to session mock.
  5. Update tests/handlers/input.test.ts and tests/handlers/input-events.test.ts: same pattern.
  6. Update src/index.ts to construct PermissionGateHandler and wire tool_call + input.

refactor: PermissionGateHandler class (#130)

Step 7: Delete HandlerDeps and src/handlers/types.ts

  1. Remove the HandlerDeps interface from src/handlers/types.ts.
  2. Delete src/handlers/types.ts entirely (types already relocated in step 1).
  3. Update src/handlers/index.ts re-exports — export classes + pure helpers only.
  4. Run pnpm run build + pnpm vitest run to verify nothing references HandlerDeps.

refactor: delete HandlerDeps and handlers/types.ts (#130)

Step 8: Update architecture doc

Update docs/architecture/architecture.md module listing:

  • Remove types.ts entry from handlers/.
  • Update lifecycle.ts, before-agent-start.ts, tool-call.ts, input.ts descriptions to mention handler classes.
  • Add ToolRegistry to tool-registry.ts description.
  • Note that PromptPermissionDetails moved to permission-prompter.ts.

docs: update architecture for handler classes (#130)

Risks and mitigations

Risk Mitigation
Could this silently weaken a permission? Pure refactor — same checkPermission calls, same parameters, same gate evaluation order. PermissionGateHandler.handleToolCall produces identical GateRunnerDeps. Integration tests validate end-to-end.
Handler class constructor changes could break test compilation Each handler class is in its own file (or a new shared file for PermissionGateHandler). Migration is per-handler, not all-at-once. Each step leaves the repo green.
PermissionSession grows by 3 methods — risk of god object? The 3 new methods (canPrompt, prompt, createPermissionRequestId) are thin delegates. The session already coordinates prompting conceptually (it owns the PermissionPrompter's caller-side interface). Total method count stays reasonable.
PromptPermissionDetails relocation could break external consumers No external consumers — it's an internal type. All import paths are updated mechanically.
ToolRegistry interface could be too narrow or too wide It mirrors exactly pi.getAllTools() and pi.setActiveTools() — the only two Pi API methods handlers need. If more are needed later, the interface can grow.
createPermissionRequestId on PermissionSession may feel out of place It's a trivial ID generator that only handlers call. Alternative: a free function or a private method on PermissionGateHandler. Placing it on PermissionSession keeps the handler class constructors exactly as specified in the issue (3 deps). If it feels wrong during implementation, it can be extracted to a free utility in the same step.
Steps 46 each partially consume HandlerDeps while it still exists Each step updates index.ts wiring for the converted handler while leaving HandlerDeps in place for the remaining free functions. Step 7 is the final deletion. This incremental approach avoids a big-bang change.

Open questions

  • Should PermissionGateHandler live in a single new file (e.g. src/handlers/permission-gate-handler.ts) or should handleToolCall stay in tool-call.ts and handleInput stay in input.ts with each exporting a class? Leaning toward a single file since the issue proposes a single class, but implementation may reveal it's cleaner to keep two files with one class spanning them via re-export. Decide during step 6.
  • Should createPermissionRequestId live on PermissionSession or as a standalone free function? The plan places it on PermissionSession to keep handler class constructors minimal, but it's a pure function with no state dependency. Decide during step 3.