A prompt answered from the notification reads both surfaces; the terminal still shows the keys it takes, which a button cannot say.
8.2 KiB
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.
Configuration
[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.
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.
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.
Desktop channel
The desktop channel needs the optional extra:
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.
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.