@gotgenes/pi-permission-system 31.1.3 → 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,31 @@ 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
+
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)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * keep serving forwarded permissions when a parent-session marker is inherited ([0e1188b](https://github.com/gotgenes/pi-packages/commit/0e1188bb88b8d5b3ee14db3027baf4038644678b)), closes [#907](https://github.com/gotgenes/pi-packages/issues/907)
26
+ * republish the serving heartbeat when the session id changes mid-session ([3fcb1a1](https://github.com/gotgenes/pi-packages/commit/3fcb1a1b040709510b774d873e08548da519b09a)), closes [#907](https://github.com/gotgenes/pi-packages/issues/907)
27
+ * refuse a forwarding target that names the requesting session ([30ab5cc](https://github.com/gotgenes/pi-packages/commit/30ab5cc1f1418b38cc572d53b4d166d4097ce3e1)), closes [#907](https://github.com/gotgenes/pi-packages/issues/907)
28
+
29
+ ### Documentation
30
+
31
+ * record that a UI host always serves forwarded permissions ([f62ca0b](https://github.com/gotgenes/pi-packages/commit/f62ca0b5ed920c0912691a8878375d7ce98d7770)), closes [#907](https://github.com/gotgenes/pi-packages/issues/907)
32
+
8
33
  ## [31.1.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.2...pi-permission-system-v31.1.3) (2026-09-08)
9
34
 
10
35
 
@@ -56,6 +56,26 @@ The variable identifies the session the child forwards its asks to, and naming a
56
56
  Earlier per-extension variables are grandfathered for compatibility: the markers `PI_IS_SUBAGENT`, `PI_SUBAGENT_CHILD`, `PI_SUBAGENT_NAME` and their siblings still register as child hints, and `PI_AGENT_ROUTER_PARENT_SESSION_ID` is still honored as a parent-session source, checked ahead of the convention name.
57
57
  New implementations use `PI_SUBAGENT_PARENT_SESSION` only.
58
58
 
59
+ Setting the variable in the implementation's **own** root process, so that children inherit it rather than receiving it per spawn, is supported.
60
+ A session with a UI serves its forwarded-permission inbox whatever its environment names, so a root carrying the marker keeps answering its children's asks.
61
+
62
+ A value naming the reading session itself is ignored as a forwarding target, since a request filed into one's own inbox can never be answered.
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
+ 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
+
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
+
59
79
  ### What an implementation does not owe
60
80
 
61
81
  None of the following is an implementation's responsibility, on either process shape:
@@ -125,7 +145,7 @@ When `@gotgenes/pi-permission-system` is not installed, an implementation emits
125
145
 
126
146
  ## Permission Forwarding
127
147
 
128
- 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.
129
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.
130
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.
131
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "31.1.3",
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
  }
@@ -2,9 +2,11 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { DebugReviewLogger } from "#src/logging/session-logger";
3
3
  import type { InboxProcessor } from "./forwarded-request-server";
4
4
  import { getSessionId } from "./forwarder-context";
5
- import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
5
+ import {
6
+ normalizePermissionForwardingSessionId,
7
+ PERMISSION_FORWARDING_POLL_INTERVAL_MS,
8
+ } from "./permission-forwarding";
6
9
  import type { ServingAnnouncer } from "./serving-registry";
7
- import type { SubagentDetector } from "./subagent-detection";
8
10
 
9
11
  /**
10
12
  * Narrow interface for the forwarding lifecycle used by `PermissionSession`.
@@ -17,8 +19,6 @@ export interface ForwardingController {
17
19
 
18
20
  /** Constructor config for {@link ForwardingManager}. */
19
21
  export interface ForwardingManagerDeps {
20
- /** Single owner of subagent detection; gates whether this session may serve. */
21
- detection: SubagentDetector;
22
22
  /** Drains this session's forwarded-permission inbox on each tick. */
23
23
  forwarder: InboxProcessor;
24
24
  /** Publishes that this session is draining its inbox, for forwarding children. */
@@ -39,6 +39,13 @@ export interface ForwardingManagerDeps {
39
39
  * into — and the review log records that id, so a child forwarding to a
40
40
  * *different* id is visible as a one-line diff against its
41
41
  * `forwarded_permission.request_created` entry (#719).
42
+ *
43
+ * Serving eligibility is `hasUI` and nothing else: a node with a UI has a human
44
+ * who can answer, so it drains its own inbox. It deliberately does **not** ask
45
+ * whether this process looks like a subagent — a spawner may export a
46
+ * parent-session marker from its own root process so the children it later
47
+ * launches inherit it, which made the root withdraw serving and fail every
48
+ * forwarded ask closed (#907).
42
49
  */
43
50
  export class ForwardingManager {
44
51
  private timer: NodeJS.Timeout | null = null;
@@ -49,13 +56,13 @@ export class ForwardingManager {
49
56
  constructor(private readonly deps: ForwardingManagerDeps) {}
50
57
 
51
58
  /**
52
- * Start polling if `ctx` has UI and is not a subagent execution context.
59
+ * Start polling if `ctx` has UI.
53
60
  * No-op (timer stays running) if already polling — updates the stored
54
61
  * context so the next tick uses the latest session.
55
62
  * Stops any existing poll when the context does not qualify for forwarding.
56
63
  */
57
64
  start(ctx: ExtensionContext): void {
58
- if (!ctx.hasUI || this.deps.detection.isSubagent(ctx)) {
65
+ if (!ctx.hasUI) {
59
66
  this.stop();
60
67
  return;
61
68
  }
@@ -99,30 +106,44 @@ export class ForwardingManager {
99
106
  *
100
107
  * A no-op when the id is unchanged, since `start` runs on every
101
108
  * `before_agent_start`, `input`, and `tool_call` — the announcement must not
102
- * cost a log line per turn.
109
+ * cost a log line per turn. Also a no-op for an unreachable id: a record
110
+ * under the `"unknown"` sentinel names a session no child can target.
103
111
  */
104
112
  private announceServing(sessionId: string): void {
105
- if (this.servingSessionId === sessionId) {
113
+ const served = normalizePermissionForwardingSessionId(sessionId);
114
+ if (served === null || this.servingSessionId === served) {
106
115
  return;
107
116
  }
108
117
  this.withdrawServing();
109
- this.servingSessionId = sessionId;
110
- this.deps.serving.markServing(sessionId);
118
+ this.servingSessionId = served;
119
+ this.deps.serving.markServing(served);
111
120
  this.deps.logger.review("forwarded_permission.serving_started", {
112
- sessionId,
121
+ sessionId: served,
113
122
  });
114
123
  }
115
124
 
116
125
  /**
117
126
  * Re-announce the served session, keeping a decayable channel current.
118
127
  *
119
- * Separate from {@link announceServing} because that one detects a change to
120
- * write its log line, and this one deliberately writes none — four review
121
- * entries a second would drown the log the announcement exists to make
122
- * readable.
128
+ * The id is re-resolved from the live context rather than trusted from
129
+ * `start`, because a session id can change in place without a turn event and
130
+ * `ForwardedRequestServer.processInbox` reads the live one on every tick. An
131
+ * announcement pinned to the id captured at `start` therefore drifts away
132
+ * from the inbox actually being drained, stranding children on both sides of
133
+ * the change (#907). A change is rare and diagnosis-worthy, so it is
134
+ * delegated to {@link announceServing} and logged; the unchanged case never
135
+ * reaches it and stays silent, since four review entries a second would drown
136
+ * the log the announcement exists to make readable.
123
137
  */
124
138
  private refreshServing(): void {
125
- if (this.servingSessionId === null) {
139
+ if (this.servingSessionId === null || this.context === null) {
140
+ return;
141
+ }
142
+ const liveSessionId = normalizePermissionForwardingSessionId(
143
+ getSessionId(this.context),
144
+ );
145
+ if (liveSessionId !== null && liveSessionId !== this.servingSessionId) {
146
+ this.announceServing(liveSessionId);
126
147
  return;
127
148
  }
128
149
  this.deps.serving.markServing(this.servingSessionId);
@@ -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,31 +313,36 @@ 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
  }
321
319
 
320
+ // A candidate naming the requester itself is not a usable target: the
321
+ // request would land in an inbox this node is not draining, and no other node
322
+ // would ever answer it. A child's own copy of a subagent extension can
323
+ // overwrite the spawner's marker with the child's own session id, which is
324
+ // how such a candidate arises (#907).
325
+ const own = normalizePermissionForwardingSessionId(options.currentSessionId);
326
+ const namesAnotherSession = (candidate: string): boolean => candidate !== own;
327
+
322
328
  // 1. Registry — in-process subagents register parentSessionId explicitly.
323
329
  if (options.registry && options.sessionId) {
324
330
  const entry = options.registry.get(options.sessionId);
325
331
  const resolved = normalizePermissionForwardingSessionId(
326
332
  entry?.parentSessionId,
327
333
  );
328
- if (resolved) return { sessionId: resolved, source: "registry" };
334
+ if (resolved && namesAnotherSession(resolved)) {
335
+ return { sessionId: resolved, source: "registry" };
336
+ }
329
337
  }
330
338
 
331
339
  // 2. Env vars — process-based subagent extensions.
332
340
  const env = options.env ?? process.env;
333
341
  for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
334
342
  const resolved = normalizePermissionForwardingSessionId(env[key]);
335
- if (resolved) return { sessionId: resolved, source: "env" };
343
+ if (resolved && namesAnotherSession(resolved)) {
344
+ return { sessionId: resolved, source: "env" };
345
+ }
336
346
  }
337
347
  return null;
338
348
  }
@@ -8,9 +8,17 @@ import type { SubagentSessionRegistry } from "./subagent-registry";
8
8
  /**
9
9
  * Narrow seam for the ask-path consumers: "is the current session a subagent?"
10
10
  *
11
- * `selectAuthorizer`/`AuthorizerSelection` and `ForwardingManager` 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.
11
+ * `selectAuthorizer`/`AuthorizerSelection` depends on this single-method view so
12
+ * its unit tests inject a one-field fake without casts. It is the
13
+ * Authorizer-selection predicate the Phase 9 spine consumes.
14
+ *
15
+ * It answers "is this process a child", which is **not** "should this node relay
16
+ * rather than decide". A UI host answers `true` here whenever its process
17
+ * carries a parent-session marker — a spawner may export one from the root so
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).
14
22
  */
15
23
  export interface SubagentDetector {
16
24
  isSubagent(ctx: SubagentDetectionContext): boolean;
package/src/index.ts CHANGED
@@ -215,7 +215,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
215
215
  session = new PermissionSession(
216
216
  paths,
217
217
  new ForwardingManager({
218
- detection: subagentDetection,
219
218
  forwarder: requestServer,
220
219
  serving: composeServingAnnouncers(servingRegistry, servingHeartbeats),
221
220
  logger,