@komarspn/pi-permission-system 16.0.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 (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,326 @@
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")).toBe(
70
+ "/projects/my-app",
71
+ );
72
+ });
73
+
74
+ test("collapses redundant separators", () => {
75
+ expect(normalizeFilesystemPath("/projects//my-app")).toBe(
76
+ "/projects/my-app",
77
+ );
78
+ });
79
+
80
+ test("resolves . and .. segments", () => {
81
+ expect(normalizeFilesystemPath("/projects/my-app/../other")).toBe(
82
+ "/projects/other",
83
+ );
84
+ });
85
+ });
86
+
87
+ describe("isSubagentExecutionContext — env hint detection", () => {
88
+ test("returns true when PI_IS_SUBAGENT is set", () => {
89
+ vi.stubEnv("PI_IS_SUBAGENT", "true");
90
+ expect(
91
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
92
+ ).toBe(true);
93
+ });
94
+
95
+ test("returns true when PI_SUBAGENT_SESSION_ID is set", () => {
96
+ vi.stubEnv("PI_SUBAGENT_SESSION_ID", "abc123");
97
+ expect(
98
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
99
+ ).toBe(true);
100
+ });
101
+
102
+ test("returns true when PI_AGENT_ROUTER_SUBAGENT is set", () => {
103
+ vi.stubEnv("PI_AGENT_ROUTER_SUBAGENT", "1");
104
+ expect(
105
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
106
+ ).toBe(true);
107
+ });
108
+
109
+ // nicobailon/pi-subagents keys
110
+ test("returns true when PI_SUBAGENT_CHILD is set", () => {
111
+ vi.stubEnv("PI_SUBAGENT_CHILD", "1");
112
+ expect(
113
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
114
+ ).toBe(true);
115
+ });
116
+
117
+ test("returns true when PI_SUBAGENT_RUN_ID is set", () => {
118
+ vi.stubEnv("PI_SUBAGENT_RUN_ID", "run-abc");
119
+ expect(
120
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
121
+ ).toBe(true);
122
+ });
123
+
124
+ test("returns true when PI_SUBAGENT_CHILD_AGENT is set", () => {
125
+ vi.stubEnv("PI_SUBAGENT_CHILD_AGENT", "worker");
126
+ expect(
127
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
128
+ ).toBe(true);
129
+ });
130
+
131
+ test("returns true when PI_SUBAGENT_DEPTH is set", () => {
132
+ vi.stubEnv("PI_SUBAGENT_DEPTH", "1");
133
+ expect(
134
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
135
+ ).toBe(true);
136
+ });
137
+
138
+ test("returns true when PI_SUBAGENT_DEPTH is zero (depth-0 is still a subagent context)", () => {
139
+ vi.stubEnv("PI_SUBAGENT_DEPTH", "0");
140
+ expect(
141
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
142
+ ).toBe(true);
143
+ });
144
+
145
+ // HazAT/pi-interactive-subagents keys
146
+ test("returns true when PI_SUBAGENT_NAME is set", () => {
147
+ vi.stubEnv("PI_SUBAGENT_NAME", "my-agent");
148
+ expect(
149
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
150
+ ).toBe(true);
151
+ });
152
+
153
+ test("returns true when PI_SUBAGENT_ID is set", () => {
154
+ vi.stubEnv("PI_SUBAGENT_ID", "id-xyz");
155
+ expect(
156
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
157
+ ).toBe(true);
158
+ });
159
+
160
+ test("returns true when PI_SUBAGENT_SESSION is set", () => {
161
+ vi.stubEnv("PI_SUBAGENT_SESSION", "session-xyz");
162
+ expect(
163
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
164
+ ).toBe(true);
165
+ });
166
+
167
+ test("returns true when PI_SUBAGENT_ACTIVITY_FILE is set", () => {
168
+ vi.stubEnv("PI_SUBAGENT_ACTIVITY_FILE", "/tmp/activity.json");
169
+ expect(
170
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
171
+ ).toBe(true);
172
+ });
173
+
174
+ test("covers all declared SUBAGENT_ENV_HINT_KEYS", () => {
175
+ // Verify the keys we test match what the module declares.
176
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_IS_SUBAGENT");
177
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION_ID");
178
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_AGENT_ROUTER_SUBAGENT");
179
+ // nicobailon/pi-subagents
180
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD");
181
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_RUN_ID");
182
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD_AGENT");
183
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_DEPTH");
184
+ // HazAT/pi-interactive-subagents
185
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_NAME");
186
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ID");
187
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION");
188
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ACTIVITY_FILE");
189
+ });
190
+
191
+ test("returns false when env hint value is empty string", () => {
192
+ vi.stubEnv("PI_IS_SUBAGENT", "");
193
+ expect(
194
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
195
+ ).toBe(false);
196
+ });
197
+
198
+ test("returns false when env hint value is whitespace only", () => {
199
+ vi.stubEnv("PI_IS_SUBAGENT", " ");
200
+ expect(
201
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
202
+ ).toBe(false);
203
+ });
204
+ });
205
+
206
+ describe("isSubagentExecutionContext — session dir detection", () => {
207
+ const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
208
+
209
+ test("returns true when session dir is within subagent root", () => {
210
+ const sessionDir = `${subagentRoot}/session-abc`;
211
+ expect(isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot)).toBe(
212
+ true,
213
+ );
214
+ });
215
+
216
+ test("returns true when session dir equals subagent root", () => {
217
+ expect(
218
+ isSubagentExecutionContext(makeCtx(subagentRoot), subagentRoot),
219
+ ).toBe(true);
220
+ });
221
+
222
+ test("returns false when session dir is outside subagent root", () => {
223
+ const sessionDir = "/home/user/.pi/agent/sessions/main-session";
224
+ expect(isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot)).toBe(
225
+ false,
226
+ );
227
+ });
228
+
229
+ test("returns false when session dir is a sibling with shared prefix", () => {
230
+ // "/sessions/subagents-extra" should not match root "/sessions/subagents"
231
+ const sessionDir = `${subagentRoot}-extra/session-abc`;
232
+ expect(isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot)).toBe(
233
+ false,
234
+ );
235
+ });
236
+
237
+ test("returns false when getSessionDir returns null", () => {
238
+ expect(isSubagentExecutionContext(makeCtx(null), subagentRoot)).toBe(false);
239
+ });
240
+
241
+ test("returns false when getSessionDir returns empty string", () => {
242
+ expect(isSubagentExecutionContext(makeCtx(""), subagentRoot)).toBe(false);
243
+ });
244
+ });
245
+
246
+ describe("isSubagentExecutionContext — registry detection", () => {
247
+ const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
248
+ const outsideDir =
249
+ "/home/user/projects/my-app/.pi/agent/sessions/parent/tasks";
250
+ const childSessionId = "child-session-abc";
251
+
252
+ test("returns true when session id is registered (no env vars, dir outside filesystem root)", () => {
253
+ const registry = new SubagentSessionRegistry();
254
+ registry.register(childSessionId, {});
255
+ expect(
256
+ isSubagentExecutionContext(
257
+ makeCtx(outsideDir, childSessionId),
258
+ subagentRoot,
259
+ registry,
260
+ ),
261
+ ).toBe(true);
262
+ });
263
+
264
+ test("returns true when registered session has a parentSessionId", () => {
265
+ const registry = new SubagentSessionRegistry();
266
+ registry.register(childSessionId, { parentSessionId: "parent-123" });
267
+ expect(
268
+ isSubagentExecutionContext(
269
+ makeCtx(outsideDir, childSessionId),
270
+ subagentRoot,
271
+ registry,
272
+ ),
273
+ ).toBe(true);
274
+ });
275
+
276
+ test("returns false when registry is provided but session id is not registered", () => {
277
+ const registry = new SubagentSessionRegistry();
278
+ expect(
279
+ isSubagentExecutionContext(
280
+ makeCtx(outsideDir, childSessionId),
281
+ subagentRoot,
282
+ registry,
283
+ ),
284
+ ).toBe(false);
285
+ });
286
+
287
+ test("returns false when session id is empty and registry has no matching entry", () => {
288
+ const registry = new SubagentSessionRegistry();
289
+ expect(
290
+ isSubagentExecutionContext(makeCtx(null, ""), subagentRoot, registry),
291
+ ).toBe(false);
292
+ });
293
+
294
+ test("registry check takes priority over env var detection", () => {
295
+ // Registry says registered; env var not set — should still return true.
296
+ const registry = new SubagentSessionRegistry();
297
+ registry.register(childSessionId, {});
298
+ // Confirm no env var is set
299
+ expect(process.env.PI_IS_SUBAGENT).toBeUndefined();
300
+ expect(
301
+ isSubagentExecutionContext(
302
+ makeCtx(outsideDir, childSessionId),
303
+ subagentRoot,
304
+ registry,
305
+ ),
306
+ ).toBe(true);
307
+ });
308
+
309
+ test("unregistered session falls through to env var detection", () => {
310
+ vi.stubEnv("PI_IS_SUBAGENT", "true");
311
+ const registry = new SubagentSessionRegistry(); // empty — childSessionId not registered
312
+ // Env var present → still true even without registry entry
313
+ expect(
314
+ isSubagentExecutionContext(
315
+ makeCtx(outsideDir, childSessionId),
316
+ subagentRoot,
317
+ registry,
318
+ ),
319
+ ).toBe(true);
320
+ });
321
+
322
+ test("no registry passed — existing behaviour unchanged", () => {
323
+ // Ensure the parameter is truly optional (no registry arg)
324
+ expect(isSubagentExecutionContext(makeCtx(null), subagentRoot)).toBe(false);
325
+ });
326
+ });
@@ -0,0 +1,132 @@
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
+ });
@@ -0,0 +1,145 @@
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
+ });