@gotgenes/pi-permission-system 25.0.0 → 25.2.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,44 @@ 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
+ ## [25.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.1.0...pi-permission-system-v25.2.0) (2026-08-14)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** record which chain links were consulted on each ask ([8bb52ff](https://github.com/gotgenes/pi-packages/commit/8bb52ffe26cfb0c995e8299ebe737a09f1717c57)), closes [#727](https://github.com/gotgenes/pi-packages/issues/727)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **pi-permission-system:** stop reporting a delegated subagent chain as unregistered links ([2a1c082](https://github.com/gotgenes/pi-packages/commit/2a1c08292131e9f7dd71adeac380a997ccc0069a)), closes [#727](https://github.com/gotgenes/pi-packages/issues/727)
19
+
20
+
21
+ ### Documentation
22
+
23
+ * **pi-permission-system:** document one-chain-per-node adjudication semantics ([c36980a](https://github.com/gotgenes/pi-packages/commit/c36980ae2cf6d1b115b4b3f20e9513dac61c3d1b)), closes [#727](https://github.com/gotgenes/pi-packages/issues/727)
24
+
25
+ ## [25.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.0.0...pi-permission-system-v25.1.0) (2026-08-13)
26
+
27
+
28
+ ### Features
29
+
30
+ * **pi-permission-system:** add a process-global serving-session registry ([2939982](https://github.com/gotgenes/pi-packages/commit/2939982f80669a65df356f17e8110174ff96e68a)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
31
+ * **pi-permission-system:** announce the session serving forwarded requests ([74c0259](https://github.com/gotgenes/pi-packages/commit/74c0259141e616b1cb7760427e507fac518ba2e4)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
32
+ * **pi-permission-system:** make the forwarding timeout configurable ([fc6190d](https://github.com/gotgenes/pi-packages/commit/fc6190de23eb6347a135bf85e45f3060ac9b6868)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
33
+
34
+
35
+ ### Bug Fixes
36
+
37
+ * **pi-permission-system:** carry the denial reason into the unavailable block message ([1a0b289](https://github.com/gotgenes/pi-packages/commit/1a0b2894854576d2a171e98a10939c2c4c56794b)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
38
+ * **pi-permission-system:** fail fast when no session serves a forwarded request ([5241df0](https://github.com/gotgenes/pi-packages/commit/5241df0d6986fa66e1f21ccab6de956ac9d630ce)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
39
+ * **pi-permission-system:** report forwarding failures as unavailable, not user-denied ([6c37d27](https://github.com/gotgenes/pi-packages/commit/6c37d2778c0be51423b9b80569205ce68a0ac977)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
40
+
41
+
42
+ ### Documentation
43
+
44
+ * **pi-permission-system:** document forwarding liveness and the timeout field ([e493991](https://github.com/gotgenes/pi-packages/commit/e493991d67eb4e305a8a50346b1affed39ecbd96)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
45
+
8
46
  ## [25.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v24.0.0...pi-permission-system-v25.0.0) (2026-08-11)
9
47
 
10
48
 
package/README.md CHANGED
@@ -116,6 +116,7 @@ The optional `shellTools` field records which non-`bash` tools carry shell seman
116
116
 
117
117
  The optional `authorizerChain` field names registered case-by-case decision links (e.g. a light model judge) to consult when a request lands on `ask`, ahead of the interactive prompt.
118
118
  A downstream extension registers a link via `getPermissionsService().registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on `external_directory`/`path` to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
119
+ A subagent's ask is reviewed by the chain of the session serving it, one hop up, rather than inside the subagent — see the same section.
119
120
  [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge) is a first-party reference implementation of such a link — a deny-first reviewer that auto-denies mistyped out-of-directory paths.
120
121
 
121
122
  For the full reference — all surfaces, runtime knobs, per-agent overrides, merge semantics, and common recipes — see [docs/configuration.md](docs/configuration.md).
@@ -6,6 +6,8 @@
6
6
  "yoloMode": false,
7
7
  "doublePressToConfirm": true,
8
8
 
9
+ "forwardingTimeoutMs": 600000,
10
+
9
11
  "toolInputPreviewMaxLength": 400,
10
12
  "toolTextSummaryMaxLength": 120,
11
13
 
@@ -36,7 +36,7 @@ See [migration/0644-project-trust-gating.md](migration/0644-project-trust-gating
36
36
  4. Project agent frontmatter
37
37
 
38
38
  The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
39
- Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`) use simple replacement.
39
+ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`) use simple replacement.
40
40
 
41
41
  **Invalid higher-precedence scope fails closed.**
42
42
  If a non-global scope (project config, global agent frontmatter, or project agent frontmatter) is present but fails to load or validate, it no longer contributes an empty scope that silently inherits the lower scope's rules.
@@ -57,6 +57,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
57
57
  "permissionReviewLog": true,
58
58
  "yoloMode": false,
59
59
  "doublePressToConfirm": true,
60
+ "forwardingTimeoutMs": 600000,
60
61
  "toolInputPreviewMaxLength": 400,
61
62
  "toolTextSummaryMaxLength": 120,
62
63
  "piInfrastructureReadPaths": [],
@@ -98,16 +99,17 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
98
99
 
99
100
  ## Runtime Knobs
100
101
 
101
- | Key | Default | Description |
102
- | --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
103
- | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
104
- | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings verbatim — see [Log file sensitivity](#log-file-sensitivity) |
105
- | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
106
- | `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
107
- | `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation. |
108
- | `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
109
- | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
110
- | `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
102
+ | Key | Default | Description |
103
+ | --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104
+ | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
105
+ | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings verbatim — see [Log file sensitivity](#log-file-sensitivity) |
106
+ | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
107
+ | `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
108
+ | `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose in-process parent is not draining its inbox gives up in ~2 s regardless. |
109
+ | `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation. |
110
+ | `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
111
+ | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
112
+ | `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
111
113
 
112
114
  Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
113
115
  No debug output is printed to the terminal.
@@ -213,6 +215,18 @@ The excluded surface is the **gate** surface the rule fired on, not the tool nam
213
215
  This holds for an ask forwarded up from a subagent exactly as it does for a local one.
214
216
  See [migration/0635-forwarded-ask-delegation-envelope.md](migration/0635-forwarded-ask-delegation-envelope.md).
215
217
 
218
+ When a **subagent** raises the ask, the chain runs one hop up.
219
+ The subagent forwards the request to the session serving it, and that session resolves it against its own rules and then runs *its* chain over the same evidence — so your configured links do review a subagent's asks, in the session you are watching.
220
+ The subagent itself resolves no links (an extension cannot register one in a child session at all), and records `authorizer_chain_delegated` in the review log to say so.
221
+
222
+ Three review-log records make the chain observable, all keyed by the ask's `requestId`:
223
+
224
+ | Record | Meaning |
225
+ | ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
226
+ | `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
227
+ | `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
228
+ | `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal |
229
+
216
230
  Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService().registerAuthorizer(name, authorize)`; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
217
231
  Registration returns a disposer, and only one link may hold a given name.
218
232
  For a complete working example, see [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge): it registers a `model-judge` link on `permissions:ready` that reviews `external_directory` asks and auto-denies mistyped paths with a corrective reason.
@@ -32,6 +32,31 @@ This keeps `ask` policies usable even when the original permission check happens
32
32
 
33
33
  For in-process child sessions, detection and forwarding use the event-driven registration described above.
34
34
 
35
+ ### When nobody answers
36
+
37
+ A forwarded request is only useful if some session is draining the inbox it was written into.
38
+ The polling session publishes the session id it polls, and an in-process child checks that its target is published before committing to a long wait.
39
+
40
+ If the target is not draining its inbox, the child gives up after a two-second grace window rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
41
+
42
+ ```text
43
+ [pi-permission-system] Running bash command 'pwd' requires approval, but no
44
+ interactive UI is available. Reason: Session 'abc123' is not serving forwarded
45
+ permission requests.
46
+ ```
47
+
48
+ The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
49
+ A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
50
+
51
+ Every other way the forwarding path can give up — an unresolvable parent session, forwarding directories that cannot be created, a request that cannot be written, an unreadable response, and the timeout itself — is reported the same way: as approval being unavailable, with a reason naming the specific failure.
52
+ None of them is reported as a user denial, because no user was ever asked.
53
+
54
+ The two sides of the exchange are correlatable in the review log: the serving session writes `forwarded_permission.serving_started` with the id it polls, and the child writes `forwarded_permission.request_created` with the `targetSessionId` it forwarded to.
55
+ When a forwarded request goes unanswered, comparing those two entries distinguishes a parent that was not polling from one polling a different session.
56
+
57
+ This liveness signal is process-local, so it applies to in-process children only.
58
+ A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) cannot observe its parent's polling and still waits the full timeout.
59
+
35
60
  ---
36
61
 
37
62
  ## Coexistence with Other Subagent Extensions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "25.0.0",
3
+ "version": "25.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,6 +31,14 @@
31
31
  "default": true,
32
32
  "type": "boolean"
33
33
  },
34
+ "forwardingTimeoutMs": {
35
+ "description": "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
36
+ "markdownDescription": "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds.\n\nOmit to use the default (`600000`, ten minutes). A child whose in-process parent is not draining its inbox at all gives up in a couple of seconds regardless of this value, so lower it only to bound how long you are willing to leave an *unanswered* prompt pending.",
37
+ "default": 600000,
38
+ "type": "integer",
39
+ "minimum": 1,
40
+ "maximum": 9007199254740991
41
+ },
34
42
  "toolInputPreviewMaxLength": {
35
43
  "description": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts. Omit to use the default (200). Set to a large value to disable truncation.",
36
44
  "markdownDescription": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts.\n\nOmit to use the default (200). Set to a large value (e.g. `10000`) to effectively disable truncation and see the full input.",
@@ -26,11 +26,13 @@ import {
26
26
  type ForwardedPromptDisplay,
27
27
  type ForwardedSessionApproval,
28
28
  PERMISSION_FORWARDING_POLL_INTERVAL_MS,
29
- PERMISSION_FORWARDING_TIMEOUT_MS,
29
+ PERMISSION_FORWARDING_SERVING_GRACE_MS,
30
30
  type PermissionForwardingLocation,
31
- resolvePermissionForwardingTargetSessionId,
31
+ type PermissionForwardingTarget,
32
+ resolvePermissionForwardingTarget,
32
33
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
33
34
  } from "#src/authority/permission-forwarding";
35
+ import type { ServingLookup } from "#src/authority/serving-registry";
34
36
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
35
37
  import { buildUiPrompt } from "#src/permission-ui-prompt";
36
38
  import type { DebugReviewLogger } from "#src/session-logger";
@@ -85,9 +87,30 @@ export interface ParentAuthorizerDeps {
85
87
  forwardingDir: string;
86
88
  /** In-process subagent session registry for forwarding target resolution. */
87
89
  registry?: SubagentSessionRegistry;
90
+ /** Whether the resolved target is draining its inbox (in-process targets only). */
91
+ serving: ServingLookup;
92
+ /** How long to wait for the target's answer, read live so config edits apply. */
93
+ getTimeoutMs: () => number;
88
94
  logger: DebugReviewLogger;
89
95
  }
90
96
 
97
+ /**
98
+ * Deny because no authority ever ruled — the request was never delivered,
99
+ * never answered, or answered unreadably.
100
+ *
101
+ * `confirmationUnavailable` is what keeps this out of the "User denied …"
102
+ * message (#719): a user who was never asked denied nothing. `denialReason`
103
+ * names which path gave up, and the gate renders it to the model.
104
+ */
105
+ function abandon(denialReason: string): PermissionPromptDecision {
106
+ return {
107
+ approved: false,
108
+ state: "denied",
109
+ confirmationUnavailable: true,
110
+ denialReason,
111
+ };
112
+ }
113
+
91
114
  /**
92
115
  * Authorizer for a subagent session: escalate the ask up the tree to the
93
116
  * parent's authority.
@@ -103,6 +126,8 @@ export interface ParentAuthorizerDeps {
103
126
  export class ParentAuthorizer implements TerminalAuthorizer {
104
127
  private readonly forwardingDir: string;
105
128
  private readonly registry: SubagentSessionRegistry | undefined;
129
+ private readonly serving: ServingLookup;
130
+ private readonly getTimeoutMs: () => number;
106
131
  private readonly logger: DebugReviewLogger;
107
132
 
108
133
  constructor(
@@ -111,6 +136,8 @@ export class ParentAuthorizer implements TerminalAuthorizer {
111
136
  ) {
112
137
  this.forwardingDir = deps.forwardingDir;
113
138
  this.registry = deps.registry;
139
+ this.serving = deps.serving;
140
+ this.getTimeoutMs = deps.getTimeoutMs;
114
141
  this.logger = deps.logger;
115
142
  }
116
143
 
@@ -137,7 +164,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
137
164
  facts: ForwardedRequestFacts,
138
165
  ): Promise<PermissionPromptDecision> {
139
166
  const requesterSessionId = getSessionId(ctx);
140
- const targetSessionId = resolvePermissionForwardingTargetSessionId({
167
+ const target = resolvePermissionForwardingTarget({
141
168
  hasUI: ctx.hasUI,
142
169
  // Invariant: selectAuthorizer only selects ParentAuthorizer for a
143
170
  // no-UI subagent context, so this is always true — no detection dep
@@ -149,7 +176,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
149
176
  registry: this.registry,
150
177
  });
151
178
 
152
- if (!targetSessionId) {
179
+ if (!target) {
153
180
  logPermissionForwardingError(
154
181
  this.logger,
155
182
  `Permission forwarding target session could not be resolved. ` +
@@ -158,27 +185,31 @@ export class ParentAuthorizer implements TerminalAuthorizer {
158
185
  `ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
159
186
  `(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
160
187
  );
161
- return { approved: false, state: "denied" };
188
+ return abandon(
189
+ "Could not resolve a parent session to forward this permission request to",
190
+ );
162
191
  }
163
192
 
164
193
  const location = ensurePermissionForwardingLocation(
165
194
  this.logger,
166
195
  this.forwardingDir,
167
- targetSessionId,
196
+ target.sessionId,
168
197
  );
169
198
  if (!location) {
170
199
  logPermissionForwardingError(
171
200
  this.logger,
172
- `Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
201
+ `Permission forwarding is unavailable because session-scoped directories could not be prepared for '${target.sessionId}'`,
202
+ );
203
+ return abandon(
204
+ `Permission forwarding directories could not be prepared for session '${target.sessionId}'`,
173
205
  );
174
- return { approved: false, state: "denied" };
175
206
  }
176
207
 
177
208
  const request = this.buildForwardedRequest(
178
209
  ctx,
179
210
  facts,
180
211
  requesterSessionId,
181
- targetSessionId,
212
+ target.sessionId,
182
213
  );
183
214
  const requestPath = join(location.requestsDir, `${request.id}.json`);
184
215
  const responsePath = join(location.responsesDir, `${request.id}.json`);
@@ -187,7 +218,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
187
218
  requestId: request.id,
188
219
  requesterAgentName: request.requesterAgentName,
189
220
  requesterSessionId: request.requesterSessionId,
190
- targetSessionId,
221
+ targetSessionId: target.sessionId,
191
222
  requestPath,
192
223
  responsePath,
193
224
  });
@@ -200,7 +231,8 @@ export class ParentAuthorizer implements TerminalAuthorizer {
200
231
  `Failed to write forwarded permission request '${requestPath}'`,
201
232
  error,
202
233
  );
203
- return { approved: false, state: "denied" };
234
+ cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
235
+ return abandon("The forwarded permission request could not be written");
204
236
  }
205
237
 
206
238
  return this.pollForForwardedResponse(
@@ -208,6 +240,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
208
240
  request,
209
241
  requestPath,
210
242
  responsePath,
243
+ target,
211
244
  );
212
245
  }
213
246
 
@@ -262,9 +295,12 @@ export class ParentAuthorizer implements TerminalAuthorizer {
262
295
  request: ForwardedPermissionRequest,
263
296
  requestPath: string,
264
297
  responsePath: string,
298
+ target: PermissionForwardingTarget,
265
299
  ): Promise<PermissionPromptDecision> {
266
300
  const { id: requestId, requesterAgentName, targetSessionId } = request;
267
- const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
301
+ const timeoutMs = this.getTimeoutMs();
302
+ const deadline = Date.now() + timeoutMs;
303
+ let unservedSince: number | null = null;
268
304
 
269
305
  while (Date.now() < deadline) {
270
306
  if (existsSync(responsePath)) {
@@ -281,18 +317,28 @@ export class ParentAuthorizer implements TerminalAuthorizer {
281
317
  targetSessionId,
282
318
  responsePath,
283
319
  });
284
- safeDeleteFile(
285
- this.logger,
286
- responsePath,
287
- "forwarded permission response",
320
+ this.discardRequest(location, requestPath, responsePath);
321
+ return (
322
+ response ??
323
+ abandon("The parent session's permission response could not be read")
288
324
  );
289
- safeDeleteFile(
290
- this.logger,
291
- requestPath,
292
- "forwarded permission request",
325
+ }
326
+
327
+ unservedSince = this.checkServingLiveness(target, unservedSince);
328
+ if (
329
+ unservedSince !== null &&
330
+ Date.now() - unservedSince >= PERMISSION_FORWARDING_SERVING_GRACE_MS
331
+ ) {
332
+ this.logger.review("forwarded_permission.no_serving_session", {
333
+ requestId,
334
+ requesterSessionId: request.requesterSessionId,
335
+ targetSessionId,
336
+ servingSessionIds: this.serving.servingIds(),
337
+ });
338
+ this.discardRequest(location, requestPath);
339
+ return abandon(
340
+ `Session '${target.sessionId}' is not serving forwarded permission requests`,
293
341
  );
294
- cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
295
- return response ?? { approved: false, state: "denied" };
296
342
  }
297
343
 
298
344
  await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
@@ -308,8 +354,51 @@ export class ParentAuthorizer implements TerminalAuthorizer {
308
354
  targetSessionId,
309
355
  responsePath,
310
356
  });
357
+ this.discardRequest(location, requestPath);
358
+ return abandon(
359
+ `Session '${target.sessionId}' did not answer within ${timeoutMs / 1000}s`,
360
+ );
361
+ }
362
+
363
+ /**
364
+ * Track how long the target has looked unserved, or `null` while it looks fine.
365
+ *
366
+ * Only an in-process target (`source: "registry"`) can be judged: a target in
367
+ * another process shares no serving registry with this one, so its absence
368
+ * from the registry says nothing (#719, follow-up in #721).
369
+ */
370
+ private checkServingLiveness(
371
+ target: PermissionForwardingTarget,
372
+ unservedSince: number | null,
373
+ ): number | null {
374
+ if (target.source !== "registry") {
375
+ return null;
376
+ }
377
+ if (this.serving.isServing(target.sessionId)) {
378
+ return null;
379
+ }
380
+ return unservedSince ?? Date.now();
381
+ }
382
+
383
+ /**
384
+ * Drop this exchange's files and, if nothing else is pending, its directories.
385
+ *
386
+ * Deleting the request is what makes an abandonment final: a request left
387
+ * behind would be answered by the parent long after the child gave up.
388
+ */
389
+ private discardRequest(
390
+ location: PermissionForwardingLocation,
391
+ requestPath: string,
392
+ responsePath?: string,
393
+ ): void {
394
+ if (responsePath) {
395
+ safeDeleteFile(
396
+ this.logger,
397
+ responsePath,
398
+ "forwarded permission response",
399
+ );
400
+ }
311
401
  safeDeleteFile(this.logger, requestPath, "forwarded permission request");
312
402
  cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
313
- return { approved: false, state: "denied" };
314
403
  }
315
404
  }
@@ -4,8 +4,8 @@ import type { PermissionQuery } from "#src/service";
4
4
  import {
5
5
  type Authorizer,
6
6
  type AuthorizerSelectionDeps,
7
+ type SelectedAuthority,
7
8
  selectAuthorizer,
8
- type TerminalAuthorizer,
9
9
  } from "./authorizer";
10
10
  import { composeAuthorizerChain } from "./authorizer-chain";
11
11
  import type { AuthorizerLookup } from "./authorizer-registry";
@@ -54,7 +54,7 @@ export interface AskEscalator {
54
54
  export class AuthorizerSelection
55
55
  implements AskEscalator, AuthorizerSelectionLifecycle
56
56
  {
57
- private terminal: TerminalAuthorizer | null = null;
57
+ private authority: SelectedAuthority | null = null;
58
58
 
59
59
  constructor(
60
60
  private readonly deps: AuthorizerSelectionDeps & {
@@ -69,13 +69,43 @@ export class AuthorizerSelection
69
69
  ) {}
70
70
 
71
71
  /**
72
- * Select the terminal Authorizer for `ctx` and store it. The non-terminal
72
+ * Select the live authority for `ctx` and store it. The non-terminal
73
73
  * chain is composed per ask in {@link escalate}, not here: ADR 0007 §4 lets a
74
74
  * link register in a `permissions:ready` handler that may fire after
75
75
  * activation, so link resolution is deferred to the session's first ask.
76
76
  */
77
77
  activate(ctx: ExtensionContext): void {
78
- this.terminal = selectAuthorizer(ctx, this.deps);
78
+ this.authority = selectAuthorizer(ctx, this.deps);
79
+ }
80
+
81
+ /**
82
+ * The chain links for this ask.
83
+ *
84
+ * A node that adjudicates locally resolves its configured names; a relaying
85
+ * node resolves none. Its terminal hands the ask to a serving node, which
86
+ * resolves the request against its own recorded authority and escalates it
87
+ * through *its* chain over the same child-fixed facts (#635) — so running
88
+ * links here would adjudicate one ask twice, and a relaying node cannot host
89
+ * a link in the first place (#699). The delegation is recorded rather than
90
+ * reported as a fail-safe skip: an absent link is the design here, not the
91
+ * misconfiguration `authorizer_chain_unregistered_link` exists to surface.
92
+ */
93
+ private linksFor(
94
+ authority: SelectedAuthority,
95
+ requestId: string,
96
+ ): Authorizer[] {
97
+ const configured = this.deps.getAuthorizerChain();
98
+ if (configured.length === 0) {
99
+ return [];
100
+ }
101
+ if (!authority.adjudicatesLocally) {
102
+ this.deps.logger.review("authorizer_chain_delegated", {
103
+ requestId,
104
+ links: configured,
105
+ });
106
+ return [];
107
+ }
108
+ return this.resolveConfiguredLinks(configured, requestId);
79
109
  }
80
110
 
81
111
  /**
@@ -84,32 +114,52 @@ export class AuthorizerSelection
84
114
  * warning (invariant 2 — more prompting, never less); each resolved link is
85
115
  * wrapped in the bounded-delegation envelope so an `allow` on an excluded
86
116
  * surface cannot exceed the operator's policy.
117
+ *
118
+ * The resolved names are recorded against the ask before any link runs — a
119
+ * link that defers decides nothing and would otherwise leave no evidence it
120
+ * was consulted at all, which is what makes "the judge never ran" and "the
121
+ * judge ran and deferred" indistinguishable in the review log.
87
122
  */
88
- private resolveConfiguredLinks(): Authorizer[] {
123
+ private resolveConfiguredLinks(
124
+ configured: readonly string[],
125
+ requestId: string,
126
+ ): Authorizer[] {
89
127
  const links: Authorizer[] = [];
90
- for (const name of this.deps.getAuthorizerChain()) {
128
+ const resolved: string[] = [];
129
+ for (const name of configured) {
91
130
  const authorize = this.deps.authorizerRegistry.get(name);
92
131
  if (authorize === undefined) {
93
- this.deps.logger.review("authorizer_chain_unregistered_link", { name });
132
+ this.deps.logger.review("authorizer_chain_unregistered_link", {
133
+ requestId,
134
+ name,
135
+ });
94
136
  continue;
95
137
  }
138
+ resolved.push(name);
96
139
  links.push({ authorize: encloseInDelegationEnvelope(authorize) });
97
140
  }
141
+ if (resolved.length > 0) {
142
+ this.deps.logger.review("authorizer_chain_resolved", {
143
+ requestId,
144
+ links: resolved,
145
+ });
146
+ }
98
147
  return links;
99
148
  }
100
149
 
101
150
  /** Clear the stored selection. */
102
151
  deactivate(): void {
103
- this.terminal = null;
152
+ this.authority = null;
104
153
  }
105
154
 
106
155
  /**
107
156
  * Escalate an ask through the composed chain and return its decision.
108
157
  *
109
- * Resolves the configured links freshly (so a link registered any time before
158
+ * Resolves this ask's links freshly (so a link registered any time before
110
159
  * this first ask is honored) and composes them ahead of the selected
111
- * terminal. With zero links the composed value **is** the terminal instance,
112
- * so behavior is identical to a bare terminal escalation.
160
+ * terminal. With zero links no chain configured, or a relaying node that
161
+ * delegates adjudication to the serving node the composed value **is** the
162
+ * terminal instance, so behavior is identical to a bare terminal escalation.
113
163
  *
114
164
  * Rejects if no terminal has been selected — i.e. before the session was
115
165
  * activated. Implements {@link AskEscalator}.
@@ -117,14 +167,15 @@ export class AuthorizerSelection
117
167
  escalate(
118
168
  details: PromptPermissionDetails,
119
169
  ): Promise<PermissionPromptDecision> {
120
- if (this.terminal === null) {
170
+ const authority = this.authority;
171
+ if (authority === null) {
121
172
  return Promise.reject(
122
173
  new Error("escalate called before the session was activated"),
123
174
  );
124
175
  }
125
176
  const chain = composeAuthorizerChain(
126
- this.resolveConfiguredLinks(),
127
- this.terminal,
177
+ this.linksFor(authority, details.requestId),
178
+ authority.terminal,
128
179
  this.deps.getPermissionQuery(),
129
180
  this.deps.logger,
130
181
  );
@@ -4,6 +4,7 @@ import type {
4
4
  PromptPreferences,
5
5
  requestPermissionDecision,
6
6
  } from "#src/authority/permission-prompt-component";
7
+ import type { ServingLookup } from "#src/authority/serving-registry";
7
8
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
8
9
  import type { PermissionEventBus } from "#src/permission-events";
9
10
  import type { AuthorizerLog, PermissionQuery } from "#src/service";
@@ -58,6 +59,28 @@ export interface TerminalAuthorizer {
58
59
  ): Promise<PermissionPromptDecision>;
59
60
  }
60
61
 
62
+ /**
63
+ * The node's live-authority selection: who decides this node's asks, and
64
+ * whether this node adjudicates them with its own chain.
65
+ *
66
+ * The chain role is the selection's product, not a discriminator a consumer
67
+ * re-derives: `selectAuthorizer` tests `hasUI` before `isSubagent`, so a
68
+ * subagent that has its own UI decides locally, and re-deriving the role from
69
+ * `detection.isSubagent(ctx)` alone would get that case wrong.
70
+ */
71
+ export interface SelectedAuthority {
72
+ /** The terminal that decides this node's asks, or relays them upward. */
73
+ readonly terminal: TerminalAuthorizer;
74
+ /**
75
+ * False when the terminal relays the ask to a serving node
76
+ * (`ParentAuthorizer`): that node resolves the request against its own
77
+ * recorded authority and escalates it through *its* chain over the same
78
+ * child-fixed facts (#635), so resolving links here would adjudicate one ask
79
+ * twice.
80
+ */
81
+ readonly adjudicatesLocally: boolean;
82
+ }
83
+
61
84
  /** Construction inputs for {@link selectAuthorizer}. */
62
85
  export interface AuthorizerSelectionDeps {
63
86
  /** Single owner of subagent detection; the ParentAuthorizer-selection predicate. */
@@ -72,12 +95,17 @@ export interface AuthorizerSelectionDeps {
72
95
  forwardingDir: string;
73
96
  /** In-process subagent session registry for forwarding target resolution. */
74
97
  registry?: SubagentSessionRegistry;
98
+ /** Which sessions are draining a forwarded-permission inbox. */
99
+ servingRegistry: ServingLookup;
100
+ /** The forwarding timeout, read live so a config edit applies to the next ask. */
101
+ getForwardingTimeoutMs: () => number;
75
102
  logger: DebugReviewLogger;
76
103
  }
77
104
 
78
105
  /**
79
- * Select the `Authorizer` for the current context: the single owner of the
80
- * three-way `hasUI` / `isSubagent` / deny dispatch.
106
+ * Select the live authority for the current context: the single owner of the
107
+ * three-way `hasUI` / `isSubagent` / deny dispatch, and of the chain role that
108
+ * dispatch implies.
81
109
  *
82
110
  * Evaluated once per session activation (`AuthorizerSelection.activate`),
83
111
  * replacing the re-derivation of the same predicates across
@@ -86,22 +114,30 @@ export interface AuthorizerSelectionDeps {
86
114
  export function selectAuthorizer(
87
115
  ctx: ExtensionContext,
88
116
  deps: AuthorizerSelectionDeps,
89
- ): TerminalAuthorizer {
117
+ ): SelectedAuthority {
90
118
  if (ctx.hasUI) {
91
- return new LocalUserAuthorizer({
92
- ui: ctx.ui,
93
- mode: ctx.mode,
94
- events: deps.events,
95
- getPromptPreferences: deps.getPromptPreferences,
96
- requestPermissionDecision: deps.requestPermissionDecision,
97
- });
119
+ return {
120
+ terminal: new LocalUserAuthorizer({
121
+ ui: ctx.ui,
122
+ mode: ctx.mode,
123
+ events: deps.events,
124
+ getPromptPreferences: deps.getPromptPreferences,
125
+ requestPermissionDecision: deps.requestPermissionDecision,
126
+ }),
127
+ adjudicatesLocally: true,
128
+ };
98
129
  }
99
130
  if (deps.detection.isSubagent(ctx)) {
100
- return new ParentAuthorizer(ctx, {
101
- forwardingDir: deps.forwardingDir,
102
- registry: deps.registry,
103
- logger: deps.logger,
104
- });
131
+ return {
132
+ terminal: new ParentAuthorizer(ctx, {
133
+ forwardingDir: deps.forwardingDir,
134
+ registry: deps.registry,
135
+ serving: deps.servingRegistry,
136
+ getTimeoutMs: deps.getForwardingTimeoutMs,
137
+ logger: deps.logger,
138
+ }),
139
+ adjudicatesLocally: false,
140
+ };
105
141
  }
106
- return new DenyingAuthorizer();
142
+ return { terminal: new DenyingAuthorizer(), adjudicatesLocally: true };
107
143
  }
@@ -1,6 +1,9 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { DebugReviewLogger } from "#src/session-logger";
2
3
  import type { InboxProcessor } from "./forwarded-request-server";
4
+ import { getSessionId } from "./forwarder-context";
3
5
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
6
+ import type { ServingAnnouncer } from "./serving-registry";
4
7
  import type { SubagentDetector } from "./subagent-detection";
5
8
 
6
9
  /**
@@ -12,6 +15,17 @@ export interface ForwardingController {
12
15
  stop(): void;
13
16
  }
14
17
 
18
+ /** Constructor config for {@link ForwardingManager}. */
19
+ export interface ForwardingManagerDeps {
20
+ /** Single owner of subagent detection; gates whether this session may serve. */
21
+ detection: SubagentDetector;
22
+ /** Drains this session's forwarded-permission inbox on each tick. */
23
+ forwarder: InboxProcessor;
24
+ /** Publishes that this session is draining its inbox, for forwarding children. */
25
+ serving: ServingAnnouncer;
26
+ logger: DebugReviewLogger;
27
+ }
28
+
15
29
  /**
16
30
  * Encapsulates the forwarded-permission polling lifecycle.
17
31
  *
@@ -19,16 +33,20 @@ export interface ForwardingController {
19
33
  * lived as 3 mutable fields on `ExtensionRuntime`. Call `start(ctx)` on each
20
34
  * session event that may activate forwarding; call `stop()` on session
21
35
  * shutdown.
36
+ *
37
+ * While polling, it publishes the session id it polls to the `ServingAnnouncer`
38
+ * so a forwarding child can tell that someone is draining the inbox it wrote
39
+ * into — and the review log records that id, so a child forwarding to a
40
+ * *different* id is visible as a one-line diff against its
41
+ * `forwarded_permission.request_created` entry (#719).
22
42
  */
23
43
  export class ForwardingManager {
24
44
  private timer: NodeJS.Timeout | null = null;
25
45
  private context: ExtensionContext | null = null;
26
46
  private processing = false;
47
+ private servingSessionId: string | null = null;
27
48
 
28
- constructor(
29
- private readonly detection: SubagentDetector,
30
- private readonly forwarder: InboxProcessor,
31
- ) {}
49
+ constructor(private readonly deps: ForwardingManagerDeps) {}
32
50
 
33
51
  /**
34
52
  * Start polling if `ctx` has UI and is not a subagent execution context.
@@ -37,11 +55,12 @@ export class ForwardingManager {
37
55
  * Stops any existing poll when the context does not qualify for forwarding.
38
56
  */
39
57
  start(ctx: ExtensionContext): void {
40
- if (!ctx.hasUI || this.detection.isSubagent(ctx)) {
58
+ if (!ctx.hasUI || this.deps.detection.isSubagent(ctx)) {
41
59
  this.stop();
42
60
  return;
43
61
  }
44
62
  this.context = ctx;
63
+ this.announceServing(getSessionId(ctx));
45
64
  if (this.timer) {
46
65
  return;
47
66
  }
@@ -50,7 +69,7 @@ export class ForwardingManager {
50
69
  return;
51
70
  }
52
71
  this.processing = true;
53
- void this.forwarder.processInbox(this.context).finally(() => {
72
+ void this.deps.forwarder.processInbox(this.context).finally(() => {
54
73
  this.processing = false;
55
74
  });
56
75
  }, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
@@ -62,7 +81,42 @@ export class ForwardingManager {
62
81
  clearInterval(this.timer);
63
82
  this.timer = null;
64
83
  }
84
+ this.withdrawServing();
65
85
  this.context = null;
66
86
  this.processing = false;
67
87
  }
88
+
89
+ // ── Private methods ────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Publish `sessionId` as the served session, replacing any previous one.
93
+ *
94
+ * A no-op when the id is unchanged, since `start` runs on every
95
+ * `before_agent_start`, `input`, and `tool_call` — the announcement must not
96
+ * cost a log line per turn.
97
+ */
98
+ private announceServing(sessionId: string): void {
99
+ if (this.servingSessionId === sessionId) {
100
+ return;
101
+ }
102
+ this.withdrawServing();
103
+ this.servingSessionId = sessionId;
104
+ this.deps.serving.markServing(sessionId);
105
+ this.deps.logger.review("forwarded_permission.serving_started", {
106
+ sessionId,
107
+ });
108
+ }
109
+
110
+ /** Withdraw the published session, if any. */
111
+ private withdrawServing(): void {
112
+ const sessionId = this.servingSessionId;
113
+ if (sessionId === null) {
114
+ return;
115
+ }
116
+ this.servingSessionId = null;
117
+ this.deps.serving.clearServing(sessionId);
118
+ this.deps.logger.review("forwarded_permission.serving_stopped", {
119
+ sessionId,
120
+ });
121
+ }
68
122
  }
@@ -16,11 +16,14 @@ export type PermissionPromptDecision = {
16
16
  */
17
17
  autoApproved?: true;
18
18
  /**
19
- * True when no live authority was reachable and the DenyingAuthorizer denied
20
- * this ask (a no-UI, non-subagent session). Consumed by deriveResolution (the
21
- * decision-event resolution), the gate (block reason), and PermissionPrompter
22
- * (review-entry resolution) to emit "confirmation_unavailable" rather than a
23
- * plain user denial.
19
+ * True when no human ever ruled on this ask: either no live authority was
20
+ * reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the
21
+ * forwarding path gave up before reaching one (`ParentAuthorizer` target
22
+ * unresolvable, request undeliverable, target not serving, or no answer
23
+ * within the timeout). Consumed by deriveResolution (the decision-event
24
+ * resolution), the gate (block reason), and PermissionPrompter (review-entry
25
+ * resolution) to emit "confirmation_unavailable" rather than a plain user
26
+ * denial — a user who was never asked denied nothing (#719).
24
27
  */
25
28
  confirmationUnavailable?: true;
26
29
  };
@@ -5,6 +5,17 @@ import type { SubagentSessionRegistry } from "./subagent-registry";
5
5
 
6
6
  export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
7
7
  export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
8
+ /**
9
+ * How long an in-process forwarding target may go unserved before the child
10
+ * gives up on it — eight poll ticks.
11
+ *
12
+ * A window rather than a single check because `ForwardingManager` withdraws and
13
+ * re-announces across a session switch, and a request that arrives inside that
14
+ * gap is about to be picked up. Not configurable: the operator-facing knob is
15
+ * the overall timeout, and this only decides how fast a hopeless wait ends.
16
+ */
17
+ export const PERMISSION_FORWARDING_SERVING_GRACE_MS =
18
+ 8 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
8
19
  export const SUBAGENT_ENV_HINT_KEYS = [
9
20
  // pi-agent-router (original)
10
21
  "PI_IS_SUBAGENT",
@@ -204,7 +215,25 @@ export function createPermissionForwardingLocation(
204
215
  };
205
216
  }
206
217
 
207
- export function resolvePermissionForwardingTargetSessionId(options: {
218
+ /**
219
+ * How a forwarding target was resolved.
220
+ *
221
+ * `"registry"` is the load-bearing value: it means the requester is an
222
+ * **in-process** child of `sessionId`, so the two share a `globalThis` and the
223
+ * requester may consult the serving-session registry to decide whether anyone
224
+ * is draining its inbox. `"env"` means the target lives in another process,
225
+ * where that signal is unavailable; `"self"` is the UI host owning its own
226
+ * forwarding location.
227
+ */
228
+ export type PermissionForwardingTargetSource = "self" | "registry" | "env";
229
+
230
+ /** The resolved forwarding target together with how it was found. */
231
+ export interface PermissionForwardingTarget {
232
+ sessionId: string;
233
+ source: PermissionForwardingTargetSource;
234
+ }
235
+
236
+ export function resolvePermissionForwardingTarget(options: {
208
237
  hasUI: boolean;
209
238
  isSubagent: boolean;
210
239
  currentSessionId?: string | null;
@@ -213,9 +242,12 @@ export function resolvePermissionForwardingTargetSessionId(options: {
213
242
  sessionId?: string;
214
243
  /** In-process subagent session registry (checked before env vars). */
215
244
  registry?: SubagentSessionRegistry;
216
- }): string | null {
245
+ }): PermissionForwardingTarget | null {
217
246
  if (options.hasUI) {
218
- return normalizePermissionForwardingSessionId(options.currentSessionId);
247
+ const own = normalizePermissionForwardingSessionId(
248
+ options.currentSessionId,
249
+ );
250
+ return own === null ? null : { sessionId: own, source: "self" };
219
251
  }
220
252
 
221
253
  if (!options.isSubagent) {
@@ -228,14 +260,14 @@ export function resolvePermissionForwardingTargetSessionId(options: {
228
260
  const resolved = normalizePermissionForwardingSessionId(
229
261
  entry?.parentSessionId,
230
262
  );
231
- if (resolved) return resolved;
263
+ if (resolved) return { sessionId: resolved, source: "registry" };
232
264
  }
233
265
 
234
266
  // 2. Env vars — process-based subagent extensions.
235
267
  const env = options.env ?? process.env;
236
268
  for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
237
269
  const resolved = normalizePermissionForwardingSessionId(env[key]);
238
- if (resolved) return resolved;
270
+ if (resolved) return { sessionId: resolved, source: "env" };
239
271
  }
240
272
  return null;
241
273
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * serving-registry.ts — Which sessions are draining a forwarded-permission inbox.
3
+ *
4
+ * A session with a UI that is not itself a subagent polls its own
5
+ * `<forwardingDir>/sessions/<id>/requests/` directory (see `ForwardingManager`)
6
+ * and answers whatever a child forwards to it. Nothing else in the process can
7
+ * observe that, so a child whose parent is *not* polling has no way to tell
8
+ * "a human is being asked" from "nobody is home", and waits out the full
9
+ * forwarding timeout before denying (#719).
10
+ *
11
+ * This registry publishes that fact: the polling session marks itself while it
12
+ * polls, and a forwarding child checks whether its resolved target is marked.
13
+ *
14
+ * The single instance is stored on `globalThis` (via `Symbol.for()`) for the
15
+ * same reason `SubagentSessionRegistry` is: each session's `ResourceLoader`
16
+ * creates its own jiti instance and its own event bus, so the parent's
17
+ * permission-system instance and an in-process child's instance share no
18
+ * module state — only process globals. See `getServingSessionRegistry()`.
19
+ *
20
+ * The signal is meaningful only for an **in-process** child (one that resolved
21
+ * its target through `SubagentSessionRegistry`, i.e. a forwarding target with
22
+ * `source: "registry"`). A child in another process shares no `globalThis` with
23
+ * its parent and must not read anything into an absent mark.
24
+ */
25
+
26
+ /** Process-global key for the shared registry slot. Exported for test teardown. */
27
+ export const SERVING_SESSION_REGISTRY_KEY = Symbol.for(
28
+ "@gotgenes/pi-permission-system:serving-registry",
29
+ );
30
+
31
+ /**
32
+ * Announce-side seam: the polling session marks and clears itself.
33
+ *
34
+ * `ForwardingManager` depends on this rather than the concrete registry so it
35
+ * neither reads the store nor gains a query it has no business making (ISP).
36
+ */
37
+ export interface ServingAnnouncer {
38
+ markServing(sessionId: string): void;
39
+ clearServing(sessionId: string): void;
40
+ }
41
+
42
+ /**
43
+ * Query-side seam: a forwarding child asks whether its target is draining.
44
+ *
45
+ * `servingIds()` exists for the diagnostic review entry a child writes when it
46
+ * abandons an unserved request — the mismatch between the id it forwarded to
47
+ * and the ids actually being served is the whole diagnosis.
48
+ */
49
+ export interface ServingLookup {
50
+ isServing(sessionId: string): boolean;
51
+ servingIds(): readonly string[];
52
+ }
53
+
54
+ /**
55
+ * Registry of sessions currently draining a forwarded-permission inbox.
56
+ *
57
+ * A process-global singleton — obtain it via {@link getServingSessionRegistry},
58
+ * never `new` (see that accessor for why). Written exclusively by the owning
59
+ * session's `ForwardingManager`, keyed by that session's own id, so one
60
+ * session's shutdown cannot clear another's mark.
61
+ *
62
+ * A mark left behind by a session that died without `session_shutdown` makes a
63
+ * child wait out the full timeout instead of abandoning early — the same
64
+ * behavior as before this signal existed, which is the safe direction to fail.
65
+ */
66
+ export class ServingSessionRegistry implements ServingAnnouncer, ServingLookup {
67
+ private readonly serving = new Set<string>();
68
+
69
+ /** Record that `sessionId` is polling its inbox. Idempotent. */
70
+ markServing(sessionId: string): void {
71
+ this.serving.add(sessionId);
72
+ }
73
+
74
+ /** Record that `sessionId` has stopped polling. No-op if unmarked. */
75
+ clearServing(sessionId: string): void {
76
+ this.serving.delete(sessionId);
77
+ }
78
+
79
+ /** Return `true` when `sessionId` is currently polling its inbox. */
80
+ isServing(sessionId: string): boolean {
81
+ return this.serving.has(sessionId);
82
+ }
83
+
84
+ /** Every currently-serving session id, for diagnostics. */
85
+ servingIds(): readonly string[] {
86
+ return [...this.serving];
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Return the process-global ServingSessionRegistry, creating it on first call.
92
+ *
93
+ * Intentionally has no teardown hook: a child's `session_shutdown` must not be
94
+ * able to wipe the parent's mark. Entries are added and removed exclusively by
95
+ * the owning session's `ForwardingManager`.
96
+ */
97
+ export function getServingSessionRegistry(): ServingSessionRegistry {
98
+ const store = globalThis as Record<symbol, unknown>;
99
+ const existing = store[SERVING_SESSION_REGISTRY_KEY] as
100
+ | ServingSessionRegistry
101
+ | undefined;
102
+ if (existing) {
103
+ return existing;
104
+ }
105
+ const registry = new ServingSessionRegistry();
106
+ store[SERVING_SESSION_REGISTRY_KEY] = registry;
107
+ return registry;
108
+ }
@@ -221,6 +221,7 @@ export function mergeUnifiedConfigs(
221
221
 
222
222
  // Number scalars: override replaces base when defined
223
223
  for (const key of [
224
+ "forwardingTimeoutMs",
224
225
  "toolInputPreviewMaxLength",
225
226
  "toolTextSummaryMaxLength",
226
227
  ] as const) {
@@ -187,6 +187,13 @@ export const unifiedConfigSchema = z
187
187
  "Require a confirming second press of a decision hotkey (`y`/`s`/`n`/`r`) in the inline permission dialog before it commits — the first press arms the action and shows a `Press y again to approve.` hint.\n\nApplies to interactive **TUI** sessions only; the non-TUI (RPC/frontend) prompt keeps its single-select flow. Set to `false` to commit decisions on the first hotkey press.",
188
188
  default: true,
189
189
  }),
190
+ forwardingTimeoutMs: z.number().int().min(1).optional().meta({
191
+ description:
192
+ "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
193
+ markdownDescription:
194
+ "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds.\n\nOmit to use the default (`600000`, ten minutes). A child whose in-process parent is not draining its inbox at all gives up in a couple of seconds regardless of this value, so lower it only to bound how long you are willing to leave an *unanswered* prompt pending.",
195
+ default: 600000,
196
+ }),
190
197
  toolInputPreviewMaxLength: z.number().int().min(1).optional().meta({
191
198
  description:
192
199
  "Maximum character length of the inline-JSON tool-input preview shown in permission prompts. Omit to use the default (200). Set to a large value to disable truncation.",
@@ -76,9 +76,19 @@ export function formatDenyReason(ctx: DenialContext): string {
76
76
  return `${EXTENSION_TAG} ${buildDenyBody(ctx)}`;
77
77
  }
78
78
 
79
- /** Format the block reason when no interactive UI is available to prompt. */
80
- export function formatUnavailableReason(ctx: DenialContext): string {
81
- return `${EXTENSION_TAG} ${buildUnavailableBody(ctx)}`;
79
+ /**
80
+ * Format the block reason when no live authority could answer the ask.
81
+ *
82
+ * `denialReason` is optional because the plain no-UI case has nothing to add;
83
+ * an authority that abandoned for a specific reason (a forwarding target that
84
+ * is not serving, a request that could not be written) supplies one, and the
85
+ * model sees it (#719).
86
+ */
87
+ export function formatUnavailableReason(
88
+ ctx: DenialContext,
89
+ denialReason?: string,
90
+ ): string {
91
+ return `${EXTENSION_TAG} ${buildUnavailableBody(ctx, denialReason)}`;
82
92
  }
83
93
 
84
94
  /** Format the block reason when the user denies at an interactive prompt. */
@@ -187,7 +197,14 @@ export function matchQualifier(
187
197
  return parts.length > 0 ? `(${parts.join(", ")})` : "";
188
198
  }
189
199
 
190
- function buildUnavailableBody(ctx: DenialContext): string {
200
+ function buildUnavailableBody(
201
+ ctx: DenialContext,
202
+ denialReason?: string,
203
+ ): string {
204
+ return `${buildUnavailableSentence(ctx)}${reasonSuffix(denialReason)}`;
205
+ }
206
+
207
+ function buildUnavailableSentence(ctx: DenialContext): string {
191
208
  switch (ctx.kind) {
192
209
  case "tool": {
193
210
  const { check } = ctx;
@@ -20,6 +20,8 @@ export interface PermissionSystemExtensionConfig {
20
20
  doublePressToConfirm: boolean;
21
21
  /** Additional directories to auto-allow for reads as Pi infrastructure. */
22
22
  piInfrastructureReadPaths?: string[];
23
+ /** How long a subagent waits for the parent's answer to a forwarded ask, in ms. Defaults to 600000. */
24
+ forwardingTimeoutMs?: number;
23
25
  /** Max length of the inline-JSON input preview shown in permission prompts. Defaults to 200. */
24
26
  toolInputPreviewMaxLength?: number;
25
27
  /** Max length of inline pattern/path summaries (grep/find/ls) in permission prompts. Defaults to 80. */
@@ -71,6 +73,9 @@ export function normalizePermissionSystemConfig(
71
73
  if (raw.piInfrastructureReadPaths !== undefined) {
72
74
  result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
73
75
  }
76
+ if (raw.forwardingTimeoutMs !== undefined) {
77
+ result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
78
+ }
74
79
  if (raw.toolInputPreviewMaxLength !== undefined) {
75
80
  result.toolInputPreviewMaxLength = raw.toolInputPreviewMaxLength;
76
81
  }
@@ -133,7 +133,11 @@ export class GateRunner {
133
133
  // Construct messages from the centralized formatter.
134
134
  const messages = {
135
135
  denyReason: formatDenyReason(descriptor.denialContext),
136
- unavailableReason: formatUnavailableReason(descriptor.denialContext),
136
+ unavailableReason: (decision: PermissionPromptDecision) =>
137
+ formatUnavailableReason(
138
+ descriptor.denialContext,
139
+ decision.denialReason,
140
+ ),
137
141
  userDeniedReason: (decision: PermissionPromptDecision) =>
138
142
  formatUserDeniedReason(descriptor.denialContext, decision.denialReason),
139
143
  };
package/src/index.ts CHANGED
@@ -9,8 +9,10 @@ import {
9
9
  type ServingPolicy,
10
10
  } from "./authority/forwarded-request-server";
11
11
  import { ForwardingManager } from "./authority/forwarding-manager";
12
+ import { PERMISSION_FORWARDING_TIMEOUT_MS } from "./authority/permission-forwarding";
12
13
  import { requestPermissionDecision } from "./authority/permission-prompt-component";
13
14
  import { PermissionPrompter } from "./authority/permission-prompter";
15
+ import { getServingSessionRegistry } from "./authority/serving-registry";
14
16
  import { SubagentDetection } from "./authority/subagent-detection";
15
17
  import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
16
18
  import { getSubagentSessionRegistry } from "./authority/subagent-registry";
@@ -55,6 +57,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
55
57
  const hostFlavor = pathFlavorForPlatform(process.platform);
56
58
  const sessionRules = new SessionRules();
57
59
  const subagentRegistry = getSubagentSessionRegistry();
60
+ // Process-global, like subagentRegistry: an in-process child reads it from a
61
+ // separate jiti instance to learn whether its parent is draining its inbox.
62
+ const servingRegistry = getServingSessionRegistry();
58
63
  // Single owner of subagent detection, shared across every consumer instead of
59
64
  // threading the (subagentSessionsDir, platform, registry) triple into each.
60
65
  const subagentDetection = new SubagentDetection({
@@ -111,6 +116,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
111
116
  requestPermissionDecision,
112
117
  forwardingDir: paths.forwardingDir,
113
118
  registry: subagentRegistry,
119
+ servingRegistry,
120
+ getForwardingTimeoutMs: () =>
121
+ configStore.current().forwardingTimeoutMs ??
122
+ PERMISSION_FORWARDING_TIMEOUT_MS,
114
123
  logger,
115
124
  prompter,
116
125
  // The published service is the narrow, session-scoped PermissionQuery a
@@ -160,7 +169,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
160
169
 
161
170
  session = new PermissionSession(
162
171
  paths,
163
- new ForwardingManager(subagentDetection, requestServer),
172
+ new ForwardingManager({
173
+ detection: subagentDetection,
174
+ forwarder: requestServer,
175
+ serving: servingRegistry,
176
+ logger,
177
+ }),
164
178
  permissionManager,
165
179
  sessionRules,
166
180
  configStore,
@@ -33,7 +33,7 @@ export interface PermissionGateParams {
33
33
  /** Message strings/factories for each outcome. */
34
34
  messages: {
35
35
  denyReason: string;
36
- unavailableReason: string;
36
+ unavailableReason: (decision: PermissionPromptDecision) => string;
37
37
  userDeniedReason: (decision: PermissionPromptDecision) => string;
38
38
  };
39
39
  }
@@ -65,7 +65,7 @@ export async function applyPermissionGate(
65
65
  return {
66
66
  action: "block",
67
67
  reason: decision.confirmationUnavailable
68
- ? messages.unavailableReason
68
+ ? messages.unavailableReason(decision)
69
69
  : messages.userDeniedReason(decision),
70
70
  };
71
71
  }