@gotgenes/pi-permission-system 7.2.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [7.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.2.0...pi-permission-system-v7.3.0) (2026-05-25)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** add SubagentSessionRegistry class ([a0ef16b](https://github.com/gotgenes/pi-packages/commit/a0ef16b8302f95b30cc11cb121441dbd164c276c))
14
+ * **pi-permission-system:** detect in-process subagents via session registry ([c90b824](https://github.com/gotgenes/pi-packages/commit/c90b824b4515a1d5ca259348ae0b60c7d70f29d4))
15
+ * **pi-permission-system:** expose registry and getToolPermission on PermissionsService ([984d2bb](https://github.com/gotgenes/pi-packages/commit/984d2bbb76f08cea91b5c0117eb356ae576ad6be))
16
+ * **pi-permission-system:** resolve forwarding target from subagent registry ([5eb15af](https://github.com/gotgenes/pi-packages/commit/5eb15afe680bfd36627c2c21165b59a0ea5e227c))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **pi-permission-system:** document subagent session registry API ([93c5c3e](https://github.com/gotgenes/pi-packages/commit/93c5c3e72b2b757a99eba17d1c6885ea49271403))
22
+ * **pi-permission-system:** update architecture for subagent registry ([7b32e6a](https://github.com/gotgenes/pi-packages/commit/7b32e6a247e789b927e5cb3f19a367db0c110353))
23
+ * plan subagent session registry and tool-level permission query ([#221](https://github.com/gotgenes/pi-packages/issues/221)) ([a11d91a](https://github.com/gotgenes/pi-packages/commit/a11d91aa1e13e846030deb0af37444c44eeda7c8))
24
+ * **retro:** add planning stage notes for issue [#221](https://github.com/gotgenes/pi-packages/issues/221) ([cf434c2](https://github.com/gotgenes/pi-packages/commit/cf434c2f9711f26290a4635aea519f1f56e98cc7))
25
+ * **retro:** add TDD stage notes for issue [#221](https://github.com/gotgenes/pi-packages/issues/221) ([e050898](https://github.com/gotgenes/pi-packages/commit/e05089840ee6bbb07cbeab5c55367e2dcd304866))
26
+
8
27
  ## [7.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.1.4...pi-permission-system-v7.2.0) (2026-05-24)
9
28
 
10
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,6 +21,7 @@ import {
21
21
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
22
22
  } from "#src/permission-forwarding";
23
23
  import { isSubagentExecutionContext } from "#src/subagent-context";
24
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
24
25
 
25
26
  import {
26
27
  cleanupPermissionForwardingLocationIfEmpty,
@@ -40,6 +41,8 @@ import {
40
41
  export interface PermissionForwardingDeps {
41
42
  forwardingDir: string;
42
43
  subagentSessionsDir: string;
44
+ /** In-process subagent session registry for detection and forwarding target resolution. */
45
+ registry?: SubagentSessionRegistry;
43
46
  logger: ForwardedPermissionLogger;
44
47
  writeReviewLog: (event: string, details: Record<string, unknown>) => void;
45
48
  requestPermissionDecisionFromUi: (
@@ -102,11 +105,18 @@ export async function waitForForwardedPermissionApproval(
102
105
  deps: PermissionForwardingDeps,
103
106
  ): Promise<PermissionPromptDecision> {
104
107
  const requesterSessionId = getSessionId(ctx);
108
+ const sessionDir = ctx.sessionManager.getSessionDir();
105
109
  const targetSessionId = resolvePermissionForwardingTargetSessionId({
106
110
  hasUI: ctx.hasUI,
107
- isSubagent: isSubagentExecutionContext(ctx, deps.subagentSessionsDir),
111
+ isSubagent: isSubagentExecutionContext(
112
+ ctx,
113
+ deps.subagentSessionsDir,
114
+ deps.registry,
115
+ ),
108
116
  currentSessionId: requesterSessionId,
109
117
  env: process.env,
118
+ sessionDir,
119
+ registry: deps.registry,
110
120
  });
111
121
 
112
122
  if (!targetSessionId) {
@@ -360,7 +370,9 @@ export async function confirmPermission(
360
370
  );
361
371
  }
362
372
 
363
- if (!isSubagentExecutionContext(ctx, deps.subagentSessionsDir)) {
373
+ if (
374
+ !isSubagentExecutionContext(ctx, deps.subagentSessionsDir, deps.registry)
375
+ ) {
364
376
  return { approved: false, state: "denied" };
365
377
  }
366
378
 
@@ -4,6 +4,7 @@ import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
4
4
  import { processForwardedPermissionRequests } from "./forwarded-permissions/polling";
5
5
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
6
6
  import { isSubagentExecutionContext } from "./subagent-context";
7
+ import type { SubagentSessionRegistry } from "./subagent-registry";
7
8
 
8
9
  /**
9
10
  * Narrow interface for the forwarding lifecycle used by `PermissionSession`.
@@ -30,6 +31,7 @@ export class ForwardingManager {
30
31
  constructor(
31
32
  private readonly subagentSessionsDir: string,
32
33
  private readonly forwardingDeps: PermissionForwardingDeps,
34
+ private readonly registry?: SubagentSessionRegistry,
33
35
  ) {}
34
36
 
35
37
  /**
@@ -41,7 +43,7 @@ export class ForwardingManager {
41
43
  start(ctx: ExtensionContext): void {
42
44
  if (
43
45
  !ctx.hasUI ||
44
- isSubagentExecutionContext(ctx, this.subagentSessionsDir)
46
+ isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
45
47
  ) {
46
48
  this.stop();
47
49
  return;
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  } from "./service";
28
28
  import { createSessionLogger } from "./session-logger";
29
29
  import { isSubagentExecutionContext } from "./subagent-context";
30
+ import { SubagentSessionRegistry } from "./subagent-registry";
30
31
  import {
31
32
  canResolveAskPermissionRequest,
32
33
  shouldAutoApprovePermissionState,
@@ -34,18 +35,21 @@ import {
34
35
 
35
36
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
36
37
  const runtime = createExtensionRuntime();
38
+ const subagentRegistry = new SubagentSessionRegistry();
37
39
 
38
40
  const prompter = new PermissionPrompter({
39
41
  getConfig: () => runtime.config,
40
42
  writeReviewLog: runtime.writeReviewLog.bind(runtime),
41
43
  subagentSessionsDir: runtime.subagentSessionsDir,
42
44
  forwardingDir: runtime.forwardingDir,
45
+ registry: subagentRegistry,
43
46
  requestPermissionDecisionFromUi,
44
47
  });
45
48
 
46
49
  const forwardingDeps: PermissionForwardingDeps = {
47
50
  forwardingDir: runtime.forwardingDir,
48
51
  subagentSessionsDir: runtime.subagentSessionsDir,
52
+ registry: subagentRegistry,
49
53
  logger: {
50
54
  writeReviewLog: runtime.writeReviewLog.bind(runtime),
51
55
  writeDebugLog: runtime.writeDebugLog.bind(runtime),
@@ -61,7 +65,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
61
65
  const session = new PermissionSession(
62
66
  runtime,
63
67
  createSessionLogger(runtime),
64
- new ForwardingManager(runtime.subagentSessionsDir, forwardingDeps),
68
+ new ForwardingManager(
69
+ runtime.subagentSessionsDir,
70
+ forwardingDeps,
71
+ subagentRegistry,
72
+ ),
65
73
  {
66
74
  refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
67
75
  logResolvedConfigPaths: () => logResolvedConfigPaths(runtime),
@@ -73,6 +81,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
73
81
  isSubagent: isSubagentExecutionContext(
74
82
  ctx,
75
83
  runtime.subagentSessionsDir,
84
+ subagentRegistry,
76
85
  ),
77
86
  }),
78
87
  promptPermission: (ctx, details) => prompter.prompt(ctx, details),
@@ -108,6 +117,15 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
108
117
  sessionRules,
109
118
  );
110
119
  },
120
+ registerSubagentSession(sessionKey, info) {
121
+ subagentRegistry.register(sessionKey, info);
122
+ },
123
+ unregisterSubagentSession(sessionKey) {
124
+ subagentRegistry.unregister(sessionKey);
125
+ },
126
+ getToolPermission(toolName, agentName) {
127
+ return runtime.permissionManager.getToolPermission(toolName, agentName);
128
+ },
111
129
  };
112
130
  publishPermissionsService(permissionsService);
113
131
 
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
 
3
3
  import type { PermissionDecisionState } from "./permission-dialog";
4
+ import type { SubagentSessionRegistry } from "./subagent-registry";
4
5
 
5
6
  export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
6
7
  export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
@@ -118,6 +119,10 @@ export function resolvePermissionForwardingTargetSessionId(options: {
118
119
  isSubagent: boolean;
119
120
  currentSessionId?: string | null;
120
121
  env?: NodeJS.ProcessEnv;
122
+ /** Session directory key for registry lookup. */
123
+ sessionDir?: string;
124
+ /** In-process subagent session registry (checked before env vars). */
125
+ registry?: SubagentSessionRegistry;
121
126
  }): string | null {
122
127
  if (options.hasUI) {
123
128
  return normalizePermissionForwardingSessionId(options.currentSessionId);
@@ -127,6 +132,16 @@ export function resolvePermissionForwardingTargetSessionId(options: {
127
132
  return null;
128
133
  }
129
134
 
135
+ // 1. Registry — in-process subagents register parentSessionId explicitly.
136
+ if (options.registry && options.sessionDir) {
137
+ const entry = options.registry.get(options.sessionDir);
138
+ const resolved = normalizePermissionForwardingSessionId(
139
+ entry?.parentSessionId,
140
+ );
141
+ if (resolved) return resolved;
142
+ }
143
+
144
+ // 2. Env vars — process-based subagent extensions.
130
145
  const env = options.env ?? process.env;
131
146
  for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
132
147
  const resolved = normalizePermissionForwardingSessionId(env[key]);
@@ -9,6 +9,7 @@ import type {
9
9
  PermissionPromptDecision,
10
10
  RequestPermissionOptions,
11
11
  } from "./permission-dialog";
12
+ import type { SubagentSessionRegistry } from "./subagent-registry";
12
13
  import { shouldAutoApprovePermissionState } from "./yolo-mode";
13
14
 
14
15
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
@@ -54,6 +55,8 @@ export interface PermissionPrompterDeps {
54
55
  subagentSessionsDir: string;
55
56
  /** Directory used for file-based permission forwarding requests/responses. */
56
57
  forwardingDir: string;
58
+ /** In-process subagent session registry for detection and forwarding target resolution. */
59
+ registry?: SubagentSessionRegistry;
57
60
  /** Show the interactive permission dialog in the UI. */
58
61
  requestPermissionDecisionFromUi(
59
62
  ui: ExtensionContext["ui"],
@@ -155,6 +158,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
155
158
  return {
156
159
  forwardingDir: deps.forwardingDir,
157
160
  subagentSessionsDir: deps.subagentSessionsDir,
161
+ registry: deps.registry,
158
162
  logger,
159
163
  // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
160
164
  writeReviewLog: deps.writeReviewLog,
package/src/service.ts CHANGED
@@ -11,9 +11,10 @@
11
11
  * reference — this ensures resilience across `/reload` and load-order edge cases.
12
12
  */
13
13
 
14
+ import type { SubagentSessionInfo } from "./subagent-registry";
14
15
  import type { PermissionCheckResult, PermissionState } from "./types";
15
16
 
16
- export type { PermissionCheckResult, PermissionState };
17
+ export type { PermissionCheckResult, PermissionState, SubagentSessionInfo };
17
18
 
18
19
  /** Process-global key for the service slot. */
19
20
  const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
@@ -42,6 +43,40 @@ export interface PermissionsService {
42
43
  value?: string,
43
44
  agentName?: string,
44
45
  ): PermissionCheckResult;
46
+
47
+ /**
48
+ * Register an in-process subagent session.
49
+ *
50
+ * Call this before `bindExtensions()` so that `isSubagentExecutionContext()`
51
+ * and permission-forwarding target resolution can detect the child session.
52
+ * Always pair with `unregisterSubagentSession()` in a `finally` block.
53
+ *
54
+ * @param sessionKey - Unique session identifier (use the session directory path).
55
+ * @param info - Agent name and optional parent session ID.
56
+ */
57
+ registerSubagentSession(sessionKey: string, info: SubagentSessionInfo): void;
58
+
59
+ /**
60
+ * Remove a previously registered in-process subagent session.
61
+ *
62
+ * Safe to call even if `registerSubagentSession` was never called for this key.
63
+ *
64
+ * @param sessionKey - The same key passed to `registerSubagentSession`.
65
+ */
66
+ unregisterSubagentSession(sessionKey: string): void;
67
+
68
+ /**
69
+ * Query the tool-level permission state for pre-filtering tools before
70
+ * creating a child session.
71
+ *
72
+ * Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
73
+ * Does not consider command-level rules (e.g. per-bash-command patterns) —
74
+ * use `checkPermission` for runtime invocation gates.
75
+ *
76
+ * @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
77
+ * @param agentName - Optional agent name for per-agent policy resolution.
78
+ */
79
+ getToolPermission(toolName: string, agentName?: string): PermissionState;
45
80
  }
46
81
 
47
82
  /**
@@ -2,6 +2,7 @@ import { normalize } from "node:path";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding";
5
+ import type { SubagentSessionRegistry } from "./subagent-registry";
5
6
 
6
7
  export function normalizeFilesystemPath(pathValue: string): string {
7
8
  const normalizedPath = normalize(pathValue);
@@ -30,7 +31,18 @@ function isPathWithinDirectoryForSubagent(
30
31
  export function isSubagentExecutionContext(
31
32
  ctx: ExtensionContext,
32
33
  subagentSessionsDir: string,
34
+ registry?: SubagentSessionRegistry,
33
35
  ): boolean {
36
+ const sessionDir = ctx.sessionManager.getSessionDir();
37
+
38
+ // 1. Explicit registry — in-process subagent extensions register before
39
+ // bindExtensions(); checked first so it takes priority over heuristics.
40
+ if (registry && sessionDir && registry.has(sessionDir)) {
41
+ return true;
42
+ }
43
+
44
+ // 2. Env vars — process-based subagent extensions (nicobailon/pi-subagents,
45
+ // HazAT/pi-interactive-subagents, pi-agent-router, etc.).
34
46
  for (const key of SUBAGENT_ENV_HINT_KEYS) {
35
47
  const value = process.env[key];
36
48
  if (typeof value === "string" && value.trim()) {
@@ -38,7 +50,8 @@ export function isSubagentExecutionContext(
38
50
  }
39
51
  }
40
52
 
41
- const sessionDir = ctx.sessionManager.getSessionDir();
53
+ // 3. Filesystem path — fallback heuristic for extensions that store sessions
54
+ // under a known subagent root directory.
42
55
  if (!sessionDir) {
43
56
  return false;
44
57
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * subagent-registry.ts — In-process subagent session registry.
3
+ *
4
+ * In-process subagent extensions (e.g. `@gotgenes/pi-subagents`) register
5
+ * each child session here before calling `bindExtensions()` so that
6
+ * `isSubagentExecutionContext()` and permission-forwarding target resolution
7
+ * can detect them without relying on environment variables or filesystem
8
+ * heuristics.
9
+ *
10
+ * The registry is keyed by session directory path, which is unique per
11
+ * session and available to both producer and consumer via
12
+ * `ctx.sessionManager.getSessionDir()`.
13
+ */
14
+
15
+ /** Signal stored per registered in-process subagent session. */
16
+ export interface SubagentSessionInfo {
17
+ /** Parent session ID for permission forwarding. Omit when unknown. */
18
+ parentSessionId?: string;
19
+ /** Agent name for per-agent policy resolution. */
20
+ agentName: string;
21
+ }
22
+
23
+ /**
24
+ * Registry of active in-process subagent sessions.
25
+ *
26
+ * Owned by `ExtensionRuntime`; exposed to external callers through the
27
+ * `PermissionsService` interface (`registerSubagentSession` /
28
+ * `unregisterSubagentSession`).
29
+ *
30
+ * Concurrent background agents are safe because each session has a unique
31
+ * directory path as its key — no scalar global flag is needed.
32
+ */
33
+ export class SubagentSessionRegistry {
34
+ private readonly sessions = new Map<string, SubagentSessionInfo>();
35
+
36
+ /**
37
+ * Register an in-process subagent session.
38
+ *
39
+ * If a previous entry exists for `sessionKey`, it is overwritten
40
+ * (last-write-wins; single-writer expected per key).
41
+ */
42
+ register(sessionKey: string, info: SubagentSessionInfo): void {
43
+ this.sessions.set(sessionKey, info);
44
+ }
45
+
46
+ /** Remove a previously registered session. No-op if the key is absent. */
47
+ unregister(sessionKey: string): void {
48
+ this.sessions.delete(sessionKey);
49
+ }
50
+
51
+ /** Return the registered info for `sessionKey`, or `undefined` if absent. */
52
+ get(sessionKey: string): SubagentSessionInfo | undefined {
53
+ return this.sessions.get(sessionKey);
54
+ }
55
+
56
+ /** Return `true` when `sessionKey` has a registered entry. */
57
+ has(sessionKey: string): boolean {
58
+ return this.sessions.has(sessionKey);
59
+ }
60
+ }
@@ -205,6 +205,7 @@ describe("ForwardingManager", () => {
205
205
  expect(mockIsSubagentExecutionContext).toHaveBeenCalledWith(
206
206
  ctx,
207
207
  "/custom/subagent-dir",
208
+ undefined,
208
209
  );
209
210
  });
210
211
  });
@@ -4,6 +4,7 @@ import {
4
4
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
5
5
  SUBAGENT_PARENT_SESSION_ENV_KEY,
6
6
  } from "#src/permission-forwarding";
7
+ import { SubagentSessionRegistry } from "#src/subagent-registry";
7
8
 
8
9
  afterEach(() => {
9
10
  vi.unstubAllEnvs();
@@ -146,3 +147,99 @@ describe("resolvePermissionForwardingTargetSessionId", () => {
146
147
  ).toBe("env-session-abc");
147
148
  });
148
149
  });
150
+
151
+ describe("resolvePermissionForwardingTargetSessionId — registry resolution", () => {
152
+ const sessionDir =
153
+ "/home/user/projects/.pi/sessions/parent/tasks/session-abc";
154
+
155
+ test("returns parentSessionId from registry when env vars are absent", () => {
156
+ const registry = new SubagentSessionRegistry();
157
+ registry.register(sessionDir, {
158
+ agentName: "Explore",
159
+ parentSessionId: "parent-from-registry",
160
+ });
161
+
162
+ expect(
163
+ resolvePermissionForwardingTargetSessionId({
164
+ hasUI: false,
165
+ isSubagent: true,
166
+ sessionDir,
167
+ registry,
168
+ env: {},
169
+ }),
170
+ ).toBe("parent-from-registry");
171
+ });
172
+
173
+ test("registry takes priority over env vars", () => {
174
+ const registry = new SubagentSessionRegistry();
175
+ registry.register(sessionDir, {
176
+ agentName: "Explore",
177
+ parentSessionId: "parent-from-registry",
178
+ });
179
+
180
+ expect(
181
+ resolvePermissionForwardingTargetSessionId({
182
+ hasUI: false,
183
+ isSubagent: true,
184
+ sessionDir,
185
+ registry,
186
+ env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
187
+ }),
188
+ ).toBe("parent-from-registry");
189
+ });
190
+
191
+ test("falls through to env vars when registry entry has no parentSessionId", () => {
192
+ const registry = new SubagentSessionRegistry();
193
+ registry.register(sessionDir, { agentName: "Explore" }); // no parentSessionId
194
+
195
+ expect(
196
+ resolvePermissionForwardingTargetSessionId({
197
+ hasUI: false,
198
+ isSubagent: true,
199
+ sessionDir,
200
+ registry,
201
+ env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
202
+ }),
203
+ ).toBe("parent-from-env");
204
+ });
205
+
206
+ test("falls through to env vars when sessionDir is not in registry", () => {
207
+ const registry = new SubagentSessionRegistry(); // empty
208
+
209
+ expect(
210
+ resolvePermissionForwardingTargetSessionId({
211
+ hasUI: false,
212
+ isSubagent: true,
213
+ sessionDir,
214
+ registry,
215
+ env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
216
+ }),
217
+ ).toBe("parent-from-env");
218
+ });
219
+
220
+ test("returns null when registry entry has no parentSessionId and no env vars set", () => {
221
+ const registry = new SubagentSessionRegistry();
222
+ registry.register(sessionDir, { agentName: "Explore" }); // no parentSessionId
223
+
224
+ expect(
225
+ resolvePermissionForwardingTargetSessionId({
226
+ hasUI: false,
227
+ isSubagent: true,
228
+ sessionDir,
229
+ registry,
230
+ env: {},
231
+ }),
232
+ ).toBeNull();
233
+ });
234
+
235
+ test("omitting registry preserves existing behaviour", () => {
236
+ expect(
237
+ resolvePermissionForwardingTargetSessionId({
238
+ hasUI: false,
239
+ isSubagent: true,
240
+ sessionDir,
241
+ env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
242
+ }),
243
+ ).toBe("parent-from-env");
244
+ });
245
+ });
@@ -6,6 +6,7 @@ import {
6
6
  publishPermissionsService,
7
7
  unpublishPermissionsService,
8
8
  } from "#src/service";
9
+ import { SubagentSessionRegistry } from "#src/subagent-registry";
9
10
  import type { PermissionCheckResult } from "#src/types";
10
11
 
11
12
  // ── helpers ────────────────────────────────────────────────────────────────
@@ -15,6 +16,9 @@ function makeService(
15
16
  ): PermissionsService {
16
17
  return {
17
18
  checkPermission: vi.fn(),
19
+ registerSubagentSession: vi.fn(),
20
+ unregisterSubagentSession: vi.fn(),
21
+ getToolPermission: vi.fn(),
18
22
  ...overrides,
19
23
  };
20
24
  }
@@ -85,12 +89,12 @@ describe("service adapter delegation", () => {
85
89
  ];
86
90
 
87
91
  // Build the adapter the same way index.ts will
88
- const service: PermissionsService = {
92
+ const service = makeService({
89
93
  checkPermission(surface, value, agentName) {
90
94
  const input = buildInputForSurface(surface, value);
91
95
  return checkPermission(surface, input, agentName, sessionRules);
92
96
  },
93
- };
97
+ });
94
98
 
95
99
  publishPermissionsService(service);
96
100
  const retrieved = getPermissionsService()!;
@@ -108,12 +112,12 @@ describe("service adapter delegation", () => {
108
112
  it("checkPermission passes agentName through", () => {
109
113
  const checkPermission = vi.fn().mockReturnValue(fakeResult);
110
114
 
111
- const service: PermissionsService = {
115
+ const service = makeService({
112
116
  checkPermission(surface, value, agentName) {
113
117
  const input = buildInputForSurface(surface, value);
114
118
  return checkPermission(surface, input, agentName, []);
115
119
  },
116
- };
120
+ });
117
121
 
118
122
  publishPermissionsService(service);
119
123
  getPermissionsService()!.checkPermission("skill", "my-skill", "Explore");
@@ -126,15 +130,105 @@ describe("service adapter delegation", () => {
126
130
  );
127
131
  });
128
132
 
133
+ it("registerSubagentSession delegates to the registry", () => {
134
+ const registry = new SubagentSessionRegistry();
135
+ const service: PermissionsService = {
136
+ checkPermission: vi.fn(),
137
+ registerSubagentSession(key, info) {
138
+ registry.register(key, info);
139
+ },
140
+ unregisterSubagentSession(key) {
141
+ registry.unregister(key);
142
+ },
143
+ getToolPermission: vi.fn((): "allow" => "allow"),
144
+ };
145
+
146
+ publishPermissionsService(service);
147
+ getPermissionsService()!.registerSubagentSession("/sessions/task-1", {
148
+ agentName: "Explore",
149
+ parentSessionId: "parent-abc",
150
+ });
151
+
152
+ expect(registry.has("/sessions/task-1")).toBe(true);
153
+ expect(registry.get("/sessions/task-1")).toEqual({
154
+ agentName: "Explore",
155
+ parentSessionId: "parent-abc",
156
+ });
157
+ });
158
+
159
+ it("unregisterSubagentSession delegates to the registry", () => {
160
+ const registry = new SubagentSessionRegistry();
161
+ const service: PermissionsService = {
162
+ checkPermission: vi.fn(),
163
+ registerSubagentSession(key, info) {
164
+ registry.register(key, info);
165
+ },
166
+ unregisterSubagentSession(key) {
167
+ registry.unregister(key);
168
+ },
169
+ getToolPermission: vi.fn((): "allow" => "allow"),
170
+ };
171
+
172
+ publishPermissionsService(service);
173
+ const svc = getPermissionsService()!;
174
+ svc.registerSubagentSession("/sessions/task-1", { agentName: "Explore" });
175
+ svc.unregisterSubagentSession("/sessions/task-1");
176
+
177
+ expect(registry.has("/sessions/task-1")).toBe(false);
178
+ });
179
+
180
+ it("getToolPermission delegates to the permission manager", () => {
181
+ const getToolPermissionFn = vi.fn(
182
+ (_t: string, _a?: string): "deny" => "deny",
183
+ );
184
+ const service: PermissionsService = {
185
+ checkPermission: vi.fn(),
186
+ registerSubagentSession: vi.fn(),
187
+ unregisterSubagentSession: vi.fn(),
188
+ getToolPermission(toolName, agentName) {
189
+ return getToolPermissionFn(toolName, agentName);
190
+ },
191
+ };
192
+
193
+ publishPermissionsService(service);
194
+ const result = getPermissionsService()!.getToolPermission(
195
+ "bash",
196
+ "Explore",
197
+ );
198
+
199
+ expect(result).toBe("deny");
200
+ expect(getToolPermissionFn).toHaveBeenCalledWith("bash", "Explore");
201
+ });
202
+
203
+ it("getToolPermission works without agentName", () => {
204
+ const getToolPermissionFn = vi.fn(
205
+ (_t: string, _a?: string): "ask" => "ask",
206
+ );
207
+ const service: PermissionsService = {
208
+ checkPermission: vi.fn(),
209
+ registerSubagentSession: vi.fn(),
210
+ unregisterSubagentSession: vi.fn(),
211
+ getToolPermission(toolName, agentName) {
212
+ return getToolPermissionFn(toolName, agentName);
213
+ },
214
+ };
215
+
216
+ publishPermissionsService(service);
217
+ const result = getPermissionsService()!.getToolPermission("write");
218
+
219
+ expect(result).toBe("ask");
220
+ expect(getToolPermissionFn).toHaveBeenCalledWith("write", undefined);
221
+ });
222
+
129
223
  it("checkPermission uses empty object for unknown surfaces", () => {
130
224
  const checkPermission = vi.fn().mockReturnValue(fakeResult);
131
225
 
132
- const service: PermissionsService = {
226
+ const service = makeService({
133
227
  checkPermission(surface, value, agentName) {
134
228
  const input = buildInputForSurface(surface, value);
135
229
  return checkPermission(surface, input, agentName, []);
136
230
  },
137
- };
231
+ });
138
232
 
139
233
  publishPermissionsService(service);
140
234
  getPermissionsService()!.checkPermission("read", "/tmp/file");
@@ -5,6 +5,7 @@ import {
5
5
  isSubagentExecutionContext,
6
6
  normalizeFilesystemPath,
7
7
  } from "#src/subagent-context";
8
+ import { SubagentSessionRegistry } from "#src/subagent-registry";
8
9
 
9
10
  afterEach(() => {
10
11
  vi.unstubAllEnvs();
@@ -197,3 +198,67 @@ describe("isSubagentExecutionContext — session dir detection", () => {
197
198
  expect(isSubagentExecutionContext(makeCtx(""), subagentRoot)).toBe(false);
198
199
  });
199
200
  });
201
+
202
+ describe("isSubagentExecutionContext — registry detection", () => {
203
+ const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
204
+ const outsideDir =
205
+ "/home/user/projects/my-app/.pi/agent/sessions/parent/tasks";
206
+
207
+ test("returns true when session dir is registered (no env vars, outside filesystem root)", () => {
208
+ const registry = new SubagentSessionRegistry();
209
+ registry.register(outsideDir, { agentName: "Explore" });
210
+ expect(
211
+ isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
212
+ ).toBe(true);
213
+ });
214
+
215
+ test("returns true when registered session has a parentSessionId", () => {
216
+ const registry = new SubagentSessionRegistry();
217
+ registry.register(outsideDir, {
218
+ agentName: "Plan",
219
+ parentSessionId: "parent-123",
220
+ });
221
+ expect(
222
+ isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
223
+ ).toBe(true);
224
+ });
225
+
226
+ test("returns false when registry is provided but session dir is not registered", () => {
227
+ const registry = new SubagentSessionRegistry();
228
+ expect(
229
+ isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
230
+ ).toBe(false);
231
+ });
232
+
233
+ test("returns false when session dir is null and registry has no matching entry", () => {
234
+ const registry = new SubagentSessionRegistry();
235
+ expect(
236
+ isSubagentExecutionContext(makeCtx(null), subagentRoot, registry),
237
+ ).toBe(false);
238
+ });
239
+
240
+ test("registry check takes priority over env var detection", () => {
241
+ // Registry says registered; env var not set — should still return true.
242
+ const registry = new SubagentSessionRegistry();
243
+ registry.register(outsideDir, { agentName: "Explore" });
244
+ // Confirm no env var is set
245
+ expect(process.env.PI_IS_SUBAGENT).toBeUndefined();
246
+ expect(
247
+ isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
248
+ ).toBe(true);
249
+ });
250
+
251
+ test("unregistered session falls through to env var detection", () => {
252
+ vi.stubEnv("PI_IS_SUBAGENT", "true");
253
+ const registry = new SubagentSessionRegistry(); // empty — outsideDir not registered
254
+ // Env var present → still true even without registry entry
255
+ expect(
256
+ isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
257
+ ).toBe(true);
258
+ });
259
+
260
+ test("no registry passed — existing behaviour unchanged", () => {
261
+ // Ensure the parameter is truly optional (no registry arg)
262
+ expect(isSubagentExecutionContext(makeCtx(null), subagentRoot)).toBe(false);
263
+ });
264
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ type SubagentSessionInfo,
4
+ SubagentSessionRegistry,
5
+ } from "#src/subagent-registry";
6
+
7
+ function makeInfo(
8
+ overrides: Partial<SubagentSessionInfo> = {},
9
+ ): SubagentSessionInfo {
10
+ return {
11
+ agentName: "Explore",
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ describe("SubagentSessionRegistry", () => {
17
+ test("has() returns false for an unregistered key", () => {
18
+ const registry = new SubagentSessionRegistry();
19
+ expect(registry.has("/sessions/task-abc")).toBe(false);
20
+ });
21
+
22
+ test("get() returns undefined for an unregistered key", () => {
23
+ const registry = new SubagentSessionRegistry();
24
+ expect(registry.get("/sessions/task-abc")).toBeUndefined();
25
+ });
26
+
27
+ test("has() returns true after register()", () => {
28
+ const registry = new SubagentSessionRegistry();
29
+ registry.register("/sessions/task-abc", makeInfo());
30
+ expect(registry.has("/sessions/task-abc")).toBe(true);
31
+ });
32
+
33
+ test("get() returns the registered info after register()", () => {
34
+ const registry = new SubagentSessionRegistry();
35
+ const info = makeInfo({ parentSessionId: "parent-123" });
36
+ registry.register("/sessions/task-abc", info);
37
+ expect(registry.get("/sessions/task-abc")).toEqual(info);
38
+ });
39
+
40
+ test("register() stores agentName without parentSessionId", () => {
41
+ const registry = new SubagentSessionRegistry();
42
+ registry.register("/sessions/task-abc", makeInfo());
43
+ expect(registry.get("/sessions/task-abc")).toEqual({
44
+ agentName: "Explore",
45
+ });
46
+ });
47
+
48
+ test("has() returns false after unregister()", () => {
49
+ const registry = new SubagentSessionRegistry();
50
+ registry.register("/sessions/task-abc", makeInfo());
51
+ registry.unregister("/sessions/task-abc");
52
+ expect(registry.has("/sessions/task-abc")).toBe(false);
53
+ });
54
+
55
+ test("get() returns undefined after unregister()", () => {
56
+ const registry = new SubagentSessionRegistry();
57
+ registry.register("/sessions/task-abc", makeInfo());
58
+ registry.unregister("/sessions/task-abc");
59
+ expect(registry.get("/sessions/task-abc")).toBeUndefined();
60
+ });
61
+
62
+ test("unregister() is a no-op for an unknown key", () => {
63
+ const registry = new SubagentSessionRegistry();
64
+ expect(() => registry.unregister("/sessions/nonexistent")).not.toThrow();
65
+ });
66
+
67
+ test("register() overwrites a previous entry for the same key", () => {
68
+ const registry = new SubagentSessionRegistry();
69
+ registry.register(
70
+ "/sessions/task-abc",
71
+ makeInfo({ parentSessionId: "parent-1" }),
72
+ );
73
+ registry.register(
74
+ "/sessions/task-abc",
75
+ makeInfo({ parentSessionId: "parent-2" }),
76
+ );
77
+ expect(registry.get("/sessions/task-abc")?.parentSessionId).toBe(
78
+ "parent-2",
79
+ );
80
+ });
81
+
82
+ test("multiple keys are independent", () => {
83
+ const registry = new SubagentSessionRegistry();
84
+ registry.register("/sessions/task-1", makeInfo({ agentName: "Explore" }));
85
+ registry.register("/sessions/task-2", makeInfo({ agentName: "Plan" }));
86
+
87
+ expect(registry.get("/sessions/task-1")?.agentName).toBe("Explore");
88
+ expect(registry.get("/sessions/task-2")?.agentName).toBe("Plan");
89
+
90
+ registry.unregister("/sessions/task-1");
91
+ expect(registry.has("/sessions/task-1")).toBe(false);
92
+ expect(registry.has("/sessions/task-2")).toBe(true);
93
+ });
94
+ });