@gotgenes/pi-permission-system 9.2.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ 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
+ ## [10.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.2.0...pi-permission-system-v10.0.0) (2026-06-02)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **pi-permission-system:** the permissions:ready event payload no longer includes protocolVersion. Consumers that read it must rely on package semver instead.
14
+
15
+ ### Features
16
+
17
+ * **pi-permission-manager:** broadcast permission prompts on permissions:prompt channel ([8540f3b](https://github.com/gotgenes/pi-packages/commit/8540f3b462b76a4789c4c17a75fadf254ae39feb))
18
+ * **pi-permission-system:** drop protocolVersion from permissions:ready ([6728a93](https://github.com/gotgenes/pi-packages/commit/6728a93af7edbc6953d20f448f1c3f54f9b7893f))
19
+ * **pi-permission-system:** harden prompt broadcasts ([067bafd](https://github.com/gotgenes/pi-packages/commit/067bafd80ef983fd8b9ab00914cf1cec9b6db915))
20
+ * **pi-permission-system:** make ready and decision broadcasts best-effort ([00a895f](https://github.com/gotgenes/pi-packages/commit/00a895f9377bcb7b598acbc6c95d7bc7cc83c515))
21
+ * **pi-permission-system:** preserve display fields for forwarded prompts ([9970912](https://github.com/gotgenes/pi-packages/commit/997091228736bbd4395d8bd16aeb9f4a4ae7e0b2))
22
+ * **pi-permission-system:** slim ui_prompt payload and centralize construction ([7a1ec56](https://github.com/gotgenes/pi-packages/commit/7a1ec56a827e90fe80a0f7de48e1222b5271700d))
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * **pi-permission-system:** drop manual CHANGELOG Unreleased section ([f14e4f5](https://github.com/gotgenes/pi-packages/commit/f14e4f5d9b5ae1d6b207a913aefdd71980a46dd6))
28
+
29
+
30
+ ### Documentation
31
+
32
+ * **pi-permission-system:** document the lean ui_prompt contract ([0b3c11c](https://github.com/gotgenes/pi-packages/commit/0b3c11c57a7b718d2f73a184802f8c5dcb95fbe7))
33
+
8
34
  ## [9.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.1.0...pi-permission-system-v9.2.0) (2026-06-02)
9
35
 
10
36
 
package/README.md CHANGED
@@ -20,6 +20,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
20
20
  - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
22
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
23
+ - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
23
24
  - **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
24
25
 
25
26
  ## Install
@@ -89,16 +90,16 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
89
90
 
90
91
  ## Documentation
91
92
 
92
- | Document | Contents |
93
- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
94
- | [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
95
- | [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
96
- | [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, decision broadcasts |
97
- | [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions |
98
- | [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
99
- | [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
100
- | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
101
- | [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
93
+ | Document | Contents |
94
+ | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
95
+ | [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
96
+ | [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
97
+ | [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, prompt and decision broadcasts |
98
+ | [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions |
99
+ | [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
100
+ | [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
101
+ | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
102
+ | [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
102
103
 
103
104
  ## Development
104
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "9.2.0",
3
+ "version": "10.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,6 +10,7 @@ import {
10
10
  } from "node:fs";
11
11
 
12
12
  import { isPermissionDecisionState } from "#src/permission-dialog";
13
+ import type { PermissionUiPromptSource } from "#src/permission-events";
13
14
  import {
14
15
  createPermissionForwardingLocation,
15
16
  type ForwardedPermissionRequest,
@@ -17,6 +18,29 @@ import {
17
18
  type PermissionForwardingLocation,
18
19
  } from "#src/permission-forwarding";
19
20
 
21
+ /** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
22
+ const UI_PROMPT_SOURCES = [
23
+ "tool_call",
24
+ "skill_input",
25
+ "skill_read",
26
+ "rpc_prompt",
27
+ ] as const satisfies readonly PermissionUiPromptSource[];
28
+
29
+ /** Narrow an unknown value to a valid prompt source, or `undefined`. */
30
+ function asUiPromptSource(
31
+ value: unknown,
32
+ ): PermissionUiPromptSource | undefined {
33
+ return UI_PROMPT_SOURCES.find((source) => source === value);
34
+ }
35
+
36
+ /** Narrow an unknown value to a nullable display string, or `undefined`. */
37
+ function asNullableDisplayString(value: unknown): string | null | undefined {
38
+ if (value === null || typeof value === "string") {
39
+ return value;
40
+ }
41
+ return undefined;
42
+ }
43
+
20
44
  type LogFn = (event: string, details: Record<string, unknown>) => void;
21
45
 
22
46
  export interface ForwardedPermissionLogger {
@@ -285,6 +309,11 @@ export function readForwardedPermissionRequest(
285
309
  targetSessionId: parsed.targetSessionId,
286
310
  requesterAgentName: parsed.requesterAgentName,
287
311
  message: parsed.message,
312
+ // Tolerant read: display fields are optional and may be absent (older
313
+ // child) or malformed; reconstruct only the well-formed ones.
314
+ source: asUiPromptSource(parsed.source),
315
+ surface: asNullableDisplayString(parsed.surface),
316
+ value: asNullableDisplayString(parsed.value),
288
317
  };
289
318
  } catch (error) {
290
319
  logPermissionForwardingWarning(
@@ -11,15 +11,21 @@ import type {
11
11
  PermissionPromptDecision,
12
12
  RequestPermissionOptions,
13
13
  } from "#src/permission-dialog";
14
+ import {
15
+ emitUiPromptEvent,
16
+ type PermissionEventBus,
17
+ } from "#src/permission-events";
14
18
  import {
15
19
  type ForwardedPermissionRequest,
16
20
  type ForwardedPermissionResponse,
21
+ type ForwardedPromptDisplay,
17
22
  isForwardedPermissionRequestForSession,
18
23
  PERMISSION_FORWARDING_POLL_INTERVAL_MS,
19
24
  PERMISSION_FORWARDING_TIMEOUT_MS,
20
25
  resolvePermissionForwardingTargetSessionId,
21
26
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
22
27
  } from "#src/permission-forwarding";
28
+ import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
23
29
  import { isSubagentExecutionContext } from "#src/subagent-context";
24
30
  import type { SubagentSessionRegistry } from "#src/subagent-registry";
25
31
 
@@ -43,6 +49,8 @@ export interface PermissionForwardingDeps {
43
49
  subagentSessionsDir: string;
44
50
  /** In-process subagent session registry for detection and forwarding target resolution. */
45
51
  registry?: SubagentSessionRegistry;
52
+ /** Event bus used for UI prompt broadcasts. */
53
+ events?: PermissionEventBus;
46
54
  logger: ForwardedPermissionLogger;
47
55
  writeReviewLog: (event: string, details: Record<string, unknown>) => void;
48
56
  requestPermissionDecisionFromUi: (
@@ -103,6 +111,7 @@ export async function waitForForwardedPermissionApproval(
103
111
  ctx: ExtensionContext,
104
112
  message: string,
105
113
  deps: PermissionForwardingDeps,
114
+ forwarded?: ForwardedPromptDisplay,
106
115
  ): Promise<PermissionPromptDecision> {
107
116
  const requesterSessionId = getSessionId(ctx);
108
117
  const targetSessionId = resolvePermissionForwardingTargetSessionId({
@@ -155,6 +164,13 @@ export async function waitForForwardedPermissionApproval(
155
164
  targetSessionId,
156
165
  requesterAgentName,
157
166
  message,
167
+ ...(forwarded
168
+ ? {
169
+ source: forwarded.source,
170
+ surface: forwarded.surface,
171
+ value: forwarded.value,
172
+ }
173
+ : {}),
158
174
  };
159
175
 
160
176
  const requestPath = join(location.requestsDir, `${requestId}.json`);
@@ -296,10 +312,25 @@ export async function processForwardedPermissionRequests(
296
312
  forwardedPermissionLogDetails,
297
313
  );
298
314
  try {
315
+ const forwardedMessage = formatForwardedPermissionPrompt(request);
316
+ if (deps.events) {
317
+ emitUiPromptEvent(
318
+ deps.events,
319
+ buildForwardedUiPrompt({
320
+ requestId: request.id,
321
+ message: forwardedMessage,
322
+ requesterAgentName: request.requesterAgentName || null,
323
+ requesterSessionId: request.requesterSessionId || null,
324
+ source: request.source ?? null,
325
+ surface: request.surface ?? null,
326
+ value: request.value ?? null,
327
+ }),
328
+ );
329
+ }
299
330
  decision = await deps.requestPermissionDecisionFromUi(
300
331
  ctx.ui,
301
332
  "Permission Required (Subagent)",
302
- formatForwardedPermissionPrompt(request),
333
+ forwardedMessage,
303
334
  );
304
335
  } catch (error) {
305
336
  logPermissionForwardingError(
@@ -359,6 +390,7 @@ export async function confirmPermission(
359
390
  message: string,
360
391
  deps: PermissionForwardingDeps,
361
392
  options?: RequestPermissionOptions,
393
+ forwarded?: ForwardedPromptDisplay,
362
394
  ): Promise<PermissionPromptDecision> {
363
395
  if (ctx.hasUI) {
364
396
  return deps.requestPermissionDecisionFromUi(
@@ -375,5 +407,5 @@ export async function confirmPermission(
375
407
  return { approved: false, state: "denied" };
376
408
  }
377
409
 
378
- return waitForForwardedPermissionApproval(ctx, message, deps);
410
+ return waitForForwardedPermissionApproval(ctx, message, deps, forwarded);
379
411
  }
package/src/index.ts CHANGED
@@ -54,6 +54,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
54
54
  subagentSessionsDir: runtime.subagentSessionsDir,
55
55
  forwardingDir: runtime.forwardingDir,
56
56
  registry: subagentRegistry,
57
+ events: pi.events,
57
58
  requestPermissionDecisionFromUi,
58
59
  });
59
60
 
@@ -61,6 +62,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
61
62
  forwardingDir: runtime.forwardingDir,
62
63
  subagentSessionsDir: runtime.subagentSessionsDir,
63
64
  registry: subagentRegistry,
65
+ events: pi.events,
64
66
  logger: {
65
67
  writeReviewLog: runtime.writeReviewLog.bind(runtime),
66
68
  writeDebugLog: runtime.writeDebugLog.bind(runtime),
@@ -21,11 +21,13 @@ import type {
21
21
  PermissionsRpcReply,
22
22
  } from "./permission-events";
23
23
  import {
24
+ emitUiPromptEvent,
24
25
  PERMISSIONS_PROTOCOL_VERSION,
25
26
  PERMISSIONS_RPC_CHECK_CHANNEL,
26
27
  PERMISSIONS_RPC_PROMPT_CHANNEL,
27
28
  } from "./permission-events";
28
29
  import type { PermissionManager } from "./permission-manager";
30
+ import { buildRpcUiPrompt } from "./permission-ui-prompt";
29
31
  import type { Rule } from "./rule";
30
32
 
31
33
  /** Dependencies injected into the RPC handler registry. */
@@ -155,6 +157,11 @@ async function handlePromptRpc(
155
157
  ? `Permission request${agentName ? ` from ${agentName}` : ""}`
156
158
  : "Permission request";
157
159
 
160
+ emitUiPromptEvent(
161
+ events,
162
+ buildRpcUiPrompt({ requestId, surface, value, agentName, message }),
163
+ );
164
+
158
165
  const decision = await deps.requestPermissionDecisionFromUi(
159
166
  ctx.ui,
160
167
  title,
@@ -27,6 +27,9 @@ export const PERMISSIONS_PROTOCOL_VERSION = 1;
27
27
  /** Emitted at `session_start`, after the service is published. */
28
28
  export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
29
29
 
30
+ /** Emitted when a permission request is committed to the active UI prompt path. */
31
+ export const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
32
+
30
33
  /** Emitted after every permission gate resolution. */
31
34
  export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
32
35
 
@@ -61,9 +64,63 @@ export type PermissionsRpcReply<T = void> =
61
64
 
62
65
  // ── permissions:ready ──────────────────────────────────────────────────────
63
66
 
64
- /** Payload emitted on `permissions:ready`. */
65
- export interface PermissionsReadyEvent {
66
- protocolVersion: number;
67
+ /**
68
+ * Payload emitted on `permissions:ready`.
69
+ *
70
+ * Intentionally empty: the channel is a readiness signal. Version negotiation
71
+ * lives in the RPC envelope (`PermissionsRpcReply`), not in broadcast payloads —
72
+ * the published types plus package semver define the broadcast contract.
73
+ */
74
+ export type PermissionsReadyEvent = Record<string, never>;
75
+
76
+ // ── permissions:ui_prompt ──────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Origin of a UI prompt.
80
+ *
81
+ * Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its
82
+ * original source and is identified by a non-null `forwarding` field, not by a
83
+ * dedicated source value.
84
+ */
85
+ export type PermissionUiPromptSource =
86
+ | "tool_call"
87
+ | "skill_input"
88
+ | "skill_read"
89
+ | "rpc_prompt";
90
+
91
+ /** Forwarding context, present only when a prompt was forwarded from a non-UI subagent. */
92
+ export interface ForwardedPromptContext {
93
+ /** Requesting subagent's display name, when known. */
94
+ requesterAgentName: string | null;
95
+ /** Requesting subagent's session id, when known. */
96
+ requesterSessionId: string | null;
97
+ }
98
+
99
+ /**
100
+ * Payload emitted on `permissions:ui_prompt`, immediately before the active
101
+ * user-facing permission UI is shown.
102
+ *
103
+ * Lean by design: `surface`/`value` are the normalized display projection a
104
+ * notification consumer reads; `source` is the origin; `forwarding` is non-null
105
+ * only for forwarded subagent prompts. There is no `protocolVersion` — the
106
+ * published types plus package semver define the broadcast contract, and
107
+ * consumers should read defensively.
108
+ */
109
+ export interface PermissionUiPromptEvent {
110
+ /** Unique ID for the permission request being prompted. */
111
+ requestId: string;
112
+ /** Prompt origin. */
113
+ source: PermissionUiPromptSource;
114
+ /** Normalized display surface (e.g. "bash", "skill"), when known. */
115
+ surface: string | null;
116
+ /** Normalized display value (command, path, skill name, etc.), when known. */
117
+ value: string | null;
118
+ /** Agent name (when known). */
119
+ agentName: string | null;
120
+ /** Message displayed to the user. */
121
+ message: string;
122
+ /** Forwarding context, or null for a direct prompt. */
123
+ forwarding: ForwardedPromptContext | null;
67
124
  }
68
125
 
69
126
  // ── permissions:decision ───────────────────────────────────────────────────
@@ -164,10 +221,29 @@ export interface PermissionsPromptReplyData {
164
221
  * reacting to ready can immediately resolve `getPermissionsService()`.
165
222
  */
166
223
  export function emitReadyEvent(events: PermissionEventBus): void {
167
- const payload: PermissionsReadyEvent = {
168
- protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
169
- };
170
- events.emit(PERMISSIONS_READY_CHANNEL, payload);
224
+ const payload: PermissionsReadyEvent = {};
225
+ try {
226
+ events.emit(PERMISSIONS_READY_CHANNEL, payload);
227
+ } catch {
228
+ // Broadcasts are best-effort. A throwing listener must not block the
229
+ // permission system from completing session startup.
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Emit a `permissions:ui_prompt` broadcast.
235
+ * Call immediately before invoking the active user-facing permission UI.
236
+ */
237
+ export function emitUiPromptEvent(
238
+ events: PermissionEventBus,
239
+ event: PermissionUiPromptEvent,
240
+ ): void {
241
+ try {
242
+ events.emit(PERMISSIONS_UI_PROMPT_CHANNEL, event);
243
+ } catch {
244
+ // UI-prompt broadcasts are observational. A consumer failure must not block
245
+ // the permission dialog itself.
246
+ }
171
247
  }
172
248
 
173
249
  /**
@@ -178,5 +254,10 @@ export function emitDecisionEvent(
178
254
  events: PermissionEventBus,
179
255
  event: PermissionDecisionEvent,
180
256
  ): void {
181
- events.emit(PERMISSIONS_DECISION_CHANNEL, event);
257
+ try {
258
+ events.emit(PERMISSIONS_DECISION_CHANNEL, event);
259
+ } catch {
260
+ // Broadcasts are best-effort. A throwing listener must not block the
261
+ // permission gate from resolving.
262
+ }
182
263
  }
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
 
3
3
  import type { PermissionDecisionState } from "./permission-dialog";
4
+ import type { PermissionUiPromptSource } from "./permission-events";
4
5
  import type { SubagentSessionRegistry } from "./subagent-registry";
5
6
 
6
7
  export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
@@ -38,6 +39,19 @@ const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
38
39
  const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
39
40
  const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
40
41
 
42
+ /**
43
+ * Display fields relayed from a forwarding child to the parent UI so the parent
44
+ * can emit a non-degraded `permissions:ui_prompt` event.
45
+ *
46
+ * Carried separately from the prompt message because the parent reconstructs
47
+ * the original event through `buildForwardedUiPrompt`, not from the message text.
48
+ */
49
+ export interface ForwardedPromptDisplay {
50
+ source: PermissionUiPromptSource;
51
+ surface: string | null;
52
+ value: string | null;
53
+ }
54
+
41
55
  export type ForwardedPermissionRequest = {
42
56
  id: string;
43
57
  createdAt: number;
@@ -45,6 +59,15 @@ export type ForwardedPermissionRequest = {
45
59
  targetSessionId: string;
46
60
  requesterAgentName: string;
47
61
  message: string;
62
+ /**
63
+ * Original prompt display fields, persisted so the parent emits a
64
+ * non-degraded event. Optional for version-skew tolerance: a parent on a
65
+ * newer version may read a request written by an older child during an
66
+ * upgrade, in which case the reader defaults `source` to `"tool_call"`.
67
+ */
68
+ source?: PermissionUiPromptSource;
69
+ surface?: string | null;
70
+ value?: string | null;
48
71
  };
49
72
 
50
73
  export type ForwardedPermissionResponse = {
@@ -9,6 +9,11 @@ import type {
9
9
  PermissionPromptDecision,
10
10
  RequestPermissionOptions,
11
11
  } from "./permission-dialog";
12
+ import {
13
+ emitUiPromptEvent,
14
+ type PermissionEventBus,
15
+ } from "./permission-events";
16
+ import { buildDirectUiPrompt } from "./permission-ui-prompt";
12
17
  import type { SubagentSessionRegistry } from "./subagent-registry";
13
18
  import { shouldAutoApprovePermissionState } from "./yolo-mode";
14
19
 
@@ -57,6 +62,8 @@ export interface PermissionPrompterDeps {
57
62
  forwardingDir: string;
58
63
  /** In-process subagent session registry for detection and forwarding target resolution. */
59
64
  registry?: SubagentSessionRegistry;
65
+ /** Event bus used for UI prompt broadcasts. */
66
+ events: PermissionEventBus;
60
67
  /** Show the interactive permission dialog in the UI. */
61
68
  requestPermissionDecisionFromUi(
62
69
  ui: ExtensionContext["ui"],
@@ -91,11 +98,25 @@ export class PermissionPrompter implements PermissionPrompterApi {
91
98
 
92
99
  this.writeReviewEntry("permission_request.waiting", details);
93
100
 
101
+ // Build the event once. When this session has UI it broadcasts directly;
102
+ // when it does not (a forwarding subagent), the display fields ride along
103
+ // to the parent so the parent emits a non-degraded event from the
104
+ // forwarded path instead of here.
105
+ const uiPrompt = buildDirectUiPrompt(details);
106
+ if (ctx.hasUI) {
107
+ emitUiPromptEvent(this.deps.events, uiPrompt);
108
+ }
109
+
94
110
  const decision = await confirmPermission(
95
111
  ctx,
96
112
  details.message,
97
113
  this.buildForwardingDeps(),
98
114
  details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
115
+ {
116
+ source: uiPrompt.source,
117
+ surface: uiPrompt.surface,
118
+ value: uiPrompt.value,
119
+ },
99
120
  );
100
121
 
101
122
  this.writeReviewEntry(
@@ -159,6 +180,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
159
180
  forwardingDir: deps.forwardingDir,
160
181
  subagentSessionsDir: deps.subagentSessionsDir,
161
182
  registry: deps.registry,
183
+ events: deps.events,
162
184
  logger,
163
185
  // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
164
186
  writeReviewLog: deps.writeReviewLog,
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Centralized construction for `permissions:ui_prompt` payloads.
3
+ *
4
+ * Every emit site builds its event through one of these functions, so the
5
+ * public contract's shape — including the normalized `surface`/`value`
6
+ * projection — lives in exactly one place and cannot drift by source.
7
+ *
8
+ * This module is a leaf: it owns narrow input types that each call site's
9
+ * domain object satisfies structurally, so it imports nothing from the
10
+ * prompter, RPC, or forwarding modules (no import cycles, correct layering).
11
+ */
12
+
13
+ import type {
14
+ PermissionUiPromptEvent,
15
+ PermissionUiPromptSource,
16
+ } from "./permission-events";
17
+
18
+ /** Input for a direct (non-forwarded) tool or skill prompt. */
19
+ export interface DirectPromptInput {
20
+ requestId: string;
21
+ source: "tool_call" | "skill_input" | "skill_read";
22
+ agentName: string | null;
23
+ message: string;
24
+ toolName?: string;
25
+ skillName?: string;
26
+ path?: string;
27
+ command?: string;
28
+ target?: string;
29
+ }
30
+
31
+ /** Input for a `permissions:rpc:prompt` forwarded UI prompt. */
32
+ export interface RpcPromptInput {
33
+ requestId: string;
34
+ surface?: string | null;
35
+ value?: string | null;
36
+ agentName?: string | null;
37
+ message: string;
38
+ }
39
+
40
+ /** Input for a file-forwarded subagent prompt shown by the parent UI. */
41
+ export interface ForwardedPromptInput {
42
+ requestId: string;
43
+ message: string;
44
+ requesterAgentName: string | null;
45
+ requesterSessionId: string | null;
46
+ /** Original prompt origin, when the forwarded request carries it. */
47
+ source?: PermissionUiPromptSource | null;
48
+ /** Original normalized surface, when the forwarded request carries it. */
49
+ surface?: string | null;
50
+ /** Original normalized value, when the forwarded request carries it. */
51
+ value?: string | null;
52
+ }
53
+
54
+ /** Normalized display surface for a direct prompt. */
55
+ function directSurface(input: DirectPromptInput): string | null {
56
+ if (input.source === "skill_input" || input.source === "skill_read") {
57
+ return "skill";
58
+ }
59
+ return input.toolName ?? null;
60
+ }
61
+
62
+ /** Normalized display value for a direct prompt. */
63
+ function directValue(input: DirectPromptInput): string | null {
64
+ return (
65
+ input.command ??
66
+ input.path ??
67
+ input.target ??
68
+ input.skillName ??
69
+ input.toolName ??
70
+ null
71
+ );
72
+ }
73
+
74
+ /** Build the UI prompt event for a direct tool/skill prompt. */
75
+ export function buildDirectUiPrompt(
76
+ input: DirectPromptInput,
77
+ ): PermissionUiPromptEvent {
78
+ return {
79
+ requestId: input.requestId,
80
+ source: input.source,
81
+ surface: directSurface(input),
82
+ value: directValue(input),
83
+ agentName: input.agentName,
84
+ message: input.message,
85
+ forwarding: null,
86
+ };
87
+ }
88
+
89
+ /** Build the UI prompt event for an RPC-forwarded prompt. */
90
+ export function buildRpcUiPrompt(
91
+ input: RpcPromptInput,
92
+ ): PermissionUiPromptEvent {
93
+ return {
94
+ requestId: input.requestId,
95
+ source: "rpc_prompt",
96
+ surface: input.surface ?? null,
97
+ value: input.value ?? null,
98
+ agentName: input.agentName ?? null,
99
+ message: input.message,
100
+ forwarding: null,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Build the UI prompt event for a file-forwarded subagent prompt.
106
+ *
107
+ * `source` defaults to `"tool_call"` (the dominant forwarded origin) when the
108
+ * persisted request predates carrying it — a parent on a newer version may read
109
+ * a request written by an older child during an upgrade. The consumer still
110
+ * receives the notify-now signal, message, and forwarding context.
111
+ */
112
+ export function buildForwardedUiPrompt(
113
+ input: ForwardedPromptInput,
114
+ ): PermissionUiPromptEvent {
115
+ return {
116
+ requestId: input.requestId,
117
+ source: input.source ?? "tool_call",
118
+ surface: input.surface ?? null,
119
+ value: input.value ?? null,
120
+ agentName: input.requesterAgentName,
121
+ message: input.message,
122
+ forwarding: {
123
+ requesterAgentName: input.requesterAgentName,
124
+ requesterSessionId: input.requesterSessionId,
125
+ },
126
+ };
127
+ }
package/src/service.ts CHANGED
@@ -14,6 +14,23 @@
14
14
  import type { ToolInputFormatter } from "./tool-input-formatter-registry";
15
15
  import type { PermissionCheckResult, PermissionState } from "./types";
16
16
 
17
+ export type {
18
+ ForwardedPromptContext,
19
+ PermissionDecisionEvent,
20
+ PermissionsPromptReplyData,
21
+ PermissionsPromptRequest,
22
+ PermissionsReadyEvent,
23
+ PermissionsRpcReply,
24
+ PermissionUiPromptEvent,
25
+ PermissionUiPromptSource,
26
+ } from "./permission-events";
27
+ export {
28
+ PERMISSIONS_DECISION_CHANNEL,
29
+ PERMISSIONS_PROTOCOL_VERSION,
30
+ PERMISSIONS_READY_CHANNEL,
31
+ PERMISSIONS_RPC_PROMPT_CHANNEL,
32
+ PERMISSIONS_UI_PROMPT_CHANNEL,
33
+ } from "./permission-events";
17
34
  export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
18
35
 
19
36
  /** Process-global key for the service slot. */
@@ -256,6 +256,11 @@ describe("subagent registry sharing across factory instances", () => {
256
256
  );
257
257
  expect(request.targetSessionId).toBe(parentSessionId);
258
258
  expect(request.requesterSessionId).toBe(childSessionId);
259
+ // The child persists the original display fields so the parent emits a
260
+ // non-degraded `permissions:ui_prompt` event (forwarded non-degradation).
261
+ expect(request.source).toBe("tool_call");
262
+ expect(request.surface).toBe("read");
263
+ expect(request.value).toBe(join(externalDir, "secret.txt"));
259
264
 
260
265
  const result = (await firePromise) as { block?: true };
261
266
  expect(result.block).toBeUndefined();