20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 317 | Remove PermissionForwardingDeps; inline polling logic as forwarder methods |
Remove PermissionForwardingDeps; inline polling logic as PermissionForwarder methods
Problem Statement
After the first two steps of the forwarding lift-and-shift (#315, #316), ForwardingManager and PermissionPrompter both go through the single PermissionForwarder instance, but the actual forwarding behavior still lives as free functions in src/forwarded-permissions/polling.ts.
The forwarder is a thin shell: requestApproval delegates to confirmPermission and processInbox delegates to processForwardedPermissionRequests, threading a privately-held PermissionForwardingDeps bag into each call.
Those two free functions are the package's longest non-test functions — processForwardedPermissionRequests (144 lines) inlines the per-request read → validate → auto-approve/prompt → respond → cleanup workflow in one loop body, and waitForForwardedPermissionApproval (132 lines) mixes target resolution, request construction, atomic write, and the deadline poll loop.
They are still "functions reaching into a bag" rather than methods reading owned state.
This step inlines those bodies as private PermissionForwarder methods reading this, decomposes them into focused helpers, and deletes the PermissionForwardingDeps interface — completing the conversion of the forwarding subsystem to a class-based design.
Goals
- Move the bodies of
waitForForwardedPermissionApprovalandprocessForwardedPermissionRequestsintoPermissionForwarderas private methods readingthisstate. - Decompose them into focused private methods:
processSingleForwardedRequest,buildForwardedRequest, andpollForForwardedResponse. - Keep
confirmPermission's UI-present fast path insiderequestApproval. - Dissolve the
PermissionForwardingDepsbag into the forwarder's own constructor-injected fields and delete the interface once no caller threads it. - Delete
src/forwarded-permissions/polling.tsentirely (every export moves into the forwarder or is no longer referenced). - Behavior-preserving: the
iotests, the forwarder/forwarding behavior tests, andcomposition-root.test.ts(including the file-based forwarding round-trip) stay green.
Non-Goals
- No change to the file-based forwarding protocol, request/response shapes, timeout, poll interval, or any user-visible behavior — this is a pure structural refactor.
- No change to
io.ts(the IO helpers stay as-is and are imported by the forwarder instead of bypolling.ts). - No change to
permission-forwarding.ts(constants +resolvePermissionForwardingTargetSessionIdare a separate module and stay put). - No change to the
ApprovalRequester/InboxProcessornarrow seams or their consumers (PermissionPrompter,ForwardingManager) — their call sites are unchanged. - Reframing
index.tsas collaborator injection is deferred to #320 (Phase 3 Step 7).
Background
Relevant existing modules:
src/forwarded-permissions/polling.ts(411 lines) — exportsPermissionForwardingDeps,getSessionId,formatForwardedPermissionPrompt,waitForForwardedPermissionApproval,processForwardedPermissionRequests,confirmPermission, plus the module-privategetContextSystemPrompt.src/forwarded-permissions/permission-forwarder.ts—PermissionForwarderclass implementingApprovalRequester+InboxProcessor; today it holdsprivate readonly deps: PermissionForwardingDepsand delegates both methods to the polling free functions.src/forwarded-permissions/io.ts— pure-ish IO helpers (ensurePermissionForwardingLocation,listRequestFiles,readForwardedPermissionRequest,readForwardedPermissionResponse,writeJsonFileAtomic,safeDeleteFile,cleanupPermissionForwardingLocationIfEmpty,sleep, theForwardedPermissionLoggertype, and thelog*helpers). Each helper already takes aloggerparameter — the forwarder passesthis.logger.src/index.ts— assembles the 8-fieldforwardingDepsobject and constructs the singlenew PermissionForwarder(forwardingDeps).src/forwarding-manager.tsandsrc/permission-prompter.ts— consume the forwarder only through the narrowInboxProcessor/ApprovalRequesterseams; untouched by this change.
Symbol usage audit (grep of src/ and test/):
getSessionId— only called insidepolling.tsitself; no external consumer.formatForwardedPermissionPrompt— only called insideprocessForwardedPermissionRequests; no external consumer, no direct test.getContextSystemPrompt— already module-private topolling.ts.confirmPermission/processForwardedPermissionRequests— consumed by the forwarder (permission-forwarder.ts) and exercised bypermission-forwarding.test.ts.waitForForwardedPermissionApproval— only called byconfirmPermission.PermissionForwardingDeps— referenced byindex.ts(type),permission-forwarder.ts,permission-forwarder.test.ts, and a stalevi.mockinruntime.test.ts.
Constraints from AGENTS.md and skills:
- Removing an export breaks every importing module and its tests at the type level in the same commit — fold the inline, all consumer updates, and all consumer-test updates into one step (TDD-order guidance).
- The architecture doc and the package skill (
.pi/skills/package-pi-permission-system/SKILL.md) document these internals; both reference removed symbols and need updates. @typescript-eslint/require-awaitis enabled forsrc/— any method that loses its onlyawaitmust dropasync. (Both public methods retainawait, so this does not bite here.)- Default to least privilege and deterministic decisions — preserve every guard (
ctx.hasUI,isSubagentExecutionContext, target resolution, auto-approve) exactly.
Design Overview
The forwarder gains a constructor-config interface and owns each former bag member as a private readonly field, so the inlined methods read this.<field> instead of deps.<field>.
The PermissionForwardingDeps interface (threaded into free functions) is replaced by PermissionForwarderDeps (consumed once, at the index.ts construction site).
Decision: dissolve the bag into individual fields rather than keeping this.deps.
The architecture doc's Step 2 entry states the forwarder "holds the PermissionForwardingDeps bag privately … a later step inlines the polling bodies as methods reading this and removes the bag" — "removes the bag" points to owned fields, not a renamed this.deps.
The lower-churn this.deps.<field> alternative was considered and rejected on that basis.
Constructor config (new, defined in permission-forwarder.ts):
export interface PermissionForwarderDeps {
forwardingDir: string;
subagentSessionsDir: string;
registry?: SubagentSessionRegistry;
events?: PermissionEventBus;
logger: ForwardedPermissionLogger;
writeReviewLog: (event: string, details: Record<string, unknown>) => void;
requestPermissionDecisionFromUi: (
ui: ExtensionContext["ui"],
title: string,
message: string,
options?: RequestPermissionOptions,
) => Promise<PermissionPromptDecision>;
shouldAutoApprove: () => boolean;
}
This is identical in shape to today's PermissionForwardingDeps, so the index.ts object literal is unchanged — only its type annotation changes.
All 8 fields are read by the forwarder's methods (ISP holds — no unused field): forwardingDir/subagentSessionsDir/registry for location + subagent resolution, events for the forwarded UI-prompt emit, logger/writeReviewLog for logging, requestPermissionDecisionFromUi for both the direct fast path and the parent prompt, shouldAutoApprove for the auto-approve branch.
Method surface after the change:
class PermissionForwarder implements ApprovalRequester, InboxProcessor {
// public seam methods
requestApproval(ctx, message, options?, forwarded?): Promise<PermissionPromptDecision>;
processInbox(ctx): Promise<void>;
// private (inlined from polling.ts)
private waitForForwardedApproval(ctx, message, forwarded?): Promise<PermissionPromptDecision>;
private buildForwardedRequest(ctx, message, requesterSessionId, targetSessionId, forwarded?): ForwardedPermissionRequest;
private pollForForwardedResponse(location, request, requestPath, responsePath): Promise<PermissionPromptDecision>;
private processSingleForwardedRequest(ctx, request, location, requestPath): Promise<void>;
}
Decomposition rationale (each piece clears the "returns a value, owns state, or gives behavior to data" bar — not procedure-splitting):
buildForwardedRequestreturns aForwardedPermissionRequestvalue object (request id, resolved agent name viagetActiveAgentName/system-prompt fallback, and the optionalsource/surface/valuedisplay fields).pollForForwardedResponsereturns aPermissionPromptDecision— it owns the deadline loop, the response read, the success/timeout review-log entries, and the request/response file cleanup.processSingleForwardedRequestowns the per-request workflow (validate target → auto-approve or prompt-via-UI → write response → delete request file), readingthis.shouldAutoApprove,this.events,this.requestPermissionDecisionFromUi,this.logger,this.writeReviewLog.
requestApproval keeps the confirmPermission control flow verbatim:
requestApproval(ctx, message, options?, forwarded?) {
if (ctx.hasUI) {
return this.requestPermissionDecisionFromUi(ctx.ui, "Permission Required", message, options);
}
if (!isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)) {
return Promise.resolve({ approved: false, state: "denied" });
}
return this.waitForForwardedApproval(ctx, message, forwarded);
}
(options is consumed only on the UI fast path, exactly as confirmPermission did — waitForForwardedApproval does not receive it.)
getSessionId, getContextSystemPrompt, and formatForwardedPermissionPrompt are pure over ctx/request (they do not read this), so they move to permission-forwarder.ts as module-private functions, not methods.
Upstream-interaction sketch (extracted code vs. its io.ts dependencies):
// inside pollForForwardedResponse — reads this state, tells io helpers
const response = readForwardedPermissionResponse(this.logger, responsePath);
this.writeReviewLog("forwarded_permission.response_received", { /* … */ });
safeDeleteFile(this.logger, responsePath, "forwarded permission response");
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
The io helpers already accept an explicit logger argument, so no upstream API gap exists — the inlined methods tell the helpers with this.logger; there is no Tell-Don't-Ask violation, no output-argument mutation, and no reverse-search pattern carried over from the free functions.
Edge cases preserved (behavior-preserving):
- Unresolvable target session → error log naming the env candidates, return
denied. - Location directories not preparable → error log, return
denied. - Request-file write failure → error log, return
denied. - Timeout → warning +
response_timed_outreview log, delete request, cleanup, returndenied. processInboxno-ops when!ctx.hasUI, when no location exists, or when the inbox is empty.- Per-request: invalid/unreadable request → delete and continue; mismatched
targetSessionId→ warn, delete, continue; auto-approve path emits no UI prompt event; response-write failure → error log,continue(request file retained for retry, matching current behavior).
Module-Level Changes
src/forwarded-permissions/permission-forwarder.ts— addPermissionForwarderDeps; change the constructor to accept it and store its members as individualprivate readonlyfields; add the four private methods plus the three module-private helpers (getSessionId,getContextSystemPrompt,formatForwardedPermissionPrompt); add the imports previously inpolling.ts(existsSync,join,getActiveAgentName,getActiveAgentNameFromSystemPrompt,toRecord,emitUiPromptEvent/PermissionEventBus, thepermission-forwardingconstants + types +isForwardedPermissionRequestForSession+resolvePermissionForwardingTargetSessionId+SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,buildForwardedUiPrompt,isSubagentExecutionContext,SubagentSessionRegistrytype, theio.tshelpers +ForwardedPermissionLogger); drop the./pollingimport; keep theApprovalRequesterandInboxProcessorseams unchanged.src/forwarded-permissions/polling.ts— deleted.src/index.ts— replaceimport type { PermissionForwardingDeps } from "./forwarded-permissions/polling"withimport type { PermissionForwarderDeps } from "./forwarded-permissions/permission-forwarder"(alongside the existingPermissionForwardervalue import); retype theforwardingDepsliteral toPermissionForwarderDeps(literal body unchanged).test/permission-forwarder.test.ts— rewrite: drop thevi.mock("#src/forwarded-permissions/polling", …)delegation harness (no free functions left to delegate to); test real behavior by constructingnew PermissionForwarder({ … vi.fn() stubs … })and assertingrequestApproval/processInboxoutcomes (absorbs the migrated behavior cases below).test/permission-forwarding.test.ts— remove thedescribe("processForwardedPermissionRequests")anddescribe("confirmPermission")blocks and theconfirmPermission/processForwardedPermissionRequestsimport from#src/forwarded-permissions/polling; keep theSUBAGENT_PARENT_SESSION_ENV_CANDIDATESandresolvePermissionForwardingTargetSessionIdblocks (they testpermission-forwarding.ts, not the forwarder).test/runtime.test.ts— remove the stalevi.mock("../src/forwarded-permissions/polling", …)(runtime.ts does not import polling; the path is about to be deleted, so the mock must go to keep module resolution valid).docs/architecture/architecture.md— mark Phase 3 Step 4 ([#317]) ✅ done with an outcome note (matching the Step 2/3 entries); the Phase 3 finding table (item 1) is historical and stays as the record of the original state.docs/architecture/permission-prompter.md— update the stale "It never constructs aPermissionForwardingDepsbag internally" sentence (the interface no longer exists) to reference the forwarder's owned dependencies..pi/skills/package-pi-permission-system/SKILL.md— update the testing note that namesconfirmPermission("confirmPermissionpolls for a response with a 10-minute timeout") to reference the forwarder's forwarded path (e.g.PermissionForwarder.requestApproval).
Historical plan docs under docs/plans/ (e.g. 0292-*, 0296-*) and docs/plans/archive/* record point-in-time states and are not rewritten.
Test Impact Analysis
- New tests enabled by the inline:
- The forwarding behavior is now reachable through the public
PermissionForwarderAPI with a plain stub config object, sopermission-forwarder.test.tscan assert real outcomes (auto-approve emits no UI prompt; rich display fields produce a non-degradedpermissions:ui_prompt; UI fast path callsrequestPermissionDecisionFromUiwithout emitting; no-UI/non-subagent returnsdenied) instead of asserting delegation to mocked free functions. - These cases are migrated almost verbatim from the
processForwardedPermissionRequests/confirmPermissionblocks inpermission-forwarding.test.ts— the only harness change is constructing aPermissionForwarderwith the 8-field config instead of calling a free function with the same-shaped bag.
- The forwarding behavior is now reachable through the public
- Tests that become redundant:
- The current
permission-forwarder.test.tsdelegation tests (assertconfirmPermission/processForwardedPermissionRequestswere called with the stored deps) describe an implementation that ceases to exist — they are removed, replaced by the behavior tests above.
- The current
- Tests that must stay as-is:
test/composition-root.test.ts"subagent registry sharing" round-trip exercises the real wiring end-to-end and must stay green unchanged — it is the integration safety net for behavior preservation.test/forwarded-permissions/io.test.tstestsio.tsdirectly and is untouched.test/forwarding-manager.test.ts(mocks{ processInbox }) andtest/permission-prompter.test.ts(mocks{ requestApproval }) depend only on the narrow seams and are untouched.- The
resolvePermissionForwardingTargetSessionId/ env-candidate blocks inpermission-forwarding.test.tsstay (they test a different module).
TDD Order
This is a behavior-preserving refactor; the existing forwarding/round-trip tests are the safety net, and the type-level coupling (deleting polling.ts breaks all importers at once) forces the production change, all consumer updates, and all consumer-test updates into a single commit.
- refactor — inline the polling logic and delete the bag (one commit;
pnpm run check && pnpm run lint && pnpm run testmust pass before committing):- In
permission-forwarder.ts: addPermissionForwarderDeps, store members as private readonly fields, inline the two public method bodies + the three private helpers (waitForForwardedApproval,buildForwardedRequest,pollForForwardedResponse,processSingleForwardedRequest) and the three module-private functions, and add the absorbed imports; drop the./pollingimport. - Delete
src/forwarded-permissions/polling.ts. - Update
index.ts(import + type annotation only). - Rewrite
test/permission-forwarder.test.tsto test real behavior; migrate theprocessForwardedPermissionRequests/confirmPermissioncases into it. - Prune the migrated blocks + the polling import from
test/permission-forwarding.test.ts. - Remove the stale polling
vi.mockfromtest/runtime.test.ts. - Verify dead-code cleanliness (
pnpm fallow dead-code) —getSessionId/formatForwardedPermissionPromptare now module-private, so no orphaned exports remain. - Commit:
refactor: inline forwarding polling logic as PermissionForwarder methods (#317).
- In
- docs — record the completed step (one commit; no compile impact, so it stands alone):
- Mark Phase 3 Step 4 ([#317]) ✅ done in
docs/architecture/architecture.mdwith an outcome note. - Fix the stale
PermissionForwardingDepsreference indocs/architecture/permission-prompter.md. - Update the
confirmPermissiontesting note in.pi/skills/package-pi-permission-system/SKILL.md. - Commit:
docs: mark Phase 3 Step 4 (remove PermissionForwardingDeps) done (#317).
- Mark Phase 3 Step 4 ([#317]) ✅ done in
Risks and Mitigations
- Risk: a guard, log event name, or error message is dropped or reworded during the move, silently changing forwarding behavior.
Mitigation: copy bodies verbatim and rename only
deps.<field>→this.<field>; thecomposition-root.test.tsround-trip and the migrated behavior tests assert the externally observable effects (request shape, response, UI-prompt emit, auto-approve suppression). - Risk: deleting
polling.tsleaves a danglingvi.mockpath inruntime.test.ts, breaking module resolution. Mitigation: remove that mock in the same commit;runtime.tshas no polling import, so the mock is provably unused. - Risk: an inlined method loses its only
awaitand trips@typescript-eslint/require-await. Mitigation: both public methods andpollForForwardedResponse/processSingleForwardedRequestretain realawaits;buildForwardedRequestis synchronous by design (returns a value). Runpnpm run lintbefore committing. - Risk: storing function-typed fields (
shouldAutoApprove,writeReviewLog,requestPermissionDecisionFromUi) reintroduces an unbound-method lint hit. Mitigation: they are plain function values already bound/arrow-wrapped at theindex.tsconstruction site; assigning them to readonly fields and callingthis.fn(…)does not rebindthisand does not triggerunbound-method(no method reference is passed around).
Open Questions
- None blocking.
The constructor-config-vs-
this.depschoice is resolved in favor of individual fields (see Design Overview); the test-home choice (behavior tests live inpermission-forwarder.test.ts) follows from the module under test.