14 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 128 | refactor: extract ForwardingManager class to encapsulate polling lifecycle |
Extract ForwardingManager class
Problem statement
Forwarding poll lifecycle is spread across 3 mutable fields on ExtensionRuntime (permissionForwardingTimer, permissionForwardingContext, isProcessingForwardedRequests), 2 free functions (startForwardedPermissionPolling, stopForwardedPermissionPolling in runtime.ts), and raw PermissionForwardingDeps.
Handlers call deps.startForwardedPermissionPolling(ctx) and deps.stopForwardedPermissionPolling() as opaque callbacks without understanding the lifecycle.
This is the third extraction in the handler decomposition series (after #126 ExtensionPaths, #127 SessionLogger).
Goals
- Extract a
ForwardingManagerclass that owns the timer, context, and processing-lock state. - Remove
permissionForwardingTimer,permissionForwardingContext,isProcessingForwardedRequestsfromExtensionRuntime. - Remove
startForwardedPermissionPollingandstopForwardedPermissionPollingfree functions fromruntime.ts. - Replace
startForwardedPermissionPolling/stopForwardedPermissionPollinginHandlerDepswith aforwarding: ForwardingManagerdep (or equivalent narrow interface). - No behavioral change — same polling logic, same timer intervals, same subagent-context detection.
Non-goals
- Changing
PermissionForwardingDepsshape or polling logic. - Extracting
PermissionSession(#129) — that depends on this issue. - Changing the
/permission-systemslash command or config format. - Refactoring
PermissionPrompterinternals (it builds its ownPermissionForwardingDepsinternally).
Background
Permission surface
This is an infrastructure refactoring — no permission surface semantics change. Forwarding is the mechanism by which subagent permission prompts are relayed to the parent agent's UI.
Dependencies
- #126 ExtensionPaths — closed, implemented.
ForwardingManagerconstructor takesExtensionPaths(needssubagentSessionsDirfor the subagent-context check). - #127 SessionLogger — closed, implemented.
Not directly consumed by
ForwardingManager(logging goes throughPermissionForwardingDeps).
Current layout
| Artifact | Location |
|---|---|
| 3 mutable fields | ExtensionRuntime interface in src/runtime.ts |
startForwardedPermissionPolling() |
Free function in src/runtime.ts (~30 lines) |
stopForwardedPermissionPolling() |
Free function in src/runtime.ts (~10 lines) |
PermissionForwardingDeps |
src/forwarded-permissions/polling.ts |
isSubagentExecutionContext() |
src/subagent-context.ts (called inside start) |
processForwardedPermissionRequests() |
src/forwarded-permissions/polling.ts |
HandlerDeps forwarding fields |
startForwardedPermissionPolling, stopForwardedPermissionPolling in src/handlers/types.ts |
| Handler call sites | before-agent-start.ts, input.ts, tool-call.ts, lifecycle.ts |
| Composition root wiring | src/index.ts lines ~46–108 |
| Runtime init (3 fields) | createExtensionRuntime() in src/runtime.ts |
Design overview
ForwardingManager class
// src/forwarding-manager.ts
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
export class ForwardingManager {
private timer: NodeJS.Timeout | null = null;
private context: ExtensionContext | null = null;
private processing = false;
constructor(
private readonly subagentSessionsDir: string,
private readonly forwardingDeps: PermissionForwardingDeps,
) {}
/** Start polling if ctx has UI and is not a subagent. No-op if already running. */
start(ctx: ExtensionContext): void { /* moved from runtime.ts */ }
/** Stop polling and clear state. */
stop(): void { /* moved from runtime.ts */ }
}
The constructor takes subagentSessionsDir (from ExtensionPaths) rather than the full ExtensionPaths object — it is the only path field used by the start/stop logic.
PermissionForwardingDeps is passed at construction time, same as currently wired in index.ts.
HandlerDeps change
// In src/handlers/types.ts — replace two methods with one dep:
// Before:
startForwardedPermissionPolling(ctx: ExtensionContext): void;
stopForwardedPermissionPolling(): void;
// After:
readonly forwarding: ForwardingManager;
Handler call sites change from deps.startForwardedPermissionPolling(ctx) → deps.forwarding.start(ctx) and deps.stopForwardedPermissionPolling() → deps.forwarding.stop().
ExtensionRuntime slimming
Remove from ExtensionRuntime:
permissionForwardingContext: ExtensionContext | nullpermissionForwardingTimer: NodeJS.Timeout | nullisProcessingForwardedRequests: boolean
Remove from createExtensionRuntime() the three field initializations.
Composition root (index.ts) change
Replace the forwardingDeps construction + two closure wrappers with:
const forwardingManager = new ForwardingManager(
runtime.subagentSessionsDir,
forwardingDeps,
);
And in the deps object: forwarding: forwardingManager.
Module-level changes
New files
src/forwarding-manager.ts—ForwardingManagerclass. MovesstartForwardedPermissionPollingandstopForwardedPermissionPollinglogic fromruntime.ts. ImportsisSubagentExecutionContext,processForwardedPermissionRequests,PERMISSION_FORWARDING_POLL_INTERVAL_MS.tests/forwarding-manager.test.ts— Unit tests forForwardingManager.start()and.stop().
Changed files
src/runtime.ts- Remove
permissionForwardingContext,permissionForwardingTimer,isProcessingForwardedRequestsfromExtensionRuntimeinterface. - Remove
startForwardedPermissionPolling()andstopForwardedPermissionPolling()free functions. - Remove the three field initializations from
createExtensionRuntime(). - Remove
PermissionForwardingDepsimport (if no longer needed).
- Remove
src/handlers/types.ts- Replace
startForwardedPermissionPolling(ctx)andstopForwardedPermissionPolling()withreadonly forwarding: ForwardingManager. - Add
ForwardingManagerimport.
- Replace
src/index.ts- Import
ForwardingManager. - Construct
ForwardingManagerinstance. - Replace
startForwardedPermissionPolling/stopForwardedPermissionPollingclosures indepswithforwarding: forwardingManager.
- Import
src/handlers/before-agent-start.ts—deps.startForwardedPermissionPolling(ctx)→deps.forwarding.start(ctx).src/handlers/input.ts— Same call-site update.src/handlers/tool-call.ts— Same call-site update.src/handlers/lifecycle.ts—deps.startForwardedPermissionPolling(ctx)→deps.forwarding.start(ctx),deps.stopForwardedPermissionPolling()→deps.forwarding.stop().
Changed test files
tests/runtime.test.ts— Remove the 3 tests asserting initialnull/falsevalues for the removed fields.tests/handlers/before-agent-start.test.ts— ReplacestartForwardedPermissionPolling: vi.fn()/stopForwardedPermissionPolling: vi.fn()withforwarding: { start: vi.fn(), stop: vi.fn() }. Update assertion fromdeps.startForwardedPermissionPollingtodeps.forwarding.start.tests/handlers/input.test.ts— Same mock shape update + assertion update.tests/handlers/input-events.test.ts— Same mock shape update (no assertions on these mocks).tests/handlers/tool-call.test.ts— Same mock shape + assertion update.tests/handlers/tool-call-events.test.ts— Same mock shape update.tests/handlers/lifecycle.test.ts— Same mock shape + both start/stop assertion updates.
Unchanged files
src/forwarded-permissions/polling.ts—PermissionForwardingDeps,processForwardedPermissionRequestsstay as-is.src/permission-prompter.ts— Builds its ownPermissionForwardingDepsinternally; no dependency onForwardingManager.tests/permission-system.test.ts— Integration test; callspiPermissionSystemExtension(mockPi)and never sees handler internals.docs/architecture/architecture.md— Does not describe forwarding internals in detail; no update needed unless the decomposition plan doc is referenced.
Test impact analysis
- New unit tests enabled:
ForwardingManager.start()and.stop()can be tested in isolation with a mockPermissionForwardingDepsand fake timers. Previously, testing required constructing a fullExtensionRuntimeor going through the integration test.start()withhasUI: false→ no-op (no timer created).start()with subagent context → stops any existing timer.start()when already running → updates context but does not create a second timer.stop()→ clears timer, context, and processing flag.start()followed by timer tick → callsprocessForwardedPermissionRequests.- Timer tick while
processingis true → skipped.
- Existing tests that become simpler: The 3
createExtensionRuntimeinit tests inruntime.test.tsfor the forwarding fields are deleted — the class constructor handles initialization internally. - Existing tests that stay: Handler tests still verify that
start(ctx)andstop()are called at the right lifecycle points — the assertion target changes fromdeps.startForwardedPermissionPollingtodeps.forwarding.startbut the intent is identical.
TDD order
- Red → Green: Add
src/forwarding-manager.tswith the class skeleton andtests/forwarding-manager.test.tswith core lifecycle tests (start no-op for non-UI, start no-op for subagent, stop clears state, timer tick calls process, tick skipped while processing, idempotent start). Commit:feat: add ForwardingManager class (#128) - Green → Refactor: Remove the 3 forwarding fields from
ExtensionRuntime, delete the two free functions fromruntime.ts, updatecreateExtensionRuntime(). Remove the 3 init-value tests fromruntime.test.ts. Runpnpm run buildto verify. Commit:refactor: remove forwarding state from ExtensionRuntime (#128) - Green → Refactor: Update
HandlerDepsinsrc/handlers/types.ts— replace the two methods withreadonly forwarding: ForwardingManager. Update all 4 handler files to usedeps.forwarding.start(ctx)/deps.forwarding.stop(). Update all 7 handler test files (mock shape + assertions). Runpnpm run buildand full test suite. Commit:refactor: wire ForwardingManager through HandlerDeps (#128) - Green → Refactor: Update
src/index.ts— constructForwardingManager, pass it asforwardingin the deps object, remove the two closure wrappers. Run full test suite. Commit:refactor: construct ForwardingManager in composition root (#128)
Note: Steps 2–4 can be combined into fewer commits if the changes are small enough, but the ordering must be maintained. Step 2 will break the build until step 3 updates callers, so steps 2 and 3 should be done together or step 2 should keep the old functions as deprecated wrappers temporarily.
Revised strategy: Combine steps 2, 3, and 4 into a single commit since removing the fields from ExtensionRuntime and updating HandlerDeps + index.ts are interdependent.
The sequence becomes:
feat: add ForwardingManager class (#128)— new file + tests, no existing code changed.refactor: wire ForwardingManager and remove legacy forwarding state (#128)— all mechanical changes in one commit: runtime, types, handlers, index, handler tests, runtime tests.
Risks and mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | No — pure structural refactor. Same processForwardedPermissionRequests call, same isSubagentExecutionContext guard, same timer interval. Integration tests unchanged. |
Timer leak if ForwardingManager is not stopped |
Same risk exists today. handleSessionEnd calls stop() (via deps.stopForwardedPermissionPolling()); the new code calls deps.forwarding.stop() in the same place. |
| Handler test mock shape changes break tests | Mechanical — replace two vi.fn() fields with one { start: vi.fn(), stop: vi.fn() } object. Grep ensures no mock factory is missed. |
ForwardingManager import creates a circular dependency |
forwarding-manager.ts imports from forwarded-permissions/polling.ts and subagent-context.ts — neither imports back. No cycle. |
Open questions
- Should
ForwardingManageraccept the fullExtensionPathsor justsubagentSessionsDir? The issue suggestsExtensionPaths; this plan uses the narrowersubagentSessionsDirto follow the dependency-width heuristic. Either works — thePermissionSession(#129) will wrap it regardless.