21 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 323 | Replace GateRunnerDeps with a GateRunner class injected with role collaborators |
Replace GateRunnerDeps with an injected GateRunner class
Problem Statement
PermissionGateHandler.handleToolCall still hand-assembles the gate runner's collaborators as closures and threads them through every gate call.
The runner is a free function, runGateCheck(descriptor, agentName, toolCallId, deps), whose deps is a GateRunnerDeps bag holding resolve, recordSessionApproval, reporter, canConfirm, and promptPermission.
After #319 (PermissionResolver) and #322 (DecisionReporter) landed, that bag resolves to four distinct role collaborators — a permission resolver, a session-approval recorder, a prompter, and a decision reporter — that are "built once in the orchestrator and reused for all gates," as the runner's own doc comment says.
That is constructor injection waiting to happen.
The handler additionally owns a runGate closure that performs the null / bypass / descriptor dispatch around runGateCheck; that dispatch belongs on the runner, not in an anonymous handler closure.
Goals
- Add a
GatePrompterrole (canConfirm()+promptPermission(details)) and aSessionApprovalRecorderrole (recordSessionApproval(approval));PermissionSessionimplements both, the prompter via stored-context adapters over its existingcanPrompt(ctx)/prompt(ctx, details). - Convert
runGateCheckinto aGateRunnerclass constructed withPermissionResolver,SessionApprovalRecorder,GatePrompter, andDecisionReporter, exposingrun(gate, agentName, toolCallId). - Consolidate the null / bypass / descriptor dispatch (the handler's
runGateclosure) intoGateRunner.run. - Delete the
GateRunnerDepsinterface;PermissionGateHandlerconstructs oneGateRunnerin its constructor and callsrun(...)per gate. - Keep the change behavior-preserving; no public npm export changes (every touched module is internal
#src).
Non-Goals
- Retyping the
PermissionGateHandlerconstructor against the narrow role set and dropping theas unknown as PermissionSessioncasts in its session mocks — that is #325, the phase capstone. This plan leaves the handler constructor taking the concretePermissionSessionand keeps theas unknown asmocks, adding only the delegating prompter methods those mocks need to keep passing. - Changing any permission decision, log entry, or decision-event payload.
- Touching
handleInput— it prompts viasession.prompt(ctx, details)directly, never through the runner, and stays as-is. - Folding the
GatePrompterrole intohandleInputor sharing more of the runner's decision-building withhandleInput.
Background
src/handlers/gates/runner.ts—runGateCheck(descriptor, agentName, toolCallId, deps)runs the check→log→emit→approve cycle usingdeps.resolve,deps.reporter.writeReviewLog/emitDecision,deps.canConfirm,deps.promptPermission, anddeps.recordSessionApproval. It handles onlyGateDescriptorinputs.src/handlers/gates/descriptor.ts—interface GateRunnerDeps extends PermissionResolveraddsrecordSessionApproval,reporter: DecisionReporter,canConfirm(),promptPermission(details). The file also definesGateDescriptor,GateBypass,GateResult,isGateBypass,isGateDescriptor.src/handlers/permission-gate-handler.ts— buildscanConfirm/promptPermission/recordSessionApprovalclosures overctxandthis.session, packs them plusthis.reporterinto arunnerDeps: GateRunnerDepsbag perhandleToolCall, and owns arunGateclosure that does the null / bypass (log+emit) / descriptor dispatch.this.reporteris already aGateDecisionReporterbuilt once in the constructor (#322).src/permission-session.ts— alreadyimplements PermissionResolver; storesthis.contextviaactivate(ctx); exposesrecordSessionApproval,canPrompt(ctx),prompt(ctx, details).activate(ctx)runs at the top ofhandleToolCall, so the stored context is current before any gate runs.src/permission-resolver.tsandsrc/decision-reporter.tsare the precedent role modules (a narrow interface, SDK-free, co-located with its implementor where natural).src/handlers/gates/types.ts—GateOutcome = { action: "allow" } | { action: "block"; reason: string }.
Constraints from AGENTS.md / code-design:
- When a shared interface references a collaborator, use the narrow interface type, not the concrete class.
- Keep Pi SDK imports out of pure role modules.
- Removing an exported interface breaks every consumer at the type level in one commit; lift-and-shift large test-file migrations rather than rewriting the whole file at once.
Design-review (from the design-review checklist) of the resulting GateRunner:
- Dependency width: four narrow role collaborators (1–2 methods each), all used by the runner — no wide bag.
- Law of Demeter: the transitional
deps.reporter.x()field-then-call on a parameter bag (the #322 "track and watch") becomesthis.reporter.x()on a direct field — resolved. - Output arguments / scattered resets: none.
- Parameter relay: the stable collaborators become constructor fields; only the genuine per-call data (
gate,agentName,toolCallId) flows torun— the relay is gone. - Missing intermediate abstractions:
GatePrompterandSessionApprovalRecordername the last two implicit roles, lettingGateRunnerDepsbe deleted entirely.
Design Overview
Two new role interfaces
// src/gate-prompter.ts
import type { PermissionPromptDecision } from "./permission-dialog";
import type { PromptPermissionDetails } from "./permission-prompter";
/**
* The prompting role the gate runner needs: a yes/no on whether an
* interactive confirmation is possible, and the prompt itself. The context
* is bound by the implementor, not threaded per call.
*/
export interface GatePrompter {
canConfirm(): boolean;
promptPermission(
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision>;
}
// src/session-approval-recorder.ts
import type { SessionApproval } from "./session-approval";
/** Records a granted session-scoped approval into the session ruleset. */
export interface SessionApprovalRecorder {
recordSessionApproval(approval: SessionApproval): void;
}
Separate one-role-per-file modules mirror permission-resolver.ts / decision-reporter.ts and keep both roles SDK-free; co-locating SessionApprovalRecorder inside session-approval.ts was considered and rejected for consistency with that precedent.
PermissionSession implements the prompter via stored-context adapters
PermissionSession already stores this.context (set by activate) and exposes canPrompt(ctx) / prompt(ctx, details).
The GatePrompter adapters read the stored context so the runner never threads it:
class PermissionSession
implements PermissionResolver, SessionApprovalRecorder, GatePrompter
{
canConfirm(): boolean {
return this.context !== null && this.canPrompt(this.context);
}
promptPermission(
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision> {
if (this.context === null) {
throw new Error(
"promptPermission called before the session was activated",
);
}
return this.prompt(this.context, details);
}
}
canConfirm() returns false when no context is active, so applyPermissionGate never reaches promptForApproval; the null guard in promptPermission is therefore unreachable in correct use and exists only as a defensive invariant.
canPrompt(ctx) / prompt(ctx, details) stay public — handleInput still calls them directly.
GateRunner class
// src/handlers/gates/runner.ts
export class GateRunner {
constructor(
private readonly resolver: PermissionResolver,
private readonly recorder: SessionApprovalRecorder,
private readonly prompter: GatePrompter,
private readonly reporter: DecisionReporter,
) {}
async run(
gate: GateResult,
agentName: string | null,
toolCallId: string,
): Promise<GateOutcome> {
if (!gate) {
return { action: "allow" };
}
if (isGateBypass(gate)) {
if (gate.log) {
this.reporter.writeReviewLog(gate.log.event, gate.log.details);
}
if (gate.decision) {
this.reporter.emitDecision(gate.decision);
}
return { action: "allow" };
}
return this.runDescriptor(gate, agentName, toolCallId);
}
private async runDescriptor(
descriptor: GateDescriptor,
agentName: string | null,
toolCallId: string,
): Promise<GateOutcome> {
/* the current runGateCheck body, using this.resolver / this.prompter /
this.reporter / this.recorder instead of deps.* */
}
}
run returns GateOutcome for all three input shapes (null and bypass both resolve to { action: "allow" } after any bypass side effects).
Handler call site
constructor(session, events, toolRegistry, customFormatters?) {
this.reporter = new GateDecisionReporter(session.logger, events);
this.runner = new GateRunner(session, session, session, this.reporter);
}
// in handleToolCall, replacing the runnerDeps bag + runGate closure:
for (const produce of gateProducers) {
const outcome = await this.runner.run(
await produce(),
tcc.agentName,
tcc.toolCallId,
);
if (outcome.action === "block") {
return { block: true, reason: outcome.reason };
}
}
return {};
The handler passes session for three roles (it implements PermissionResolver, SessionApprovalRecorder, GatePrompter) and this.reporter for the fourth.
This is Tell-Don't-Ask (tell the runner to run the gate) and respects the Law of Demeter (the handler talks to the runner, not its collaborators).
The collaborator closures (canConfirm, promptPermission, recordSessionApproval, the resolve lambda) and the runGate closure are deleted.
Transition strategy (lift-and-shift)
GateRunnerDeps structurally already supplies all four roles (resolve → resolver, recordSessionApproval → recorder, canConfirm + promptPermission → prompter, reporter → reporter).
So GateRunner is introduced alongside runGateCheck, and runGateCheck temporarily becomes a thin wrapper:
export async function runGateCheck(descriptor, agentName, toolCallId, deps) {
return new GateRunner(deps, deps, deps, deps.reporter).run(
descriptor,
agentName,
toolCallId,
);
}
This keeps runner.test.ts and the handler green while the handler (next step) and the large runner test (after) migrate independently; the wrapper, GateRunnerDeps, and makeRunnerDeps are deleted only once nothing references them.
Edge cases
run(null, …)returns{ action: "allow" }, matching the oldrunGatereturningundefined(the handler treats any non-block outcome as continue).- The bypass branch fires
writeReviewLog/emitDecisionthrough the reporter exactly as the handler'srunGatedid — byte-identical side effects. - Stored context equals the
handleToolCallctx:activate(ctx)setsthis.context = ctxbefore the gate loop, so the prompter adapters see the same context the old closures captured. - The handler integration-test session mocks are
as unknown as PermissionSession, so they do not structurally require the new methods; at runtime the runner callssession.canConfirm()/session.promptPermission(details), so the three mocks gain delegating adapters (see Module-Level Changes) to keep theirprompt-override andprompt-call-count tests passing.
Module-Level Changes
src/gate-prompter.ts— new:GatePrompterinterface.src/session-approval-recorder.ts— new:SessionApprovalRecorderinterface.src/permission-session.ts— addSessionApprovalRecorder, GatePrompterto theimplementsclause; addcanConfirm()andpromptPermission(details)stored-context adapters; import both interface types.src/handlers/gates/runner.ts— add theGateRunnerclass (with the movedrunDescriptorbody and the consolidated null/bypass dispatch); importPermissionResolver,SessionApprovalRecorder,GatePrompter,DecisionReporter, andisGateBypass; (step 2) reducerunGateCheckto a wrapper; (final step) deleterunGateCheck.src/handlers/gates/descriptor.ts— (final step) delete theGateRunnerDepsinterface and remove its now-unused imports (DecisionReporter,PermissionResolver,PromptPermissionDetails,PermissionPromptDecision); keepDenialContext,PermissionDecisionEvent,SessionApproval,PermissionCheckResult,PermissionState, and the descriptor/guard exports.src/handlers/permission-gate-handler.ts— add aprivate readonly runner: GateRunnerfield built in the constructor; replace therunnerDepsbag, the collaborator closures, and therunGateclosure withthis.runner.run(...)in the gate loop; drop the now-unused imports (runGateCheck,GateRunnerDeps,isGateBypass,PermissionResolverif unused after the closure removal,PromptPermissionDetailsif unused); keepGateResult(gate-producer typing).test/helpers/gate-fixtures.ts— addmakeGateRunner(overrides)returning{ runner, deps }(builds the four role mocks and aGateRunner); keepmakeReporterandmakeResolver; (final step) deletemakeRunnerDepsand theGateRunnerDepsimport.test/helpers/handler-fixtures.ts—makeSession: add delegatingcanConfirm(→ mockcanPrompt) andpromptPermission(→ mockprompt) adapters, guarded withObject.hasOwnlike the existingresolvedelegation.test/handlers/external-directory-integration.test.tsandtest/handlers/external-directory-session-dedup.test.ts— add the same delegatingcanConfirm/promptPermissionto their local session mocks.test/handlers/gates/runner.test.ts— migrate eachrunGateCheck(d, a, t, makeRunnerDeps(X))toconst { runner, deps } = makeGateRunner(X); runner.run(d, a, t), keeping thedeps.reporter.*/deps.resolve/deps.promptPermission/deps.recordSessionApprovalassertions unchanged.test/permission-session.test.ts— new unit tests forcanConfirm/promptPermission(delegation and null-context behavior)..pi/skills/package-pi-permission-system/SKILL.md— update thegate-fixtures.tslisting: replacemakeRunnerDepswithmakeGateRunner(constructs aGateRunnerwith role mocks, returns{ runner, deps }).docs/architecture/architecture.md— mark Phase 3 step 8 (#323) and the row-6GateRunnerclause ✅; update therunner.tsanddescriptor.tssrc/tree lines (GateRunnerclass;GateRunnerDepsremoved); update theS8Mermaid node and the Track C summary; add a✅ … (#323)entry to the numbered improvement steps.
A repo-wide grep confirms runGateCheck and GateRunnerDeps are referenced only in runner.ts, descriptor.ts, permission-gate-handler.ts, gate-fixtures.ts, and runner.test.ts (plus the architecture doc); no other SKILL.md references them.
Test Impact Analysis
- New tests enabled.
PermissionSession.canConfirm/promptPermissionbecome directly unit-testable (delegation tocanPrompt/prompt;canConfirmfalse and thepromptPermissionthrow when no context is active) — behavior previously buried in per-handleToolCallclosures.GateRunner.run's null and bypass dispatch become directly unit-testable; that dispatch lived in the handler's anonymousrunGateclosure and was only reachable through full handler integration tests. - Redundant / simplified tests.
None are removed.
runner.test.tskeeps every assertion; only the call form changes (runGateCheck(…, deps)→makeGateRunner(…).runner.run(…)with the samedeps.*mocks). The handler integration tests that exercise the infra-read bypass still pass through the handler, now additionally covered at the unit level byGateRunner.run. - Tests that stay as-is (behavior-preserving).
The handler integration suites (
tool-call,tool-call-events,external-directory-integration,external-directory-session-dedup) keep theirpromptoverrides andsession.promptcall-count assertions working because the new mockpromptPermission/canConfirmdelegate to the mock's ownprompt/canPrompt.input/input-eventsare untouched (handleInputstill callssession.prompt(ctx, …)directly). Every gate descriptor test (path,bash-path,bash-external-directory,bash-command, etc.) is unaffected — they depend onmakeResolver, not the runner.
TDD Order
- Add the
GatePrompterandSessionApprovalRecorderroles and implement them onPermissionSession. Surface:test/permission-session.test.ts. Covers:canConfirmdelegates tocanPromptwith the stored context and returnsfalsewhen inactive;promptPermissiondelegates topromptwith the stored context and throws when inactive. No other consumers yet — repo stays green. Commit:feat: add GatePrompter and SessionApprovalRecorder session roles. - Add the
GateRunnerclass alongsiderunGateCheck; reducerunGateCheckto a wrapper delegating tonew GateRunner(deps, deps, deps, deps.reporter).run(...); addmakeGateRunnertogate-fixtures.ts. Surface: newGateRunner.runnull/bypass tests intest/handlers/gates/runner.test.ts(existingrunGateChecktests stay green via the wrapper). Runpnpm run checkafter this step (new class + transitional wrapper). Commit:feat: add GateRunner class consolidating gate dispatch. - Migrate
PermissionGateHandler: buildthis.runner = new GateRunner(session, session, session, this.reporter)in the constructor; replace therunnerDepsbag, the collaborator closures, and therunGateclosure withthis.runner.run(...)in the gate loop; drop the now-unused imports. Add delegatingcanConfirm/promptPermissiontomakeSessionand the twoexternal-directory-*local session mocks so the runtime runner calls resolve through the mocks'canPrompt/prompt. Surface: existing handler integration suites stay green (behavior-preserving). Runpnpm run checkafter this step (constructor signature is unchanged, but the runner wiring and mock shapes change). Commit:refactor: run permission gates through an injected GateRunner. - Migrate
runner.test.tstomakeGateRunner(...).runner.run(...); delete therunGateCheckwrapper, theGateRunnerDepsinterface (and its now-unused descriptor imports), and themakeRunnerDepsfixture; update thegate-fixtures.tsentry inSKILL.md. Surface:test/handlers/gates/runner.test.ts(full assertion set preserved). Commit:refactor: remove GateRunnerDeps and runGateCheck. - Update
docs/architecture/architecture.md: mark step 8 (#323) and the row-6GateRunnerclause ✅; update therunner.ts/descriptor.tstree lines, theS8Mermaid node, and the Track C summary. Commit:docs: record the GateRunner extraction in the Phase 3 roadmap.
Step 2's wrapper keeps the handler and the large runner test green so steps 3 and 4 migrate independently; the wrapper, interface, and fixture are deleted only in step 4, once no consumer remains.
Risks and Mitigations
- The runtime runner calls
session.canConfirm()/session.promptPermission(), which theas unknown as PermissionSessionmocks do not structurally require — so a missing method would fail at runtime, not atpnpm run check(exactly the #319 friction). Mitigation: step 3 adds the delegating adapters to all three session mocks (grepped:handler-fixtures.tsmakeSession,external-directory-integration.test.ts,external-directory-session-dedup.test.ts) and runs the full handler suite before committing. - The delegating-mock tactic is itself a decoupling smell.
Mitigation: it is transitional; #325 retypes the handler against the role interfaces and removes the
as unknown ascasts, at which point the delegation is unnecessary. - Deleting
GateRunnerDepsripples to the descriptor, runner, handler, fixture, and runner test. Mitigation: the wrapper isolates the test migration (step 4) from the handler migration (step 3); the interface is deleted only when both are done. runDescriptoris a verbatim move of therunGateCheckbody; a transcription slip could change behavior. Mitigation: the existingrunner.test.tsassertions (run through the wrapper in steps 2–3, directly in step 4) guard the descriptor path unchanged.
Open Questions
- The residual session-member cluster (
activate,resolveAgentName,config,getInfrastructureDirs,getInfrastructureReadPaths,getActiveSkillEntries,createPermissionRequestId) has no role yet and is deferred to #325; this plan introduces onlyGatePrompterandSessionApprovalRecorder, the last two roles the runner needs. - Whether
handleInputshould eventually prompt through aGatePrompterrather thansession.prompt(ctx, …)directly is out of scope; it does not run through the gate runner.