--- issue: 128 issue_title: "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 ~46–108 | | Runtime init (3 fields) | `createExtensionRuntime()` in `src/runtime.ts` | ## Design overview ### ForwardingManager class ```typescript // 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 ```typescript // 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: ```typescript const forwardingManager = new ForwardingManager( runtime.subagentSessionsDir, forwardingDeps, ); ``` And in the `deps` object: `forwarding: forwardingManager`. ## Module-level changes ### New files 1. **`src/forwarding-manager.ts`** — `ForwardingManager` 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.ts`** — `deps.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.ts`** — `deps.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.ts`** — `PermissionForwardingDeps`, `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 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: 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.