@gotgenes/pi-permission-system 40.1.4 → 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 +13 -0
- package/README.md +1 -0
- package/dist/public.d.ts +5 -0
- package/docs/configuration.md +26 -1
- package/docs/cross-extension-api.md +15 -14
- package/docs/subagent-integration.md +2 -0
- package/docs/troubleshooting.md +1 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +7 -0
- package/src/authority/answer-window.ts +86 -0
- package/src/authority/decision-resolution.ts +2 -0
- package/src/authority/decision-source.ts +19 -0
- package/src/authority/local-user-authorizer.ts +2 -5
- package/src/authority/permission-dialog.ts +58 -14
- package/src/authority/permission-prompt-component.ts +109 -14
- package/src/config/config-loader.ts +1 -0
- package/src/config/config-schema.ts +6 -0
- package/src/config/extension-config.ts +5 -0
- package/src/index.ts +1 -0
- package/src/presentation/agent-renderer.ts +36 -0
- package/src/service/permission-events.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ 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
|
+
|
|
8
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)
|
|
9
22
|
|
|
10
23
|
|
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. */
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
-
| `
|
|
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.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
@@ -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.",
|
|
@@ -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
|
|
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)
|
|
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(
|
|
60
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
*
|
|
@@ -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. */
|