18 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 145 | Add Symbol.for()-backed service accessor, deprecate permissions:rpc:check |
Symbol.for()-backed service accessor
Problem Statement
The current cross-extension API for policy queries (permissions:rpc:check) wraps a synchronous checkPermission() call in async RPC ceremony: requestId → scoped reply channel → timeout handling → as-cast deserialization.
This works, but the ergonomics are poor for what is fundamentally a direct function call.
Symbol.for() is process-global by spec and survives jiti's per-extension module isolation.
A service object stored on globalThis via Symbol.for() enables direct, type-safe, synchronous function calls from any extension — eliminating the RPC envelope entirely.
Goals
- Add
src/service.tswith aPermissionsServiceinterface andSymbol.for()-backed accessor functions (getPermissionsService,publishPermissionsService,unpublishPermissionsService). - Add an
exportsfield topackage.jsonsoimport("@gotgenes/pi-permission-system")resolves to the service module. - Publish the service during the extension factory and clear it on shutdown.
- Deprecate
permissions:rpc:check— keep the handler working but document the service accessor as the preferred path.
Non-Goals
- Remove
permissions:rpc:check— it stays as a zero-dependency fallback for consumers who do not want to add an optional peer dep. - Move
permissions:rpc:promptto the service — prompt forwarding is genuinely async and the event bus is a reasonable fit. - Move
permissions:decisionbroadcasts — fire-and-forget observation belongs on the event bus. - Add a Proxy delegate for reload safety — during
/reload, all extensions re-initialize; both provider and consumer call their factories anew, so captured references are naturally refreshed. Document the "call per use, don't cache" pattern as a best practice. - Add a JS build step — consumers are Pi extensions that use jiti; pointing
exportsto.tssource is sufficient. - Upstream
registerService/getService— tracked in earendil-works/pi#4207; this plan works independently.
Background
Dependency status
| Issue | Description | Status |
|---|---|---|
| #29 | Permission event channel with RPC | ✅ Implemented |
| earendil-works/pi#4207 | Upstream registerService/getService | Open — independent; this plan works without it |
jiti isolation model
Pi's extension loader creates a fresh jiti instance per extension with moduleCache: false.
Module-scoped state is invisible across extensions.
The only shared channels are:
pi.events— the event bus, explicitly passed by the loader.globalThis+Symbol.for()— process-global by spec, survives jiti isolation.
When a consumer does import("@gotgenes/pi-permission-system"), their jiti loads a fresh module copy.
That copy's getPermissionsService() reads globalThis[Symbol.for(...)], which was set by the provider's factory running in a different jiti instance.
The accessor works because globalThis and Symbol.for() are both process-global.
Affected permission surfaces
This change adds a new cross-extension access layer.
It does not alter any allow/deny/ask decision logic.
All six surfaces (tools, bash, mcp, skills, special, external_directory) are queryable through the service's checkPermission method.
Existing buildInputForSurface utility
src/permission-event-rpc.ts contains a non-exported buildInputForSurface() helper that translates (surface, value) into the input object PermissionManager.checkPermission() expects.
The new service adapter needs the same logic.
The plan extracts it to src/input-normalizer.ts so both the RPC handler and the service factory can import it.
Design Overview
Service interface
import type { PermissionCheckResult } from "./types";
export interface PermissionsService {
/**
* Query the permission policy for a surface and value.
* Returns the full check result including state, matched pattern, and origin.
* Session rules are included automatically.
*/
checkPermission(
surface: string,
value?: string,
agentName?: string,
): PermissionCheckResult;
}
The interface exposes a single method matching the simplified RPC signature.
Internally it delegates to PermissionManager.checkPermission() with the current session rules, mirroring the existing permissions:rpc:check handler logic.
The return type is the existing PermissionCheckResult — re-exported from the service module so consumers get full type safety without importing internal modules.
globalThis accessor
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
export function publishPermissionsService(service: PermissionsService): void {
(globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
}
export function getPermissionsService(): PermissionsService | undefined {
return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
| PermissionsService
| undefined;
}
export function unpublishPermissionsService(): void {
delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
}
publishPermissionsServiceoverwrites the slot — safe for/reload.unpublishPermissionsServiceclears the slot — called duringsession_shutdownto avoid stale references after the extension is torn down.getPermissionsServicereturnsundefinedwhen the extension has not loaded (or has been unloaded). Consumers handle this with atry/catcharound the dynamic import plus anifguard.
Consumer usage
try {
const { getPermissionsService } = await import(
"@gotgenes/pi-permission-system"
);
const permissions = getPermissionsService();
if (permissions) {
const result = permissions.checkPermission("bash", "git push");
// Direct call, full type safety, no async envelope
}
} catch {
// Not installed — graceful degradation
}
Reload safety
During /reload, the Pi extension loader:
- Fires
session_shutdownto all extensions (provider callsunpublishPermissionsService()). - Tears down all extension runtimes.
- Creates fresh jiti instances and calls each extension factory anew.
- Provider's factory calls
publishPermissionsService(newImpl). - Consumer's factory calls
getPermissionsService()and gets the new impl.
Both sides re-initialize, so there is no stale-reference window in the normal flow.
The plan documents "call getPermissionsService() per use, do not cache the reference" as a best practice for resilience against load-order edge cases.
package.json exports
{
"exports": {
".": "./src/service.ts"
}
}
Points to the TypeScript source — jiti consumers resolve it natively.
TypeScript consumers with moduleResolution: "Bundler" get full type inference from the source.
No build step is required.
Deprecation of permissions:rpc:check
- Add
@deprecatedJSDoc annotations toPERMISSIONS_RPC_CHECK_CHANNEL,PermissionsCheckRequest, andPermissionsCheckReplyDatainsrc/permission-events.ts. - The RPC handler in
src/permission-event-rpc.tscontinues to function — no runtime change. - README and architecture docs note the service accessor as the preferred API.
Module-Level Changes
| File | Action | Detail |
|---|---|---|
src/service.ts |
new | PermissionsService interface, SERVICE_KEY constant, publishPermissionsService(), getPermissionsService(), unpublishPermissionsService(). Re-exports PermissionCheckResult and PermissionState from src/types.ts. |
src/input-normalizer.ts |
changed | Export new buildInputForSurface(surface, value) function (moved from src/permission-event-rpc.ts). |
src/permission-event-rpc.ts |
changed | Remove local buildInputForSurface; import from src/input-normalizer.ts. |
src/permission-events.ts |
changed | Add @deprecated JSDoc to PERMISSIONS_RPC_CHECK_CHANNEL, PermissionsCheckRequest, PermissionsCheckReplyData. |
src/index.ts |
changed | Build service adapter object, call publishPermissionsService() after RPC registration. Pass unpublishPermissionsService to SessionLifecycleHandler cleanup. |
src/handlers/lifecycle.ts |
changed | Call the additional cleanup function (unpublish) alongside cleanupRpc(). |
package.json |
changed | Add "exports": { ".": "./src/service.ts" }. |
tests/service.test.ts |
new | Unit tests for accessor functions and service delegation. |
tests/permission-event-rpc.test.ts |
unchanged | Existing RPC tests remain valid — the handler still works. |
docs/architecture/architecture.md |
changed | Add "Cross-extension service accessor" section describing the Symbol.for() pattern. |
README.md |
changed | Add "Service API" section; mark RPC check as deprecated in the event API section. |
Test Impact Analysis
- New unit tests enabled:
tests/service.test.tstests theglobalThisaccessor in isolation — publish, get, unpublish, overwrite. Also tests the service adapter'scheckPermissiondelegation via a mockPermissionManager. - No existing tests become redundant: the RPC handler tests cover the event-bus path which remains the fallback API.
- Existing tests that must stay:
tests/permission-event-rpc.test.ts— the RPC handler is not removed, only deprecated. buildInputForSurfaceextraction: no test changes needed — the function is non-exported today and tested only indirectly through the RPC handler tests, which continue to exercise it after the move.
TDD Order
Step 1 — Service accessor module
- Red:
tests/service.test.ts— assertgetPermissionsService()returnsundefinedby default; assertpublishPermissionsService(mock)makes it retrievable; assertunpublishPermissionsService()clears it; assert a second publish overwrites the first. - Green: implement
src/service.tswith thePermissionsServiceinterface,SERVICE_KEY, and the three accessor functions. Re-exportPermissionCheckResultandPermissionState. - Commit:
feat: add Symbol.for()-backed service accessor module (#145)
Step 2 — Extract buildInputForSurface
- Green: move
buildInputForSurfacefromsrc/permission-event-rpc.tstosrc/input-normalizer.tsas a named export. Updatesrc/permission-event-rpc.tsto import it. Run existing tests to confirm no breakage. - Commit:
refactor: extract buildInputForSurface to input-normalizer (#145)
Step 3 — Service adapter and lifecycle wiring
- Red:
tests/service.test.ts— add tests that construct a service adapter object using a mockPermissionManagerand mockSessionRules, callcheckPermission("bash", "git push"), and assert it delegates correctly with the right input shape and session rules. - Red: verify that
getPermissionsService()returnsundefinedafter the shutdown cleanup runs (test the cleanup callback separately or via theSessionLifecycleHandlertest). - Green: in
src/index.ts, build the service adapter object and callpublishPermissionsService(). PassunpublishPermissionsServiceinto the lifecycle handler's cleanup callback. Updatesrc/handlers/lifecycle.tsto accept and call the additional cleanup. - Build: run
pnpm run buildto verify thehandlers/lifecycle.tssignature change compiles. - Commit:
feat: publish permissions service on startup, clear on shutdown (#145)
Step 4 — Package exports
- Add
"exports": { ".": "./src/service.ts" }topackage.json. - Verify:
pnpm run buildpasses;node -e "import('@gotgenes/pi-permission-system').then(m => console.log(Object.keys(m)))"lists the exported names (or verify via a simpler smoke test). - Commit:
feat: add package.json exports field for cross-extension import (#145)
Step 5 — Deprecate permissions:rpc:check
- Add
@deprecatedJSDoc toPERMISSIONS_RPC_CHECK_CHANNEL,PermissionsCheckRequest, andPermissionsCheckReplyDatainsrc/permission-events.ts. - Commit:
docs: deprecate permissions:rpc:check types in favor of service accessor (#145)
Step 6 — Documentation
- Update
README.md: add "Service API" section documenting theSymbol.for()accessor, consumer usage pattern, and reload behavior. Markpermissions:rpc:checkas deprecated in the existing event API section. - Update
docs/architecture/architecture.md: add cross-extension service accessor description. - Commit:
docs: document service accessor and deprecate RPC check (#145)
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | No. The service delegates to the same PermissionManager.checkPermission() and SessionRules that the event-bus RPC and tool-call handler use. No decision logic changes. |
Stale service reference after /reload |
Both provider and consumer re-initialize during reload. Document "call per use, don't cache" as best practice. unpublishPermissionsService() on shutdown clears the slot as extra safety. |
exports field breaks Pi's jiti loader resolution |
Pi's loader uses pi.extensions (not exports) to find the extension factory. The exports field only affects bare-specifier import() from other extensions. Verify with pnpm run build + smoke test. |
Consumer calls getPermissionsService() before provider has loaded |
Returns undefined — the consumer's if (permissions) { ... } guard handles this. Same as the RPC fallback path's timeout. Document load-order independence. |
buildInputForSurface extraction breaks RPC handler |
The function body is unchanged; only its location moves. Existing permission-event-rpc.test.ts tests pass as-is. |
globalThis pollution across unrelated processes |
Symbol.for() keys are scoped by the full string name ("@gotgenes/pi-permission-system:service"). Collision with other packages is infeasible. Cleanup on shutdown removes the slot. |
Open Questions
- Should the service expose
getToolPermission()for tool-filtering queries? The current RPC only exposescheckPermission. AddinggetToolPermissionwould let consumers replicate before_agent_start filtering. Deferred — add when a consumer needs it. - Should additional event types be re-exported from
src/service.ts? Consumers using the service accessor for policy queries may also wantPermissionDecisionEventfor observation. Subpath exports ("./events") can be added later without breaking changes. - Should the
exportsfield include a"types"condition? Since the entry point is a.tsfile and jiti consumers resolve types natively, a"types"condition adds no value today. Revisit if a JS build step is added.