22 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 43 | Eliminate module-scope mutable state and cached getAgentDir() in src/index.ts |
Eliminate module-scope mutable state
Problem Statement
src/index.ts has 16 module-scope declarations (5 cached path constants, 4 mutable variables, 3 setter functions, and 4 helper functions that close over them) that violate the AGENTS.md rule against caching getAgentDir() at module scope.
Additionally, src/forwarded-permissions/io.ts has its own module-scope logger variable with a setForwardedPermissionLogger setter — a hidden temporal coupling that silently no-ops if the setter is never called.
These work in production but make the module untestable in isolation because tests set PI_CODING_AGENT_DIR after import, by which point the cached values are already frozen.
The setter-injection pattern (setExtensionConfig, setLoggingWarningReporter, setForwardedPermissionLogger) creates hidden call-order requirements: if the setup sequence changes, state silently breaks.
Goals
- Create an
ExtensionRuntimecontext object insrc/runtime.ts, constructed insidepiPermissionSystemExtension()at factory invocation time (callinggetAgentDir()then). - Move all module-scope mutable state and cached path constants from
src/index.tsintoExtensionRuntime. - Eliminate
setExtensionConfig,setLoggingWarningReporter, andsetForwardedPermissionLoggerby threading the runtime (or its logger) through to the functions that need it. - Simplify
HandlerDepsto referenceExtensionRuntimeinstead of duplicating getter/setter pairs. - Reduce
src/index.tstoward the ≤200-line target by moving factory helpers into the runtime module.
Non-Goals
- Changing permission resolution logic, merge precedence, or default policy.
- Unifying the Rule type or normalizing config into flat Ruleset (#56).
- Replacing
SessionApprovalCachewith session Ruleset (#57). - Changing the on-disk config format, schema, or example config.
- Extracting the
/permission-systemslash command registration to a separate module.
Background
Dependencies (all resolved)
- #41 (permission-gate extraction) — CLOSED.
applyPermissionGateexists insrc/permission-gate.ts. - #42 (handler extraction) — CLOSED.
Handlers live in
src/handlers/and receive aHandlerDepsobject. The #42 plan explicitly noted: "#43 will fold the getter/setter pairs and mutable fields into ExtensionRuntime."
Current module-scope state in src/index.ts
Lines 74–108 contain everything that must move:
// Cached getAgentDir() — AGENTS.md violation
const PI_AGENT_DIR = getAgentDir();
const SESSIONS_DIR = join(PI_AGENT_DIR, "sessions");
const SUBAGENT_SESSIONS_DIR = join(PI_AGENT_DIR, "subagent-sessions");
const PERMISSION_FORWARDING_DIR = join(SESSIONS_DIR, "permission-forwarding");
const GLOBAL_LOGS_DIR = getGlobalLogsDir(PI_AGENT_DIR);
// Mutable config state + setter
let extensionConfig: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
function setExtensionConfig(config) { ... }
// Logger created from cached paths + config getter
const extensionLogger = createPermissionSystemLogger({ ... });
// Warning dedup state + setter
const reportedLoggingWarnings = new Set<string>();
let loggingWarningReporter: ((message: string) => void) | null = null;
function setLoggingWarningReporter(reporter) { ... }
// Logging helpers that close over the above
function reportLoggingWarning(message) { ... }
function writeDebugLog(event, details) { ... }
function writeReviewLog(event, details) { ... }
Setter injection in src/forwarded-permissions/io.ts
Lines 28–33: module-scope logger variable with setForwardedPermissionLogger setter.
Called from src/index.ts line 259.
Used by logPermissionForwardingWarning and logPermissionForwardingError, which are in turn called by 8+ IO functions in the same file.
Current HandlerDeps (from src/handlers/types.ts)
95 lines of getter/setter pairs and helper closures. The #42 plan designed it as a stepping stone: "Handler function signatures use a single deps parameter — swapping the type is a one-line change per handler."
Permission surfaces involved
All surfaces (tools, bash, mcp, skills, special, external_directory) — this is a cross-cutting structural refactor, not a surface-specific change.
Design Overview
ExtensionRuntime interface
export interface ExtensionRuntime {
// ── Immutable paths (derived from getAgentDir() at construction) ─────
readonly agentDir: string;
readonly sessionsDir: string;
readonly subagentSessionsDir: string;
readonly forwardingDir: string;
readonly globalLogsDir: string;
// ── Mutable state ──────────────────────────────────────────────────────
config: PermissionSystemExtensionConfig;
runtimeContext: ExtensionContext | null;
permissionManager: PermissionManager;
activeSkillEntries: SkillPromptEntry[];
lastKnownActiveAgentName: string | null;
lastActiveToolsCacheKey: string | null;
lastPromptStateCacheKey: string | null;
lastConfigWarning: string | null;
readonly sessionApprovalCache: SessionApprovalCache;
// ── Forwarding polling state ───────────────────────────────────────────
permissionForwardingContext: ExtensionContext | null;
permissionForwardingTimer: NodeJS.Timeout | null;
isProcessingForwardedRequests: boolean;
// ── Logging (created at construction, closes over config) ──────────────
writeDebugLog(event: string, details?: Record<string, unknown>): void;
writeReviewLog(event: string, details?: Record<string, unknown>): void;
}
createExtensionRuntime() factory
export function createExtensionRuntime(
options?: { agentDir?: string },
): ExtensionRuntime { ... }
- Calls
getAgentDir()(or uses the override fromoptions) to derive all path constants. - Creates the logger via
createPermissionSystemLogger()with the derived paths. - Initializes mutable state to defaults.
- Tests call
createExtensionRuntime({ agentDir: tmpDir })— no module-scope caching, noPI_CODING_AGENT_DIRtiming issues.
HandlerDeps simplification
Replace getter/setter pairs with direct ExtensionRuntime access.
The interface shrinks from ~95 lines to ~40 by referencing the runtime:
export interface HandlerDeps {
readonly runtime: ExtensionRuntime;
// Factories
createPermissionManagerForCwd(cwd: string | undefined | null): PermissionManager;
// Config & lifecycle
refreshExtensionConfig(ctx?: ExtensionContext): void;
notifyWarning(message: string): void;
logResolvedConfigPaths(): void;
// Permission helpers
resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
promptPermission(ctx: ExtensionContext, details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
createPermissionRequestId(prefix: string): string;
// Forwarding
startForwardedPermissionPolling(ctx: ExtensionContext): void;
stopForwardedPermissionPolling(): void;
// Pi API subset
getAllTools(): unknown[];
setActiveTools(names: string[]): void;
}
Handlers access state via deps.runtime.config, deps.runtime.permissionManager, etc. instead of deps.getPermissionManager().
Forwarded-permission logger threading
Two approaches for eliminating setForwardedPermissionLogger:
- Add a
loggerfield toPermissionForwardingDeps(which already exists and is threaded through). - Make
logPermissionForwardingWarningandlogPermissionForwardingErroraccept a logger parameter, threaded through the ~8 IO functions that call them.
Option 1 is simpler — PermissionForwardingDeps already has writeReviewLog and is threaded to polling.ts.
The remaining gap is io.ts functions called within polling that call logPermissionForwardingWarning/Error.
These functions already receive context indirectly; adding an explicit logger parameter to each is mechanical but verbose.
The pragmatic approach: make logPermissionForwardingWarning and logPermissionForwardingError accept an optional logger parameter (falling back to null for backward compat during migration), then convert all internal call sites to pass the logger.
Once all callers pass it, remove the module-scope logger variable and setForwardedPermissionLogger, and make the parameter required.
Helper function relocation
Factory helpers currently defined inside piPermissionSystemExtension() in src/index.ts (~200 lines) move into src/runtime.ts as standalone functions that take ExtensionRuntime:
| Helper | Current location | New location |
|---|---|---|
refreshExtensionConfig |
index.ts closure | src/runtime.ts (takes runtime) |
saveExtensionConfig |
index.ts closure | src/runtime.ts (takes runtime) |
createPermissionManagerForCwd |
index.ts module scope | src/runtime.ts (takes agentDir) |
derivePiProjectPaths |
index.ts module scope | src/runtime.ts (pure, unchanged) |
writeDebugLog/writeReviewLog |
index.ts module scope | ExtensionRuntime methods |
reportLoggingWarning |
index.ts module scope | internal to runtime logger setup |
reviewPermissionDecision |
index.ts closure | src/runtime.ts (takes writeReviewLog) |
promptPermission |
index.ts closure | src/runtime.ts (takes runtime) |
resolveAgentName |
index.ts closure | src/runtime.ts (takes runtime) |
logResolvedConfigPaths |
index.ts closure | src/runtime.ts (takes runtime) |
startForwardedPermissionPolling |
index.ts closure | src/runtime.ts (takes runtime) |
stopForwardedPermissionPolling |
index.ts closure | src/runtime.ts (takes runtime) |
Target src/index.ts shape
After this refactor, src/index.ts becomes:
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
const runtime = createExtensionRuntime();
const deps = createHandlerDeps(runtime, pi);
refreshExtensionConfig(runtime);
registerPermissionSystemCommand(pi, { ... });
pi.on("session_start", (event, ctx) => handleSessionStart(deps, event, ctx));
pi.on("resources_discover", (event) => handleResourcesDiscover(deps, event));
// ... etc
}
Target: ≤150 lines.
Module-Level Changes
New files
| File | Contents |
|---|---|
src/runtime.ts |
ExtensionRuntime interface, createExtensionRuntime() factory, relocated helper functions |
Modified files
| File | Change |
|---|---|
src/index.ts |
Remove all module-scope state (lines 74–130), remove factory helper closures (~200 lines), replace with createExtensionRuntime() + createHandlerDeps(). Target ≤150 lines. |
src/handlers/types.ts |
Simplify HandlerDeps: replace getter/setter pairs with runtime: ExtensionRuntime field. Remove ~30 lines of accessor declarations. |
src/handlers/lifecycle.ts |
Update to access state via deps.runtime.* instead of deps.get*() / deps.set*(). |
src/handlers/before-agent-start.ts |
Same state-access updates. |
src/handlers/input.ts |
Same state-access updates. |
src/handlers/tool-call.ts |
Same state-access updates. |
src/forwarded-permissions/io.ts |
Remove module-scope logger and setForwardedPermissionLogger. Add logger parameter to logPermissionForwardingWarning and logPermissionForwardingError. Thread through internal callers. |
src/forwarded-permissions/polling.ts |
Pass logger from PermissionForwardingDeps to IO functions that need it. |
Test files
| File | Change |
|---|---|
tests/runtime.test.ts (new) |
Unit tests for createExtensionRuntime() and relocated helper functions. |
tests/handlers/lifecycle.test.ts |
Update mock deps to use runtime field instead of getter/setter stubs. |
tests/handlers/before-agent-start.test.ts |
Same mock deps updates. |
tests/handlers/input.test.ts |
Same mock deps updates. |
tests/handlers/tool-call.test.ts |
Same mock deps updates. |
tests/permission-system.test.ts |
May need updates if module-scope imports of removed functions change. Run full suite to verify. |
tests/forwarded-permissions/io.test.ts (new or update) |
Test that IO functions work with explicit logger parameter. |
No changes to
schemas/permissions.schema.json,config/config.example.json,README.md— this is an internal structural refactor with no config, schema, or user-facing impact.src/permission-manager.ts,src/permission-gate.ts,src/permission-dialog.ts— consumed but not modified.src/config-paths.ts,src/config-loader.ts— pure path/loading functions, unchanged.
TDD Order
Step 1: Define ExtensionRuntime interface and createExtensionRuntime()
- Test surface:
tests/runtime.test.ts— verifycreateExtensionRuntime()derives correct paths from a testagentDir; verify default mutable state; verifywriteDebugLog/writeReviewLogdelegate to the logger. - Commit:
feat: define ExtensionRuntime and createExtensionRuntime factory (#43)
Step 2: Extract helper functions into src/runtime.ts
- Test surface:
tests/runtime.test.ts— testcreatePermissionManagerForCwd,derivePiProjectPaths,refreshExtensionConfig,resolveAgentNameas standalone functions that take runtime. Verify they read/writeruntime.*fields correctly. - Commit:
feat: relocate factory helpers into src/runtime.ts (#43)
Step 3: Update handler tests to use runtime field in mock deps
- Test surface:
tests/handlers/*.test.ts— update all mockHandlerDepsconstruction to use{ runtime: mockRuntime, ... }instead of getter/setter stubs. All existing handler tests must still pass. - Commit:
test: update handler test mocks for ExtensionRuntime deps (#43)
Step 4: Simplify HandlerDeps and update handler implementations
- Test surface: all handler tests +
npx vitest runfull suite. - Change
HandlerDepsinsrc/handlers/types.tsto useruntime: ExtensionRuntime. - Update all handler files to access
deps.runtime.*instead ofdeps.get*(). - Commit:
feat: simplify HandlerDeps to use ExtensionRuntime (#43)
Step 5: Thread logger through forwarded-permissions IO
- Test surface:
tests/forwarded-permissions/io.test.ts(new or existing) — verifylogPermissionForwardingWarningandlogPermissionForwardingErrorcall the provided logger; verify IO functions that call them propagate the logger. - Remove
setForwardedPermissionLoggerand module-scopelogger. - Update
src/forwarded-permissions/polling.tsto pass the logger. - Commit:
feat: thread logger through forwarded-permissions IO (#43)
Step 6: Wire ExtensionRuntime in src/index.ts and remove module-scope state
- Remove all module-scope mutable state (lines 74–130).
- Remove
setExtensionConfig,setLoggingWarningReporter, module-scope helper functions. - Replace with
createExtensionRuntime()call inside factory. - Build deps from runtime.
- Run full test suite.
- Commit:
feat: eliminate module-scope state in src/index.ts (#43)
Step 7: Verify line count and clean build
- Confirm
src/index.tsis ≤150 lines. - Run
pnpm run buildfor type checking. - Run
npx vitest runfor full suite. - Commit:
docs: update plan notes for module-scope state elimination (#43)
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | No — this is a pure structural refactor. Permission decisions are unchanged. The same PermissionManager, applyPermissionGate, and handler logic run with identical inputs. No new "allow" path is introduced. |
| Handler tests break due to mock shape change | Step 3 updates all handler test mocks before step 4 changes the production HandlerDeps type. This ensures tests are green on both sides of the transition. |
forwarded-permissions/io.ts functions silently lose logging |
Step 5 adds the logger parameter and removes the setter in one atomic step. Any function that previously called logger?.writeReviewLog(...) now receives the logger explicitly. The ?. optional chaining is preserved for the case where no logger is configured (e.g., direct IO function usage in tests). |
Integration tests in permission-system.test.ts import module-scope functions that get removed |
permission-system.test.ts imports piPermissionSystemExtension (the factory), not the module-scope helpers directly. The only risk is if test setup depends on module-scope state being initialized at import time — but the test already sets PI_CODING_AGENT_DIR before calling the factory, so the fix aligns with the test's intent. |
createExtensionRuntime called multiple times in concurrent test files |
Each call creates an independent runtime with its own state. No shared mutable state between instances — this is the whole point. |
| Large changeset across many files | Steps are ordered so each commit is independently valid and testable. The riskiest step (6) is preceded by comprehensive mock updates (3) and type changes (4) that surface any mismatch at compile time. |
Implementation Notes
createHandlerDepswas kept inline insrc/index.ts(≤20 lines as predicted).PermissionForwardingDepsgained alogger: ForwardedPermissionLoggerfield (step 5 option 1 from the Open Questions). All io.ts functions that log now takelogger: ForwardedPermissionLogger | nullas the first parameter. The module-scopeloggervariable andsetForwardedPermissionLoggerwere removed entirely.src/index.tsreduced from 466 → 99 lines (79% reduction). The ≤150-line target was comfortably met.runtime.writeReviewLog/runtime.writeDebugLogare plain arrow functions on the runtime object (not class methods), so.bind(runtime)is technically a no-op but was added for clarity when passing them as callbacks.- The
getContextSystemPrompthelper inpolling.tscallslogPermissionForwardingWarning(null, ...)because it has no access todeps— the warning is silently dropped in that one case, which is acceptable (it's a best-effort metadata read).
Open Questions
- None remaining.