14 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 221 | Expose subagent session registry and tool-level permission query on PermissionsService |
Subagent session registry and tool-level permission query
Problem Statement
PermissionsService currently exposes only checkPermission(surface, value?, agentName?).
In-process subagent extensions (like @gotgenes/pi-subagents) cannot:
- Signal that a session is a child —
isSubagentExecutionContext()relies on env vars (process-based) and filesystem-path matching (session dir ⊂ subagentSessionsDir), neither of which works for in-process children. - Provide a parent session ID for
ask-state forwarding —resolvePermissionForwardingTargetSessionId()only reads env var candidates. - Efficiently check tool-level permissions — consumers must call
checkPermissionper tool and interpret the full result; the internalPermissionManager.getToolPermission()is not exposed.
Goals
- Add
registerSubagentSessionandunregisterSubagentSessionmethods toPermissionsService. - Add
getToolPermission(toolName, agentName?)method toPermissionsService. - Update
isSubagentExecutionContext()to check the session registry before falling back to env vars and filesystem heuristics. - Update
resolvePermissionForwardingTargetSessionId()to readparentSessionIdfrom the registry when env vars are absent. - All additions are backwards-compatible (semver-minor).
Non-Goals
- Changing the
checkPermissionmethod signature or behavior. - Adding bulk
filterToolsForAgent(tools[], agentName)— consumers compose this fromgetToolPermissionthemselves. - Implementing the pi-subagents consumer side (that is #101).
- Changing how process-based subagent detection (env vars) works.
- Changing the file-based forwarding mechanism itself.
Background
Current detection flow in isSubagentExecutionContext()
function isSubagentExecutionContext(ctx, subagentSessionsDir): boolean {
// 1. Check env vars (process-based subagent extensions)
for (const key of SUBAGENT_ENV_HINT_KEYS) { ... }
// 2. Check filesystem path (session dir within known subagent dir)
return isPathWithinDirectory(sessionDir, subagentSessionsDir);
}
Pi-subagents stores sessions under <parent-dir>/<parent-basename>/tasks/, which is NOT within <agentDir>/subagent-sessions/.
So filesystem detection fails for in-process children created by @gotgenes/pi-subagents.
Current forwarding target resolution
function resolvePermissionForwardingTargetSessionId(options): string | null {
if (options.hasUI) return normalize(options.currentSessionId);
if (!options.isSubagent) return null;
// Only checks env vars — no other source
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) { ... }
return null;
}
When isSubagent is true but no env var is set, null is returned and forwarding fails with a logged error.
PermissionManager.getToolPermission()
Already exists internally with clear semantics:
getToolPermission(toolName: string, agentName?: string): PermissionState {
const { composedRules } = this.resolvePermissions(agentName);
return evaluate(normalizedToolName, "*", composedRules).action;
}
This needs only a one-line delegation on the service.
Concurrency consideration
Multiple in-process subagents may run concurrently (background agents).
A scalar flag on globalThis would cause race conditions.
A session-keyed Map (keyed by session directory path) is race-safe because each session has a unique directory.
Design Overview
Subagent session registry
A Map<string, SubagentSessionInfo> stored on globalThis via Symbol.for().
Both the provider (pi-subagents) and reader (pi-permission-system) access the same map through accessor helpers.
/** Signal stored per registered in-process subagent session. */
export interface SubagentSessionInfo {
/** Parent session ID for permission forwarding. */
parentSessionId?: string;
/** Agent name for per-agent policy resolution. */
agentName: string;
}
The key is the session directory path (from ctx.sessionManager.getSessionDir()), which is unique per session and available to both the producer and consumer.
Consumer call site (pi-subagents, 3 lines):
const svc = getPermissionsService();
svc?.registerSubagentSession(sessionDir, { parentSessionId, agentName });
// ... after session completes:
svc?.unregisterSubagentSession(sessionDir);
Updated detection flow
function isSubagentExecutionContext(ctx, subagentSessionsDir): boolean {
// 1. Check explicit registry (in-process subagent extensions)
if (isRegisteredSubagentSession(sessionDir)) return true;
// 2. Check env vars (process-based subagent extensions)
for (const key of SUBAGENT_ENV_HINT_KEYS) { ... }
// 3. Check filesystem path (fallback heuristic)
return isPathWithinDirectory(sessionDir, subagentSessionsDir);
}
Updated forwarding target resolution
function resolvePermissionForwardingTargetSessionId(options): string | null {
if (options.hasUI) return normalize(options.currentSessionId);
if (!options.isSubagent) return null;
// 1. Check explicit registry for parent session ID
const registered = getRegisteredSubagentSession(sessionDir);
if (registered?.parentSessionId) return registered.parentSessionId;
// 2. Fall back to env vars
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) { ... }
return null;
}
Note: resolvePermissionForwardingTargetSessionId currently doesn't receive the session directory.
We need to add sessionDir?: string to its options interface, threading it from the call site that already has ctx.sessionManager.getSessionDir().
Extended PermissionsService interface
export interface PermissionsService {
checkPermission(
surface: string,
value?: string,
agentName?: string,
): PermissionCheckResult;
/** Register an in-process subagent session for detection and forwarding. */
registerSubagentSession(sessionKey: string, info: SubagentSessionInfo): void;
/** Unregister a previously registered subagent session. */
unregisterSubagentSession(sessionKey: string): void;
/** Query tool-level permission state (deny/allow/ask) for pre-filtering. */
getToolPermission(toolName: string, agentName?: string): PermissionState;
}
Registry storage
The registry Map is stored on the ExtensionRuntime (not on globalThis directly).
The service adapter delegates register/unregister to a SubagentSessionRegistry class.
Detection functions receive the registry as a parameter (testable without global state).
export class SubagentSessionRegistry {
private readonly sessions = new Map<string, SubagentSessionInfo>();
register(sessionKey: string, info: SubagentSessionInfo): void {
this.sessions.set(sessionKey, info);
}
unregister(sessionKey: string): void {
this.sessions.delete(sessionKey);
}
get(sessionKey: string): SubagentSessionInfo | undefined {
return this.sessions.get(sessionKey);
}
has(sessionKey: string): boolean {
return this.sessions.has(sessionKey);
}
}
This class lives in a new module src/subagent-registry.ts.
It follows the "State owns its mutations" principle from the architecture.
Why not store on globalThis directly?
The PermissionsService is already on globalThis via Symbol.for().
Consumers call getPermissionsService()?.registerSubagentSession(...).
The registry is internal state of the permission system, not a standalone protocol.
This keeps ownership clear — pi-permission-system owns the registry; consumers interact through the typed service interface.
Module-Level Changes
New files
| File | Purpose |
|---|---|
src/subagent-registry.ts |
SubagentSessionRegistry class + SubagentSessionInfo type |
Modified files
| File | Change |
|---|---|
src/service.ts |
Extend PermissionsService interface with 3 new methods; export SubagentSessionInfo type |
src/index.ts |
Construct SubagentSessionRegistry; wire new service methods in the permissionsService object |
src/subagent-context.ts |
Add registry parameter to isSubagentExecutionContext(); check it first |
src/permission-forwarding.ts |
Add sessionDir? to resolvePermissionForwardingTargetSessionId options; check registry |
src/forwarding-manager.ts |
Thread registry through to isSubagentExecutionContext call |
src/forwarded-permissions/polling.ts |
Thread registry + sessionDir through to detection/resolution calls |
src/permission-prompter.ts |
Thread registry into forwarding deps (for confirmPermission call) |
src/runtime.ts |
Add subagentRegistry field to ExtensionRuntime |
src/yolo-mode.ts |
No change (already correct) |
Test files
| File | Change |
|---|---|
test/subagent-registry.test.ts (new) |
Unit tests for SubagentSessionRegistry |
test/subagent-context.test.ts |
Add tests for registry-based detection |
test/permission-forwarding.test.ts |
Add tests for registry-based target resolution |
test/service.test.ts |
Add tests for new service methods |
Test Impact Analysis
- New tests enabled:
SubagentSessionRegistrycan be tested in pure isolation (no filesystem, no env vars, no SDK). Detection and forwarding can now be tested with explicit registry entries instead of env var stubbing. - Existing tests unchanged: All env-var and filesystem detection tests remain valid — those paths still execute when the registry has no matching entry.
- Signature change tests: Tests that call
isSubagentExecutionContext(ctx, subagentSessionsDir)must add the registry parameter. Tests forresolvePermissionForwardingTargetSessionIdmust addsessionDirto the options object.
TDD Order
Step 1: SubagentSessionRegistry class
- Test:
register,unregister,get,hasoperations - Green: Implement
src/subagent-registry.ts - Commit:
feat(pi-permission-system): add SubagentSessionRegistry class
Step 2: Extend PermissionsService interface and service wiring
- Test:
service.test.ts— new methods delegate correctly - Green: Extend interface in
src/service.ts; wire insrc/index.tsusing the registry andPermissionManager.getToolPermission() - Commit:
feat(pi-permission-system): expose registry and getToolPermission on PermissionsService
Step 3: Registry-aware subagent detection
- Test:
subagent-context.test.ts— returns true when session key is in registry (no env vars, no filesystem match) - Green: Add
registryparameter toisSubagentExecutionContext(); check it first - Update all callers:
forwarding-manager.ts,forwarded-permissions/polling.ts,index.ts - Commit:
feat(pi-permission-system): detect in-process subagents via session registry
Step 4: Registry-aware forwarding target resolution
- Test:
permission-forwarding.test.ts— returnsparentSessionIdfrom registry when env vars absent - Green: Add
sessionDir?to options; check registry before env vars - Update call sites:
forwarded-permissions/polling.ts - Commit:
feat(pi-permission-system): resolve forwarding target from subagent registry
Step 5: Thread registry through remaining callers
- Update
runtime.tsto hold the registry instance - Update
permission-prompter.tsandforwarding-manager.tsto pass registry - Run full test suite; fix any callers still using the old 2-arg signature
- Commit:
refactor(pi-permission-system): thread SubagentSessionRegistry through runtime
Step 6: Documentation
- Update
README.mdcross-extension integration section - Commit:
docs(pi-permission-system): document subagent session registry API
Risks and Mitigations
| Risk | Mitigation |
|---|---|
Signature change to isSubagentExecutionContext breaks call sites |
Lift-and-shift: add optional registry? parameter first, make required in a follow-up step within the same PR |
Registry entries leak if unregisterSubagentSession is never called (crash) |
Document that consumers MUST unregister in a finally block; registry entries are harmless (detection returns true for a session that no longer exists — benign) |
Multiple extensions could call registerSubagentSession for the same key |
Last-write-wins semantics (Map.set); documented as single-writer expected |
Adding methods to PermissionsService interface |
Backwards-compatible: existing consumers only call checkPermission; new methods are additive |
Open Questions
- Should
getToolPermissionalso accept session rules (likecheckPermissiondoes internally)? Deferred — start with the simple delegation; add if a consumer needs it. - Should the registry be cleared on
session_shutdown? No — the registry is process-scoped and outlives individual sessions. Entries are managed by the producer (pi-subagents), not the permission system.