10 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 367 | Narrow `PermissionForwarder`'s context dependency to a local interface |
Retro: #367 — Narrow PermissionForwarder's context dependency to a local interface
Stage: Planning (2026-06-10T14:16:13Z)
Session summary
Produced the implementation plan for Track C Step 6: narrowing PermissionForwarder's ExtensionContext dependency to a local ForwarderContext interface to eliminate the five as unknown as ExtensionContext casts in permission-forwarder.test.ts.
Investigation found the narrowing cannot be confined to the forwarder — it passes ctx into the shared collaborators isSubagentExecutionContext / isRegisteredSubagentChild (subagent-context.ts) and getActiveAgentName (active-agent.ts), which must also accept the narrower type for the change to type-check.
The plan therefore narrows those collaborators too, which incidentally clears three more casts (2 in subagent-context.test.ts, 1 in active-agent.test.ts) — 8 of the 12 systemic casts cleared.
Observations
- The forwarder's
requestPermissionDecisionFromUidep already receives aPermissionDecisionUi-typed function (frompermission-dialog.ts) but redundantly widens the parameter toExtensionContext["ui"]. Narrowing it toPermissionDecisionUiis what makes the{ select, input }test stubs satisfyForwarderContext.uiwithout a cast. - Verified the SDK signatures against the live dev source (
~/development/pi/pi,v0.79.1), not just the pinned0.75.4innode_modules:getSessionId/getSessionDir/getEntries/ theSessionEntryunion are identical across both, so the narrow interfaces are upgrade-safe. - That check collapsed one proposed divergence: an early draft used
getSessionDir(): string | nullto fit a test stub returningnull, but the SDK returnsstringin every version (nullis unreachable) — the productionif (!sessionDir)guard is really the empty-string case. KeptgetSessionDir(): stringfaithful to the SDK and coerced the test stub to""(vi.fn(() => sessionDir ?? "")). The only standing local type isSessionEntryView(the structural slicegetActiveAgentNamealready operated on; the SDK union's nine variants aren't satisfiable by the tests' simplified entry literals). - Process note: a concurrent session committed a
pi-subagentsdoc change between the retro commit and a plan-revision amend, so the amend folded the plan edit into the wrong commit; recovered withgit reset --soft+ re-split into two clean commits. Prefer a fresh commit over--amendwhen other sessions may be active in the repo. - Followed the
0366sibling-plan precedent (Track C Step 5): single atomicrefactor:commit, narrow interfaces over wide types, "method bodies unchanged," reuse-over-strict-ISP for the collaborator interface (SubagentDetectionContextcarriesgetSessionDireven thoughisRegisteredSubagentChildreads onlygetSessionId). - No
ask_userwas needed — the design is determined by type constraints and the 0366 precedent; the collaborator narrowing is forced, not a discretionary scope choice. - Grep confirmed all production callers of the narrowed collaborators pass a full
ExtensionContext(assignable), mocked callers usevi.mock, andindex.tsre-exports onlyPermissionForwarder/PermissionForwarderDeps— so the change is non-breaking and stays off the public surface. - Decided against
extends-ing the collaborator interfaces fromForwarderContextto avoid cross-module type coupling;ForwarderContextis defined standalone with asessionManagerthat is a structural superset of both collaborator needs.
Stage: Implementation — TDD (2026-06-10T17:52:00Z)
Session summary
Executed the plan as a single Red→Green→Commit cycle (commit 047e8927).
Red: removed all 8 as unknown as ExtensionContext casts and retyped the three test makeCtx helpers to the not-yet-existing narrow interfaces, so pnpm run check failed with 4 missing-member errors.
Green: added SessionEntryView / ActiveAgentContext (active-agent.ts), SubagentDetectionContext (subagent-context.ts), and ForwarderContext (permission-forwarder.ts), narrowed every signature/field, and dropped the per-entry cast in getActiveAgentName.
Test count unchanged at 1902 (pure type-narrowing refactor, no behavior change).
Observations
- Pre-completion reviewer: PASS — all deterministic checks green (
check,lint,test,fallow dead-code), no design concerns, conventional commits clean. - Started from a non-clean state:
git pull --ff-onlyfailed because last session's history repair left a duplicate-content pi-subagents commit (93c72ce6) diverging from the pushedcd33a322. Resolved (with user approval) viagit rebase --onto origin/main 93c72ce6 main, dropping the duplicate and replaying the two#367doc commits cleanly. - One pre-existing baseline lint failure in the plan doc itself (MD053 unused link refs
[#366]/[#367]) — the planning session's pre-commit hook runsrumdl fmt, not full markdownlint, so it slipped through. Fixed as a separatedocs:cleanup commit before starting TDD. - The forwarder test
makeCtxhelper deep-mergessessionManager(top-level spread would replace the whole object and drop the other two required readers);getEntries: vi.fn(() => [])infersnever[], which is assignable toreadonly SessionEntryView[]and needs no annotation since no test overrides it with a non-empty array. - Reviewer's grep initially reported only 3 remaining out-of-scope cast files; a
test/helpers/recheck confirmed 4 (config-store.test.ts,handler-fixtures.ts,permission-prompter.test.ts,prompting-gateway.test.ts), matching the plan's Non-Goals exactly — the reviewer's pattern just missed thehelpers/subdir. index.ts,permission-prompter.ts, andforwarding-manager.tsneeded no edits (fullExtensionContextstays assignable to the narrowed params), confirming the plan's assignability analysis.
Stage: Final Retrospective (2026-06-10T15:12:51Z)
Session summary
Shipped #367 end-to-end across planning, TDD, and ship stages: a pure type-narrowing refactor that replaced PermissionForwarder's full-ExtensionContext dependency with three local interfaces (ForwarderContext, SubagentDetectionContext, ActiveAgentContext / SessionEntryView) and removed 8 as unknown as ExtensionContext casts, with no behavior change (1902 tests, unchanged).
The implementation itself was clean (one Red→Green→Commit cycle, pre-completion PASS, CI green, no release bump since refactor: doesn't trigger release-please).
The friction was entirely in version-control mechanics, not design: a planning-stage git commit --amend collided with a concurrent session's commit and compounded into a failed git pull --ff-only at the start of the TDD stage.
Observations
What went well
- User-prompted live-SDK verification was a genuine win.
When asked "should we investigate the latest SDK at
~/development/pi/pi?", checking the dev source (v0.79.1) against the pinnednode_modules(0.75.4) collapsed a proposedgetSessionDir(): string | nulldivergence down to the SDK-faithfulgetSessionDir(): stringand confirmed all four signatures are upgrade-safe. Verifying against live SDK source — not just the installed version — turned a "documented divergence" into "no divergence," strengthening the design. - The single-commit type-narrowing TDD discipline worked cleanly: de-cast the tests first to drive
tscred (4 missing-member errors), then add the interfaces to drive it green, all in one atomicrefactor:commit — exactly mirroring the0366sibling precedent. - Incremental verification was well-sequenced: baseline
check/lint/testbefore TDD,checkafter Red to confirm the failure, thencheck+ fulltest+lint+fallow dead-codeafter Green and before commit. No end-of-session verification surprises.
What caused friction (agent side)
other— planning-stagegit commit --amend --no-editrewrote a concurrent session'spi-subagentscommit, because HEAD had advanced past my own commit between my last commit and the amend. Recovery (git reset --soft+ re-split into two clean commits) worked, but the recreated commit had identical content to the pushed original under a different SHA. Impact (compounding): the TDD stage opened with a failedgit pull --ff-only(divergent history), requiring agit rebase --onto origin/mainrecovery with user approval before any TDD work could start. Self-identified at amend time; the downstream pull failure was the larger cost.instruction-violation(self-identified) — the planning-stage plan doc ended with orphaned[#366]:/[#367]:reference-link definitions: unused (no matching body[#N]) and one for the doc's own issue number, both of which themarkdown-conventionsskill explicitly forbids. The pre-commitrumdl fmthook doesn't run full markdownlint, so MD053 slipped through to the TDD stage's baselinepnpm run lint. Impact: one extradocs:cleanup commit before TDD could start from green. Rule already exists — this was an application miss, not a missing rule.
What caused friction (user side)
- None. The user's two planning-stage questions ("tell me more about the divergences" and "investigate the latest SDK?") were well-timed strategic redirects that materially improved the design rather than mechanical oversight.
Diagnostic details
- Model-performance correlation — one subagent dispatched:
pre-completion-revieweronanthropic/claude-sonnet-4-6(judgment-heavy review work; appropriate). One imprecision: its cast-count grep missed thetest/helpers/subdir (reported 3 remaining out-of-scope cast files; actual 4), caught and corrected during the parent session with no rework. - Escalation-delay tracking — no
rabbit-holefriction; the amend recovery and the rebase were each resolved in a handful of deliberate commands, never >5 consecutive calls on the same error. - Unused-tool detection — none.
The SDK investigation was targeted
grep/readon known files; an Explore subagent would not have helped. - Feedback-loop gap analysis — no gap; verification ran incrementally at every Red/Green/commit boundary rather than only at the end.
Changes made
AGENTS.md§ Commits — added an amend-safety line: confirm HEAD is your own commit (git log -1) beforegit commit --amend, since a concurrent session may have committed since yours and amend rewrites whatever HEAD points at.