@gotgenes/pi-permission-system 36.0.0 → 36.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 +22 -0
- package/README.md +1 -0
- package/config/config.example.json +1 -0
- package/docs/configuration.md +45 -0
- package/docs/cross-extension-api.md +33 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +10 -0
- package/src/authority/authorizer.ts +4 -0
- package/src/authority/local-user-authorizer.ts +15 -3
- package/src/authority/permission-prompt-component.ts +22 -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 +108 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ 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.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.1.0...pi-permission-system-v36.2.0) (2026-09-30)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* name the session and the requested tool in prompt notifications ([#996](https://github.com/gotgenes/pi-packages/issues/996)) ([eb49c64](https://github.com/gotgenes/pi-packages/commit/eb49c6494d12749f71130d74b75c578530c89afa)), closes [#996](https://github.com/gotgenes/pi-packages/issues/996)
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
* describe the session and tool in prompt notifications ([#996](https://github.com/gotgenes/pi-packages/issues/996)) ([c5c74db](https://github.com/gotgenes/pi-packages/commit/c5c74db43235297a39bbe94303225ac50baf0ca4))
|
|
18
|
+
|
|
19
|
+
## [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)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
* **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)
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
* **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)
|
|
29
|
+
|
|
8
30
|
## [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)
|
|
9
31
|
|
|
10
32
|
|
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,49 @@ 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 names the session and what is being asked, so it tells you which pane to go to.
|
|
212
|
+
Its title is `pi — <session name>`, or `pi — <directory name>` for a session without a name.
|
|
213
|
+
Its body is the dialog title followed by the tool (or, for an ask that is not a tool call, the gate surface) and the requesting agent when there is one:
|
|
214
|
+
|
|
215
|
+
| Ask | `osc777` title | `osc777` body |
|
|
216
|
+
| ------------------------------------------- | -------------------- | ---------------------------------------------- |
|
|
217
|
+
| local `bash` ask in session `refactor-auth` | `pi — refactor-auth` | `Permission Required: bash` |
|
|
218
|
+
| `read` forwarded from subagent `scout` | `pi — refactor-auth` | `Permission Required (Subagent): read (scout)` |
|
|
219
|
+
|
|
220
|
+
OSC 9 has a single text field, so `osc9` writes the title ahead of the body: `pi — refactor-auth: Permission Required: bash`.
|
|
221
|
+
The notification carries labels only, never the command, path, MCP target, or skill being decided, so nothing the request holds lands in your notification history.
|
|
222
|
+
|
|
223
|
+
The signal fires once per prompt, when the prompt is actually shown.
|
|
224
|
+
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.
|
|
225
|
+
An ask decided without a dialog (by a rule, a session approval, or an authorizer link) emits nothing.
|
|
226
|
+
Only interactive TUI sessions signal; outside interactive mode Pi routes stdout away from the terminal, so there is nothing to ring.
|
|
227
|
+
|
|
228
|
+
Inside tmux, prefer `"bell"`: tmux passes a BEL through to its own bell handling, but drops OSC notifications unless passthrough is enabled.
|
|
229
|
+
|
|
230
|
+
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
|
+
|
|
187
232
|
### What a prompt shows
|
|
188
233
|
|
|
189
234
|
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.
|
|
@@ -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 names the session and the requested tool and agent, never the command, path, or other value being decided.",
|
|
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.",
|
|
@@ -158,6 +158,10 @@ export function selectAuthorizer(
|
|
|
158
158
|
dialogs: deps.dialogs,
|
|
159
159
|
getPromptPreferences: deps.getPromptPreferences,
|
|
160
160
|
requestPermissionDecision: deps.requestPermissionDecision,
|
|
161
|
+
describeSession: () => ({
|
|
162
|
+
name: ctx.sessionManager.getSessionName(),
|
|
163
|
+
cwd: ctx.cwd,
|
|
164
|
+
}),
|
|
161
165
|
}),
|
|
162
166
|
adjudicatesLocally: true,
|
|
163
167
|
};
|
|
@@ -4,6 +4,10 @@ import {
|
|
|
4
4
|
buildForwardedScopeLabels,
|
|
5
5
|
describeGrantTarget,
|
|
6
6
|
} from "#src/presentation/pattern-suggest";
|
|
7
|
+
import {
|
|
8
|
+
describePromptNotice,
|
|
9
|
+
type NotificationSession,
|
|
10
|
+
} from "#src/presentation/prompt-notification";
|
|
7
11
|
import {
|
|
8
12
|
emitUiPromptEvent,
|
|
9
13
|
type PermissionEventBus,
|
|
@@ -37,6 +41,8 @@ export interface LocalUserAuthorizerDeps {
|
|
|
37
41
|
getPromptPreferences: () => PromptPreferences;
|
|
38
42
|
/** Injected for testability; production callers pass the real function. */
|
|
39
43
|
requestPermissionDecision: typeof requestPermissionDecision;
|
|
44
|
+
/** Read when each prompt opens, so a session named mid-session is picked up. */
|
|
45
|
+
describeSession: () => NotificationSession;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/**
|
|
@@ -77,15 +83,21 @@ export class LocalUserAuthorizer implements TerminalAuthorizer {
|
|
|
77
83
|
details: PromptPermissionDetails,
|
|
78
84
|
): Promise<PermissionPromptDecision> {
|
|
79
85
|
emitUiPromptEvent(this.deps.events, buildUiPrompt(details));
|
|
86
|
+
const title = details.forwarding
|
|
87
|
+
? "Permission Required (Subagent)"
|
|
88
|
+
: "Permission Required";
|
|
80
89
|
return this.deps.requestPermissionDecision(
|
|
81
90
|
{
|
|
82
91
|
mode: this.deps.mode,
|
|
83
92
|
ui: this.deps.ui,
|
|
84
93
|
...this.deps.getPromptPreferences(),
|
|
94
|
+
notice: describePromptNotice(
|
|
95
|
+
title,
|
|
96
|
+
details.payload.request,
|
|
97
|
+
this.deps.describeSession(),
|
|
98
|
+
),
|
|
85
99
|
},
|
|
86
|
-
|
|
87
|
-
? "Permission Required (Subagent)"
|
|
88
|
-
: "Permission Required",
|
|
100
|
+
title,
|
|
89
101
|
details.payload,
|
|
90
102
|
buildRequestOptions(details),
|
|
91
103
|
);
|
|
@@ -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,10 @@ import {
|
|
|
17
18
|
renderPromptDialog,
|
|
18
19
|
} from "#src/presentation/dialog-renderer";
|
|
19
20
|
import { fitLinesToWidth } from "#src/presentation/line-fitting";
|
|
21
|
+
import {
|
|
22
|
+
type PromptNotice,
|
|
23
|
+
renderPromptNotification,
|
|
24
|
+
} from "#src/presentation/prompt-notification";
|
|
20
25
|
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
21
26
|
import { collapsePastedNewlines } from "./bracketed-paste";
|
|
22
27
|
import type { DecisionSource, UserDecisionSurface } from "./decision-source";
|
|
@@ -58,6 +63,8 @@ type PromptKeybindings = Pick<KeybindingsManager, "matches">;
|
|
|
58
63
|
export interface PermissionPromptView extends PromptPreferences {
|
|
59
64
|
mode: ExtensionContext["mode"];
|
|
60
65
|
ui: PermissionPromptUi;
|
|
66
|
+
/** What the inline dialog's terminal notification says; the fallback ignores it. */
|
|
67
|
+
notice: PromptNotice;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
/** Live prompt-behavior preferences read at prompt time (see `doublePressToConfirm`). */
|
|
@@ -67,6 +74,8 @@ export interface PromptPreferences {
|
|
|
67
74
|
budget: RenderBudget;
|
|
68
75
|
/** The character bound to each decision. */
|
|
69
76
|
dialogKeys: DialogKeyBindings;
|
|
77
|
+
/** Terminal notifications the inline dialog emits as it opens; empty for none. */
|
|
78
|
+
promptNotifications: readonly PromptNotificationChannel[];
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
/**
|
|
@@ -151,8 +160,17 @@ export function presentInlinePermissionPrompt(
|
|
|
151
160
|
keys: view.dialogKeys,
|
|
152
161
|
};
|
|
153
162
|
return view.ui.custom<UnattributedDecision>(
|
|
154
|
-
(tui, theme, keybindings, done) =>
|
|
155
|
-
|
|
163
|
+
(tui, theme, keybindings, done) => {
|
|
164
|
+
// The factory runs once, as the dialog mounts, which is the moment the
|
|
165
|
+
// human is being asked; a re-render does not come back through here.
|
|
166
|
+
const notification = renderPromptNotification(
|
|
167
|
+
view.promptNotifications,
|
|
168
|
+
view.notice,
|
|
169
|
+
);
|
|
170
|
+
if (notification) {
|
|
171
|
+
tui.terminal.write(notification);
|
|
172
|
+
}
|
|
173
|
+
return new PermissionPromptComponent(
|
|
156
174
|
theme,
|
|
157
175
|
config,
|
|
158
176
|
title,
|
|
@@ -163,7 +181,8 @@ export function presentInlinePermissionPrompt(
|
|
|
163
181
|
tui.requestRender();
|
|
164
182
|
},
|
|
165
183
|
done,
|
|
166
|
-
)
|
|
184
|
+
);
|
|
185
|
+
},
|
|
167
186
|
{ overlay: false },
|
|
168
187
|
);
|
|
169
188
|
}
|
|
@@ -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 names the session and the requested tool and agent, never the command, path, or other value being decided.',
|
|
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,108 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { PromptNotificationChannel } from "#src/config/config-schema";
|
|
3
|
+
import type { PromptRequestFacts } from "./prompt-payload";
|
|
4
|
+
|
|
5
|
+
const BEL = "\x07";
|
|
6
|
+
const OSC = "\x1b]";
|
|
7
|
+
|
|
8
|
+
/** What a prompt notification says: OSC 777 carries both fields. */
|
|
9
|
+
export interface PromptNotice {
|
|
10
|
+
readonly title: string;
|
|
11
|
+
readonly body: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The session facts a notice names, read when the prompt opens. */
|
|
15
|
+
export interface NotificationSession {
|
|
16
|
+
readonly name: string | undefined;
|
|
17
|
+
readonly cwd: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The request facts a notice may name: labels only, never the value. */
|
|
21
|
+
export type NoticeRequestFacts = Pick<
|
|
22
|
+
PromptRequestFacts,
|
|
23
|
+
"toolName" | "surface" | "requester"
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What a prompt notification says: which session is waiting, and what is
|
|
28
|
+
* being asked and by whom.
|
|
29
|
+
*
|
|
30
|
+
* Labels only. The request's value (the command, path, MCP target, or skill)
|
|
31
|
+
* never appears: the OS notification history is a store this package does not
|
|
32
|
+
* control, outside the owner-only logs and their key-name masking.
|
|
33
|
+
*/
|
|
34
|
+
export function describePromptNotice(
|
|
35
|
+
dialogTitle: string,
|
|
36
|
+
request: NoticeRequestFacts,
|
|
37
|
+
session: NotificationSession,
|
|
38
|
+
): PromptNotice {
|
|
39
|
+
const label = sessionLabel(session);
|
|
40
|
+
const subject = askSubject(request);
|
|
41
|
+
return {
|
|
42
|
+
title: label ? `pi — ${label}` : "pi",
|
|
43
|
+
body: subject ? `${dialogTitle}: ${subject}` : dialogTitle,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The session's name, or the name of the directory it runs in, as Pi's own terminal title does. */
|
|
48
|
+
function sessionLabel(session: NotificationSession): string {
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty name is unnamed
|
|
50
|
+
return session.name || basename(session.cwd);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What is asked (the tool, else the gate surface) and by which agent. */
|
|
54
|
+
function askSubject(request: NoticeRequestFacts): string {
|
|
55
|
+
const what = request.toolName ?? request.surface;
|
|
56
|
+
const who = request.requester.agentName;
|
|
57
|
+
if (what && who) return `${what} (${who})`;
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty label names nothing
|
|
59
|
+
return what || who || "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The bytes that ask a terminal for attention as a permission dialog opens.
|
|
64
|
+
*
|
|
65
|
+
* One sequence per configured channel, in configured order; an empty list
|
|
66
|
+
* renders nothing. Every field is stripped of control characters, so it cannot
|
|
67
|
+
* end a sequence early or start another one.
|
|
68
|
+
*/
|
|
69
|
+
export function renderPromptNotification(
|
|
70
|
+
channels: readonly PromptNotificationChannel[],
|
|
71
|
+
notice: PromptNotice,
|
|
72
|
+
): string {
|
|
73
|
+
const clean: PromptNotice = {
|
|
74
|
+
title: withoutControlCharacters(notice.title),
|
|
75
|
+
body: withoutControlCharacters(notice.body),
|
|
76
|
+
};
|
|
77
|
+
return channels.map((channel) => renderChannel(channel, clean)).join("");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderChannel(
|
|
81
|
+
channel: PromptNotificationChannel,
|
|
82
|
+
notice: PromptNotice,
|
|
83
|
+
): string {
|
|
84
|
+
switch (channel) {
|
|
85
|
+
case "bell":
|
|
86
|
+
return BEL;
|
|
87
|
+
case "osc9":
|
|
88
|
+
// One field, so the title leads it: it is what names the session.
|
|
89
|
+
return `${OSC}9;${notice.title}: ${notice.body}${BEL}`;
|
|
90
|
+
case "osc777":
|
|
91
|
+
return `${OSC}777;notify;${osc777Field(notice.title)};${osc777Field(notice.body)}${BEL}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `;` separates OSC 777's fields, so one inside a field would split it. */
|
|
96
|
+
function osc777Field(text: string): string {
|
|
97
|
+
return text.replaceAll(";", ":");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function withoutControlCharacters(value: string): string {
|
|
101
|
+
// Code points, not grapheme clusters: a control character is always one.
|
|
102
|
+
return Array.from(value)
|
|
103
|
+
.filter((character) => {
|
|
104
|
+
const code = character.codePointAt(0) ?? 0;
|
|
105
|
+
return code > 31 && code !== 127;
|
|
106
|
+
})
|
|
107
|
+
.join("");
|
|
108
|
+
}
|