@gotgenes/pi-permission-system 10.3.1 → 10.5.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 (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/src/config-modal.ts +10 -8
  4. package/src/config-store.ts +6 -11
  5. package/src/forwarded-permissions/io.ts +16 -22
  6. package/src/forwarded-permissions/permission-forwarder.ts +16 -19
  7. package/src/gate-prompter.ts +1 -3
  8. package/src/handlers/gates/bash-command.ts +2 -2
  9. package/src/handlers/gates/bash-external-directory.ts +2 -2
  10. package/src/handlers/gates/bash-path.ts +2 -2
  11. package/src/handlers/gates/path.ts +2 -2
  12. package/src/handlers/gates/runner.ts +3 -3
  13. package/src/handlers/gates/tool-call-gate-pipeline.ts +10 -9
  14. package/src/index.ts +27 -41
  15. package/src/permission-event-rpc.ts +19 -15
  16. package/src/permission-prompter.ts +4 -3
  17. package/src/permission-resolver.ts +69 -2
  18. package/src/permission-session.ts +7 -83
  19. package/src/prompting-gateway.ts +104 -0
  20. package/src/session-logger.ts +17 -3
  21. package/test/config-modal.test.ts +13 -7
  22. package/test/config-store.test.ts +7 -9
  23. package/test/forwarded-permissions/io.test.ts +23 -26
  24. package/test/handlers/external-directory-integration.test.ts +45 -32
  25. package/test/handlers/external-directory-session-dedup.test.ts +47 -57
  26. package/test/handlers/gates/bash-external-directory.test.ts +2 -2
  27. package/test/handlers/gates/bash-path.test.ts +2 -2
  28. package/test/handlers/gates/runner.test.ts +10 -16
  29. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +30 -21
  30. package/test/handlers/input-events.test.ts +19 -4
  31. package/test/handlers/input.test.ts +29 -13
  32. package/test/handlers/tool-call-events.test.ts +23 -5
  33. package/test/helpers/gate-fixtures.ts +11 -15
  34. package/test/helpers/handler-fixtures.ts +31 -50
  35. package/test/permission-event-rpc.test.ts +30 -28
  36. package/test/permission-forwarder.test.ts +6 -5
  37. package/test/permission-prompter.test.ts +28 -28
  38. package/test/permission-resolver.test.ts +194 -0
  39. package/test/permission-session.test.ts +27 -180
  40. package/test/prompting-gateway.test.ts +230 -0
package/src/index.ts CHANGED
@@ -23,19 +23,16 @@ import { requestPermissionDecisionFromUi } from "./permission-dialog";
23
23
  import { registerPermissionRpcHandlers } from "./permission-event-rpc";
24
24
  import { PermissionManager } from "./permission-manager";
25
25
  import { PermissionPrompter } from "./permission-prompter";
26
+ import { PermissionResolver } from "./permission-resolver";
26
27
  import { PermissionSession } from "./permission-session";
27
28
  import { LocalPermissionsService } from "./permissions-service";
29
+ import { PromptingGateway } from "./prompting-gateway";
28
30
  import { PermissionServiceLifecycle } from "./service-lifecycle";
29
31
  import { createSessionLogger } from "./session-logger";
30
32
  import { SessionRules } from "./session-rules";
31
- import { isSubagentExecutionContext } from "./subagent-context";
32
33
  import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
33
34
  import { getSubagentSessionRegistry } from "./subagent-registry";
34
35
  import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
35
- import {
36
- canResolveAskPermissionRequest,
37
- shouldAutoApprovePermissionState,
38
- } from "./yolo-mode";
39
36
 
40
37
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
41
38
  const agentDir = getAgentDir();
@@ -67,10 +64,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
67
64
  configStore = new ConfigStore({
68
65
  agentDir,
69
66
  policyPaths: permissionManager,
70
- logger: {
71
- writeDebugLog: (e, d) => logger.debug(e, d),
72
- writeReviewLog: (e, d) => logger.review(e, d),
73
- },
67
+ logger,
74
68
  });
75
69
 
76
70
  const forwardingDeps: PermissionForwarderDeps = {
@@ -78,26 +72,28 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
78
72
  subagentSessionsDir: paths.subagentSessionsDir,
79
73
  registry: subagentRegistry,
80
74
  events: pi.events,
81
- logger: {
82
- writeReviewLog: (event, details) => logger.review(event, details),
83
- writeDebugLog: (event, details) => logger.debug(event, details),
84
- },
85
- writeReviewLog: (event, details) => logger.review(event, details),
75
+ logger,
86
76
  requestPermissionDecisionFromUi,
87
- shouldAutoApprove: () =>
88
- shouldAutoApprovePermissionState("ask", configStore.current()),
77
+ config: configStore,
89
78
  };
90
79
  const forwarder = new PermissionForwarder(forwardingDeps);
91
80
 
92
81
  const prompter = new PermissionPrompter({
93
82
  config: configStore,
94
- writeReviewLog: (event, details) => logger.review(event, details),
83
+ logger,
95
84
  events: pi.events,
96
85
  forwarder,
97
86
  });
98
87
 
99
88
  configStore.refresh();
100
89
 
90
+ const gateway = new PromptingGateway({
91
+ config: configStore,
92
+ subagentSessionsDir: paths.subagentSessionsDir,
93
+ registry: subagentRegistry,
94
+ prompter,
95
+ });
96
+
101
97
  const session = new PermissionSession(
102
98
  paths,
103
99
  logger,
@@ -109,39 +105,26 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
109
105
  permissionManager,
110
106
  sessionRules,
111
107
  configStore,
112
- {
113
- canRequestPermissionConfirmation: (ctx) =>
114
- canResolveAskPermissionRequest({
115
- config: configStore.current(),
116
- hasUI: ctx.hasUI,
117
- isSubagent: isSubagentExecutionContext(
118
- ctx,
119
- paths.subagentSessionsDir,
120
- subagentRegistry,
121
- ),
122
- }),
123
- promptPermission: (ctx, details) => prompter.prompt(ctx, details),
124
- },
108
+ gateway,
125
109
  );
126
110
 
127
111
  // Connect the notify sink now that session is available.
128
112
  sessionNotify = session;
129
113
 
114
+ const configPath = getGlobalConfigPath(agentDir);
130
115
  registerPermissionSystemCommand(pi, {
131
116
  config: configStore,
132
- getConfigPath: () => getGlobalConfigPath(agentDir),
133
- getComposedRules: () =>
134
- permissionManager.getComposedConfigRules(
135
- session.lastKnownActiveAgentName ?? undefined,
136
- ),
117
+ configPath,
118
+ permissionManager,
119
+ session,
137
120
  });
138
121
 
139
122
  const rpcHandles = registerPermissionRpcHandlers(pi.events, {
140
- getPermissionManager: () => permissionManager,
141
- getSessionRules: () => sessionRules.getRuleset(),
142
- getRuntimeContext: () => session.getRuntimeContext(),
123
+ permissionManager,
124
+ sessionRules,
125
+ session,
143
126
  requestPermissionDecisionFromUi,
144
- writeReviewLog: (event, details) => logger.review(event, details),
127
+ logger,
145
128
  });
146
129
 
147
130
  const permissionsService = new LocalPermissionsService(
@@ -176,13 +159,16 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
176
159
 
177
160
  const lifecycle = new SessionLifecycleHandler(session, serviceLifecycle);
178
161
  const agentPrep = new AgentPrepHandler(session, toolRegistry);
162
+ const resolver = new PermissionResolver(permissionManager, sessionRules);
163
+
179
164
  const reporter = new GateDecisionReporter(session.logger, pi.events);
180
- const gateRunner = new GateRunner(session, session, session, reporter);
165
+ const gateRunner = new GateRunner(resolver, session, gateway, reporter);
181
166
  const toolCallGatePipeline = new ToolCallGatePipeline(
167
+ resolver,
182
168
  session,
183
169
  formatterRegistry,
184
170
  );
185
- const skillInputGatePipeline = new SkillInputGatePipeline(session);
171
+ const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
186
172
  const gates = new PermissionGateHandler(
187
173
  session,
188
174
  toolRegistry,
@@ -28,19 +28,20 @@ import {
28
28
  } from "./permission-events";
29
29
  import type { PermissionManager } from "./permission-manager";
30
30
  import { buildRpcUiPrompt } from "./permission-ui-prompt";
31
- import type { Rule } from "./rule";
31
+ import type { ReviewLogger } from "./session-logger";
32
+ import type { SessionRules } from "./session-rules";
32
33
 
33
34
  /** Dependencies injected into the RPC handler registry. */
34
35
  export interface PermissionRpcDeps {
35
- /** Returns the current PermissionManager (refreshed on session start). */
36
- getPermissionManager(): Pick<PermissionManager, "checkPermission">;
37
- /** Returns the current session rules (highest-priority approvals). */
38
- getSessionRules(): Rule[];
36
+ /** The shared PermissionManager instance. */
37
+ permissionManager: Pick<PermissionManager, "checkPermission">;
38
+ /** The shared SessionRules instance. */
39
+ sessionRules: Pick<SessionRules, "getRuleset">;
39
40
  /**
40
- * Returns the current ExtensionContext, or null if no session is active.
41
+ * Narrow session view: provides runtime context.
41
42
  * Used by the prompt handler to check hasUI and access the UI dialog.
42
43
  */
43
- getRuntimeContext(): ExtensionContext | null;
44
+ session: { getRuntimeContext(): ExtensionContext | null };
44
45
  /** Show the interactive permission dialog in the parent session UI. */
45
46
  requestPermissionDecisionFromUi(
46
47
  ui: ExtensionContext["ui"],
@@ -48,8 +49,8 @@ export interface PermissionRpcDeps {
48
49
  message: string,
49
50
  options?: RequestPermissionOptions,
50
51
  ): Promise<PermissionPromptDecision>;
51
- /** Write structured entries to the permission review log. */
52
- writeReviewLog(event: string, details: Record<string, unknown>): void;
52
+ /** Write review-log entries for prompted decisions. */
53
+ logger: ReviewLogger;
53
54
  }
54
55
 
55
56
  /** Unsubscribe handles returned from registerPermissionRpcHandlers. */
@@ -107,10 +108,13 @@ function handleCheckRpc(
107
108
  }
108
109
 
109
110
  const input = buildInputForSurface(surface, value);
110
- const sessionRules = deps.getSessionRules();
111
- const result = deps
112
- .getPermissionManager()
113
- .checkPermission(surface, input, agentName ?? undefined, sessionRules);
111
+ const sessionRules = deps.sessionRules.getRuleset();
112
+ const result = deps.permissionManager.checkPermission(
113
+ surface,
114
+ input,
115
+ agentName ?? undefined,
116
+ sessionRules,
117
+ );
114
118
 
115
119
  const data: PermissionsCheckReplyData = {
116
120
  result: result.state,
@@ -141,7 +145,7 @@ async function handlePromptRpc(
141
145
 
142
146
  const replyChannel = `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:${requestId}`;
143
147
 
144
- const ctx = deps.getRuntimeContext();
148
+ const ctx = deps.session.getRuntimeContext();
145
149
  if (!ctx?.hasUI) {
146
150
  events.emit(replyChannel, errorReply("no_ui"));
147
151
  return;
@@ -169,7 +173,7 @@ async function handlePromptRpc(
169
173
  sessionLabel ? { sessionLabel } : undefined,
170
174
  );
171
175
 
172
- deps.writeReviewLog("permission_request.rpc_prompt", {
176
+ deps.logger.review("permission_request.rpc_prompt", {
173
177
  requestId,
174
178
  surface: surface ?? null,
175
179
  value: value ?? null,
@@ -7,6 +7,7 @@ import {
7
7
  type PermissionEventBus,
8
8
  } from "./permission-events";
9
9
  import { buildDirectUiPrompt } from "./permission-ui-prompt";
10
+ import type { ReviewLogger } from "./session-logger";
10
11
  import { shouldAutoApprovePermissionState } from "./yolo-mode";
11
12
 
12
13
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
@@ -40,14 +41,14 @@ export interface PermissionPrompterApi {
40
41
  * Dependencies required by PermissionPrompter.
41
42
  *
42
43
  * Keeps the prompter's external surface narrow: callers provide config
43
- * access, review-log writing, the UI-prompt event bus, and the forwarder
44
+ * access, a review logger, the UI-prompt event bus, and the forwarder
44
45
  * that owns the UI/subagent-forwarding branching logic.
45
46
  */
46
47
  export interface PermissionPrompterDeps {
47
48
  /** Read current config for yolo-mode check (called at prompt time). */
48
49
  config: ConfigReader;
49
50
  /** Write structured entries to the permission review log. */
50
- writeReviewLog(event: string, details: Record<string, unknown>): void;
51
+ logger: ReviewLogger;
51
52
  /** Event bus used for UI prompt broadcasts. */
52
53
  events: PermissionEventBus;
53
54
  /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
@@ -122,7 +123,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
122
123
  denialReason?: string;
123
124
  },
124
125
  ): void {
125
- this.deps.writeReviewLog(event, {
126
+ this.deps.logger.review(event, {
126
127
  requestId: details.requestId,
127
128
  source: details.source,
128
129
  agentName: details.agentName,
@@ -1,4 +1,7 @@
1
- import type { PermissionCheckResult } from "./types";
1
+ import type { ScopedPermissionManager } from "./permission-manager";
2
+ import type { Rule } from "./rule";
3
+ import type { SessionRules } from "./session-rules";
4
+ import type { PermissionCheckResult, PermissionState } from "./types";
2
5
 
3
6
  /**
4
7
  * Resolves the effective permission for a surface/input, applying the current
@@ -8,10 +11,74 @@ import type { PermissionCheckResult } from "./types";
8
11
  * previously threaded by hand: the ruleset was only ever fetched to be passed
9
12
  * straight back into `checkPermission`, so the two are one operation.
10
13
  */
11
- export interface PermissionResolver {
14
+ export interface ScopedPermissionResolver {
12
15
  resolve(
13
16
  surface: string,
14
17
  input: unknown,
15
18
  agentName?: string,
16
19
  ): PermissionCheckResult;
17
20
  }
21
+
22
+ /**
23
+ * Concrete collaborator that owns the resolution surface.
24
+ *
25
+ * Holds a `ScopedPermissionManager` and a `SessionRules` store, composing
26
+ * them so callers never thread the session ruleset by hand.
27
+ *
28
+ * Constructor deps:
29
+ * - `permissionManager` — the narrow session-scoped permission-checking interface
30
+ * - `sessionRules` — narrowed to `getRuleset` (ISP: the resolver only reads, never records)
31
+ */
32
+ export class PermissionResolver implements ScopedPermissionResolver {
33
+ constructor(
34
+ private readonly permissionManager: ScopedPermissionManager,
35
+ private readonly sessionRules: Pick<SessionRules, "getRuleset">,
36
+ ) {}
37
+
38
+ /**
39
+ * Resolve the effective permission for a surface/input, applying the current
40
+ * session rules. Composes `checkPermission` with `getRuleset()` so callers
41
+ * never thread the ruleset by hand.
42
+ */
43
+ resolve(
44
+ surface: string,
45
+ input: unknown,
46
+ agentName?: string,
47
+ ): PermissionCheckResult {
48
+ return this.checkPermission(
49
+ surface,
50
+ input,
51
+ agentName,
52
+ this.sessionRules.getRuleset(),
53
+ );
54
+ }
55
+
56
+ checkPermission(
57
+ surface: string,
58
+ input: unknown,
59
+ agentName?: string,
60
+ sessionRules?: Rule[],
61
+ ): PermissionCheckResult {
62
+ return this.permissionManager.checkPermission(
63
+ surface,
64
+ input,
65
+ agentName,
66
+ sessionRules,
67
+ );
68
+ }
69
+
70
+ // fallow-ignore-next-line unused-class-member
71
+ getToolPermission(toolName: string, agentName?: string): PermissionState {
72
+ return this.permissionManager.getToolPermission(toolName, agentName);
73
+ }
74
+
75
+ // fallow-ignore-next-line unused-class-member
76
+ getConfigIssues(agentName?: string): string[] {
77
+ return this.permissionManager.getConfigIssues(agentName);
78
+ }
79
+
80
+ // fallow-ignore-next-line unused-class-member
81
+ getPolicyCacheStamp(agentName?: string): string {
82
+ return this.permissionManager.getPolicyCacheStamp(agentName);
83
+ }
84
+ }
@@ -10,11 +10,8 @@ import type { PermissionSystemExtensionConfig } from "./extension-config";
10
10
  import type { ExtensionPaths } from "./extension-paths";
11
11
  import type { ForwardingController } from "./forwarding-manager";
12
12
  import type { GateHandlerSession } from "./gate-handler-session";
13
- import type { GatePrompter } from "./gate-prompter";
14
- import type { PermissionPromptDecision } from "./permission-dialog";
15
13
  import type { ScopedPermissionManager } from "./permission-manager";
16
- import type { PromptPermissionDetails } from "./permission-prompter";
17
- import type { PermissionResolver } from "./permission-resolver";
14
+ import type { PromptingGatewayLifecycle } from "./prompting-gateway";
18
15
  import type { Rule } from "./rule";
19
16
  import type { SessionApproval } from "./session-approval";
20
17
  import type { SessionApprovalRecorder } from "./session-approval-recorder";
@@ -28,21 +25,6 @@ import {
28
25
  } from "./tool-preview-formatter";
29
26
  import type { PermissionCheckResult, PermissionState } from "./types";
30
27
 
31
- /**
32
- * Runtime operations that `PermissionSession` delegates to but does not own.
33
- *
34
- * Injected at construction time from the composition root (`index.ts`).
35
- */
36
- export interface PermissionSessionRuntimeDeps {
37
- /** Whether the current context can show an interactive permission prompt. */
38
- canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
39
- /** Prompt the user for a permission decision, log the outcome, and return it. */
40
- promptPermission(
41
- ctx: ExtensionContext,
42
- details: PromptPermissionDetails,
43
- ): Promise<PermissionPromptDecision>;
44
- }
45
-
46
28
  /**
47
29
  * Encapsulates all mutable session state and exposes operations instead of
48
30
  * fields.
@@ -56,13 +38,11 @@ export interface PermissionSessionRuntimeDeps {
56
38
  * - `SessionLogger` — debug + review + warn
57
39
  * - `ForwardingController` — polling lifecycle
58
40
  * - `SessionConfigStore` — owns extension config; provides refresh, log, read
59
- * - `PermissionSessionRuntimeDeps` — prompting + permission-confirmation bridge
41
+ * - `PromptingGatewayLifecycle` — prompting lifecycle forwarded via activate/deactivate
60
42
  */
61
43
  export class PermissionSession
62
44
  implements
63
- PermissionResolver,
64
45
  SessionApprovalRecorder,
65
- GatePrompter,
66
46
  GateHandlerSession,
67
47
  AgentPrepSession,
68
48
  SessionLifecycleSession
@@ -80,21 +60,23 @@ export class PermissionSession
80
60
  private readonly permissionManager: ScopedPermissionManager,
81
61
  private readonly sessionRules: SessionRules,
82
62
  private readonly configStore: SessionConfigStore,
83
- private readonly runtimeDeps: PermissionSessionRuntimeDeps,
63
+ private readonly gateway: PromptingGatewayLifecycle,
84
64
  ) {}
85
65
 
86
66
  // ── Context lifecycle ──────────────────────────────────────────────────
87
67
 
88
- /** Store the current extension context and start forwarding. */
68
+ /** Store the current extension context, start forwarding, and activate the gateway. */
89
69
  activate(ctx: ExtensionContext): void {
90
70
  this.context = ctx;
91
71
  this.forwarding.start(ctx);
72
+ this.gateway.activate(ctx);
92
73
  }
93
74
 
94
- /** Clear the context and stop forwarding. */
75
+ /** Clear the context, stop forwarding, and deactivate the gateway. */
95
76
  deactivate(): void {
96
77
  this.context = null;
97
78
  this.forwarding.stop();
79
+ this.gateway.deactivate();
98
80
  }
99
81
 
100
82
  /** Return the current runtime context, or null if not activated. */
@@ -118,24 +100,6 @@ export class PermissionSession
118
100
  );
119
101
  }
120
102
 
121
- /**
122
- * Resolve the effective permission for a surface/input, applying the current
123
- * session rules. Composes `checkPermission` with `getSessionRuleset` so
124
- * callers never thread the ruleset by hand.
125
- */
126
- resolve(
127
- surface: string,
128
- input: unknown,
129
- agentName?: string,
130
- ): PermissionCheckResult {
131
- return this.checkPermission(
132
- surface,
133
- input,
134
- agentName,
135
- this.getSessionRuleset(),
136
- );
137
- }
138
-
139
103
  getToolPermission(toolName: string, agentName?: string): PermissionState {
140
104
  return this.permissionManager.getToolPermission(toolName, agentName);
141
105
  }
@@ -291,44 +255,4 @@ export class PermissionSession
291
255
  getToolPreviewLimits(): ToolPreviewFormatterOptions {
292
256
  return resolveToolPreviewLimits(this.config);
293
257
  }
294
-
295
- // ── Prompting ──────────────────────────────────────────────────────────
296
-
297
- /** Whether the current context can show an interactive permission prompt. */
298
- canPrompt(ctx: ExtensionContext): boolean {
299
- return this.runtimeDeps.canRequestPermissionConfirmation(ctx);
300
- }
301
-
302
- /** Prompt the user for a permission decision, log the outcome, and return it. */
303
- prompt(
304
- ctx: ExtensionContext,
305
- details: PromptPermissionDetails,
306
- ): Promise<PermissionPromptDecision> {
307
- return this.runtimeDeps.promptPermission(ctx, details);
308
- }
309
-
310
- /**
311
- * Whether an interactive confirmation is possible using the stored context.
312
- * Returns `false` when no context is active (before `activate` is called).
313
- * Implements {@link GatePrompter}.
314
- */
315
- canConfirm(): boolean {
316
- return this.context !== null && this.canPrompt(this.context);
317
- }
318
-
319
- /**
320
- * Prompt the user for a permission decision using the stored context.
321
- * Throws if no context is active — `canConfirm()` guards this in normal use.
322
- * Implements {@link GatePrompter}.
323
- */
324
- promptPermission(
325
- details: PromptPermissionDetails,
326
- ): Promise<PermissionPromptDecision> {
327
- if (this.context === null) {
328
- return Promise.reject(
329
- new Error("promptPermission called before the session was activated"),
330
- );
331
- }
332
- return this.prompt(this.context, details);
333
- }
334
258
  }
@@ -0,0 +1,104 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { ConfigReader } from "./config-store";
4
+ import type { GatePrompter } from "./gate-prompter";
5
+ import type { PermissionPromptDecision } from "./permission-dialog";
6
+ import type {
7
+ PermissionPrompterApi,
8
+ PromptPermissionDetails,
9
+ } from "./permission-prompter";
10
+ import { isSubagentExecutionContext } from "./subagent-context";
11
+ import type { SubagentSessionRegistry } from "./subagent-registry";
12
+ import { canResolveAskPermissionRequest } from "./yolo-mode";
13
+
14
+ /**
15
+ * Dependencies required by PromptingGateway.
16
+ *
17
+ * All four fields are actively consumed:
18
+ * - `config` + `subagentSessionsDir` + `registry` drive `canConfirm()`.
19
+ * - `prompter` is called by `prompt()`.
20
+ */
21
+ export interface PromptingGatewayDeps {
22
+ /** Read current config for the yolo-mode branch of the can-prompt policy. */
23
+ config: ConfigReader;
24
+ /** Static path used to detect a forwarding subagent context. */
25
+ subagentSessionsDir: string;
26
+ /** Process-global registry used to detect a registered child session. */
27
+ registry?: SubagentSessionRegistry;
28
+ /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
29
+ prompter: PermissionPrompterApi;
30
+ }
31
+
32
+ /**
33
+ * The lifecycle slice of the gateway that PermissionSession drives.
34
+ *
35
+ * PermissionSession calls activate/deactivate to keep the gateway's stored
36
+ * context in sync with its own — the same pattern used for ForwardingController.
37
+ */
38
+ export interface PromptingGatewayLifecycle {
39
+ activate(ctx: ExtensionContext): void;
40
+ deactivate(): void;
41
+ }
42
+
43
+ /**
44
+ * Context-owning implementation of the GatePrompter role.
45
+ *
46
+ * Owns the stored ExtensionContext and the "can we prompt?" policy
47
+ * (UI / subagent / yolo-mode), replacing the four twin methods
48
+ * that previously lived on PermissionSession.
49
+ *
50
+ * Lifecycle: PermissionSession drives activate/deactivate so the stored
51
+ * context mirrors the session context without independent call-site changes.
52
+ */
53
+ export class PromptingGateway
54
+ implements GatePrompter, PromptingGatewayLifecycle
55
+ {
56
+ private context: ExtensionContext | null = null;
57
+
58
+ constructor(private readonly deps: PromptingGatewayDeps) {}
59
+
60
+ /** Store the current extension context. */
61
+ activate(ctx: ExtensionContext): void {
62
+ this.context = ctx;
63
+ }
64
+
65
+ /** Clear the stored context. */
66
+ deactivate(): void {
67
+ this.context = null;
68
+ }
69
+
70
+ /**
71
+ * Whether an interactive permission prompt can be shown.
72
+ *
73
+ * Returns false when no context is active. Otherwise delegates to
74
+ * canResolveAskPermissionRequest, which checks hasUI, subagent status,
75
+ * and yolo-mode — relocating the policy from the index.ts closure.
76
+ */
77
+ canConfirm(): boolean {
78
+ if (this.context === null) return false;
79
+ return canResolveAskPermissionRequest({
80
+ config: this.deps.config.current(),
81
+ hasUI: this.context.hasUI,
82
+ isSubagent: isSubagentExecutionContext(
83
+ this.context,
84
+ this.deps.subagentSessionsDir,
85
+ this.deps.registry,
86
+ ),
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Prompt the user for a permission decision using the stored context.
92
+ *
93
+ * Rejects if no context is active — canConfirm() guards this in normal use.
94
+ * Implements {@link GatePrompter}.
95
+ */
96
+ prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
97
+ if (this.context === null) {
98
+ return Promise.reject(
99
+ new Error("prompt called before the session was activated"),
100
+ );
101
+ }
102
+ return this.deps.prompter.prompt(this.context, details);
103
+ }
104
+ }
@@ -6,6 +6,22 @@ import {
6
6
  } from "./extension-config";
7
7
  import { createPermissionSystemLogger } from "./logging";
8
8
 
9
+ /**
10
+ * Narrowest logging seam — consumers that only write review-log entries.
11
+ * Injected into `PermissionPrompter` and the RPC handlers.
12
+ */
13
+ export interface ReviewLogger {
14
+ review(event: string, details?: Record<string, unknown>): void;
15
+ }
16
+
17
+ /**
18
+ * Logging seam for consumers that write both debug and review entries.
19
+ * Injected into `ConfigStore` and `PermissionForwarder`.
20
+ */
21
+ export interface DebugReviewLogger extends ReviewLogger {
22
+ debug(event: string, details?: Record<string, unknown>): void;
23
+ }
24
+
9
25
  /**
10
26
  * Unified logging + notification surface for handler deps.
11
27
  *
@@ -13,9 +29,7 @@ import { createPermissionSystemLogger } from "./logging";
13
29
  * `writeReviewLog`, `notifyWarning`) with a single typed collaborator.
14
30
  * This is an intermediate abstraction on the path to PermissionSession (#129).
15
31
  */
16
- export interface SessionLogger {
17
- debug(event: string, details?: Record<string, unknown>): void;
18
- review(event: string, details?: Record<string, unknown>): void;
32
+ export interface SessionLogger extends DebugReviewLogger {
19
33
  warn(message: string): void;
20
34
  }
21
35
 
@@ -9,7 +9,7 @@ import {
9
9
  normalizePermissionSystemConfig,
10
10
  type PermissionSystemExtensionConfig,
11
11
  } from "#src/extension-config";
12
- import type { Rule } from "#src/rule";
12
+ import type { Rule, Ruleset } from "#src/rule";
13
13
 
14
14
  vi.mock("@earendil-works/pi-coding-agent", () => ({
15
15
  getSettingsListTheme: () => ({}),
@@ -88,7 +88,9 @@ test("permission-system command completions expose top-level config actions", ()
88
88
  };
89
89
  const controller = {
90
90
  config: configStore,
91
- getConfigPath: () => configPath,
91
+ configPath,
92
+ permissionManager: { getComposedConfigRules: () => [] as Ruleset },
93
+ session: { lastKnownActiveAgentName: null },
92
94
  };
93
95
 
94
96
  let definition: {
@@ -160,7 +162,9 @@ test("permission-system command handlers manage config summary, persistence, and
160
162
  };
161
163
  const controller = {
162
164
  config: configStore,
163
- getConfigPath: () => configPath,
165
+ configPath,
166
+ permissionManager: { getComposedConfigRules: () => [] as Ruleset },
167
+ session: { lastKnownActiveAgentName: null },
164
168
  };
165
169
 
166
170
  let registeredName = "";
@@ -257,8 +261,9 @@ test("show output includes rule origins when getComposedRules is provided", asyn
257
261
 
258
262
  const controller = {
259
263
  config: { current: () => config, save: () => {} } as CommandConfigStore,
260
- getConfigPath: () => "/fake/config.json",
261
- getComposedRules: () => composedRules,
264
+ configPath: "/fake/config.json",
265
+ permissionManager: { getComposedConfigRules: () => composedRules },
266
+ session: { lastKnownActiveAgentName: null },
262
267
  };
263
268
 
264
269
  let definition: {
@@ -289,8 +294,9 @@ test("show output omits rule summary when getComposedRules is not provided", asy
289
294
 
290
295
  const controller = {
291
296
  config: { current: () => config, save: () => {} } as CommandConfigStore,
292
- getConfigPath: () => "/fake/config.json",
293
- // no getComposedRules
297
+ configPath: "/fake/config.json",
298
+ permissionManager: { getComposedConfigRules: () => [] as Ruleset },
299
+ session: { lastKnownActiveAgentName: null },
294
300
  };
295
301
 
296
302
  let definition: {