@gotgenes/pi-permission-system 40.1.4 → 40.2.1

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,30 @@ 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
+ ## [40.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.2.0...pi-permission-system-v40.2.1) (2026-10-14)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** a computed word in a wrapper's options keeps the indirection floor ([754a5fa](https://github.com/gotgenes/pi-packages/commit/754a5fadcab0aeac765ecb8cf423b1fb55e8d4d5))
14
+
15
+ ### Documentation
16
+
17
+ * **pi-permission-system:** one literal-word rule for both wrapper floor exemptions ([334318a](https://github.com/gotgenes/pi-packages/commit/334318a0a918557e92741d442cba7728b66cc92e))
18
+
19
+ ## [40.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.4...pi-permission-system-v40.2.0) (2026-10-12)
20
+
21
+
22
+ ### Features
23
+
24
+ * **pi-permission-system:** deny a permission prompt nobody answers within promptTimeoutMs ([10812e8](https://github.com/gotgenes/pi-packages/commit/10812e83e8c766198d1b8a7eceafee7743bc6b23)), closes [#735](https://github.com/gotgenes/pi-packages/issues/735)
25
+ * **pi-permission-system:** keep a permission prompt open once you start answering it ([39f7e3f](https://github.com/gotgenes/pi-packages/commit/39f7e3fba80d7c7235dc20c574430fb967779f0b)), closes [#735](https://github.com/gotgenes/pi-packages/issues/735)
26
+
27
+ ### Documentation
28
+
29
+ * **pi-permission-system:** document promptTimeoutMs and the unanswered decider ([38eb4a6](https://github.com/gotgenes/pi-packages/commit/38eb4a6fe651ebc566b4d119a24a1d7645412dd7)), closes [#735](https://github.com/gotgenes/pi-packages/issues/735)
30
+ * **pi-permission-system:** fix stale releasedDecision reference and note the fallback window spans follow-up dialogs ([4cc6b10](https://github.com/gotgenes/pi-packages/commit/4cc6b10327a504823d921fa11bc1b6bfa5bbb82e)), closes [#735](https://github.com/gotgenes/pi-packages/issues/735)
31
+
8
32
  ## [40.1.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.3...pi-permission-system-v40.1.4) (2026-10-10)
9
33
 
10
34
 
package/README.md CHANGED
@@ -68,6 +68,7 @@ When the dialog prompts, you can approve once or approve a pattern for the rest
68
68
  In an interactive TUI session the prompt is an inline keybind dialog — `y` approve, `s` approve for this session, `n` deny, `r` deny with a reason — where each hotkey arms and a second press confirms (configurable via `doublePressToConfirm`).
69
69
  The hotkeys themselves are remappable through `permissionDialogKeys`, which matters if you type with an input method editor: composition mode swallows letter keys before they reach the terminal, and digits do not.
70
70
  Set `promptNotifications` (for example `["bell"]`) and the dialog also rings the terminal as it opens, so a session waiting on you in another tab or pane gets flagged.
71
+ For an unattended run, set `promptTimeoutMs` and a prompt nobody answers within that window is denied, with the agent told nobody answered; by default a prompt waits indefinitely, and pressing any key stops the clock ([details](docs/configuration.md#unanswered-prompts)).
71
72
  A file-access ask that proves a single direction offers `b` as well, granting the session both directions instead of only the one the gate proved.
72
73
  The prompt shows one fact per line — who is asking, the tool, the matched rule, the value being decided — within a row budget, so a large tool input cannot take over the transcript; `Ctrl+O` (`app.tools.expand`) expands it to the complete request.
73
74
  See [docs/configuration.md](docs/configuration.md#inline-permission-dialog-tui) for the hotkeys and [docs/session-approvals.md](docs/session-approvals.md) for session-scoped rules and pattern suggestions.
package/dist/public.d.ts CHANGED
@@ -313,6 +313,11 @@ interface PermissionUiPromptEvent {
313
313
  }
314
314
  /** How a permission decision was reached. */
315
315
  type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable"
316
+ /**
317
+ * A human was shown the prompt and nobody answered within the configured
318
+ * `promptTimeoutMs`, so the request was denied.
319
+ */
320
+ | "prompt_unanswered"
316
321
  /** A registered `authorizerChain` link granted the ask; no human was asked. */
317
322
  | "authorizer_allowed"
318
323
  /** A registered `authorizerChain` link refused the ask; no human was asked. */
@@ -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`, `forwardingTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement.
39
+ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `promptTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement.
40
40
  `permissionDialogKeys` replaces the whole map rather than merging entry by entry, so the map that was validated is the map that applies.
41
41
  `promptNotifications` likewise replaces the whole list, so a project `[]` turns off a global list.
42
42
 
@@ -60,6 +60,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
60
60
  "yoloMode": false,
61
61
  "doublePressToConfirm": true,
62
62
  "forwardingTimeoutMs": 600000,
63
+ // "promptTimeoutMs": 60000, // opt-in: deny a prompt nobody answers (unset = no timer)
63
64
  "piInfrastructureReadPaths": [],
64
65
 
65
66
  // Non-bash tools that carry shell semantics
@@ -108,6 +109,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
108
109
  | `permissionDialogKeys` | — | Remaps the inline TUI dialog's decision hotkeys (see below). One printable character per decision; omitted decisions keep `y` / `s` / `b` / `n` / `r`. |
109
110
  | `promptNotifications` | `[]` | Terminal notifications to emit when the inline TUI dialog opens: `"bell"`, `"osc9"`, `"osc777"` (see below). Empty for none. |
110
111
  | `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose parent is not draining its inbox gives up in ~2 s regardless, whether that parent runs in this process or its own. |
112
+ | `promptTimeoutMs` | — | How long a permission prompt, once shown, waits for an answer before it is dismissed and the request denied, in milliseconds. Unset (the default) means no timer. See [Unanswered prompts](#unanswered-prompts). |
111
113
  | `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request. |
112
114
  | `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
113
115
  | `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
@@ -229,6 +231,29 @@ Inside tmux, prefer `"bell"`: tmux passes a BEL through to its own bell handling
229
231
 
230
232
  To run a program instead (Herdr, `cmux notify`, a push to your phone), write a small extension on the `permissions:ui_prompt` and `permissions:decision` broadcasts; see the [recipe](cross-extension-api.md#recipe-bridging-a-prompt-to-an-external-notifier).
231
233
 
234
+ ### Unanswered prompts
235
+
236
+ By default a permission prompt waits until someone answers it.
237
+ For a run you leave unattended, set `promptTimeoutMs` so a prompt nobody answers is denied instead of holding the agent indefinitely:
238
+
239
+ ```jsonc
240
+ {
241
+ "promptTimeoutMs": 60000
242
+ }
243
+ ```
244
+
245
+ When the window expires, the prompt is dismissed and the request is **denied**; the timeout never approves anything.
246
+ The agent is told that nobody answered the approval prompt within the window, not that you refused it, so it can try another approach or carry on with other work.
247
+ The review log records the decision as `decidedBy: { "kind": "unanswered", "timeoutMs": 60000 }`, and the `permissions:decision` broadcast reports `prompt_unanswered`.
248
+
249
+ The clock starts when a prompt is shown, not when the request is raised.
250
+ A request waiting behind another open prompt gets its own full window once it is shown, so several unanswered requests take about one window each.
251
+ In the inline TUI dialog, the hint row names the window while it runs, and pressing any key stops the clock: once you start answering (moving the highlight, typing a denial reason), the dialog waits for you to finish.
252
+ The `select` fallback outside TUI mode has no keystroke to watch, so its window runs until you finish answering or it expires, and it spans the follow-up dialogs too: a window that expires while the grant-scope choice or the denial-reason input is open discards the half-made answer and denies the request as unanswered.
253
+
254
+ A request forwarded from a subagent is shown in the parent session, so the **parent's** `promptTimeoutMs` applies to it, and the subagent receives the same unanswered denial.
255
+ The subagent separately stops waiting after `forwardingTimeoutMs`; keep `promptTimeoutMs` below it, or the subagent can give up while the parent's prompt is still open.
256
+
232
257
  ### What a prompt shows
233
258
 
234
259
  The prompt renders one fact per line, with the requesting agent (and, for a forwarded subagent ask, its session), the tool, the gate surface, the matched rule, the decision-relevant value, and — for a wrapper such as `xargs` — the command that will actually run.
@@ -1069,15 +1094,18 @@ The floor guards unknowability of *scope*, and scope stays the path surfaces' jo
1069
1094
  Such a unit is therefore **not** floored.
1070
1095
  It resolves by the inner command's own `bash` rules instead, and the review log records `floorExemption: "core-reader"` beside the rule that decided, so an allow the floor would once have prompted for is auditable to the reason that let it through.
1071
1096
 
1072
- All four of these must hold, and each is a way the floor's reason could still apply:
1097
+ All five of these must hold, and each is a way the floor's reason could still apply:
1073
1098
 
1074
1099
  1. The unit is an indirection wrapper.
1075
1100
  An `sh -c`/`eval` payload is not one — see below.
1076
1101
  2. The command it runs can be established without passing through an inline shell.
1077
1102
  `watch` and `parallel` hand their whole command line to a shell, and `rush` and `rust-parallel` build theirs from a template, so a unit with any of the four as a layer never qualifies: `watch cat x \; rm y` runs `rm`.
1078
- 3. That command **proves** a read: a bare-basename core word with no option that withdraws the claim.
1103
+ 3. Every word the wrapper itself takes (an option, its value, an operand, a `NAME=value` word) is written literally.
1104
+ The shell splits or expands a computed word before the wrapper runs, so `sudo -u $U cat x` with `U="root rm y"` runs `rm y cat x`; quoting does not exempt it, since `"$@"` can vanish and a quoted variable can name an array.
1105
+ A computed word of the command it runs is fine: `sudo -u root cat $X` still qualifies.
1106
+ 4. That command **proves** a read: a bare-basename core word with no option that withdraws the claim.
1079
1107
  So `xargs sort -o /tmp/x` and `xargs find . -delete` stay floored, and so does `xargs ./grep foo`.
1080
- 4. The enclosing statement provably writes no file through a redirect.
1108
+ 5. The enclosing statement provably writes no file through a redirect.
1081
1109
  A destination the parse cannot resolve — `> $OUT`, `> $(mktemp)` — counts against the exemption rather than for it.
1082
1110
 
1083
1111
  So `xargs grep -l foo`, `xargs wc -l`, `xargs sed -n 1p`, `find . -name '*.ts' -exec cat {} +`, and the no-op probe `sudo -n true` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, and `find . -exec sh -c '…' \;` still prompt.
@@ -1090,7 +1118,7 @@ Three things this does **not** change:
1090
1118
  Under `bash: {"*": "allow", "grep *": "deny"}`, `xargs grep foo` is denied rather than merely prompted.
1091
1119
  - Path gating is untouched.
1092
1120
  The exemption decides the `bash` surface only, and every path token the command projects still goes through `path` and `external_directory` with the direction its command proved.
1093
- Clause 4 is what keeps that from being a weaker promise than it sounds: a redirect destination the parse cannot resolve (`> $OUT`, `> $(mktemp)`) is not projected onto those surfaces either, so the wrapper keeps its floor rather than relying on a gate that would not see the write.
1121
+ Clause 5 is what keeps that from being a weaker promise than it sounds: a redirect destination the parse cannot resolve (`> $OUT`, `> $(mktemp)`) is not projected onto those surfaces either, so the wrapper keeps its floor rather than relying on a gate that would not see the write.
1094
1122
 
1095
1123
  ##### A wrapper that only modifies execution
1096
1124
 
@@ -1102,7 +1130,7 @@ All four of these must hold, and each keeps the decision about the command that
1102
1130
 
1103
1131
  1. **Every** wrapper layer is one of the five.
1104
1132
  `time sudo rm -rf x` and `sudo time pnpm test` stay floored, as do `nohup` (it may write `nohup.out`) and `flock` (it creates its lock file).
1105
- 2. Every option on each layer is one that wrapper is known to take, spelled in full, and every option, value, and operand is written literally (an environment assignment, which the shell does not split, may be computed).
1133
+ 2. Every option on each layer is one that wrapper is known to take, spelled in full, and every option, value, operand, and `NAME=value` word is written literally.
1106
1134
  A word the shell rewrites (`timeout $D …`, `timeout {5,sudo} …`) may become several, one of them a wrapper, so it keeps the floor.
1107
1135
  So `timeout --sig KILL 5 …` (an abbreviation) and `nice -5 …` stay floored, and so do `time`'s file-writing `-o`, `--output`, and `-a`.
1108
1136
  The admitted options are `time -p`/`-l`/`-h`/`-f`; `timeout -s`/`-k`/`-f`/`-p`/`-v` and their long forms; `nice -n`/`--adjustment`; and `stdbuf -i`/`-o`/`-e` and their long forms.
@@ -502,20 +502,21 @@ pi.events.on("permissions:decision", (raw) => {
502
502
 
503
503
  ### Resolution Values
504
504
 
505
- | Value | Meaning |
506
- | ----------------------------- | -------------------------------------------------------------------- |
507
- | `policy_allow` | Config rule said allow — no prompt shown |
508
- | `policy_deny` | Config rule said deny — blocked immediately |
509
- | `session_approved` | Covered by a session-level approval from earlier in the same session |
510
- | `infrastructure_auto_allowed` | Read of a Pi infrastructure path — auto-allowed |
511
- | `user_approved` | User approved once via dialog |
512
- | `user_approved_for_session` | User approved for the rest of the session |
513
- | `user_denied` | User denied via dialog |
514
- | `authorizer_allowed` | A registered `authorizerChain` link granted the ask — no human asked |
515
- | `authorizer_denied` | A registered `authorizerChain` link refused the ask — no human asked |
516
- | `auto_approved` | Yolo mode — approved automatically without dialog |
517
- | `confirmation_unavailable` | State was `ask` but no UI was available — blocked |
518
- | `gate_error` | The gate threw, or an escalation failed — blocked, fail-closed |
505
+ | Value | Meaning |
506
+ | ----------------------------- | ------------------------------------------------------------------------ |
507
+ | `policy_allow` | Config rule said allow — no prompt shown |
508
+ | `policy_deny` | Config rule said deny — blocked immediately |
509
+ | `session_approved` | Covered by a session-level approval from earlier in the same session |
510
+ | `infrastructure_auto_allowed` | Read of a Pi infrastructure path — auto-allowed |
511
+ | `user_approved` | User approved once via dialog |
512
+ | `user_approved_for_session` | User approved for the rest of the session |
513
+ | `user_denied` | User denied via dialog |
514
+ | `authorizer_allowed` | A registered `authorizerChain` link granted the ask — no human asked |
515
+ | `authorizer_denied` | A registered `authorizerChain` link refused the ask — no human asked |
516
+ | `auto_approved` | Yolo mode — approved automatically without dialog |
517
+ | `confirmation_unavailable` | State was `ask` but no UI was available — blocked |
518
+ | `prompt_unanswered` | A prompt was shown and nobody answered within `promptTimeoutMs` — denied |
519
+ | `gate_error` | The gate threw, or an escalation failed — blocked, fail-closed |
519
520
 
520
521
  ### Recipe: bridging a prompt to an external notifier
521
522
 
@@ -186,6 +186,8 @@ permission requests.
186
186
  The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
187
187
  Absence of a record is deliberately **not** read as unknown: a cleanly exited parent leaves nothing behind, so treating absence as "maybe serving" would restore the full-timeout stall that the heartbeat exists to end.
188
188
  A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
189
+ The exception is the serving session's own `promptTimeoutMs`: when it is set and nobody answers the forwarded prompt within it, the serving session denies the request as unanswered and the child receives that decision, telling its agent that nobody answered rather than that the user refused.
190
+ Keep `promptTimeoutMs` below `forwardingTimeoutMs`, or the child can give up while the parent's prompt is still open.
189
191
  That includes a parent whose human is still deliberating at an earlier forwarded prompt: it keeps refreshing its heartbeat throughout, so a second child does not read it as gone.
190
192
 
191
193
  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.
@@ -11,6 +11,7 @@
11
11
  | External file path blocked | `external_directory` is `ask` without UI or `deny` | Allow/ask the permission or keep file tools inside the active working directory. |
12
12
  | Spurious external-path prompt for `cd <subdir> && grep … ../path` | Relative path was resolved against cwd instead of the `cd` target | Fixed in current version — paths after a leading `cd <subdir> &&` are resolved against the cd target, matching actual shell behavior. |
13
13
  | Permission prompt is too verbose | Generic extension tool input is large | Built-in file tools are summarized automatically; third-party tools are capped to a bounded one-line JSON preview. |
14
+ | Tool blocked with "Nobody answered the approval prompt … within Ns" | `promptTimeoutMs` is set and the prompt was left unanswered for that long | Answer prompts within the window, press any key in the inline dialog to stop its clock, or raise or unset `promptTimeoutMs` (see [Unanswered prompts](configuration.md#unanswered-prompts)). |
14
15
  | Windows: `permission_forwarding.error — EPERM … rename` in the logs, and a subagent's tool call refused | An antivirus scanner or the search indexer held a transient handle on a forwarding file | The write is retried automatically for a short window. If it still fails, enable `debugLog` and look for `permission_forwarding.fs_retried` entries — their `attempts` and `code` say whether retrying is helping — then exclude the forwarding directory from the scanner. |
15
16
 
16
17
  ## Diagnostic Logging
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "40.1.4",
3
+ "version": "40.2.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -86,6 +86,13 @@
86
86
  "minimum": 1,
87
87
  "maximum": 9007199254740991
88
88
  },
89
+ "promptTimeoutMs": {
90
+ "description": "How long a permission prompt, once shown, waits for an answer before it is dismissed and the request denied, in milliseconds. Omit for no timer (the default). Pressing any key in the inline dialog stops the timer.",
91
+ "markdownDescription": "How long a permission prompt, once shown, waits for an answer before it is dismissed and the request denied, in milliseconds.\n\nOmit for no timer (the default): a prompt waits until someone answers it. The clock starts when the prompt is shown, not when the request is raised, so a request queued behind another prompt gets its own full window. In the inline TUI dialog, pressing any key stops the timer, so a prompt you have started answering is never cut off.\n\nThe timeout only ever **denies**, and the agent is told nobody answered. It applies to prompts this session shows, including requests forwarded from its subagents; keep it below `forwardingTimeoutMs` so a forwarded request is answered before the subagent stops waiting.",
92
+ "type": "integer",
93
+ "minimum": 1,
94
+ "maximum": 9007199254740991
95
+ },
89
96
  "promptMaxRows": {
90
97
  "description": "Maximum rows a permission prompt renders before eliding its evidence. Omit to use the default (24).",
91
98
  "markdownDescription": "Maximum rows a permission prompt renders before eliding its evidence.\n\nOmit to use the default (24). The request's own facts — the requesting agent, the tool, the matched rule, the decision-relevant value — are never elided by this budget; what gives way is the supporting evidence, and `Ctrl+O` expands the prompt to the complete request.",
@@ -158,7 +158,7 @@ export function executedUnitOf(
158
158
  * alike. Argument-independence is the core's admission bar, so there are no
159
159
  * arguments that make `grep` write a file.
160
160
  *
161
- * Four things must hold for it, and each is a way the reason could still hold:
161
+ * Five things must hold for it, and each is a way the reason could still hold:
162
162
  *
163
163
  * 1. The unit is an indirection wrapper — an ordinary command has no floor.
164
164
  * 2. Unwrapping reached the inner command without passing through an opaque
@@ -168,16 +168,21 @@ export function executedUnitOf(
168
168
  * line to a shell or builds it from a template
169
169
  * ({@link OPAQUE_COMMAND_LINE_WRAPPERS}) is that payload in another shape:
170
170
  * `watch cat x \; rm y` runs `rm`, so no layer may be one.
171
- * 3. The inner command *proves* a read — bare-basename core word, retraction
171
+ * 3. Every word a peeled layer passes its wrapper is literal: each option,
172
+ * value, operand, and `NAME=value` word. The shell splits or expands a
173
+ * computed one before the wrapper runs, so one of the words it becomes may
174
+ * be the command that really runs — `sudo -u $U cat x` with
175
+ * `U="root rm y"` runs `rm y cat x` as root.
176
+ * 4. The inner command *proves* a read — bare-basename core word, retraction
172
177
  * guards applied, so `xargs sort -o /tmp/x` and `xargs find . -delete` are
173
178
  * not transparent.
174
- * 4. The unit writes no file through a redirect, which the caller reads off
179
+ * 5. The unit writes no file through a redirect, which the caller reads off
175
180
  * the parse tree and this module never sees.
176
181
  *
177
182
  * `"execution-modifier"`: every wrapper layer only changes *how* the visible
178
183
  * inner command runs, so the unit resolves by that command's own rule whatever
179
- * it does (see {@link onlyModifiesExecution}). The first two conditions above
180
- * hold for it too; the core-reader reason is preferred when both apply.
184
+ * it does (see {@link onlyModifiesExecution}). The first three conditions
185
+ * above hold for it too; the core-reader reason is preferred when both apply.
181
186
  */
182
187
  export function floorExemptionOf(
183
188
  words: readonly CommandWord[],
@@ -191,7 +196,8 @@ export function floorExemptionOf(
191
196
  if (
192
197
  unwrapped.kind === "opaque" ||
193
198
  unwrapped.peeled.length === 0 ||
194
- unwrapped.peeled.some(runsOpaqueCommandLine)
199
+ unwrapped.peeled.some(runsOpaqueCommandLine) ||
200
+ unwrapped.peeled.some(carriesComputedWord)
195
201
  ) {
196
202
  return undefined;
197
203
  }
@@ -211,6 +217,17 @@ function runsOpaqueCommandLine(layer: readonly CommandWord[]): boolean {
211
217
  return name !== undefined && OPAQUE_COMMAND_LINE_WRAPPERS.has(name);
212
218
  }
213
219
 
220
+ /**
221
+ * True when the shell may rewrite a word a peeled layer passes its wrapper.
222
+ *
223
+ * The wrapper name is skipped: it is matched by its text against a fixed
224
+ * vocabulary, so a computed spelling never classifies as a wrapper, and the
225
+ * word adapter reads a `command_name` node as computed whatever it spells.
226
+ */
227
+ function carriesComputedWord(layer: readonly CommandWord[]): boolean {
228
+ return layer.slice(1).some((word) => word.computed);
229
+ }
230
+
214
231
  /**
215
232
  * True when every peeled layer only changes *how* the inner command runs, and
216
233
  * that command is a literal name the gate can resolve by its own rule.
@@ -230,9 +247,9 @@ function runsOpaqueCommandLine(layer: readonly CommandWord[]): boolean {
230
247
  * 2. Every option in every layer is on that modifier's allowlist. The real
231
248
  * tools accept abbreviations (`timeout --sig KILL 5 rm`) the inner-command
232
249
  * search does not know, and one of them misplaces where the command starts.
233
- * Each option, value, and operand must also be literal: the shell splits
234
- * `timeout $D pnpm` or expands `timeout {5,sudo} rm` into extra words before
235
- * the modifier runs, and one of those may be a wrapper or the real command.
250
+ * That every word is also literal is {@link floorExemptionOf}'s condition,
251
+ * shared with the core-reader reason: the shell splits `timeout $D pnpm` or
252
+ * expands `timeout {5,sudo} rm` into extra words before the modifier runs.
236
253
  * 3. The peel ended at an ordinary command, not a wrapper it could not see
237
254
  * past.
238
255
  * 4. The inner head is a literal command name. The grammar has no `time`
@@ -267,6 +284,9 @@ function reachedLiteralCommand(inner: readonly CommandWord[]): boolean {
267
284
  *
268
285
  * Walks the words with the same value-taking table {@link innerCommandIndex}
269
286
  * skips by, so an admitted option's value can never be taken for the command.
287
+ * It reads option shapes only: each caller refuses a computed word before it
288
+ * gets here ({@link floorExemptionOf}'s literal-layer condition, and
289
+ * {@link sessionCommandIndex}'s refusal of any token with shell syntax).
270
290
  */
271
291
  function isAdmittedModifierLayer(layer: readonly CommandWord[]): boolean {
272
292
  const name = wrapperName(layer);
@@ -278,15 +298,11 @@ function isAdmittedModifierLayer(layer: readonly CommandWord[]): boolean {
278
298
  for (let index = 1; index < layer.length; index++) {
279
299
  const word = layer[index].text;
280
300
  if (isEnvironmentAssignment(word)) continue;
281
- // A word the shell rewrites (`$D`, `{5,sudo}`, `*`) may become several, one
282
- // of them a wrapper or the real command, so only a literal word is admitted.
283
- if (layer[index].computed) return false;
284
301
  if (word === "--") continue;
285
302
  if (!word.startsWith("-")) continue;
286
303
  if (flags.has(word)) continue;
287
304
  if (valueTaking.has(word)) {
288
305
  index++;
289
- if (layer[index]?.computed) return false;
290
306
  continue;
291
307
  }
292
308
  if (!hasAttachedValue(word, valueTaking)) return false;
@@ -0,0 +1,86 @@
1
+ import type { PermissionPromptDecision } from "./permission-dialog";
2
+
3
+ /**
4
+ * How long a shown permission prompt waits for a human before it denies.
5
+ *
6
+ * Opened when a prompt is presented — never when its ask is admitted, so an
7
+ * ask queued behind another dialog gets its own full window — and closed when
8
+ * a human answers. A human who starts answering engages it, which stops the
9
+ * clock without settling anything: the window bounds an unattended prompt and
10
+ * never cuts off someone mid-answer. If neither happens first, the window aborts its signal,
11
+ * which dismisses the prompt, and the window itself is what decided: it stamps
12
+ * the `unanswered` decider rather than leaving the caller to infer one.
13
+ *
14
+ * The timer is `unref`ed, so a window still open when the session ends never
15
+ * holds the process alive.
16
+ */
17
+ export class AnswerWindow {
18
+ private timer: NodeJS.Timeout | undefined;
19
+
20
+ private constructor(
21
+ private readonly timeoutMs: number | undefined,
22
+ private readonly controller: AbortController | undefined,
23
+ ) {
24
+ if (timeoutMs !== undefined && controller !== undefined) {
25
+ this.timer = setTimeout(() => {
26
+ this.timer = undefined;
27
+ controller.abort();
28
+ }, timeoutMs);
29
+ this.timer.unref();
30
+ }
31
+ }
32
+
33
+ /** Opens a window, or a null window that never expires when `timeoutMs` is undefined. */
34
+ static open(timeoutMs: number | undefined): AnswerWindow {
35
+ return timeoutMs === undefined
36
+ ? new AnswerWindow(undefined, undefined)
37
+ : new AnswerWindow(timeoutMs, new AbortController());
38
+ }
39
+
40
+ /** Aborts when the window expires; `undefined` when no window is configured. */
41
+ get signal(): AbortSignal | undefined {
42
+ return this.controller?.signal;
43
+ }
44
+
45
+ /** The window's length while its clock is running; `undefined` once stopped, expired, or never opened. */
46
+ get pendingTimeoutMs(): number | undefined {
47
+ return this.timer === undefined ? undefined : this.timeoutMs;
48
+ }
49
+
50
+ /** A human answered: stop the clock. Idempotent. */
51
+ close(): void {
52
+ this.stopClock();
53
+ }
54
+
55
+ /** A human started answering: stop the clock and let them finish. Idempotent. */
56
+ engage(): void {
57
+ this.stopClock();
58
+ }
59
+
60
+ /**
61
+ * The decision for a prompt this window dismissed.
62
+ *
63
+ * `confirmationUnavailable` holds because no human ruled. A null window
64
+ * cannot have dismissed anything, so asking it is a caller defect, and the
65
+ * throw fails the ask closed at the gate boundary rather than inventing a
66
+ * decider.
67
+ */
68
+ expiredDecision(): PermissionPromptDecision {
69
+ if (this.timeoutMs === undefined) {
70
+ throw new Error(
71
+ "no answer window was open, so no prompt can have expired",
72
+ );
73
+ }
74
+ return {
75
+ approved: false,
76
+ state: "denied",
77
+ confirmationUnavailable: true,
78
+ decidedBy: { kind: "unanswered", timeoutMs: this.timeoutMs },
79
+ };
80
+ }
81
+
82
+ private stopClock(): void {
83
+ clearTimeout(this.timer);
84
+ this.timer = undefined;
85
+ }
86
+ }
@@ -47,6 +47,8 @@ export function resolutionFor(
47
47
  return "confirmation_unavailable";
48
48
  case "gate_error":
49
49
  return "gate_error";
50
+ case "unanswered":
51
+ return "prompt_unanswered";
50
52
  case "user":
51
53
  // A `forwarded` frame reaches here only when the responder named no
52
54
  // decider — an older parent — so the hop is the only fact available and
@@ -51,6 +51,12 @@ export type DecisionSource =
51
51
  | { kind: "unavailable"; reason: string }
52
52
  /** A gate threw, and the boundary blocked rather than allowed. */
53
53
  | { kind: "gate_error"; reason: string }
54
+ /**
55
+ * A human was shown the prompt and nobody answered it within the configured
56
+ * `promptTimeoutMs` window, so the window denied. Distinct from
57
+ * `unavailable`, where no prompt could be shown at all.
58
+ */
59
+ | { kind: "unanswered"; timeoutMs: number }
54
60
  /**
55
61
  * Another session decided. Recursive by design: the requesting side records
56
62
  * both that the decider was elsewhere and what, within that session, decided
@@ -147,6 +153,8 @@ function narrowSource(
147
153
  return typeof candidate.reason === "string"
148
154
  ? { kind: "gate_error", reason: candidate.reason }
149
155
  : undefined;
156
+ case "unanswered":
157
+ return narrowUnanswered(candidate);
150
158
  case "forwarded":
151
159
  return narrowForwarded(candidate, depthBudget);
152
160
  default:
@@ -161,6 +169,17 @@ function narrowUser(
161
169
  return via ? { kind: "user", via } : undefined;
162
170
  }
163
171
 
172
+ function narrowUnanswered(
173
+ candidate: Record<string, unknown>,
174
+ ): DecisionSource | undefined {
175
+ const { timeoutMs } = candidate;
176
+ return typeof timeoutMs === "number" &&
177
+ Number.isFinite(timeoutMs) &&
178
+ timeoutMs > 0
179
+ ? { kind: "unanswered", timeoutMs }
180
+ : undefined;
181
+ }
182
+
164
183
  function narrowAuthorizer(
165
184
  candidate: Record<string, unknown>,
166
185
  ): DecisionSource | undefined {
@@ -66,10 +66,7 @@ export class LocalUserAuthorizer implements TerminalAuthorizer {
66
66
  authorize(
67
67
  details: PromptPermissionDetails,
68
68
  ): Promise<PermissionPromptDecision> {
69
- return this.deps.dialogs.run(
70
- () => this.present(details),
71
- unansweredDecision,
72
- );
69
+ return this.deps.dialogs.run(() => this.present(details), releasedDecision);
73
70
  }
74
71
 
75
72
  /**
@@ -114,7 +111,7 @@ export class LocalUserAuthorizer implements TerminalAuthorizer {
114
111
  * one string so what the model is told and what the log attributes cannot
115
112
  * drift (#726).
116
113
  */
117
- function unansweredDecision(reason: string): PermissionPromptDecision {
114
+ function releasedDecision(reason: string): PermissionPromptDecision {
118
115
  return {
119
116
  approved: false,
120
117
  state: "denied",
@@ -17,7 +17,8 @@ export type PermissionPromptDecision = {
17
17
  * reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the
18
18
  * forwarding path gave up before reaching one (`ParentAuthorizer` — target
19
19
  * unresolvable, request undeliverable, target not serving, or no answer
20
- * within the timeout). Consumed by the gate (block reason) and
20
+ * within the timeout), or a prompt was shown and nobody answered it within
21
+ * its `promptTimeoutMs` window (`AnswerWindow`). Consumed by the gate (block reason) and
21
22
  * `PermissionPrompter` (review-entry resolution) to report
22
23
  * "confirmation_unavailable" rather than a plain user denial — a user who
23
24
  * was never asked denied nothing (#719). The decision-event resolution
@@ -55,9 +56,22 @@ export type PermissionPromptDecision = {
55
56
  */
56
57
  export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
57
58
 
59
+ /** What the fallback hands each host dialog: the signal that may dismiss it. */
60
+ export interface PermissionDialogOptions {
61
+ signal?: AbortSignal;
62
+ }
63
+
58
64
  export interface PermissionDecisionUi {
59
- select(title: string, options: string[]): Promise<string | undefined>;
60
- input(title: string, placeholder?: string): Promise<string | undefined>;
65
+ select(
66
+ title: string,
67
+ options: string[],
68
+ opts?: PermissionDialogOptions,
69
+ ): Promise<string | undefined>;
70
+ input(
71
+ title: string,
72
+ placeholder?: string,
73
+ opts?: PermissionDialogOptions,
74
+ ): Promise<string | undefined>;
61
75
  }
62
76
 
63
77
  const APPROVE_OPTION = "Yes";
@@ -140,12 +154,25 @@ export interface RequestPermissionOptions {
140
154
  };
141
155
  }
142
156
 
157
+ /**
158
+ * Ask through the host's `select`/`input` dialogs.
159
+ *
160
+ * `signal` reaches every dialog, and the host resolves a dismissed one
161
+ * `undefined` — the same value a human cancel produces. The two are told apart
162
+ * by the signal: an `undefined` while it is aborted answers `undefined` (the
163
+ * prompt was dismissed, so no human ruled), and one while it is live keeps its
164
+ * meaning as the human's cancel.
165
+ */
143
166
  export async function requestPermissionDecisionFromUi(
144
167
  ui: PermissionDecisionUi,
145
168
  title: string,
146
169
  message: string,
147
170
  options?: RequestPermissionOptions,
148
- ): Promise<UnattributedDecision> {
171
+ signal?: AbortSignal,
172
+ ): Promise<UnattributedDecision | undefined> {
173
+ const dialogOptions: PermissionDialogOptions = { signal };
174
+ const dismissed = (answer: string | undefined): boolean =>
175
+ answer === undefined && signal?.aborted === true;
149
176
  const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
150
177
  const widthOption = options?.sessionWidth?.label;
151
178
  const decisionOptions = [
@@ -156,7 +183,14 @@ export async function requestPermissionDecisionFromUi(
156
183
  DENY_WITH_REASON_OPTION,
157
184
  ];
158
185
 
159
- const selected = await ui.select(`${title}\n${message}`, decisionOptions);
186
+ const selected = await ui.select(
187
+ `${title}\n${message}`,
188
+ decisionOptions,
189
+ dialogOptions,
190
+ );
191
+ if (dismissed(selected)) {
192
+ return undefined;
193
+ }
160
194
 
161
195
  if (selected === APPROVE_OPTION) {
162
196
  return {
@@ -171,10 +205,17 @@ export async function requestPermissionDecisionFromUi(
171
205
  const width: SessionGrantWidth =
172
206
  selected === widthOption ? "family" : "proven";
173
207
  if (options?.sessionScope) {
174
- const scope = await ui.select(`${title}\nApply this session grant to:`, [
175
- options.sessionScope.subagentLabel,
176
- options.sessionScope.servingSessionLabel,
177
- ]);
208
+ const scope = await ui.select(
209
+ `${title}\nApply this session grant to:`,
210
+ [
211
+ options.sessionScope.subagentLabel,
212
+ options.sessionScope.servingSessionLabel,
213
+ ],
214
+ dialogOptions,
215
+ );
216
+ if (dismissed(scope)) {
217
+ return undefined;
218
+ }
178
219
  return sessionApproval(
179
220
  // A cancelled scope select (undefined) falls back to the
180
221
  // least-privilege subagent scope.
@@ -188,12 +229,15 @@ export async function requestPermissionDecisionFromUi(
188
229
  }
189
230
 
190
231
  if (selected === DENY_WITH_REASON_OPTION) {
191
- const denialReason = normalizePermissionDenialReason(
192
- await ui.input(
193
- `${title}\nShare why this request was denied (optional).`,
194
- "Reason shown back to the agent",
195
- ),
232
+ const typed = await ui.input(
233
+ `${title}\nShare why this request was denied (optional).`,
234
+ "Reason shown back to the agent",
235
+ dialogOptions,
196
236
  );
237
+ if (dismissed(typed)) {
238
+ return undefined;
239
+ }
240
+ const denialReason = normalizePermissionDenialReason(typed);
197
241
 
198
242
  return createDeniedPermissionDecision(denialReason);
199
243
  }
@@ -23,6 +23,7 @@ import {
23
23
  renderPromptNotification,
24
24
  } from "#src/presentation/prompt-notification";
25
25
  import type { PromptPayload } from "#src/presentation/prompt-payload";
26
+ import { AnswerWindow } from "./answer-window";
26
27
  import { collapsePastedNewlines } from "./bracketed-paste";
27
28
  import type { DecisionSource, UserDecisionSurface } from "./decision-source";
28
29
  import {
@@ -76,6 +77,8 @@ export interface PromptPreferences {
76
77
  dialogKeys: DialogKeyBindings;
77
78
  /** Terminal notifications the inline dialog emits as it opens; empty for none. */
78
79
  promptNotifications: readonly PromptNotificationChannel[];
80
+ /** How long a shown prompt waits for an answer before it denies; absent for no timer. */
81
+ promptTimeoutMs?: number;
79
82
  }
80
83
 
81
84
  /**
@@ -89,6 +92,10 @@ export interface PromptPreferences {
89
92
  * answered on, so it is where the decision is attributed to that surface
90
93
  * (#726). Having the dialog model and the fallback each name themselves would
91
94
  * be two sites that must agree with this branch.
95
+ *
96
+ * It also opens the prompt's {@link AnswerWindow} as the prompt is shown. A
97
+ * surface the window dismissed answers `undefined`, and the window, which is
98
+ * what decided, stamps the `unanswered` decision itself.
92
99
  */
93
100
  export async function requestPermissionDecision(
94
101
  view: PermissionPromptView,
@@ -96,12 +103,65 @@ export async function requestPermissionDecision(
96
103
  payload: PromptPayload,
97
104
  options?: RequestPermissionOptions,
98
105
  ): Promise<PermissionPromptDecision> {
106
+ const window = AnswerWindow.open(view.promptTimeoutMs);
107
+ const surface = chooseDecisionSurface(view, title, payload, options);
108
+ try {
109
+ const answer = await surface.ask(window);
110
+ return answer === undefined
111
+ ? window.expiredDecision()
112
+ : attributeToHuman(answer, surface.via);
113
+ } finally {
114
+ window.close();
115
+ }
116
+ }
117
+
118
+ /**
119
+ * One human-facing surface: how to ask on it, and the name it answers under.
120
+ *
121
+ * `ask` answers `undefined` only when the window dismissed the prompt.
122
+ */
123
+ interface DecisionSurface {
124
+ via: UserDecisionSurface;
125
+ ask: (window: DialogClock) => Promise<UnattributedDecision | undefined>;
126
+ }
127
+
128
+ /**
129
+ * The answer window as a surface uses it: the signal that dismisses the
130
+ * prompt, and — for the inline dialog, which sees keystrokes — the
131
+ * running length to show and the means to stop it once the human engages.
132
+ */
133
+ export type DialogClock = Pick<
134
+ AnswerWindow,
135
+ "signal" | "pendingTimeoutMs" | "engage"
136
+ >;
137
+
138
+ function chooseDecisionSurface(
139
+ view: PermissionPromptView,
140
+ title: string,
141
+ payload: PromptPayload,
142
+ options?: RequestPermissionOptions,
143
+ ): DecisionSurface {
99
144
  if (view.mode === "tui") {
100
- return attributeToHuman(
101
- await presentInlinePermissionPrompt(view, title, payload, options),
102
- "dialog",
103
- );
145
+ return {
146
+ via: "dialog",
147
+ ask: (window) =>
148
+ presentInlinePermissionPrompt(view, title, payload, options, window),
149
+ };
104
150
  }
151
+ return {
152
+ via: "select",
153
+ ask: (window) =>
154
+ askThroughFallback(view, title, payload, options, window.signal),
155
+ };
156
+ }
157
+
158
+ function askThroughFallback(
159
+ view: PermissionPromptView,
160
+ title: string,
161
+ payload: PromptPayload,
162
+ options: RequestPermissionOptions | undefined,
163
+ signal: AbortSignal | undefined,
164
+ ): Promise<UnattributedDecision | undefined> {
105
165
  // The fallback renders once and cannot re-render, so it neither paints nor
106
166
  // offers an expansion; it substitutes a nominal width for the terminal size
107
167
  // it is never told, and the host's own select wraps from there.
@@ -109,14 +169,12 @@ export async function requestPermissionDecision(
109
169
  ...view.budget,
110
170
  width: FALLBACK_RENDER_WIDTH,
111
171
  });
112
- return attributeToHuman(
113
- await requestPermissionDecisionFromUi(
114
- view.ui,
115
- title,
116
- rendered.lines.join("\n"),
117
- options,
118
- ),
119
- "select",
172
+ return requestPermissionDecisionFromUi(
173
+ view.ui,
174
+ title,
175
+ rendered.lines.join("\n"),
176
+ options,
177
+ signal,
120
178
  );
121
179
  }
122
180
 
@@ -146,12 +204,18 @@ const OPTION_LABELS: Record<PromptAction, string> = {
146
204
  denyWithReason: "No, provide reason",
147
205
  };
148
206
 
207
+ /**
208
+ * Show the inline dialog. The clock's signal dismisses it: an abort closes the
209
+ * dialog and answers `undefined`, so a caller can tell a dismissal from a
210
+ * human answer. The first keystroke engages the clock, stopping it.
211
+ */
149
212
  export function presentInlinePermissionPrompt(
150
213
  view: PermissionPromptView,
151
214
  title: string,
152
215
  payload: PromptPayload,
153
216
  options?: RequestPermissionOptions,
154
- ): Promise<UnattributedDecision> {
217
+ clock?: DialogClock,
218
+ ): Promise<UnattributedDecision | undefined> {
155
219
  const config: PromptModelConfig = {
156
220
  doublePressToConfirm: view.doublePressToConfirm,
157
221
  sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
@@ -159,8 +223,17 @@ export function presentInlinePermissionPrompt(
159
223
  sessionScope: options?.sessionScope,
160
224
  keys: view.dialogKeys,
161
225
  };
162
- return view.ui.custom<UnattributedDecision>(
226
+ return view.ui.custom<UnattributedDecision | undefined>(
163
227
  (tui, theme, keybindings, done) => {
228
+ // The host's `done` is idempotent, so a human answer racing the abort
229
+ // settles whichever arrives first.
230
+ clock?.signal?.addEventListener(
231
+ "abort",
232
+ () => {
233
+ done(undefined);
234
+ },
235
+ { once: true },
236
+ );
164
237
  // The factory runs once, as the dialog mounts, which is the moment the
165
238
  // human is being asked; a re-render does not come back through here.
166
239
  const notification = renderPromptNotification(
@@ -181,6 +254,7 @@ export function presentInlinePermissionPrompt(
181
254
  tui.requestRender();
182
255
  },
183
256
  done,
257
+ clock,
184
258
  );
185
259
  },
186
260
  { overlay: false },
@@ -228,6 +302,7 @@ class PermissionPromptComponent implements Component {
228
302
  private readonly handleAppAction: (data: string) => boolean,
229
303
  private readonly requestRender: () => void,
230
304
  private readonly done: (decision: UnattributedDecision) => void,
305
+ private readonly clock: DialogClock | undefined,
231
306
  ) {
232
307
  this.state = initialPromptState(config);
233
308
  this.reason = this.createReasonEditor();
@@ -306,10 +381,15 @@ class PermissionPromptComponent implements Component {
306
381
  } else if (view.elided) {
307
382
  keys.push("ctrl+o full request");
308
383
  }
384
+ const pendingTimeoutMs = this.clock?.pendingTimeoutMs;
385
+ if (pendingTimeoutMs !== undefined) {
386
+ keys.push(`unanswered, denies in ${pendingTimeoutMs / 1000}s`);
387
+ }
309
388
  return this.theme.fg("muted", keys.join(" · "));
310
389
  }
311
390
 
312
391
  handleInput(data: string): void {
392
+ this.engageClock();
313
393
  if (this.state.step === "reason") {
314
394
  this.handleReasonInput(data);
315
395
  return;
@@ -327,6 +407,21 @@ class PermissionPromptComponent implements Component {
327
407
  }
328
408
  }
329
409
 
410
+ /**
411
+ * Stop the answer window on the human's first keystroke.
412
+ *
413
+ * The window bounds a prompt nobody is attending to; a keystroke proves
414
+ * someone is, so the dialog waits for them to finish rather than vanishing
415
+ * mid-answer. Re-renders so the hint stops advertising a deadline.
416
+ */
417
+ private engageClock(): void {
418
+ if (this.clock?.pendingTimeoutMs === undefined) {
419
+ return;
420
+ }
421
+ this.clock.engage();
422
+ this.requestRender();
423
+ }
424
+
330
425
  /**
331
426
  * Hand the keystroke to the framework line editor.
332
427
  *
@@ -225,6 +225,7 @@ export function mergeUnifiedConfigs(
225
225
  // Number scalars: override replaces base when defined
226
226
  for (const key of [
227
227
  "forwardingTimeoutMs",
228
+ "promptTimeoutMs",
228
229
  "promptMaxRows",
229
230
  "promptFieldMaxWidth",
230
231
  "reviewLogFieldMaxWidth",
@@ -388,6 +388,12 @@ export const unifiedConfigSchema = z
388
388
  "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.",
389
389
  default: 600000,
390
390
  }),
391
+ promptTimeoutMs: z.number().int().min(1).optional().meta({
392
+ description:
393
+ "How long a permission prompt, once shown, waits for an answer before it is dismissed and the request denied, in milliseconds. Omit for no timer (the default). Pressing any key in the inline dialog stops the timer.",
394
+ markdownDescription:
395
+ "How long a permission prompt, once shown, waits for an answer before it is dismissed and the request denied, in milliseconds.\n\nOmit for no timer (the default): a prompt waits until someone answers it. The clock starts when the prompt is shown, not when the request is raised, so a request queued behind another prompt gets its own full window. In the inline TUI dialog, pressing any key stops the timer, so a prompt you have started answering is never cut off.\n\nThe timeout only ever **denies**, and the agent is told nobody answered. It applies to prompts this session shows, including requests forwarded from its subagents; keep it below `forwardingTimeoutMs` so a forwarded request is answered before the subagent stops waiting.",
396
+ }),
391
397
  promptMaxRows: z.number().int().min(1).optional().meta({
392
398
  description:
393
399
  "Maximum rows a permission prompt renders before eliding its evidence. Omit to use the default (24).",
@@ -24,6 +24,8 @@ export interface PermissionSystemExtensionConfig {
24
24
  piInfrastructureReadPaths?: string[];
25
25
  /** How long a subagent waits for the parent's answer to a forwarded ask, in ms. Defaults to 600000. */
26
26
  forwardingTimeoutMs?: number;
27
+ /** How long a shown permission prompt waits for an answer before denying, in ms. Absent means no timer. */
28
+ promptTimeoutMs?: number;
27
29
  /** Max rows a permission prompt renders before eliding its evidence. Defaults to 24. */
28
30
  promptMaxRows?: number;
29
31
  /** Max characters of any one field shown in a permission prompt. Defaults to 400. */
@@ -84,6 +86,9 @@ export function normalizePermissionSystemConfig(
84
86
  if (raw.forwardingTimeoutMs !== undefined) {
85
87
  result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
86
88
  }
89
+ if (raw.promptTimeoutMs !== undefined) {
90
+ result.promptTimeoutMs = raw.promptTimeoutMs;
91
+ }
87
92
  if (raw.promptMaxRows !== undefined) {
88
93
  result.promptMaxRows = raw.promptMaxRows;
89
94
  }
package/src/index.ts CHANGED
@@ -159,6 +159,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
159
159
  budget: resolveRenderBudget(configStore.current()),
160
160
  dialogKeys: resolveDialogKeys(configStore.current()).keys,
161
161
  promptNotifications: configStore.current().promptNotifications ?? [],
162
+ promptTimeoutMs: configStore.current().promptTimeoutMs,
162
163
  }),
163
164
  requestPermissionDecision,
164
165
  forwardingDir: paths.forwardingDir,
@@ -82,6 +82,13 @@ export function renderRefusal(
82
82
  );
83
83
  case "unavailable":
84
84
  return renderUnavailableDenial(payload, denialReason, budget);
85
+ case "unanswered":
86
+ return renderUnansweredDenial(
87
+ payload,
88
+ { timeoutMs: decider.timeoutMs, decidedElsewhere },
89
+ denialReason,
90
+ budget,
91
+ );
85
92
  case "rule":
86
93
  return renderEscalatedPolicyDenial(
87
94
  payload,
@@ -231,6 +238,35 @@ export function renderUnavailableDenial(
231
238
  );
232
239
  }
233
240
 
241
+ /** The prompt window that expired, and where the prompt was shown. */
242
+ export interface UnansweredPrompt {
243
+ /** How long the prompt stayed open unanswered, in milliseconds. */
244
+ readonly timeoutMs: number;
245
+ /** Whether the prompt was shown in a session reached through a forwarding hop. */
246
+ readonly decidedElsewhere: boolean;
247
+ }
248
+
249
+ /**
250
+ * The agent-facing render of a prompt nobody answered within its window.
251
+ *
252
+ * Unlike {@link renderUnavailableDenial}, a prompt *was* shown, so the sentence
253
+ * says nobody answered rather than that no UI exists; unlike
254
+ * {@link renderUserDenial}, it never claims the user refused. Like the
255
+ * unavailable render, it omits the escaped boundary: no rule ruled, so no
256
+ * retry shape would change the outcome.
257
+ */
258
+ export function renderUnansweredDenial(
259
+ payload: PromptPayload,
260
+ prompt: UnansweredPrompt,
261
+ denialReason: string | null,
262
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
263
+ ): string {
264
+ return tagged(
265
+ `Nobody${servingClause(prompt.decidedElsewhere)} answered the approval prompt for this ${identification(payload, budget, "call", askRuleClause(payload))} within ${prompt.timeoutMs / 1000}s.`,
266
+ denialReason,
267
+ );
268
+ }
269
+
234
270
  // ── Sentence assembly ──────────────────────────────────────────────────────
235
271
 
236
272
  /**
@@ -139,6 +139,11 @@ export type PermissionDecisionResolution =
139
139
  | "user_denied"
140
140
  | "auto_approved"
141
141
  | "confirmation_unavailable"
142
+ /**
143
+ * A human was shown the prompt and nobody answered within the configured
144
+ * `promptTimeoutMs`, so the request was denied.
145
+ */
146
+ | "prompt_unanswered"
142
147
  /** A registered `authorizerChain` link granted the ask; no human was asked. */
143
148
  | "authorizer_allowed"
144
149
  /** A registered `authorizerChain` link refused the ask; no human was asked. */