@gotgenes/pi-permission-system 5.8.0 → 5.9.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,19 @@ 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
+ ## [5.9.0](https://github.com/gotgenes/pi-permission-system/compare/v5.8.0...v5.9.0) (2026-05-08)
9
+
10
+
11
+ ### Features
12
+
13
+ * add ForwardingManager class ([#128](https://github.com/gotgenes/pi-permission-system/issues/128)) ([7790380](https://github.com/gotgenes/pi-permission-system/commit/7790380eb0291f55724425a0bd6bd0b45cf15d91))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan ForwardingManager extraction ([#128](https://github.com/gotgenes/pi-permission-system/issues/128)) ([2f10450](https://github.com/gotgenes/pi-permission-system/commit/2f10450974adaedd7a43e8a7d986f8f61a0508db))
19
+ * **retro:** add retro notes for issue [#127](https://github.com/gotgenes/pi-permission-system/issues/127) ([2dde534](https://github.com/gotgenes/pi-permission-system/commit/2dde53416c535331972367ca2a44ba302b25d2a0))
20
+
8
21
  ## [5.8.0](https://github.com/gotgenes/pi-permission-system/compare/v5.7.0...v5.8.0) (2026-05-08)
9
22
 
10
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.8.0",
3
+ "version": "5.9.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,76 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
4
+ import { processForwardedPermissionRequests } from "./forwarded-permissions/polling";
5
+ import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
6
+ import { isSubagentExecutionContext } from "./subagent-context";
7
+
8
+ /**
9
+ * Narrow interface for the forwarding lifecycle used by `HandlerDeps`.
10
+ * `ForwardingManager` satisfies it; tests can provide a plain object mock.
11
+ */
12
+ export interface ForwardingController {
13
+ start(ctx: ExtensionContext): void;
14
+ stop(): void;
15
+ }
16
+
17
+ /**
18
+ * Encapsulates the forwarded-permission polling lifecycle.
19
+ *
20
+ * Owns the timer, current context, and processing-lock state that previously
21
+ * lived as 3 mutable fields on `ExtensionRuntime`. Call `start(ctx)` on each
22
+ * session event that may activate forwarding; call `stop()` on session
23
+ * shutdown.
24
+ */
25
+ export class ForwardingManager {
26
+ private timer: NodeJS.Timeout | null = null;
27
+ private context: ExtensionContext | null = null;
28
+ private processing = false;
29
+
30
+ constructor(
31
+ private readonly subagentSessionsDir: string,
32
+ private readonly forwardingDeps: PermissionForwardingDeps,
33
+ ) {}
34
+
35
+ /**
36
+ * Start polling if `ctx` has UI and is not a subagent execution context.
37
+ * No-op (timer stays running) if already polling — updates the stored
38
+ * context so the next tick uses the latest session.
39
+ * Stops any existing poll when the context does not qualify for forwarding.
40
+ */
41
+ start(ctx: ExtensionContext): void {
42
+ if (
43
+ !ctx.hasUI ||
44
+ isSubagentExecutionContext(ctx, this.subagentSessionsDir)
45
+ ) {
46
+ this.stop();
47
+ return;
48
+ }
49
+ this.context = ctx;
50
+ if (this.timer) {
51
+ return;
52
+ }
53
+ this.timer = setInterval(() => {
54
+ if (!this.context || this.processing) {
55
+ return;
56
+ }
57
+ this.processing = true;
58
+ void processForwardedPermissionRequests(
59
+ this.context,
60
+ this.forwardingDeps,
61
+ ).finally(() => {
62
+ this.processing = false;
63
+ });
64
+ }, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
65
+ }
66
+
67
+ /** Stop polling and clear all internal state. */
68
+ stop(): void {
69
+ if (this.timer) {
70
+ clearInterval(this.timer);
71
+ this.timer = null;
72
+ }
73
+ this.context = null;
74
+ this.processing = false;
75
+ }
76
+ }
@@ -43,7 +43,7 @@ export async function handleBeforeAgentStart(
43
43
  ): Promise<BeforeAgentStartEventResult> {
44
44
  deps.session.runtimeContext = ctx;
45
45
  deps.refreshExtensionConfig(ctx);
46
- deps.startForwardedPermissionPolling(ctx);
46
+ deps.forwarding.start(ctx);
47
47
 
48
48
  const agentName = deps.resolveAgentName(ctx, event.systemPrompt);
49
49
  const { permissionManager } = deps.session;
@@ -41,7 +41,7 @@ export async function handleInput(
41
41
  ctx: ExtensionContext,
42
42
  ): Promise<InputEventResult> {
43
43
  deps.session.runtimeContext = ctx;
44
- deps.startForwardedPermissionPolling(ctx);
44
+ deps.forwarding.start(ctx);
45
45
 
46
46
  const skillName = extractSkillNameFromInput(event.text);
47
47
  if (!skillName) {
@@ -26,7 +26,7 @@ export async function handleSessionStart(
26
26
  deps.session.lastActiveToolsCacheKey = null;
27
27
  deps.session.lastPromptStateCacheKey = null;
28
28
  deps.session.lastKnownActiveAgentName = getActiveAgentName(ctx);
29
- deps.startForwardedPermissionPolling(ctx);
29
+ deps.forwarding.start(ctx);
30
30
  deps.logResolvedConfigPaths();
31
31
 
32
32
  const agentName = deps.session.lastKnownActiveAgentName;
@@ -77,6 +77,6 @@ export async function handleSessionShutdown(deps: HandlerDeps): Promise<void> {
77
77
  deps.session.lastActiveToolsCacheKey = null;
78
78
  deps.session.lastPromptStateCacheKey = null;
79
79
  deps.session.sessionRules.clear();
80
- deps.stopForwardedPermissionPolling();
80
+ deps.forwarding.stop();
81
81
  deps.stopPermissionRpcHandlers();
82
82
  }
@@ -44,7 +44,7 @@ export async function handleToolCall(
44
44
  ctx: ExtensionContext,
45
45
  ): Promise<{ block?: true; reason?: string }> {
46
46
  deps.session.runtimeContext = ctx;
47
- deps.startForwardedPermissionPolling(ctx);
47
+ deps.forwarding.start(ctx);
48
48
 
49
49
  const agentName = deps.resolveAgentName(ctx);
50
50
  const toolName = getToolNameFromValue(event);
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
 
3
+ import type { ForwardingController } from "../forwarding-manager";
3
4
  import type { PermissionPromptDecision } from "../permission-dialog";
4
5
  import type { PermissionEventBus } from "../permission-events";
5
6
  import type { PermissionManager } from "../permission-manager";
@@ -79,8 +80,7 @@ export interface HandlerDeps {
79
80
  createPermissionRequestId(prefix: string): string;
80
81
 
81
82
  // ── Forwarding ─────────────────────────────────────────────────────────
82
- startForwardedPermissionPolling(ctx: ExtensionContext): void;
83
- stopForwardedPermissionPolling(): void;
83
+ readonly forwarding: ForwardingController;
84
84
  /** Unsubscribe the permissions:rpc:check and permissions:rpc:prompt handlers. */
85
85
  stopPermissionRpcHandlers(): void;
86
86
 
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { registerPermissionSystemCommand } from "./config-modal";
3
3
  import { getGlobalConfigPath } from "./config-paths";
4
4
  import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
5
+ import { ForwardingManager } from "./forwarding-manager";
5
6
  import {
6
7
  type HandlerDeps,
7
8
  handleBeforeAgentStart,
@@ -22,8 +23,6 @@ import {
22
23
  refreshExtensionConfig,
23
24
  resolveAgentName,
24
25
  saveExtensionConfig,
25
- startForwardedPermissionPolling,
26
- stopForwardedPermissionPolling,
27
26
  } from "./runtime";
28
27
  import { createSessionLogger } from "./session-logger";
29
28
  import { isSubagentExecutionContext } from "./subagent-context";
@@ -102,10 +101,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
102
101
  }),
103
102
  promptPermission: (ctx, details) => prompter.prompt(ctx, details),
104
103
  createPermissionRequestId,
105
- startForwardedPermissionPolling: (ctx) =>
106
- startForwardedPermissionPolling(runtime, forwardingDeps, ctx),
107
- stopForwardedPermissionPolling: () =>
108
- stopForwardedPermissionPolling(runtime),
104
+ forwarding: new ForwardingManager(
105
+ runtime.subagentSessionsDir,
106
+ forwardingDeps,
107
+ ),
109
108
  stopPermissionRpcHandlers: () => {
110
109
  rpcHandles.unsubCheck();
111
110
  rpcHandles.unsubPrompt();
package/src/runtime.ts CHANGED
@@ -38,17 +38,11 @@ import { computeExtensionPaths, type ExtensionPaths } from "./extension-paths";
38
38
 
39
39
  export type { ExtensionPaths } from "./extension-paths";
40
40
 
41
- import {
42
- type PermissionForwardingDeps,
43
- processForwardedPermissionRequests,
44
- } from "./forwarded-permissions/polling";
45
41
  import { createPermissionSystemLogger } from "./logging";
46
- import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
47
42
  import { PermissionManager } from "./permission-manager";
48
43
  import { SessionRules } from "./session-rules";
49
44
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
50
45
  import { syncPermissionSystemStatus } from "./status";
51
- import { isSubagentExecutionContext } from "./subagent-context";
52
46
 
53
47
  /**
54
48
  * Mutable session state — the subset of ExtensionRuntime that handlers
@@ -81,11 +75,6 @@ export interface ExtensionRuntime extends ExtensionPaths, SessionState {
81
75
  config: PermissionSystemExtensionConfig;
82
76
  lastConfigWarning: string | null;
83
77
 
84
- // ── Forwarding polling state ───────────────────────────────────────────
85
- permissionForwardingContext: ExtensionContext | null;
86
- permissionForwardingTimer: NodeJS.Timeout | null;
87
- isProcessingForwardedRequests: boolean;
88
-
89
78
  // ── Logging (backed by logger created at construction) ─────────────────
90
79
  writeDebugLog(event: string, details?: Record<string, unknown>): void;
91
80
  writeReviewLog(event: string, details?: Record<string, unknown>): void;
@@ -277,58 +266,6 @@ export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
277
266
  );
278
267
  }
279
268
 
280
- // ── Forwarding polling lifecycle ───────────────────────────────────────────
281
-
282
- /** Stop the forwarded-permission polling interval and clear related state. */
283
- export function stopForwardedPermissionPolling(
284
- runtime: ExtensionRuntime,
285
- ): void {
286
- if (runtime.permissionForwardingTimer) {
287
- clearInterval(runtime.permissionForwardingTimer);
288
- runtime.permissionForwardingTimer = null;
289
- }
290
- runtime.permissionForwardingContext = null;
291
- runtime.isProcessingForwardedRequests = false;
292
- }
293
-
294
- /**
295
- * Start the forwarded-permission polling interval.
296
- * No-ops (and stops any existing poll) when the context has no UI or is a
297
- * subagent execution context.
298
- */
299
- export function startForwardedPermissionPolling(
300
- runtime: ExtensionRuntime,
301
- forwardingDeps: PermissionForwardingDeps,
302
- ctx: ExtensionContext,
303
- ): void {
304
- if (
305
- !ctx.hasUI ||
306
- isSubagentExecutionContext(ctx, runtime.subagentSessionsDir)
307
- ) {
308
- stopForwardedPermissionPolling(runtime);
309
- return;
310
- }
311
- runtime.permissionForwardingContext = ctx;
312
- if (runtime.permissionForwardingTimer) {
313
- return;
314
- }
315
- runtime.permissionForwardingTimer = setInterval(() => {
316
- if (
317
- !runtime.permissionForwardingContext ||
318
- runtime.isProcessingForwardedRequests
319
- ) {
320
- return;
321
- }
322
- runtime.isProcessingForwardedRequests = true;
323
- void processForwardedPermissionRequests(
324
- runtime.permissionForwardingContext,
325
- forwardingDeps,
326
- ).finally(() => {
327
- runtime.isProcessingForwardedRequests = false;
328
- });
329
- }, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
330
- }
331
-
332
269
  // ── Factory ────────────────────────────────────────────────────────────────
333
270
 
334
271
  /**
@@ -356,9 +293,6 @@ export function createExtensionRuntime(options?: {
356
293
  lastPromptStateCacheKey: null,
357
294
  lastConfigWarning: null,
358
295
  sessionRules: new SessionRules(),
359
- permissionForwardingContext: null,
360
- permissionForwardingTimer: null,
361
- isProcessingForwardedRequests: false,
362
296
  // Logging methods are replaced below after the logger is constructed.
363
297
  writeDebugLog: () => {},
364
298
  writeReviewLog: () => {},
@@ -0,0 +1,211 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { ForwardingManager } from "../src/forwarding-manager";
4
+
5
+ // ── Mocks ─────────────────────────────────────────────────────────────────
6
+
7
+ const mockProcessForwardedPermissionRequests = vi.hoisted(() => vi.fn());
8
+ const mockIsSubagentExecutionContext = vi.hoisted(() => vi.fn());
9
+
10
+ vi.mock("../src/forwarded-permissions/polling", () => ({
11
+ processForwardedPermissionRequests: mockProcessForwardedPermissionRequests,
12
+ }));
13
+
14
+ vi.mock("../src/subagent-context", () => ({
15
+ isSubagentExecutionContext: mockIsSubagentExecutionContext,
16
+ }));
17
+
18
+ // ── Helpers ───────────────────────────────────────────────────────────────
19
+
20
+ function makeCtx(overrides: { hasUI?: boolean; sessionId?: string } = {}) {
21
+ return {
22
+ hasUI: overrides.hasUI ?? true,
23
+ sessionManager: {
24
+ getSessionId: vi.fn().mockReturnValue(overrides.sessionId ?? "sess-1"),
25
+ },
26
+ cwd: "/project",
27
+ } as unknown as import("@mariozechner/pi-coding-agent").ExtensionContext;
28
+ }
29
+
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;
39
+ }
40
+
41
+ function makeManager() {
42
+ return new ForwardingManager(
43
+ "/agent/subagent-sessions",
44
+ makeForwardingDeps(),
45
+ );
46
+ }
47
+
48
+ // ── Tests ─────────────────────────────────────────────────────────────────
49
+
50
+ describe("ForwardingManager", () => {
51
+ beforeEach(() => {
52
+ vi.useFakeTimers();
53
+ mockIsSubagentExecutionContext.mockReset();
54
+ mockIsSubagentExecutionContext.mockReturnValue(false);
55
+ mockProcessForwardedPermissionRequests.mockReset();
56
+ mockProcessForwardedPermissionRequests.mockResolvedValue(undefined);
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.useRealTimers();
61
+ });
62
+
63
+ describe("stop()", () => {
64
+ it("is a no-op when not started", () => {
65
+ const manager = makeManager();
66
+ expect(() => manager.stop()).not.toThrow();
67
+ });
68
+
69
+ it("clears the timer and processing state after start()", async () => {
70
+ const manager = makeManager();
71
+ const ctx = makeCtx();
72
+ manager.start(ctx);
73
+ manager.stop();
74
+
75
+ // After stop, the timer fires no more callbacks.
76
+ mockProcessForwardedPermissionRequests.mockClear();
77
+ await vi.advanceTimersByTimeAsync(500);
78
+ expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
79
+ });
80
+ });
81
+
82
+ describe("start()", () => {
83
+ it("does not start polling when hasUI is false", async () => {
84
+ const manager = makeManager();
85
+ const ctx = makeCtx({ hasUI: false });
86
+ manager.start(ctx);
87
+
88
+ await vi.advanceTimersByTimeAsync(500);
89
+ expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("stops any existing poll and does not start a new one when hasUI is false", async () => {
93
+ const manager = makeManager();
94
+ const uiCtx = makeCtx({ hasUI: true });
95
+ const noUiCtx = makeCtx({ hasUI: false });
96
+
97
+ manager.start(uiCtx);
98
+ // Now stop the polling by calling start() with no-UI ctx.
99
+ manager.start(noUiCtx);
100
+
101
+ mockProcessForwardedPermissionRequests.mockClear();
102
+ await vi.advanceTimersByTimeAsync(500);
103
+ expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("does not start polling when isSubagentExecutionContext returns true", async () => {
107
+ mockIsSubagentExecutionContext.mockReturnValue(true);
108
+ const manager = makeManager();
109
+ const ctx = makeCtx();
110
+ manager.start(ctx);
111
+
112
+ await vi.advanceTimersByTimeAsync(500);
113
+ expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it("stops any existing poll when called with a subagent context", async () => {
117
+ mockIsSubagentExecutionContext.mockReturnValueOnce(false);
118
+ const manager = makeManager();
119
+ const ctx1 = makeCtx();
120
+ manager.start(ctx1);
121
+
122
+ // Second call with a subagent context.
123
+ mockIsSubagentExecutionContext.mockReturnValue(true);
124
+ const ctx2 = makeCtx();
125
+ manager.start(ctx2);
126
+
127
+ mockProcessForwardedPermissionRequests.mockClear();
128
+ await vi.advanceTimersByTimeAsync(500);
129
+ expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it("starts polling and calls processForwardedPermissionRequests on tick", async () => {
133
+ const manager = makeManager();
134
+ const ctx = makeCtx();
135
+ manager.start(ctx);
136
+
137
+ await vi.advanceTimersByTimeAsync(250);
138
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
139
+ ctx,
140
+ expect.anything(),
141
+ );
142
+ });
143
+
144
+ it("is idempotent — calling start() twice does not create a second timer", async () => {
145
+ const manager = makeManager();
146
+ const ctx = makeCtx();
147
+ manager.start(ctx);
148
+ manager.start(ctx);
149
+
150
+ await vi.advanceTimersByTimeAsync(250);
151
+ // Only one tick should fire per interval, not two.
152
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
153
+ });
154
+
155
+ it("updates the context when called again while already running", async () => {
156
+ const manager = makeManager();
157
+ const ctx1 = makeCtx({ sessionId: "sess-1" });
158
+ const ctx2 = makeCtx({ sessionId: "sess-2" });
159
+ manager.start(ctx1);
160
+ manager.start(ctx2);
161
+
162
+ await vi.advanceTimersByTimeAsync(250);
163
+ // The process call should use the newer context.
164
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
165
+ ctx2,
166
+ expect.anything(),
167
+ );
168
+ });
169
+
170
+ it("skips a tick while processing is in progress", async () => {
171
+ // Make processForwardedPermissionRequests hang so processing=true persists.
172
+ let resolveProcess: () => void;
173
+ mockProcessForwardedPermissionRequests.mockReturnValue(
174
+ new Promise<void>((resolve) => {
175
+ resolveProcess = resolve;
176
+ }),
177
+ );
178
+
179
+ const manager = makeManager();
180
+ const ctx = makeCtx();
181
+ manager.start(ctx);
182
+
183
+ // First tick starts processing.
184
+ await vi.advanceTimersByTimeAsync(250);
185
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
186
+
187
+ // Second tick is skipped because processing flag is still true.
188
+ await vi.advanceTimersByTimeAsync(250);
189
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
190
+
191
+ // Resolve and a third tick should fire.
192
+ resolveProcess!();
193
+ await vi.advanceTimersByTimeAsync(250);
194
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(2);
195
+ });
196
+
197
+ it("passes subagentSessionsDir from the constructor to isSubagentExecutionContext", () => {
198
+ const manager = new ForwardingManager(
199
+ "/custom/subagent-dir",
200
+ makeForwardingDeps(),
201
+ );
202
+ const ctx = makeCtx();
203
+ manager.start(ctx);
204
+
205
+ expect(mockIsSubagentExecutionContext).toHaveBeenCalledWith(
206
+ ctx,
207
+ "/custom/subagent-dir",
208
+ );
209
+ });
210
+ });
211
+ });
@@ -90,8 +90,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
90
90
  .mockResolvedValue({ approved: true, state: "approved" }),
91
91
  createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
92
92
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
93
- startForwardedPermissionPolling: vi.fn(),
94
- stopForwardedPermissionPolling: vi.fn(),
93
+ forwarding: { start: vi.fn(), stop: vi.fn() },
95
94
  stopPermissionRpcHandlers: vi.fn(),
96
95
  getAllTools: vi.fn().mockReturnValue([]),
97
96
  setActiveTools: vi.fn(),
@@ -144,7 +143,7 @@ describe("handleBeforeAgentStart", () => {
144
143
  const ctx = makeCtx();
145
144
  const deps = makeDeps();
146
145
  await handleBeforeAgentStart(deps, makeEvent(), ctx);
147
- expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
146
+ expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
148
147
  });
149
148
 
150
149
  it("resolves agent name using systemPrompt", async () => {
@@ -81,8 +81,7 @@ function makeDeps(
81
81
  .fn()
82
82
  .mockResolvedValue({ approved: true, state: "approved" }),
83
83
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
84
- startForwardedPermissionPolling: vi.fn(),
85
- stopForwardedPermissionPolling: vi.fn(),
84
+ forwarding: { start: vi.fn(), stop: vi.fn() },
86
85
  stopPermissionRpcHandlers: vi.fn(),
87
86
  getAllTools: vi.fn().mockReturnValue([]),
88
87
  setActiveTools: vi.fn(),
@@ -69,8 +69,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
69
69
  .mockResolvedValue({ approved: true, state: "approved" }),
70
70
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
71
71
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
72
- startForwardedPermissionPolling: vi.fn(),
73
- stopForwardedPermissionPolling: vi.fn(),
72
+ forwarding: { start: vi.fn(), stop: vi.fn() },
74
73
  stopPermissionRpcHandlers: vi.fn(),
75
74
  getAllTools: vi.fn().mockReturnValue([]),
76
75
  setActiveTools: vi.fn(),
@@ -126,7 +125,7 @@ describe("handleInput", () => {
126
125
  const ctx = makeCtx();
127
126
  const deps = makeDeps();
128
127
  await handleInput(deps, makeInputEvent("hello"), ctx);
129
- expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
128
+ expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
130
129
  });
131
130
 
132
131
  it("returns continue for non-skill input", async () => {
@@ -98,8 +98,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
98
98
  .mockResolvedValue({ approved: true, state: "approved" }),
99
99
  createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
100
100
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
101
- startForwardedPermissionPolling: vi.fn(),
102
- stopForwardedPermissionPolling: vi.fn(),
101
+ forwarding: { start: vi.fn(), stop: vi.fn() },
103
102
  stopPermissionRpcHandlers: vi.fn(),
104
103
  getAllTools: vi.fn().mockReturnValue([]),
105
104
  setActiveTools: vi.fn(),
@@ -171,7 +170,7 @@ describe("handleSessionStart", () => {
171
170
  const ctx = makeCtx();
172
171
  const deps = makeDeps();
173
172
  await handleSessionStart(deps, { reason: "startup" }, ctx);
174
- expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
173
+ expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
175
174
  });
176
175
 
177
176
  it("logs resolved config paths", async () => {
@@ -319,7 +318,7 @@ describe("handleSessionShutdown", () => {
319
318
  it("stops forwarded permission polling", async () => {
320
319
  const deps = makeDeps();
321
320
  await handleSessionShutdown(deps);
322
- expect(deps.stopForwardedPermissionPolling).toHaveBeenCalledOnce();
321
+ expect(deps.forwarding.stop).toHaveBeenCalledOnce();
323
322
  });
324
323
 
325
324
  it("calls stopPermissionRpcHandlers on shutdown", async () => {
@@ -102,8 +102,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
102
102
  .fn()
103
103
  .mockResolvedValue({ approved: true, state: "approved" }),
104
104
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
105
- startForwardedPermissionPolling: vi.fn(),
106
- stopForwardedPermissionPolling: vi.fn(),
105
+ forwarding: { start: vi.fn(), stop: vi.fn() },
107
106
  stopPermissionRpcHandlers: vi.fn(),
108
107
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
109
108
  setActiveTools: vi.fn(),
@@ -90,8 +90,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
90
90
  .mockResolvedValue({ approved: true, state: "approved" }),
91
91
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
92
92
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
93
- startForwardedPermissionPolling: vi.fn(),
94
- stopForwardedPermissionPolling: vi.fn(),
93
+ forwarding: { start: vi.fn(), stop: vi.fn() },
95
94
  stopPermissionRpcHandlers: vi.fn(),
96
95
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
97
96
  setActiveTools: vi.fn(),
@@ -139,7 +138,7 @@ describe("handleToolCall", () => {
139
138
  const ctx = makeCtx();
140
139
  const deps = makeDeps();
141
140
  await handleToolCall(deps, makeToolCallEvent("read"), ctx);
142
- expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
141
+ expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
143
142
  });
144
143
 
145
144
  it("blocks when tool name cannot be resolved", async () => {
@@ -212,21 +212,6 @@ describe("createExtensionRuntime", () => {
212
212
  expect(runtime.lastConfigWarning).toBeNull();
213
213
  });
214
214
 
215
- it("initializes permissionForwardingContext to null", () => {
216
- const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
217
- expect(runtime.permissionForwardingContext).toBeNull();
218
- });
219
-
220
- it("initializes permissionForwardingTimer to null", () => {
221
- const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
222
- expect(runtime.permissionForwardingTimer).toBeNull();
223
- });
224
-
225
- it("initializes isProcessingForwardedRequests to false", () => {
226
- const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
227
- expect(runtime.isProcessingForwardedRequests).toBe(false);
228
- });
229
-
230
215
  it("creates a sessionRules instance", () => {
231
216
  const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
232
217
  expect(runtime.sessionRules).toBeDefined();