16 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 42 | Extract event handlers from piPermissionSystemExtension into separate modules |
Extract event handlers into separate modules
Problem Statement
After #21 (module extraction), #41 (permission-gate abstraction), and #55 (pure evaluate()), src/index.ts is still ~1066 lines.
The piPermissionSystemExtension factory contains 6 inline event-handler closures (~740 lines) that each represent a distinct concern.
The tool_call handler alone is ~250 lines.
This makes the file hard to navigate, review, and test in isolation.
The target architecture identifies this extraction as the first step in the structural cleanup phase, blocking #43 (eliminate module-scope state).
Goals
- Create
src/handlers/with dedicated modules for each handler group. - Define a
HandlerDepsinterface that replaces closure-captured state with an explicit dependency bag. - Reduce
src/index.tsto a thin wiring layer (≤200 lines target). - Add unit tests for each handler module using mocked deps.
- No change to permission semantics — pure structural refactor.
Non-Goals
- Changing permission resolution logic, merge precedence, or default policy.
- Restructuring module-scope state (
PI_AGENT_DIR,extensionLogger, etc.) — deferred to #43 (ExtensionRuntime). - Refactoring
promptPermission,writeReviewLog, or other helper internals. - Extracting the config-save/load helpers or the slash-command registration (they are not event handlers).
- Unifying the Rule type or normalizing config into flat
Rulesetat load time — deferred to #56 per the refactoring sequence.
Background
The six handlers currently live as anonymous closures inside piPermissionSystemExtension():
| Handler | Approx lines | Concern |
|---|---|---|
session_start |
~20 | Init runtime state, start forwarding, log config |
resources_discover |
~12 | Re-create permission manager on reload |
session_shutdown |
~8 | Teardown |
before_agent_start |
~80 | Tool filtering + prompt sanitization |
input |
~50 | Skill input permission gate |
tool_call |
~250 | Skill-read, external-directory, and tool permission gates |
All handlers share closure state: permissionManager, extensionConfig, runtimeContext, activeSkillEntries, sessionApprovalCache, lastKnownActiveAgentName, and several inner helper functions (resolveAgentName, shouldExposeTool, promptPermission, canRequestPermissionConfirmation, reviewPermissionDecision, createPermissionRequestId, etc.).
Key dependencies already extracted:
applyPermissionGatefromsrc/permission-gate.ts(#41) — used byinputandtool_callhandlers.evaluate(),Rule,Rulesetfromsrc/rule.ts(#55) — used internally bycheckPermission(); handlers still go throughPermissionManager.sanitizeAvailableToolsSectionfromsrc/system-prompt-sanitizer.ts— used bybefore_agent_start.resolveSkillPromptEntries/findSkillPathMatchfromsrc/skill-prompt-sanitizer.ts.
Permission surfaces involved: tools, bash, mcp, skills, external_directory (all gate through these handlers).
See architecture docs § "Monolithic index.ts" and § "Module map" for the full as-is picture.
Design Overview
Handler deps interface
A single context object replaces individual closure captures. Each handler function receives deps + the event + the Pi extension context:
export interface HandlerDeps {
// Mutable shared state (wrapped for testability)
getPermissionManager: () => PermissionManager;
setPermissionManager: (pm: PermissionManager) => void;
getExtensionConfig: () => PermissionSystemExtensionConfig;
getRuntimeContext: () => ExtensionContext | null;
setRuntimeContext: (ctx: ExtensionContext | null) => void;
getActiveSkillEntries: () => SkillPromptEntry[];
setActiveSkillEntries: (entries: SkillPromptEntry[]) => void;
sessionApprovalCache: SessionApprovalCache;
// Derived helpers (closures over shared state)
refreshExtensionConfig: (ctx?: ExtensionContext) => void;
invalidateAgentStartCache: () => void;
resolveAgentName: (ctx: ExtensionContext, systemPrompt?: string) => string | null;
shouldExposeTool: (toolName: string, agentName: string | null) => boolean;
canRequestPermissionConfirmation: (ctx: ExtensionContext) => boolean;
promptPermission: (ctx: ExtensionContext, details: PromptPermissionDetails) => Promise<PermissionPromptDecision>;
createPermissionRequestId: (prefix: string) => string;
notifyWarning: (message: string) => void;
logResolvedConfigPaths: () => void;
// Forwarding
startForwardedPermissionPolling: (ctx: ExtensionContext) => void;
stopForwardedPermissionPolling: () => void;
// Logging
writeReviewLog: (event: string, details: Record<string, unknown>) => void;
writeDebugLog: (event: string, details: Record<string, unknown>) => void;
// Pi API subset
getAllTools: () => unknown[];
setActiveTools: (names: string[]) => void;
}
The exact shape may slim down during implementation — some helpers (e.g., shouldExposeTool) could stay in the handler module if they only need getPermissionManager.
The key constraint is: every test can construct a HandlerDeps with stubs and exercise a handler without importing src/index.ts.
Alignment with ExtensionRuntime (#43)
The target architecture defines an ExtensionRuntime context object that replaces all module-scope mutable state.
HandlerDeps is designed as a stepping stone: #43 will fold the getter/setter pairs and mutable fields into ExtensionRuntime and pass that to handlers instead.
To keep that transition smooth:
- Handler function signatures use a single
depsparameter (not positional state args) — swapping the type is a one-line change per handler. - Helpers that only read state (e.g.,
shouldExposeTool,canRequestPermissionConfirmation) should be pure functions of their inputs where possible, taking the needed value as a parameter rather than closing over the deps bag. This aligns with the target architecture's principle: "pure evaluation, IO at the edges."
File layout
src/handlers/
types.ts # HandlerDeps interface + PromptPermissionDetails
lifecycle.ts # session_start, resources_discover, session_shutdown
before-agent-start.ts # tool filtering + prompt sanitization
input.ts # skill input gate
tool-call.ts # skill-read, external-directory, tool permission gates
index.ts # barrel re-export
Each handler file exports a named function matching the event:
// src/handlers/lifecycle.ts
export async function handleSessionStart(
deps: HandlerDeps,
event: SessionStartEvent,
ctx: ExtensionContext,
): Promise<void> { ... }
Wiring in src/index.ts
After extraction, piPermissionSystemExtension becomes:
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
// ~40 lines: create shared state, build deps object
const deps: HandlerDeps = { ... };
// ~10 lines: setup (config, command registration, forwarding logger)
refreshExtensionConfig();
registerPermissionSystemCommand(pi, { ... });
// ~20 lines: register handlers
pi.on("session_start", (event, ctx) => handleSessionStart(deps, event, ctx));
pi.on("resources_discover", (event, ctx) => handleResourcesDiscover(deps, event, ctx));
pi.on("session_shutdown", () => handleSessionShutdown(deps));
pi.on("before_agent_start", (event, ctx) => handleBeforeAgentStart(deps, event, ctx));
pi.on("input", (event, ctx) => handleInput(deps, event, ctx));
pi.on("tool_call", (event, ctx) => handleToolCall(deps, event, ctx));
}
Target: ≤200 lines for src/index.ts (currently ~1066).
Module-scope state
The issue explicitly defers restructuring module-scope state (PI_AGENT_DIR, extensionLogger, setExtensionConfig, etc.).
These remain in src/index.ts and are referenced by the deps object closures.
Issue #43 will lift them into ExtensionRuntime (see src/runtime.ts in the target module structure).
Module-Level Changes
New files
| File | Contents |
|---|---|
src/handlers/types.ts |
HandlerDeps interface, PromptPermissionDetails type |
src/handlers/lifecycle.ts |
handleSessionStart, handleResourcesDiscover, handleSessionShutdown |
src/handlers/before-agent-start.ts |
handleBeforeAgentStart |
src/handlers/input.ts |
handleInput |
src/handlers/tool-call.ts |
handleToolCall |
src/handlers/index.ts |
Barrel re-export |
Modified files
| File | Change |
|---|---|
src/index.ts |
Remove inline handler bodies; build HandlerDeps; register handlers via one-liner calls. Extract extractSkillNameFromInput, getEventToolName, getEventInput to a utility or into the handler that uses them. |
Test files
| File | Contents |
|---|---|
tests/handlers/lifecycle.test.ts |
Session start init, reload path, resources_discover reload, shutdown cleanup |
tests/handlers/before-agent-start.test.ts |
Tool filtering, prompt sanitization, cache key logic |
tests/handlers/input.test.ts |
Skill input gate: allow, deny, ask paths |
tests/handlers/tool-call.test.ts |
Skill-read gate, external-directory (file + bash), normal tool permission gate |
TDD Order
Each cycle is red → green → commit.
-
Define
HandlerDepsandPromptPermissionDetailstypes- Test:
tsccompiles with no errors (type-only, no runtime test). - Commit:
feat: define HandlerDeps interface for handler extraction (#42)
- Test:
-
Extract lifecycle handlers + tests
- Test surface:
tests/handlers/lifecycle.test.ts— session_start sets runtime context and refreshes config; resources_discover re-creates permission manager on reload; session_shutdown clears state and stops polling. - Commit:
feat: extract lifecycle handlers into src/handlers/lifecycle.ts (#42)
- Test surface:
-
Extract before_agent_start handler + tests
- Test surface:
tests/handlers/before-agent-start.test.ts— tool filtering respectsshouldExposeTool; prompt sanitization modifies system prompt; cache key prevents redundant work. - Commit:
feat: extract before_agent_start handler into src/handlers/before-agent-start.ts (#42)
- Test surface:
-
Extract input handler + tests
- Test surface:
tests/handlers/input.test.ts— non-skill input passes through; skill input deny/ask/allow gates viaapplyPermissionGate. - Commit:
feat: extract input handler into src/handlers/input.ts (#42)
- Test surface:
-
Extract tool_call handler + tests
- Test surface:
tests/handlers/tool-call.test.ts— missing/unregistered tool blocking; skill-read gate; external-directory gate (file tools); external-directory gate (bash); normal tool permission gate; session-approval cache integration. - Commit:
feat: extract tool_call handler into src/handlers/tool-call.ts (#42)
- Test surface:
-
Wire handlers in src/index.ts and verify integration
- Replace inline handler bodies with one-liner registrations.
- Run full test suite (
npx vitest run) to confirm no regressions. - Commit:
refactor: wire extracted handlers in src/index.ts (#42)
-
Move shared helper functions out of index.ts
- Move
extractSkillNameFromInput,getEventToolName,getEventInputto appropriate handler modules or a shared utility. - Run full test suite.
- Commit:
refactor: relocate handler helper functions from src/index.ts (#42)
- Move
-
Verify line count target and update docs
- Confirm
src/index.tsis ≤200 lines. - Commit:
docs: update plan status for handler extraction (#42)
- Confirm
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | No — this is a pure structural refactor. Handler logic moves verbatim. Tests verify identical gate behavior with mocked deps. The full existing test suite (npx vitest run) must pass at every commit. |
| Closure state semantics change when accessed via deps | The deps bag uses getter/setter pairs for mutable state, preserving the same "always read latest" semantics as closures. Tests verify state mutations propagate correctly. |
HandlerDeps interface becomes a god object |
Start minimal — only include what handlers actually consume. If any field is unused, remove it. The interface can be narrowed per-handler via Pick<HandlerDeps, ...> if it grows unwieldy. |
| Event type signatures are not exported by the Pi SDK | Use Parameters<...> inference or define minimal event shapes in src/handlers/types.ts. If the SDK changes, type errors surface at compile time. |
| Existing integration tests break during incremental extraction | Steps 2–5 keep old inline handlers working in parallel until step 6 swaps them out. This avoids a big-bang rewrite. |
Implementation Notes
extractSkillNameFromInputlanded insrc/handlers/input.ts(exported).getEventInputlanded insrc/handlers/tool-call.ts(exported).getEventToolNamewas eliminated entirely — handlers callgetToolNameFromValuefromtool-registry.tsdirectly.shouldExposeToolwas extracted as a pure exported function insrc/handlers/before-agent-start.tsrather than a dep entry, consistent with the target architecture principle.- Event parameter types: the SDK does not export
ResourcesDiscoverEvent; handler files use lean local payload interfaces (SessionStartPayload,ResourcesDiscoverPayload,InputPayload,BeforeAgentStartPayload) instead of full SDK event types, since handlers consume only a subset of fields. src/index.tsreduced from 1066 → 466 lines (56% reduction). The ≤200 line target requires #43 to eliminate module-scope state and extract the remaining factory helpers (refreshExtensionConfig,saveExtensionConfig,promptPermission,resolveAgentName,logResolvedConfigPaths, etc.) into anExtensionRuntimecontext object.
Open Questions
- Should
HandlerDepsbe split into per-handler narrower interfaces? Defer until the single interface proves unwieldy — YAGNI for now.