@gotgenes/pi-permission-system 18.1.1 → 18.2.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +25 -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/extension-config.ts +7 -0
  8. package/src/forwarded-permissions/permission-forwarder.ts +6 -3
  9. package/src/handlers/gates/helpers.ts +1 -1
  10. package/src/handlers/gates/runner.ts +22 -0
  11. package/src/index.ts +10 -6
  12. package/src/path-normalizer.ts +105 -2
  13. package/src/permission-manager.ts +21 -1
  14. package/src/permission-prompter.ts +10 -16
  15. package/src/prompting-gateway.ts +13 -17
  16. package/src/rule.ts +21 -1
  17. package/src/status.ts +1 -1
  18. package/src/yolo-mode.ts +0 -30
  19. package/test/access-intent/access-path.test.ts +0 -277
  20. package/test/access-intent/bash/node-text.test.ts +0 -148
  21. package/test/access-intent/bash/parser.test.ts +0 -19
  22. package/test/access-intent/bash/program.test.ts +0 -673
  23. package/test/access-intent/bash/token-classification.test.ts +0 -363
  24. package/test/access-intent/bash/token-collection.test.ts +0 -300
  25. package/test/active-agent.test.ts +0 -155
  26. package/test/async-cache.test.ts +0 -48
  27. package/test/bash-arity.test.ts +0 -144
  28. package/test/bash-external-directory.test.ts +0 -1022
  29. package/test/builtin-tool-input-formatters.test.ts +0 -109
  30. package/test/canonicalize-path.test.ts +0 -119
  31. package/test/composition-root.test.ts +0 -698
  32. package/test/config-loader.test.ts +0 -740
  33. package/test/config-modal.test.ts +0 -320
  34. package/test/config-paths.test.ts +0 -83
  35. package/test/config-pipeline.test.ts +0 -90
  36. package/test/config-reporter.test.ts +0 -147
  37. package/test/config-store.test.ts +0 -466
  38. package/test/decision-audit.test.ts +0 -72
  39. package/test/decision-reporter.test.ts +0 -112
  40. package/test/denial-messages.test.ts +0 -714
  41. package/test/detect-permissive-bash-fallback.test.ts +0 -56
  42. package/test/expand-home.test.ts +0 -93
  43. package/test/extension-config.test.ts +0 -129
  44. package/test/extension-paths.test.ts +0 -108
  45. package/test/forwarded-permissions/io.test.ts +0 -251
  46. package/test/forwarding-manager.test.ts +0 -200
  47. package/test/handlers/before-agent-start.test.ts +0 -314
  48. package/test/handlers/external-directory-integration.test.ts +0 -515
  49. package/test/handlers/external-directory-session-dedup.test.ts +0 -175
  50. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
  51. package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
  52. package/test/handlers/gates/bash-command.test.ts +0 -257
  53. package/test/handlers/gates/bash-external-directory.test.ts +0 -268
  54. package/test/handlers/gates/bash-path.test.ts +0 -346
  55. package/test/handlers/gates/candidate-check.test.ts +0 -52
  56. package/test/handlers/gates/external-directory-messages.test.ts +0 -85
  57. package/test/handlers/gates/external-directory-policy.test.ts +0 -134
  58. package/test/handlers/gates/external-directory.test.ts +0 -267
  59. package/test/handlers/gates/helpers.test.ts +0 -165
  60. package/test/handlers/gates/path.test.ts +0 -369
  61. package/test/handlers/gates/runner.test.ts +0 -408
  62. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
  63. package/test/handlers/gates/skill-input.test.ts +0 -128
  64. package/test/handlers/gates/skill-read.test.ts +0 -161
  65. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
  66. package/test/handlers/gates/tool.test.ts +0 -244
  67. package/test/handlers/input-events.test.ts +0 -168
  68. package/test/handlers/input.test.ts +0 -199
  69. package/test/handlers/lifecycle.test.ts +0 -221
  70. package/test/handlers/tool-call-boundary.test.ts +0 -145
  71. package/test/handlers/tool-call-events.test.ts +0 -277
  72. package/test/handlers/tool-call.test.ts +0 -395
  73. package/test/handlers/validate-requested-tool.test.ts +0 -92
  74. package/test/helpers/external-directory-fixtures.ts +0 -269
  75. package/test/helpers/gate-fixtures.ts +0 -316
  76. package/test/helpers/handler-fixtures.ts +0 -335
  77. package/test/helpers/make-fake-pi.ts +0 -100
  78. package/test/helpers/manager-harness.ts +0 -112
  79. package/test/helpers/session-fixtures.ts +0 -199
  80. package/test/input-normalizer.test.ts +0 -325
  81. package/test/logging.test.ts +0 -51
  82. package/test/mcp-targets.test.ts +0 -233
  83. package/test/node-modules-discovery.test.ts +0 -97
  84. package/test/normalize.test.ts +0 -247
  85. package/test/path-containment.test.ts +0 -161
  86. package/test/path-normalization.test.ts +0 -233
  87. package/test/path-normalizer.test.ts +0 -196
  88. package/test/path-surfaces.test.ts +0 -55
  89. package/test/pattern-suggest.test.ts +0 -248
  90. package/test/permission-dialog.test.ts +0 -205
  91. package/test/permission-event-rpc.test.ts +0 -560
  92. package/test/permission-events.test.ts +0 -400
  93. package/test/permission-forwarder.test.ts +0 -370
  94. package/test/permission-forwarding.test.ts +0 -315
  95. package/test/permission-gate.test.ts +0 -269
  96. package/test/permission-manager-unified.test.ts +0 -3714
  97. package/test/permission-merge.test.ts +0 -61
  98. package/test/permission-prompter.test.ts +0 -518
  99. package/test/permission-prompts.test.ts +0 -363
  100. package/test/permission-resolver.test.ts +0 -277
  101. package/test/permission-session.test.ts +0 -404
  102. package/test/permission-ui-prompt.test.ts +0 -146
  103. package/test/permissions-service.test.ts +0 -192
  104. package/test/pi-infrastructure-read.test.ts +0 -432
  105. package/test/policy-loader.test.ts +0 -561
  106. package/test/prompting-gateway.test.ts +0 -231
  107. package/test/rule.test.ts +0 -650
  108. package/test/safe-system-paths.test.ts +0 -46
  109. package/test/scope-merge.test.ts +0 -116
  110. package/test/service-lifecycle.test.ts +0 -163
  111. package/test/service.test.ts +0 -261
  112. package/test/session-approval.test.ts +0 -75
  113. package/test/session-logger.test.ts +0 -200
  114. package/test/session-rules.test.ts +0 -321
  115. package/test/session-start.test.ts +0 -112
  116. package/test/skill-prompt-sanitizer.test.ts +0 -418
  117. package/test/status.test.ts +0 -10
  118. package/test/subagent-context.test.ts +0 -372
  119. package/test/subagent-lifecycle-events.test.ts +0 -132
  120. package/test/subagent-registry.test.ts +0 -145
  121. package/test/synthesize.test.ts +0 -302
  122. package/test/system-prompt-sanitizer.test.ts +0 -382
  123. package/test/tool-access-extractor-registry.test.ts +0 -77
  124. package/test/tool-input-formatter-registry.test.ts +0 -75
  125. package/test/tool-input-path.test.ts +0 -84
  126. package/test/tool-input-preview.test.ts +0 -129
  127. package/test/tool-input-prompt-formatters.test.ts +0 -115
  128. package/test/tool-preview-formatter.test.ts +0 -458
  129. package/test/tool-registry.test.ts +0 -197
  130. package/test/value-guards.test.ts +0 -193
  131. package/test/wildcard-matcher.test.ts +0 -424
  132. package/test/yaml-frontmatter.test.ts +0 -91
  133. 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
- });