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

225 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 ~46108 |
| 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 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.