From b197c98ce9221401fd44422d0c880b9fb1316493 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 21 Oct 2026 20:03:36 +0800 Subject: [PATCH] docs: describe prompt notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt that waits on the human is announced after a grace period, and the notification can carry the answer: allow / deny / review buttons for a confirmation, a reply field for an `ask_user_line`. `doc/notifications.md` lays out the two channels (the optional `desktop-notifier` extra, and OSC 777 / OSC 9 written to `/dev/tty` with no dependency), what each prompt kind offers, why the reason line stays out by default, and the platform limits — no buttons where a server does not render them, a reply field only under KDE, kitty left with the bell. The example config and the README config block gain the `[notify]` section, and AGENTS.md documents the three prompt hooks and the rule that only the outermost prompt of a nested read is announced. --- AGENTS.md | 2 + README.md | 11 +++++ doc/architecture.md | 8 +++- doc/notifications.md | 91 +++++++++++++++++++++++++++++++++++++++ doc/plyngent.example.toml | 21 +++++++++ 5 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 doc/notifications.md diff --git a/AGENTS.md b/AGENTS.md index 0cf5a99..1f953d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,8 @@ 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. `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. + ### CLI (`cli/`) Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`. diff --git a/README.md b/README.md index fc028b9..ab02824 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,15 @@ max_context_tokens = 200000 # enable = ["acme"] ``` +```toml +# Notifications while a prompt waits on you. See doc/notifications.md. +# [notify] +# grace_seconds = 5.0 # announce only after this long +# channels = ["auto"] # auto | desktop | terminal +# actions = true # allow / deny / review buttons + reply field +# include_reason = false # off: the reason may name a command or URL +``` + Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_argv`, PTY, policy confirm) are separate. **Thinking strength** (`reasoning_effort`) can be set on `[agent]`, on a provider, or on a single model: the most specific layer wins, per field, so a model may override only the effort or only the budget. `""` sends nothing and lets the provider apply its own default. Each API surface maps the value onto its own wire field — chat completions send `reasoning_effort`, Responses send `reasoning.effort`, and Anthropic has no effort level at all, so it sends `thinking` with the token budget that level maps onto (`minimal` 1024 … `xhigh`/`max` 32768; `none` disables thinking); a thinking block the model refuses is retried once with the shape it takes — adaptive thinking with the level itself as `output_config.effort` (from Claude 4.7 on), or no block at all where thinking cannot be turned off — and the refusal is remembered for the session, so `thinking_budget_tokens` is ignored on such a model. `max` is DeepSeek's own top level on chat completions and clamps to `xhigh` everywhere else. `thinking_budget_tokens` overrides that mapping; either way the request stays valid: the budget never drops below 1024, and when it would swallow the output limit `max_tokens` grows so the answer still has room. DeepSeek chat keeps `thinking.type = "enabled"` unless the effort is `none`. @@ -193,6 +202,8 @@ 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 `/.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). + **MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp____` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. A server may also return optional `instructions` (usage guidance) in its MCP `initialize` response; when tools are on those are folded into the agent system prompt as `MCP server instructions:` blocks (`[agent] mcp_instructions = false` disables), and `/mcp` previews them. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones). Supported provider presets today: diff --git a/doc/architecture.md b/doc/architecture.md index d3f037b..f500fca 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -32,8 +32,10 @@ ``@tool`` / tags / registry. History stays chat-completions-shaped. - tools: Workspace file/process/VCS/chat/todo/net/skill tools + directory-access grants (`access`); catalog; plugins; instance/session context and views. - - prompting: Shared ask/choose/form for CLI and tools. - - cli: Click entry, slash registry, REPL, one-shot chat. + - prompting: Shared ask/choose/form for CLI and tools; the hooks a host uses + to take over a prompt (output break, SIGINT guard, waiting notification). + - cli: Click entry, slash registry, REPL, one-shot chat; `notify` announces a + prompt that waits on the human. - web: Web service (Phase H; not implemented). Plugins (third-party entry points, allowlisted under ``[plugins]``): [plugins.md](./plugins.md). @@ -41,4 +43,6 @@ Conversation units (a *round* = one request→response plus its tool batch, a *turn* = a user message to the explicit stop): [turns-and-rounds.md](./turns-and-rounds.md). Images (a turn's pictures, a tool result's pictures, what each wire surface takes): [images.md](./images.md). +Prompt notifications (a prompt that waits on the human, and how it can be +answered from the notification): [notifications.md](./notifications.md). diff --git a/doc/notifications.md b/doc/notifications.md new file mode 100644 index 0000000..4cda01a --- /dev/null +++ b/doc/notifications.md @@ -0,0 +1,91 @@ +# 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`, and the announcement says what is being asked, not what +it would run: the one-line reason can name a command, argv or URL, and a +notification outlives the prompt (and may be mirrored to a lock screen), so it +travels only with `include_reason = true`. + +## 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 / 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 | +| `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) | + +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. + +## 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. + +## 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. diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 71688f7..3579466 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -42,6 +42,27 @@ # include_project_roots = true # allow_write = false +# 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. +# 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. +# `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] +# enabled = true +# grace_seconds = 5.0 +# min_interval_seconds = 10.0 +# channels = ["auto"] +# actions = true +# include_reason = false +# protocol = "" # terminal protocol: "" = detect, or osc777 / osc9 / bell + # MCP (Model Context Protocol) servers exposed as agent tools. # Defining a server opts it in; tools appear as mcp____ with # LOCAL tags (confirm flow applies). Names in disable stay disconnected.