23 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 320 | Reframe the index.ts composition root as collaborator injection |
Reframe the index.ts composition root as collaborator injection
Problem Statement
piPermissionSystemExtension (src/index.ts, now 206 lines) is the package's #1 churn hotspot.
The issue frames the remaining factory work as "constructing collaborators and injecting them, not hand-rolling closures," once Tracks B and C have landed.
Those prerequisites — the PermissionForwarder (#315/#316/#317), PermissionResolver (#319), DecisionReporter (#322), GateRunner (#323), and the gate pipelines (#327/#329) — are all present in main, so the factory already injects those collaborators.
What is left in the factory is two genuinely anemic constructs that still hold behavior as inline literals/closures:
- The
permissionsServiceobject literal (~18 lines) — three methods reaching intoruntimeandformatterRegistry, the in-process implementation of the cross-extensionPermissionsServiceinterface. - The service-publication lifecycle — an
activateServiceForSessionclosure (the #302-critical "publish only when not a registered subagent child, then emit ready" gate) plus the teardown closure passed toSessionLifecycleHandler(unsubscribe RPC + subagent-lifecycle, then unpublish).
Both are behavior with no named home: testable only through the heavy make-fake-pi.ts composition-root harness.
The rest of the factory builds the package's established construction-time injection bags (PermissionSessionRuntimeDeps, PermissionPrompterDeps, PermissionForwarderDeps, command deps, RPC deps) — legitimate composition-root wiring, not closure-bag smell.
Goals
- Promote the inline
permissionsServiceliteral to a namedLocalPermissionsServiceclass with a unit-testable home. - Promote the two service-lifecycle closures to a named
PermissionServiceLifecyclecollaborator (implementing a narrowServiceLifecycleinterface) that owns the #302 child-gated publish, ready emission, and session-scoped teardown ordering. - Inject the
ServiceLifecyclecollaborator intoSessionLifecycleHandler, replacing its twoactivateService/cleanupRpccallback parameters with one narrow collaborator. - Keep the change behavior-preserving: handler registration, the
session_start-gated service publish, and the synchronous lifecycle subscription must behave identically (verified bytest/composition-root.test.ts). - Cool the
index.tshotspot by giving wiring a collaborator to touch instead of an inline literal/closure.
Non-Goals
- Hitting the roadmap's "< 100 lines" target.
The scope chosen here is collaborators-only (an
ask_userdecision): the two genuine extractions remove ~35–40 lines, landingindex.tsnear ~165–170 lines. Forcing it under 100 would require relocating the established injection-bag construction intobuildX()helpers — pure statement relocation with no new collaborator, which AGENTS.md flags as procedure-splitting, not design improvement. - Eliminating the
() => runtime.config/runtime.x.bind(runtime)relay closures by retypingPermissionPrompter/PermissionSession/ command / RPC consumers onto narrowExtensionRuntimerole interfaces (the deeper, multi-consumer Track-C-style option, explicitly declined). - Changing any permission decision, log entry, ready event, or service-publication semantics.
- Touching the forwarder, prompter, session, command, or RPC deps bags — they stay constructed inline.
Background
Current src/index.ts construction sites:
runtime = createExtensionRuntime()—ExtensionRuntime(data: paths + mutableSessionState+config+ log methods).runtime.permissionManageris typed mutable onSessionStatebut is never reassigned on the runtime object (verified: the only.permissionManager =writes arethis.permissionManagerinsidePermissionSession, a different object);runtime.sessionRulesisreadonly.forwardingDeps: PermissionForwarderDeps→forwarder = new PermissionForwarder(...).prompter = new PermissionPrompter({ getConfig, writeReviewLog, events, forwarder }).session = new PermissionSession(runtime, logger, ForwardingManager, runtimeDeps).registerPermissionSystemCommand(pi, { … }).rpcHandles = registerPermissionRpcHandlers(pi.events, { … })→ returns{ unsubCheck, unsubPrompt }(plain closures fromevents.on).permissionsService: PermissionsService = { checkPermission, getToolPermission, registerToolInputFormatter }— the literal to promote.activateServiceForSession,unsubSubagentLifecycle = subscribeSubagentLifecycle(pi.events, subagentRegistry), and the teardown closure innew SessionLifecycleHandler(session, activateServiceForSession, teardown)— the lifecycle to promote.toolRegistryadapter and thepi.on(...)arrows — genuine SDK-boundary glue, kept as-is.
Relevant modules:
src/service.ts— defines thePermissionsServiceinterface (cross-extension,Symbol.for()-backed accessor) pluspublishPermissionsService/unpublishPermissionsService/getPermissionsService. Stays the pure accessor + interface module.src/permission-events.ts—emitReadyEvent(events: PermissionEventBus);PermissionEventBusinterface.src/subagent-context.ts—isRegisteredSubagentChild(ctx, registry).src/subagent-lifecycle-events.ts—subscribeSubagentLifecycle(events, registry): () => void.src/input-normalizer.ts—buildInputForSurface(surface, value)(pure).src/handlers/lifecycle.ts—SessionLifecycleHandler(session, activateService, cleanupRpc); callsactivateService(ctx)inhandleSessionStartandcleanupRpc()inhandleSessionShutdown.src/permission-manager.ts(PermissionManager),src/session-rules.ts(SessionRules),src/tool-input-formatter-registry.ts(ToolInputFormatterRegistry) — the service's collaborators.
Constraints from AGENTS.md / code-design:
- An extraction that only relocates statements without a new collaborator or moving behavior onto data is procedure-splitting — both extractions here introduce a named, testable collaborator, clearing that bar.
- When a shared interface references a collaborator, use a narrow interface type, not the concrete class (
SessionLifecycleHandlerdepends onServiceLifecycle, notPermissionServiceLifecycle). - Lifecycle/wiring collaborators at the SDK boundary may import SDK types (
ExtensionContext), like the existing handlers. - Composition-root tests must
vi.stubEnv("PI_CODING_AGENT_DIR", …)and clear bothSymbol.for()slots inafterEach.
Design-review checklist (run before finalizing)
| Check | Finding |
|---|---|
| Dependency width | LocalPermissionsService — 3 deps, all used. PermissionServiceLifecycle — 4 deps, all used (subscriptions is a () => void[] data array, not a bag). SessionLifecycleHandler narrows from two callbacks to one 2-method interface. |
| Law of Demeter | The service talks only to its own injected fields (this.permissionManager, this.sessionRules, this.formatterRegistry) — no reach-through. The composition root grabbing runtime.permissionManager to inject is normal wiring. |
| Output arguments | None. |
| Scattered resets | None. |
| Parameter relay | SessionLifecycleHandler's two relayed callbacks collapse to one collaborator — relay reduced. |
| Test mock depth | New collaborators unit-tested with plain mocks; lifecycle.test.ts mock simplifies to { activate, teardown }. No as unknown as. |
| Missing abstractions | The two anemic literals/closures become named classes — this is the fix. |
Verdict: both extractions are genuine (named, testable homes; narrower handler coupling) and inline to this change.
Design Overview
LocalPermissionsService
The in-process implementation of PermissionsService, injected with the three collaborators it delegates to.
runtime.permissionManager and runtime.sessionRules are injected as instances (verified stable: the manager is never reassigned on the runtime, sessionRules is readonly), so the class talks only to its own fields — no reach-through:
// src/permissions-service.ts (new)
import { buildInputForSurface } from "./input-normalizer";
import type { PermissionManager } from "./permission-manager";
import type { PermissionsService } from "./service";
import type { SessionRules } from "./session-rules";
import type {
ToolInputFormatter,
ToolInputFormatterRegistry,
} from "./tool-input-formatter-registry";
/** In-process implementation of the cross-extension PermissionsService. */
export class LocalPermissionsService implements PermissionsService {
constructor(
private readonly permissionManager: PermissionManager,
private readonly sessionRules: SessionRules,
private readonly formatterRegistry: ToolInputFormatterRegistry,
) {}
checkPermission(surface: string, value?: string, agentName?: string) {
const input = buildInputForSurface(surface, value);
return this.permissionManager.checkPermission(
surface,
input,
agentName,
this.sessionRules.getRuleset(),
);
}
getToolPermission(toolName: string, agentName?: string) {
return this.permissionManager.getToolPermission(toolName, agentName);
}
registerToolInputFormatter(toolName: string, formatter: ToolInputFormatter) {
return this.formatterRegistry.register(toolName, formatter);
}
}
index.ts constructs it with new LocalPermissionsService(runtime.permissionManager, runtime.sessionRules, formatterRegistry) — byte-identical to the literal's runtime.permissionManager / runtime.sessionRules.getRuleset() reads (the literal already used the runtime's manager, not the session's).
ServiceLifecycle / PermissionServiceLifecycle
A narrow interface for SessionLifecycleHandler to depend on, plus the implementation owning the #302-critical publish gate and teardown ordering.
The session-scoped subscription unsubs (rpcHandles.unsubCheck, rpcHandles.unsubPrompt, unsubSubagentLifecycle) flow in as a () => void[] data array, preserving the current shutdown order (subscriptions first, then unpublish):
// src/service-lifecycle.ts (new)
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
import {
publishPermissionsService,
unpublishPermissionsService,
type PermissionsService,
} from "./service";
import { isRegisteredSubagentChild } from "./subagent-context";
import type { SubagentSessionRegistry } from "./subagent-registry";
/** The session-scoped service lifecycle the lifecycle handler drives. */
export interface ServiceLifecycle {
activate(ctx: ExtensionContext): void;
teardown(): void;
}
export class PermissionServiceLifecycle implements ServiceLifecycle {
constructor(
private readonly service: PermissionsService,
private readonly registry: SubagentSessionRegistry,
private readonly events: PermissionEventBus,
private readonly subscriptions: readonly (() => void)[],
) {}
activate(ctx: ExtensionContext): void {
// Publish only for a non-child session so a registered subagent child
// never clobbers the parent's process-global service. See #302.
if (!isRegisteredSubagentChild(ctx, this.registry)) {
publishPermissionsService(this.service);
}
emitReadyEvent(this.events);
}
teardown(): void {
for (const unsubscribe of this.subscriptions) {
unsubscribe();
}
unpublishPermissionsService(this.service);
}
}
SessionLifecycleHandler retyping
The handler's two callback params collapse to one collaborator:
constructor(
private readonly session: SessionLifecycleSession,
private readonly serviceLifecycle: ServiceLifecycle,
) {}
// handleSessionStart: this.serviceLifecycle.activate(ctx);
// handleSessionShutdown: this.serviceLifecycle.teardown();
Composition root after the change
const rpcHandles = registerPermissionRpcHandlers(pi.events, { … });
const permissionsService = new LocalPermissionsService(
runtime.permissionManager,
runtime.sessionRules,
formatterRegistry,
);
const unsubSubagentLifecycle = subscribeSubagentLifecycle(pi.events, subagentRegistry);
const serviceLifecycle = new PermissionServiceLifecycle(
permissionsService,
subagentRegistry,
pi.events,
[rpcHandles.unsubCheck, rpcHandles.unsubPrompt, unsubSubagentLifecycle],
);
// …
const lifecycle = new SessionLifecycleHandler(session, serviceLifecycle);
Edge cases
runtime.permissionManagerstability — injecting the instance (vs. late-binding throughruntime) is behavior-preserving only because it is never reassigned; a clarifying comment records the invariant.emitReadyEventordering —activatepublishes then emits, matching the old closure; the "ready emitted after service publication" test guards it.- Teardown order —
subscriptionsiterates[unsubCheck, unsubPrompt, unsubSubagentLifecycle]beforeunpublishPermissionsService, identical to the old teardown closure; the "shutdown teardown chain" test guards it. - Child gating —
activateskips publish for a registered child but still emits ready, identical to the oldactivateServiceForSession; the "multi-instance global service interplay" (#302) test guards it. - Passing
rpcHandles.unsubCheck/unsubPromptby reference is safe — they are plain closures returned fromregisterPermissionRpcHandlers, not class methods, so@typescript-eslint/unbound-methoddoes not fire (if it does, wrap as() => rpcHandles.unsubCheck()).
Module-Level Changes
src/permissions-service.ts— new:LocalPermissionsServiceclass.src/service-lifecycle.ts— new:ServiceLifecycleinterface +PermissionServiceLifecycleclass.src/handlers/lifecycle.ts— replace theactivateService: (ctx) => voidandcleanupRpc: () => voidconstructor params withserviceLifecycle: ServiceLifecycle; callthis.serviceLifecycle.activate(ctx)inhandleSessionStartandthis.serviceLifecycle.teardown()inhandleSessionShutdown; update the constructor doc comment; importServiceLifecycle.src/index.ts— replace thepermissionsServiceliteral withnew LocalPermissionsService(...); remove theactivateServiceForSessionclosure and the inline teardown closure, replacing them withnew PermissionServiceLifecycle(...)andnew SessionLifecycleHandler(session, serviceLifecycle); drop the now-unused imports (buildInputForSurface,publishPermissionsService,unpublishPermissionsService,emitReadyEvent,isRegisteredSubagentChild); keepsubscribeSubagentLifecycle,getSubagentSessionRegistry,isSubagentExecutionContext(still used by the session deps).test/permissions-service.test.ts— new: unit tests forLocalPermissionsService.test/service-lifecycle.test.ts— new: unit tests forPermissionServiceLifecycle.test/handlers/lifecycle.test.ts— replace theactivateService/cleanupRpcvi.fn()mocks with aserviceLifecycle = { activate: vi.fn(), teardown: vi.fn() }; update the constructor call and the two assertions ("activates the service for the session with ctx" →serviceLifecycle.activatewithctx; "calls cleanupRpc" →serviceLifecycle.teardown).docs/architecture/architecture.md— addpermissions-service.tsandservice-lifecycle.tsto thesrc/tree; update theindex.tstree line; update thepermission-gate-handler.ts/ lifecycle wiring note if it references the old callbacks; mark Phase 3 Step 15 (#320) with the collaborators-only outcome (two collaborators extracted,index.ts~206 → ~170; the "< 100 lines" target reconsidered as procedure-splitting and deferred); update theS15Mermaid node and the Track D row; refresh theindex.tschurn-hotspot note..pi/skills/package-pi-permission-system/SKILL.md— optional: note thatLocalPermissionsServiceandPermissionServiceLifecycleown the service implementation and thesession_start-gated publish / teardown (the #302 narrative now points at a named collaborator).
A repo-wide grep confirms the permissionsService literal, activateServiceForSession, and the teardown closure live only in index.ts; SessionLifecycleHandler's callbacks are referenced only in lifecycle.ts and lifecycle.test.ts; no other module imports these.
Test Impact Analysis
- New tests enabled.
LocalPermissionsServicebecomes directly unit-testable (input building, session-rule application, and delegation to the manager / registry) — previously reachable only throughcomposition-root.test.ts.PermissionServiceLifecycle.activate/teardownbecome directly unit-testable (child-gated publish, ready emission, teardown ordering) — previously buried in two anonymousindex.tsclosures. - Redundant / simplified tests.
None removed.
composition-root.test.tskeeps every assertion (the wiring it checks is unchanged); the new unit tests add lower-level coverage of the same behavior.lifecycle.test.tssimplifies its mock surface (two callbacks → one 2-method collaborator) with no loss of assertion coverage. - Tests that stay as-is.
composition-root.test.ts(handler-registration completeness, subagent registry sharing, shutdown teardown chain, shared formatter registry, ready-after-publish, multi-instance #302 interplay) — these genuinely exercise the wired composition root and must pass unchanged, proving the extraction is behavior-preserving.
TDD Order
- Extract
LocalPermissionsService. Surface: newtest/permissions-service.test.ts—checkPermissionbuilds the surface input, applies the current session ruleset, and delegates toPermissionManager.checkPermission;getToolPermissiondelegates;registerToolInputFormatterdelegates to the registry. Green: addsrc/permissions-service.ts; rewireindex.tstonew LocalPermissionsService(runtime.permissionManager, runtime.sessionRules, formatterRegistry); drop thebuildInputForSurfaceimport.composition-root.test.ts"service and gate share one formatter registry" must stay green (sameformatterRegistryinstance still injected into the service and the gate pipeline). Runpnpm run checkafter this step. Commit:refactor: extract LocalPermissionsService from the composition root. - Extract
PermissionServiceLifecycleand inject it intoSessionLifecycleHandler. Surface: newtest/service-lifecycle.test.ts—activatepublishes then emits ready for a non-child session, skips publish but still emits ready for a registered child, andteardownruns the subscriptions in order before unpublishing. Green: addsrc/service-lifecycle.ts; retypeSessionLifecycleHandlerto takeserviceLifecycle: ServiceLifecycle(replacing the two callbacks) and callactivate/teardown; updatelifecycle.test.ts; rewireindex.tsto buildPermissionServiceLifecycleandnew SessionLifecycleHandler(session, serviceLifecycle); drop the now-unusedpublishPermissionsService/unpublishPermissionsService/emitReadyEvent/isRegisteredSubagentChildimports. The handler constructor-signature change breakslifecycle.test.tsat the type level in this commit, so the collaborator, handler retype, handler-test update, andindex.tswiring land together. Runpnpm run checkafter this step. Commit:refactor: drive service publish/teardown through an injected ServiceLifecycle. - Update the architecture roadmap and package skill.
Surface: docs only.
Update
docs/architecture/architecture.md(tree, Step 15 outcome,S15Mermaid node, Track D row,index.tschurn note) and theSKILL.mdservice-publish note. Commit:docs: record the composition-root collaborator extraction (#320).
Risks and Mitigations
- Injecting
runtime.permissionManageras an instance (vs. late binding) would change behavior if it were ever reassigned. Mitigation: verified by grep thatruntime.permissionManageris never reassigned (onlythis.permissionManagerinsidePermissionSession);sessionRulesisreadonly; a clarifying comment records the invariant, andcomposition-root.test.tsexercises the live service. - A transcription slip in
activate/teardowncould change publish gating or teardown order. Mitigation: the closures move verbatim;composition-root.test.ts's ready-after-publish, teardown-chain, and #302 interplay tests guard each behavior, and the newservice-lifecycle.test.tsasserts ordering directly. @typescript-eslint/unbound-methodcould flagrpcHandles.unsubCheck/unsubPromptpassed by reference. Mitigation: they are plain closures (not class methods), so the rule does not fire; if it does, wrap as() => rpcHandles.unsubCheck().- Scope creep toward the "< 100 lines" target.
Mitigation: the collaborators-only scope is the recorded
ask_userdecision; the remaining injection-bag construction stays inline as legitimate wiring.
Open Questions
- Eliminating the relay closures (
() => runtime.config,runtime.x.bind(runtime)) by retyping the prompter / session / command / RPC consumers onto narrowExtensionRuntimerole interfaces is the deeper follow-up that would getindex.tswell under 100 lines; deferred (declined for this issue viaask_user). - Whether the runtime's separate
permissionManager(global-only config, never refreshed for project cwd) is the intended source for the cross-extension service is pre-existing behavior preserved verbatim here — worth a future look, out of scope.