Files

154 lines
8.2 KiB
Markdown
Raw Permalink Normal View History

2026-10-21 20:03:36 +08:00
# Prompt notifications
A turn stops while a prompt waits on the human. If you switched to another
window, the wait is invisible until you come back — so the CLI announces it.
A prompt is announced only after `grace_seconds` of waiting (5s by default), so
answering at the terminal stays silent. Bursts are throttled by
`min_interval_seconds`.
A notification says what is asked, not what would be run. The title is a short
label; the body carries, in this order:
| Line | When |
|---|---|
| the question itself | an `ask_user_line` question, an `ask_user_choice` prompt |
| the reason line | only with `include_reason = true`: it can name a command, argv or URL, and a notification outlives the prompt (and may be mirrored to a lock screen) |
| the object | a confirmation or a batch: the program's name (`program: rm`), the file's (`target: todo.md`), the tools of a batch, `kind: directory` for an access request, the skill directory — never an argument, a URL or a piece of code |
| `no answer in 30s = deny` | a prompt that takes a default answer on timeout (command denylist, directory access, skill write) |
| the workspace | always: the directory the chat runs in, with the home directory shown as `~` (`~/Projects/plyngent`), so several waiting chats are told apart — it follows a session resumed in another workspace |
The lines are filled in that order until the body is full and each one is
capped, so a long reason drops out before the deadline and the workspace do.
The deadline is the one line a human must not miss, because not answering *is*
an answer there; the workspace comes last and is kept for the same reason. A
desktop popup wraps and keeps the lines apart; the terminal channel carries the
same body on a single line.
2026-10-21 20:03:36 +08:00
## Configuration
```toml
[notify]
enabled = true
grace_seconds = 5.0
min_interval_seconds = 10.0
channels = ["auto"] # auto | desktop | terminal
actions = true # offer the answer, not just the news
include_reason = false
protocol = "" # terminal protocol override: osc777 / osc9 / bell
```
`channels = ["auto"]` tries the desktop first and falls back to the terminal;
`["desktop"]` and `["terminal"]` pin one of them. Notifications are only sent
from an interactive run — a one-shot `plyngent "…"` never waits on a human, and
a prompt answered inside the grace period is never announced at all.
## What is answered where
| Prompt | Notification offers |
|---|---|
| tool confirm (single) | Allow once / Deny |
| a round's batch of confirms | Allow all / Review one by one / Deny |
| command denylist, `fetch_local` private host | Allow once / Deny |
| directory-access request | same two (the level asked for, not a lower one) |
| skill write | Allow once / This chat / Deny |
| limit-continue | Continue / Continue, stop asking / Stop here |
| `ask_user_line` | a reply field — the answer *is* the text you type |
| `ask_user_choice`, `ask_user_form` | nothing (terminal only): the question and its options are in the body |
| PTY secrets | nothing at all: a secret prompt is never announced |
Each prompt says which answers it accepts (`prompt_wait(..., choices=…)`), and
those are the buttons; one that names none gets Allow once / Deny. There is no
"go to terminal" button: it could not do what it says — no desktop lets a
program raise another application's window (Wayland has no such call, the X11
ones need a helper per window manager) — and the popup's own close button does
as much. Clicking the body (the platform's default action) or closing the popup
withdraws the notification and leaves the prompt waiting; the prompt is not
announced a second time.
What says *where* to look is the bell: every notification that went to the
desktop also rings the terminal's bell. The announcement only happens when
nobody answered inside the grace period, so the human is at some other window —
possibly another virtual desktop, where a popup is easy to miss — and the bell
is the only portable cue. It is the terminal's own bell, so a terminal with its
bell switched off stays silent.
2026-10-21 20:03:36 +08:00
An answer read from the notification is treated exactly like a typed one —
`allow` is the `y` the prompt accepts, `deny` is `n` — and it never records a
session grant: the button means "this once", which is why a prompt with an
extra answer (this chat, stop asking) offers it as a button of its own. Text
typed into a reply field is the answer, whatever it spells. A secret prompt is
announced nowhere and echoable from nothing: its value would have to travel
back through a surface that outlives it.
2026-10-21 20:03:36 +08:00
A prompt that can be answered from the notification reads both surfaces while it
blocks, from the first moment — the notification itself only shows up after
`grace_seconds`, so waiting for it to appear before listening would block on the
terminal alone. The terminal keeps its own prompt line with the keys it accepts —
a button elsewhere does not say what typing here would do. Typing at the terminal
always wins; a readline-only convenience
(Tab completion on `ask_user_line`) is what a question gives up in exchange.
A prompt that *follows* an answer given on the notification — the "why denied"
comment, the per-call prompts after "Review one by one" — is announced at once,
with no grace period and past the burst throttle: the human is looking at the
notification, and the follow-up is the next thing they must answer.
2026-10-21 20:03:36 +08:00
## Desktop channel
The desktop channel needs the optional extra:
```console
pip install 'plyngent[notify]'
```
It wraps `desktop-notifier`, which talks to `org.freedesktop.Notifications` on
Linux, Notification Center on macOS and WinRT on Windows. Buttons need a
notification server that renders them (KDE Plasma and GNOME Shell do; a bare
`dunst` may not, and then the notification is text-only without any error). The
reply field is KDE-only in practice: it is the `inline-reply` capability plus the
`x-kde-reply-submit-button-text` hint, which only KNotifications implements.
Notifications are shown from the library's async API on a loop that runs in a
daemon thread, because callbacks only fire while the loop that owns the
notification is running.
The notification stays on screen: it is sent with no expiry (`timeout = 0`, the
freedesktop value for "until dismissed"), so the server does not slide it into
the tray a few seconds later. That is the point of it — the turn is blocked
until the prompt is answered, and a collapsed notification is one the human
would find only on coming back anyway. A prompt that gives up on its own is the
exception: the popup is sent with its deadline as its timeout (30 seconds for a
prompt that denies after 30s), so the notification disappears when the answer
window closes instead of announcing a prompt that is already gone. Urgency stays
at the library default: on KDE Plasma, `critical` keeps the notification up just
the same but paints the popup as urgent, and on every platform it asks the Focus
mode to let it through — which a prompt has no business doing. Once the prompt
is answered, the notification is dismissed.
2026-10-21 20:03:36 +08:00
## Terminal channel
No dependency and no extra: the CLI writes an escape sequence to `/dev/tty`
(never stdout, so a redirect cannot swallow it) and the terminal turns it into a
native notification.
| Protocol | Sequence | Terminals |
|---|---|---|
| OSC 777 | `ESC ] 777 ; notify ; title ; body` | Ghostty, WezTerm, Warp, Contour, VTE (GNOME Terminal, Tilix, Terminator), rxvt, Windows Terminal |
| OSC 9 | `ESC ] 9 ; title: body` | iTerm2, Apple Terminal, ConEmu |
| bell | `BEL` | everything else |
Detection reads the environment only (`TERM_PROGRAM`, `TERM`, `WT_SESSION`,
`VTE_VERSION`), so it never blocks and never touches the input stream;
`protocol` pins it. Inside tmux the sequence is wrapped in DCS passthrough —
tmux needs `set -g allow-passthrough on` — and inside GNU Screen in a Screen
DCS. kitty speaks OSC 99 only, which is not implemented, so it keeps the bell;
install the extra to reach it through the desktop instead.
## Failure is always silent
A missing extra, a desktop without a notification server, a refused write: each
falls through to the next channel and never raises into the prompt. There is no
sound, no remote push and no notification when a turn merely ends — only when a
prompt is waiting for you.