diff --git a/AGENTS.md b/AGENTS.md index 1070d4d..52b3f18 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, 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. +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. ### CLI (`cli/`) diff --git a/README.md b/README.md index a637d9a..b62c4a6 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 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). +**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). **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 c8f5f7e..f2023bd 100644 --- a/doc/notifications.md +++ b/doc/notifications.md @@ -16,12 +16,14 @@ label; the body carries, in this order: | 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 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. +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 @@ -58,9 +60,12 @@ a prompt answered inside the grace period is never announced at all. 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 — 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. +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. 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 @@ -105,11 +110,14 @@ 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. +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 diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 3f2a799..23d80c4 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -58,7 +58,10 @@ # 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). +# deny for the denylist, directory-access and skill-write prompts). The last line +# is the workspace the chat runs in (`~` for home), so several waiting chats are +# told apart; a prompt that has a deadline hands it to the popup, which then +# disappears with it. # `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). A # secret is never announced.