docs: describe the notification that stays and the answers it offers

The table of what a notification answers was out of date: the limit prompt and
the batch confirm now offer their own answers, and a skill write distinguishes
"once" from "this chat". The desktop section now says what actually keeps the
notification up (no expiry, urgency at the default) instead of naming `critical`,
and the follow-up rule — announced at once, past the grace period — is written
where a reader looks for why a prompt appeared without waiting.
This commit is contained in:
2026-10-24 01:48:47 +08:00
parent 3631af2d60
commit abd8c4d805
4 changed files with 37 additions and 13 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable backend; non-TTY uses defaults or errors. CLI limit/confirm hooks and chat tools both use this. Async helpers serialize prompts (`run_prompt_async`).
Three process-wide hooks let a host take over a prompt: `set_prompt_output_hook` (break an open pretty line before the prompt draws), `configure_prompting(pause_factory=…)` (the SIGINT guard around an off-loop read), and `set_prompt_wait_hook` — a prompt that blocks announces itself through `prompt_wait(kind, title, detail)`, which yields the `PromptAnswerSource` the read may consult (`take()` returns `allow` / `deny` / reply text without blocking) or `None` for notify-only. Only the outermost prompt of a nested read is announced; with no hook installed nothing changes. A source that *will* offer an answer says so through `supports_actions` before it offers it, so the read polls from its first moment instead of deciding once. `read_line_with_timeout` accepts a `source` and a `None` deadline, and stdin always wins over a host answer. The CLI's implementation is `cli/notify.py` (`[notify]`, desktop via the optional `desktop-notifier` extra, terminal via OSC 777/9 to `/dev/tty`): a prompt is announced only after `grace_seconds`, the answer is treated like a typed one (never a session grant), and every failure falls through to the next channel instead of raising.
Three process-wide hooks let a host take over a prompt: `set_prompt_output_hook` (break an open pretty line before the prompt draws), `configure_prompting(pause_factory=…)` (the SIGINT guard around an off-loop read), and `set_prompt_wait_hook` — a prompt that blocks announces itself through `prompt_wait(kind, title, detail, choices)`, which yields the `PromptAnswerSource` the read may consult (`take()` returns `allow` / `deny` / reply text, or any key the prompt named in `choices`, without blocking) or `None` for notify-only. Only the outermost prompt of a nested read is announced; with no hook installed nothing changes. A source that *will* offer an answer says so through `supports_actions` before it offers it, so the read polls from its first moment instead of deciding once. `read_line_with_timeout` accepts a `source` and a `None` deadline, and stdin always wins over a host answer. The CLI's implementation is `cli/notify.py` (`[notify]`, desktop via the optional `desktop-notifier` extra, terminal via OSC 777/9 to `/dev/tty`): a prompt is announced only after `grace_seconds` — except one that follows an answer given on the notification, which goes out at once and past the burst throttle — `choices` become the notification's buttons (plus "Go to terminal"), the answer is treated like a typed one (never a session grant), the notification is sent without expiry (`timeout=0`, urgency at the library default) so it stays on screen until it is dismissed, and every failure falls through to the next channel instead of raising.
### CLI (`cli/`)
+1 -1
View File
@@ -206,7 +206,7 @@ Third-party **plugins**: install a package that declares `project.entry-points."
**Skills** (instruction directories with a `SKILL.md`): discovered under `[skills].paths`, our own user skills directory, and other harnesses' skill directories (`~/.claude/skills`, plus `<workspace>/.claude/skills` when `include_project_roots`). A session-start notice (a host notice, never a system-prompt edit) tells the model that the skill tools exist and how to use them — `skill_list` to see what exists, `skill_read` for a `SKILL.md`, `skill_search` for content — and deliberately lists nothing; `skill_create` / `skill_edit` write one behind a timed `once`/`session`/`no` grant (`[skills].allow_write = true` for a standing one; the path denylist still wins and `--yes`/`/yolo` never skips it). Reading needs no grant: skill roots are read-only roots, so `read_file` / `regex_files` see them too while writes stay denied. Details: [doc/skills.md](doc/skills.md).
**Notifications while a prompt waits**: a turn stops at a confirm, a directory-access request or an `ask_user_*` question, so if you walked away you only find out on your return. After `[notify].grace_seconds` (5s) the prompt is announced — with **Allow once / Deny / Go to terminal** buttons for a confirmation and a reply field for a question, where the platform renders them — and the answer is taken as if typed at the terminal (never as a session grant). The desktop channel needs the optional extra (`pip install 'plyngent[notify]'`); without it the CLI writes an OSC 777 / OSC 9 escape sequence to `/dev/tty`, which every modern terminal turns into a native notification with no dependency at all. Answering at the terminal inside the grace period stays silent, `[notify].include_reason` keeps the reason line (a command, argv or URL) out by default, and no channel can break a prompt: a missing extra or a refusing desktop falls through to the next one. Details: [doc/notifications.md](doc/notifications.md).
**Notifications while a prompt waits**: a turn stops at a confirm, a directory-access request or an `ask_user_*` question, so if you walked away you only find out on your return. After `[notify].grace_seconds` (5s) the prompt is announced — with the answers that prompt accepts as buttons where the platform renders them (a confirmation: **Allow once / Deny / Go to terminal**; a limit prompt: **Continue / Continue, stop asking / Stop here**; a skill write: **Allow once / This chat / Deny**) and a reply field for a question — and the answer is taken as if typed at the terminal (never as a session grant). The desktop channel needs the optional extra (`pip install 'plyngent[notify]'`); without it the CLI writes an OSC 777 / OSC 9 escape sequence to `/dev/tty`, which every modern terminal turns into a native notification with no dependency at all. Answering at the terminal inside the grace period stays silent, and a prompt that follows an answer given on the notification is announced at once; `[notify].include_reason` keeps the reason line (a command, argv or URL) out by default; the notification is kept on screen (no expiry) until the prompt is answered instead of collapsing into the tray; and no channel can break a prompt: a missing extra or a refusing desktop falls through to the next one. Details: [doc/notifications.md](doc/notifications.md).
**Deletes go to the trash**: `delete_path` moves the path into the desktop's own trash — the freedesktop trash on Linux, the Finder trash on macOS, the Recycle Bin on Windows — so a delete the agent made on its own can be undone from the file manager you already use. That matters because the tool is YOLO-tagged: under `--yes` / `/yolo on` it runs without a confirmation. Ask for the old behaviour with `permanent=true`, or turn it off for every call with `[trash] enabled = false`. Paths under the system temp directory are scratch and are deleted outright, a trashing that fails (a volume with no usable trash directory, a broken install) leaves the file alone and says so instead of deleting it, and a shell `rm` never uses the trash. Details: [doc/trash.md](doc/trash.md).
+29 -7
View File
@@ -32,18 +32,25 @@ a prompt answered inside the grace period is never announced at all.
| Prompt | Notification offers |
|---|---|
| tool confirm (single / a round's batch) | Allow once / Deny / Go to terminal |
| command denylist, `fetch_local` private host | same three |
| directory-access request, skill write | same three |
| tool confirm (single) | Allow once / Deny / Go to terminal |
| a round's batch of confirms | Allow all / Review one by one / Deny / Go to terminal |
| command denylist, `fetch_local` private host | Allow once / Deny / Go to terminal |
| directory-access request | same three (the level asked for, not a lower one) |
| skill write | Allow once / This chat / Deny / Go to terminal |
| limit-continue | Continue / Continue, stop asking / Stop here / Go to terminal |
| `ask_user_line` | a reply field — the answer *is* the text you type |
| `ask_user_choice`, `ask_user_form`, PTY secrets | nothing (terminal only) |
| limit-continue | nothing (terminal only) |
Each prompt says which answers it accepts (`prompt_wait(..., choices=…)`), and
those are the buttons; one that names none gets Allow once / Deny / Go to
terminal. "Go to terminal" is always the last button: it withdraws the
notification and leaves the prompt waiting.
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". "Go to terminal" withdraws the
notification and leaves the prompt waiting. A secret is never asked for in a
notification, and never echoed into one.
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. A secret
is never asked for in a notification, and never echoed into one.
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
@@ -51,6 +58,11 @@ blocks, from the first moment — the notification itself only shows up after
terminal alone. 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:
@@ -70,6 +82,16 @@ 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. 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`
+6 -4
View File
@@ -45,13 +45,15 @@
# Notifications while a prompt waits on the human: a tool confirmation, a
# directory-access grant, a skill write, an ``ask_user_*`` question, or the
# limit-continue prompt. A prompt is announced only after `grace_seconds` of
# waiting, so one you answer at the terminal stays silent; `min_interval_seconds`
# throttles bursts.
# waiting, so one you answer at the terminal stays silent — except a prompt that
# follows an answer given on the notification, which is announced at once;
# `min_interval_seconds` throttles bursts.
# channels: "auto" tries the desktop first (needs the `notify` extra:
# `pip install plyngent[notify]`) and falls back to a terminal escape sequence;
# "desktop" / "terminal" pin one of them.
# `actions` offers allow / deny / review as notification buttons; where the
# platform has none the notification stays text-only.
# `actions` offers the answers the prompt accepts as notification buttons (allow /
# deny / review when it names none); where the platform has none the notification
# stays text-only.
# `include_reason` adds the one-line reason — it can name a command or a URL, so
# it stays off by default (a notification may be mirrored to a lock screen).
# [notify]