diff --git a/AGENTS.md b/AGENTS.md index 52b3f18..cd2071e 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, *, 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) until the budget is gone, with the deadline and the workspace — the directory the chat runs in, `~` for home — kept for last, so a long reason drops out before they do; the desktop keeps the lines apart and the terminal flattens them. A popup is sent with no expiry, except for a prompt that gives up on its own: its deadline becomes the popup's timeout. 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): it withdraws the notification, rings the terminal's bell and leaves the prompt waiting — no desktop can be asked to raise another window. A `secret` prompt is never announced. +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, 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 — a prompt with a deadline gives it that deadline instead — and every failure falls through to the next channel instead of raising. A notification that went to the desktop rings the terminal's bell: a popup may be on another virtual desktop, and no desktop lets a program raise another window. The body is filled in reading order (question, reason, object) until the budget is gone, with the deadline and the workspace — the directory the chat runs in, `~` for home — kept for last, so a long reason drops out before they do; the desktop keeps the lines apart and the terminal flattens them. A popup is sent with no expiry, except for a prompt that gives up on its own: its deadline becomes the popup's timeout. There is no "go to terminal" button — no desktop lets a program raise another window, and the popup's own close does as much; instead every notification that went to the desktop also rings the terminal's bell, and a click on the body (the platform's default action) or a close just withdraws it, leaving the prompt waiting. A `secret` prompt is never announced. ### CLI (`cli/`) diff --git a/README.md b/README.md index b62c4a6..64c2adc 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ max_context_tokens = 200000 # [notify] # grace_seconds = 5.0 # announce only after this long # channels = ["auto"] # auto | desktop | terminal -# actions = true # allow / deny / review buttons + reply field +# actions = true # allow / deny buttons + a reply field # include_reason = false # off: the reason may name a command or URL # Deletes: `delete_path` moves the path to the system trash. See doc/trash.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 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), a prompt that denies itself on timeout says so (`no answer in 30s = deny`) and hands that deadline to the popup, which then disappears with it, and the last line is the workspace the chat runs in (`~/Projects/plyngent`). Clicking the notification's body — or its **Go to terminal** button — withdraws it and rings the terminal's bell, since no desktop lets a program raise another window; 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). +**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**; 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), a prompt that denies itself on timeout says so (`no answer in 30s = deny`) and hands that deadline to the popup, which then disappears with it, and the last line is the workspace the chat runs in (`~/Projects/plyngent`). It also rings the terminal's bell, since a popup can sit on another virtual desktop and no desktop lets a program raise another window; closing the popup or clicking its body withdraws it and leaves the prompt waiting. 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 f2023bd..3caeea9 100644 --- a/doc/notifications.md +++ b/doc/notifications.md @@ -47,32 +47,37 @@ a prompt answered inside the grace period is never announced at all. | Prompt | Notification offers | |---|---| -| 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, plus Go to terminal — the answer *is* the text you type | +| 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 / Go to -terminal. "Go to terminal" is always the last button: it withdraws the -notification, rings the terminal's bell 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. -Nothing can raise another application's window portably (Wayland has no such -call), so the bell is what says *which* window holds the waiting prompt; the -notification is gone after that, and the prompt is announced no second time. +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 an answer even when it spells a button's key -(`review` to a question is the word, not the button). A secret prompt is +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. diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 23d80c4..b68f501 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -52,9 +52,11 @@ # `pip install plyngent[notify]`) and falls back to a terminal escape sequence; # "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. Clicking a notification's body asks for no answer: it takes you -# back to the terminal, like the "Go to terminal" button. +# deny when it names none); where the platform has none the notification stays +# text-only. Every notification that went to the desktop also rings the terminal's +# bell — the popup may be on another virtual desktop, and no desktop lets a program +# raise another application's window. Closing the popup (or clicking its body) +# withdraws it and leaves the prompt waiting. # 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