diff --git a/AGENTS.md b/AGENTS.md index d482b15..1070d4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, 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. +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, *, body, facts, expires_in)`: the short `title`, the `body` it asks (a question, whole), the gated `detail` (a reason that can name a command or a URL), `facts` (the object — a program or file name, never an argument), and the `expires_in` after which its default answer takes over. It 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. The body is filled in reading order (question, reason, object, deadline) until the budget is gone, so a long reason drops out before the deadline does; the desktop keeps the lines apart and the terminal flattens them. A click on the notification's body is the platform's default action and means "back to the terminal" (a reply prompt offers that button as well), and a `secret` prompt is never announced. ### CLI (`cli/`) diff --git a/README.md b/README.md index 43883f8..a637d9a 100644 --- a/README.md +++ b/README.md @@ -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 `/.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 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). +**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 announcement says what is asked: a question travels whole in the body, a confirmation names the program or the file it is about (`program: rm`, `target: todo.md` — never the arguments), and a prompt that denies itself on timeout says so (`no answer in 30s = deny`). Clicking the notification's body goes back to the terminal like its **Go to terminal** button; a secret is never announced. 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). diff --git a/doc/notifications.md b/doc/notifications.md index 29d9b5d..c8f5f7e 100644 --- a/doc/notifications.md +++ b/doc/notifications.md @@ -5,12 +5,23 @@ 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`. The title is the prompt line and the -body is nothing but that reason — a channel gives the title a field of its own, -so repeating it in the body would only say the same thing twice. +`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 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 does — the deadline is +the one line a human must not miss, because not answering *is* an answer there. +A desktop popup wraps and keeps the lines apart; the terminal channel carries +the same body on a single line. ## Configuration @@ -40,19 +51,25 @@ a prompt answered inside the grace period is never announced at all. | 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) | +| `ask_user_line` | a reply field, plus Go to terminal — 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 / Go to terminal. "Go to terminal" is always the last button: it withdraws the -notification and leaves the prompt waiting. +notification and leaves the prompt waiting — and so does a click on the +notification's body, which is the platform's own default action, so a +notification with no room for buttons still has that way back. 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. A secret -is never asked for in a notification, and never echoed into one. +extra answer (this chat, stop asking) offers it as a button of its own. Text +typed into a reply field is an answer even when it spells a button's key +(`review` to a question is the word, not the button). 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 diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 2e713a8..3f2a799 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -53,9 +53,15 @@ # "desktop" / "terminal" pin one of them. # `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. +# stays text-only. Clicking a notification's body asks for no answer: it takes you +# back to the terminal, like the "Go to terminal" button. +# The body says what is asked: a question or a choice travels whole (the title is +# only a short label), a confirmation names the program or file it is about, and a +# timed prompt says when silence answers ("no answer in 30s = deny", which is a +# deny for the denylist, directory-access and skill-write prompts). # `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). +# it stays off by default (a notification may be mirrored to a lock screen). A +# secret is never announced. # [notify] # enabled = true # grace_seconds = 5.0