19 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 296 | Permission forwarding broken for in-process @gotgenes/pi-subagents children — `ask` silently blocked (regression: pi-subagents v11.4.0 / pi-permission-system v8.0.0) |
Back SubagentSessionRegistry with a process-global instance
Problem Statement
Permission forwarding no longer works for in-process @gotgenes/pi-subagents children.
When a subagent triggers an ask decision (for example an external-directory ls), the request is blocked deterministically instead of being forwarded to the parent session's UI, and no forwarded_permission.* entry is written — the child never enters the forwarding path.
The root cause is a per-session event-bus split.
Each session's ResourceLoader creates its own EventBus, and @gotgenes/pi-subagents builds the child session's loader without passing the parent's bus.
The child lifecycle is published on the parent's bus (createChildLifecyclePublisher((c, d) => pi.events.emit(c, d))), so the parent's permission-system instance registers the child in its SubagentSessionRegistry and can poll/surface requests.
But the child's permission-system instance is a separate jiti module instance with its own empty new SubagentSessionRegistry(), subscribed to the child's separate bus, so it never receives the registration.
In the child, isSubagentExecutionContext() then misses the registry, finds no env hints (@gotgenes/pi-subagents sets none), and fails the filesystem heuristic (child sessions live at <parent-session-dir>/<basename>/tasks, not ~/.pi/agent/subagent-sessions).
canResolveAskPermissionRequest = hasUI || isSubagent || yolo is then false on all three counts, so the ask is blocked.
This is a regression.
The retired permission-bridge.ts (pi-subagents #101) registered through globalThis[Symbol.for("@gotgenes/pi-permission-system:service")] — a process-global channel that survives jiti/session isolation.
The #261 event-based inversion replaced that with a per-session bus, and #267 then removed the globalThis fallback entirely.
Goals
- Restore forwarding for in-process
@gotgenes/pi-subagentschildren: a child'saskdecision reaches the parent's UI as aPermission Required (Subagent)prompt. - Make the single
SubagentSessionRegistryinstance process-global so the parent's instance writes and the child's instance reads the same store, regardless of which per-session event bus each instance is wired to. - Reuse the package's established
Symbol.for()convention (the same mechanismsrc/service.tsuses forPermissionsService). - Keep the existing event-driven registration (ADR-0002) intact — only the registry's storage location changes; the publish/subscribe flow and the synchronous
session-createdordering guarantee are unchanged.
Non-Goals
- No changes to
@gotgenes/pi-subagents. The publisher already emitssubagents:child:session-created/subagents:child:disposedon the parent bus correctly, and the parent-side subscription is correct. This is a single-package fix even though the issue carries bothpkg:*labels. - Not fixing the sibling-key collision for concurrent children of the same parent (all share the
<parent>/<basename>/tasksgetSessionDir()key). This pre-dates the regression and is independent of the event-bus split — see Open Questions. - Not broadening env-hint or filesystem detection for CLI/process-based subagent extensions (that is the separate, still-open #22).
- No change to the
PermissionsServicepublic surface or the prompt-forwarding RPC.
Background
Relevant modules in packages/pi-permission-system/src/:
subagent-registry.ts— theSubagentSessionRegistryclass (aMap<string, SubagentSessionInfo>keyed by child session directory). Today it is instantiated once withnew SubagentSessionRegistry()inindex.ts.index.ts— the composition root. Line 41 constructs the registry and threads the instance intoPermissionPrompter,forwardingDeps,ForwardingManager, the twoisSubagentExecutionContext(...)closures, andsubscribeSubagentLifecycle(pi.events, subagentRegistry).subagent-context.ts—isSubagentExecutionContext(ctx, subagentSessionsDir, registry)checksregistry.has(sessionDir)first.permission-forwarding.ts—resolvePermissionForwardingTargetSessionId(...)readsregistry.get(sessionDir)?.parentSessionIdto find the forwarding target.subagent-lifecycle-events.ts—subscribeSubagentLifecycle(events, registry)registers onsession-createdand unregisters ondisposed.service.ts— the existingSymbol.for("@gotgenes/pi-permission-system:service")pattern to mirror: a process-global slot accessor that survives jiti'smoduleCache: falseisolation.
Constraint from AGENTS.md / the package-pi-permission-system skill: under jiti, module-scoped state is isolated per extension instance; globalThis + Symbol.for() is the prescribed mechanism for process-global state.
The session-created handler must stay synchronous (the core emits it on the same call stack right before bindExtensions()); this fix does not touch that handler.
The registration key matches the runtime lookup key: the event payload's sessionDir is the output of deriveSubagentSessionDir, and the SDK's SessionManager.getSessionDir() returns exactly the directory passed to SessionManager.create(cwd, dir) (verified — newSession() does not mutate sessionDir).
So once the parent's registration lands in a shared store, the child's lookup hits it.
Design Overview
Introduce a lazily-initialized, process-global accessor for the registry and have the composition root use it instead of new.
New accessor in subagent-registry.ts (the class is unchanged):
const SUBAGENT_SESSION_REGISTRY_KEY = Symbol.for(
"@gotgenes/pi-permission-system:subagent-registry",
);
/**
* Return the process-global SubagentSessionRegistry, creating it on first call.
*
* Backed by globalThis + Symbol.for() so the parent's permission-system
* instance (which registers children on the parent event bus) and each child's
* separate jiti instance (which reads the registry to detect itself and resolve
* its forwarding target) share one store across per-session event buses.
*/
export function getSubagentSessionRegistry(): SubagentSessionRegistry {
const store = globalThis as Record<symbol, unknown>;
const existing = store[SUBAGENT_SESSION_REGISTRY_KEY] as
| SubagentSessionRegistry
| undefined;
if (existing) {
return existing;
}
const registry = new SubagentSessionRegistry();
store[SUBAGENT_SESSION_REGISTRY_KEY] = registry;
return registry;
}
Consumer call site in index.ts (one line):
const subagentRegistry = getSubagentSessionRegistry();
All downstream wiring (prompter, forwardingDeps, ForwardingManager, subscribeSubagentLifecycle, the isSubagentExecutionContext closures) is unchanged — it already receives the instance by reference and the type is identical.
Lifecycle and ordering, after the change:
- The parent's permission-system runs
getSubagentSessionRegistry()at process startup — first call creates the global registry — and subscribes to the parent bus. - A child spawns; the core emits
session-createdon the parent bus beforebindExtensions(); the parent's synchronous subscriber callsregistry.register(childSessionDir, { agentName, parentSessionId })on the global registry. bindExtensions()instantiates the child's permission-system; itsgetSubagentSessionRegistry()returns the same global instance (already populated).- On the child's first
tool_call,isSubagentExecutionContext()hitsregistry.has(childSessionDir)→ true; theaskresolves viawaitForForwardedPermissionApproval, which readsparentSessionIdfrom the same registry and writes a request the parent already polls for. - On child completion, the core emits
disposedon the parent bus; the parent's subscriber callsregistry.unregister(childSessionDir).
Why this is the right shape:
- Tell-Don't-Ask / Law of Demeter: callers ask once for the shared registry, then tell it to
register/unregister/has/get. No reach-through, no new collaborator surface. - SRP: the accessor owns the "where the single instance lives" concern; the class keeps owning the data.
- It deliberately does not add a shutdown/unpublish hook for the registry.
A child's
session_shutdownmust not be able to wipe the parent's registrations. Entries are created and removed only by the parent'ssession-created/disposedsubscription, so no teardown hook is needed and child shutdown leaves the store intact.
Why not share the event bus instead?
The deeper cause of the regression is that a cross-session signal (subagents:child:session-created) was routed over a per-session transport.
Each session's ResourceLoader mints its own pi.events bus (resource-loader.ts: this.eventBus = options.eventBus ?? createEventBus()), and the child's loader is built without the parent's bus, so the child subscribes to a different bus than the one the parent publishes on.
Crucially, lifecycle events do not travel on pi.events.
pi.on(...) registers into a per-extension handler map, and the per-session ExtensionRunner dispatches lifecycle by iterating those maps — it never routes tool_call / turn_end / etc. through the bus.
Session isolation (the parent not seeing the child's tool calls) is therefore guaranteed by the per-session ExtensionRunner, not by the bus being per-session.
The per-session scope of pi.events is incidental to lifecycle correctness.
A per-session pi.events is still the right default: intra-session cross-extension coordination must stay scoped, and this package itself depends on it (registerPermissionRpcHandlers(pi.events, ...), emitReadyEvent(pi.events)).
The mismatch is using that bus as a cross-session channel, not the bus being per-session.
Rejected alternatives:
- Pass the parent's bus into the child's loader (share one
pi.events). Highest blast radius: it would cross every extension's intra-session channels between parent and child (this package's RPC and ready events, plus any other extension's pub/sub), breaking the isolation other extensions rely on. - Introduce a process-global event bus for cross-session signals.
More general, but no such bus exists today; inventing one is broader scope and largely re-creates what
globalThis+Symbol.for()already provides.
The chosen approach keeps per-session buses and moves only the cross-session state to a process-global store. The child never needs to receive the event — it only needs to answer "am I a subagent, and who is my parent?", which is a state lookup, not an event. The parent writes the global registry; the child reads it. The lifecycle events remain useful for the parent-side registration and telemetry, and the child simply stops depending on receiving them.
Edge cases:
/reload: the parent re-runsgetSubagentSessionRegistry()and reuses the existing global registry. This differs from the previous (broken) per-instance registry, which started empty after every reload; reuse is correct because entries are keyed per child and cleaned ondisposed, and a reload with no in-flight children leaves an empty store.- Concurrency: distinct children with distinct session directories register under distinct keys safely. Concurrent siblings of one parent share a key (see Open Questions) — pre-existing, unchanged by this fix.
Module-Level Changes
packages/pi-permission-system/src/subagent-registry.ts— add theSUBAGENT_SESSION_REGISTRY_KEYsymbol (module-private) and the exportedgetSubagentSessionRegistry()accessor. TheSubagentSessionRegistryclass andSubagentSessionInfointerface are unchanged.packages/pi-permission-system/src/index.ts— change the import from{ SubagentSessionRegistry }to{ getSubagentSessionRegistry }and replaceconst subagentRegistry = new SubagentSessionRegistry();withconst subagentRegistry = getSubagentSessionRegistry();. No other lines change (SubagentSessionRegistryis referenced only at the import and that one construction site).packages/pi-permission-system/test/subagent-registry.test.ts— add adescribe("getSubagentSessionRegistry (process-global accessor)")block (mirrors theglobalThis accessorblock intest/service.test.ts), with anafterEachthat clears the global slot.packages/pi-permission-system/docs/subagent-integration.md— the "Deterministic child detection" bullet currently implies in-process detection works via the registry; add that the registry is process-global (globalThis+Symbol.for()) because parent and child run on separate per-session event buses, which is what makes the child's lookup see the parent's registration.packages/pi-permission-system/docs/architecture/architecture.md— in the detection-model section (around theSubagentSessionRegistryreferences near lines 417 and 438) add a sentence noting the registry is a process-global singleton shared across per-session buses, and addgetSubagentSessionRegistryto thesubagent-registry.tsdescription in the module listing (around line 538)..pi/skills/package-pi-permission-system/SKILL.md— extend the "Event-based subagent integration" section: the registry must be process-global because the publisher emits on the parent's bus while the child's instance listens on a separate bus; record this as the durable lesson behind this regression.
No changes to service.ts, subagent-context.ts, permission-forwarding.ts, forwarding-manager.ts, or subagent-lifecycle-events.ts — they already operate on the injected registry instance.
Test Impact Analysis
- New tests enabled: the process-global accessor is new behavior that could not be tested before (the registry was only ever
new'd directly). New cases: returns aSubagentSessionRegistry; returns the same instance on repeated calls; an entry registered through one returned reference is visible through another (the parent-writes/child-reads property); state is observable across calls within the process. - Redundant tests: none.
The existing
subagent-registry.test.tscases constructnew SubagentSessionRegistry()and exerciseregister/unregister/has/getmechanics — those remain valid and are not duplicated by the accessor tests. - Tests that must stay as-is:
subagent-context.test.ts,permission-forwarding.test.ts, andsubagent-lifecycle-events.test.tsinject a registry instance directly and exercise detection, target resolution, and the subscribe/register/unregister wiring. They genuinely cover that layer and are unaffected by where the production instance is stored.
TDD Order
fix:— add the process-global accessor. Test surface:test/subagent-registry.test.ts, newdescribe("getSubagentSessionRegistry (process-global accessor)")block withafterEachclearing the global slot viaSymbol.for("@gotgenes/pi-permission-system:subagent-registry"). Red: importgetSubagentSessionRegistry(does not exist yet) and assert same-instance-on-repeat plus cross-reference state sharing. Green: implementgetSubagentSessionRegistry()and the module-private symbol insrc/subagent-registry.ts. Commit:fix: add process-global SubagentSessionRegistry accessor (#296).fix:— wire the composition root to the shared registry. Changesrc/index.tsto import and callgetSubagentSessionRegistry()instead ofnew SubagentSessionRegistry(). This is the regression fix; the accessor from step 1 gains its production consumer here (so no dead-export window remains at review time). Runpnpm run checkimmediately (composition-root/wiring change) and the full package suite. Commit:fix: share SubagentSessionRegistry across parent and child sessions (#296).docs:— updatedocs/subagent-integration.md,docs/architecture/architecture.md, and.pi/skills/package-pi-permission-system/SKILL.mdto document the process-global registry and the per-session-bus rationale. Commit:docs: explain process-global subagent registry across session buses (#296).
Risks and Mitigations
- Global state leaking across unit tests.
Mitigation: the accessor test clears the
Symbol.for(...)slot inafterEach, mirroringtest/service.test.ts. No other test uses the accessor (all others construct the class directly), so cross-file pollution is not possible. - A child's shutdown wiping the parent's registrations.
Mitigation by design: no shutdown/unpublish hook is added for the registry; entries are mutated only by the parent's
session-created/disposedsubscription. - Stale entries across
/reload. Mitigation: entries are keyed per child and removed ondisposed; a reload with no in-flight children leaves an empty store, and the parent reuses the same registry deliberately. - Release semantics.
Both code commits use
fix:so release-please cuts a patch — appropriate for restoring previously-working behavior; the accessor is internal (not part of the publishedPermissionsServicesurface), so it is not afeat.
Open Questions
- Concurrent sibling children of the same parent share the
<parent-session-dir>/<basename>/tasksgetSessionDir()key, so they collide on one registry entry; when one sibling is disposed,unregisterremoves the shared entry and detection breaks for still-running siblings. This pre-dates the regression (the old bridge keyed on the same path) and is independent of the event-bus split, so it is out of scope here. It is latent today (forwarding is broken end-to-end) but becomes live the moment this fix lands, and is tracked in #298 (leaning toward keying the registry by the child's session id). - This regression is an instance of a broader gap: the composition root (
index.ts) has no test coverage, so wiring faults (a collaborator instantiated instead of shared, a dropped handler, a leaked teardown) slip past the unit suite. The practical coverage for this fix is the accessor unit test (TDD step 1) plus the existing injected-registry tests; amakeFakePi()composition-root harness and the backfill tests that would have caught this class of fault are tracked separately in #297 and intentionally out of scope here.