@gotgenes/pi-permission-system 31.1.4 → 32.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,18 @@ 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
+ ## [32.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.4...pi-permission-system-v32.0.0) (2026-09-11)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** **breaking:** prompt in the parent session for a subagent that has its own UI ([24b56a2](https://github.com/gotgenes/pi-packages/commit/24b56a2181df3db8b17e45b1acefd5a53fe2eddf)), closes [#907](https://github.com/gotgenes/pi-packages/issues/907), closes [#909](https://github.com/gotgenes/pi-packages/issues/909)
14
+ * **pi-permission-system:** record when a session starts or stops relaying its asks ([7d1e2bb](https://github.com/gotgenes/pi-packages/commit/7d1e2bbe69d59360a66a27d3277d41aedecbefb5)), closes [#909](https://github.com/gotgenes/pi-packages/issues/909)
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** document relaying from a session that has its own UI ([0250d68](https://github.com/gotgenes/pi-packages/commit/0250d68bb3a89e32782836ed1b6a17d4e52d25e8)), closes [#909](https://github.com/gotgenes/pi-packages/issues/909)
19
+
8
20
  ## [31.1.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.3...pi-permission-system-v31.1.4) (2026-09-11)
9
21
 
10
22
 
@@ -63,6 +63,19 @@ A value naming the reading session itself is ignored as a forwarding target, sin
63
63
  That matters when an implementation rewrites an inherited marker with the current session's id: doing so in a child destroys the only record of its real parent, and the child's asks then fail closed with an unresolved-target error.
64
64
  Guard such a rewrite on the process being a root — for example, skip it when a child marker such as `PI_SUBAGENT_CHILD` is present.
65
65
 
66
+ #### Children that keep a UI of their own
67
+
68
+ An implementation may spawn each child as a full interactive session — a visible pane per agent, for observability and direct inspection — while keeping permission authority with the human at the lead session.
69
+ A child like that forwards its asks to the session the variable names, rather than opening its own dialog, for as long as that session is draining its forwarded-permission inbox.
70
+ It is the declared target that decides this, so an implementation opts in by naming a parent, and opts out by not naming one; nothing else is required of it.
71
+
72
+ The liveness condition is what keeps a visible child usable.
73
+ A child whose named parent has exited, been killed, or stopped polling opens its own dialog instead of refusing the tool call, and the decision is remade on every turn: a pane whose lead exits mid-run returns to prompting locally at its next turn, and one whose lead comes back starts forwarding again.
74
+ Only an ask already in flight when the parent disappears is refused, with the usual approval-unavailable reason.
75
+
76
+ A session that relays still serves its own inbox, so a `lead → agent → agent` tree relays through the middle hop.
77
+ What it does not do is run its own authorizer chain: live authority converges at the node that decides, which is the serving one ([ADR 0007] §7).
78
+
66
79
  ### What an implementation does not owe
67
80
 
68
81
  None of the following is an implementation's responsibility, on either process shape:
@@ -132,7 +145,7 @@ When `@gotgenes/pi-permission-system` is not installed, an implementation emits
132
145
 
133
146
  ## Permission Forwarding
134
147
 
135
- When a delegated or routed subagent runs without direct UI access, `ask` permissions can still be enforced by forwarding the confirmation request through Pi session directories.
148
+ When a delegated or routed subagent cannot decide an `ask` where it runs — because it has no UI of its own, or because it names a parent session that is answering for it — the confirmation request is forwarded through Pi session directories instead.
136
149
  The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
137
150
  A parent `allow`/`deny` rule governs a child's escalation directly (the serving node resolves it as recorded authority before prompting), and a "whole session" grant recorded on the parent auto-approves later forwards of the same pattern.
138
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "31.1.4",
3
+ "version": "32.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -173,10 +173,11 @@ function forwardableRequestId(requesterRequestId: string): string {
173
173
  * Owns the escalation-up role of the forwarded-permission behavior: builds
174
174
  * and persists a request file, then polls for the parent session's
175
175
  * response. `ctx` is bound once at construction — `selectAuthorizer` only
176
- * constructs a `ParentAuthorizer` for a context it has already confirmed has
177
- * no UI and is a subagent, so `authorize` never re-derives that dispatch
178
- * (formerly `ApprovalEscalator.requestApproval`'s `hasUI` / `!isSubagent`
179
- * arms, both dead once every caller routes through `selectAuthorizer`).
176
+ * constructs a `ParentAuthorizer` for a context it has already established is a
177
+ * child, whether that child has a UI of its own (#909) or not, so `authorize`
178
+ * never re-derives that dispatch (formerly `ApprovalEscalator.requestApproval`'s
179
+ * `hasUI` / `!isSubagent` arms, both dead once every caller routes through
180
+ * `selectAuthorizer`).
180
181
  */
181
182
  export class ParentAuthorizer implements TerminalAuthorizer {
182
183
  private readonly forwardingDir: string;
@@ -221,10 +222,9 @@ export class ParentAuthorizer implements TerminalAuthorizer {
221
222
  ): Promise<PermissionPromptDecision> {
222
223
  const requesterSessionId = getSessionId(ctx);
223
224
  const target = resolvePermissionForwardingTarget({
224
- hasUI: ctx.hasUI,
225
- // Invariant: selectAuthorizer only selects ParentAuthorizer for a
226
- // no-UI subagent context, so this is always true — no detection dep
227
- // needed to re-derive it here.
225
+ // Invariant: selectAuthorizer only selects ParentAuthorizer for a context
226
+ // it has already established is a child — no detection dep needed to
227
+ // re-derive it here.
228
228
  isSubagent: true,
229
229
  currentSessionId: requesterSessionId,
230
230
  env: process.env,
@@ -10,6 +10,7 @@ import { composeAuthorizerChain } from "./authorizer-chain";
10
10
  import type { AuthorizerLookup } from "./authorizer-registry";
11
11
  import { encloseInDelegationEnvelope } from "./delegation-envelope";
12
12
  import type { PermissionPromptDecision } from "./permission-dialog";
13
+ import type { PermissionForwardingTarget } from "./permission-forwarding";
13
14
  import type {
14
15
  PermissionPrompterApi,
15
16
  PromptPermissionDetails,
@@ -48,9 +49,13 @@ export interface AskEscalator {
48
49
  * so a sibling extension learns it without knowing what a subagent is) and by
49
50
  * the registration observer (which records a link registered where no chain
50
51
  * runs). Both depend on this single-method view rather than the selection
51
- * itself, and neither may re-derive the role from `detection.isSubagent(ctx)`:
52
- * `selectAuthorizer` tests `hasUI` first, so a subagent with its own UI
53
- * adjudicates locally.
52
+ * itself, and neither may re-derive the role from `ctx.hasUI` or
53
+ * `detection.isSubagent(ctx)`: a node with a UI relays when it names another
54
+ * session that is draining its inbox, and decides locally otherwise (#909).
55
+ *
56
+ * Because the selection is remade on every activation, the answer can change
57
+ * within one session — a node stops relaying as soon as its declared parent
58
+ * stops serving.
54
59
  */
55
60
  export interface AdjudicationRole {
56
61
  adjudicatesLocally(): boolean;
@@ -72,6 +77,7 @@ export class AuthorizerSelection
72
77
  implements AskEscalator, AuthorizerSelectionLifecycle, AdjudicationRole
73
78
  {
74
79
  private authority: SelectedAuthority | null = null;
80
+ private relayTarget: PermissionForwardingTarget | null = null;
75
81
 
76
82
  constructor(
77
83
  private readonly deps: AuthorizerSelectionDeps & {
@@ -92,7 +98,39 @@ export class AuthorizerSelection
92
98
  * activation, so link resolution is deferred to the session's first ask.
93
99
  */
94
100
  activate(ctx: ExtensionContext): void {
95
- this.authority = selectAuthorizer(ctx, this.deps);
101
+ const authority = selectAuthorizer(ctx, this.deps);
102
+ this.recordRelayTransition(authority.relayTarget ?? null);
103
+ this.authority = authority;
104
+ }
105
+
106
+ /**
107
+ * Record that this node started, stopped, or redirected its relaying.
108
+ *
109
+ * `activate` runs on every turn event, so only a change is worth a line: the
110
+ * pair reads beside the serving node's own
111
+ * `forwarded_permission.serving_started`/`serving_stopped`, which is what
112
+ * makes a misdirected relay a one-line diff across the two sessions. A node
113
+ * that never relays writes nothing at all.
114
+ */
115
+ private recordRelayTransition(
116
+ target: PermissionForwardingTarget | null,
117
+ ): void {
118
+ const previous = this.relayTarget;
119
+ if (previous?.sessionId === target?.sessionId) {
120
+ return;
121
+ }
122
+ this.relayTarget = target;
123
+ if (previous !== null) {
124
+ this.deps.logger.review("forwarded_permission.relay_stopped", {
125
+ targetSessionId: previous.sessionId,
126
+ });
127
+ }
128
+ if (target !== null) {
129
+ this.deps.logger.review("forwarded_permission.relay_started", {
130
+ targetSessionId: target.sessionId,
131
+ channel: target.source,
132
+ });
133
+ }
96
134
  }
97
135
 
98
136
  /**
@@ -181,6 +219,7 @@ export class AuthorizerSelection
181
219
 
182
220
  /** Clear the stored selection. */
183
221
  deactivate(): void {
222
+ this.recordRelayTransition(null);
184
223
  this.authority = null;
185
224
  }
186
225
 
@@ -4,9 +4,12 @@ import type { AuthorizerLog, PermissionQuery } from "#src/service";
4
4
  import type { PermissionEventBus } from "#src/service/permission-events";
5
5
  import { ParentAuthorizer } from "./approval-escalator";
6
6
  import { DenyingAuthorizer } from "./denying-authorizer";
7
+ import { getSessionId } from "./forwarder-context";
7
8
  import type { TargetServingLookup } from "./forwarding-liveness";
8
9
  import { LocalUserAuthorizer } from "./local-user-authorizer";
9
10
  import type { PermissionPromptDecision } from "./permission-dialog";
11
+ import type { PermissionForwardingTarget } from "./permission-forwarding";
12
+ import { resolvePermissionForwardingTarget } from "./permission-forwarding";
10
13
  import type {
11
14
  PromptPreferences,
12
15
  requestPermissionDecision,
@@ -77,9 +80,10 @@ export interface TerminalAuthorizer {
77
80
  * whether this node adjudicates them with its own chain.
78
81
  *
79
82
  * The chain role is the selection's product, not a discriminator a consumer
80
- * re-derives: `selectAuthorizer` tests `hasUI` before `isSubagent`, so a
81
- * subagent that has its own UI decides locally, and re-deriving the role from
82
- * `detection.isSubagent(ctx)` alone would get that case wrong.
83
+ * re-derives: a node with a UI decides locally unless it names another session
84
+ * that is draining its forwarded-permission inbox (#909), so re-deriving the
85
+ * role from `ctx.hasUI` or `detection.isSubagent(ctx)` alone would get that
86
+ * case wrong in opposite directions.
83
87
  */
84
88
  export interface SelectedAuthority {
85
89
  /** The terminal that decides this node's asks, or relays them upward. */
@@ -92,6 +96,12 @@ export interface SelectedAuthority {
92
96
  * twice.
93
97
  */
94
98
  readonly adjudicatesLocally: boolean;
99
+ /**
100
+ * The target this selection itself verified as live, for the relay-transition
101
+ * record. Absent on the headless relay arm, where the target is resolved per
102
+ * ask by `ParentAuthorizer` rather than at selection.
103
+ */
104
+ readonly relayTarget?: PermissionForwardingTarget;
95
105
  }
96
106
 
97
107
  /** Construction inputs for {@link selectAuthorizer}. */
@@ -117,40 +127,84 @@ export interface AuthorizerSelectionDeps {
117
127
 
118
128
  /**
119
129
  * Select the live authority for the current context: the single owner of the
120
- * three-way `hasUI` / `isSubagent` / deny dispatch, and of the chain role that
121
- * dispatch implies.
130
+ * three-way local / relay / deny dispatch, and of the chain role that dispatch
131
+ * implies.
122
132
  *
123
- * Evaluated once per session activation (`AuthorizerSelection.activate`),
124
- * replacing the re-derivation of the same predicates across
125
- * `PromptingGateway`, `PermissionPrompter`, and `ApprovalEscalator`.
133
+ * Evaluated on every session activation (`AuthorizerSelection.activate`), which
134
+ * is what lets a node with a UI follow its declared parent's liveness: the
135
+ * moment that parent stops serving, the next activation selects the local
136
+ * dialog again.
126
137
  */
127
138
  export function selectAuthorizer(
128
139
  ctx: ExtensionContext,
129
140
  deps: AuthorizerSelectionDeps,
130
141
  ): SelectedAuthority {
131
142
  if (ctx.hasUI) {
143
+ const relayTarget = resolveLiveRelayTarget(ctx, deps);
144
+ if (relayTarget === null) {
145
+ return {
146
+ terminal: new LocalUserAuthorizer({
147
+ ui: ctx.ui,
148
+ mode: ctx.mode,
149
+ events: deps.events,
150
+ getPromptPreferences: deps.getPromptPreferences,
151
+ requestPermissionDecision: deps.requestPermissionDecision,
152
+ }),
153
+ adjudicatesLocally: true,
154
+ };
155
+ }
132
156
  return {
133
- terminal: new LocalUserAuthorizer({
134
- ui: ctx.ui,
135
- mode: ctx.mode,
136
- events: deps.events,
137
- getPromptPreferences: deps.getPromptPreferences,
138
- requestPermissionDecision: deps.requestPermissionDecision,
139
- }),
140
- adjudicatesLocally: true,
157
+ terminal: buildParentAuthorizer(ctx, deps),
158
+ adjudicatesLocally: false,
159
+ relayTarget,
141
160
  };
142
161
  }
143
162
  if (deps.detection.isSubagent(ctx)) {
144
163
  return {
145
- terminal: new ParentAuthorizer(ctx, {
146
- forwardingDir: deps.forwardingDir,
147
- registry: deps.registry,
148
- serving: deps.serving,
149
- getTimeoutMs: deps.getForwardingTimeoutMs,
150
- logger: deps.logger,
151
- }),
164
+ terminal: buildParentAuthorizer(ctx, deps),
152
165
  adjudicatesLocally: false,
153
166
  };
154
167
  }
155
168
  return { terminal: new DenyingAuthorizer(), adjudicatesLocally: true };
156
169
  }
170
+
171
+ /**
172
+ * The session a node with a UI should relay to, or `null` when it should decide
173
+ * for itself.
174
+ *
175
+ * A human is present here, so only a definite "yes" relays: a target nobody can
176
+ * confirm is draining its inbox leaves the ask with the human who is already
177
+ * watching. That is the opposite burden of proof from
178
+ * `ParentAuthorizer.checkServingLiveness`, where an unjudgeable target waits
179
+ * out the timeout because a headless child has no alternative.
180
+ */
181
+ function resolveLiveRelayTarget(
182
+ ctx: ExtensionContext,
183
+ deps: AuthorizerSelectionDeps,
184
+ ): PermissionForwardingTarget | null {
185
+ const sessionId = getSessionId(ctx);
186
+ const target = resolvePermissionForwardingTarget({
187
+ isSubagent: deps.detection.isSubagent(ctx),
188
+ currentSessionId: sessionId,
189
+ sessionId,
190
+ registry: deps.registry,
191
+ });
192
+ if (target === null || deps.serving.isServing(target) !== true) {
193
+ return null;
194
+ }
195
+ return target;
196
+ }
197
+
198
+ /** The relaying terminal for `ctx`, built from the selection's own deps. */
199
+ function buildParentAuthorizer(
200
+ ctx: ExtensionContext,
201
+ deps: AuthorizerSelectionDeps,
202
+ ): ParentAuthorizer {
203
+ return new ParentAuthorizer(ctx, {
204
+ forwardingDir: deps.forwardingDir,
205
+ registry: deps.registry,
206
+ serving: deps.serving,
207
+ getTimeoutMs: deps.getForwardingTimeoutMs,
208
+ logger: deps.logger,
209
+ });
210
+ }
@@ -92,8 +92,7 @@ export interface HeartbeatReader {
92
92
  * Keyed on the target rather than a session id because the answer depends on
93
93
  * how the target was resolved. An in-process child and its parent share a
94
94
  * `globalThis`, so the registry answers for them; an out-of-process pair shares
95
- * only the filesystem; and a session that owns the inbox it is forwarding to is
96
- * not a case either channel describes.
95
+ * only the filesystem.
97
96
  *
98
97
  * Consolidating that into one collaborator is what keeps `ParentAuthorizer`
99
98
  * from holding two lookups and re-deciding which one applies — the decision has
@@ -108,6 +107,10 @@ export interface TargetServingLookup {
108
107
 
109
108
  /** What answered a liveness question, and what it saw. */
110
109
  export interface ServingObservation {
110
+ /**
111
+ * Which channel answered. `"none"` belongs to a lookup that consulted none —
112
+ * the real judge never gives it, since every target it sees names a channel.
113
+ */
111
114
  channel: "registry" | "heartbeat" | "none";
112
115
  /** The heartbeat state behind a `"heartbeat"` answer; `null` on the other channels. */
113
116
  state: HeartbeatState | null;
@@ -138,8 +141,6 @@ export class ForwardingLivenessJudge implements TargetServingLookup {
138
141
  return this.deps.registry.isServing(target.sessionId);
139
142
  case "env":
140
143
  return this.deps.heartbeats.read(target.sessionId) === "alive";
141
- case "self":
142
- return null;
143
144
  }
144
145
  }
145
146
 
@@ -157,8 +158,6 @@ export class ForwardingLivenessJudge implements TargetServingLookup {
157
158
  state: this.deps.heartbeats.read(target.sessionId),
158
159
  servingIds: this.deps.heartbeats.servingIds(),
159
160
  };
160
- case "self":
161
- return { channel: "none", state: null, servingIds: [] };
162
161
  }
163
162
  }
164
163
  }
@@ -287,10 +287,9 @@ export function createPermissionForwardingLocation(
287
287
  * **in-process** child of `sessionId`, so the two share a `globalThis` and the
288
288
  * requester may consult the serving-session registry to decide whether anyone
289
289
  * is draining its inbox. `"env"` means the target lives in another process,
290
- * where that signal is unavailable; `"self"` is the UI host owning its own
291
- * forwarding location.
290
+ * where that signal is unavailable.
292
291
  */
293
- export type PermissionForwardingTargetSource = "self" | "registry" | "env";
292
+ export type PermissionForwardingTargetSource = "registry" | "env";
294
293
 
295
294
  /** The resolved forwarding target together with how it was found. */
296
295
  export interface PermissionForwardingTarget {
@@ -298,8 +297,14 @@ export interface PermissionForwardingTarget {
298
297
  source: PermissionForwardingTargetSource;
299
298
  }
300
299
 
300
+ /**
301
+ * The session this node relays its asks to, or `null` when it has none.
302
+ *
303
+ * Answers only "which *other* session", never "myself": a node that owns its
304
+ * forwarding location has nothing to resolve, and a request filed into one's
305
+ * own inbox is drained by no watcher.
306
+ */
301
307
  export function resolvePermissionForwardingTarget(options: {
302
- hasUI: boolean;
303
308
  isSubagent: boolean;
304
309
  currentSessionId?: string | null;
305
310
  env?: NodeJS.ProcessEnv;
@@ -308,13 +313,6 @@ export function resolvePermissionForwardingTarget(options: {
308
313
  /** In-process subagent session registry (checked before env vars). */
309
314
  registry?: SubagentSessionRegistry;
310
315
  }): PermissionForwardingTarget | null {
311
- if (options.hasUI) {
312
- const own = normalizePermissionForwardingSessionId(
313
- options.currentSessionId,
314
- );
315
- return own === null ? null : { sessionId: own, source: "self" };
316
- }
317
-
318
316
  if (!options.isSubagent) {
319
317
  return null;
320
318
  }
@@ -15,9 +15,10 @@ import type { SubagentSessionRegistry } from "./subagent-registry";
15
15
  * It answers "is this process a child", which is **not** "should this node relay
16
16
  * rather than decide". A UI host answers `true` here whenever its process
17
17
  * carries a parent-session marker — a spawner may export one from the root so
18
- * the children it launches inherit it. Every consumer therefore tests `hasUI`
19
- * first: `selectAuthorizer` returns before reaching this predicate, and serving
20
- * eligibility does not consult it at all (#907).
18
+ * the children it launches inherit it. No consumer may read it as a relay
19
+ * decision: `selectAuthorizer` relays a node with a UI only when a forwarding
20
+ * target resolves *and* that target is serving (#909), and serving eligibility
21
+ * does not consult this predicate at all (#907).
21
22
  */
22
23
  export interface SubagentDetector {
23
24
  isSubagent(ctx: SubagentDetectionContext): boolean;