@gotgenes/pi-permission-system 10.0.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +53 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-manager.ts +69 -3
  26. package/src/permission-prompter.ts +7 -58
  27. package/src/permission-resolver.ts +17 -0
  28. package/src/permission-session.ts +83 -27
  29. package/src/permissions-service.ts +53 -0
  30. package/src/runtime.ts +1 -37
  31. package/src/service-lifecycle.ts +49 -0
  32. package/src/session-approval-recorder.ts +6 -0
  33. package/src/session-lifecycle-session.ts +24 -0
  34. package/src/tool-input-preview.ts +0 -62
  35. package/src/tool-input-prompt-formatters.ts +63 -0
  36. package/src/tool-preview-formatter.ts +6 -4
  37. package/test/decision-reporter.test.ts +112 -0
  38. package/test/denial-messages.test.ts +62 -0
  39. package/test/forwarding-manager.test.ts +26 -44
  40. package/test/handlers/before-agent-start.test.ts +45 -21
  41. package/test/handlers/external-directory-integration.test.ts +83 -114
  42. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  43. package/test/handlers/gates/bash-command.test.ts +49 -90
  44. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  45. package/test/handlers/gates/bash-path.test.ts +54 -157
  46. package/test/handlers/gates/path.test.ts +38 -105
  47. package/test/handlers/gates/runner.test.ts +151 -186
  48. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  49. package/test/handlers/gates/skill-input.test.ts +128 -0
  50. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  51. package/test/handlers/input.test.ts +1 -2
  52. package/test/handlers/lifecycle.test.ts +49 -33
  53. package/test/handlers/tool-call-events.test.ts +1 -1
  54. package/test/handlers/tool-call.test.ts +44 -153
  55. package/test/helpers/gate-fixtures.ts +212 -17
  56. package/test/helpers/handler-fixtures.ts +226 -29
  57. package/test/mcp-targets.test.ts +55 -0
  58. package/test/permission-forwarder.test.ts +295 -0
  59. package/test/permission-forwarding.test.ts +0 -282
  60. package/test/permission-manager-unified.test.ts +159 -1
  61. package/test/permission-prompter.test.ts +33 -44
  62. package/test/permission-session.test.ts +211 -105
  63. package/test/permissions-service.test.ts +151 -0
  64. package/test/runtime.test.ts +2 -86
  65. package/test/service-lifecycle.test.ts +162 -0
  66. package/test/tool-input-preview.test.ts +0 -111
  67. package/test/tool-input-prompt-formatters.test.ts +115 -0
  68. package/src/forwarded-permissions/polling.ts +0 -411
@@ -0,0 +1,49 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
4
+ import {
5
+ type PermissionsService,
6
+ publishPermissionsService,
7
+ unpublishPermissionsService,
8
+ } from "./service";
9
+ import { isRegisteredSubagentChild } from "./subagent-context";
10
+ import type { SubagentSessionRegistry } from "./subagent-registry";
11
+
12
+ /** The session-scoped service lifecycle that the lifecycle handler drives. */
13
+ export interface ServiceLifecycle {
14
+ activate(ctx: ExtensionContext): void;
15
+ teardown(): void;
16
+ }
17
+
18
+ /**
19
+ * Owns the process-global service publication lifecycle for one extension
20
+ * instance.
21
+ *
22
+ * - `activate` publishes the service (skipped for registered subagent children
23
+ * so they never clobber the parent's slot — see #302), then emits the ready
24
+ * event.
25
+ * - `teardown` runs all session-scoped subscription cleanups in order, then
26
+ * unpublishes the service.
27
+ */
28
+ export class PermissionServiceLifecycle implements ServiceLifecycle {
29
+ constructor(
30
+ private readonly service: PermissionsService,
31
+ private readonly registry: SubagentSessionRegistry,
32
+ private readonly events: PermissionEventBus,
33
+ private readonly subscriptions: readonly (() => void)[],
34
+ ) {}
35
+
36
+ activate(ctx: ExtensionContext): void {
37
+ if (!isRegisteredSubagentChild(ctx, this.registry)) {
38
+ publishPermissionsService(this.service);
39
+ }
40
+ emitReadyEvent(this.events);
41
+ }
42
+
43
+ teardown(): void {
44
+ for (const unsubscribe of this.subscriptions) {
45
+ unsubscribe();
46
+ }
47
+ unpublishPermissionsService(this.service);
48
+ }
49
+ }
@@ -0,0 +1,6 @@
1
+ import type { SessionApproval } from "./session-approval";
2
+
3
+ /** Records a granted session-scoped approval into the session ruleset. */
4
+ export interface SessionApprovalRecorder {
5
+ recordSessionApproval(approval: SessionApproval): void;
6
+ }
@@ -0,0 +1,24 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { SessionLogger } from "./session-logger";
4
+
5
+ /**
6
+ * The session surface `SessionLifecycleHandler` invokes across
7
+ * `session_start`, `resources_discover`, and `session_shutdown`: refresh and
8
+ * report config, reset / reload / shut down session state, resolve the agent
9
+ * name, surface config issues, read the runtime context, and log.
10
+ *
11
+ * `activate` is intentionally absent — the lifecycle handler never calls it
12
+ * directly (ISP: do not depend on methods you do not use).
13
+ */
14
+ export interface SessionLifecycleSession {
15
+ refreshConfig(ctx?: ExtensionContext): void;
16
+ resetForNewSession(ctx: ExtensionContext): void;
17
+ logResolvedConfigPaths(): void;
18
+ resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
19
+ getConfigIssues(agentName?: string): string[];
20
+ reload(): void;
21
+ getRuntimeContext(): ExtensionContext | null;
22
+ shutdown(): void;
23
+ readonly logger: SessionLogger;
24
+ }
@@ -1,4 +1,3 @@
1
- import { getNonEmptyString, toRecord } from "./common";
2
1
  import { safeJsonStringify } from "./logging";
3
2
 
4
3
  export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
@@ -25,67 +24,6 @@ export function formatCount(
25
24
  return `${value} ${value === 1 ? singular : plural}`;
26
25
  }
27
26
 
28
- export function getPromptPath(input: Record<string, unknown>): string | null {
29
- return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
30
- }
31
-
32
- export function formatEditInputForPrompt(
33
- input: Record<string, unknown>,
34
- ): string {
35
- const path = getPromptPath(input);
36
- const rawEdits = Array.isArray(input.edits)
37
- ? input.edits
38
- : typeof input.oldText === "string" && typeof input.newText === "string"
39
- ? [{ oldText: input.oldText, newText: input.newText }]
40
- : [];
41
-
42
- const edits = rawEdits
43
- .map((edit) => toRecord(edit))
44
- .filter(
45
- (edit) =>
46
- typeof edit.oldText === "string" && typeof edit.newText === "string",
47
- );
48
-
49
- const pathPart = path ? `for '${path}'` : "";
50
- if (edits.length === 0) {
51
- return pathPart ? `${pathPart} with edit input` : "with edit input";
52
- }
53
-
54
- const firstEdit = edits[0];
55
- const oldText = String(firstEdit.oldText);
56
- const newText = String(firstEdit.newText);
57
- const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
58
- const extraEdits =
59
- edits.length > 1
60
- ? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
61
- : "";
62
- const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
63
- return pathPart ? `${pathPart} ${summary}` : summary;
64
- }
65
-
66
- export function formatWriteInputForPrompt(
67
- input: Record<string, unknown>,
68
- ): string {
69
- const path = getPromptPath(input);
70
- const content = typeof input.content === "string" ? input.content : "";
71
- const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
72
- return path ? `for '${path}' ${summary}` : summary;
73
- }
74
-
75
- export function formatReadInputForPrompt(
76
- input: Record<string, unknown>,
77
- ): string {
78
- const path = getPromptPath(input);
79
- const parts = path ? [`path '${path}'`] : [];
80
- if (typeof input.offset === "number") {
81
- parts.push(`offset ${input.offset}`);
82
- }
83
- if (typeof input.limit === "number") {
84
- parts.push(`limit ${input.limit}`);
85
- }
86
- return parts.length > 0 ? `for ${parts.join(", ")}` : "";
87
- }
88
-
89
27
  export function serializeToolInputPreview(input: unknown): string {
90
28
  const serialized = safeJsonStringify(input);
91
29
  if (!serialized || serialized === "{}" || serialized === "null") {
@@ -0,0 +1,63 @@
1
+ import { getNonEmptyString, toRecord } from "./common";
2
+ import { countTextLines, formatCount } from "./tool-input-preview";
3
+
4
+ export function getPromptPath(input: Record<string, unknown>): string | null {
5
+ return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
6
+ }
7
+
8
+ export function formatEditInputForPrompt(
9
+ input: Record<string, unknown>,
10
+ ): string {
11
+ const path = getPromptPath(input);
12
+ const rawEdits = Array.isArray(input.edits)
13
+ ? input.edits
14
+ : typeof input.oldText === "string" && typeof input.newText === "string"
15
+ ? [{ oldText: input.oldText, newText: input.newText }]
16
+ : [];
17
+
18
+ const edits = rawEdits
19
+ .map((edit) => toRecord(edit))
20
+ .filter(
21
+ (edit) =>
22
+ typeof edit.oldText === "string" && typeof edit.newText === "string",
23
+ );
24
+
25
+ const pathPart = path ? `for '${path}'` : "";
26
+ if (edits.length === 0) {
27
+ return pathPart ? `${pathPart} with edit input` : "with edit input";
28
+ }
29
+
30
+ const firstEdit = edits[0];
31
+ const oldText = String(firstEdit.oldText);
32
+ const newText = String(firstEdit.newText);
33
+ const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
34
+ const extraEdits =
35
+ edits.length > 1
36
+ ? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
37
+ : "";
38
+ const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
39
+ return pathPart ? `${pathPart} ${summary}` : summary;
40
+ }
41
+
42
+ export function formatWriteInputForPrompt(
43
+ input: Record<string, unknown>,
44
+ ): string {
45
+ const path = getPromptPath(input);
46
+ const content = typeof input.content === "string" ? input.content : "";
47
+ const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
48
+ return path ? `for '${path}' ${summary}` : summary;
49
+ }
50
+
51
+ export function formatReadInputForPrompt(
52
+ input: Record<string, unknown>,
53
+ ): string {
54
+ const path = getPromptPath(input);
55
+ const parts = path ? [`path '${path}'`] : [];
56
+ if (typeof input.offset === "number") {
57
+ parts.push(`offset ${input.offset}`);
58
+ }
59
+ if (typeof input.limit === "number") {
60
+ parts.push(`limit ${input.limit}`);
61
+ }
62
+ return parts.length > 0 ? `for ${parts.join(", ")}` : "";
63
+ }
@@ -2,16 +2,18 @@ import { getNonEmptyString, toRecord } from "./common";
2
2
  import type { PermissionSystemExtensionConfig } from "./extension-config";
3
3
  import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
4
4
  import {
5
- formatEditInputForPrompt,
6
- formatReadInputForPrompt,
7
- formatWriteInputForPrompt,
8
- getPromptPath,
9
5
  serializeToolInputPreview,
10
6
  TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
11
7
  TOOL_INPUT_PREVIEW_MAX_LENGTH,
12
8
  TOOL_TEXT_SUMMARY_MAX_LENGTH,
13
9
  truncateInlineText,
14
10
  } from "./tool-input-preview";
11
+ import {
12
+ formatEditInputForPrompt,
13
+ formatReadInputForPrompt,
14
+ formatWriteInputForPrompt,
15
+ getPromptPath,
16
+ } from "./tool-input-prompt-formatters";
15
17
  import type { PermissionCheckResult } from "./types";
16
18
 
17
19
  export interface ToolPreviewFormatterOptions {
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ type DecisionReporter,
5
+ GateDecisionReporter,
6
+ } from "#src/decision-reporter";
7
+ import {
8
+ PERMISSIONS_DECISION_CHANNEL,
9
+ type PermissionDecisionEvent,
10
+ } from "#src/permission-events";
11
+ import type { SessionLogger } from "#src/session-logger";
12
+
13
+ // ── fixtures ───────────────────────────────────────────────────────────────
14
+
15
+ function makeLogger(): SessionLogger {
16
+ return {
17
+ debug: vi.fn(),
18
+ review: vi.fn(),
19
+ warn: vi.fn(),
20
+ };
21
+ }
22
+
23
+ function makeEvents() {
24
+ return {
25
+ emit: vi.fn(),
26
+ on: vi.fn().mockReturnValue(() => undefined),
27
+ };
28
+ }
29
+
30
+ function makeDecisionEvent(
31
+ overrides: Partial<PermissionDecisionEvent> = {},
32
+ ): PermissionDecisionEvent {
33
+ return {
34
+ surface: "read",
35
+ value: "read",
36
+ result: "allow",
37
+ resolution: "policy_allow",
38
+ origin: "global",
39
+ agentName: null,
40
+ matchedPattern: null,
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ // ── tests ──────────────────────────────────────────────────────────────────
46
+
47
+ describe("GateDecisionReporter", () => {
48
+ it("satisfies the DecisionReporter interface", () => {
49
+ const reporter: DecisionReporter = new GateDecisionReporter(
50
+ makeLogger(),
51
+ makeEvents(),
52
+ );
53
+ expect(reporter).toBeDefined();
54
+ });
55
+
56
+ describe("writeReviewLog", () => {
57
+ it("delegates to logger.review with event and details", () => {
58
+ const logger = makeLogger();
59
+ const reporter = new GateDecisionReporter(logger, makeEvents());
60
+ reporter.writeReviewLog("permission_request.blocked", { tool: "bash" });
61
+ expect(logger.review).toHaveBeenCalledWith("permission_request.blocked", {
62
+ tool: "bash",
63
+ });
64
+ });
65
+
66
+ it("delegates with an empty details object", () => {
67
+ const logger = makeLogger();
68
+ const reporter = new GateDecisionReporter(logger, makeEvents());
69
+ reporter.writeReviewLog("permission_request.session_approved", {});
70
+ expect(logger.review).toHaveBeenCalledWith(
71
+ "permission_request.session_approved",
72
+ {},
73
+ );
74
+ });
75
+
76
+ it("does not call emitDecision", () => {
77
+ const events = makeEvents();
78
+ const reporter = new GateDecisionReporter(makeLogger(), events);
79
+ reporter.writeReviewLog("some.event", { key: "val" });
80
+ expect(events.emit).not.toHaveBeenCalled();
81
+ });
82
+ });
83
+
84
+ describe("emitDecision", () => {
85
+ it("emits on the PERMISSIONS_DECISION_CHANNEL with the event", () => {
86
+ const events = makeEvents();
87
+ const reporter = new GateDecisionReporter(makeLogger(), events);
88
+ const event = makeDecisionEvent();
89
+ reporter.emitDecision(event);
90
+ expect(events.emit).toHaveBeenCalledWith(
91
+ PERMISSIONS_DECISION_CHANNEL,
92
+ event,
93
+ );
94
+ });
95
+
96
+ it("does not call writeReviewLog", () => {
97
+ const logger = makeLogger();
98
+ const reporter = new GateDecisionReporter(logger, makeEvents());
99
+ reporter.emitDecision(makeDecisionEvent());
100
+ expect(logger.review).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it("does not propagate a throwing listener", () => {
104
+ const events = makeEvents();
105
+ events.emit.mockImplementation(() => {
106
+ throw new Error("listener boom");
107
+ });
108
+ const reporter = new GateDecisionReporter(makeLogger(), events);
109
+ expect(() => reporter.emitDecision(makeDecisionEvent())).not.toThrow();
110
+ });
111
+ });
112
+ });
@@ -265,6 +265,31 @@ describe("formatDenyReason", () => {
265
265
  );
266
266
  });
267
267
  });
268
+
269
+ describe("skill_input context", () => {
270
+ test("without agent", () => {
271
+ expect(
272
+ formatDenyReason({
273
+ kind: "skill_input",
274
+ skillName: "librarian",
275
+ }),
276
+ ).toBe(
277
+ "[pi-permission-system] Current agent is not permitted to access skill 'librarian'.",
278
+ );
279
+ });
280
+
281
+ test("with agent", () => {
282
+ expect(
283
+ formatDenyReason({
284
+ kind: "skill_input",
285
+ skillName: "librarian",
286
+ agentName: "my-agent",
287
+ }),
288
+ ).toBe(
289
+ "[pi-permission-system] Agent 'my-agent' is not permitted to access skill 'librarian'.",
290
+ );
291
+ });
292
+ });
268
293
  });
269
294
 
270
295
  // ── formatUnavailableReason ────────────────────────────────────────────────
@@ -353,6 +378,17 @@ describe("formatUnavailableReason", () => {
353
378
  "[pi-permission-system] Accessing skill 'librarian' requires approval, but no interactive UI is available.",
354
379
  );
355
380
  });
381
+
382
+ test("skill_input", () => {
383
+ expect(
384
+ formatUnavailableReason({
385
+ kind: "skill_input",
386
+ skillName: "librarian",
387
+ }),
388
+ ).toBe(
389
+ "[pi-permission-system] Accessing skill 'librarian' requires approval, but no interactive UI is available.",
390
+ );
391
+ });
356
392
  });
357
393
 
358
394
  // ── formatUserDeniedReason ─────────────────────────────────────────────────
@@ -530,4 +566,30 @@ describe("formatUserDeniedReason", () => {
530
566
  );
531
567
  });
532
568
  });
569
+
570
+ describe("skill_input context", () => {
571
+ test("without agent and without reason", () => {
572
+ expect(
573
+ formatUserDeniedReason({
574
+ kind: "skill_input",
575
+ skillName: "librarian",
576
+ }),
577
+ ).toBe("[pi-permission-system] User denied access to skill 'librarian'.");
578
+ });
579
+
580
+ test("with agent and with reason", () => {
581
+ expect(
582
+ formatUserDeniedReason(
583
+ {
584
+ kind: "skill_input",
585
+ skillName: "librarian",
586
+ agentName: "code-agent",
587
+ },
588
+ "not permitted",
589
+ ),
590
+ ).toBe(
591
+ "[pi-permission-system] User denied access to skill 'librarian'. Reason: not permitted.",
592
+ );
593
+ });
594
+ });
533
595
  });
@@ -4,13 +4,11 @@ import { ForwardingManager } from "#src/forwarding-manager";
4
4
 
5
5
  // ── Mocks ─────────────────────────────────────────────────────────────────
6
6
 
7
- const mockProcessForwardedPermissionRequests = vi.hoisted(() => vi.fn());
7
+ const mockProcessInbox = vi.hoisted(() =>
8
+ vi.fn((): Promise<void> => Promise.resolve()),
9
+ );
8
10
  const mockIsSubagentExecutionContext = vi.hoisted(() => vi.fn());
9
11
 
10
- vi.mock("../src/forwarded-permissions/polling", () => ({
11
- processForwardedPermissionRequests: mockProcessForwardedPermissionRequests,
12
- }));
13
-
14
12
  vi.mock("../src/subagent-context", () => ({
15
13
  isSubagentExecutionContext: mockIsSubagentExecutionContext,
16
14
  }));
@@ -27,22 +25,12 @@ function makeCtx(overrides: { hasUI?: boolean; sessionId?: string } = {}) {
27
25
  } as unknown as import("@earendil-works/pi-coding-agent").ExtensionContext;
28
26
  }
29
27
 
30
- function makeForwardingDeps() {
31
- return {
32
- forwardingDir: "/agent/sessions/permission-forwarding",
33
- subagentSessionsDir: "/agent/subagent-sessions",
34
- logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
35
- writeReviewLog: vi.fn(),
36
- requestPermissionDecisionFromUi: vi.fn(),
37
- shouldAutoApprove: vi.fn().mockReturnValue(false),
38
- } as unknown as import("../src/forwarded-permissions/polling").PermissionForwardingDeps;
28
+ function makeForwarder() {
29
+ return { processInbox: mockProcessInbox };
39
30
  }
40
31
 
41
32
  function makeManager() {
42
- return new ForwardingManager(
43
- "/agent/subagent-sessions",
44
- makeForwardingDeps(),
45
- );
33
+ return new ForwardingManager("/agent/subagent-sessions", makeForwarder());
46
34
  }
47
35
 
48
36
  // ── Tests ─────────────────────────────────────────────────────────────────
@@ -52,8 +40,8 @@ describe("ForwardingManager", () => {
52
40
  vi.useFakeTimers();
53
41
  mockIsSubagentExecutionContext.mockReset();
54
42
  mockIsSubagentExecutionContext.mockReturnValue(false);
55
- mockProcessForwardedPermissionRequests.mockReset();
56
- mockProcessForwardedPermissionRequests.mockResolvedValue(undefined);
43
+ mockProcessInbox.mockReset();
44
+ mockProcessInbox.mockResolvedValue(undefined);
57
45
  });
58
46
 
59
47
  afterEach(() => {
@@ -73,9 +61,9 @@ describe("ForwardingManager", () => {
73
61
  manager.stop();
74
62
 
75
63
  // After stop, the timer fires no more callbacks.
76
- mockProcessForwardedPermissionRequests.mockClear();
64
+ mockProcessInbox.mockClear();
77
65
  await vi.advanceTimersByTimeAsync(500);
78
- expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
66
+ expect(mockProcessInbox).not.toHaveBeenCalled();
79
67
  });
80
68
  });
81
69
 
@@ -86,7 +74,7 @@ describe("ForwardingManager", () => {
86
74
  manager.start(ctx);
87
75
 
88
76
  await vi.advanceTimersByTimeAsync(500);
89
- expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
77
+ expect(mockProcessInbox).not.toHaveBeenCalled();
90
78
  });
91
79
 
92
80
  it("stops any existing poll and does not start a new one when hasUI is false", async () => {
@@ -98,9 +86,9 @@ describe("ForwardingManager", () => {
98
86
  // Now stop the polling by calling start() with no-UI ctx.
99
87
  manager.start(noUiCtx);
100
88
 
101
- mockProcessForwardedPermissionRequests.mockClear();
89
+ mockProcessInbox.mockClear();
102
90
  await vi.advanceTimersByTimeAsync(500);
103
- expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
91
+ expect(mockProcessInbox).not.toHaveBeenCalled();
104
92
  });
105
93
 
106
94
  it("does not start polling when isSubagentExecutionContext returns true", async () => {
@@ -110,7 +98,7 @@ describe("ForwardingManager", () => {
110
98
  manager.start(ctx);
111
99
 
112
100
  await vi.advanceTimersByTimeAsync(500);
113
- expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
101
+ expect(mockProcessInbox).not.toHaveBeenCalled();
114
102
  });
115
103
 
116
104
  it("stops any existing poll when called with a subagent context", async () => {
@@ -124,21 +112,18 @@ describe("ForwardingManager", () => {
124
112
  const ctx2 = makeCtx();
125
113
  manager.start(ctx2);
126
114
 
127
- mockProcessForwardedPermissionRequests.mockClear();
115
+ mockProcessInbox.mockClear();
128
116
  await vi.advanceTimersByTimeAsync(500);
129
- expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
117
+ expect(mockProcessInbox).not.toHaveBeenCalled();
130
118
  });
131
119
 
132
- it("starts polling and calls processForwardedPermissionRequests on tick", async () => {
120
+ it("starts polling and calls processInbox on tick", async () => {
133
121
  const manager = makeManager();
134
122
  const ctx = makeCtx();
135
123
  manager.start(ctx);
136
124
 
137
125
  await vi.advanceTimersByTimeAsync(250);
138
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
139
- ctx,
140
- expect.anything(),
141
- );
126
+ expect(mockProcessInbox).toHaveBeenCalledWith(ctx);
142
127
  });
143
128
 
144
129
  it("is idempotent — calling start() twice does not create a second timer", async () => {
@@ -149,7 +134,7 @@ describe("ForwardingManager", () => {
149
134
 
150
135
  await vi.advanceTimersByTimeAsync(250);
151
136
  // Only one tick should fire per interval, not two.
152
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
137
+ expect(mockProcessInbox).toHaveBeenCalledTimes(1);
153
138
  });
154
139
 
155
140
  it("updates the context when called again while already running", async () => {
@@ -161,16 +146,13 @@ describe("ForwardingManager", () => {
161
146
 
162
147
  await vi.advanceTimersByTimeAsync(250);
163
148
  // The process call should use the newer context.
164
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
165
- ctx2,
166
- expect.anything(),
167
- );
149
+ expect(mockProcessInbox).toHaveBeenCalledWith(ctx2);
168
150
  });
169
151
 
170
152
  it("skips a tick while processing is in progress", async () => {
171
- // Make processForwardedPermissionRequests hang so processing=true persists.
153
+ // Make processInbox hang so processing=true persists.
172
154
  let resolveProcess: () => void;
173
- mockProcessForwardedPermissionRequests.mockReturnValue(
155
+ mockProcessInbox.mockReturnValue(
174
156
  new Promise<void>((resolve) => {
175
157
  resolveProcess = resolve;
176
158
  }),
@@ -182,22 +164,22 @@ describe("ForwardingManager", () => {
182
164
 
183
165
  // First tick starts processing.
184
166
  await vi.advanceTimersByTimeAsync(250);
185
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
167
+ expect(mockProcessInbox).toHaveBeenCalledTimes(1);
186
168
 
187
169
  // Second tick is skipped because processing flag is still true.
188
170
  await vi.advanceTimersByTimeAsync(250);
189
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
171
+ expect(mockProcessInbox).toHaveBeenCalledTimes(1);
190
172
 
191
173
  // Resolve and a third tick should fire.
192
174
  resolveProcess!();
193
175
  await vi.advanceTimersByTimeAsync(250);
194
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(2);
176
+ expect(mockProcessInbox).toHaveBeenCalledTimes(2);
195
177
  });
196
178
 
197
179
  it("passes subagentSessionsDir from the constructor to isSubagentExecutionContext", () => {
198
180
  const manager = new ForwardingManager(
199
181
  "/custom/subagent-dir",
200
- makeForwardingDeps(),
182
+ makeForwarder(),
201
183
  );
202
184
  const ctx = makeCtx();
203
185
  manager.start(ctx);