import type { AgentSession, ExtensionAPI } from "@earendil-works/pi-coding-agent"; /** * Process-local lifecycle contract consumed by pi-permission-system. * * Keep these string channels local rather than importing permission-system * internals: pi-subagents only publishes child identity, while the permission * extension remains the sole owner of policy and authorization decisions. */ export const PERMISSION_CHILD_CREATED_CHANNEL = "subagents:child:session-created"; export const PERMISSION_CHILD_DISPOSED_CHANNEL = "subagents:child:disposed"; interface EventPublisher { events?: { emit(channel: string, payload: unknown): void; }; } interface SessionIdentity { sessionManager?: { getSessionId?: () => string | undefined; }; } interface ChildRegistration { events: NonNullable; sessionId: string; } const childRegistrations = new WeakMap(); const ACTIVE_AGENT_TAG = /]*>\s*/gi; const SAFE_AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; function normalizedSessionId(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const normalized = value.trim(); return normalized || undefined; } /** * Replace inherited agent identity with this child's stable config key. * The permission system reads the first active_agent tag, so inherited parent * tags must be removed rather than merely appending another one. */ export function withActiveAgentIdentity(systemPrompt: string, agentName: string): string { const withoutInheritedIdentity = systemPrompt.replace(ACTIVE_AGENT_TAG, "").trimEnd(); const normalizedAgentName = agentName.trim(); if (!SAFE_AGENT_NAME.test(normalizedAgentName)) return withoutInheritedIdentity; return `${withoutInheritedIdentity}\n\n`; } /** * Register a child synchronously before bindExtensions(). This is deliberately * fail-closed when an event publisher exists: a thrown lifecycle listener must * abort child startup rather than bind a headless permission system as if it * were an ordinary top-level session. */ export function registerPermissionChildSession( pi: Pick | EventPublisher, session: Pick | SessionIdentity, parentSessionId: string | undefined, ): boolean { const events = (pi as EventPublisher).events; const childSessionId = normalizedSessionId( (session as SessionIdentity).sessionManager?.getSessionId?.(), ); const normalizedParentSessionId = normalizedSessionId(parentSessionId); if (!events || !childSessionId || !normalizedParentSessionId) return false; const sessionKey = session as object; if (childRegistrations.has(sessionKey)) return true; events.emit(PERMISSION_CHILD_CREATED_CHANNEL, { sessionId: childSessionId, parentSessionId: normalizedParentSessionId, }); childRegistrations.set(sessionKey, { events, sessionId: childSessionId }); return true; } /** Publish disposal at most once, after the child session has actually closed. */ export function unregisterPermissionChildSession(session: object | undefined): void { if (!session) return; const registration = childRegistrations.get(session); if (!registration) return; childRegistrations.delete(session); registration.events.emit(PERMISSION_CHILD_DISPOSED_CHANNEL, { sessionId: registration.sessionId, }); }