Files
my-pi/pi-permission-system/docs/plans/archive/0128-extract-forwarding-manager.md

14 KiB
Raw Permalink Blame History

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 ForwardingManager class that owns the timer, context, and processing-lock state.
  • Remove permissionForwardingTimer, permissionForwardingContext, isProcessingForwardedRequests from ExtensionRuntime.
  • Remove startForwardedPermissionPolling and stopForwardedPermissionPolling free functions from runtime.ts.
  • Replace startForwardedPermissionPolling / stopForwardedPermissionPolling in HandlerDeps with a forwarding: ForwardingManager dep (or equivalent narrow interface).
  • No behavioral change — same polling logic, same timer intervals, same subagent-context detection.

Non-goals

  • Changing PermissionForwardingDeps shape or polling logic.
  • Extracting PermissionSession (#129) — that depends on this issue.
  • Changing the /permission-system slash command or config format.
  • Refactoring PermissionPrompter internals (it builds its own PermissionForwardingDeps internally).

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. ForwardingManager constructor takes ExtensionPaths (needs subagentSessionsDir for the subagent-context check).
  • #127 SessionLogger — closed, implemented. Not directly consumed by ForwardingManager (logging goes through PermissionForwardingDeps).

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 ~46108
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 | null
  • permissionForwardingTimer: NodeJS.Timeout | null
  • isProcessingForwardedRequests: 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

  1. src/forwarding-manager.tsForwardingManager class. Moves startForwardedPermissionPolling and stopForwardedPermissionPolling logic from runtime.ts. Imports isSubagentExecutionContext, processForwardedPermissionRequests, PERMISSION_FORWARDING_POLL_INTERVAL_MS.
  2. tests/forwarding-manager.test.ts — Unit tests for ForwardingManager.start() and .stop().

Changed files

  1. src/runtime.ts
    • Remove permissionForwardingContext, permissionForwardingTimer, isProcessingForwardedRequests from ExtensionRuntime interface.
    • Remove startForwardedPermissionPolling() and stopForwardedPermissionPolling() free functions.
    • Remove the three field initializations from createExtensionRuntime().
    • Remove PermissionForwardingDeps import (if no longer needed).
  2. src/handlers/types.ts
    • Replace startForwardedPermissionPolling(ctx) and stopForwardedPermissionPolling() with readonly forwarding: ForwardingManager.
    • Add ForwardingManager import.
  3. src/index.ts
    • Import ForwardingManager.
    • Construct ForwardingManager instance.
    • Replace startForwardedPermissionPolling / stopForwardedPermissionPolling closures in deps with forwarding: forwardingManager.
  4. src/handlers/before-agent-start.tsdeps.startForwardedPermissionPolling(ctx)deps.forwarding.start(ctx).
  5. src/handlers/input.ts — Same call-site update.
  6. src/handlers/tool-call.ts — Same call-site update.
  7. src/handlers/lifecycle.tsdeps.startForwardedPermissionPolling(ctx)deps.forwarding.start(ctx), deps.stopForwardedPermissionPolling()deps.forwarding.stop().

Changed test files

  1. tests/runtime.test.ts — Remove the 3 tests asserting initial null/false values for the removed fields.
  2. tests/handlers/before-agent-start.test.ts — Replace startForwardedPermissionPolling: vi.fn() / stopForwardedPermissionPolling: vi.fn() with forwarding: { start: vi.fn(), stop: vi.fn() }. Update assertion from deps.startForwardedPermissionPolling to deps.forwarding.start.
  3. tests/handlers/input.test.ts — Same mock shape update + assertion update.
  4. tests/handlers/input-events.test.ts — Same mock shape update (no assertions on these mocks).
  5. tests/handlers/tool-call.test.ts — Same mock shape + assertion update.
  6. tests/handlers/tool-call-events.test.ts — Same mock shape update.
  7. tests/handlers/lifecycle.test.ts — Same mock shape + both start/stop assertion updates.

Unchanged files

  • src/forwarded-permissions/polling.tsPermissionForwardingDeps, processForwardedPermissionRequests stay as-is.
  • src/permission-prompter.ts — Builds its own PermissionForwardingDeps internally; no dependency on ForwardingManager.
  • tests/permission-system.test.ts — Integration test; calls piPermissionSystemExtension(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

  1. New unit tests enabled: ForwardingManager.start() and .stop() can be tested in isolation with a mock PermissionForwardingDeps and fake timers. Previously, testing required constructing a full ExtensionRuntime or going through the integration test.
    • start() with hasUI: 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 → calls processForwardedPermissionRequests.
    • Timer tick while processing is true → skipped.
  2. Existing tests that become simpler: The 3 createExtensionRuntime init tests in runtime.test.ts for the forwarding fields are deleted — the class constructor handles initialization internally.
  3. Existing tests that stay: Handler tests still verify that start(ctx) and stop() are called at the right lifecycle points — the assertion target changes from deps.startForwardedPermissionPolling to deps.forwarding.start but the intent is identical.

TDD order

  1. Red → Green: Add src/forwarding-manager.ts with the class skeleton and tests/forwarding-manager.test.ts with 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)
  2. Green → Refactor: Remove the 3 forwarding fields from ExtensionRuntime, delete the two free functions from runtime.ts, update createExtensionRuntime(). Remove the 3 init-value tests from runtime.test.ts. Run pnpm run build to verify. Commit: refactor: remove forwarding state from ExtensionRuntime (#128)
  3. Green → Refactor: Update HandlerDeps in src/handlers/types.ts — replace the two methods with readonly forwarding: ForwardingManager. Update all 4 handler files to use deps.forwarding.start(ctx) / deps.forwarding.stop(). Update all 7 handler test files (mock shape + assertions). Run pnpm run build and full test suite. Commit: refactor: wire ForwardingManager through HandlerDeps (#128)
  4. Green → Refactor: Update src/index.ts — construct ForwardingManager, pass it as forwarding in the deps object, remove the two closure wrappers. Run full test suite. Commit: refactor: construct ForwardingManager in composition root (#128)

Note: Steps 24 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:

  1. feat: add ForwardingManager class (#128) — new file + tests, no existing code changed.
  2. 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 ForwardingManager accept the full ExtensionPaths or just subagentSessionsDir? The issue suggests ExtensionPaths; this plan uses the narrower subagentSessionsDir to follow the dependency-width heuristic. Either works — the PermissionSession (#129) will wrap it regardless.