@gotgenes/pi-permission-system 7.1.4 → 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +2 -2
  3. package/src/active-agent.ts +1 -1
  4. package/src/bash-arity.ts +1 -0
  5. package/src/config-modal.ts +2 -0
  6. package/src/forwarded-permissions/io.ts +4 -2
  7. package/src/forwarded-permissions/polling.ts +22 -9
  8. package/src/forwarding-manager.ts +3 -1
  9. package/src/handlers/before-agent-start.ts +7 -6
  10. package/src/handlers/gates/bash-path-extractor.ts +3 -5
  11. package/src/handlers/gates/bash-path.ts +1 -1
  12. package/src/handlers/gates/runner.ts +3 -0
  13. package/src/handlers/lifecycle.ts +9 -8
  14. package/src/handlers/permission-gate-handler.ts +12 -7
  15. package/src/index.ts +19 -1
  16. package/src/logging.ts +3 -0
  17. package/src/node-modules-discovery.ts +1 -1
  18. package/src/normalize.ts +1 -0
  19. package/src/permission-event-rpc.ts +2 -0
  20. package/src/permission-forwarding.ts +15 -0
  21. package/src/permission-manager.ts +7 -6
  22. package/src/permission-merge.ts +4 -2
  23. package/src/permission-prompter.ts +7 -0
  24. package/src/permission-prompts.ts +1 -1
  25. package/src/policy-loader.ts +5 -5
  26. package/src/service.ts +37 -1
  27. package/src/skill-prompt-sanitizer.ts +3 -3
  28. package/src/subagent-context.ts +14 -1
  29. package/src/subagent-registry.ts +60 -0
  30. package/src/tool-registry.ts +1 -1
  31. package/src/yolo-mode.ts +2 -1
  32. package/test/config-modal.test.ts +6 -8
  33. package/test/forwarding-manager.test.ts +1 -0
  34. package/test/handlers/before-agent-start.test.ts +1 -1
  35. package/test/handlers/external-directory-integration.test.ts +1 -1
  36. package/test/handlers/gates/skill-read.test.ts +8 -10
  37. package/test/handlers/gates/tool.test.ts +1 -1
  38. package/test/handlers/input-events.test.ts +1 -1
  39. package/test/handlers/input.test.ts +1 -1
  40. package/test/handlers/tool-call-events.test.ts +1 -1
  41. package/test/handlers/tool-call.test.ts +1 -1
  42. package/test/permission-event-rpc.test.ts +1 -0
  43. package/test/permission-events.test.ts +2 -0
  44. package/test/permission-forwarding.test.ts +98 -0
  45. package/test/permission-manager-unified.test.ts +4 -2
  46. package/test/permission-session.test.ts +2 -2
  47. package/test/permission-system.test.ts +8 -8
  48. package/test/service.test.ts +100 -6
  49. package/test/subagent-context.test.ts +65 -0
  50. package/test/subagent-registry.test.ts +94 -0
@@ -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"],
@@ -148,14 +151,18 @@ export class PermissionPrompter implements PermissionPrompterApi {
148
151
  private buildForwardingDeps(): PermissionForwardingDeps {
149
152
  const { deps } = this;
150
153
  const logger: ForwardedPermissionLogger = {
154
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
151
155
  writeReviewLog: deps.writeReviewLog,
152
156
  writeDebugLog: () => undefined,
153
157
  };
154
158
  return {
155
159
  forwardingDir: deps.forwardingDir,
156
160
  subagentSessionsDir: deps.subagentSessionsDir,
161
+ registry: deps.registry,
157
162
  logger,
163
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
158
164
  writeReviewLog: deps.writeReviewLog,
165
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- same as above
159
166
  requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
160
167
  shouldAutoApprove: () => false,
161
168
  };
@@ -38,7 +38,7 @@ export function formatAskPrompt(
38
38
  const patternInfo = result.matchedPattern
39
39
  ? ` (matched '${result.matchedPattern}')`
40
40
  : "";
41
- return `${subject} requested bash command '${result.command || ""}'${patternInfo}. Allow this command?`;
41
+ return `${subject} requested bash command '${result.command ?? ""}'${patternInfo}. Allow this command?`;
42
42
  }
43
43
 
44
44
  if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
@@ -169,12 +169,12 @@ export class FilePolicyLoader implements PolicyLoader {
169
169
 
170
170
  constructor(options: PolicyLoaderOptions = {}) {
171
171
  this.globalConfigPath =
172
- options.globalConfigPath || defaultGlobalConfigPath();
173
- this.agentsDir = options.agentsDir || defaultAgentsDir();
174
- this.projectGlobalConfigPath = options.projectGlobalConfigPath || null;
175
- this.projectAgentsDir = options.projectAgentsDir || null;
172
+ options.globalConfigPath ?? defaultGlobalConfigPath();
173
+ this.agentsDir = options.agentsDir ?? defaultAgentsDir();
174
+ this.projectGlobalConfigPath = options.projectGlobalConfigPath ?? null;
175
+ this.projectAgentsDir = options.projectAgentsDir ?? null;
176
176
  this.globalMcpConfigPath =
177
- options.globalMcpConfigPath || defaultGlobalMcpConfigPath();
177
+ options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath();
178
178
  this.configuredMcpServerNamesOverride = options.mcpServerNames
179
179
  ? [
180
180
  ...new Set(
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
  /**
@@ -71,5 +106,6 @@ export function getPermissionsService(): PermissionsService | undefined {
71
106
  * extension is torn down.
72
107
  */
73
108
  export function unpublishPermissionsService(): void {
109
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
74
110
  delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
75
111
  }
@@ -70,9 +70,9 @@ function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
70
70
 
71
71
  for (const match of sectionBody.matchAll(skillBlockRegex)) {
72
72
  const block = match[1];
73
- const nameMatch = block.match(SKILL_NAME_REGEX);
74
- const descriptionMatch = block.match(SKILL_DESCRIPTION_REGEX);
75
- const locationMatch = block.match(SKILL_LOCATION_REGEX);
73
+ const nameMatch = SKILL_NAME_REGEX.exec(block);
74
+ const descriptionMatch = SKILL_DESCRIPTION_REGEX.exec(block);
75
+ const locationMatch = SKILL_LOCATION_REGEX.exec(block);
76
76
 
77
77
  if (!nameMatch || !descriptionMatch || !locationMatch) {
78
78
  continue;
@@ -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
+ }
@@ -35,7 +35,7 @@ function buildReverseAliases(
35
35
  const reverse = new Map<string, string[]>();
36
36
 
37
37
  for (const [alias, canonical] of Object.entries(aliases)) {
38
- const existing = reverse.get(canonical) || [];
38
+ const existing = reverse.get(canonical) ?? [];
39
39
  if (!existing.includes(alias)) {
40
40
  existing.push(alias);
41
41
  }
package/src/yolo-mode.ts CHANGED
@@ -10,7 +10,8 @@ export interface AskPermissionResolutionOptions {
10
10
  export function isYoloModeEnabled(
11
11
  config: PermissionSystemExtensionConfig,
12
12
  ): boolean {
13
- return config.yoloMode === true;
13
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- typed as boolean but may be undefined at runtime (untyped callers); Boolean() guards against that
14
+ return Boolean(config.yoloMode);
14
15
  }
15
16
 
16
17
  export function shouldAutoApprovePermissionState(
@@ -68,7 +68,7 @@ function createCommandContext(hasUI: boolean): {
68
68
  }
69
69
 
70
70
  function lastNotification(notifications: Notification[]): Notification {
71
- return notifications[notifications.length - 1] as Notification;
71
+ return notifications[notifications.length - 1];
72
72
  }
73
73
 
74
74
  test("permission-system command completions expose top-level config actions", () => {
@@ -101,7 +101,7 @@ test("permission-system command completions expose top-level config actions", ()
101
101
  definition = nextDefinition;
102
102
  },
103
103
  } as never,
104
- controller as never,
104
+ controller,
105
105
  );
106
106
 
107
107
  expect(definition!.getArgumentCompletions).toBeTypeOf("function");
@@ -172,13 +172,11 @@ test("permission-system command handlers manage config summary, persistence, and
172
172
  definition = nextDefinition;
173
173
  },
174
174
  } as never,
175
- controller as never,
175
+ controller,
176
176
  );
177
177
 
178
178
  expect(registeredName).toBe("permission-system");
179
- expect(definition!.description ?? "").toContain(
180
- "Configure pi-permission-system",
181
- );
179
+ expect(definition!.description).toContain("Configure pi-permission-system");
182
180
 
183
181
  const infoCtx = createCommandContext(true);
184
182
  await definition!.handler("show", infoCtx.ctx);
@@ -267,7 +265,7 @@ test("show output includes rule origins when getComposedRules is provided", asyn
267
265
  definition = nextDef;
268
266
  },
269
267
  } as never,
270
- controller as never,
268
+ controller,
271
269
  );
272
270
 
273
271
  const ctx = createCommandContext(true);
@@ -300,7 +298,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
300
298
  definition = nextDef;
301
299
  },
302
300
  } as never,
303
- controller as never,
301
+ controller,
304
302
  );
305
303
 
306
304
  const ctx = createCommandContext(true);
@@ -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
  });
@@ -52,7 +52,7 @@ function makeSession(
52
52
  activate: vi.fn(),
53
53
  refreshConfig: vi.fn(),
54
54
  resolveAgentName: vi.fn().mockReturnValue(null),
55
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
55
+ getToolPermission: vi.fn().mockReturnValue("allow"),
56
56
  checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
57
57
  shouldUpdateActiveTools: vi.fn().mockReturnValue(true),
58
58
  commitActiveToolsCacheKey: vi.fn(),
@@ -110,7 +110,7 @@ function makeSession(
110
110
  activate: vi.fn(),
111
111
  resolveAgentName: vi.fn().mockReturnValue(null),
112
112
  checkPermission: makeCheckPermission("deny"),
113
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
113
+ getToolPermission: vi.fn().mockReturnValue("allow"),
114
114
  getSessionRuleset: vi.fn().mockReturnValue([]),
115
115
  approveSessionRule: vi.fn(),
116
116
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
@@ -74,7 +74,7 @@ describe("describeSkillReadGate", () => {
74
74
  makeSkillEntry({ state: "ask" }),
75
75
  ]);
76
76
  expect(result).not.toBeNull();
77
- const desc = result as GateDescriptor;
77
+ const desc = result!;
78
78
  expect(desc.preResolved).toEqual({ state: "ask" });
79
79
  });
80
80
 
@@ -83,7 +83,7 @@ describe("describeSkillReadGate", () => {
83
83
  makeSkillEntry({ state: "allow" }),
84
84
  ]);
85
85
  expect(result).not.toBeNull();
86
- const desc = result as GateDescriptor;
86
+ const desc = result!;
87
87
  expect(desc.preResolved).toEqual({ state: "allow" });
88
88
  });
89
89
 
@@ -92,14 +92,14 @@ describe("describeSkillReadGate", () => {
92
92
  makeSkillEntry({ state: "deny" }),
93
93
  ]);
94
94
  expect(result).not.toBeNull();
95
- const desc = result as GateDescriptor;
95
+ const desc = result!;
96
96
  expect(desc.preResolved).toEqual({ state: "deny" });
97
97
  });
98
98
 
99
99
  it("decision surface is 'skill' and decision value is the skill name", () => {
100
100
  const result = describeSkillReadGate(makeTcc(), () => [
101
101
  makeSkillEntry({ name: "my-skill" }),
102
- ]) as GateDescriptor;
102
+ ])!;
103
103
  expect(result.decision.surface).toBe("skill");
104
104
  expect(result.decision.value).toBe("my-skill");
105
105
  });
@@ -107,7 +107,7 @@ describe("describeSkillReadGate", () => {
107
107
  it("denialContext contains the skill name and read path", () => {
108
108
  const result = describeSkillReadGate(makeTcc(), () => [
109
109
  makeSkillEntry({ name: "librarian" }),
110
- ]) as GateDescriptor;
110
+ ])!;
111
111
  expect(result.denialContext).toEqual({
112
112
  kind: "skill_read",
113
113
  skillName: "librarian",
@@ -120,7 +120,7 @@ describe("describeSkillReadGate", () => {
120
120
  const result = describeSkillReadGate(
121
121
  makeTcc({ agentName: "test-agent", toolCallId: "tc-42" }),
122
122
  () => [makeSkillEntry({ name: "my-skill" })],
123
- ) as GateDescriptor;
123
+ )!;
124
124
  expect(result.promptDetails).toMatchObject({
125
125
  source: "skill_read",
126
126
  agentName: "test-agent",
@@ -135,7 +135,7 @@ describe("describeSkillReadGate", () => {
135
135
  const result = describeSkillReadGate(
136
136
  makeTcc({ agentName: "agent-1" }),
137
137
  () => [makeSkillEntry({ name: "librarian" })],
138
- ) as GateDescriptor;
138
+ )!;
139
139
  expect(result.logContext).toMatchObject({
140
140
  source: "skill_read",
141
141
  skillName: "librarian",
@@ -144,9 +144,7 @@ describe("describeSkillReadGate", () => {
144
144
  });
145
145
 
146
146
  it("surface is 'skill' on the descriptor", () => {
147
- const result = describeSkillReadGate(makeTcc(), () => [
148
- makeSkillEntry(),
149
- ]) as GateDescriptor;
147
+ const result = describeSkillReadGate(makeTcc(), () => [makeSkillEntry()])!;
150
148
  expect(result.surface).toBe("skill");
151
149
  });
152
150
  });
@@ -93,7 +93,7 @@ describe("describeToolGate", () => {
93
93
  it("populates denialContext with agent name when provided", () => {
94
94
  const check = makeCheckResult("ask", { toolName: "read" });
95
95
  const desc = describeToolGate(makeTcc({ agentName: "my-agent" }), check);
96
- expect(desc.denialContext!.agentName).toBe("my-agent");
96
+ expect(desc.denialContext.agentName).toBe("my-agent");
97
97
  });
98
98
 
99
99
  it("populates denialContext with input for tool context", () => {
@@ -54,7 +54,7 @@ function makeSession(
54
54
  origin: "global",
55
55
  matchedPattern: "*",
56
56
  }),
57
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
57
+ getToolPermission: vi.fn().mockReturnValue("allow"),
58
58
  getSessionRuleset: vi.fn().mockReturnValue([]),
59
59
  approveSessionRule: vi.fn(),
60
60
  canPrompt: vi.fn().mockReturnValue(true),
@@ -42,7 +42,7 @@ function makeSession(
42
42
  activate: vi.fn(),
43
43
  resolveAgentName: vi.fn().mockReturnValue(null),
44
44
  checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
45
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
45
+ getToolPermission: vi.fn().mockReturnValue("allow"),
46
46
  getSessionRuleset: vi.fn().mockReturnValue([]),
47
47
  approveSessionRule: vi.fn(),
48
48
  canPrompt: vi.fn().mockReturnValue(true),
@@ -75,7 +75,7 @@ function makeSession(
75
75
  activate: vi.fn(),
76
76
  resolveAgentName: vi.fn().mockReturnValue(null),
77
77
  checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
78
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
78
+ getToolPermission: vi.fn().mockReturnValue("allow"),
79
79
  getSessionRuleset: vi.fn().mockReturnValue([]),
80
80
  approveSessionRule: vi.fn(),
81
81
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
@@ -66,7 +66,7 @@ function makeSession(
66
66
  activate: vi.fn(),
67
67
  resolveAgentName: vi.fn().mockReturnValue(null),
68
68
  checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
69
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
69
+ getToolPermission: vi.fn().mockReturnValue("allow"),
70
70
  getSessionRuleset: vi.fn().mockReturnValue([]),
71
71
  approveSessionRule: vi.fn(),
72
72
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-deprecated -- tests the deprecated RPC channel implementation */
1
2
  import { createEventBus } from "@earendil-works/pi-coding-agent";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import {
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-deprecated -- tests the deprecated RPC channel implementation */
1
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { dirname, join } from "node:path";
@@ -164,6 +165,7 @@ describe("type shapes (PermissionsRpcReply)", () => {
164
165
  error: "no_ui",
165
166
  };
166
167
  expect(reply.success).toBe(false);
168
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- narrowing on discriminated union
167
169
  if (!reply.success) {
168
170
  expect(reply.error).toBe("no_ui");
169
171
  }
@@ -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();
@@ -24,6 +25,7 @@ describe("SUBAGENT_PARENT_SESSION_ENV_CANDIDATES", () => {
24
25
  });
25
26
 
26
27
  test("deprecated SUBAGENT_PARENT_SESSION_ENV_KEY equals the first candidate", () => {
28
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- test verifying the deprecated alias
27
29
  expect(SUBAGENT_PARENT_SESSION_ENV_KEY).toBe(
28
30
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0],
29
31
  );
@@ -145,3 +147,99 @@ describe("resolvePermissionForwardingTargetSessionId", () => {
145
147
  ).toBe("env-session-abc");
146
148
  });
147
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
+ });
@@ -685,10 +685,12 @@ function createInMemoryPolicyLoader(
685
685
  ): PolicyLoader {
686
686
  const issues: string[] = [];
687
687
  return {
688
- loadGlobalConfig: () => scopes.global ?? {},
689
- loadProjectConfig: () => scopes.project ?? {},
688
+ loadGlobalConfig: () => scopes.global ?? ({} as const),
689
+ loadProjectConfig: () => scopes.project ?? ({} as const),
690
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
690
691
  loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {},
691
692
  loadProjectAgentConfig: (name?: string) =>
693
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
692
694
  (name && scopes.projectAgent?.[name]) || {},
693
695
  getConfiguredMcpServerNames: () => mcpServerNames,
694
696
  getCacheStamp: () => "in-memory",
@@ -122,7 +122,7 @@ function makePermissionManager(
122
122
  toolName: "read",
123
123
  source: "tool",
124
124
  origin: "builtin",
125
- } as PermissionCheckResult),
125
+ }),
126
126
  getToolPermission: vi.fn().mockReturnValue("allow"),
127
127
  getConfigIssues: vi.fn().mockReturnValue([]),
128
128
  getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
@@ -260,7 +260,7 @@ describe("PermissionSession", () => {
260
260
  toolName: "bash",
261
261
  source: "bash",
262
262
  origin: "global",
263
- } as PermissionCheckResult),
263
+ }),
264
264
  });
265
265
  mockCreatePermissionManagerForCwd.mockReturnValue(pm2);
266
266
  const { session } = createSession();