@gotgenes/pi-permission-system 18.1.1 → 18.1.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.
Files changed (122) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/configuration.md +11 -0
  3. package/package.json +1 -2
  4. package/src/access-intent/access-path.ts +29 -4
  5. package/src/access-intent/bash/bash-path-resolver.ts +39 -18
  6. package/src/access-intent/bash/msys-bash-tokens.ts +64 -0
  7. package/src/path-normalizer.ts +105 -2
  8. package/test/access-intent/access-path.test.ts +0 -277
  9. package/test/access-intent/bash/node-text.test.ts +0 -148
  10. package/test/access-intent/bash/parser.test.ts +0 -19
  11. package/test/access-intent/bash/program.test.ts +0 -673
  12. package/test/access-intent/bash/token-classification.test.ts +0 -363
  13. package/test/access-intent/bash/token-collection.test.ts +0 -300
  14. package/test/active-agent.test.ts +0 -155
  15. package/test/async-cache.test.ts +0 -48
  16. package/test/bash-arity.test.ts +0 -144
  17. package/test/bash-external-directory.test.ts +0 -1022
  18. package/test/builtin-tool-input-formatters.test.ts +0 -109
  19. package/test/canonicalize-path.test.ts +0 -119
  20. package/test/composition-root.test.ts +0 -698
  21. package/test/config-loader.test.ts +0 -740
  22. package/test/config-modal.test.ts +0 -320
  23. package/test/config-paths.test.ts +0 -83
  24. package/test/config-pipeline.test.ts +0 -90
  25. package/test/config-reporter.test.ts +0 -147
  26. package/test/config-store.test.ts +0 -466
  27. package/test/decision-audit.test.ts +0 -72
  28. package/test/decision-reporter.test.ts +0 -112
  29. package/test/denial-messages.test.ts +0 -714
  30. package/test/detect-permissive-bash-fallback.test.ts +0 -56
  31. package/test/expand-home.test.ts +0 -93
  32. package/test/extension-config.test.ts +0 -129
  33. package/test/extension-paths.test.ts +0 -108
  34. package/test/forwarded-permissions/io.test.ts +0 -251
  35. package/test/forwarding-manager.test.ts +0 -200
  36. package/test/handlers/before-agent-start.test.ts +0 -314
  37. package/test/handlers/external-directory-integration.test.ts +0 -515
  38. package/test/handlers/external-directory-session-dedup.test.ts +0 -175
  39. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
  40. package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
  41. package/test/handlers/gates/bash-command.test.ts +0 -257
  42. package/test/handlers/gates/bash-external-directory.test.ts +0 -268
  43. package/test/handlers/gates/bash-path.test.ts +0 -346
  44. package/test/handlers/gates/candidate-check.test.ts +0 -52
  45. package/test/handlers/gates/external-directory-messages.test.ts +0 -85
  46. package/test/handlers/gates/external-directory-policy.test.ts +0 -134
  47. package/test/handlers/gates/external-directory.test.ts +0 -267
  48. package/test/handlers/gates/helpers.test.ts +0 -165
  49. package/test/handlers/gates/path.test.ts +0 -369
  50. package/test/handlers/gates/runner.test.ts +0 -408
  51. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
  52. package/test/handlers/gates/skill-input.test.ts +0 -128
  53. package/test/handlers/gates/skill-read.test.ts +0 -161
  54. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
  55. package/test/handlers/gates/tool.test.ts +0 -244
  56. package/test/handlers/input-events.test.ts +0 -168
  57. package/test/handlers/input.test.ts +0 -199
  58. package/test/handlers/lifecycle.test.ts +0 -221
  59. package/test/handlers/tool-call-boundary.test.ts +0 -145
  60. package/test/handlers/tool-call-events.test.ts +0 -277
  61. package/test/handlers/tool-call.test.ts +0 -395
  62. package/test/handlers/validate-requested-tool.test.ts +0 -92
  63. package/test/helpers/external-directory-fixtures.ts +0 -269
  64. package/test/helpers/gate-fixtures.ts +0 -316
  65. package/test/helpers/handler-fixtures.ts +0 -335
  66. package/test/helpers/make-fake-pi.ts +0 -100
  67. package/test/helpers/manager-harness.ts +0 -112
  68. package/test/helpers/session-fixtures.ts +0 -199
  69. package/test/input-normalizer.test.ts +0 -325
  70. package/test/logging.test.ts +0 -51
  71. package/test/mcp-targets.test.ts +0 -233
  72. package/test/node-modules-discovery.test.ts +0 -97
  73. package/test/normalize.test.ts +0 -247
  74. package/test/path-containment.test.ts +0 -161
  75. package/test/path-normalization.test.ts +0 -233
  76. package/test/path-normalizer.test.ts +0 -196
  77. package/test/path-surfaces.test.ts +0 -55
  78. package/test/pattern-suggest.test.ts +0 -248
  79. package/test/permission-dialog.test.ts +0 -205
  80. package/test/permission-event-rpc.test.ts +0 -560
  81. package/test/permission-events.test.ts +0 -400
  82. package/test/permission-forwarder.test.ts +0 -370
  83. package/test/permission-forwarding.test.ts +0 -315
  84. package/test/permission-gate.test.ts +0 -269
  85. package/test/permission-manager-unified.test.ts +0 -3714
  86. package/test/permission-merge.test.ts +0 -61
  87. package/test/permission-prompter.test.ts +0 -518
  88. package/test/permission-prompts.test.ts +0 -363
  89. package/test/permission-resolver.test.ts +0 -277
  90. package/test/permission-session.test.ts +0 -404
  91. package/test/permission-ui-prompt.test.ts +0 -146
  92. package/test/permissions-service.test.ts +0 -192
  93. package/test/pi-infrastructure-read.test.ts +0 -432
  94. package/test/policy-loader.test.ts +0 -561
  95. package/test/prompting-gateway.test.ts +0 -231
  96. package/test/rule.test.ts +0 -650
  97. package/test/safe-system-paths.test.ts +0 -46
  98. package/test/scope-merge.test.ts +0 -116
  99. package/test/service-lifecycle.test.ts +0 -163
  100. package/test/service.test.ts +0 -261
  101. package/test/session-approval.test.ts +0 -75
  102. package/test/session-logger.test.ts +0 -200
  103. package/test/session-rules.test.ts +0 -321
  104. package/test/session-start.test.ts +0 -112
  105. package/test/skill-prompt-sanitizer.test.ts +0 -418
  106. package/test/status.test.ts +0 -10
  107. package/test/subagent-context.test.ts +0 -372
  108. package/test/subagent-lifecycle-events.test.ts +0 -132
  109. package/test/subagent-registry.test.ts +0 -145
  110. package/test/synthesize.test.ts +0 -302
  111. package/test/system-prompt-sanitizer.test.ts +0 -382
  112. package/test/tool-access-extractor-registry.test.ts +0 -77
  113. package/test/tool-input-formatter-registry.test.ts +0 -75
  114. package/test/tool-input-path.test.ts +0 -84
  115. package/test/tool-input-preview.test.ts +0 -129
  116. package/test/tool-input-prompt-formatters.test.ts +0 -115
  117. package/test/tool-preview-formatter.test.ts +0 -458
  118. package/test/tool-registry.test.ts +0 -197
  119. package/test/value-guards.test.ts +0 -193
  120. package/test/wildcard-matcher.test.ts +0 -424
  121. package/test/yaml-frontmatter.test.ts +0 -91
  122. package/test/yolo-mode.test.ts +0 -188
@@ -1,372 +0,0 @@
1
- import { afterEach, describe, expect, test, vi } from "vitest";
2
- import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
3
- import {
4
- isRegisteredSubagentChild,
5
- isSubagentExecutionContext,
6
- normalizeFilesystemPath,
7
- type SubagentDetectionContext,
8
- } from "#src/subagent-context";
9
- import { SubagentSessionRegistry } from "#src/subagent-registry";
10
-
11
- afterEach(() => {
12
- vi.unstubAllEnvs();
13
- vi.restoreAllMocks();
14
- });
15
-
16
- function makeCtx(
17
- sessionDir: string | null,
18
- sessionId: string = "",
19
- ): SubagentDetectionContext {
20
- return {
21
- sessionManager: {
22
- getSessionDir: vi.fn(() => sessionDir ?? ""),
23
- getSessionId: vi.fn(() => sessionId),
24
- },
25
- };
26
- }
27
-
28
- describe("isRegisteredSubagentChild", () => {
29
- const childSessionId = "child-session-abc";
30
-
31
- test("returns true when the session id is registered", () => {
32
- const registry = new SubagentSessionRegistry();
33
- registry.register(childSessionId, {});
34
- expect(
35
- isRegisteredSubagentChild(makeCtx(null, childSessionId), registry),
36
- ).toBe(true);
37
- });
38
-
39
- test("returns false when the session id is not registered", () => {
40
- const registry = new SubagentSessionRegistry();
41
- expect(
42
- isRegisteredSubagentChild(makeCtx(null, childSessionId), registry),
43
- ).toBe(false);
44
- });
45
-
46
- test("returns false when the session id is empty", () => {
47
- const registry = new SubagentSessionRegistry();
48
- registry.register("", {});
49
- expect(isRegisteredSubagentChild(makeCtx(null, ""), registry)).toBe(false);
50
- });
51
-
52
- test("returns false when getSessionId throws", () => {
53
- const registry = new SubagentSessionRegistry();
54
- registry.register(childSessionId, {});
55
- const ctx: SubagentDetectionContext = {
56
- sessionManager: {
57
- getSessionDir: vi.fn(() => ""),
58
- getSessionId: vi.fn(() => {
59
- throw new Error("session id unavailable");
60
- }),
61
- },
62
- };
63
- expect(isRegisteredSubagentChild(ctx, registry)).toBe(false);
64
- });
65
- });
66
-
67
- describe("normalizeFilesystemPath", () => {
68
- test("normalizes a simple absolute path", () => {
69
- expect(normalizeFilesystemPath("/projects/my-app", "linux")).toBe(
70
- "/projects/my-app",
71
- );
72
- });
73
-
74
- test("collapses redundant separators", () => {
75
- expect(normalizeFilesystemPath("/projects//my-app", "linux")).toBe(
76
- "/projects/my-app",
77
- );
78
- });
79
-
80
- test("resolves . and .. segments", () => {
81
- expect(normalizeFilesystemPath("/projects/my-app/../other", "linux")).toBe(
82
- "/projects/other",
83
- );
84
- });
85
-
86
- test("win32: lowercases and normalizes with win32 separators", () => {
87
- expect(normalizeFilesystemPath("C:\\Projects\\My-App", "win32")).toBe(
88
- "c:\\projects\\my-app",
89
- );
90
- });
91
-
92
- test("posix: leaves case untouched", () => {
93
- expect(normalizeFilesystemPath("/Projects/My-App", "linux")).toBe(
94
- "/Projects/My-App",
95
- );
96
- });
97
- });
98
-
99
- describe("isSubagentExecutionContext — injected platform (#510)", () => {
100
- test("win32: detects a subagent session dir case-insensitively", () => {
101
- const subagentRoot = "C:\\Sessions\\Subagents";
102
- const sessionDir = "c:\\sessions\\subagents\\child";
103
- expect(
104
- isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot, "win32"),
105
- ).toBe(true);
106
- });
107
-
108
- test("posix: the same mixed-case dir is not a subagent context", () => {
109
- const subagentRoot = "/Sessions/Subagents";
110
- const sessionDir = "/sessions/subagents/child";
111
- expect(
112
- isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot, "linux"),
113
- ).toBe(false);
114
- });
115
- });
116
-
117
- describe("isSubagentExecutionContext — env hint detection", () => {
118
- test("returns true when PI_IS_SUBAGENT is set", () => {
119
- vi.stubEnv("PI_IS_SUBAGENT", "true");
120
- expect(
121
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
122
- ).toBe(true);
123
- });
124
-
125
- test("returns true when PI_SUBAGENT_SESSION_ID is set", () => {
126
- vi.stubEnv("PI_SUBAGENT_SESSION_ID", "abc123");
127
- expect(
128
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
129
- ).toBe(true);
130
- });
131
-
132
- test("returns true when PI_AGENT_ROUTER_SUBAGENT is set", () => {
133
- vi.stubEnv("PI_AGENT_ROUTER_SUBAGENT", "1");
134
- expect(
135
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
136
- ).toBe(true);
137
- });
138
-
139
- // nicobailon/pi-subagents keys
140
- test("returns true when PI_SUBAGENT_CHILD is set", () => {
141
- vi.stubEnv("PI_SUBAGENT_CHILD", "1");
142
- expect(
143
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
144
- ).toBe(true);
145
- });
146
-
147
- test("returns true when PI_SUBAGENT_RUN_ID is set", () => {
148
- vi.stubEnv("PI_SUBAGENT_RUN_ID", "run-abc");
149
- expect(
150
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
151
- ).toBe(true);
152
- });
153
-
154
- test("returns true when PI_SUBAGENT_CHILD_AGENT is set", () => {
155
- vi.stubEnv("PI_SUBAGENT_CHILD_AGENT", "worker");
156
- expect(
157
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
158
- ).toBe(true);
159
- });
160
-
161
- test("returns true when PI_SUBAGENT_DEPTH is set", () => {
162
- vi.stubEnv("PI_SUBAGENT_DEPTH", "1");
163
- expect(
164
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
165
- ).toBe(true);
166
- });
167
-
168
- test("returns true when PI_SUBAGENT_DEPTH is zero (depth-0 is still a subagent context)", () => {
169
- vi.stubEnv("PI_SUBAGENT_DEPTH", "0");
170
- expect(
171
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
172
- ).toBe(true);
173
- });
174
-
175
- // HazAT/pi-interactive-subagents keys
176
- test("returns true when PI_SUBAGENT_NAME is set", () => {
177
- vi.stubEnv("PI_SUBAGENT_NAME", "my-agent");
178
- expect(
179
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
180
- ).toBe(true);
181
- });
182
-
183
- test("returns true when PI_SUBAGENT_ID is set", () => {
184
- vi.stubEnv("PI_SUBAGENT_ID", "id-xyz");
185
- expect(
186
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
187
- ).toBe(true);
188
- });
189
-
190
- test("returns true when PI_SUBAGENT_SESSION is set", () => {
191
- vi.stubEnv("PI_SUBAGENT_SESSION", "session-xyz");
192
- expect(
193
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
194
- ).toBe(true);
195
- });
196
-
197
- test("returns true when PI_SUBAGENT_ACTIVITY_FILE is set", () => {
198
- vi.stubEnv("PI_SUBAGENT_ACTIVITY_FILE", "/tmp/activity.json");
199
- expect(
200
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
201
- ).toBe(true);
202
- });
203
-
204
- test("covers all declared SUBAGENT_ENV_HINT_KEYS", () => {
205
- // Verify the keys we test match what the module declares.
206
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_IS_SUBAGENT");
207
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION_ID");
208
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_AGENT_ROUTER_SUBAGENT");
209
- // nicobailon/pi-subagents
210
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD");
211
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_RUN_ID");
212
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD_AGENT");
213
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_DEPTH");
214
- // HazAT/pi-interactive-subagents
215
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_NAME");
216
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ID");
217
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION");
218
- expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ACTIVITY_FILE");
219
- });
220
-
221
- test("returns false when env hint value is empty string", () => {
222
- vi.stubEnv("PI_IS_SUBAGENT", "");
223
- expect(
224
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
225
- ).toBe(false);
226
- });
227
-
228
- test("returns false when env hint value is whitespace only", () => {
229
- vi.stubEnv("PI_IS_SUBAGENT", " ");
230
- expect(
231
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
232
- ).toBe(false);
233
- });
234
- });
235
-
236
- describe("isSubagentExecutionContext — session dir detection", () => {
237
- const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
238
-
239
- test("returns true when session dir is within subagent root", () => {
240
- const sessionDir = `${subagentRoot}/session-abc`;
241
- expect(
242
- isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot, "linux"),
243
- ).toBe(true);
244
- });
245
-
246
- test("returns true when session dir equals subagent root", () => {
247
- expect(
248
- isSubagentExecutionContext(makeCtx(subagentRoot), subagentRoot, "linux"),
249
- ).toBe(true);
250
- });
251
-
252
- test("returns false when session dir is outside subagent root", () => {
253
- const sessionDir = "/home/user/.pi/agent/sessions/main-session";
254
- expect(
255
- isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot, "linux"),
256
- ).toBe(false);
257
- });
258
-
259
- test("returns false when session dir is a sibling with shared prefix", () => {
260
- // "/sessions/subagents-extra" should not match root "/sessions/subagents"
261
- const sessionDir = `${subagentRoot}-extra/session-abc`;
262
- expect(
263
- isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot, "linux"),
264
- ).toBe(false);
265
- });
266
-
267
- test("returns false when getSessionDir returns null", () => {
268
- expect(
269
- isSubagentExecutionContext(makeCtx(null), subagentRoot, "linux"),
270
- ).toBe(false);
271
- });
272
-
273
- test("returns false when getSessionDir returns empty string", () => {
274
- expect(isSubagentExecutionContext(makeCtx(""), subagentRoot, "linux")).toBe(
275
- false,
276
- );
277
- });
278
- });
279
-
280
- describe("isSubagentExecutionContext — registry detection", () => {
281
- const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
282
- const outsideDir =
283
- "/home/user/projects/my-app/.pi/agent/sessions/parent/tasks";
284
- const childSessionId = "child-session-abc";
285
-
286
- test("returns true when session id is registered (no env vars, dir outside filesystem root)", () => {
287
- const registry = new SubagentSessionRegistry();
288
- registry.register(childSessionId, {});
289
- expect(
290
- isSubagentExecutionContext(
291
- makeCtx(outsideDir, childSessionId),
292
- subagentRoot,
293
- "linux",
294
- registry,
295
- ),
296
- ).toBe(true);
297
- });
298
-
299
- test("returns true when registered session has a parentSessionId", () => {
300
- const registry = new SubagentSessionRegistry();
301
- registry.register(childSessionId, { parentSessionId: "parent-123" });
302
- expect(
303
- isSubagentExecutionContext(
304
- makeCtx(outsideDir, childSessionId),
305
- subagentRoot,
306
- "linux",
307
- registry,
308
- ),
309
- ).toBe(true);
310
- });
311
-
312
- test("returns false when registry is provided but session id is not registered", () => {
313
- const registry = new SubagentSessionRegistry();
314
- expect(
315
- isSubagentExecutionContext(
316
- makeCtx(outsideDir, childSessionId),
317
- subagentRoot,
318
- "linux",
319
- registry,
320
- ),
321
- ).toBe(false);
322
- });
323
-
324
- test("returns false when session id is empty and registry has no matching entry", () => {
325
- const registry = new SubagentSessionRegistry();
326
- expect(
327
- isSubagentExecutionContext(
328
- makeCtx(null, ""),
329
- subagentRoot,
330
- "linux",
331
- registry,
332
- ),
333
- ).toBe(false);
334
- });
335
-
336
- test("registry check takes priority over env var detection", () => {
337
- // Registry says registered; env var not set — should still return true.
338
- const registry = new SubagentSessionRegistry();
339
- registry.register(childSessionId, {});
340
- // Confirm no env var is set
341
- expect(process.env.PI_IS_SUBAGENT).toBeUndefined();
342
- expect(
343
- isSubagentExecutionContext(
344
- makeCtx(outsideDir, childSessionId),
345
- subagentRoot,
346
- "linux",
347
- registry,
348
- ),
349
- ).toBe(true);
350
- });
351
-
352
- test("unregistered session falls through to env var detection", () => {
353
- vi.stubEnv("PI_IS_SUBAGENT", "true");
354
- const registry = new SubagentSessionRegistry(); // empty — childSessionId not registered
355
- // Env var present → still true even without registry entry
356
- expect(
357
- isSubagentExecutionContext(
358
- makeCtx(outsideDir, childSessionId),
359
- subagentRoot,
360
- "linux",
361
- registry,
362
- ),
363
- ).toBe(true);
364
- });
365
-
366
- test("no registry passed — existing behaviour unchanged", () => {
367
- // Ensure the parameter is truly optional (no registry arg)
368
- expect(
369
- isSubagentExecutionContext(makeCtx(null), subagentRoot, "linux"),
370
- ).toBe(false);
371
- });
372
- });
@@ -1,132 +0,0 @@
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
- sessionId: "child-session-abc",
23
- parentSessionId: "parent-42",
24
- });
25
-
26
- expect(registry.get("child-session-abc")).toEqual({
27
- parentSessionId: "parent-42",
28
- });
29
- });
30
-
31
- it("populates the registry synchronously — before emit() returns", () => {
32
- // Guards the pre-bindExtensions ordering: the core emits session-created
33
- // on the same synchronous call stack right before bindExtensions(), so the
34
- // handler must complete before emit() returns. A real EventEmitter-backed
35
- // bus dispatches synchronously; this fails loudly if the handler ever
36
- // becomes async (awaiting before registry.register).
37
- const bus = createEventBus();
38
- subscribeSubagentLifecycle(bus, registry);
39
-
40
- bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
41
- sessionId: "child-session-sync",
42
- });
43
-
44
- // No await between emit and this assertion.
45
- expect(registry.has("child-session-sync")).toBe(true);
46
- });
47
-
48
- it("omits parentSessionId when the event does not carry one", () => {
49
- const bus = createEventBus();
50
- subscribeSubagentLifecycle(bus, registry);
51
-
52
- bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
53
- sessionId: "child-session-xyz",
54
- });
55
-
56
- expect(registry.get("child-session-xyz")).toEqual({
57
- parentSessionId: undefined,
58
- });
59
- });
60
-
61
- it("unregisters a child session on disposed", () => {
62
- const bus = createEventBus();
63
- subscribeSubagentLifecycle(bus, registry);
64
- registry.register("child-session-abc", { parentSessionId: "parent-42" });
65
-
66
- bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-session-abc" });
67
-
68
- expect(registry.has("child-session-abc")).toBe(false);
69
- });
70
-
71
- it("detaches both handlers when the returned unsubscribe is called", () => {
72
- const bus = createEventBus();
73
- const unsubscribe = subscribeSubagentLifecycle(bus, registry);
74
-
75
- unsubscribe();
76
-
77
- bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
78
- sessionId: "child-session-abc",
79
- });
80
- bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-session-abc" });
81
-
82
- expect(registry.has("child-session-abc")).toBe(false);
83
- });
84
-
85
- it("subscribes to a fake bus on the exact channel names", () => {
86
- const handlers = new Map<string, (data: unknown) => void>();
87
- const bus = {
88
- on: vi.fn((channel: string, handler: (data: unknown) => void) => {
89
- handlers.set(channel, handler);
90
- return () => handlers.delete(channel);
91
- }),
92
- };
93
-
94
- subscribeSubagentLifecycle(bus, registry);
95
-
96
- expect(bus.on).toHaveBeenCalledTimes(2);
97
- expect(handlers.has("subagents:child:session-created")).toBe(true);
98
- expect(handlers.has("subagents:child:disposed")).toBe(true);
99
- });
100
-
101
- it("exposes the canonical channel-name strings", () => {
102
- expect(SUBAGENT_CHILD_SESSION_CREATED).toBe(
103
- "subagents:child:session-created",
104
- );
105
- expect(SUBAGENT_CHILD_DISPOSED).toBe("subagents:child:disposed");
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
- });
132
- });
@@ -1,145 +0,0 @@
1
- import { afterEach, describe, expect, test } from "vitest";
2
- import {
3
- getSubagentSessionRegistry,
4
- type SubagentSessionInfo,
5
- SubagentSessionRegistry,
6
- } from "#src/subagent-registry";
7
-
8
- const REGISTRY_KEY = Symbol.for(
9
- "@gotgenes/pi-permission-system:subagent-registry",
10
- );
11
-
12
- function makeInfo(
13
- overrides: Partial<SubagentSessionInfo> = {},
14
- ): SubagentSessionInfo {
15
- return { ...overrides };
16
- }
17
-
18
- describe("SubagentSessionRegistry", () => {
19
- test("has() returns false for an unregistered key", () => {
20
- const registry = new SubagentSessionRegistry();
21
- expect(registry.has("session-abc")).toBe(false);
22
- });
23
-
24
- test("get() returns undefined for an unregistered key", () => {
25
- const registry = new SubagentSessionRegistry();
26
- expect(registry.get("session-abc")).toBeUndefined();
27
- });
28
-
29
- test("has() returns true after register()", () => {
30
- const registry = new SubagentSessionRegistry();
31
- registry.register("session-abc", makeInfo());
32
- expect(registry.has("session-abc")).toBe(true);
33
- });
34
-
35
- test("get() returns the registered info after register()", () => {
36
- const registry = new SubagentSessionRegistry();
37
- const info = makeInfo({ parentSessionId: "parent-123" });
38
- registry.register("session-abc", info);
39
- expect(registry.get("session-abc")).toEqual(info);
40
- });
41
-
42
- test("register() stores entry without parentSessionId", () => {
43
- const registry = new SubagentSessionRegistry();
44
- registry.register("session-abc", makeInfo());
45
- expect(registry.get("session-abc")).toEqual({});
46
- });
47
-
48
- test("has() returns false after unregister()", () => {
49
- const registry = new SubagentSessionRegistry();
50
- registry.register("session-abc", makeInfo());
51
- registry.unregister("session-abc");
52
- expect(registry.has("session-abc")).toBe(false);
53
- });
54
-
55
- test("get() returns undefined after unregister()", () => {
56
- const registry = new SubagentSessionRegistry();
57
- registry.register("session-abc", makeInfo());
58
- registry.unregister("session-abc");
59
- expect(registry.get("session-abc")).toBeUndefined();
60
- });
61
-
62
- test("unregister() is a no-op for an unknown key", () => {
63
- const registry = new SubagentSessionRegistry();
64
- expect(() => registry.unregister("session-nonexistent")).not.toThrow();
65
- });
66
-
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", () => {
77
- const registry = new SubagentSessionRegistry();
78
- registry.register(
79
- "child-session-A",
80
- makeInfo({ parentSessionId: "parent-P" }),
81
- );
82
- registry.register(
83
- "child-session-B",
84
- makeInfo({ parentSessionId: "parent-P" }),
85
- );
86
-
87
- expect(registry.has("child-session-A")).toBe(true);
88
- expect(registry.has("child-session-B")).toBe(true);
89
- });
90
-
91
- test("disposing one sibling does not evict the other (collision regression)", () => {
92
- const registry = new SubagentSessionRegistry();
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");
104
-
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
- });
140
-
141
- test("starts empty on first call", () => {
142
- const registry = getSubagentSessionRegistry();
143
- expect(registry.has("any-session-id")).toBe(false);
144
- });
145
- });