26 KiB
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:SessionLifecycleHandler(2 deps:session,cleanupRpc)AgentPrepHandler(2 deps:session,toolRegistry)PermissionGateHandler(3 deps:session,events,toolRegistry)
- Extract a
ToolRegistryinterface wrappingpi.getAllTools()andpi.setActiveTools(). - Absorb
canRequestPermissionConfirmationandpromptPermissionintoPermissionSession(deferred from #129, see its open questions). - Delete
HandlerDepsandsrc/handlers/types.ts. - Move
PromptPermissionDetailsandPermissionReviewSourceto a shared location before their host file is deleted. - No behavioral change — same permission decisions, same event emissions, same config loading.
Non-goals
- Refactoring
PermissionManagerorPermissionPrompterinternals. - Changing the
/permission-systemslash command or config format. - Changing any default policy state.
- Migrating
registerPermissionRpcHandlersorregisterPermissionSystemCommandto usePermissionSession— they wire throughruntimedirectly inindex.tsand 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
- New unit tests enabled:
PermissionSession.canPrompt,.prompt,.createPermissionRequestIdcan be tested in isolation — these were previously untestable closures inindex.ts. - 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 gainscanPrompt/prompt/createPermissionRequestIdbut this is mechanical — and the mock is still flatvi.fn()stubs. - 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. - Integration test unaffected:
tests/permission-system.test.tscallspiPermissionSystemExtension(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
- Add tests to
tests/permission-session.test.tsfor the three new methods. - Expand
PermissionSessionRuntimeDepswithcanRequestPermissionConfirmationandpromptPermission. - Implement the methods on
PermissionSession. - Update
tests/permission-session.test.tshelper (makeRuntimeDeps) to include new delegates. - Run
pnpm run buildto verify types.
feat: PermissionSession absorbs prompting methods (#130)
Step 4: Convert lifecycle handlers to SessionLifecycleHandler
- Convert
handleSessionStart,handleResourcesDiscover,handleSessionShutdownfrom free functions to methods onSessionLifecycleHandlerclass. - Keep the free functions as deprecated re-exports temporarily (or just update callers).
- Update
tests/handlers/lifecycle.test.ts: replacemakeDeps()+handleSessionStart(deps, ...)withnew SessionLifecycleHandler(mockSession, mockCleanup)+handler.handleSessionStart(...). - Update
src/index.tsto constructSessionLifecycleHandlerand wire lifecycle events. - Remove lifecycle handler fields from
HandlerDepsthat are no longer needed (juststopPermissionRpcHandlers).
refactor: SessionLifecycleHandler class (#130)
Step 5: Convert before-agent-start handler to AgentPrepHandler
- Convert
handleBeforeAgentStarttoAgentPrepHandler.handlemethod. - Update
tests/handlers/before-agent-start.test.ts: replacemakeDeps()withnew AgentPrepHandler(mockSession, mockToolRegistry). - Update
src/index.tsto constructAgentPrepHandlerand wirebefore_agent_start. shouldExposeToolstays as an exported free function in the same file.
refactor: AgentPrepHandler class (#130)
Step 6: Convert tool-call and input handlers to PermissionGateHandler
- Create
PermissionGateHandlerclass withhandleToolCallandhandleInputmethods. handleToolCallmoves fromsrc/handlers/tool-call.tsinto the class.getEventInputstays as exported free function.handleInputmoves fromsrc/handlers/input.tsinto the class.extractSkillNameFromInputstays as exported free function.- Update
tests/handlers/tool-call.test.tsandtests/handlers/tool-call-events.test.ts: replacemakeDeps()withnew PermissionGateHandler(mockSession, mockEvents, mockToolRegistry). AddcanPrompt/promptto session mock. - Update
tests/handlers/input.test.tsandtests/handlers/input-events.test.ts: same pattern. - Update
src/index.tsto constructPermissionGateHandlerand wiretool_call+input.
refactor: PermissionGateHandler class (#130)
Step 7: Delete HandlerDeps and src/handlers/types.ts
- Remove the
HandlerDepsinterface fromsrc/handlers/types.ts. - Delete
src/handlers/types.tsentirely (types already relocated in step 1). - Update
src/handlers/index.tsre-exports — export classes + pure helpers only. - Run
pnpm run build+pnpm vitest runto verify nothing referencesHandlerDeps.
refactor: delete HandlerDeps and handlers/types.ts (#130)
Step 8: Update architecture doc
Update docs/architecture/architecture.md module listing:
- Remove
types.tsentry fromhandlers/. - Update
lifecycle.ts,before-agent-start.ts,tool-call.ts,input.tsdescriptions to mention handler classes. - Add
ToolRegistrytotool-registry.tsdescription. - Note that
PromptPermissionDetailsmoved topermission-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 4–6 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
PermissionGateHandlerlive in a single new file (e.g.src/handlers/permission-gate-handler.ts) or shouldhandleToolCallstay intool-call.tsandhandleInputstay ininput.tswith 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
createPermissionRequestIdlive onPermissionSessionor as a standalone free function? The plan places it onPermissionSessionto keep handler class constructors minimal, but it's a pure function with no state dependency. Decide during step 3.