@gotgenes/pi-permission-system 35.0.3 → 36.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -0
- package/config/config.example.json +1 -0
- package/docs/configuration.md +39 -0
- package/docs/cross-extension-api.md +33 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +10 -0
- package/src/access-intent/bash/command-effects.ts +15 -5
- package/src/access-intent/bash/node-text.ts +132 -2
- package/src/authority/permission-prompt-component.ts +17 -3
- package/src/config/config-loader.ts +10 -0
- package/src/config/config-schema.ts +17 -0
- package/src/config/extension-config.ts +6 -0
- package/src/index.ts +1 -0
- package/src/presentation/prompt-notification.ts +47 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ 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
|
+
## [36.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.0.0...pi-permission-system-v36.1.0) (2026-09-30)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** ring the terminal when a permission prompt opens ([b92ede8](https://github.com/gotgenes/pi-packages/commit/b92ede819523aa2bd776acb2d2ce6dac11622571)), closes [#906](https://github.com/gotgenes/pi-packages/issues/906)
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** document prompt notifications and the notifier recipe ([9ce9b32](https://github.com/gotgenes/pi-packages/commit/9ce9b32c82cca97c3fa73ff377f5f773673821f7)), closes [#906](https://github.com/gotgenes/pi-packages/issues/906)
|
|
18
|
+
|
|
19
|
+
## [36.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.3...pi-permission-system-v36.0.0) (2026-09-29)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **pi-permission-system:** a digit argument no longer withdraws sed's or awk's read claim ([18df901](https://github.com/gotgenes/pi-packages/commit/18df9012843b5811746098bcd106c52d1bc15510)), closes [#992](https://github.com/gotgenes/pi-packages/issues/992)
|
|
25
|
+
* **pi-permission-system:** **breaking:** a computed argument that may spell an option withdraws find's, fd's, and sort's read claim ([8116d72](https://github.com/gotgenes/pi-packages/commit/8116d725b7d74ec610ef97cfb26e6a2df64a839d)), closes [#992](https://github.com/gotgenes/pi-packages/issues/992)
|
|
26
|
+
* **pi-permission-system:** a quoted "$@" after a literal withdraws find's, fd's, and sort's read claim ([45da982](https://github.com/gotgenes/pi-packages/commit/45da9825f80ce4fe2955081f2692a1bc678ddb26)), closes [#992](https://github.com/gotgenes/pi-packages/issues/992)
|
|
27
|
+
* **pi-permission-system:** a quoted indirect expansion after a literal withdraws find's, fd's, and sort's read claim ([dd78839](https://github.com/gotgenes/pi-packages/commit/dd78839f6327e7de51b1024b07ea21653e34b680)), closes [#992](https://github.com/gotgenes/pi-packages/issues/992)
|
|
28
|
+
* **pi-permission-system:** a quoted variable after a literal withdraws find's, fd's, and sort's read claim ([7727223](https://github.com/gotgenes/pi-packages/commit/7727223ec01b5dd0e8560248dd965e7f638930ae)), closes [#992](https://github.com/gotgenes/pi-packages/issues/992)
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
* **pi-permission-system:** document the computed-word rule for find, fd, and sort ([9f7deb9](https://github.com/gotgenes/pi-packages/commit/9f7deb9ca270a17c99a820f79e40fa64afc444d0))
|
|
33
|
+
* **pi-permission-system:** name a quoted "$@" among the words that split ([1595490](https://github.com/gotgenes/pi-packages/commit/15954901cc61154ad5548f3b5553b60bb48ab4d6))
|
|
34
|
+
* **pi-permission-system:** name an indirect expansion among the words that split ([7d875c3](https://github.com/gotgenes/pi-packages/commit/7d875c37d4130d06ae1969b4739f030b5ce79dd7))
|
|
35
|
+
* **pi-permission-system:** a quoted variable may split, since it may be a nameref ([0df4871](https://github.com/gotgenes/pi-packages/commit/0df4871f2ca42d4df6639f9bcf7d11d0647b3e31))
|
|
36
|
+
|
|
8
37
|
## [35.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v35.0.2...pi-permission-system-v35.0.3) (2026-09-29)
|
|
9
38
|
|
|
10
39
|
|
package/README.md
CHANGED
|
@@ -67,6 +67,7 @@ All permissions use one of three states:
|
|
|
67
67
|
When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
|
|
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
|
+
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.
|
|
70
71
|
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.
|
|
71
72
|
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.
|
|
72
73
|
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/docs/configuration.md
CHANGED
|
@@ -38,6 +38,7 @@ See [migration/0644-project-trust-gating.md](migration/0644-project-trust-gating
|
|
|
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
39
|
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `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
|
+
`promptNotifications` likewise replaces the whole list, so a project `[]` turns off a global list.
|
|
41
42
|
|
|
42
43
|
**Invalid higher-precedence scope fails closed.**
|
|
43
44
|
If a non-global scope (project config, global agent frontmatter, or project agent frontmatter) is present but fails to load or validate, it no longer contributes an empty scope that silently inherits the lower scope's rules.
|
|
@@ -105,6 +106,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
|
|
|
105
106
|
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
|
|
106
107
|
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
|
|
107
108
|
| `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
|
+
| `promptNotifications` | `[]` | Terminal notifications to emit when the inline TUI dialog opens: `"bell"`, `"osc9"`, `"osc777"` (see below). Empty for none. |
|
|
108
110
|
| `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. |
|
|
109
111
|
| `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. |
|
|
110
112
|
| `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. |
|
|
@@ -184,6 +186,39 @@ The reason editor is Pi's own line editor, so it behaves like the chat input: pa
|
|
|
184
186
|
The reason is a single line — a pasted line break becomes a space, and a long reason scrolls sideways rather than growing the dialog.
|
|
185
187
|
`enter` submits it, and `esc` (or `Ctrl+C`) returns to the decision list without denying.
|
|
186
188
|
|
|
189
|
+
#### Terminal notifications
|
|
190
|
+
|
|
191
|
+
An ask stops the agent mid-turn, so a session in a background tab or pane can sit waiting without anything telling you.
|
|
192
|
+
Set `promptNotifications` to have the dialog signal the terminal as it opens:
|
|
193
|
+
|
|
194
|
+
```jsonc
|
|
195
|
+
{
|
|
196
|
+
"promptNotifications": ["bell", "osc777"]
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
| Channel | Writes |
|
|
201
|
+
| ---------- | ------------------------------------------------ |
|
|
202
|
+
| `"bell"` | BEL, the terminal bell |
|
|
203
|
+
| `"osc9"` | an OSC 9 desktop notification (`ESC ] 9 ;`) |
|
|
204
|
+
| `"osc777"` | an OSC 777 desktop notification (`ESC ] 777 ;`) |
|
|
205
|
+
|
|
206
|
+
The bell is the widest-supported signal: terminals and multiplexers already route it to a sound, a badge, or pane attention (tmux `monitor-bell`, for one).
|
|
207
|
+
The two OSC forms raise a desktop notification in terminals that implement them; which one yours supports is in its documentation, and listing both is harmless, since a terminal ignores an OSC it does not know.
|
|
208
|
+
|
|
209
|
+
Channels are written in the order listed.
|
|
210
|
+
The list is empty by default, so nothing is emitted until you set it; an unknown channel name is a validation error like any other malformed field.
|
|
211
|
+
The notification text is the dialog title (`Permission Required`, or `Permission Required (Subagent)` for a forwarded ask), never the command or path being decided, so nothing from the request lands in your notification history.
|
|
212
|
+
|
|
213
|
+
The signal fires once per prompt, when the prompt is actually shown.
|
|
214
|
+
A second ask that waits behind an open dialog signals when its own turn comes, and a forwarded subagent ask signals in the parent session that shows it.
|
|
215
|
+
An ask decided without a dialog (by a rule, a session approval, or an authorizer link) emits nothing.
|
|
216
|
+
Only interactive TUI sessions signal; outside interactive mode Pi routes stdout away from the terminal, so there is nothing to ring.
|
|
217
|
+
|
|
218
|
+
Inside tmux, prefer `"bell"`: tmux passes a BEL through to its own bell handling, but drops OSC notifications unless passthrough is enabled.
|
|
219
|
+
|
|
220
|
+
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).
|
|
221
|
+
|
|
187
222
|
### What a prompt shows
|
|
188
223
|
|
|
189
224
|
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.
|
|
@@ -896,6 +931,10 @@ Three members are read-only **until an argument says otherwise**, and naming one
|
|
|
896
931
|
|
|
897
932
|
A long option is matched by any unambiguous abbreviation too (`sort --out=…` withdraws the claim exactly as `--output` does), and a short letter is matched anywhere in a cluster (`-uo`) or with its value attached (`-o/tmp/x`).
|
|
898
933
|
|
|
934
|
+
An argument whose value only the shell decides withdraws the claim too when it may arrive beginning with `-`, since it could spell any of these options: `$opt`, `"$dir"`, `$(cmd)`, and a leading glob such as `*` all do.
|
|
935
|
+
One whose first character is a literal other than `-` cannot become an option unless it can split into several words, so `find packages/*/docs` and `find . \( -name a \)` still read.
|
|
936
|
+
A variable can split even inside double quotes (`"x$@"` yields one word per positional parameter, and any variable may be a name reference to an array), so `find /src -name "x$y"` withdraws the claim too.
|
|
937
|
+
|
|
899
938
|
A core word counts only as a **bare basename**.
|
|
900
939
|
`./grep`, `/usr/bin/grep`, and `bin\grep` name programs this audit never saw, so they prove nothing and consult both surfaces.
|
|
901
940
|
|
|
@@ -516,6 +516,39 @@ pi.events.on("permissions:decision", (raw) => {
|
|
|
516
516
|
| `confirmation_unavailable` | State was `ask` but no UI was available — blocked |
|
|
517
517
|
| `gate_error` | The gate threw, or an escalation failed — blocked, fail-closed |
|
|
518
518
|
|
|
519
|
+
### Recipe: bridging a prompt to an external notifier
|
|
520
|
+
|
|
521
|
+
This extension can ring the terminal itself (`promptNotifications` in [configuration.md](configuration.md#terminal-notifications)).
|
|
522
|
+
Anything beyond that (running a program, reporting to a session manager such as Herdr, calling `cmux notify`, pushing to a phone) belongs in a small extension of your own that listens to the two broadcasts.
|
|
523
|
+
This package does not emit another tool's events itself: an outbound bridge would tie it to that tool's contract, where a listener leaves both sides free to change.
|
|
524
|
+
|
|
525
|
+
The two channels pair up by `requestId`.
|
|
526
|
+
`permissions:ui_prompt` means the human is being asked right now, and the `permissions:decision` carrying the same `requestId` means that prompt is over, however it ended.
|
|
527
|
+
Track the open ids rather than a counter or a boolean: several prompts can be pending at once (queued in one session, or forwarded from subagents), and a decision for a request that never prompted must not clear anything.
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
import type {
|
|
531
|
+
PermissionDecisionEvent,
|
|
532
|
+
PermissionUiPromptEvent,
|
|
533
|
+
} from "@gotgenes/pi-permission-system";
|
|
534
|
+
|
|
535
|
+
const open = new Set<string>();
|
|
536
|
+
|
|
537
|
+
pi.events.on("permissions:ui_prompt", (raw) => {
|
|
538
|
+
const { requestId } = raw as PermissionUiPromptEvent;
|
|
539
|
+
if (open.size === 0) markBlocked(); // e.g. herdr:blocked { active: true }
|
|
540
|
+
open.add(requestId);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
pi.events.on("permissions:decision", (raw) => {
|
|
544
|
+
const { requestId } = raw as PermissionDecisionEvent;
|
|
545
|
+
if (!open.delete(requestId)) return; // decided without a prompt
|
|
546
|
+
if (open.size === 0) clearBlocked(); // e.g. herdr:blocked { active: false }
|
|
547
|
+
});
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Both events fire on the bus of the session that shows the prompt, including the parent session that shows a forwarded subagent ask, so the listener belongs in whichever session holds the UI.
|
|
551
|
+
|
|
519
552
|
---
|
|
520
553
|
|
|
521
554
|
## Ready Event
|
package/package.json
CHANGED
|
@@ -68,6 +68,16 @@
|
|
|
68
68
|
}
|
|
69
69
|
]
|
|
70
70
|
},
|
|
71
|
+
"promptNotifications": {
|
|
72
|
+
"description": "Terminal notifications to emit when the inline permission dialog opens: bell (BEL), osc9 (OSC 9 notification), osc777 (OSC 777 notification). Omit or leave empty for none. TUI sessions only.",
|
|
73
|
+
"markdownDescription": "Terminal notifications to emit when the inline permission dialog opens, so a terminal or multiplexer can flag a session waiting on a decision.\n\n- `\"bell\"` writes a BEL — what tmux `monitor-bell` and most terminals react to.\n- `\"osc9\"` writes an OSC 9 desktop notification.\n- `\"osc777\"` writes an OSC 777 desktop notification.\n\nOmit or leave empty for none (the default). Applies to interactive **TUI** sessions only; the notification text is the dialog title, never the request itself.",
|
|
74
|
+
"default": [],
|
|
75
|
+
"type": "array",
|
|
76
|
+
"items": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"enum": ["bell", "osc9", "osc777"]
|
|
79
|
+
}
|
|
80
|
+
},
|
|
71
81
|
"forwardingTimeoutMs": {
|
|
72
82
|
"description": "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
|
|
73
83
|
"markdownDescription": "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds.\n\nOmit to use the default (`600000`, ten minutes). A child whose in-process parent is not draining its inbox at all gives up in a couple of seconds regardless of this value, so lower it only to bound how long you are willing to leave an *unanswered* prompt pending.",
|
|
@@ -16,7 +16,7 @@ import { sedWithdrawsReadClaim } from "./sed-invocation";
|
|
|
16
16
|
* without reading the host's path language.
|
|
17
17
|
*
|
|
18
18
|
* A guarded word's claim is withdrawn when an argument names one of its
|
|
19
|
-
* write-capable options, yielding `retracted` rather than a write: the command
|
|
19
|
+
* write-capable options, or is computed and may arrive as one, yielding `retracted` rather than a write: the command
|
|
20
20
|
* may still only read, so the fail-closed base case is the honest answer and
|
|
21
21
|
* `retracted` is the blame line that says why.
|
|
22
22
|
*
|
|
@@ -206,17 +206,27 @@ interface RetractionGuard {
|
|
|
206
206
|
*/
|
|
207
207
|
type ClaimWithdrawal = (argWords: readonly ArgWord[]) => boolean;
|
|
208
208
|
|
|
209
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* A withdrawal decided by option spellings: any argument naming one.
|
|
211
|
+
*
|
|
212
|
+
* A computed argument's value is its unresolved source spelling, not what the
|
|
213
|
+
* program receives, so it is asked only whether it may arrive beginning with
|
|
214
|
+
* `-`, the shape every guarded option has.
|
|
215
|
+
*/
|
|
210
216
|
function optionGuard(guard: RetractionGuard): ClaimWithdrawal {
|
|
211
217
|
return (argWords) =>
|
|
212
|
-
argWords.some((
|
|
218
|
+
argWords.some((word) =>
|
|
219
|
+
word.computed ? word.mayLeadWithDash : retractsClaim(word.value, guard),
|
|
220
|
+
);
|
|
213
221
|
}
|
|
214
222
|
|
|
215
223
|
/**
|
|
216
224
|
* The guarded words and what withdraws each one's claim.
|
|
217
225
|
*
|
|
218
|
-
* `find`, `fd`, and `sort` are guarded by option spellings
|
|
219
|
-
*
|
|
226
|
+
* `find`, `fd`, and `sort` are guarded by option spellings, chosen because
|
|
227
|
+
* their write options spell identically in GNU and BSD; a computed argument
|
|
228
|
+
* that may lead with `-` withdraws their claim too, since it could spell any
|
|
229
|
+
* of them. `sed` needs a
|
|
220
230
|
* proof over its script too, since a `w` command writes whatever its options
|
|
221
231
|
* say, and `awk` a proof over its program, since `print >` does the same — so
|
|
222
232
|
* each owns a predicate of its own. `find`'s options are single-dash long
|
|
@@ -44,16 +44,143 @@ export interface ArgWord {
|
|
|
44
44
|
* expansion, an ANSI-C string — which {@link resolveNodeText} passes through as written.
|
|
45
45
|
*/
|
|
46
46
|
readonly computed: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Whether the program may receive this argument, or a word split from it,
|
|
49
|
+
* beginning with `-` — the shape every option has. Exact for a word that is
|
|
50
|
+
* not computed; for a computed one, `false` only when a literal leading
|
|
51
|
+
* character survives every rewrite the shell applies.
|
|
52
|
+
*/
|
|
53
|
+
readonly mayLeadWithDash: boolean;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/** Read an argument node into the word the program receives. */
|
|
50
57
|
export function readArgWord(node: TSNode): ArgWord {
|
|
58
|
+
const value = resolveNodeText(node);
|
|
59
|
+
const computed = hasComputedPart(node) || !isSpelledExactly(node);
|
|
51
60
|
return {
|
|
52
|
-
value
|
|
53
|
-
computed
|
|
61
|
+
value,
|
|
62
|
+
computed,
|
|
63
|
+
mayLeadWithDash: computed
|
|
64
|
+
? mayExpandToDashWord(node)
|
|
65
|
+
: value.startsWith("-"),
|
|
54
66
|
};
|
|
55
67
|
}
|
|
56
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Whether a computed word may reach the program — whole, or as one of the
|
|
71
|
+
* words the shell splits it into — beginning with `-`.
|
|
72
|
+
*
|
|
73
|
+
* Globbing, brace expansion, and escape removal each keep a literal prefix, so
|
|
74
|
+
* a word whose leading character is a literal other than `-` cannot become an
|
|
75
|
+
* option; an expansion that can split into several words decides nothing,
|
|
76
|
+
* since only the first of them carries the prefix.
|
|
77
|
+
*/
|
|
78
|
+
function mayExpandToDashWord(node: TSNode): boolean {
|
|
79
|
+
return (
|
|
80
|
+
maySplitIntoWords(node, false) || (leadingCharacterMayBeDash(node) ?? true)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Expansions whose unquoted result the shell splits into words. */
|
|
85
|
+
const WORD_SPLITTING_TYPES: ReadonlySet<string> = new Set([
|
|
86
|
+
"simple_expansion",
|
|
87
|
+
"expansion",
|
|
88
|
+
"command_substitution",
|
|
89
|
+
"arithmetic_expansion",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Nodes whose inside is never split into the argument's words: a
|
|
94
|
+
* single-quoted or ANSI-C string, and a process substitution, whose body
|
|
95
|
+
* belongs to its own command.
|
|
96
|
+
*/
|
|
97
|
+
const UNSPLIT_TYPES: ReadonlySet<string> = new Set([
|
|
98
|
+
"raw_string",
|
|
99
|
+
"ansi_c_string",
|
|
100
|
+
"process_substitution",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether any expansion in the word may produce more than one word.
|
|
105
|
+
*
|
|
106
|
+
* Unquoted, every expansion splits. Inside double quotes a command
|
|
107
|
+
* substitution or arithmetic expansion is one word, but a parameter expansion
|
|
108
|
+
* may not be: `$@` and `${arr[@]}` expand to one word per element, an indirect
|
|
109
|
+
* `${!name}` may name either, and any variable may be a nameref
|
|
110
|
+
* (`declare -n s='arr[@]'`), with nothing in its own spelling to show it. So
|
|
111
|
+
* every quoted parameter expansion counts, over-counting a joined `$*`.
|
|
112
|
+
*/
|
|
113
|
+
function maySplitIntoWords(node: TSNode, quoted: boolean): boolean {
|
|
114
|
+
if (WORD_SPLITTING_TYPES.has(node.type)) {
|
|
115
|
+
return !quoted || PARAMETER_EXPANSION_TYPES.has(node.type);
|
|
116
|
+
}
|
|
117
|
+
if (UNSPLIT_TYPES.has(node.type)) return false;
|
|
118
|
+
const childrenQuoted = quoted || node.type === "string";
|
|
119
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
120
|
+
const child = node.child(i);
|
|
121
|
+
if (child && maySplitIntoWords(child, childrenQuoted)) return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Parameter expansions, which may yield several words even when quoted. */
|
|
127
|
+
const PARAMETER_EXPANSION_TYPES: ReadonlySet<string> = new Set([
|
|
128
|
+
"simple_expansion",
|
|
129
|
+
"expansion",
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether the first character the word produces may be `-`, or `undefined`
|
|
134
|
+
* while every part read so far produced nothing (`""`, `''`).
|
|
135
|
+
*
|
|
136
|
+
* A part this does not know is non-literal, and so may lead with anything.
|
|
137
|
+
*/
|
|
138
|
+
function leadingCharacterMayBeDash(node: TSNode): boolean | undefined {
|
|
139
|
+
switch (node.type) {
|
|
140
|
+
case "word":
|
|
141
|
+
case "number":
|
|
142
|
+
return unquotedLiteralMayLeadWithDash(node.text);
|
|
143
|
+
case "raw_string":
|
|
144
|
+
return quotedLiteralMayLeadWithDash(node.text.slice(1, -1));
|
|
145
|
+
case "string_content":
|
|
146
|
+
return quotedLiteralMayLeadWithDash(node.text);
|
|
147
|
+
case '"':
|
|
148
|
+
return undefined;
|
|
149
|
+
case "process_substitution":
|
|
150
|
+
// The program receives a `/dev/fd/N` path.
|
|
151
|
+
return false;
|
|
152
|
+
case "string":
|
|
153
|
+
case "concatenation":
|
|
154
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
155
|
+
const child = node.child(i);
|
|
156
|
+
const lead = child ? leadingCharacterMayBeDash(child) : undefined;
|
|
157
|
+
if (lead !== undefined) return lead;
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
default:
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* An unquoted literal: a leading glob or brace can produce any character, and
|
|
167
|
+
* an escape yields the character after it.
|
|
168
|
+
*/
|
|
169
|
+
function unquotedLiteralMayLeadWithDash(text: string): boolean | undefined {
|
|
170
|
+
if (text === "") return undefined;
|
|
171
|
+
if (text.startsWith("\\")) return text.length < 2 || text[1] === "-";
|
|
172
|
+
return UNDETERMINED_LEAD.test(text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A leading character that may become, or already is, `-`. */
|
|
176
|
+
const UNDETERMINED_LEAD = /^[-*?[{]/;
|
|
177
|
+
|
|
178
|
+
/** A quoted literal reaches the program as written. */
|
|
179
|
+
function quotedLiteralMayLeadWithDash(text: string): boolean | undefined {
|
|
180
|
+
if (text === "") return undefined;
|
|
181
|
+
return text.startsWith("-");
|
|
182
|
+
}
|
|
183
|
+
|
|
57
184
|
/**
|
|
58
185
|
* Whether {@link resolveNodeText} returns exactly the string the shell passes.
|
|
59
186
|
*
|
|
@@ -72,6 +199,9 @@ function isSpelledExactly(node: TSNode): boolean {
|
|
|
72
199
|
case "expansion":
|
|
73
200
|
return resolvePlainVariableExpansion(node) !== null;
|
|
74
201
|
case "string":
|
|
202
|
+
// A digit run is an ordinary word to the shell; the grammar's `10#$x` form
|
|
203
|
+
// carries an expansion child, which answers for itself.
|
|
204
|
+
case "number":
|
|
75
205
|
return childrenSpelledExactly(node);
|
|
76
206
|
case "concatenation":
|
|
77
207
|
// The grammar splits `{-i,-n}` into plain words, so the expansion is
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type KeyId,
|
|
10
10
|
matchesKey,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
|
+
import type { PromptNotificationChannel } from "#src/config/config-schema";
|
|
12
13
|
import type { DialogKeyBindings, PromptAction } from "#src/config/dialog-keys";
|
|
13
14
|
import {
|
|
14
15
|
completeViewBudget,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
renderPromptDialog,
|
|
18
19
|
} from "#src/presentation/dialog-renderer";
|
|
19
20
|
import { fitLinesToWidth } from "#src/presentation/line-fitting";
|
|
21
|
+
import { renderPromptNotification } from "#src/presentation/prompt-notification";
|
|
20
22
|
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
21
23
|
import { collapsePastedNewlines } from "./bracketed-paste";
|
|
22
24
|
import type { DecisionSource, UserDecisionSurface } from "./decision-source";
|
|
@@ -67,6 +69,8 @@ export interface PromptPreferences {
|
|
|
67
69
|
budget: RenderBudget;
|
|
68
70
|
/** The character bound to each decision. */
|
|
69
71
|
dialogKeys: DialogKeyBindings;
|
|
72
|
+
/** Terminal notifications the inline dialog emits as it opens; empty for none. */
|
|
73
|
+
promptNotifications: readonly PromptNotificationChannel[];
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
/**
|
|
@@ -151,8 +155,17 @@ export function presentInlinePermissionPrompt(
|
|
|
151
155
|
keys: view.dialogKeys,
|
|
152
156
|
};
|
|
153
157
|
return view.ui.custom<UnattributedDecision>(
|
|
154
|
-
(tui, theme, keybindings, done) =>
|
|
155
|
-
|
|
158
|
+
(tui, theme, keybindings, done) => {
|
|
159
|
+
// The factory runs once, as the dialog mounts, which is the moment the
|
|
160
|
+
// human is being asked; a re-render does not come back through here.
|
|
161
|
+
const notification = renderPromptNotification(
|
|
162
|
+
view.promptNotifications,
|
|
163
|
+
title,
|
|
164
|
+
);
|
|
165
|
+
if (notification) {
|
|
166
|
+
tui.terminal.write(notification);
|
|
167
|
+
}
|
|
168
|
+
return new PermissionPromptComponent(
|
|
156
169
|
theme,
|
|
157
170
|
config,
|
|
158
171
|
title,
|
|
@@ -163,7 +176,8 @@ export function presentInlinePermissionPrompt(
|
|
|
163
176
|
tui.requestRender();
|
|
164
177
|
},
|
|
165
178
|
done,
|
|
166
|
-
)
|
|
179
|
+
);
|
|
180
|
+
},
|
|
167
181
|
{ overlay: false },
|
|
168
182
|
);
|
|
169
183
|
}
|
|
@@ -198,6 +198,7 @@ function formatConfigIssues(error: ZodError): string[] {
|
|
|
198
198
|
* - Array fields (piInfrastructureReadPaths) replace the base when present in
|
|
199
199
|
* the override (override-wins, same as scalars).
|
|
200
200
|
* - `permissionDialogKeys` replaces the base map whole, unlike `shellTools`.
|
|
201
|
+
* - `promptNotifications` replaces the base list whole, like the arrays.
|
|
201
202
|
*/
|
|
202
203
|
// Scalar knobs merged by override-replaces-base; keep in sync with
|
|
203
204
|
// PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog,
|
|
@@ -254,6 +255,15 @@ export function mergeUnifiedConfigs(
|
|
|
254
255
|
merged.permissionDialogKeys = dialogKeys;
|
|
255
256
|
}
|
|
256
257
|
|
|
258
|
+
// promptNotifications: override replaces base, as the string arrays above
|
|
259
|
+
// do; its own block because a union-keyed write in that loop would have to
|
|
260
|
+
// satisfy both element types at once.
|
|
261
|
+
const notifications =
|
|
262
|
+
override.promptNotifications ?? base.promptNotifications;
|
|
263
|
+
if (notifications !== undefined) {
|
|
264
|
+
merged.promptNotifications = notifications;
|
|
265
|
+
}
|
|
266
|
+
|
|
257
267
|
// shellTools: shallow-merge by tool name so a project entry overrides a
|
|
258
268
|
// colliding tool's alias but never drops a global entry (a dropped alias is
|
|
259
269
|
// a silent enforcement regression).
|
|
@@ -291,6 +291,8 @@ const dialogKeysSchema = z
|
|
|
291
291
|
],
|
|
292
292
|
});
|
|
293
293
|
|
|
294
|
+
const promptNotificationChannelSchema = z.enum(["bell", "osc9", "osc777"]);
|
|
295
|
+
|
|
294
296
|
const shellToolAliasSchema = z
|
|
295
297
|
.strictObject({
|
|
296
298
|
commandArgument: z.string().min(1).meta({
|
|
@@ -369,6 +371,16 @@ export const unifiedConfigSchema = z
|
|
|
369
371
|
default: true,
|
|
370
372
|
}),
|
|
371
373
|
permissionDialogKeys: dialogKeysSchema.optional(),
|
|
374
|
+
promptNotifications: z
|
|
375
|
+
.array(promptNotificationChannelSchema)
|
|
376
|
+
.optional()
|
|
377
|
+
.meta({
|
|
378
|
+
description:
|
|
379
|
+
"Terminal notifications to emit when the inline permission dialog opens: bell (BEL), osc9 (OSC 9 notification), osc777 (OSC 777 notification). Omit or leave empty for none. TUI sessions only.",
|
|
380
|
+
markdownDescription:
|
|
381
|
+
'Terminal notifications to emit when the inline permission dialog opens, so a terminal or multiplexer can flag a session waiting on a decision.\n\n- `"bell"` writes a BEL — what tmux `monitor-bell` and most terminals react to.\n- `"osc9"` writes an OSC 9 desktop notification.\n- `"osc777"` writes an OSC 777 desktop notification.\n\nOmit or leave empty for none (the default). Applies to interactive **TUI** sessions only; the notification text is the dialog title, never the request itself.',
|
|
382
|
+
default: [],
|
|
383
|
+
}),
|
|
372
384
|
forwardingTimeoutMs: z.number().int().min(1).optional().meta({
|
|
373
385
|
description:
|
|
374
386
|
"How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
|
|
@@ -454,6 +466,11 @@ export type ShellToolsConfig = z.infer<typeof shellToolsSchema>;
|
|
|
454
466
|
/** The raw config file shape after validation (all fields optional). */
|
|
455
467
|
export type UnifiedPermissionConfig = z.infer<typeof unifiedConfigSchema>;
|
|
456
468
|
|
|
469
|
+
/** One terminal notification the inline dialog emits as it opens. */
|
|
470
|
+
export type PromptNotificationChannel = z.infer<
|
|
471
|
+
typeof promptNotificationChannelSchema
|
|
472
|
+
>;
|
|
473
|
+
|
|
457
474
|
/**
|
|
458
475
|
* Derive the published JSON Schema (Draft 2020-12) from the zod source.
|
|
459
476
|
*
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ShellToolsConfig,
|
|
10
10
|
UnifiedPermissionConfig,
|
|
11
11
|
} from "./config-loader";
|
|
12
|
+
import type { PromptNotificationChannel } from "./config-schema";
|
|
12
13
|
import type { DialogKeyOverrides } from "./dialog-keys";
|
|
13
14
|
|
|
14
15
|
export const EXTENSION_ID = "pi-permission-system";
|
|
@@ -31,6 +32,8 @@ export interface PermissionSystemExtensionConfig {
|
|
|
31
32
|
reviewLogFieldMaxWidth?: number;
|
|
32
33
|
/** The inline dialog's hotkey bindings, as configured; resolved at prompt time. */
|
|
33
34
|
permissionDialogKeys?: DialogKeyOverrides;
|
|
35
|
+
/** Terminal notifications the inline dialog emits as it opens; absent means none. */
|
|
36
|
+
promptNotifications?: PromptNotificationChannel[];
|
|
34
37
|
/** Non-bash tools that carry shell semantics, keyed by tool name. */
|
|
35
38
|
shellTools?: ShellToolsConfig;
|
|
36
39
|
/** Ordered names of registered live-authority chain links to consult before the terminal authorizer. */
|
|
@@ -97,6 +100,9 @@ export function normalizePermissionSystemConfig(
|
|
|
97
100
|
if (raw.permissionDialogKeys !== undefined) {
|
|
98
101
|
result.permissionDialogKeys = raw.permissionDialogKeys;
|
|
99
102
|
}
|
|
103
|
+
if (raw.promptNotifications !== undefined) {
|
|
104
|
+
result.promptNotifications = raw.promptNotifications;
|
|
105
|
+
}
|
|
100
106
|
if (raw.shellTools !== undefined) {
|
|
101
107
|
result.shellTools = raw.shellTools;
|
|
102
108
|
}
|
package/src/index.ts
CHANGED
|
@@ -160,6 +160,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
160
160
|
doublePressToConfirm: configStore.current().doublePressToConfirm,
|
|
161
161
|
budget: resolveRenderBudget(configStore.current()),
|
|
162
162
|
dialogKeys: resolveDialogKeys(configStore.current()).keys,
|
|
163
|
+
promptNotifications: configStore.current().promptNotifications ?? [],
|
|
163
164
|
}),
|
|
164
165
|
requestPermissionDecision,
|
|
165
166
|
forwardingDir: paths.forwardingDir,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { PromptNotificationChannel } from "#src/config/config-schema";
|
|
2
|
+
|
|
3
|
+
const BEL = "\x07";
|
|
4
|
+
const OSC = "\x1b]";
|
|
5
|
+
|
|
6
|
+
/** The OSC 777 title; the body carries the dialog title. */
|
|
7
|
+
const OSC_777_TITLE = "pi";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The bytes that ask a terminal for attention as a permission dialog opens.
|
|
11
|
+
*
|
|
12
|
+
* One sequence per configured channel, in configured order; an empty list
|
|
13
|
+
* renders nothing. The message is stripped of control characters, so it cannot
|
|
14
|
+
* end a sequence early or start another one.
|
|
15
|
+
*/
|
|
16
|
+
export function renderPromptNotification(
|
|
17
|
+
channels: readonly PromptNotificationChannel[],
|
|
18
|
+
message: string,
|
|
19
|
+
): string {
|
|
20
|
+
const text = withoutControlCharacters(message);
|
|
21
|
+
return channels.map((channel) => renderChannel(channel, text)).join("");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderChannel(
|
|
25
|
+
channel: PromptNotificationChannel,
|
|
26
|
+
text: string,
|
|
27
|
+
): string {
|
|
28
|
+
switch (channel) {
|
|
29
|
+
case "bell":
|
|
30
|
+
return BEL;
|
|
31
|
+
case "osc9":
|
|
32
|
+
return `${OSC}9;${text}${BEL}`;
|
|
33
|
+
case "osc777":
|
|
34
|
+
// `;` separates OSC 777's fields, so one in the body would split it.
|
|
35
|
+
return `${OSC}777;notify;${OSC_777_TITLE};${text.replaceAll(";", ":")}${BEL}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function withoutControlCharacters(value: string): string {
|
|
40
|
+
// Code points, not grapheme clusters: a control character is always one.
|
|
41
|
+
return Array.from(value)
|
|
42
|
+
.filter((character) => {
|
|
43
|
+
const code = character.codePointAt(0) ?? 0;
|
|
44
|
+
return code > 31 && code !== 127;
|
|
45
|
+
})
|
|
46
|
+
.join("");
|
|
47
|
+
}
|