@gotgenes/pi-permission-system 7.3.3 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [7.4.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.3...pi-permission-system-v7.4.0) (2026-05-29)
9
+
10
+
11
+ ### Features
12
+
13
+ * register subagent child sessions via lifecycle events ([cd324dc](https://github.com/gotgenes/pi-packages/commit/cd324dc5f8b18fe69ba8802eda0b17a6a36ccc58))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * document event-based subagent child lifecycle ([62621fa](https://github.com/gotgenes/pi-packages/commit/62621fa9abd093b5deadb3c15139179ae85ad519))
19
+
8
20
  ## [7.3.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.2...pi-permission-system-v7.3.3) (2026-05-28)
9
21
 
10
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "7.3.3",
3
+ "version": "7.4.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  } from "./service";
28
28
  import { createSessionLogger } from "./session-logger";
29
29
  import { isSubagentExecutionContext } from "./subagent-context";
30
+ import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
30
31
  import { SubagentSessionRegistry } from "./subagent-registry";
31
32
  import {
32
33
  canResolveAskPermissionRequest,
@@ -129,6 +130,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
129
130
  };
130
131
  publishPermissionsService(permissionsService);
131
132
 
133
+ // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
134
+ // sessions register/unregister without the core calling us (ADR 0002).
135
+ const unsubSubagentLifecycle = subscribeSubagentLifecycle(
136
+ pi.events,
137
+ subagentRegistry,
138
+ );
139
+
132
140
  emitReadyEvent(pi.events);
133
141
 
134
142
  const toolRegistry = {
@@ -139,6 +147,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
139
147
  const lifecycle = new SessionLifecycleHandler(session, () => {
140
148
  rpcHandles.unsubCheck();
141
149
  rpcHandles.unsubPrompt();
150
+ unsubSubagentLifecycle();
142
151
  unpublishPermissionsService();
143
152
  });
144
153
  const agentPrep = new AgentPrepHandler(session, toolRegistry);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * subagent-lifecycle-events.ts — Subscribe to @gotgenes/pi-subagents' child
3
+ * lifecycle events and keep the SubagentSessionRegistry in sync.
4
+ *
5
+ * @gotgenes/pi-subagents publishes its child-execution lifecycle on the Pi
6
+ * event bus (ADR 0002): it no longer calls this package's service directly.
7
+ * We register the child on `session-created` and unregister it on `disposed`.
8
+ *
9
+ * The channel names and payload shapes are declared independently here (the two
10
+ * packages must not depend on each other under jiti) and MUST match the
11
+ * publisher in `@gotgenes/pi-subagents` (`src/lifecycle/child-lifecycle.ts`).
12
+ *
13
+ * The `session-created` handler MUST stay synchronous: the core emits it on the
14
+ * same synchronous call stack immediately before `bindExtensions()`, and the
15
+ * event bus dispatches listeners synchronously, so a synchronous handler lands
16
+ * the registry entry before binding proceeds. Introducing an `await` before
17
+ * `registry.register(...)` would break the pre-bind ordering.
18
+ */
19
+
20
+ import type { SubagentSessionRegistry } from "./subagent-registry";
21
+
22
+ /** Emitted by the core after session creation, before `bindExtensions()`. */
23
+ export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
24
+
25
+ /** Emitted by the core in the run's `finally` (success and error). */
26
+ export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
27
+
28
+ /** Minimal event-bus surface this module needs (subscribe only). */
29
+ interface LifecycleEventBus {
30
+ on(channel: string, handler: (data: unknown) => void): () => void;
31
+ }
32
+
33
+ /** Fields read from the `session-created` payload (ISP). */
34
+ interface ChildSessionCreatedEvent {
35
+ sessionDir: string;
36
+ agentName: string;
37
+ parentSessionId?: string;
38
+ }
39
+
40
+ /** Fields read from the `disposed` payload (ISP). */
41
+ interface ChildDisposedEvent {
42
+ sessionDir: string;
43
+ }
44
+
45
+ /**
46
+ * Subscribe to the subagent child lifecycle.
47
+ *
48
+ * @returns an unsubscribe that detaches both handlers (call during
49
+ * `session_shutdown`).
50
+ */
51
+ export function subscribeSubagentLifecycle(
52
+ events: LifecycleEventBus,
53
+ registry: SubagentSessionRegistry,
54
+ ): () => void {
55
+ const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
56
+ const event = data as ChildSessionCreatedEvent;
57
+ registry.register(event.sessionDir, {
58
+ agentName: event.agentName,
59
+ parentSessionId: event.parentSessionId,
60
+ });
61
+ });
62
+
63
+ const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
64
+ const event = data as ChildDisposedEvent;
65
+ registry.unregister(event.sessionDir);
66
+ });
67
+
68
+ return () => {
69
+ unsubCreated();
70
+ unsubDisposed();
71
+ };
72
+ }
@@ -0,0 +1,113 @@
1
+ import { createEventBus } from "@earendil-works/pi-coding-agent";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ SUBAGENT_CHILD_DISPOSED,
5
+ SUBAGENT_CHILD_SESSION_CREATED,
6
+ subscribeSubagentLifecycle,
7
+ } from "#src/subagent-lifecycle-events";
8
+ import { SubagentSessionRegistry } from "#src/subagent-registry";
9
+
10
+ describe("subscribeSubagentLifecycle", () => {
11
+ let registry: SubagentSessionRegistry;
12
+
13
+ beforeEach(() => {
14
+ registry = new SubagentSessionRegistry();
15
+ });
16
+
17
+ it("registers a child session on session-created", () => {
18
+ const bus = createEventBus();
19
+ subscribeSubagentLifecycle(bus, registry);
20
+
21
+ bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
22
+ sessionDir: "/sessions/child-abc",
23
+ agentName: "Explore",
24
+ parentSessionId: "parent-42",
25
+ });
26
+
27
+ expect(registry.get("/sessions/child-abc")).toEqual({
28
+ agentName: "Explore",
29
+ parentSessionId: "parent-42",
30
+ });
31
+ });
32
+
33
+ it("populates the registry synchronously — before emit() returns", () => {
34
+ // Guards the pre-bindExtensions ordering: the core emits session-created
35
+ // on the same synchronous call stack right before bindExtensions(), so the
36
+ // handler must complete before emit() returns. A real EventEmitter-backed
37
+ // bus dispatches synchronously; this fails loudly if the handler ever
38
+ // becomes async (awaiting before registry.register).
39
+ const bus = createEventBus();
40
+ subscribeSubagentLifecycle(bus, registry);
41
+
42
+ bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
43
+ sessionDir: "/sessions/child-sync",
44
+ agentName: "Explore",
45
+ });
46
+
47
+ // No await between emit and this assertion.
48
+ expect(registry.has("/sessions/child-sync")).toBe(true);
49
+ });
50
+
51
+ it("omits parentSessionId when the event does not carry one", () => {
52
+ const bus = createEventBus();
53
+ subscribeSubagentLifecycle(bus, registry);
54
+
55
+ bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
56
+ sessionDir: "/sessions/child-xyz",
57
+ agentName: "general-purpose",
58
+ });
59
+
60
+ expect(registry.get("/sessions/child-xyz")).toEqual({
61
+ agentName: "general-purpose",
62
+ parentSessionId: undefined,
63
+ });
64
+ });
65
+
66
+ it("unregisters a child session on disposed", () => {
67
+ const bus = createEventBus();
68
+ subscribeSubagentLifecycle(bus, registry);
69
+ registry.register("/sessions/child-abc", { agentName: "Explore" });
70
+
71
+ bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionDir: "/sessions/child-abc" });
72
+
73
+ expect(registry.has("/sessions/child-abc")).toBe(false);
74
+ });
75
+
76
+ it("detaches both handlers when the returned unsubscribe is called", () => {
77
+ const bus = createEventBus();
78
+ const unsubscribe = subscribeSubagentLifecycle(bus, registry);
79
+
80
+ unsubscribe();
81
+
82
+ bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
83
+ sessionDir: "/sessions/child-abc",
84
+ agentName: "Explore",
85
+ });
86
+ bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionDir: "/sessions/child-abc" });
87
+
88
+ expect(registry.has("/sessions/child-abc")).toBe(false);
89
+ });
90
+
91
+ it("subscribes to a fake bus on the exact channel names", () => {
92
+ const handlers = new Map<string, (data: unknown) => void>();
93
+ const bus = {
94
+ on: vi.fn((channel: string, handler: (data: unknown) => void) => {
95
+ handlers.set(channel, handler);
96
+ return () => handlers.delete(channel);
97
+ }),
98
+ };
99
+
100
+ subscribeSubagentLifecycle(bus, registry);
101
+
102
+ expect(bus.on).toHaveBeenCalledTimes(2);
103
+ expect(handlers.has("subagents:child:session-created")).toBe(true);
104
+ expect(handlers.has("subagents:child:disposed")).toBe(true);
105
+ });
106
+
107
+ it("exposes the canonical channel-name strings", () => {
108
+ expect(SUBAGENT_CHILD_SESSION_CREATED).toBe(
109
+ "subagents:child:session-created",
110
+ );
111
+ expect(SUBAGENT_CHILD_DISPOSED).toBe("subagents:child:disposed");
112
+ });
113
+ });