23 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 338 | Collapse the index.ts closure bags into object references |
Collapse the index.ts closure bags into object references
Problem Statement
index.ts is the composition root, and it carries roughly twenty () => / .bind adapter closures.
These are not intrinsic wiring — they are scar tissue from the now-dissolved runtime god object.
() => runtime.config thunks existed because config was mutable shared state; runtime.writeReviewLog.bind(runtime) adapters existed because the logging operations were free functions; (ctx) => refreshExtensionConfig(runtime, ctx) wrapped each runtime free-function.
With Phase 4 Steps 2–4 done (ConfigStore owns config, the logger is an injectable object, the runtime ops are methods, and PermissionManager / SessionRules are single shared instances), each consumer can now receive the real collaborator object and call its methods directly.
The adapter closures collapse to plain object references.
This is Phase 4 Step 5 (Track B). It is behavior-preserving: only the shape of the dependency wiring changes, not what the extension does.
Goals
- Replace the logging/config adapter closures in
index.tswith direct references to the sharedlogger,ConfigStore,PermissionManager,SessionRules, andPermissionSessioncollaborators. - Shrink the deps interfaces on
PermissionPrompter,PermissionSession, the command controller, the RPC handlers,ConfigStore, andPermissionForwarderso each accepts the collaborator object (or a narrow interface over it) instead of a bag of adapter functions. - Unify all logging on the single
SessionLoggerobject via narrowReviewLogger/DebugReviewLoggerseam interfaces, eliminating the duplicatedwriteReviewLogfield on the forwarder. - Keep the suite green and verify the composition root via
test/composition-root.test.ts.
This is not a breaking change to any published API: every interface touched is internal to the package.
Non-Goals
- Extracting the
PromptingGatewaythat owns the stored context,canConfirm(), and the prompt twins — that is Phase 4 Step 6 (#339). ThecanRequestPermissionConfirmationclosure onPermissionSessionRuntimeDepslegitimately remains until then. - Extracting the
PermissionResolvercollaborator (#340) or slimmingPermissionSessionto a state/lifecycle owner (#341). - Removing the two forward-reference cycle closures in the logger construction (
getConfig,notify) — see Design Overview; per project direction these stay as idiomatic forward-reference closures (the pi-subagents pattern), not setter-injected mutations. - Any change to log file format, config schema, or permission semantics.
Background
Relevant modules and their current adapter-closure relationships in src/index.ts:
createSessionLogger({ globalLogsDir, getConfig, notify })— the logger reads config toggles viagetConfig: () => configStore.current()and surfaces UI warnings vianotify: (m) => sessionNotify?.getRuntimeContext()?.ui.notify(...). Both are forward-reference closures: the logger is built beforeconfigStoreand beforesession.ConfigStore— itsConfigStoreLoggerdep ({ writeDebugLog, writeReviewLog }) is fed two arrow adapters wrappinglogger.debug/logger.review.PermissionForwarder— its deps carrylogger: ForwardedPermissionLogger(two adapters), a duplicated top-levelwriteReviewLogadapter, andshouldAutoApprove: () => shouldAutoApprovePermissionState("ask", configStore.current()).PermissionPrompter—writeReviewLog: (e, d) => logger.review(e, d).PermissionSessionruntime deps —promptPermission: (ctx, d) => prompter.prompt(ctx, d)andcanRequestPermissionConfirmation: (ctx) => canResolveAskPermissionRequest({...}).- The
/permission-systemcommand controller —getConfigPath: () => getGlobalConfigPath(agentDir)andgetComposedRules: () => permissionManager.getComposedConfigRules(session.lastKnownActiveAgentName ?? undefined). - The RPC handlers —
getPermissionManager,getSessionRules,getRuntimeContext, andwriteReviewLogclosures. toolRegistry—getAll: () => pi.getAllTools(),setActive: (n) => pi.setActiveTools(n).- Six
pi.on(...)event handler arrows.
Why the closures are now collapsible:
- The config-read thunks returned
configStore.current(); consumers can hold theConfigStoreobject (aConfigReader) and call.current()themselves. - The logging adapters wrapped
logger.review/logger.debugto bridge method-name mismatches and to avoid@typescript-eslint/unbound-method. Passing theloggerobject and callingthis.deps.logger.review(...)as a method avoids the unbound-method rule (it is a method call on a stored object, not a bare value) and removes the adapter. - The RPC
getPermissionManager/getSessionRules/getRuntimeContextclosures returned single shared instances (after #334 / #337); passing the objects and calling their methods at handle time preserves the same liveness.
Constraint from AGENTS.md and the package skill: keep schema/example/docs aligned (not touched here — no config change); the session-created handler must stay synchronous (not touched); use #src/ path aliases for sibling imports.
Design Overview
The two logger cycles stay as forward-reference closures
The logger participates in two genuine construction cycles:
- Logger ↔
ConfigStore: the logger reads the debug/review toggles from config;ConfigStorewrites the debug log. - Logger ↔
PermissionSession: the logger surfaces UI warnings through the session's runtime context; the session is constructed with the logger as a constructor argument.
Per project direction (matching the pi-subagents composition root), these are resolved with forward-reference closures that capture the not-yet-assigned variable by reference — not with setter methods on the logger and not by restructuring the toggle-read into a push model.
So getConfig and notify remain as the only two closures in the logger construction, and the logger object itself is built before configStore and session.
Crucially, because the logger object is fully built first, every other consumer (ConfigStore, PermissionForwarder, PermissionPrompter, the RPC handlers, PermissionSession) receives the logger object directly — their logging adapters all collapse.
Unify logging on narrow seam interfaces
Add two narrow interfaces to session-logger.ts so consumers depend only on what they use (ISP), and SessionLogger satisfies all of them:
export interface ReviewLogger {
review(event: string, details?: Record<string, unknown>): void;
}
export interface DebugReviewLogger extends ReviewLogger {
debug(event: string, details?: Record<string, unknown>): void;
}
export interface SessionLogger extends DebugReviewLogger {
warn(message: string): void;
}
Consumers depend on the narrowest slice:
PermissionPrompter, RPC handlers →ReviewLogger.ConfigStore,PermissionForwarder(andforwarded-permissions/io.ts) →DebugReviewLogger.
This lets index.ts pass the one logger object everywhere with zero adapters.
ConfigStoreLogger is deleted; ForwardedPermissionLogger is replaced by DebugReviewLogger (its writeReviewLog / writeDebugLog methods rename to review / debug).
Resulting deps shapes
// config-store.ts
interface ConfigStoreDeps {
agentDir: string;
policyPaths: ResolvedPolicyPathProvider;
logger: DebugReviewLogger; // was ConfigStoreLogger
}
// permission-prompter.ts
interface PermissionPrompterDeps {
config: ConfigReader;
logger: ReviewLogger; // was writeReviewLog(event, details)
events: PermissionEventBus;
forwarder: ApprovalRequester;
}
// forwarded-permissions/permission-forwarder.ts
interface PermissionForwarderDeps {
forwardingDir: string;
subagentSessionsDir: string;
registry?: SubagentSessionRegistry;
events?: PermissionEventBus;
logger: DebugReviewLogger; // merges old logger + duplicated writeReviewLog
requestPermissionDecisionFromUi: /* unchanged bare function */;
config: ConfigReader; // was shouldAutoApprove: () => boolean
}
// permission-event-rpc.ts
interface PermissionRpcDeps {
permissionManager: Pick<PermissionManager, "checkPermission">; // was getPermissionManager()
sessionRules: Pick<SessionRules, "getRuleset">; // was getSessionRules()
session: { getRuntimeContext(): ExtensionContext | null }; // was getRuntimeContext()
requestPermissionDecisionFromUi: /* unchanged */;
logger: ReviewLogger; // was writeReviewLog
}
// config-modal.ts
interface PermissionSystemConfigController {
config: CommandConfigStore;
configPath: string; // was getConfigPath(): string
permissionManager: { getComposedConfigRules(agentName?: string): Ruleset };
session: { readonly lastKnownActiveAgentName: string | null };
}
// permission-session.ts
interface PermissionSessionRuntimeDeps {
canRequestPermissionConfirmation(ctx: ExtensionContext): boolean; // kept (Step 6 / #339)
prompter: PermissionPrompterApi; // was promptPermission(ctx, details)
}
requestPermissionDecisionFromUi is already passed as a bare imported function reference (not a closure) in both the forwarder and RPC deps; it stays.
Consumer call-site sketches (verify Tell-Don't-Ask / LoD)
The command controller computes the composed rules from the two injected references rather than receiving a pre-bound thunk:
// config-modal.ts handleArgs, "show" branch
const rules = controller.permissionManager.getComposedConfigRules(
controller.session.lastKnownActiveAgentName ?? undefined,
);
The RPC check handler asks each injected collaborator directly:
const sessionRules = deps.sessionRules.getRuleset();
const result = deps.permissionManager.checkPermission(surface, input, agentName ?? undefined, sessionRules);
// ...
deps.logger.review("permission_request.rpc_prompt", { ... });
PermissionForwarder reads config and logs through its own injected objects (the duplicated review field is gone):
if (shouldAutoApprovePermissionState("ask", this.config.current())) {
this.logger.review("forwarded_permission.auto_approved", details);
// ...
}
These are all single-level method calls on injected collaborators (ask the object for what you need), not reach-through chains.
index.ts closure budget after this step
| Closure / adapter | Count | Disposition |
|---|---|---|
pi.on(...) handlers |
6 | Legitimate event wiring (permanent) |
toolRegistry getAll / setActive |
2 | Legitimate SDK adapter (permanent) |
logger getConfig / notify |
2 | Forward-reference cycle closures (permanent; pi-subagents pattern) |
session canRequestPermissionConfirmation |
1 | Transitional — removed by Step 6 (#339) |
So index.ts drops from ~20 to 11 (10 after Step 6).
The roadmap's "≤ 8" target assumed the two logger cycle closures would also collapse; with the no-setter / forward-reference-closure direction they remain as the idiomatic floor.
The architecture metric and Step 5 outcome note are updated to record this (20 → 11) rather than leaving the optimistic ≤ 8.
Design-review checklist
| Smell | Location | Finding | Resolution |
|---|---|---|---|
| Dependency width | every consumer deps bag | Adapter-function fields replaced by collaborator objects; forwarder loses writeReviewLog + shouldAutoApprove |
Narrower, object-based deps |
| LoD violation | logger notify chain session.getRuntimeContext().ui.notify |
Reach-through remains inside the kept cycle closure | Out of scope; addressed when prompting/context ownership moves (Steps 6/8) |
| Output arguments | none | No writes back into received deps | — |
| Parameter relay | adapter closures relaying to one method | Removed by passing the object | Fixed here |
| Test mock depth | consumer tests | Bare-function mocks become { review: vi.fn() } object mocks; no new casts |
Improved |
| Missing abstraction | scattered logging adapters | The shared SessionLogger object + ReviewLogger / DebugReviewLogger seams |
Introduced here |
Module-Level Changes
src/session-logger.ts— addReviewLoggerandDebugReviewLogger; makeSessionLogger extends DebugReviewLogger.src/config-store.ts—ConfigStoreDeps.logger: DebugReviewLogger; delete theConfigStoreLoggerinterface; change internalthis.deps.logger.writeDebugLog/writeReviewLogcalls to.debug/.review.src/permission-prompter.ts— replacewriteReviewLogdep withlogger: ReviewLogger;writeReviewEntrycallsthis.deps.logger.review(...).src/permission-event-rpc.ts— reshapePermissionRpcDepsto{ permissionManager, sessionRules, session, requestPermissionDecisionFromUi, logger }; updatehandleCheckRpc/handlePromptRpcbodies (deps.getPermissionManager()→deps.permissionManager,deps.getSessionRules()→deps.sessionRules.getRuleset(),deps.getRuntimeContext()→deps.session.getRuntimeContext(),deps.writeReviewLog→deps.logger.review).src/forwarded-permissions/io.ts— renameForwardedPermissionLoggerto usereview/debug(alias toDebugReviewLoggerimported from#src/session-logger); update the 4 internallogger?.writeReviewLog/writeDebugLogcalls.src/forwarded-permissions/permission-forwarder.ts— mergelogger+ the duplicatedwriteReviewLoginto onelogger: DebugReviewLogger; replaceshouldAutoApprovewithconfig: ConfigReader; update internals (this.writeReviewLog(...)→this.logger.review(...),this.shouldAutoApprove()→shouldAutoApprovePermissionState("ask", this.config.current())); importshouldAutoApprovePermissionStateandConfigReader.src/config-modal.ts—PermissionSystemConfigController: replacegetConfigPath(): stringwithconfigPath: string; replace optionalgetComposedRules?()withpermissionManager+sessionnarrow refs;handleArgscomputes the composition inline.src/permission-session.ts—PermissionSessionRuntimeDeps: replacepromptPermissionwithprompter: PermissionPrompterApi;prompt()callsthis.runtimeDeps.prompter.prompt(ctx, details).src/index.ts— collapse the logging/config/rule adapter closures into directlogger/configStore/permissionManager/sessionRules/session/prompterreferences; precomputeconfigPath: getGlobalConfigPath(agentDir); keep the two logger cycle closures,canRequestPermissionConfirmation,toolRegistry, and the sixpi.onhandlers.docs/architecture/architecture.md— update theindex.tsclosures +.bindadapters metric (20 → 11 with the budget breakdown) and the Step 5 outcome note; the✓ completemark is added during shipping per the package skill.- Tests updated alongside their consumers (see TDD Order):
test/config-store.test.ts,test/permission-prompter.test.ts,test/permission-event-rpc.test.ts,test/forwarded-permissions/io.test.ts,test/permission-forwarder.test.ts,test/config-modal.test.ts,test/permission-session.test.ts,test/composition-root.test.ts.
ConfigStoreLogger and ForwardedPermissionLogger are referenced only in historical docs/plans/ and docs/retro/ files (and not in .pi/skills/); historical docs are left untouched.
Test Impact Analysis
- New tests enabled — minimal; this is interface reshaping, not new extraction.
Consumer unit tests now build simple object mocks (
{ review: vi.fn() },{ debug: vi.fn(), review: vi.fn() },{ getRuleset: vi.fn() }) instead of bare-function mocks, which is closer to how production wires them and removes a small amount of indirection. - Redundant tests — none become redundant; no test is deleted.
Existing assertions on
writeReviewLog(...)calls migrate tologger.review(...)on the object mock. - Tests that must stay — all behavioral tests for the forwarder, prompter, RPC, config-store, config-modal, and session continue to exercise the same behavior through the reshaped deps.
test/composition-root.test.ts(handler-registration completeness, single-source-of-truth, teardown, subagent registry sharing) is the primary behavior-preserving guard and stays green throughout; extend it to assert the command and RPC paths operate against the injected objects.
TDD Order
Each cycle reshapes one consumer's deps interface and folds its test updates and the matching index.ts wiring change into the same commit, because an interface-shape change breaks the consumer's construction at the type level immediately (AGENTS.md rule).
refactor:add narrow logger seams and migrateConfigStore. Surface:test/config-store.test.ts. AddReviewLogger/DebugReviewLoggertosession-logger.ts; changeConfigStoreDeps.loggertoDebugReviewLogger; deleteConfigStoreLogger; update internal calls; update the 7 test mock references ({ writeDebugLog, writeReviewLog }→{ debug, review }); passloggertoConfigStoreinindex.ts(removes 2 closures). Commit:refactor: pass the session logger directly to ConfigStore.refactor:migratePermissionPrompter. Surface:test/permission-prompter.test.ts. ReplacewriteReviewLogdep withlogger: ReviewLogger; update the ~28 test references and assertions; passloggerinindex.ts(removes 1 closure). Commit:refactor: inject the session logger into PermissionPrompter.refactor:migrate the RPC handlers. Surface:test/permission-event-rpc.test.ts,test/composition-root.test.ts. ReshapePermissionRpcDepsto object references; update handler bodies and test mocks; passpermissionManager/sessionRules/session/loggerinindex.ts(removes 4 closures); add a composition-root assertion that an RPC check resolves against the injected manager + rules. Commit:refactor: inject collaborators into the permission RPC handlers.refactor:migratePermissionForwarderand the forwarding IO logger. Surface:test/forwarded-permissions/io.test.ts,test/permission-forwarder.test.ts. RenameForwardedPermissionLogger→DebugReviewLogger(4io.tscalls + 12 io-test refs + 2 forwarder-test refs); mergelogger+ duplicatedwriteReviewLoginto onelogger; replaceshouldAutoApprovewithconfig: ConfigReader; update forwarder internals; passlogger+configStoreinindex.ts(removes 4 closures). Commit:refactor: inject the session logger and config reader into PermissionForwarder.refactor:migrate the/permission-systemcommand controller. Surface:test/config-modal.test.ts. ReplacegetConfigPath()withconfigPath: stringandgetComposedRules?()withpermissionManager+sessionnarrow refs; compute the composition inline inhandleArgs; update the test controller mocks; passconfigPath+ objects inindex.ts(removes 2 closures). Commit:refactor: pass config path and rule sources to the command controller.refactor:migratePermissionSessionprompting dep. Surface:test/permission-session.test.ts,test/composition-root.test.ts. ReplacepromptPermissionwithprompter: PermissionPrompterApi;prompt()delegates tothis.runtimeDeps.prompter.prompt; update the session test mock; pass theprompterobject inindex.ts(removes 1 closure); leavecanRequestPermissionConfirmationfor Step 6. Commit:refactor: inject the prompter into PermissionSession as an object reference.docs:update the architecture roadmap. Update theindex.tsclosures metric (20 → 11 with the budget breakdown) and the Step 5 outcome note explaining the retained cycle closures. Commit:docs: update architecture metrics for collapsed index.ts wiring (#338).
Risks and Mitigations
- Import cycle from the forwarder importing
yolo-modeandConfigReader. Mitigation: verifiedyolo-mode.tsimports onlyextension-configandtypes, andconfig-store.tsdoes not import the forwarder — no cycle. Re-runpnpm run checkafter cycle 4. - The
ForwardedPermissionLoggerrename ripples throughio.tsand two test files. Mitigation: only 4 internalio.tscall sites; the rename is mechanical and confined to cycle 4 with its tests in the same commit. @typescript-eslint/unbound-methodre-triggering. Mitigation: consumers callthis.deps.logger.review(...)as a method on a stored object (not a bare reference), which the rule allows; no arrow wrappers are reintroduced.- Behavior drift in the RPC / command paths (they now call methods on objects instead of pre-bound thunks).
Mitigation: the objects are the same single shared instances the thunks returned (post-#334 / #337);
composition-root.test.tsasserts the end-to-end paths.
Open Questions
- Whether to add
globalConfigPathtoExtensionPathsrather than recomputinggetGlobalConfigPath(agentDir)for the command'sconfigPath. Deferred: recomputing once at construction is a value reference, not a closure, and adding it toExtensionPathsis out of scope for this step.