@gotgenes/pi-permission-system 8.3.0 → 8.3.2

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,33 @@ 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
+ ## [8.3.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.3.1...pi-permission-system-v8.3.2) (2026-06-01)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** key subagent registry by session id and drop vestigial agentName ([d299c54](https://github.com/gotgenes/pi-packages/commit/d299c5421f41ab0829fb83fcf4e030d1c7af6d56))
14
+ * **pi-permission-system:** resolve subagent detection and forwarding target by session id ([0f7e079](https://github.com/gotgenes/pi-packages/commit/0f7e0795b911797e645f4d44c42bb314bf0cb103))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * **retro:** add retro notes for issue [#296](https://github.com/gotgenes/pi-packages/issues/296) ([75743ab](https://github.com/gotgenes/pi-packages/commit/75743abe92604de142ff6e77c9c0fbc44266e12a))
20
+
21
+ ## [8.3.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.3.0...pi-permission-system-v8.3.1) (2026-06-01)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * add process-global SubagentSessionRegistry accessor ([#296](https://github.com/gotgenes/pi-packages/issues/296)) ([d3fd3b0](https://github.com/gotgenes/pi-packages/commit/d3fd3b04223b2d276873094ad8c14f239654b8c8))
27
+ * share SubagentSessionRegistry across parent and child sessions ([#296](https://github.com/gotgenes/pi-packages/issues/296)) ([fed676a](https://github.com/gotgenes/pi-packages/commit/fed676aaa485abe8db158e522ba898705f3dff94))
28
+
29
+
30
+ ### Documentation
31
+
32
+ * explain process-global subagent registry across session buses ([#296](https://github.com/gotgenes/pi-packages/issues/296)) ([1804dbb](https://github.com/gotgenes/pi-packages/commit/1804dbbb766d7b7fbc0e49da877f3238f5c3e8dc))
33
+ * use ADR-NNNN with links docs-wide ([c6b6431](https://github.com/gotgenes/pi-packages/commit/c6b6431c004f324931f23be46cf2e47e8fdac919))
34
+
8
35
  ## [8.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.2.1...pi-permission-system-v8.3.0) (2026-06-01)
9
36
 
10
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "8.3.0",
3
+ "version": "8.3.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -105,7 +105,6 @@ export async function waitForForwardedPermissionApproval(
105
105
  deps: PermissionForwardingDeps,
106
106
  ): Promise<PermissionPromptDecision> {
107
107
  const requesterSessionId = getSessionId(ctx);
108
- const sessionDir = ctx.sessionManager.getSessionDir();
109
108
  const targetSessionId = resolvePermissionForwardingTargetSessionId({
110
109
  hasUI: ctx.hasUI,
111
110
  isSubagent: isSubagentExecutionContext(
@@ -115,7 +114,7 @@ export async function waitForForwardedPermissionApproval(
115
114
  ),
116
115
  currentSessionId: requesterSessionId,
117
116
  env: process.env,
118
- sessionDir,
117
+ sessionId: requesterSessionId,
119
118
  registry: deps.registry,
120
119
  });
121
120
 
package/src/index.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  import { createSessionLogger } from "./session-logger";
30
30
  import { isSubagentExecutionContext } from "./subagent-context";
31
31
  import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
32
- import { SubagentSessionRegistry } from "./subagent-registry";
32
+ import { getSubagentSessionRegistry } from "./subagent-registry";
33
33
  import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
34
34
  import {
35
35
  canResolveAskPermissionRequest,
@@ -38,7 +38,7 @@ import {
38
38
 
39
39
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
40
40
  const runtime = createExtensionRuntime();
41
- const subagentRegistry = new SubagentSessionRegistry();
41
+ const subagentRegistry = getSubagentSessionRegistry();
42
42
  const formatterRegistry = new ToolInputFormatterRegistry();
43
43
  registerBuiltinToolInputFormatters(formatterRegistry);
44
44
 
@@ -119,8 +119,8 @@ export function resolvePermissionForwardingTargetSessionId(options: {
119
119
  isSubagent: boolean;
120
120
  currentSessionId?: string | null;
121
121
  env?: NodeJS.ProcessEnv;
122
- /** Session directory key for registry lookup. */
123
- sessionDir?: string;
122
+ /** Child session id for registry lookup. */
123
+ sessionId?: string;
124
124
  /** In-process subagent session registry (checked before env vars). */
125
125
  registry?: SubagentSessionRegistry;
126
126
  }): string | null {
@@ -133,8 +133,8 @@ export function resolvePermissionForwardingTargetSessionId(options: {
133
133
  }
134
134
 
135
135
  // 1. Registry — in-process subagents register parentSessionId explicitly.
136
- if (options.registry && options.sessionDir) {
137
- const entry = options.registry.get(options.sessionDir);
136
+ if (options.registry && options.sessionId) {
137
+ const entry = options.registry.get(options.sessionId);
138
138
  const resolved = normalizePermissionForwardingSessionId(
139
139
  entry?.parentSessionId,
140
140
  );
@@ -33,14 +33,23 @@ export function isSubagentExecutionContext(
33
33
  subagentSessionsDir: string,
34
34
  registry?: SubagentSessionRegistry,
35
35
  ): boolean {
36
- const sessionDir = ctx.sessionManager.getSessionDir();
37
-
38
- // 1. Explicit registry in-process subagent extensions register before
39
- // bindExtensions(); checked first so it takes priority over heuristics.
40
- if (registry && sessionDir && registry.has(sessionDir)) {
41
- return true;
36
+ // 1. Explicit registry — in-process subagent extensions register by child
37
+ // session id before bindExtensions(); checked first so it takes priority
38
+ // over heuristics. Each concurrent sibling has a unique session id, so
39
+ // one sibling's disposed event cannot affect another's registration.
40
+ if (registry) {
41
+ try {
42
+ const sessionId = ctx.sessionManager.getSessionId();
43
+ if (sessionId && registry.has(sessionId)) {
44
+ return true;
45
+ }
46
+ } catch {
47
+ // getSessionId() unavailable — fall through to env/filesystem detection.
48
+ }
42
49
  }
43
50
 
51
+ const sessionDir = ctx.sessionManager.getSessionDir();
52
+
44
53
  // 2. Env vars — process-based subagent extensions (nicobailon/pi-subagents,
45
54
  // HazAT/pi-interactive-subagents, pi-agent-router, etc.).
46
55
  for (const key of SUBAGENT_ENV_HINT_KEYS) {
@@ -32,14 +32,15 @@ interface LifecycleEventBus {
32
32
 
33
33
  /** Fields read from the `session-created` payload (ISP). */
34
34
  interface ChildSessionCreatedEvent {
35
- sessionDir: string;
36
- agentName: string;
35
+ /** Child session id — the registry key. Must match the publisher. */
36
+ sessionId: string;
37
37
  parentSessionId?: string;
38
38
  }
39
39
 
40
40
  /** Fields read from the `disposed` payload (ISP). */
41
41
  interface ChildDisposedEvent {
42
- sessionDir: string;
42
+ /** Child session id — the registry key. Must match the publisher. */
43
+ sessionId: string;
43
44
  }
44
45
 
45
46
  /**
@@ -54,15 +55,14 @@ export function subscribeSubagentLifecycle(
54
55
  ): () => void {
55
56
  const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
56
57
  const event = data as ChildSessionCreatedEvent;
57
- registry.register(event.sessionDir, {
58
- agentName: event.agentName,
58
+ registry.register(event.sessionId, {
59
59
  parentSessionId: event.parentSessionId,
60
60
  });
61
61
  });
62
62
 
63
63
  const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
64
64
  const event = data as ChildDisposedEvent;
65
- registry.unregister(event.sessionDir);
65
+ registry.unregister(event.sessionId);
66
66
  });
67
67
 
68
68
  return () => {
@@ -7,28 +7,73 @@
7
7
  * can detect them without relying on environment variables or filesystem
8
8
  * heuristics.
9
9
  *
10
- * The registry is keyed by session directory path, which is unique per
11
- * session and available to both producer and consumer via
12
- * `ctx.sessionManager.getSessionDir()`.
10
+ * The registry is keyed by the child's **session id**, which is unique per
11
+ * child and available to both producer (via `sessionManager.getSessionId()`
12
+ * after `newSession()` in `create-subagent-session.ts`) and consumer (via
13
+ * `ctx.sessionManager.getSessionId()`). Two concurrent siblings of the same
14
+ * parent therefore occupy distinct keys, so one sibling's `disposed` event
15
+ * cannot evict the entry the others depend on.
16
+ *
17
+ * The single registry instance is stored on `globalThis` (via `Symbol.for()`)
18
+ * so that the parent's permission-system instance (which registers children
19
+ * on the parent's event bus) and each child's separate jiti instance (which
20
+ * reads the registry to detect itself and resolve its forwarding target) share
21
+ * one store across per-session event buses. See `getSubagentSessionRegistry()`.
22
+ *
23
+ * When a future code path needs the child's agent name, read it from
24
+ * `tcc.agentName` (resolved from the `<active_agent>` system-prompt tag) —
25
+ * not from this registry.
26
+ */
27
+
28
+ /** Process-global key for the shared registry slot. */
29
+ const SUBAGENT_SESSION_REGISTRY_KEY = Symbol.for(
30
+ "@gotgenes/pi-permission-system:subagent-registry",
31
+ );
32
+
33
+ /**
34
+ * Return the process-global SubagentSessionRegistry, creating it on first call.
35
+ *
36
+ * Backed by `globalThis` + `Symbol.for()` so the parent's permission-system
37
+ * instance (which registers children on the parent event bus) and each child's
38
+ * separate jiti instance (which reads the registry to detect itself and resolve
39
+ * its forwarding target) share one store across per-session event buses.
40
+ *
41
+ * Intentionally has no shutdown/unpublish hook — a child's `session_shutdown`
42
+ * must not be able to wipe the parent's registrations. Entries are added and
43
+ * removed exclusively by the parent's `subagents:child:session-created` /
44
+ * `subagents:child:disposed` subscription.
13
45
  */
46
+ export function getSubagentSessionRegistry(): SubagentSessionRegistry {
47
+ const store = globalThis as Record<symbol, unknown>;
48
+ const existing = store[SUBAGENT_SESSION_REGISTRY_KEY] as
49
+ | SubagentSessionRegistry
50
+ | undefined;
51
+ if (existing) {
52
+ return existing;
53
+ }
54
+ const registry = new SubagentSessionRegistry();
55
+ store[SUBAGENT_SESSION_REGISTRY_KEY] = registry;
56
+ return registry;
57
+ }
14
58
 
15
59
  /** Signal stored per registered in-process subagent session. */
16
60
  export interface SubagentSessionInfo {
17
61
  /** Parent session ID for permission forwarding. Omit when unknown. */
18
62
  parentSessionId?: string;
19
- /** Agent name for per-agent policy resolution. */
20
- agentName: string;
21
63
  }
22
64
 
23
65
  /**
24
66
  * Registry of active in-process subagent sessions.
25
67
  *
26
- * Owned by `ExtensionRuntime`; written exclusively by `subscribeSubagentLifecycle`
27
- * via the `subagents:child:session-created` / `subagents:child:disposed` event
28
- * subscription (ADR 0002 — the core publishes, consumers observe).
68
+ * A process-global singleton obtain it via `getSubagentSessionRegistry()`,
69
+ * never `new` (see that accessor for why). Written exclusively by
70
+ * `subscribeSubagentLifecycle` via the `subagents:child:session-created` /
71
+ * `subagents:child:disposed` event subscription (ADR 0002 — the core
72
+ * publishes, consumers observe).
29
73
  *
30
- * Concurrent background agents are safe because each session has a unique
31
- * directory path as its key no scalar global flag is needed.
74
+ * Keyed by child session id. Each concurrent child of the same parent receives
75
+ * a unique session id from `sessionManager.newSession()`, so siblings occupy
76
+ * distinct keys and one sibling's `disposed` cannot evict another's entry.
32
77
  */
33
78
  export class SubagentSessionRegistry {
34
79
  private readonly sessions = new Map<string, SubagentSessionInfo>();
@@ -36,25 +81,25 @@ export class SubagentSessionRegistry {
36
81
  /**
37
82
  * Register an in-process subagent session.
38
83
  *
39
- * If a previous entry exists for `sessionKey`, it is overwritten
84
+ * If a previous entry exists for `sessionId`, it is overwritten
40
85
  * (last-write-wins; single-writer expected per key).
41
86
  */
42
- register(sessionKey: string, info: SubagentSessionInfo): void {
43
- this.sessions.set(sessionKey, info);
87
+ register(sessionId: string, info: SubagentSessionInfo): void {
88
+ this.sessions.set(sessionId, info);
44
89
  }
45
90
 
46
91
  /** Remove a previously registered session. No-op if the key is absent. */
47
- unregister(sessionKey: string): void {
48
- this.sessions.delete(sessionKey);
92
+ unregister(sessionId: string): void {
93
+ this.sessions.delete(sessionId);
49
94
  }
50
95
 
51
- /** Return the registered info for `sessionKey`, or `undefined` if absent. */
52
- get(sessionKey: string): SubagentSessionInfo | undefined {
53
- return this.sessions.get(sessionKey);
96
+ /** Return the registered info for `sessionId`, or `undefined` if absent. */
97
+ get(sessionId: string): SubagentSessionInfo | undefined {
98
+ return this.sessions.get(sessionId);
54
99
  }
55
100
 
56
- /** Return `true` when `sessionKey` has a registered entry. */
57
- has(sessionKey: string): boolean {
58
- return this.sessions.has(sessionKey);
101
+ /** Return `true` when `sessionId` has a registered entry. */
102
+ has(sessionId: string): boolean {
103
+ return this.sessions.has(sessionId);
59
104
  }
60
105
  }
@@ -149,13 +149,11 @@ describe("resolvePermissionForwardingTargetSessionId", () => {
149
149
  });
150
150
 
151
151
  describe("resolvePermissionForwardingTargetSessionId — registry resolution", () => {
152
- const sessionDir =
153
- "/home/user/projects/.pi/sessions/parent/tasks/session-abc";
152
+ const childSessionId = "child-session-abc";
154
153
 
155
154
  test("returns parentSessionId from registry when env vars are absent", () => {
156
155
  const registry = new SubagentSessionRegistry();
157
- registry.register(sessionDir, {
158
- agentName: "Explore",
156
+ registry.register(childSessionId, {
159
157
  parentSessionId: "parent-from-registry",
160
158
  });
161
159
 
@@ -163,7 +161,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
163
161
  resolvePermissionForwardingTargetSessionId({
164
162
  hasUI: false,
165
163
  isSubagent: true,
166
- sessionDir,
164
+ sessionId: childSessionId,
167
165
  registry,
168
166
  env: {},
169
167
  }),
@@ -172,8 +170,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
172
170
 
173
171
  test("registry takes priority over env vars", () => {
174
172
  const registry = new SubagentSessionRegistry();
175
- registry.register(sessionDir, {
176
- agentName: "Explore",
173
+ registry.register(childSessionId, {
177
174
  parentSessionId: "parent-from-registry",
178
175
  });
179
176
 
@@ -181,7 +178,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
181
178
  resolvePermissionForwardingTargetSessionId({
182
179
  hasUI: false,
183
180
  isSubagent: true,
184
- sessionDir,
181
+ sessionId: childSessionId,
185
182
  registry,
186
183
  env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
187
184
  }),
@@ -190,27 +187,27 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
190
187
 
191
188
  test("falls through to env vars when registry entry has no parentSessionId", () => {
192
189
  const registry = new SubagentSessionRegistry();
193
- registry.register(sessionDir, { agentName: "Explore" }); // no parentSessionId
190
+ registry.register(childSessionId, {}); // no parentSessionId
194
191
 
195
192
  expect(
196
193
  resolvePermissionForwardingTargetSessionId({
197
194
  hasUI: false,
198
195
  isSubagent: true,
199
- sessionDir,
196
+ sessionId: childSessionId,
200
197
  registry,
201
198
  env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
202
199
  }),
203
200
  ).toBe("parent-from-env");
204
201
  });
205
202
 
206
- test("falls through to env vars when sessionDir is not in registry", () => {
203
+ test("falls through to env vars when sessionId is not in registry", () => {
207
204
  const registry = new SubagentSessionRegistry(); // empty
208
205
 
209
206
  expect(
210
207
  resolvePermissionForwardingTargetSessionId({
211
208
  hasUI: false,
212
209
  isSubagent: true,
213
- sessionDir,
210
+ sessionId: childSessionId,
214
211
  registry,
215
212
  env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
216
213
  }),
@@ -219,13 +216,13 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
219
216
 
220
217
  test("returns null when registry entry has no parentSessionId and no env vars set", () => {
221
218
  const registry = new SubagentSessionRegistry();
222
- registry.register(sessionDir, { agentName: "Explore" }); // no parentSessionId
219
+ registry.register(childSessionId, {}); // no parentSessionId
223
220
 
224
221
  expect(
225
222
  resolvePermissionForwardingTargetSessionId({
226
223
  hasUI: false,
227
224
  isSubagent: true,
228
- sessionDir,
225
+ sessionId: childSessionId,
229
226
  registry,
230
227
  env: {},
231
228
  }),
@@ -237,7 +234,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
237
234
  resolvePermissionForwardingTargetSessionId({
238
235
  hasUI: false,
239
236
  isSubagent: true,
240
- sessionDir,
237
+ sessionId: childSessionId,
241
238
  env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
242
239
  }),
243
240
  ).toBe("parent-from-env");
@@ -12,10 +12,14 @@ afterEach(() => {
12
12
  vi.restoreAllMocks();
13
13
  });
14
14
 
15
- function makeCtx(sessionDir: string | null): ExtensionContext {
15
+ function makeCtx(
16
+ sessionDir: string | null,
17
+ sessionId: string = "",
18
+ ): ExtensionContext {
16
19
  return {
17
20
  sessionManager: {
18
21
  getSessionDir: vi.fn(() => sessionDir),
22
+ getSessionId: vi.fn(() => sessionId),
19
23
  },
20
24
  } as unknown as ExtensionContext;
21
25
  }
@@ -203,57 +207,75 @@ describe("isSubagentExecutionContext — registry detection", () => {
203
207
  const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
204
208
  const outsideDir =
205
209
  "/home/user/projects/my-app/.pi/agent/sessions/parent/tasks";
210
+ const childSessionId = "child-session-abc";
206
211
 
207
- test("returns true when session dir is registered (no env vars, outside filesystem root)", () => {
212
+ test("returns true when session id is registered (no env vars, dir outside filesystem root)", () => {
208
213
  const registry = new SubagentSessionRegistry();
209
- registry.register(outsideDir, { agentName: "Explore" });
214
+ registry.register(childSessionId, {});
210
215
  expect(
211
- isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
216
+ isSubagentExecutionContext(
217
+ makeCtx(outsideDir, childSessionId),
218
+ subagentRoot,
219
+ registry,
220
+ ),
212
221
  ).toBe(true);
213
222
  });
214
223
 
215
224
  test("returns true when registered session has a parentSessionId", () => {
216
225
  const registry = new SubagentSessionRegistry();
217
- registry.register(outsideDir, {
218
- agentName: "Plan",
219
- parentSessionId: "parent-123",
220
- });
226
+ registry.register(childSessionId, { parentSessionId: "parent-123" });
221
227
  expect(
222
- isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
228
+ isSubagentExecutionContext(
229
+ makeCtx(outsideDir, childSessionId),
230
+ subagentRoot,
231
+ registry,
232
+ ),
223
233
  ).toBe(true);
224
234
  });
225
235
 
226
- test("returns false when registry is provided but session dir is not registered", () => {
236
+ test("returns false when registry is provided but session id is not registered", () => {
227
237
  const registry = new SubagentSessionRegistry();
228
238
  expect(
229
- isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
239
+ isSubagentExecutionContext(
240
+ makeCtx(outsideDir, childSessionId),
241
+ subagentRoot,
242
+ registry,
243
+ ),
230
244
  ).toBe(false);
231
245
  });
232
246
 
233
- test("returns false when session dir is null and registry has no matching entry", () => {
247
+ test("returns false when session id is empty and registry has no matching entry", () => {
234
248
  const registry = new SubagentSessionRegistry();
235
249
  expect(
236
- isSubagentExecutionContext(makeCtx(null), subagentRoot, registry),
250
+ isSubagentExecutionContext(makeCtx(null, ""), subagentRoot, registry),
237
251
  ).toBe(false);
238
252
  });
239
253
 
240
254
  test("registry check takes priority over env var detection", () => {
241
255
  // Registry says registered; env var not set — should still return true.
242
256
  const registry = new SubagentSessionRegistry();
243
- registry.register(outsideDir, { agentName: "Explore" });
257
+ registry.register(childSessionId, {});
244
258
  // Confirm no env var is set
245
259
  expect(process.env.PI_IS_SUBAGENT).toBeUndefined();
246
260
  expect(
247
- isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
261
+ isSubagentExecutionContext(
262
+ makeCtx(outsideDir, childSessionId),
263
+ subagentRoot,
264
+ registry,
265
+ ),
248
266
  ).toBe(true);
249
267
  });
250
268
 
251
269
  test("unregistered session falls through to env var detection", () => {
252
270
  vi.stubEnv("PI_IS_SUBAGENT", "true");
253
- const registry = new SubagentSessionRegistry(); // empty — outsideDir not registered
271
+ const registry = new SubagentSessionRegistry(); // empty — childSessionId not registered
254
272
  // Env var present → still true even without registry entry
255
273
  expect(
256
- isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
274
+ isSubagentExecutionContext(
275
+ makeCtx(outsideDir, childSessionId),
276
+ subagentRoot,
277
+ registry,
278
+ ),
257
279
  ).toBe(true);
258
280
  });
259
281
 
@@ -19,13 +19,11 @@ describe("subscribeSubagentLifecycle", () => {
19
19
  subscribeSubagentLifecycle(bus, registry);
20
20
 
21
21
  bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
22
- sessionDir: "/sessions/child-abc",
23
- agentName: "Explore",
22
+ sessionId: "child-session-abc",
24
23
  parentSessionId: "parent-42",
25
24
  });
26
25
 
27
- expect(registry.get("/sessions/child-abc")).toEqual({
28
- agentName: "Explore",
26
+ expect(registry.get("child-session-abc")).toEqual({
29
27
  parentSessionId: "parent-42",
30
28
  });
31
29
  });
@@ -40,12 +38,11 @@ describe("subscribeSubagentLifecycle", () => {
40
38
  subscribeSubagentLifecycle(bus, registry);
41
39
 
42
40
  bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
43
- sessionDir: "/sessions/child-sync",
44
- agentName: "Explore",
41
+ sessionId: "child-session-sync",
45
42
  });
46
43
 
47
44
  // No await between emit and this assertion.
48
- expect(registry.has("/sessions/child-sync")).toBe(true);
45
+ expect(registry.has("child-session-sync")).toBe(true);
49
46
  });
50
47
 
51
48
  it("omits parentSessionId when the event does not carry one", () => {
@@ -53,12 +50,10 @@ describe("subscribeSubagentLifecycle", () => {
53
50
  subscribeSubagentLifecycle(bus, registry);
54
51
 
55
52
  bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
56
- sessionDir: "/sessions/child-xyz",
57
- agentName: "general-purpose",
53
+ sessionId: "child-session-xyz",
58
54
  });
59
55
 
60
- expect(registry.get("/sessions/child-xyz")).toEqual({
61
- agentName: "general-purpose",
56
+ expect(registry.get("child-session-xyz")).toEqual({
62
57
  parentSessionId: undefined,
63
58
  });
64
59
  });
@@ -66,11 +61,11 @@ describe("subscribeSubagentLifecycle", () => {
66
61
  it("unregisters a child session on disposed", () => {
67
62
  const bus = createEventBus();
68
63
  subscribeSubagentLifecycle(bus, registry);
69
- registry.register("/sessions/child-abc", { agentName: "Explore" });
64
+ registry.register("child-session-abc", { parentSessionId: "parent-42" });
70
65
 
71
- bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionDir: "/sessions/child-abc" });
66
+ bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-session-abc" });
72
67
 
73
- expect(registry.has("/sessions/child-abc")).toBe(false);
68
+ expect(registry.has("child-session-abc")).toBe(false);
74
69
  });
75
70
 
76
71
  it("detaches both handlers when the returned unsubscribe is called", () => {
@@ -80,12 +75,11 @@ describe("subscribeSubagentLifecycle", () => {
80
75
  unsubscribe();
81
76
 
82
77
  bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
83
- sessionDir: "/sessions/child-abc",
84
- agentName: "Explore",
78
+ sessionId: "child-session-abc",
85
79
  });
86
- bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionDir: "/sessions/child-abc" });
80
+ bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-session-abc" });
87
81
 
88
- expect(registry.has("/sessions/child-abc")).toBe(false);
82
+ expect(registry.has("child-session-abc")).toBe(false);
89
83
  });
90
84
 
91
85
  it("subscribes to a fake bus on the exact channel names", () => {
@@ -110,4 +104,29 @@ describe("subscribeSubagentLifecycle", () => {
110
104
  );
111
105
  expect(SUBAGENT_CHILD_DISPOSED).toBe("subagents:child:disposed");
112
106
  });
107
+
108
+ // ── #298 regression: concurrent siblings must be independent ──────────────
109
+
110
+ it("disposing one sibling does not evict the other (collision regression)", () => {
111
+ const bus = createEventBus();
112
+ subscribeSubagentLifecycle(bus, registry);
113
+
114
+ // Two concurrent children of the same parent register under distinct ids.
115
+ bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
116
+ sessionId: "child-A",
117
+ parentSessionId: "parent-P",
118
+ });
119
+ bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
120
+ sessionId: "child-B",
121
+ parentSessionId: "parent-P",
122
+ });
123
+
124
+ // Sibling A finishes first.
125
+ bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-A" });
126
+
127
+ // B must still be detected as a registered subagent.
128
+ expect(registry.has("child-A")).toBe(false);
129
+ expect(registry.has("child-B")).toBe(true);
130
+ expect(registry.get("child-B")?.parentSessionId).toBe("parent-P");
131
+ });
113
132
  });
@@ -1,94 +1,145 @@
1
- import { describe, expect, test } from "vitest";
1
+ import { afterEach, describe, expect, test } from "vitest";
2
2
  import {
3
+ getSubagentSessionRegistry,
3
4
  type SubagentSessionInfo,
4
5
  SubagentSessionRegistry,
5
6
  } from "#src/subagent-registry";
6
7
 
8
+ const REGISTRY_KEY = Symbol.for(
9
+ "@gotgenes/pi-permission-system:subagent-registry",
10
+ );
11
+
7
12
  function makeInfo(
8
13
  overrides: Partial<SubagentSessionInfo> = {},
9
14
  ): SubagentSessionInfo {
10
- return {
11
- agentName: "Explore",
12
- ...overrides,
13
- };
15
+ return { ...overrides };
14
16
  }
15
17
 
16
18
  describe("SubagentSessionRegistry", () => {
17
19
  test("has() returns false for an unregistered key", () => {
18
20
  const registry = new SubagentSessionRegistry();
19
- expect(registry.has("/sessions/task-abc")).toBe(false);
21
+ expect(registry.has("session-abc")).toBe(false);
20
22
  });
21
23
 
22
24
  test("get() returns undefined for an unregistered key", () => {
23
25
  const registry = new SubagentSessionRegistry();
24
- expect(registry.get("/sessions/task-abc")).toBeUndefined();
26
+ expect(registry.get("session-abc")).toBeUndefined();
25
27
  });
26
28
 
27
29
  test("has() returns true after register()", () => {
28
30
  const registry = new SubagentSessionRegistry();
29
- registry.register("/sessions/task-abc", makeInfo());
30
- expect(registry.has("/sessions/task-abc")).toBe(true);
31
+ registry.register("session-abc", makeInfo());
32
+ expect(registry.has("session-abc")).toBe(true);
31
33
  });
32
34
 
33
35
  test("get() returns the registered info after register()", () => {
34
36
  const registry = new SubagentSessionRegistry();
35
37
  const info = makeInfo({ parentSessionId: "parent-123" });
36
- registry.register("/sessions/task-abc", info);
37
- expect(registry.get("/sessions/task-abc")).toEqual(info);
38
+ registry.register("session-abc", info);
39
+ expect(registry.get("session-abc")).toEqual(info);
38
40
  });
39
41
 
40
- test("register() stores agentName without parentSessionId", () => {
42
+ test("register() stores entry without parentSessionId", () => {
41
43
  const registry = new SubagentSessionRegistry();
42
- registry.register("/sessions/task-abc", makeInfo());
43
- expect(registry.get("/sessions/task-abc")).toEqual({
44
- agentName: "Explore",
45
- });
44
+ registry.register("session-abc", makeInfo());
45
+ expect(registry.get("session-abc")).toEqual({});
46
46
  });
47
47
 
48
48
  test("has() returns false after unregister()", () => {
49
49
  const registry = new SubagentSessionRegistry();
50
- registry.register("/sessions/task-abc", makeInfo());
51
- registry.unregister("/sessions/task-abc");
52
- expect(registry.has("/sessions/task-abc")).toBe(false);
50
+ registry.register("session-abc", makeInfo());
51
+ registry.unregister("session-abc");
52
+ expect(registry.has("session-abc")).toBe(false);
53
53
  });
54
54
 
55
55
  test("get() returns undefined after unregister()", () => {
56
56
  const registry = new SubagentSessionRegistry();
57
- registry.register("/sessions/task-abc", makeInfo());
58
- registry.unregister("/sessions/task-abc");
59
- expect(registry.get("/sessions/task-abc")).toBeUndefined();
57
+ registry.register("session-abc", makeInfo());
58
+ registry.unregister("session-abc");
59
+ expect(registry.get("session-abc")).toBeUndefined();
60
60
  });
61
61
 
62
62
  test("unregister() is a no-op for an unknown key", () => {
63
63
  const registry = new SubagentSessionRegistry();
64
- expect(() => registry.unregister("/sessions/nonexistent")).not.toThrow();
64
+ expect(() => registry.unregister("session-nonexistent")).not.toThrow();
65
65
  });
66
66
 
67
67
  test("register() overwrites a previous entry for the same key", () => {
68
+ const registry = new SubagentSessionRegistry();
69
+ registry.register("session-abc", makeInfo({ parentSessionId: "parent-1" }));
70
+ registry.register("session-abc", makeInfo({ parentSessionId: "parent-2" }));
71
+ expect(registry.get("session-abc")?.parentSessionId).toBe("parent-2");
72
+ });
73
+
74
+ // ── #298 regression: concurrent siblings must be independent ──────────────
75
+
76
+ test("two sibling session ids are registered independently", () => {
68
77
  const registry = new SubagentSessionRegistry();
69
78
  registry.register(
70
- "/sessions/task-abc",
71
- makeInfo({ parentSessionId: "parent-1" }),
79
+ "child-session-A",
80
+ makeInfo({ parentSessionId: "parent-P" }),
72
81
  );
73
82
  registry.register(
74
- "/sessions/task-abc",
75
- makeInfo({ parentSessionId: "parent-2" }),
76
- );
77
- expect(registry.get("/sessions/task-abc")?.parentSessionId).toBe(
78
- "parent-2",
83
+ "child-session-B",
84
+ makeInfo({ parentSessionId: "parent-P" }),
79
85
  );
86
+
87
+ expect(registry.has("child-session-A")).toBe(true);
88
+ expect(registry.has("child-session-B")).toBe(true);
80
89
  });
81
90
 
82
- test("multiple keys are independent", () => {
91
+ test("disposing one sibling does not evict the other (collision regression)", () => {
83
92
  const registry = new SubagentSessionRegistry();
84
- registry.register("/sessions/task-1", makeInfo({ agentName: "Explore" }));
85
- registry.register("/sessions/task-2", makeInfo({ agentName: "Plan" }));
93
+ registry.register(
94
+ "child-session-A",
95
+ makeInfo({ parentSessionId: "parent-P" }),
96
+ );
97
+ registry.register(
98
+ "child-session-B",
99
+ makeInfo({ parentSessionId: "parent-P" }),
100
+ );
101
+
102
+ // Sibling A finishes — should not affect B.
103
+ registry.unregister("child-session-A");
86
104
 
87
- expect(registry.get("/sessions/task-1")?.agentName).toBe("Explore");
88
- expect(registry.get("/sessions/task-2")?.agentName).toBe("Plan");
105
+ expect(registry.has("child-session-A")).toBe(false);
106
+ expect(registry.has("child-session-B")).toBe(true);
107
+ expect(registry.get("child-session-B")?.parentSessionId).toBe("parent-P");
108
+ });
109
+ });
110
+
111
+ // ── process-global accessor ────────────────────────────────────────────────
112
+
113
+ describe("getSubagentSessionRegistry (process-global accessor)", () => {
114
+ afterEach(() => {
115
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
116
+ delete (globalThis as Record<symbol, unknown>)[REGISTRY_KEY];
117
+ });
118
+
119
+ test("returns a SubagentSessionRegistry instance", () => {
120
+ const registry = getSubagentSessionRegistry();
121
+ expect(registry).toBeInstanceOf(SubagentSessionRegistry);
122
+ });
123
+
124
+ test("returns the same instance on repeated calls", () => {
125
+ const first = getSubagentSessionRegistry();
126
+ const second = getSubagentSessionRegistry();
127
+ expect(first).toBe(second);
128
+ });
129
+
130
+ test("state registered through one call is visible through another call", () => {
131
+ const writer = getSubagentSessionRegistry();
132
+ writer.register("child-session-xyz", {
133
+ parentSessionId: "parent-abc",
134
+ });
135
+
136
+ const reader = getSubagentSessionRegistry();
137
+ expect(reader.has("child-session-xyz")).toBe(true);
138
+ expect(reader.get("child-session-xyz")?.parentSessionId).toBe("parent-abc");
139
+ });
89
140
 
90
- registry.unregister("/sessions/task-1");
91
- expect(registry.has("/sessions/task-1")).toBe(false);
92
- expect(registry.has("/sessions/task-2")).toBe(true);
141
+ test("starts empty on first call", () => {
142
+ const registry = getSubagentSessionRegistry();
143
+ expect(registry.has("any-session-id")).toBe(false);
93
144
  });
94
145
  });