@gotgenes/pi-permission-system 19.0.0 → 19.0.1

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,13 @@ 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
+ ## [19.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v19.0.0...pi-permission-system-v19.0.1) (2026-07-07)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **pi-permission-system:** mark Phase 8 Step 6 complete; retarget forwarder docs ([c11d8a2](https://github.com/gotgenes/pi-packages/commit/c11d8a2653edbac3637e45c1e5c5867f0a882afe))
14
+
8
15
  ## [19.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.2.0...pi-permission-system-v19.0.0) (2026-07-06)
9
16
 
10
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "19.0.0",
3
+ "version": "19.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,86 +3,53 @@ import { join } from "node:path";
3
3
  import {
4
4
  getActiveAgentName,
5
5
  getActiveAgentNameFromSystemPrompt,
6
- type SessionEntryView,
7
6
  } from "#src/active-agent";
8
- import type { ConfigReader } from "#src/config-store";
9
- import { isYoloModeEnabled } from "#src/extension-config";
7
+ import {
8
+ type ForwarderContext,
9
+ getSessionId,
10
+ } from "#src/authority/forwarder-context";
11
+ import {
12
+ cleanupPermissionForwardingLocationIfEmpty,
13
+ ensurePermissionForwardingLocation,
14
+ logPermissionForwardingError,
15
+ logPermissionForwardingWarning,
16
+ readForwardedPermissionResponse,
17
+ safeDeleteFile,
18
+ sleep,
19
+ writeJsonFileAtomic,
20
+ } from "#src/authority/forwarding-io";
21
+ import type { SubagentDetector } from "#src/authority/subagent-detection";
10
22
  import type {
11
23
  PermissionDecisionUi,
12
24
  PermissionPromptDecision,
13
25
  RequestPermissionOptions,
14
26
  } from "#src/permission-dialog";
15
- import {
16
- emitUiPromptEvent,
17
- type PermissionEventBus,
18
- } from "#src/permission-events";
19
27
  import {
20
28
  type ForwardedPermissionRequest,
21
- type ForwardedPermissionResponse,
22
29
  type ForwardedPromptDisplay,
23
- isForwardedPermissionRequestForSession,
24
30
  PERMISSION_FORWARDING_POLL_INTERVAL_MS,
25
31
  PERMISSION_FORWARDING_TIMEOUT_MS,
26
32
  type PermissionForwardingLocation,
27
33
  resolvePermissionForwardingTargetSessionId,
28
34
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
29
35
  } from "#src/permission-forwarding";
30
- import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
31
36
  import type { DebugReviewLogger } from "#src/session-logger";
32
- import { isSubagentExecutionContext } from "#src/subagent-context";
33
37
  import type { SubagentSessionRegistry } from "#src/subagent-registry";
34
38
  import { toRecord } from "#src/value-guards";
35
39
 
36
- import {
37
- cleanupPermissionForwardingLocationIfEmpty,
38
- ensureDirectoryExists,
39
- ensurePermissionForwardingLocation,
40
- getExistingPermissionForwardingLocation,
41
- listRequestFiles,
42
- logPermissionForwardingError,
43
- logPermissionForwardingWarning,
44
- readForwardedPermissionRequest,
45
- readForwardedPermissionResponse,
46
- safeDeleteFile,
47
- sleep,
48
- writeJsonFileAtomic,
49
- } from "./io";
50
-
51
- /**
52
- * Narrow context the forwarder reads: the UI gate (`hasUI`), the dialog UI
53
- * surface, and the three session-manager readers it uses directly or via
54
- * {@link isSubagentExecutionContext} / {@link getActiveAgentName}.
55
- *
56
- * `getSystemPrompt` is read reflectively (see `getContextSystemPrompt`), so it
57
- * is intentionally not a typed member. A full `ExtensionContext` satisfies this
58
- * structurally, so production callers pass `ctx` unchanged.
59
- */
60
- export interface ForwarderContext {
61
- hasUI: boolean;
62
- ui: PermissionDecisionUi;
63
- sessionManager: {
64
- getSessionId(): string;
65
- getSessionDir(): string;
66
- getEntries(): readonly SessionEntryView[];
67
- };
68
- }
69
-
70
40
  /**
71
- * Constructor config for `PermissionForwarder`.
41
+ * Constructor config for `ApprovalEscalator`.
72
42
  *
73
43
  * Replaces the `PermissionForwardingDeps` interface that was previously
74
- * threaded into free functions in `polling.ts`. The forwarder consumes it
44
+ * threaded into free functions in `polling.ts`. The escalator consumes it
75
45
  * once at construction and stores each member as a private readonly field.
76
46
  */
77
- export interface PermissionForwarderDeps {
47
+ export interface ApprovalEscalatorDeps {
78
48
  forwardingDir: string;
79
- subagentSessionsDir: string;
80
- /** Host platform, injected from the composition root, for subagent-context path detection. */
81
- platform: NodeJS.Platform;
82
- /** In-process subagent session registry for detection and forwarding target resolution. */
49
+ /** Single owner of subagent detection; gates the forward-vs-deny decision. */
50
+ detection: SubagentDetector;
51
+ /** In-process subagent session registry for forwarding target resolution. */
83
52
  registry?: SubagentSessionRegistry;
84
- /** Event bus used for UI prompt broadcasts. */
85
- events?: PermissionEventBus;
86
53
  logger: DebugReviewLogger;
87
54
  requestPermissionDecisionFromUi: (
88
55
  ui: PermissionDecisionUi,
@@ -90,23 +57,10 @@ export interface PermissionForwarderDeps {
90
57
  message: string,
91
58
  options?: RequestPermissionOptions,
92
59
  ) => Promise<PermissionPromptDecision>;
93
- /** Read current config for the retained forwarded-inbox yolo auto-approve check. */
94
- config: ConfigReader;
95
60
  }
96
61
 
97
62
  // ── Module-private helpers ────────────────────────────────────────────────
98
63
 
99
- function getSessionId(ctx: ForwarderContext): string {
100
- try {
101
- const sessionId = ctx.sessionManager.getSessionId();
102
- if (typeof sessionId === "string" && sessionId.trim()) {
103
- return sessionId.trim();
104
- }
105
- } catch {}
106
-
107
- return "unknown";
108
- }
109
-
110
64
  function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
111
65
  const getSystemPrompt = toRecord(ctx).getSystemPrompt;
112
66
  if (typeof getSystemPrompt !== "function") {
@@ -128,27 +82,14 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
128
82
  }
129
83
  }
130
84
 
131
- function formatForwardedPermissionPrompt(
132
- request: ForwardedPermissionRequest,
133
- ): string {
134
- const agentName = request.requesterAgentName || "unknown";
135
- const sessionId = request.requesterSessionId || "unknown";
136
- return [
137
- `Subagent '${agentName}' requested permission.`,
138
- `Session ID: ${sessionId}`,
139
- "",
140
- request.message,
141
- ].join("\n");
142
- }
143
-
144
85
  // ── Public seam interfaces ────────────────────────────────────────────────
145
86
 
146
87
  /**
147
- * Narrow seam describing what `PermissionPrompter` needs from the forwarder:
88
+ * Narrow seam describing what `PermissionPrompter` needs from the escalator:
148
89
  * a single method that resolves a permission decision for the current context
149
90
  * (prompt directly when the session has UI, otherwise forward to the parent).
150
91
  *
151
- * Depending on the interface (not the concrete `PermissionForwarder`) keeps
92
+ * Depending on the interface (not the concrete `ApprovalEscalator`) keeps
152
93
  * the prompter's unit tests free of casts — they inject a plain
153
94
  * `{ requestApproval: vi.fn() }` mock.
154
95
  */
@@ -161,35 +102,20 @@ export interface ApprovalRequester {
161
102
  ): Promise<PermissionPromptDecision>;
162
103
  }
163
104
 
164
- /**
165
- * Narrow seam describing what `ForwardingManager` needs from the forwarder:
166
- * a single method that drains this session's forwarded-permission inbox.
167
- *
168
- * Depending on the interface (not the concrete `PermissionForwarder`) keeps
169
- * the manager's unit tests free of casts — they inject a plain
170
- * `{ processInbox: vi.fn() }` mock.
171
- */
172
- export interface InboxProcessor {
173
- processInbox(ctx: ForwarderContext): Promise<void>;
174
- }
175
-
176
- // ── PermissionForwarder ───────────────────────────────────────────────────
105
+ // ── ApprovalEscalator ────────────────────────────────────────────────
177
106
 
178
107
  /**
179
- * Owner of the forwarded-permission behavior.
108
+ * Owner of the escalation-up role of the forwarded-permission behavior.
180
109
  *
181
- * Holds all forwarding state as private readonly fields and provides two
182
- * public methods (`requestApproval`, `processInbox`) that together encapsulate
183
- * the full forwarding lifecycle: deciding whether to prompt directly or
184
- * forward to the parent, building and persisting request files, polling for
185
- * responses, and processing the parent-session inbox.
110
+ * Holds all forwarding state as private readonly fields and provides the
111
+ * public `requestApproval` method: deciding whether to prompt directly or
112
+ * forward to the parent, building and persisting request files, and polling
113
+ * for responses.
186
114
  */
187
- export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
115
+ export class ApprovalEscalator implements ApprovalRequester {
188
116
  private readonly forwardingDir: string;
189
- private readonly subagentSessionsDir: string;
190
- private readonly platform: NodeJS.Platform;
117
+ private readonly detection: SubagentDetector;
191
118
  private readonly registry: SubagentSessionRegistry | undefined;
192
- private readonly events: PermissionEventBus | undefined;
193
119
  private readonly logger: DebugReviewLogger;
194
120
  private readonly requestPermissionDecisionFromUi: (
195
121
  ui: PermissionDecisionUi,
@@ -197,17 +123,13 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
197
123
  message: string,
198
124
  options?: RequestPermissionOptions,
199
125
  ) => Promise<PermissionPromptDecision>;
200
- private readonly config: ConfigReader;
201
126
 
202
- constructor(deps: PermissionForwarderDeps) {
127
+ constructor(deps: ApprovalEscalatorDeps) {
203
128
  this.forwardingDir = deps.forwardingDir;
204
- this.subagentSessionsDir = deps.subagentSessionsDir;
205
- this.platform = deps.platform;
129
+ this.detection = deps.detection;
206
130
  this.registry = deps.registry;
207
- this.events = deps.events;
208
131
  this.logger = deps.logger;
209
132
  this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
210
- this.config = deps.config;
211
133
  }
212
134
 
213
135
  // ── Public seam methods ────────────────────────────────────────────────
@@ -231,78 +153,13 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
231
153
  );
232
154
  }
233
155
 
234
- if (
235
- !isSubagentExecutionContext(
236
- ctx,
237
- this.subagentSessionsDir,
238
- this.platform,
239
- this.registry,
240
- )
241
- ) {
156
+ if (!this.detection.isSubagent(ctx)) {
242
157
  return Promise.resolve({ approved: false, state: "denied" });
243
158
  }
244
159
 
245
160
  return this.waitForForwardedApproval(ctx, message, forwarded);
246
161
  }
247
162
 
248
- /** Drain and respond to this session's forwarded-permission inbox. */
249
- async processInbox(ctx: ForwarderContext): Promise<void> {
250
- if (!ctx.hasUI) {
251
- return;
252
- }
253
-
254
- const currentSessionId = getSessionId(ctx);
255
- const location = getExistingPermissionForwardingLocation(
256
- this.forwardingDir,
257
- currentSessionId,
258
- );
259
- if (!location) {
260
- return;
261
- }
262
-
263
- const requestFiles = listRequestFiles(this.logger, location.requestsDir);
264
- if (requestFiles.length === 0) {
265
- return;
266
- }
267
-
268
- // Defensively recreate responses/ before writing any response — a
269
- // concurrent cleanup pass may have removed it between the requestsDir
270
- // existence check above and the write inside processSingleForwardedRequest
271
- // (the ENOENT write loop reported in issue #398).
272
- if (
273
- !ensureDirectoryExists(
274
- this.logger,
275
- location.responsesDir,
276
- "permission forwarding responses",
277
- )
278
- ) {
279
- return;
280
- }
281
-
282
- for (const fileName of requestFiles) {
283
- const requestPath = join(location.requestsDir, fileName);
284
- const request = readForwardedPermissionRequest(this.logger, requestPath);
285
- if (!request) {
286
- safeDeleteFile(
287
- this.logger,
288
- requestPath,
289
- `${location.label} forwarded permission request`,
290
- );
291
- continue;
292
- }
293
-
294
- await this.processSingleForwardedRequest(
295
- ctx,
296
- request,
297
- location,
298
- requestPath,
299
- currentSessionId,
300
- );
301
- }
302
-
303
- cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
304
- }
305
-
306
163
  // ── Private methods ────────────────────────────────────────────────────
307
164
 
308
165
  private async waitForForwardedApproval(
@@ -313,12 +170,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
313
170
  const requesterSessionId = getSessionId(ctx);
314
171
  const targetSessionId = resolvePermissionForwardingTargetSessionId({
315
172
  hasUI: ctx.hasUI,
316
- isSubagent: isSubagentExecutionContext(
317
- ctx,
318
- this.subagentSessionsDir,
319
- this.platform,
320
- this.registry,
321
- ),
173
+ isSubagent: this.detection.isSubagent(ctx),
322
174
  currentSessionId: requesterSessionId,
323
175
  env: process.env,
324
176
  sessionId: requesterSessionId,
@@ -472,122 +324,4 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
472
324
  cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
473
325
  return { approved: false, state: "denied" };
474
326
  }
475
-
476
- private async processSingleForwardedRequest(
477
- ctx: ForwarderContext,
478
- request: ForwardedPermissionRequest,
479
- location: PermissionForwardingLocation,
480
- requestPath: string,
481
- currentSessionId: string,
482
- ): Promise<void> {
483
- if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
484
- logPermissionForwardingWarning(
485
- this.logger,
486
- `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
487
- );
488
- safeDeleteFile(
489
- this.logger,
490
- requestPath,
491
- `${location.label} forwarded permission request`,
492
- );
493
- return;
494
- }
495
-
496
- const forwardedPermissionLogDetails = {
497
- requestId: request.id,
498
- source: location.label,
499
- requesterAgentName: request.requesterAgentName,
500
- requesterSessionId: request.requesterSessionId,
501
- targetSessionId: request.targetSessionId,
502
- requestPath,
503
- };
504
-
505
- let decision: PermissionPromptDecision = {
506
- approved: false,
507
- state: "denied",
508
- };
509
- // Last yolo check outside the composed ruleset: dissolves when
510
- // processInbox is refactored onto evaluate() + Authorizer selection in
511
- // the Phase 9 spine work.
512
- if (isYoloModeEnabled(this.config.current())) {
513
- this.logger.review(
514
- "forwarded_permission.auto_approved",
515
- forwardedPermissionLogDetails,
516
- );
517
- decision = { approved: true, state: "approved" };
518
- } else {
519
- this.logger.review(
520
- "forwarded_permission.prompted",
521
- forwardedPermissionLogDetails,
522
- );
523
- try {
524
- const forwardedMessage = formatForwardedPermissionPrompt(request);
525
- if (this.events) {
526
- emitUiPromptEvent(
527
- this.events,
528
- buildForwardedUiPrompt({
529
- requestId: request.id,
530
- message: forwardedMessage,
531
- requesterAgentName: request.requesterAgentName || null,
532
- requesterSessionId: request.requesterSessionId || null,
533
- source: request.source ?? null,
534
- surface: request.surface ?? null,
535
- value: request.value ?? null,
536
- }),
537
- );
538
- }
539
- decision = await this.requestPermissionDecisionFromUi(
540
- ctx.ui,
541
- "Permission Required (Subagent)",
542
- forwardedMessage,
543
- );
544
- } catch (error) {
545
- logPermissionForwardingError(
546
- this.logger,
547
- "Failed to show forwarded permission confirmation dialog",
548
- error,
549
- );
550
- decision = { approved: false, state: "denied" };
551
- }
552
- }
553
-
554
- const responsePath = join(location.responsesDir, `${request.id}.json`);
555
- this.logger.review(
556
- decision.approved
557
- ? "forwarded_permission.approved"
558
- : "forwarded_permission.denied",
559
- {
560
- requestId: request.id,
561
- source: location.label,
562
- requesterAgentName: request.requesterAgentName,
563
- requesterSessionId: request.requesterSessionId,
564
- targetSessionId: request.targetSessionId,
565
- responsePath,
566
- resolution: decision.state,
567
- denialReason: decision.denialReason ?? null,
568
- },
569
- );
570
- try {
571
- writeJsonFileAtomic(this.logger, responsePath, {
572
- approved: decision.approved,
573
- state: decision.state,
574
- denialReason: decision.denialReason,
575
- responderSessionId: currentSessionId,
576
- respondedAt: Date.now(),
577
- } satisfies ForwardedPermissionResponse);
578
- } catch (error) {
579
- logPermissionForwardingError(
580
- this.logger,
581
- `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
582
- error,
583
- );
584
- return;
585
- }
586
-
587
- safeDeleteFile(
588
- this.logger,
589
- requestPath,
590
- `${location.label} forwarded permission request`,
591
- );
592
- }
593
327
  }
@@ -0,0 +1,285 @@
1
+ import { join } from "node:path";
2
+ import {
3
+ type ForwarderContext,
4
+ getSessionId,
5
+ } from "#src/authority/forwarder-context";
6
+ import type { ConfigReader } from "#src/config-store";
7
+ import { isYoloModeEnabled } from "#src/extension-config";
8
+ import type {
9
+ PermissionDecisionUi,
10
+ PermissionPromptDecision,
11
+ RequestPermissionOptions,
12
+ } from "#src/permission-dialog";
13
+ import {
14
+ emitUiPromptEvent,
15
+ type PermissionEventBus,
16
+ } from "#src/permission-events";
17
+ import {
18
+ type ForwardedPermissionRequest,
19
+ type ForwardedPermissionResponse,
20
+ isForwardedPermissionRequestForSession,
21
+ type PermissionForwardingLocation,
22
+ } from "#src/permission-forwarding";
23
+ import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
24
+ import type { DebugReviewLogger } from "#src/session-logger";
25
+
26
+ import {
27
+ cleanupPermissionForwardingLocationIfEmpty,
28
+ ensureDirectoryExists,
29
+ getExistingPermissionForwardingLocation,
30
+ listRequestFiles,
31
+ logPermissionForwardingError,
32
+ logPermissionForwardingWarning,
33
+ readForwardedPermissionRequest,
34
+ safeDeleteFile,
35
+ writeJsonFileAtomic,
36
+ } from "./forwarding-io";
37
+
38
+ /**
39
+ * Narrow seam describing what `ForwardingManager` needs from the server: a
40
+ * single method that drains this session's forwarded-permission inbox.
41
+ *
42
+ * Depending on the interface (not the concrete `ForwardedRequestServer`)
43
+ * keeps the manager's unit tests free of casts — they inject a plain
44
+ * `{ processInbox: vi.fn() }` mock.
45
+ */
46
+ export interface InboxProcessor {
47
+ processInbox(ctx: ForwarderContext): Promise<void>;
48
+ }
49
+
50
+ /** Constructor config for `ForwardedRequestServer`. */
51
+ export interface ForwardedRequestServerDeps {
52
+ forwardingDir: string;
53
+ logger: DebugReviewLogger;
54
+ /** Event bus used for UI prompt broadcasts. */
55
+ events?: PermissionEventBus;
56
+ requestPermissionDecisionFromUi: (
57
+ ui: PermissionDecisionUi,
58
+ title: string,
59
+ message: string,
60
+ options?: RequestPermissionOptions,
61
+ ) => Promise<PermissionPromptDecision>;
62
+ /** Read current config for the retained forwarded-inbox yolo auto-approve check. */
63
+ config: ConfigReader;
64
+ }
65
+
66
+ // ── Module-private helpers ────────────────────────────────────────────────
67
+
68
+ function formatForwardedPermissionPrompt(
69
+ request: ForwardedPermissionRequest,
70
+ ): string {
71
+ const agentName = request.requesterAgentName || "unknown";
72
+ const sessionId = request.requesterSessionId || "unknown";
73
+ return [
74
+ `Subagent '${agentName}' requested permission.`,
75
+ `Session ID: ${sessionId}`,
76
+ "",
77
+ request.message,
78
+ ].join("\n");
79
+ }
80
+
81
+ // ── ForwardedRequestServer ────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Owner of the serving-down role of the forwarded-permission behavior:
85
+ * draining this session's forwarded-permission inbox and answering each
86
+ * request (auto-approve under yolo, or prompt via the injected UI).
87
+ */
88
+ export class ForwardedRequestServer implements InboxProcessor {
89
+ private readonly forwardingDir: string;
90
+ private readonly events: PermissionEventBus | undefined;
91
+ private readonly logger: DebugReviewLogger;
92
+ private readonly requestPermissionDecisionFromUi: (
93
+ ui: PermissionDecisionUi,
94
+ title: string,
95
+ message: string,
96
+ options?: RequestPermissionOptions,
97
+ ) => Promise<PermissionPromptDecision>;
98
+ private readonly config: ConfigReader;
99
+
100
+ constructor(deps: ForwardedRequestServerDeps) {
101
+ this.forwardingDir = deps.forwardingDir;
102
+ this.events = deps.events;
103
+ this.logger = deps.logger;
104
+ this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
105
+ this.config = deps.config;
106
+ }
107
+
108
+ /** Drain and respond to this session's forwarded-permission inbox. */
109
+ async processInbox(ctx: ForwarderContext): Promise<void> {
110
+ if (!ctx.hasUI) {
111
+ return;
112
+ }
113
+
114
+ const currentSessionId = getSessionId(ctx);
115
+ const location = getExistingPermissionForwardingLocation(
116
+ this.forwardingDir,
117
+ currentSessionId,
118
+ );
119
+ if (!location) {
120
+ return;
121
+ }
122
+
123
+ const requestFiles = listRequestFiles(this.logger, location.requestsDir);
124
+ if (requestFiles.length === 0) {
125
+ return;
126
+ }
127
+
128
+ // Defensively recreate responses/ before writing any response — a
129
+ // concurrent cleanup pass may have removed it between the requestsDir
130
+ // existence check above and the write inside processSingleForwardedRequest
131
+ // (the ENOENT write loop reported in issue #398).
132
+ if (
133
+ !ensureDirectoryExists(
134
+ this.logger,
135
+ location.responsesDir,
136
+ "permission forwarding responses",
137
+ )
138
+ ) {
139
+ return;
140
+ }
141
+
142
+ for (const fileName of requestFiles) {
143
+ const requestPath = join(location.requestsDir, fileName);
144
+ const request = readForwardedPermissionRequest(this.logger, requestPath);
145
+ if (!request) {
146
+ safeDeleteFile(
147
+ this.logger,
148
+ requestPath,
149
+ `${location.label} forwarded permission request`,
150
+ );
151
+ continue;
152
+ }
153
+
154
+ await this.processSingleForwardedRequest(
155
+ ctx,
156
+ request,
157
+ location,
158
+ requestPath,
159
+ currentSessionId,
160
+ );
161
+ }
162
+
163
+ cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
164
+ }
165
+
166
+ // ── Private methods ────────────────────────────────────────────────────
167
+
168
+ private async processSingleForwardedRequest(
169
+ ctx: ForwarderContext,
170
+ request: ForwardedPermissionRequest,
171
+ location: PermissionForwardingLocation,
172
+ requestPath: string,
173
+ currentSessionId: string,
174
+ ): Promise<void> {
175
+ if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
176
+ logPermissionForwardingWarning(
177
+ this.logger,
178
+ `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
179
+ );
180
+ safeDeleteFile(
181
+ this.logger,
182
+ requestPath,
183
+ `${location.label} forwarded permission request`,
184
+ );
185
+ return;
186
+ }
187
+
188
+ const forwardedPermissionLogDetails = {
189
+ requestId: request.id,
190
+ source: location.label,
191
+ requesterAgentName: request.requesterAgentName,
192
+ requesterSessionId: request.requesterSessionId,
193
+ targetSessionId: request.targetSessionId,
194
+ requestPath,
195
+ };
196
+
197
+ let decision: PermissionPromptDecision = {
198
+ approved: false,
199
+ state: "denied",
200
+ };
201
+ // Last yolo check outside the composed ruleset: dissolves when
202
+ // processInbox is refactored onto evaluate() + Authorizer selection in
203
+ // the Phase 9 spine work.
204
+ if (isYoloModeEnabled(this.config.current())) {
205
+ this.logger.review(
206
+ "forwarded_permission.auto_approved",
207
+ forwardedPermissionLogDetails,
208
+ );
209
+ decision = { approved: true, state: "approved" };
210
+ } else {
211
+ this.logger.review(
212
+ "forwarded_permission.prompted",
213
+ forwardedPermissionLogDetails,
214
+ );
215
+ try {
216
+ const forwardedMessage = formatForwardedPermissionPrompt(request);
217
+ if (this.events) {
218
+ emitUiPromptEvent(
219
+ this.events,
220
+ buildForwardedUiPrompt({
221
+ requestId: request.id,
222
+ message: forwardedMessage,
223
+ requesterAgentName: request.requesterAgentName || null,
224
+ requesterSessionId: request.requesterSessionId || null,
225
+ source: request.source ?? null,
226
+ surface: request.surface ?? null,
227
+ value: request.value ?? null,
228
+ }),
229
+ );
230
+ }
231
+ decision = await this.requestPermissionDecisionFromUi(
232
+ ctx.ui,
233
+ "Permission Required (Subagent)",
234
+ forwardedMessage,
235
+ );
236
+ } catch (error) {
237
+ logPermissionForwardingError(
238
+ this.logger,
239
+ "Failed to show forwarded permission confirmation dialog",
240
+ error,
241
+ );
242
+ decision = { approved: false, state: "denied" };
243
+ }
244
+ }
245
+
246
+ const responsePath = join(location.responsesDir, `${request.id}.json`);
247
+ this.logger.review(
248
+ decision.approved
249
+ ? "forwarded_permission.approved"
250
+ : "forwarded_permission.denied",
251
+ {
252
+ requestId: request.id,
253
+ source: location.label,
254
+ requesterAgentName: request.requesterAgentName,
255
+ requesterSessionId: request.requesterSessionId,
256
+ targetSessionId: request.targetSessionId,
257
+ responsePath,
258
+ resolution: decision.state,
259
+ denialReason: decision.denialReason ?? null,
260
+ },
261
+ );
262
+ try {
263
+ writeJsonFileAtomic(this.logger, responsePath, {
264
+ approved: decision.approved,
265
+ state: decision.state,
266
+ denialReason: decision.denialReason,
267
+ responderSessionId: currentSessionId,
268
+ respondedAt: Date.now(),
269
+ } satisfies ForwardedPermissionResponse);
270
+ } catch (error) {
271
+ logPermissionForwardingError(
272
+ this.logger,
273
+ `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
274
+ error,
275
+ );
276
+ return;
277
+ }
278
+
279
+ safeDeleteFile(
280
+ this.logger,
281
+ requestPath,
282
+ `${location.label} forwarded permission request`,
283
+ );
284
+ }
285
+ }
@@ -0,0 +1,32 @@
1
+ import type { SessionEntryView } from "#src/active-agent";
2
+ import type { PermissionDecisionUi } from "#src/permission-dialog";
3
+
4
+ /**
5
+ * Narrow context the forwarding subsystem reads: the UI gate (`hasUI`), the
6
+ * dialog UI surface, and the three session-manager readers `getSessionId`
7
+ * and the `active-agent` helpers use.
8
+ *
9
+ * A full `ExtensionContext` satisfies this structurally, so production
10
+ * callers pass `ctx` unchanged.
11
+ */
12
+ export interface ForwarderContext {
13
+ hasUI: boolean;
14
+ ui: PermissionDecisionUi;
15
+ sessionManager: {
16
+ getSessionId(): string;
17
+ getSessionDir(): string;
18
+ getEntries(): readonly SessionEntryView[];
19
+ };
20
+ }
21
+
22
+ /** Reads the current session id off `ctx`, falling back to `"unknown"`. */
23
+ export function getSessionId(ctx: ForwarderContext): string {
24
+ try {
25
+ const sessionId = ctx.sessionManager.getSessionId();
26
+ if (typeof sessionId === "string" && sessionId.trim()) {
27
+ return sessionId.trim();
28
+ }
29
+ } catch {}
30
+
31
+ return "unknown";
32
+ }
@@ -1,7 +1,7 @@
1
1
  import { posix as posixPath, win32 as winPath } from "node:path";
2
2
 
3
- import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding";
4
- import type { SubagentSessionRegistry } from "./subagent-registry";
3
+ import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
4
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
5
5
 
6
6
  /**
7
7
  * Narrow context for subagent detection — the only session-manager readers
@@ -0,0 +1,65 @@
1
+ import {
2
+ isRegisteredSubagentChild,
3
+ isSubagentExecutionContext,
4
+ type SubagentDetectionContext,
5
+ } from "#src/authority/subagent-context";
6
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
7
+
8
+ /**
9
+ * Narrow seam for the ask-path consumers: "is the current session a subagent?"
10
+ *
11
+ * `PromptingGateway`, `ForwardingManager`, and `ApprovalEscalator` depend on
12
+ * this single-method view so their unit tests inject a one-field fake without
13
+ * casts. It is the Authorizer-selection predicate the Phase 9 spine consumes.
14
+ */
15
+ export interface SubagentDetector {
16
+ isSubagent(ctx: SubagentDetectionContext): boolean;
17
+ }
18
+
19
+ /**
20
+ * Narrow seam for the service-publication guard (#302): "is the current
21
+ * session a registered in-process child?"
22
+ *
23
+ * `PermissionServiceLifecycle` depends on this single-method view so a
24
+ * registered child never publishes over its parent's process-global slot.
25
+ */
26
+ export interface RegisteredChildDetector {
27
+ isRegisteredChild(ctx: SubagentDetectionContext): boolean;
28
+ }
29
+
30
+ /** Composition-root inputs for {@link SubagentDetection}. */
31
+ export interface SubagentDetectionDeps {
32
+ subagentSessionsDir: string;
33
+ platform: NodeJS.Platform;
34
+ registry?: SubagentSessionRegistry;
35
+ }
36
+
37
+ /**
38
+ * Single owner of subagent detection.
39
+ *
40
+ * Constructed once in the composition root with the detection inputs
41
+ * (`subagentSessionsDir`, `platform`, `registry`) and shared across every
42
+ * consumer, replacing the dep triple those consumers previously threaded
43
+ * individually. Delegates to the pure detection functions in
44
+ * {@link ./subagent-context}, holding only the deps.
45
+ */
46
+ export class SubagentDetection
47
+ implements SubagentDetector, RegisteredChildDetector
48
+ {
49
+ constructor(private readonly deps: SubagentDetectionDeps) {}
50
+
51
+ isSubagent(ctx: SubagentDetectionContext): boolean {
52
+ return isSubagentExecutionContext(
53
+ ctx,
54
+ this.deps.subagentSessionsDir,
55
+ this.deps.platform,
56
+ this.deps.registry,
57
+ );
58
+ }
59
+
60
+ isRegisteredChild(ctx: SubagentDetectionContext): boolean {
61
+ return this.deps.registry
62
+ ? isRegisteredSubagentChild(ctx, this.deps.registry)
63
+ : false;
64
+ }
65
+ }
@@ -1,9 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
3
- import type { InboxProcessor } from "./forwarded-permissions/permission-forwarder";
2
+ import type { InboxProcessor } from "./authority/forwarded-request-server";
3
+ import type { SubagentDetector } from "./authority/subagent-detection";
4
4
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
5
- import { isSubagentExecutionContext } from "./subagent-context";
6
- import type { SubagentSessionRegistry } from "./subagent-registry";
7
5
 
8
6
  /**
9
7
  * Narrow interface for the forwarding lifecycle used by `PermissionSession`.
@@ -28,10 +26,8 @@ export class ForwardingManager {
28
26
  private processing = false;
29
27
 
30
28
  constructor(
31
- private readonly subagentSessionsDir: string,
29
+ private readonly detection: SubagentDetector,
32
30
  private readonly forwarder: InboxProcessor,
33
- private readonly platform: NodeJS.Platform,
34
- private readonly registry?: SubagentSessionRegistry,
35
31
  ) {}
36
32
 
37
33
  /**
@@ -41,15 +37,7 @@ export class ForwardingManager {
41
37
  * Stops any existing poll when the context does not qualify for forwarding.
42
38
  */
43
39
  start(ctx: ExtensionContext): void {
44
- if (
45
- !ctx.hasUI ||
46
- isSubagentExecutionContext(
47
- ctx,
48
- this.subagentSessionsDir,
49
- this.platform,
50
- this.registry,
51
- )
52
- ) {
40
+ if (!ctx.hasUI || this.detection.isSubagent(ctx)) {
53
41
  this.stop();
54
42
  return;
55
43
  }
package/src/index.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ ApprovalEscalator,
5
+ type ApprovalEscalatorDeps,
6
+ } from "./authority/approval-escalator";
7
+ import {
8
+ ForwardedRequestServer,
9
+ type ForwardedRequestServerDeps,
10
+ } from "./authority/forwarded-request-server";
11
+ import { SubagentDetection } from "./authority/subagent-detection";
3
12
  import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
4
13
  import { registerPermissionSystemCommand } from "./config-modal";
5
14
  import { getGlobalConfigPath } from "./config-paths";
@@ -8,10 +17,6 @@ import { DecisionAudit } from "./decision-audit";
8
17
  import { GateDecisionReporter } from "./decision-reporter";
9
18
  import { isYoloModeEnabled } from "./extension-config";
10
19
  import { computeExtensionPaths } from "./extension-paths";
11
- import {
12
- PermissionForwarder,
13
- type PermissionForwarderDeps,
14
- } from "./forwarded-permissions/permission-forwarder";
15
20
  import { ForwardingManager } from "./forwarding-manager";
16
21
  import {
17
22
  AgentPrepHandler,
@@ -49,6 +54,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
49
54
  const hostPlatform = process.platform;
50
55
  const sessionRules = new SessionRules();
51
56
  const subagentRegistry = getSubagentSessionRegistry();
57
+ // Single owner of subagent detection, shared across every consumer instead of
58
+ // threading the (subagentSessionsDir, platform, registry) triple into each.
59
+ const subagentDetection = new SubagentDetection({
60
+ subagentSessionsDir: paths.subagentSessionsDir,
61
+ platform: hostPlatform,
62
+ registry: subagentRegistry,
63
+ });
52
64
  const formatterRegistry = new ToolInputFormatterRegistry();
53
65
  registerBuiltinToolInputFormatters(formatterRegistry);
54
66
  const accessExtractorRegistry = new ToolAccessExtractorRegistry();
@@ -83,39 +95,38 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
83
95
  logger,
84
96
  });
85
97
 
86
- const forwardingDeps: PermissionForwarderDeps = {
98
+ const escalatorDeps: ApprovalEscalatorDeps = {
87
99
  forwardingDir: paths.forwardingDir,
88
- subagentSessionsDir: paths.subagentSessionsDir,
89
- platform: hostPlatform,
100
+ detection: subagentDetection,
90
101
  registry: subagentRegistry,
91
- events: pi.events,
92
102
  logger,
93
103
  requestPermissionDecisionFromUi,
104
+ };
105
+ const escalator = new ApprovalEscalator(escalatorDeps);
106
+
107
+ const requestServerDeps: ForwardedRequestServerDeps = {
108
+ forwardingDir: paths.forwardingDir,
109
+ logger,
110
+ events: pi.events,
111
+ requestPermissionDecisionFromUi,
94
112
  config: configStore,
95
113
  };
96
- const forwarder = new PermissionForwarder(forwardingDeps);
114
+ const requestServer = new ForwardedRequestServer(requestServerDeps);
97
115
 
98
116
  const prompter = new PermissionPrompter({
99
117
  logger,
100
118
  events: pi.events,
101
- forwarder,
119
+ forwarder: escalator,
102
120
  });
103
121
 
104
122
  const gateway = new PromptingGateway({
105
- subagentSessionsDir: paths.subagentSessionsDir,
106
- platform: hostPlatform,
107
- registry: subagentRegistry,
123
+ detection: subagentDetection,
108
124
  prompter,
109
125
  });
110
126
 
111
127
  session = new PermissionSession(
112
128
  paths,
113
- new ForwardingManager(
114
- paths.subagentSessionsDir,
115
- forwarder,
116
- hostPlatform,
117
- subagentRegistry,
118
- ),
129
+ new ForwardingManager(subagentDetection, requestServer),
119
130
  permissionManager,
120
131
  sessionRules,
121
132
  configStore,
@@ -171,7 +182,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
171
182
  // requires the session id from ctx, unavailable at factory-init time.
172
183
  const serviceLifecycle = new PermissionServiceLifecycle(
173
184
  permissionsService,
174
- subagentRegistry,
185
+ subagentDetection,
175
186
  pi.events,
176
187
  [rpcHandles.unsubCheck, rpcHandles.unsubPrompt, unsubSubagentLifecycle],
177
188
  );
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { ApprovalRequester } from "./forwarded-permissions/permission-forwarder";
2
+ import type { ApprovalRequester } from "./authority/approval-escalator";
3
3
  import type { PermissionPromptDecision } from "./permission-dialog";
4
4
  import {
5
5
  emitUiPromptEvent,
@@ -1,28 +1,22 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
2
+ import type { SubagentDetector } from "./authority/subagent-detection";
3
3
  import type { GatePrompter } from "./gate-prompter";
4
4
  import type { PermissionPromptDecision } from "./permission-dialog";
5
5
  import type {
6
6
  PermissionPrompterApi,
7
7
  PromptPermissionDetails,
8
8
  } from "./permission-prompter";
9
- import { isSubagentExecutionContext } from "./subagent-context";
10
- import type { SubagentSessionRegistry } from "./subagent-registry";
11
9
 
12
10
  /**
13
11
  * Dependencies required by PromptingGateway.
14
12
  *
15
13
  * All fields are actively consumed:
16
- * - `subagentSessionsDir` + `registry` drive `canConfirm()`.
14
+ * - `detection` drives `canConfirm()`.
17
15
  * - `prompter` is called by `prompt()`.
18
16
  */
19
17
  export interface PromptingGatewayDeps {
20
- /** Static path used to detect a forwarding subagent context. */
21
- subagentSessionsDir: string;
22
- /** Host platform, injected from the composition root, for subagent-context path detection. */
23
- platform: NodeJS.Platform;
24
- /** Process-global registry used to detect a registered child session. */
25
- registry?: SubagentSessionRegistry;
18
+ /** Single owner of subagent detection; drives `canConfirm()`. */
19
+ detection: SubagentDetector;
26
20
  /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
27
21
  prompter: PermissionPrompterApi;
28
22
  }
@@ -75,15 +69,7 @@ export class PromptingGateway
75
69
  */
76
70
  canConfirm(): boolean {
77
71
  if (this.context === null) return false;
78
- return (
79
- this.context.hasUI ||
80
- isSubagentExecutionContext(
81
- this.context,
82
- this.deps.subagentSessionsDir,
83
- this.deps.platform,
84
- this.deps.registry,
85
- )
86
- );
72
+ return this.context.hasUI || this.deps.detection.isSubagent(this.context);
87
73
  }
88
74
 
89
75
  /**
@@ -1,13 +1,11 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
2
+ import type { RegisteredChildDetector } from "./authority/subagent-detection";
3
3
  import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
4
4
  import {
5
5
  type PermissionsService,
6
6
  publishPermissionsService,
7
7
  unpublishPermissionsService,
8
8
  } from "./service";
9
- import { isRegisteredSubagentChild } from "./subagent-context";
10
- import type { SubagentSessionRegistry } from "./subagent-registry";
11
9
 
12
10
  /** The session-scoped service lifecycle that the lifecycle handler drives. */
13
11
  export interface ServiceLifecycle {
@@ -28,13 +26,13 @@ export interface ServiceLifecycle {
28
26
  export class PermissionServiceLifecycle implements ServiceLifecycle {
29
27
  constructor(
30
28
  private readonly service: PermissionsService,
31
- private readonly registry: SubagentSessionRegistry,
29
+ private readonly detection: RegisteredChildDetector,
32
30
  private readonly events: PermissionEventBus,
33
31
  private readonly subscriptions: readonly (() => void)[],
34
32
  ) {}
35
33
 
36
34
  activate(ctx: ExtensionContext): void {
37
- if (!isRegisteredSubagentChild(ctx, this.registry)) {
35
+ if (!this.detection.isRegisteredChild(ctx)) {
38
36
  publishPermissionsService(this.service);
39
37
  }
40
38
  emitReadyEvent(this.events);
@@ -19,7 +19,7 @@ export interface ReviewLogger {
19
19
 
20
20
  /**
21
21
  * Logging seam for consumers that write both debug and review entries.
22
- * Injected into `ConfigStore` and `PermissionForwarder`.
22
+ * Injected into `ConfigStore`, `ApprovalEscalator`, and `ForwardedRequestServer`.
23
23
  */
24
24
  export interface DebugReviewLogger extends ReviewLogger {
25
25
  debug(event: string, details?: Record<string, unknown>): void;