@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
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
113
|
+
const served = normalizePermissionForwardingSessionId(sessionId);
|
|
114
|
+
if (served === null || this.servingSessionId === served) {
|
|
106
115
|
return;
|
|
107
116
|
}
|
|
108
117
|
this.withdrawServing();
|
|
109
|
-
this.servingSessionId =
|
|
110
|
-
this.deps.serving.markServing(
|
|
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
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
|
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
|
|
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`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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,
|