Files
my-pi/pi-permission-system/docs/plans/0221-subagent-session-registry.md

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:

  1. 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.
  2. Provide a parent session ID for ask-state forwarding — resolvePermissionForwardingTargetSessionId() only reads env var candidates.
  3. Efficiently check tool-level permissions — consumers must call checkPermission per tool and interpret the full result; the internal PermissionManager.getToolPermission() is not exposed.

Goals

  • Add registerSubagentSession and unregisterSubagentSession methods to PermissionsService.
  • Add getToolPermission(toolName, agentName?) method to PermissionsService.
  • Update isSubagentExecutionContext() to check the session registry before falling back to env vars and filesystem heuristics.
  • Update resolvePermissionForwardingTargetSessionId() to read parentSessionId from the registry when env vars are absent.
  • All additions are backwards-compatible (semver-minor).

Non-Goals

  • Changing the checkPermission method signature or behavior.
  • Adding bulk filterToolsForAgent(tools[], agentName) — consumers compose this from getToolPermission themselves.
  • 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

  1. New tests enabled: SubagentSessionRegistry can 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.
  2. Existing tests unchanged: All env-var and filesystem detection tests remain valid — those paths still execute when the registry has no matching entry.
  3. Signature change tests: Tests that call isSubagentExecutionContext(ctx, subagentSessionsDir) must add the registry parameter. Tests for resolvePermissionForwardingTargetSessionId must add sessionDir to the options object.

TDD Order

Step 1: SubagentSessionRegistry class

  • Test: register, unregister, get, has operations
  • 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 in src/index.ts using the registry and PermissionManager.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 registry parameter to isSubagentExecutionContext(); 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 — returns parentSessionId from 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.ts to hold the registry instance
  • Update permission-prompter.ts and forwarding-manager.ts to 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.md cross-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

  1. Should getToolPermission also accept session rules (like checkPermission does internally)? Deferred — start with the simple delegation; add if a consumer needs it.
  2. 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.