@gotgenes/pi-permission-system 31.1.3 → 31.1.4

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,19 @@ 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
+ ## [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
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * 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)
14
+ * 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)
15
+ * 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)
16
+
17
+ ### Documentation
18
+
19
+ * 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)
20
+
8
21
  ## [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
22
 
10
23
 
@@ -56,6 +56,13 @@ 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
+
59
66
  ### What an implementation does not owe
60
67
 
61
68
  None of the following is an implementation's responsibility, on either process shape:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "31.1.3",
3
+ "version": "31.1.4",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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);
@@ -319,20 +319,32 @@ export function resolvePermissionForwardingTarget(options: {
319
319
  return null;
320
320
  }
321
321
 
322
+ // A candidate naming the requester itself is not a usable target: the
323
+ // request would land in an inbox this node is not draining, and no other node
324
+ // would ever answer it. A child's own copy of a subagent extension can
325
+ // overwrite the spawner's marker with the child's own session id, which is
326
+ // how such a candidate arises (#907).
327
+ const own = normalizePermissionForwardingSessionId(options.currentSessionId);
328
+ const namesAnotherSession = (candidate: string): boolean => candidate !== own;
329
+
322
330
  // 1. Registry — in-process subagents register parentSessionId explicitly.
323
331
  if (options.registry && options.sessionId) {
324
332
  const entry = options.registry.get(options.sessionId);
325
333
  const resolved = normalizePermissionForwardingSessionId(
326
334
  entry?.parentSessionId,
327
335
  );
328
- if (resolved) return { sessionId: resolved, source: "registry" };
336
+ if (resolved && namesAnotherSession(resolved)) {
337
+ return { sessionId: resolved, source: "registry" };
338
+ }
329
339
  }
330
340
 
331
341
  // 2. Env vars — process-based subagent extensions.
332
342
  const env = options.env ?? process.env;
333
343
  for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
334
344
  const resolved = normalizePermissionForwardingSessionId(env[key]);
335
- if (resolved) return { sessionId: resolved, source: "env" };
345
+ if (resolved && namesAnotherSession(resolved)) {
346
+ return { sessionId: resolved, source: "env" };
347
+ }
336
348
  }
337
349
  return null;
338
350
  }
@@ -8,9 +8,16 @@ 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. Every consumer therefore tests `hasUI`
19
+ * first: `selectAuthorizer` returns before reaching this predicate, and serving
20
+ * eligibility does not consult it at all (#907).
14
21
  */
15
22
  export interface SubagentDetector {
16
23
  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,