@gotgenes/pi-permission-system 18.2.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.
@@ -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
  }