@gotgenes/pi-permission-system 8.3.1 → 9.0.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.
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Composition-root tests for `piPermissionSystemExtension(pi)`.
3
+ *
4
+ * These run the real factory via the `makeFakePi()` harness and assert the
5
+ * wiring contracts that unit tests cannot see: handler-registration
6
+ * completeness, shared-instance contracts across factory invocations, teardown,
7
+ * service↔gate registry sharing, and `ready`-after-publish ordering.
8
+ *
9
+ * Every test runs the factory, which mutates two process-global `Symbol.for()`
10
+ * slots and reads `PI_CODING_AGENT_DIR`. The shared `beforeEach`/`afterEach`
11
+ * isolate the agent dir to a tmpdir and clear both global slots so factory runs
12
+ * do not leak across tests.
13
+ */
14
+ import {
15
+ mkdirSync,
16
+ mkdtempSync,
17
+ readdirSync,
18
+ readFileSync,
19
+ rmSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+
25
+ import {
26
+ createEventBus,
27
+ type ExtensionAPI,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
30
+
31
+ import { getGlobalConfigPath } from "#src/config-paths";
32
+ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
33
+ import piPermissionSystemExtension from "#src/index";
34
+ import { PERMISSIONS_READY_CHANNEL } from "#src/permission-events";
35
+ import {
36
+ createPermissionForwardingLocation,
37
+ type ForwardedPermissionRequest,
38
+ } from "#src/permission-forwarding";
39
+ import { getPermissionsService } from "#src/service";
40
+ import { SUBAGENT_CHILD_SESSION_CREATED } from "#src/subagent-lifecycle-events";
41
+ import { getSubagentSessionRegistry } from "#src/subagent-registry";
42
+ import { makeFakePi } from "#test/helpers/make-fake-pi";
43
+
44
+ const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
45
+ const SUBAGENT_REGISTRY_KEY = Symbol.for(
46
+ "@gotgenes/pi-permission-system:subagent-registry",
47
+ );
48
+
49
+ /** The six events the factory must register a handler for. */
50
+ const EXPECTED_HANDLERS = [
51
+ "before_agent_start",
52
+ "input",
53
+ "resources_discover",
54
+ "session_shutdown",
55
+ "session_start",
56
+ "tool_call",
57
+ ];
58
+
59
+ let agentDir: string;
60
+
61
+ beforeEach(() => {
62
+ agentDir = mkdtempSync(join(tmpdir(), "pi-perm-comp-root-"));
63
+ vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
64
+ });
65
+
66
+ afterEach(() => {
67
+ // Drop both process-global slots so factory runs do not leak across tests.
68
+ const store = globalThis as Record<symbol, unknown>;
69
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property
70
+ delete store[SERVICE_KEY];
71
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property
72
+ delete store[SUBAGENT_REGISTRY_KEY];
73
+ vi.unstubAllEnvs();
74
+ rmSync(agentDir, { recursive: true, force: true });
75
+ });
76
+
77
+ // ── Shared helpers ──────────────────────────────────────────────────────────
78
+
79
+ /** Write the global config file under the stubbed agent dir. */
80
+ function writeGlobalConfig(config: Record<string, unknown>): void {
81
+ const globalConfigPath = getGlobalConfigPath(agentDir);
82
+ mkdirSync(dirname(globalConfigPath), { recursive: true });
83
+ writeFileSync(
84
+ globalConfigPath,
85
+ `${JSON.stringify({ ...DEFAULT_EXTENSION_CONFIG, ...config }, null, 2)}\n`,
86
+ "utf8",
87
+ );
88
+ }
89
+
90
+ /** Build a minimal subagent `ctx` (no UI) for driving tool-call gates. */
91
+ function makeChildCtx(cwd: string, sessionId: string): unknown {
92
+ return {
93
+ cwd,
94
+ hasUI: false,
95
+ sessionManager: {
96
+ getEntries: (): unknown[] => [],
97
+ getSessionId: (): string => sessionId,
98
+ getSessionDir: (): string => cwd,
99
+ },
100
+ ui: {
101
+ notify: (): void => {},
102
+ setStatus: (): void => {},
103
+ select: async (): Promise<string | undefined> => undefined,
104
+ input: async (): Promise<string | undefined> => undefined,
105
+ },
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Build a UI-present `ctx` that records the titles passed to `ui.select`, and
111
+ * approves every prompt. The ask-prompt message (which embeds the tool-input
112
+ * preview) is the first line of the select title.
113
+ */
114
+ function makeUiCtx(cwd: string, capturedTitles: string[]): { ctx: unknown } {
115
+ const ctx = {
116
+ cwd,
117
+ hasUI: true,
118
+ sessionManager: {
119
+ getEntries: (): unknown[] => [],
120
+ getSessionId: (): string => "ui-session",
121
+ getSessionDir: (): string => cwd,
122
+ },
123
+ ui: {
124
+ notify: (): void => {},
125
+ setStatus: (): void => {},
126
+ select: async (title: string): Promise<string | undefined> => {
127
+ capturedTitles.push(title);
128
+ return "Yes";
129
+ },
130
+ input: async (): Promise<string | undefined> => undefined,
131
+ },
132
+ };
133
+ return { ctx };
134
+ }
135
+
136
+ const sleep = (ms: number): Promise<void> =>
137
+ new Promise((resolve) => setTimeout(resolve, ms));
138
+
139
+ /** Drive the registered `session_start` handler with a ctx. */
140
+ function fireSessionStart(
141
+ pi: ReturnType<typeof makeFakePi>,
142
+ ctx: unknown,
143
+ ): Promise<unknown> {
144
+ return pi.fire("session_start", { reason: "start" }, ctx);
145
+ }
146
+
147
+ /**
148
+ * Simulate the parent UI session responding to a forwarded permission request.
149
+ *
150
+ * Polls the parent's requests directory for the child's request file, then
151
+ * writes an approval response so the child's forwarding poll resolves quickly
152
+ * instead of waiting out the 10-minute timeout.
153
+ */
154
+ async function approveForwardedRequest(
155
+ forwardingDir: string,
156
+ parentSessionId: string,
157
+ ): Promise<ForwardedPermissionRequest> {
158
+ const location = createPermissionForwardingLocation(
159
+ forwardingDir,
160
+ parentSessionId,
161
+ );
162
+ const deadline = Date.now() + 2000;
163
+ while (Date.now() < deadline) {
164
+ let files: string[] = [];
165
+ try {
166
+ files = readdirSync(location.requestsDir).filter((f) =>
167
+ f.endsWith(".json"),
168
+ );
169
+ } catch {
170
+ files = [];
171
+ }
172
+ const requestFile = files[0];
173
+ if (requestFile) {
174
+ const request = JSON.parse(
175
+ readFileSync(join(location.requestsDir, requestFile), "utf8"),
176
+ ) as ForwardedPermissionRequest;
177
+ mkdirSync(location.responsesDir, { recursive: true });
178
+ writeFileSync(
179
+ join(location.responsesDir, `${request.id}.json`),
180
+ JSON.stringify({
181
+ approved: true,
182
+ state: "approved",
183
+ responderSessionId: parentSessionId,
184
+ respondedAt: Date.now(),
185
+ }),
186
+ "utf8",
187
+ );
188
+ return request;
189
+ }
190
+ await sleep(5);
191
+ }
192
+ throw new Error("Timed out waiting for the forwarded permission request");
193
+ }
194
+
195
+ describe("event-handler registration completeness", () => {
196
+ it("registers a handler for every required event exactly once", () => {
197
+ const pi = makeFakePi();
198
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
199
+
200
+ expect([...pi.handlers.keys()].sort()).toEqual(EXPECTED_HANDLERS);
201
+ });
202
+ });
203
+
204
+ describe("subagent registry sharing across factory instances", () => {
205
+ // The #296 regression class: two factory invocations on *different* event
206
+ // buses must still resolve the same process-global SubagentSessionRegistry,
207
+ // so a child registered via the parent's bus detects itself as a subagent and
208
+ // forwards (rather than blocking) an external-directory `ask`.
209
+ it("lets a child instance forward an ask it received via the parent's bus", async () => {
210
+ writeGlobalConfig({
211
+ permission: { "*": "allow", external_directory: "ask" },
212
+ });
213
+
214
+ const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
215
+ const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
216
+ const forwardingDir = join(agentDir, "sessions", "permission-forwarding");
217
+ const parentSessionId = "parent-session-1";
218
+ const childSessionId = "child-session-1";
219
+
220
+ // Two factory instances, each wired to its own event bus (as in production:
221
+ // every session's ResourceLoader creates a separate bus).
222
+ const parentBus = createEventBus();
223
+ const childBus = createEventBus();
224
+ piPermissionSystemExtension(
225
+ makeFakePi({ events: parentBus }) as unknown as ExtensionAPI,
226
+ );
227
+ const childPi = makeFakePi({
228
+ events: childBus,
229
+ toolNames: ["read"],
230
+ });
231
+ piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
232
+
233
+ // The child session is announced on the *parent's* bus only; the parent's
234
+ // lifecycle subscription writes it into the shared global registry.
235
+ parentBus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
236
+ sessionId: childSessionId,
237
+ parentSessionId,
238
+ });
239
+
240
+ // The child fires an external-directory read with no UI. With the shared
241
+ // registry it detects itself as a subagent and forwards; the simulated
242
+ // parent approves.
243
+ const firePromise = childPi.fire(
244
+ "tool_call",
245
+ {
246
+ toolName: "read",
247
+ toolCallId: "child-external-read",
248
+ input: { path: join(externalDir, "secret.txt") },
249
+ },
250
+ makeChildCtx(childCwd, childSessionId),
251
+ );
252
+
253
+ const request = await approveForwardedRequest(
254
+ forwardingDir,
255
+ parentSessionId,
256
+ );
257
+ expect(request.targetSessionId).toBe(parentSessionId);
258
+ expect(request.requesterSessionId).toBe(childSessionId);
259
+
260
+ const result = (await firePromise) as { block?: true };
261
+ expect(result.block).toBeUndefined();
262
+
263
+ rmSync(childCwd, { recursive: true, force: true });
264
+ rmSync(externalDir, { recursive: true, force: true });
265
+ });
266
+ });
267
+
268
+ describe("shutdown teardown chain", () => {
269
+ it("unpublishes the service and unsubscribes the lifecycle on shutdown", async () => {
270
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-teardown-cwd-"));
271
+ const pi = makeFakePi();
272
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
273
+
274
+ // The service is published at session_start, not at factory init.
275
+ await fireSessionStart(pi, makeChildCtx(cwd, "top-session"));
276
+ expect(getPermissionsService()).toBeDefined();
277
+
278
+ await pi.fire("session_shutdown");
279
+
280
+ // Service slot cleared.
281
+ expect(getPermissionsService()).toBeUndefined();
282
+
283
+ // Lifecycle unsubscribed: a post-shutdown session-created must not register.
284
+ pi.events.emit(SUBAGENT_CHILD_SESSION_CREATED, {
285
+ sessionId: "late-child",
286
+ parentSessionId: "p-late",
287
+ });
288
+ expect(getSubagentSessionRegistry().has("late-child")).toBe(false);
289
+
290
+ rmSync(cwd, { recursive: true, force: true });
291
+ });
292
+ });
293
+
294
+ describe("service and gate share one formatter registry", () => {
295
+ // A formatter registered through the published service must be consulted by
296
+ // the live gate handler — proving both reference the same
297
+ // ToolInputFormatterRegistry instance the factory created once.
298
+ it("surfaces a service-registered formatter in the gate's ask prompt", async () => {
299
+ writeGlobalConfig({
300
+ permission: { "*": "allow", demo: "ask" },
301
+ });
302
+
303
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ui-cwd-"));
304
+ const pi = makeFakePi({ toolNames: ["demo"] });
305
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
306
+
307
+ const capturedTitles: string[] = [];
308
+ const { ctx } = makeUiCtx(cwd, capturedTitles);
309
+ // The service is published at session_start; publish before resolving it.
310
+ await fireSessionStart(pi, ctx);
311
+
312
+ const previewMarker = "PREVIEW::shared-registry-proof";
313
+ getPermissionsService()!.registerToolInputFormatter(
314
+ "demo",
315
+ () => previewMarker,
316
+ );
317
+ const result = (await pi.fire(
318
+ "tool_call",
319
+ { toolName: "demo", toolCallId: "demo-ask", input: { foo: "bar" } },
320
+ ctx,
321
+ )) as { block?: true };
322
+
323
+ // The gate prompted (not blocked) and the prompt embedded the formatter's
324
+ // preview — so the gate consulted the same registry the service wrote to.
325
+ expect(result.block).toBeUndefined();
326
+ expect(capturedTitles.some((t) => t.includes(previewMarker))).toBe(true);
327
+
328
+ rmSync(cwd, { recursive: true, force: true });
329
+ });
330
+ });
331
+
332
+ describe("ready emitted after service publication", () => {
333
+ // Ordering contracts exist only at the composition root: a consumer reacting
334
+ // to permissions:ready must be able to resolve the service immediately. The
335
+ // service is published and ready fires at session_start (not factory init).
336
+ it("publishes the service before emitting permissions:ready", async () => {
337
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ready-cwd-"));
338
+ const seen: string[] = [];
339
+ const pi = makeFakePi();
340
+ pi.events.on(PERMISSIONS_READY_CHANNEL, () => {
341
+ seen.push(getPermissionsService() ? "present" : "missing");
342
+ });
343
+
344
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
345
+
346
+ // ready is not emitted at load; only after session_start publishes.
347
+ expect(seen).toEqual([]);
348
+
349
+ await fireSessionStart(pi, makeChildCtx(cwd, "top-session"));
350
+
351
+ expect(seen).toEqual(["present"]);
352
+
353
+ rmSync(cwd, { recursive: true, force: true });
354
+ });
355
+ });
356
+
357
+ describe("multi-instance global service interplay", () => {
358
+ // The fix (#302) scopes the process-global service slot to the publishing
359
+ // instance. The parent publishes at its session_start; an in-process child
360
+ // (registered by session id) skips publishing, and its identity-scoped
361
+ // teardown is a no-op — so the parent's service is the one that resolves
362
+ // throughout the child's lifecycle and survives the child's shutdown.
363
+ it("keeps the parent's service published across the child's lifecycle", async () => {
364
+ const parentCwd = mkdtempSync(join(tmpdir(), "pi-perm-parent-cwd-"));
365
+ const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
366
+ const childSessionId = "child-session-mi";
367
+
368
+ const parentPi = makeFakePi({ events: createEventBus() });
369
+ piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
370
+ const childPi = makeFakePi({ events: createEventBus() });
371
+ piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
372
+
373
+ // The parent is not a registered child, so it publishes its service.
374
+ await fireSessionStart(
375
+ parentPi,
376
+ makeChildCtx(parentCwd, "parent-session-mi"),
377
+ );
378
+ const parentService = getPermissionsService();
379
+ expect(parentService).toBeDefined();
380
+
381
+ // The child is registered in the shared global registry before its own
382
+ // session_start, so it detects itself and skips publishing.
383
+ getSubagentSessionRegistry().register(childSessionId, {
384
+ parentSessionId: "parent-session-mi",
385
+ });
386
+ await fireSessionStart(childPi, makeChildCtx(childCwd, childSessionId));
387
+
388
+ // Mid-run: the slot resolves the parent's service, never the child's.
389
+ expect(getPermissionsService()).toBe(parentService);
390
+
391
+ // The child's shutdown is a no-op for the slot it never owned.
392
+ await childPi.fire("session_shutdown");
393
+ expect(getPermissionsService()).toBe(parentService);
394
+
395
+ rmSync(parentCwd, { recursive: true, force: true });
396
+ rmSync(childCwd, { recursive: true, force: true });
397
+ });
398
+ });
@@ -36,12 +36,18 @@ function makeHandler(
36
36
  ): {
37
37
  handler: SessionLifecycleHandler;
38
38
  session: PermissionSession;
39
+ activateService: ReturnType<typeof vi.fn>;
39
40
  cleanupRpc: ReturnType<typeof vi.fn>;
40
41
  } {
41
42
  const session = makeSession(overrides);
43
+ const activateService = vi.fn();
42
44
  const cleanupRpc = vi.fn();
43
- const handler = new SessionLifecycleHandler(session, cleanupRpc);
44
- return { handler, session, cleanupRpc };
45
+ const handler = new SessionLifecycleHandler(
46
+ session,
47
+ activateService,
48
+ cleanupRpc,
49
+ );
50
+ return { handler, session, activateService, cleanupRpc };
45
51
  }
46
52
 
47
53
  // ── handleSessionStart ─────────────────────────────────────────────────────
@@ -106,6 +112,13 @@ describe("handleSessionStart", () => {
106
112
  expect(session.logger.debug).not.toHaveBeenCalled();
107
113
  });
108
114
 
115
+ it("activates the service for the session with ctx", async () => {
116
+ const ctx = makeCtx();
117
+ const { handler, activateService } = makeHandler();
118
+ await handler.handleSessionStart({ reason: "startup" }, ctx);
119
+ expect(activateService).toHaveBeenCalledWith(ctx);
120
+ });
121
+
109
122
  it("calls refreshConfig before resetForNewSession", async () => {
110
123
  const callOrder: string[] = [];
111
124
  const { handler } = makeHandler({
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `makeFakePi()` — a composition-root test harness.
3
+ *
4
+ * Lets a test run the real `piPermissionSystemExtension(pi)` factory and then
5
+ * introspect and drive the result. Unlike the per-handler unit fixtures in
6
+ * `handler-fixtures.ts` (which inject collaborators), this harness exercises the
7
+ * factory itself — the wiring layer where registration completeness, shared-
8
+ * instance contracts, teardown, and event ordering live.
9
+ *
10
+ * It provides:
11
+ * - `events` — a real `createEventBus()` so cross-extension pub/sub and RPC
12
+ * behave as in production (tests can inject a shared bus to model parent/child
13
+ * instances).
14
+ * - `handlers` — every `pi.on(event, handler)` registration, keyed by event
15
+ * name, so a test can assert completeness and fire handlers.
16
+ * - `commands` — every `pi.registerCommand(name, …)` registration.
17
+ * - `fire(event, input, ctx)` — drive a registered handler; resolves to its
18
+ * (possibly async) result.
19
+ *
20
+ * The harness object is cast to `ExtensionAPI` at the call to the factory; the
21
+ * `FakePi` interface itself stays narrow (ISP — only what the factory touches).
22
+ */
23
+ import { createEventBus, type EventBus } from "@earendil-works/pi-coding-agent";
24
+ import { vi } from "vitest";
25
+
26
+ /** A handler recorded by `pi.on(...)`, kept generic over event/result shapes. */
27
+ export type RecordedHandler = (event: unknown, ctx: unknown) => unknown;
28
+
29
+ export interface FakePi {
30
+ /** Real event bus so cross-extension pub/sub and RPC behave as in production. */
31
+ events: EventBus;
32
+ /** Every `pi.on(event, handler)` registration, keyed by event name. */
33
+ handlers: Map<string, RecordedHandler>;
34
+ /** Every `pi.registerCommand(name, …)` registration, keyed by command name. */
35
+ commands: Map<string, unknown>;
36
+ /**
37
+ * Drive a registered handler; resolves to its (possibly async) result.
38
+ *
39
+ * Throws if no handler is registered for `event` so a typo in a test surfaces
40
+ * loudly instead of silently resolving to `undefined`.
41
+ */
42
+ fire(event: string, input?: unknown, ctx?: unknown): Promise<unknown>;
43
+ /** Minimal tool registry — returns the configured tool names. */
44
+ getAllTools(): { name: string }[];
45
+ setActiveTools(names: string[]): void;
46
+ }
47
+
48
+ export interface MakeFakePiOptions {
49
+ /** Inject a shared bus to model parent/child instances; defaults to a fresh bus. */
50
+ events?: EventBus;
51
+ /** Tool names returned by `getAllTools()`; defaults to a small set. */
52
+ toolNames?: readonly string[];
53
+ }
54
+
55
+ const DEFAULT_TOOL_NAMES = ["read", "write", "edit", "bash", "ls", "grep"];
56
+
57
+ /**
58
+ * Build a fake `ExtensionAPI` for composition-root tests.
59
+ *
60
+ * The returned object is structurally a `FakePi`; pass it to the factory as
61
+ * `piPermissionSystemExtension(pi as unknown as ExtensionAPI)`.
62
+ */
63
+ export function makeFakePi(options: MakeFakePiOptions = {}): FakePi {
64
+ const events = options.events ?? createEventBus();
65
+ const toolNames = options.toolNames ?? DEFAULT_TOOL_NAMES;
66
+ const handlers = new Map<string, RecordedHandler>();
67
+ const commands = new Map<string, unknown>();
68
+
69
+ return {
70
+ events,
71
+ handlers,
72
+ commands,
73
+ fire(event, input, ctx): Promise<unknown> {
74
+ const handler = handlers.get(event);
75
+ if (!handler) {
76
+ throw new Error(`No handler registered for event "${event}"`);
77
+ }
78
+ return Promise.resolve(handler(input, ctx));
79
+ },
80
+ getAllTools(): { name: string }[] {
81
+ return toolNames.map((name) => ({ name }));
82
+ },
83
+ setActiveTools: vi.fn(),
84
+ // ── ExtensionAPI methods the factory touches (recorded) ────────────────
85
+ on(event: string, handler: RecordedHandler): void {
86
+ handlers.set(event, handler);
87
+ },
88
+ registerCommand(name: string, optionsArg: unknown): void {
89
+ commands.set(name, optionsArg);
90
+ },
91
+ // ── ExtensionAPI methods present for the cast but unused by the factory ─
92
+ registerProvider: vi.fn(),
93
+ exec: vi.fn(),
94
+ } as FakePi & Record<string, unknown>;
95
+ }
@@ -279,10 +279,18 @@ describe("piPermissionSystemExtension ready event wiring", () => {
279
279
  rmSync(baseDir, { recursive: true, force: true });
280
280
  });
281
281
 
282
- it("emits permissions:ready with protocolVersion when extension loads", () => {
282
+ it("emits permissions:ready with protocolVersion at session_start", async () => {
283
283
  const emitSpy = vi.fn();
284
+ const handlers = new Map<
285
+ string,
286
+ (event: unknown, ctx: unknown) => unknown
287
+ >();
284
288
  piPermissionSystemExtension({
285
- on: vi.fn(),
289
+ on: vi.fn(
290
+ (event: string, handler: (e: unknown, c: unknown) => unknown) => {
291
+ handlers.set(event, handler);
292
+ },
293
+ ),
286
294
  registerCommand: vi.fn(),
287
295
  getAllTools: vi.fn().mockReturnValue([]),
288
296
  setActiveTools: vi.fn(),
@@ -290,6 +298,28 @@ describe("piPermissionSystemExtension ready event wiring", () => {
290
298
  events: { emit: emitSpy, on: vi.fn().mockReturnValue(() => undefined) },
291
299
  } as never);
292
300
 
301
+ // ready is not emitted at load — only after session_start publishes.
302
+ expect(
303
+ emitSpy.mock.calls.filter(([c]) => c === PERMISSIONS_READY_CHANNEL),
304
+ ).toHaveLength(0);
305
+
306
+ const ctx = {
307
+ cwd: baseDir,
308
+ hasUI: false,
309
+ sessionManager: {
310
+ getEntries: (): unknown[] => [],
311
+ getSessionId: (): string => "top-session",
312
+ getSessionDir: (): string => baseDir,
313
+ },
314
+ ui: {
315
+ notify: (): void => {},
316
+ setStatus: (): void => {},
317
+ select: async (): Promise<string | undefined> => undefined,
318
+ input: async (): Promise<string | undefined> => undefined,
319
+ },
320
+ };
321
+ await handlers.get("session_start")?.({ reason: "start" }, ctx);
322
+
293
323
  const readyCalls = emitSpy.mock.calls.filter(
294
324
  ([channel]) => channel === PERMISSIONS_READY_CHANNEL,
295
325
  );
@@ -149,13 +149,11 @@ describe("resolvePermissionForwardingTargetSessionId", () => {
149
149
  });
150
150
 
151
151
  describe("resolvePermissionForwardingTargetSessionId — registry resolution", () => {
152
- const sessionDir =
153
- "/home/user/projects/.pi/sessions/parent/tasks/session-abc";
152
+ const childSessionId = "child-session-abc";
154
153
 
155
154
  test("returns parentSessionId from registry when env vars are absent", () => {
156
155
  const registry = new SubagentSessionRegistry();
157
- registry.register(sessionDir, {
158
- agentName: "Explore",
156
+ registry.register(childSessionId, {
159
157
  parentSessionId: "parent-from-registry",
160
158
  });
161
159
 
@@ -163,7 +161,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
163
161
  resolvePermissionForwardingTargetSessionId({
164
162
  hasUI: false,
165
163
  isSubagent: true,
166
- sessionDir,
164
+ sessionId: childSessionId,
167
165
  registry,
168
166
  env: {},
169
167
  }),
@@ -172,8 +170,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
172
170
 
173
171
  test("registry takes priority over env vars", () => {
174
172
  const registry = new SubagentSessionRegistry();
175
- registry.register(sessionDir, {
176
- agentName: "Explore",
173
+ registry.register(childSessionId, {
177
174
  parentSessionId: "parent-from-registry",
178
175
  });
179
176
 
@@ -181,7 +178,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
181
178
  resolvePermissionForwardingTargetSessionId({
182
179
  hasUI: false,
183
180
  isSubagent: true,
184
- sessionDir,
181
+ sessionId: childSessionId,
185
182
  registry,
186
183
  env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
187
184
  }),
@@ -190,27 +187,27 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
190
187
 
191
188
  test("falls through to env vars when registry entry has no parentSessionId", () => {
192
189
  const registry = new SubagentSessionRegistry();
193
- registry.register(sessionDir, { agentName: "Explore" }); // no parentSessionId
190
+ registry.register(childSessionId, {}); // no parentSessionId
194
191
 
195
192
  expect(
196
193
  resolvePermissionForwardingTargetSessionId({
197
194
  hasUI: false,
198
195
  isSubagent: true,
199
- sessionDir,
196
+ sessionId: childSessionId,
200
197
  registry,
201
198
  env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
202
199
  }),
203
200
  ).toBe("parent-from-env");
204
201
  });
205
202
 
206
- test("falls through to env vars when sessionDir is not in registry", () => {
203
+ test("falls through to env vars when sessionId is not in registry", () => {
207
204
  const registry = new SubagentSessionRegistry(); // empty
208
205
 
209
206
  expect(
210
207
  resolvePermissionForwardingTargetSessionId({
211
208
  hasUI: false,
212
209
  isSubagent: true,
213
- sessionDir,
210
+ sessionId: childSessionId,
214
211
  registry,
215
212
  env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
216
213
  }),
@@ -219,13 +216,13 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
219
216
 
220
217
  test("returns null when registry entry has no parentSessionId and no env vars set", () => {
221
218
  const registry = new SubagentSessionRegistry();
222
- registry.register(sessionDir, { agentName: "Explore" }); // no parentSessionId
219
+ registry.register(childSessionId, {}); // no parentSessionId
223
220
 
224
221
  expect(
225
222
  resolvePermissionForwardingTargetSessionId({
226
223
  hasUI: false,
227
224
  isSubagent: true,
228
- sessionDir,
225
+ sessionId: childSessionId,
229
226
  registry,
230
227
  env: {},
231
228
  }),
@@ -237,7 +234,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
237
234
  resolvePermissionForwardingTargetSessionId({
238
235
  hasUI: false,
239
236
  isSubagent: true,
240
- sessionDir,
237
+ sessionId: childSessionId,
241
238
  env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
242
239
  }),
243
240
  ).toBe("parent-from-env");