@gotgenes/pi-permission-system 40.1.3 → 40.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ 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.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.4...pi-permission-system-v40.2.0) (2026-10-12)
9
+
10
+
11
+ ### Features
12
+
13
+ * **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)
14
+ * **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)
15
+
16
+ ### Documentation
17
+
18
+ * **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)
19
+ * **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)
20
+
21
+ ## [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)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * **pi-permission-system:** scope a wrapper's session approval to its inner command ([97f805f](https://github.com/gotgenes/pi-packages/commit/97f805fc7f07be93ef0fc054310755bddabada24)), closes [#1055](https://github.com/gotgenes/pi-packages/issues/1055), closes [#1054](https://github.com/gotgenes/pi-packages/issues/1054)
27
+ * **pi-permission-system:** keep an xargs replacement template exact in session approvals ([b4a4d06](https://github.com/gotgenes/pi-packages/commit/b4a4d0648c9cf4e873a7679e8f701925232cd5aa)), closes [#1055](https://github.com/gotgenes/pi-packages/issues/1055), closes [#1054](https://github.com/gotgenes/pi-packages/issues/1054)
28
+ * **pi-permission-system:** keep a wrapped command exact when a word holds a non-breaking space ([f16aa96](https://github.com/gotgenes/pi-packages/commit/f16aa968dbeed76b23967838b88fff0d18e93cde)), closes [#1055](https://github.com/gotgenes/pi-packages/issues/1055), closes [#1054](https://github.com/gotgenes/pi-packages/issues/1054)
29
+
30
+ ### Documentation
31
+
32
+ * **pi-permission-system:** describe wrapper session-approval suggestions ([a999c58](https://github.com/gotgenes/pi-packages/commit/a999c585e60c8d4d8836de744788440712c9d099)), closes [#1055](https://github.com/gotgenes/pi-packages/issues/1055), closes [#1054](https://github.com/gotgenes/pi-packages/issues/1054)
33
+ * **pi-permission-system:** note that a bare inline-shell payload keeps its exact session pattern ([3d53393](https://github.com/gotgenes/pi-packages/commit/3d53393c9da27c74c900902d2f0ec2453df1055d)), closes [#1055](https://github.com/gotgenes/pi-packages/issues/1055), closes [#1054](https://github.com/gotgenes/pi-packages/issues/1054)
34
+
8
35
  ## [40.1.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.2...pi-permission-system-v40.1.3) (2026-10-10)
9
36
 
10
37
 
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.
@@ -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
 
@@ -87,6 +87,31 @@ The arity table covers common CLI tools including git, npm/pnpm/yarn/bun, docker
87
87
  To add an entry, open `src/access-intent/bash/bash-arity.ts` and add a key/arity pair to the `ARITY` object.
88
88
  Put the most specific multi-word prefix first (e.g. `"npm run": 3`) before the shorter fallback (`"npm": 2`).
89
89
 
90
+ ### Wrapped commands
91
+
92
+ A command behind an indirection wrapper (`sudo`, `env`, `xargs`, `nohup`, `timeout`, `nice`, and the rest of the wrapper vocabulary) is approved by its inner command, not by the wrapper.
93
+ A session approval skips the wrapper's floor, so the suggestion keeps every wrapper word literally (options, operands, nested wrappers) and lets only the inner command's arguments vary.
94
+
95
+ | Example command | Suggested pattern |
96
+ | ------------------------------------ | ----------------------------- |
97
+ | `nohup mytool argument` | `nohup mytool *` |
98
+ | `sudo aws s3 rm s3://bucket` | `sudo aws s3 rm *` |
99
+ | `env FOO=bar mytool argument` | `env FOO=bar mytool *` |
100
+ | `timeout 5 mytool argument` | `timeout 5 mytool *` |
101
+ | `sudo -u root nohup mytool argument` | `sudo -u root nohup mytool *` |
102
+
103
+ A wrapped pattern always puts a space before its wildcard, so approving `nohup mytool` covers `nohup mytool` and `nohup mytool <args>` but not `nohup mytool-other`.
104
+
105
+ When the inner command cannot be established from the words alone, the suggestion is the whole command with no wildcard:
106
+
107
+ - Shell syntax in any word: quoting, `$` expansion, a glob, `~`, grouping, or an operator (`sudo mytool $HOME`).
108
+ - An inline shell payload, bare or wrapped (`bash -c …`, `eval …`, `sudo bash -c …`), or a wrapper that hands its command line to a shell or template (`watch`, `parallel`, `rush`, `rust-parallel`).
109
+ - A wrapper mode in which the peeled command is not what runs (`sudo -s`, `env -C`), an option the wrapper's table does not admit (`timeout --sig KILL 5 …`, `flock -w 5 …`), or a `find`/`fd` exec clause.
110
+ - An `xargs` replacement option (`-I`, `-i`, `--replace`), since standard input then rewrites the inner command's arguments (`xargs -I X nohup X first`).
111
+ - Wrappers nested deeper than the gate peels.
112
+
113
+ This changes only the suggested pattern; the wrapper floors and their exemptions are unchanged.
114
+
90
115
  ## Review Log Entries
91
116
 
92
117
  The review log records session approval decisions:
@@ -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.3",
3
+ "version": "40.2.0",
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.",
@@ -3,10 +3,11 @@
3
3
  * what it actually runs, and whether its floor still has a reason to hold.
4
4
  *
5
5
  * Pure and word-based; the AST walk that produces the words lives in
6
- * `command-enumeration.ts`. The three questions live here together
7
- * deliberately: the shape that floors a unit to `ask`, the shape that names its
8
- * inner command, and the shape that exempts it must agree, and separate
9
- * classifiers over the same vocabulary would drift.
6
+ * `command-enumeration.ts`. The questions live here together deliberately:
7
+ * the shape that floors a unit to `ask`, the shape that names its inner
8
+ * command, the shape that exempts it, and how far a session approval may
9
+ * generalize past it must agree, and separate classifiers over the same
10
+ * vocabulary would drift.
10
11
  */
11
12
 
12
13
  import type { FloorExemption } from "#src/types";
@@ -245,8 +246,16 @@ function onlyModifiesExecution(
245
246
  peeled: readonly (readonly CommandWord[])[],
246
247
  inner: readonly CommandWord[],
247
248
  ): boolean {
249
+ return peeled.every(isAdmittedModifierLayer) && reachedLiteralCommand(inner);
250
+ }
251
+
252
+ /**
253
+ * True when a peel ended at an ordinary command spelled as a literal name: not
254
+ * a wrapper it could not see past, and not shell syntax standing where the
255
+ * command name should be.
256
+ */
257
+ function reachedLiteralCommand(inner: readonly CommandWord[]): boolean {
248
258
  return (
249
- peeled.every(isAdmittedModifierLayer) &&
250
259
  classifyWrapperWords(inner) === undefined &&
251
260
  isLiteralCommandName(inner.at(0)?.text ?? "")
252
261
  );
@@ -309,6 +318,90 @@ function isLiteralCommandName(text: string): boolean {
309
318
  return LITERAL_COMMAND_NAME.test(text) && !RESERVED_WORDS.has(text);
310
319
  }
311
320
 
321
+ // ── Session patterns ─────────────────────────────────────────────────────────
322
+
323
+ /**
324
+ * Where the inner command starts for a session-approval pattern: `0` for an
325
+ * ordinary command, the count of peeled wrapper words when every layer may be
326
+ * generalized past, or `null` to keep the whole command.
327
+ *
328
+ * A session grant skips the wrapper floor, so the pattern built from this
329
+ * index is the grant's whole width: everything before the index stays literal,
330
+ * and only the inner command's arguments may vary. Each `null` is a shape where
331
+ * a pattern ending in a wildcard could name a command the human never saw —
332
+ * shell syntax the whitespace split cannot read, an opaque payload, a peel
333
+ * that stopped short, or a layer {@link generalizesLayer} refuses.
334
+ *
335
+ * The count is summed over the peeled layers rather than taken from the inner
336
+ * command's length, since the walk trims an exec terminator off the end.
337
+ */
338
+ export function sessionCommandIndex(tokens: readonly string[]): number | null {
339
+ const words = literalWordsOf(tokens);
340
+ if (classifyWrapperWords(words) === undefined) return 0;
341
+ if (words.some((word) => word.computed)) return null;
342
+ const unwrapped = unwrapIndirection(words, "");
343
+ if (unwrapped.kind === "opaque") return null;
344
+ if (!unwrapped.peeled.every(generalizesLayer)) return null;
345
+ if (!reachedLiteralCommand(unwrapped.words)) return null;
346
+ return unwrapped.peeled.reduce((count, layer) => count + layer.length, 0);
347
+ }
348
+
349
+ /**
350
+ * True when a session pattern may keep a peeled layer literal and vary what
351
+ * follows its inner command.
352
+ *
353
+ * A getopt-read wrapper's peel already refuses an unlisted or mode-changing
354
+ * option. A table-read wrapper skips an unknown option as a flag, so it is
355
+ * admitted only by the floor exemption's own admission or with no option at
356
+ * all; that also refuses a `find`/`fd` exec flag, after which `find`'s own
357
+ * options may follow the inner command.
358
+ */
359
+ function generalizesLayer(layer: readonly CommandWord[]): boolean {
360
+ const name = wrapperName(layer);
361
+ if (name === undefined || runsOpaqueCommandLine(layer)) return false;
362
+ if (name === "xargs") return !mayReplaceArguments(layer);
363
+ if (GETOPT_GRAMMARS.has(name)) return true;
364
+ return (
365
+ isAdmittedModifierLayer(layer) ||
366
+ layer.slice(1).every((word) => !word.text.startsWith("-"))
367
+ );
368
+ }
369
+
370
+ /**
371
+ * True when an `xargs` layer may carry a replacement option (`-I`, `-i`,
372
+ * `--replace`), which lets standard input rewrite the inner command's
373
+ * arguments, a nested wrapper's executable included.
374
+ *
375
+ * Deliberately wider than the grammar: any short cluster holding `I` or `i`
376
+ * and any long option opening `--r` counts, since a false positive only keeps
377
+ * the whole command.
378
+ */
379
+ function mayReplaceArguments(layer: readonly CommandWord[]): boolean {
380
+ return layer
381
+ .slice(1)
382
+ .some(({ text }) => /^-[^-]*[Ii]/.test(text) || text.startsWith("--r"));
383
+ }
384
+
385
+ /**
386
+ * A command's whitespace tokens as command words, each marked `computed` when
387
+ * it carries shell syntax the split cannot read: quoting, expansion, a glob,
388
+ * grouping, an operator, or a comment.
389
+ */
390
+ function literalWordsOf(tokens: readonly string[]): CommandWord[] {
391
+ let offset = 0;
392
+ return tokens.map((text) => {
393
+ const word: CommandWord = {
394
+ text,
395
+ value: text,
396
+ computed: SHELL_SYNTAX.test(text),
397
+ mayLeadWithDash: text.startsWith("-"),
398
+ offset,
399
+ };
400
+ offset += text.length + 1;
401
+ return word;
402
+ });
403
+ }
404
+
312
405
  // ── Unwrapping ───────────────────────────────────────────────────────────────
313
406
 
314
407
  /**
@@ -959,6 +1052,12 @@ const WRITING_OPTIONS = new Map<string, ReadonlySet<string>>([
959
1052
  */
960
1053
  const LITERAL_COMMAND_NAME = /^[A-Za-z0-9_./+@%,:][A-Za-z0-9_./+@%,:-]*$/;
961
1054
 
1055
+ /**
1056
+ * A character that makes a whitespace token something other than the literal
1057
+ * word a program receives.
1058
+ */
1059
+ const SHELL_SYNTAX = /['"\\$`|&;<>(){}[\]*?!~#]/;
1060
+
962
1061
  /** Bash reserved words, which open syntax rather than name a command. */
963
1062
  const RESERVED_WORDS: ReadonlySet<string> = new Set([
964
1063
  "!",
@@ -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
  /**
@@ -2,6 +2,7 @@ import {
2
2
  prefix,
3
3
  stripBashCommentLines,
4
4
  } from "#src/access-intent/bash/bash-arity";
5
+ import { sessionCommandIndex } from "#src/access-intent/bash/wrapper-analysis";
5
6
  import {
6
7
  type CapabilityDirection,
7
8
  PATH_BEARING_TOOLS,
@@ -30,6 +31,10 @@ export interface SessionApprovalSuggestion {
30
31
  * - Arity prefix covers all tokens: trailing wildcard (`npm run build*`).
31
32
  * - Arity prefix shorter than token list: space + wildcard (`git checkout *`).
32
33
  * - Unknown command: first token + space wildcard (`mytool *`).
34
+ * - Recognized wrapper: its words, then the inner command's arity prefix,
35
+ * then a space wildcard (`sudo -u root nohup mytool *`).
36
+ * - Wrapper whose inner command cannot be proven: the exact command, with no
37
+ * wildcard (`watch mytool x`).
33
38
  */
34
39
  export function suggestBashPattern(command: string): string {
35
40
  const trimmed = command.trim();
@@ -40,6 +45,12 @@ export function suggestBashPattern(command: string): string {
40
45
  if (!stripped) return "";
41
46
  const tokens = stripped.split(/\s+/);
42
47
  if (tokens.length === 1) return stripped;
48
+ // A wrapper's words are split only where bash splits them: `\s` also splits
49
+ // on a non-breaking space or `\r`, which would read `rm<NBSP>-rf` as `rm`.
50
+ const words = stripped.split(BASH_BLANKS);
51
+ const commandIndex = sessionCommandIndex(words);
52
+ if (commandIndex === null) return stripped;
53
+ if (commandIndex > 0) return wrappedPattern(words, commandIndex);
43
54
  const meaningful = prefix(tokens);
44
55
  if (meaningful.length >= tokens.length) {
45
56
  return `${stripped}*`;
@@ -47,6 +58,28 @@ export function suggestBashPattern(command: string): string {
47
58
  return `${meaningful.join(" ")} *`;
48
59
  }
49
60
 
61
+ /** The blanks bash splits a command's words on. */
62
+ const BASH_BLANKS = /[ \t\n]+/;
63
+
64
+ /**
65
+ * The wrapper words kept literally, then the inner command's arity prefix.
66
+ *
67
+ * The wildcard always follows a space, even when the arity prefix covers every
68
+ * token, so approving `nohup mytool` never approves `nohup mytool-other`; the
69
+ * matcher treats a trailing space and wildcard as optional, so the bare
70
+ * command is still covered.
71
+ */
72
+ function wrappedPattern(
73
+ tokens: readonly string[],
74
+ commandIndex: number,
75
+ ): string {
76
+ const meaningful = [
77
+ ...tokens.slice(0, commandIndex),
78
+ ...prefix(tokens.slice(commandIndex)),
79
+ ];
80
+ return `${meaningful.join(" ")} *`;
81
+ }
82
+
50
83
  /**
51
84
  * Suggest an MCP session-approval pattern from a resolved target string.
52
85
  *
@@ -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. */