@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 +12 -0
- package/docs/subagent-integration.md +14 -1
- package/package.json +1 -1
- package/src/authority/approval-escalator.ts +8 -8
- package/src/authority/authorizer-selection.ts +43 -4
- package/src/authority/authorizer.ts +77 -23
- package/src/authority/forwarding-liveness.ts +5 -6
- package/src/authority/permission-forwarding.ts +9 -11
- package/src/authority/subagent-detection.ts +4 -3
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
|
|
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
|
@@ -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
|
|
177
|
-
*
|
|
178
|
-
* (formerly `ApprovalEscalator.requestApproval`'s
|
|
179
|
-
* arms, both dead once every caller routes through
|
|
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
|
-
|
|
225
|
-
//
|
|
226
|
-
//
|
|
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 `
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
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
|
-
|
|
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:
|
|
81
|
-
*
|
|
82
|
-
* `detection.isSubagent(ctx)` alone would get that
|
|
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
|
|
121
|
-
*
|
|
130
|
+
* three-way local / relay / deny dispatch, and of the chain role that dispatch
|
|
131
|
+
* implies.
|
|
122
132
|
*
|
|
123
|
-
* Evaluated
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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:
|
|
134
|
-
|
|
135
|
-
|
|
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:
|
|
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
|
|
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
|
|
291
|
-
* forwarding location.
|
|
290
|
+
* where that signal is unavailable.
|
|
292
291
|
*/
|
|
293
|
-
export type PermissionForwardingTargetSource = "
|
|
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.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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;
|