@gotgenes/pi-permission-system 5.8.0 → 5.10.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,252 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ import {
4
+ getActiveAgentName,
5
+ getActiveAgentNameFromSystemPrompt,
6
+ } from "./active-agent";
7
+ import type { PermissionSystemExtensionConfig } from "./extension-config";
8
+ import type { ExtensionPaths } from "./extension-paths";
9
+ import type { ForwardingController } from "./forwarding-manager";
10
+ import type { PermissionManager } from "./permission-manager";
11
+ import type { Rule } from "./rule";
12
+ import { createPermissionManagerForCwd } from "./runtime";
13
+ import type { SessionLogger } from "./session-logger";
14
+ import { SessionRules } from "./session-rules";
15
+ import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
16
+ import type { PermissionCheckResult, PermissionState } from "./types";
17
+
18
+ /**
19
+ * Runtime operations that `PermissionSession` delegates to but does not own.
20
+ *
21
+ * Injected at construction time from the composition root (`index.ts`),
22
+ * where the `ExtensionRuntime` is available.
23
+ */
24
+ export interface PermissionSessionRuntimeDeps {
25
+ /** Reload merged config from disk; optionally update the stored runtime context. */
26
+ refreshExtensionConfig(ctx?: ExtensionContext): void;
27
+ /** Write the resolved config path set to the review and debug logs. */
28
+ logResolvedConfigPaths(): void;
29
+ /** Read current extension config (called at query time). */
30
+ getConfig(): PermissionSystemExtensionConfig;
31
+ }
32
+
33
+ /**
34
+ * Encapsulates all mutable session state and exposes operations instead of
35
+ * fields.
36
+ *
37
+ * Replaces the `SessionState` interface + scattered handler field mutations
38
+ * with a single class that owns the `PermissionManager`, `SessionRules`,
39
+ * cache keys, skill entries, and runtime context.
40
+ *
41
+ * Constructor deps:
42
+ * - `ExtensionPaths` — immutable path constants
43
+ * - `SessionLogger` — debug + review + warn
44
+ * - `ForwardingController` — polling lifecycle
45
+ * - `PermissionSessionRuntimeDeps` — config refresh + log delegates
46
+ */
47
+ export class PermissionSession {
48
+ private context: ExtensionContext | null = null;
49
+ private permissionManager: PermissionManager;
50
+ private readonly sessionRules = new SessionRules();
51
+ private skillEntries: SkillPromptEntry[] = [];
52
+ private knownAgentName: string | null = null;
53
+ private toolsCacheKey: string | null = null;
54
+ private promptCacheKey: string | null = null;
55
+
56
+ constructor(
57
+ private readonly paths: ExtensionPaths,
58
+ readonly logger: SessionLogger,
59
+ private readonly forwarding: ForwardingController,
60
+ private readonly runtimeDeps: PermissionSessionRuntimeDeps,
61
+ ) {
62
+ this.permissionManager = createPermissionManagerForCwd(
63
+ paths.agentDir,
64
+ undefined,
65
+ );
66
+ }
67
+
68
+ // ── Context lifecycle ──────────────────────────────────────────────────
69
+
70
+ /** Store the current extension context and start forwarding. */
71
+ activate(ctx: ExtensionContext): void {
72
+ this.context = ctx;
73
+ this.forwarding.start(ctx);
74
+ }
75
+
76
+ /** Clear the context and stop forwarding. */
77
+ deactivate(): void {
78
+ this.context = null;
79
+ this.forwarding.stop();
80
+ }
81
+
82
+ /** Return the current runtime context, or null if not activated. */
83
+ getRuntimeContext(): ExtensionContext | null {
84
+ return this.context;
85
+ }
86
+
87
+ // ── Permission checking (delegates to PermissionManager) ───────────────
88
+
89
+ checkPermission(
90
+ surface: string,
91
+ input: unknown,
92
+ agentName?: string,
93
+ sessionRules?: Rule[],
94
+ ): PermissionCheckResult {
95
+ return this.permissionManager.checkPermission(
96
+ surface,
97
+ input,
98
+ agentName,
99
+ sessionRules,
100
+ );
101
+ }
102
+
103
+ getToolPermission(toolName: string, agentName?: string): PermissionState {
104
+ return this.permissionManager.getToolPermission(toolName, agentName);
105
+ }
106
+
107
+ getConfigIssues(agentName?: string): string[] {
108
+ return this.permissionManager.getConfigIssues(agentName);
109
+ }
110
+
111
+ getPolicyCacheStamp(agentName?: string): string {
112
+ return this.permissionManager.getPolicyCacheStamp(agentName);
113
+ }
114
+
115
+ // ── Session rules (delegates to SessionRules) ──────────────────────────
116
+
117
+ getSessionRuleset(): Rule[] {
118
+ return this.sessionRules.getRuleset();
119
+ }
120
+
121
+ approveSessionRule(surface: string, pattern: string): void {
122
+ this.sessionRules.approve(surface, pattern);
123
+ }
124
+
125
+ // ── Session lifecycle ────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Reset all mutable state for a new session.
129
+ *
130
+ * Creates a fresh PermissionManager scoped to `ctx.cwd`, clears caches,
131
+ * skill entries, and activates the new context.
132
+ */
133
+ resetForNewSession(ctx: ExtensionContext): void {
134
+ this.permissionManager = createPermissionManagerForCwd(
135
+ this.paths.agentDir,
136
+ ctx.cwd,
137
+ );
138
+ this.skillEntries = [];
139
+ this.toolsCacheKey = null;
140
+ this.promptCacheKey = null;
141
+ this.activate(ctx);
142
+ }
143
+
144
+ /**
145
+ * Shut down the session: clear rules, caches, skill entries, and
146
+ * deactivate context + forwarding.
147
+ */
148
+ shutdown(): void {
149
+ this.sessionRules.clear();
150
+ this.skillEntries = [];
151
+ this.toolsCacheKey = null;
152
+ this.promptCacheKey = null;
153
+ this.deactivate();
154
+ }
155
+
156
+ /**
157
+ * Reload permission manager and clear caches for the current context.
158
+ * Used on config reload (e.g. `resources_discover` with reason "reload").
159
+ */
160
+ reload(): void {
161
+ this.permissionManager = createPermissionManagerForCwd(
162
+ this.paths.agentDir,
163
+ this.context?.cwd,
164
+ );
165
+ this.skillEntries = [];
166
+ this.toolsCacheKey = null;
167
+ this.promptCacheKey = null;
168
+ }
169
+
170
+ // ── Agent-start caching ────────────────────────────────────────────────
171
+
172
+ shouldUpdateActiveTools(cacheKey: string): boolean {
173
+ return this.toolsCacheKey !== cacheKey;
174
+ }
175
+
176
+ commitActiveToolsCacheKey(cacheKey: string): void {
177
+ this.toolsCacheKey = cacheKey;
178
+ }
179
+
180
+ shouldUpdatePromptState(cacheKey: string): boolean {
181
+ return this.promptCacheKey !== cacheKey;
182
+ }
183
+
184
+ commitPromptStateCacheKey(cacheKey: string): void {
185
+ this.promptCacheKey = cacheKey;
186
+ }
187
+
188
+ // ── Skill entries ──────────────────────────────────────────────────────
189
+
190
+ getActiveSkillEntries(): SkillPromptEntry[] {
191
+ return this.skillEntries;
192
+ }
193
+
194
+ setActiveSkillEntries(entries: SkillPromptEntry[]): void {
195
+ this.skillEntries = entries;
196
+ }
197
+
198
+ // ── Agent name ─────────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Resolve the active agent name from the session context, system prompt,
202
+ * or last known name. Updates lastKnownActiveAgentName as a side effect.
203
+ */
204
+ resolveAgentName(
205
+ ctx: ExtensionContext,
206
+ systemPrompt?: string,
207
+ ): string | null {
208
+ const fromSession = getActiveAgentName(ctx);
209
+ if (fromSession) {
210
+ this.knownAgentName = fromSession;
211
+ return fromSession;
212
+ }
213
+ const fromSystemPrompt = getActiveAgentNameFromSystemPrompt(systemPrompt);
214
+ if (fromSystemPrompt) {
215
+ this.knownAgentName = fromSystemPrompt;
216
+ return fromSystemPrompt;
217
+ }
218
+ return this.knownAgentName;
219
+ }
220
+
221
+ get lastKnownActiveAgentName(): string | null {
222
+ return this.knownAgentName;
223
+ }
224
+
225
+ // ── Config ─────────────────────────────────────────────────────────────
226
+
227
+ /** Reload merged config from disk; optionally update the stored runtime context. */
228
+ refreshConfig(ctx?: ExtensionContext): void {
229
+ this.runtimeDeps.refreshExtensionConfig(ctx);
230
+ }
231
+
232
+ /** Write the resolved config path set to the review and debug logs. */
233
+ logResolvedConfigPaths(): void {
234
+ this.runtimeDeps.logResolvedConfigPaths();
235
+ }
236
+
237
+ /** Read current extension config. */
238
+ get config(): PermissionSystemExtensionConfig {
239
+ return this.runtimeDeps.getConfig();
240
+ }
241
+
242
+ // ── Infrastructure paths ───────────────────────────────────────────────
243
+
244
+ getInfrastructureDirs(): readonly string[] {
245
+ return this.paths.piInfrastructureDirs;
246
+ }
247
+
248
+ /** Config-derived infrastructure read paths (current at call time). */
249
+ getInfrastructureReadPaths(): string[] {
250
+ return this.config.piInfrastructureReadPaths ?? [];
251
+ }
252
+ }
package/src/runtime.ts CHANGED
@@ -12,10 +12,6 @@ import {
12
12
  getAgentDir,
13
13
  } from "@mariozechner/pi-coding-agent";
14
14
 
15
- import {
16
- getActiveAgentName,
17
- getActiveAgentNameFromSystemPrompt,
18
- } from "./active-agent";
19
15
  import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
20
16
  import {
21
17
  DEBUG_LOG_FILENAME,
@@ -38,24 +34,19 @@ import { computeExtensionPaths, type ExtensionPaths } from "./extension-paths";
38
34
 
39
35
  export type { ExtensionPaths } from "./extension-paths";
40
36
 
41
- import {
42
- type PermissionForwardingDeps,
43
- processForwardedPermissionRequests,
44
- } from "./forwarded-permissions/polling";
45
37
  import { createPermissionSystemLogger } from "./logging";
46
- import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
47
38
  import { PermissionManager } from "./permission-manager";
48
39
  import { SessionRules } from "./session-rules";
49
40
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
50
41
  import { syncPermissionSystemStatus } from "./status";
51
- import { isSubagentExecutionContext } from "./subagent-context";
52
42
 
53
43
  /**
54
- * Mutable session state — the subset of ExtensionRuntime that handlers
55
- * read and write. Lifecycle handlers reset fields here on session
56
- * start/shutdown; gate adapters read permissionManager and sessionRules.
44
+ * Mutable session state — the subset of ExtensionRuntime that holds
45
+ * per-session fields. `PermissionSession` now owns these for handler
46
+ * use; this interface remains so `ExtensionRuntime` can still serve
47
+ * as the internal composition root (config-modal, RPC handlers).
57
48
  */
58
- export interface SessionState {
49
+ interface SessionState {
59
50
  runtimeContext: ExtensionContext | null;
60
51
  permissionManager: PermissionManager;
61
52
  readonly sessionRules: SessionRules;
@@ -81,11 +72,6 @@ export interface ExtensionRuntime extends ExtensionPaths, SessionState {
81
72
  config: PermissionSystemExtensionConfig;
82
73
  lastConfigWarning: string | null;
83
74
 
84
- // ── Forwarding polling state ───────────────────────────────────────────
85
- permissionForwardingContext: ExtensionContext | null;
86
- permissionForwardingTimer: NodeJS.Timeout | null;
87
- isProcessingForwardedRequests: boolean;
88
-
89
75
  // ── Logging (backed by logger created at construction) ─────────────────
90
76
  writeDebugLog(event: string, details?: Record<string, unknown>): void;
91
77
  writeReviewLog(event: string, details?: Record<string, unknown>): void;
@@ -220,28 +206,6 @@ export function saveExtensionConfig(
220
206
  });
221
207
  }
222
208
 
223
- /**
224
- * Resolve the active agent name from the Pi session, system prompt, or last
225
- * known name. Updates `runtime.lastKnownActiveAgentName` as a side effect.
226
- */
227
- export function resolveAgentName(
228
- runtime: ExtensionRuntime,
229
- ctx: ExtensionContext,
230
- systemPrompt?: string,
231
- ): string | null {
232
- const fromSession = getActiveAgentName(ctx);
233
- if (fromSession) {
234
- runtime.lastKnownActiveAgentName = fromSession;
235
- return fromSession;
236
- }
237
- const fromSystemPrompt = getActiveAgentNameFromSystemPrompt(systemPrompt);
238
- if (fromSystemPrompt) {
239
- runtime.lastKnownActiveAgentName = fromSystemPrompt;
240
- return fromSystemPrompt;
241
- }
242
- return runtime.lastKnownActiveAgentName;
243
- }
244
-
245
209
  /**
246
210
  * Write the resolved config path set (global, project, legacy) to the review
247
211
  * and debug logs.
@@ -277,58 +241,6 @@ export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
277
241
  );
278
242
  }
279
243
 
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
244
  // ── Factory ────────────────────────────────────────────────────────────────
333
245
 
334
246
  /**
@@ -356,9 +268,6 @@ export function createExtensionRuntime(options?: {
356
268
  lastPromptStateCacheKey: null,
357
269
  lastConfigWarning: null,
358
270
  sessionRules: new SessionRules(),
359
- permissionForwardingContext: null,
360
- permissionForwardingTimer: null,
361
- isProcessingForwardedRequests: false,
362
271
  // Logging methods are replaced below after the logger is constructed.
363
272
  writeDebugLog: () => {},
364
273
  writeReviewLog: () => {},
@@ -4,8 +4,19 @@ import {
4
4
  isPathWithinDirectory,
5
5
  normalizePathForComparison,
6
6
  } from "./path-utils";
7
- import type { PermissionManager } from "./permission-manager";
8
- import type { PermissionState } from "./types";
7
+ import type { PermissionCheckResult, PermissionState } from "./types";
8
+
9
+ /**
10
+ * Narrow interface for the permission checker used by skill prompt resolution.
11
+ * Both `PermissionManager` and `PermissionSession` satisfy this structurally.
12
+ */
13
+ export interface SkillPermissionChecker {
14
+ checkPermission(
15
+ surface: string,
16
+ input: unknown,
17
+ agentName?: string,
18
+ ): PermissionCheckResult;
19
+ }
9
20
 
10
21
  const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
11
22
  const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
@@ -148,7 +159,7 @@ export function parseAllSkillPromptSections(
148
159
 
149
160
  function resolvePermissionState(
150
161
  skillName: string,
151
- permissionManager: PermissionManager,
162
+ permissionManager: SkillPermissionChecker,
152
163
  agentName: string | null,
153
164
  cache: Map<string, PermissionState>,
154
165
  ): PermissionState {
@@ -205,7 +216,7 @@ function removePromptRange(prompt: string, start: number, end: number): string {
205
216
 
206
217
  export function resolveSkillPromptEntries(
207
218
  prompt: string,
208
- permissionManager: PermissionManager,
219
+ permissionManager: SkillPermissionChecker,
209
220
  agentName: string | null,
210
221
  cwd: string,
211
222
  ): { prompt: string; entries: SkillPromptEntry[] } {
@@ -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
+ });