@gotgenes/pi-permission-system 24.0.0 → 25.1.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,48 @@ 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.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.0.0...pi-permission-system-v25.1.0) (2026-08-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * **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)
14
+ * **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)
15
+ * **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)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **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)
21
+ * **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)
22
+ * **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)
23
+
24
+
25
+ ### Documentation
26
+
27
+ * **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)
28
+
29
+ ## [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)
30
+
31
+
32
+ ### ⚠ BREAKING CHANGES
33
+
34
+ * **pi-permission-system:** a bash command referencing `$HOME` or `${HOME}` now reaches the `external_directory` gate whether or not the target exists, so a policy with `external_directory: {"*": "ask"}` prompts for commands that previously ran silently. Allow the directory explicitly to restore the old behavior — e.g. `"external_directory": {"~/.cargo/registry/*": "allow"}`. The token shown in prompts, review-log entries, and derived session-approval patterns is now the expanded path rather than the `$HOME/...` spelling; this makes the prompt agree with the approval pattern, which was already derived from the expanded form.
35
+
36
+ ### Features
37
+
38
+ * **pi-permission-system:** expand ${HOME} alongside $HOME in path patterns ([3ebbd41](https://github.com/gotgenes/pi-packages/commit/3ebbd418e5b85a6dfbc1865c69f62c1f9a3cb144)), closes [#694](https://github.com/gotgenes/pi-packages/issues/694)
39
+
40
+
41
+ ### Bug Fixes
42
+
43
+ * **pi-permission-system:** resolve $HOME and $PWD expansions in bash path tokens ([8cbc7ee](https://github.com/gotgenes/pi-packages/commit/8cbc7ee011a3e06dcf047bfe8a467db3aa487721)), closes [#694](https://github.com/gotgenes/pi-packages/issues/694)
44
+
45
+
46
+ ### Documentation
47
+
48
+ * **pi-permission-system:** record resolved shell expansions in ADR 0009 and user docs ([caaf5ec](https://github.com/gotgenes/pi-packages/commit/caaf5ec02ef2f7df1e2a922dba0a9afdc121b191)), closes [#694](https://github.com/gotgenes/pi-packages/issues/694)
49
+
8
50
  ## [24.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.3...pi-permission-system-v24.0.0) (2026-07-26)
9
51
 
10
52
 
@@ -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` 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.
@@ -136,7 +138,7 @@ Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt
136
138
  ### `piInfrastructureReadPaths` patterns
137
139
 
138
140
  Each entry is either a plain directory prefix or a wildcard pattern.
139
- Plain entries match any path that starts with the given directory (after `~`/`$HOME` expansion).
141
+ Plain entries match any path that starts with the given directory (after `~`/`$HOME`/`${HOME}` expansion).
140
142
  Wildcard entries use `*` (any characters, including `/`) and `?` (exactly one character).
141
143
  `*` and `**` are equivalent — both cross directory boundaries.
142
144
 
@@ -587,9 +589,22 @@ The pattern is stored and displayed as written (`~/.cargo/registry/*`) in logs a
587
589
  For caches you only ever **read**, `piInfrastructureReadPaths` is a lighter alternative — it auto-allows read-only tools (`read`, `find`, `grep`, `ls`) and bypasses the gate entirely, but it does not cover `write`/`edit` or bash.
588
590
  Use `external_directory` when the allowance must apply to every tool.
589
591
 
590
- Bash commands are also covered: the extension extracts path-like tokens from the command string and applies the same gate when any resolve outside `ctx.cwd`.
591
- Quoted strings are stripped first to reduce false positives.
592
- This is a best-effort heuristic — variable expansion and escaped quotes are not parsed, and relative paths inside subshells are not yet resolved against a per-subshell working directory. (The separate `bash` command-pattern surface does evaluate commands nested inside substitutions and subshells; see that section.) OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
592
+ Bash commands are also covered: the extension parses the command and applies the same gate to every token that resolves outside `ctx.cwd`.
593
+ Quoting is understood, so `ls "$HOME/x"` and `ls $HOME/x` are treated alike.
594
+
595
+ What the bash projection resolves:
596
+
597
+ - Absolute, home-relative (`~/`), parent-traversal (`../`), and separator-bearing tokens, plus redirect targets (`> out.txt`) and values embedded in long options (`--file=/tmp/patterns`).
598
+ - The plain shell variables `$HOME` / `${HOME}` and `$PWD` / `${PWD}`, so `$HOME/x` is gated exactly as `~/x` and the literal absolute spelling, whether or not the target exists.
599
+ - Relative tokens, against the working directory produced by folding literal current-shell `cd` commands.
600
+ - A bare token (`cat id_rsa`) when it names an existing filesystem entry.
601
+
602
+ What it deliberately does not resolve: any other variable (`$CONFIG_DIR`), a command substitution (`$(cmd)`), an expansion carrying an operator (`${HOME:-/tmp}`), and a variable reached through an assignment (`CURRENT="$HOME"; ls "$CURRENT"`).
603
+ A non-literal `cd` (`cd "$DIR"`) makes the working directory unknown, after which relative tokens are kept literal rather than resolved against a guess.
604
+ Commands whose payload is opaque (`bash -c`, `eval`, `sudo`, `xargs`) are floored to `ask` instead of projected.
605
+ The governing record is [ADR 0009](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0009-bash-path-projection-completeness-contract.md), which states what the projection guarantees and which gaps are accepted residuals rather than bugs.
606
+
607
+ (The separate `bash` command-pattern surface does evaluate commands nested inside substitutions and subshells; see that section.) OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
593
608
 
594
609
  #### Symlinked paths
595
610
 
@@ -644,8 +659,9 @@ To allow-list such a path, write the rule using the path as typed — for exampl
644
659
 
645
660
  ### Home Directory Expansion in Patterns
646
661
 
647
- Pattern keys in any permission surface can start with `~/` or `$HOME/` (or be exactly `~` / `$HOME`).
662
+ Pattern keys in any permission surface can start with `~/`, `$HOME/`, or `${HOME}/` (or be exactly `~`, `$HOME`, or `${HOME}`).
648
663
  They are expanded to the OS home directory at match time, so configs are portable across machines and users.
664
+ A prefix is recognized only when it stands alone or precedes a separator, so a longer name (`~username`, `$HOMEDIR`) and a braced expansion carrying an operator (`${HOME:-/tmp}`) are left alone.
649
665
 
650
666
  ```jsonc
651
667
  {
@@ -661,7 +677,7 @@ They are expanded to the OS home directory at match time, so configs are portabl
661
677
  The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs.
662
678
 
663
679
  Path **values** supplied by tool calls and bash commands are expanded the same way.
664
- This means `~/...`, `$HOME/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule.
680
+ This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, `${HOME}/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule.
665
681
 
666
682
  ---
667
683
 
@@ -20,7 +20,7 @@ The following concepts are shared between OpenCode and this extension:
20
20
  | Last-match-wins | When multiple patterns match, the last one in config order wins |
21
21
  | `*` wildcard | Matches zero or more of any character (including path separators) |
22
22
  | `?` wildcard | Matches exactly one character |
23
- | Home directory expansion | `~/` and `$HOME/` expand to the OS home directory in patterns |
23
+ | Home directory expansion | `~/`, `$HOME/`, and `${HOME}/` expand to the OS home directory in patterns |
24
24
  | `external_directory` surface | Gates access to paths outside the working directory |
25
25
  | `bash` surface | Command patterns matched against shell commands |
26
26
  | `skill` surface | Skill name patterns matched against skill invocations |
@@ -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": "24.0.0",
3
+ "version": "25.1.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.",
@@ -1,4 +1,5 @@
1
1
  import type { TSNode } from "#src/access-intent/bash/parser";
2
+ import { resolvePlainVariableExpansion } from "#src/access-intent/bash/shell-variable-expansion";
2
3
 
3
4
  /**
4
5
  * Node types whose subtrees must never be descended into for
@@ -29,6 +30,8 @@ export const ARG_NODE_TYPES = new Set([
29
30
  * - `raw_string` → strip surrounding single quotes
30
31
  * - `string` → strip surrounding double quotes, concatenate children text
31
32
  * - `concatenation` → concatenate resolved children
33
+ * - expansions → the resolved value of a plain `$HOME`/`$PWD` reference,
34
+ * else `.text` (see `shell-variable-expansion.ts`)
32
35
  * - other → `.text` as fallback
33
36
  */
34
37
  export function resolveNodeText(node: TSNode): string {
@@ -57,9 +60,10 @@ export function resolveNodeText(node: TSNode): string {
57
60
  return result;
58
61
  }
59
62
  case "string_content":
63
+ return node.text;
60
64
  case "simple_expansion":
61
65
  case "expansion":
62
- return node.text;
66
+ return resolvePlainVariableExpansion(node) ?? node.text;
63
67
  case "concatenation": {
64
68
  let result = "";
65
69
  for (let i = 0; i < node.childCount; i++) {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Resolution of the shell variable references the bash path projection can
3
+ * settle statically.
4
+ *
5
+ * Runs at token collection, upstream of classification: by the time a token
6
+ * reaches `classifyTokenAsPathCandidate` it already carries the expanded path,
7
+ * so `$HOME/x` is accepted by the ordinary absolute-shape branch and needs no
8
+ * per-variable knowledge in the classifiers (#694). Keeping the vocabulary here
9
+ * — rather than teaching each classifier a `$HOME` prefix — is what stops the
10
+ * two from drifting apart, which is the defect this module closes.
11
+ *
12
+ * The resolvable set is deliberately tiny and closed. `HOME` is the spelling
13
+ * `expandHomePath` already resolves for config patterns and path literals, so
14
+ * resolving it here removes an inconsistency rather than widening the
15
+ * determinism boundary; `PWD` reads no environment at all. Every other name
16
+ * keeps its literal text, so ADR 0003's exclusion of ambient host state stands.
17
+ * See `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
18
+ */
19
+ import { homedir } from "node:os";
20
+
21
+ import type { TSNode } from "#src/access-intent/bash/parser";
22
+
23
+ /**
24
+ * The value of a plain `$NAME` / `${NAME}` reference, or `null` when the node
25
+ * is not a plain reference or names a variable outside the resolvable set.
26
+ *
27
+ * Plainness is decided structurally, not by matching the node's text: a plain
28
+ * reference carries exactly one `variable_name` child and nothing else but
29
+ * delimiters. An operator form (`${HOME:-/tmp}`, `${#HOME}`, `${HOME%/*}`)
30
+ * carries additional children and is therefore rejected without this module
31
+ * needing to enumerate bash's expansion operators.
32
+ */
33
+ export function resolvePlainVariableExpansion(node: TSNode): string | null {
34
+ const name = plainVariableName(node);
35
+ return name === null ? null : (RESOLVABLE_VARIABLES.get(name)?.() ?? null);
36
+ }
37
+
38
+ /**
39
+ * How each resolvable variable is spelled as a path.
40
+ *
41
+ * `PWD` resolves to the base-relative marker rather than a directory: the
42
+ * shell's working directory at a given point *is* the projection's effective
43
+ * base, which the resolver already applies via `resolveBase`. Handing back `.`
44
+ * therefore lands `$PWD/x` on the same footing as `./x` — correct after any
45
+ * `cd` folding, conservative under an unknown base (#393), and free of both a
46
+ * threaded base parameter and a platform branch.
47
+ */
48
+ const RESOLVABLE_VARIABLES: ReadonlyMap<string, () => string> = new Map([
49
+ ["HOME", homedir],
50
+ ["PWD", () => "."],
51
+ ]);
52
+
53
+ /** Node types that delimit an expansion without altering what it evaluates to. */
54
+ const EXPANSION_DELIMITERS: ReadonlySet<string> = new Set(["$", "${", "}"]);
55
+
56
+ /**
57
+ * The variable a node plainly references, or `null` when it references none —
58
+ * because it has no `variable_name` child, has more than one, or carries a
59
+ * child that is neither the name nor a delimiter (an expansion operator and its
60
+ * operand, or an assignment's `=` and value).
61
+ */
62
+ function plainVariableName(node: TSNode): string | null {
63
+ let name: string | null = null;
64
+
65
+ for (let i = 0; i < node.childCount; i++) {
66
+ const child = node.child(i);
67
+ if (!child) continue;
68
+ if (child.type === "variable_name") {
69
+ if (name !== null) return null;
70
+ name = child.text;
71
+ continue;
72
+ }
73
+ if (!EXPANSION_DELIMITERS.has(child.type)) return null;
74
+ }
75
+
76
+ return name;
77
+ }
@@ -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,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";
@@ -72,6 +73,10 @@ export interface AuthorizerSelectionDeps {
72
73
  forwardingDir: string;
73
74
  /** In-process subagent session registry for forwarding target resolution. */
74
75
  registry?: SubagentSessionRegistry;
76
+ /** Which sessions are draining a forwarded-permission inbox. */
77
+ servingRegistry: ServingLookup;
78
+ /** The forwarding timeout, read live so a config edit applies to the next ask. */
79
+ getForwardingTimeoutMs: () => number;
75
80
  logger: DebugReviewLogger;
76
81
  }
77
82
 
@@ -100,6 +105,8 @@ export function selectAuthorizer(
100
105
  return new ParentAuthorizer(ctx, {
101
106
  forwardingDir: deps.forwardingDir,
102
107
  registry: deps.registry,
108
+ serving: deps.servingRegistry,
109
+ getTimeoutMs: deps.getForwardingTimeoutMs,
103
110
  logger: deps.logger,
104
111
  });
105
112
  }
@@ -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;
@@ -2,27 +2,41 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
 
4
4
  /**
5
- * Expand `~` and `$HOME` prefixes in a pattern to the OS home directory.
5
+ * The spellings of the home directory this package resolves, in every pattern
6
+ * and path literal.
6
7
  *
7
- * Supported forms:
8
- * - `~` → `homedir()`
9
- * - `~/path` → `homedir()/path`
10
- * - `~\path` → `homedir()\path` (Windows)
11
- * - `$HOME` → `homedir()`
12
- * - `$HOME/path` `homedir()/path`
13
- * - `$HOME\path` → `homedir()\path` (Windows)
8
+ * `$HOME` and `${HOME}` are the two spellings of the same shell variable and
9
+ * must stay interchangeable: a rule keyed on one form has to match a path
10
+ * written in the other, and the bash path projection classifies a token by the
11
+ * shape it has *after* this expansion (#694).
12
+ */
13
+ const HOME_PREFIXES = ["~", "$HOME", "${HOME}"] as const;
14
+
15
+ /**
16
+ * Expand a home-directory prefix in a pattern or path value to the OS home
17
+ * directory.
18
+ *
19
+ * A prefix is recognized only when it stands alone or is followed by a path
20
+ * separator, so a longer name (`~username`, `$HOMEDIR`, `${HOMEDIR}`) and a
21
+ * braced parameter expansion carrying an operator (`${HOME:-/tmp}`,
22
+ * `${HOME%/*}`) are both left untouched.
23
+ *
24
+ * Supported forms, for each prefix in {@link HOME_PREFIXES}:
25
+ * - `<prefix>` → `homedir()`
26
+ * - `<prefix>/path` → `homedir()/path`
27
+ * - `<prefix>\path` → `homedir()\path` (Windows)
14
28
  *
15
29
  * All other patterns are returned unchanged.
16
30
  */
17
31
  export function expandHomePath(pattern: string): string {
18
- if (pattern === "~" || pattern === "$HOME") {
19
- return homedir();
20
- }
21
- if (pattern.startsWith("~/") || pattern.startsWith("~\\")) {
22
- return join(homedir(), pattern.slice(2));
23
- }
24
- if (pattern.startsWith("$HOME/") || pattern.startsWith("$HOME\\")) {
25
- return join(homedir(), pattern.slice(6));
32
+ for (const prefix of HOME_PREFIXES) {
33
+ if (pattern === prefix) return homedir();
34
+ if (!pattern.startsWith(prefix)) continue;
35
+
36
+ const rest = pattern.slice(prefix.length);
37
+ if (rest.startsWith("/") || rest.startsWith("\\")) {
38
+ return join(homedir(), rest.slice(1));
39
+ }
26
40
  }
27
41
  return pattern;
28
42
  }
@@ -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
  }