626 Commits

Author SHA1 Message Date
NCBM f511c08afb test: cover the body a prompt notification carries
What a prompt declares (question, object, deadline), how the two channels
render it (lines apart on the desktop, one line in a terminal), that a long
reason goes before the deadline does, that a question keeps its reply field
*and* the way back to the terminal, that a body click leaves the prompt
waiting, that a reply spelling "review" is still an answer, and that a secret
is never announced.
2026-10-24 04:22:00 +08:00
NCBM 04d34b34f3 core/cli: name what a confirm is about, and when it denies itself
A tool confirm's notification said which tool asked and nothing else, and
the three timed prompts (denylist, directory access, skill write) hid the
clock: each of them denies on timeout, so a human reading the notification
late was answering a prompt that had already given up.

`_tool_facts` lifts the object out of the call — the program's name, or the
file's — and never an argument, a URL or a piece of code: those stay in the
reason, which only travels with `include_reason`. The timed prompts pass
their timeout as `expires_in`, and a batch names the tools it asks about the
same way instead of keeping them behind the reason.
2026-10-24 04:21:37 +08:00
NCBM 9ed4620579 core/cli: render a prompt's body from what it declares
The body was the reason line or nothing, so with the default
`include_reason = false` every notification was a bare title. It is now the
question, then the reason when the config allows it, then what the prompt is
about, and last the deadline — filled in that order until the budget is gone,
so a long reason drops out before "no answer in 30s = deny" does. A desktop
popup wraps and keeps the lines apart; a terminal escape sequence carries them
on one line.

Two answers were missing from the surface: a reply prompt had no button at all
(a question could not be sent back to the terminal), and the notification body
was not clickable. Clicking it is the freedesktop default action, so it now
withdraws the notification and leaves the prompt waiting — the same as the
"Go to terminal" button. A typed reply is recorded by `reply()`, which keeps a
reply that spells "review" an answer; a secret is never announced at all.
2026-10-24 04:21:25 +08:00
NCBM 5f7a70bf84 core/prompting: let a prompt say what it asks and when silence answers
`PromptWait` only had a short title and the reason line behind
`include_reason`, so a host that keeps the reason out had a bare title to
show — and a question, whose text *is* what is asked, was cut to the title's
few words. It now carries `body` (the question itself), `facts` (the object:
a program or a file name, never an argument) and `expires_in` (when the
default answer takes over). `ask` and `choose` put their question in the body
and a choice names its options; a secret says in its docstring why its prompt
is announced but never answered.
2026-10-24 04:21:14 +08:00
NCBM caecf62f47 release: plyngent 0.13.0 v0.13.0 2026-10-24 03:24:55 +08:00
NCBM 102c377adc deps: bump ruff to 0.17.2
The commit gateway pins its hooks to the same versions as pdm.lock (see
prek.toml), so both ruff pins move together and `pdm lock` follows.
0.17.2 finds nothing new in src or tests, and `prek run --all-files` is green
on it.
2026-10-24 03:24:44 +08:00
NCBM beaaaea2d9 deps: refresh the lock
`pdm lock` re-resolved within the existing constraints and picked up
sqlalchemy 2.1.5 → 2.1.6 and wassima 2.1.4 → 2.1.5; `pdm sync` installed
both, and the suite is green on them.
2026-10-24 03:21:16 +08:00
NCBM 475f7da645 test: keep the one-shot test off the host's chat.db
`test_chat_oneshot_success` proved that an explicit `[database].url` wins over
the durable default by stat'ing the *real* `~/.local/share/plyngent/chat.db` and
comparing its mtime. So a test read the user's own session database, and it went
red whenever a live chat happened to write it — which is what a run does while
another plyngent session is open.

The default path is now pointed into `tmp_path`, the way `test_app.py` already
does it, and the assertion is that no `chat.db` shows up there. Nothing outside
tmp_path is read, and a regression — the explicit url ignored — still fails,
because the default branch would create that file.
2026-10-24 02:19:39 +08:00
NCBM ce4fa80176 test: cover the payload a write_pty line shows
Pins the escaped form (a newline must not break the line), the 40-character cut
with its ellipsis, and the keys line keeping its markers.
2026-10-24 02:13:50 +08:00
NCBM 36e37e2a6f core/cli/display: show what a literal PTY write sends
`write_pty` printed only `* PTY Write 3 (5 chars)`, so the one line describing
typed text said nothing about it — the keys line at least named its markers.

Both write tools now share one prefix: the payload, rendered with `repr` so it
stays on one line and shows what is invisible (`'ls -la'`, `'a\nb'`, an
all-spaces payload that is not mistaken for an empty one), shortened to 40
characters. `ask_into_pty` keeps the payload-less line: the human's answer is
the one thing that must not be shown.
2026-10-24 02:13:37 +08:00
NCBM 280daa651d docs: name the modifier markers
The marker lists stopped at the unmodified keys, so `<c-up>` and `<c-s-f5>` —
the shapes a TUI actually needs — were not written down anywhere.
2026-10-24 02:08:57 +08:00
NCBM 6e69e5046c test: cover the modifier combinations
Pins the parameter arithmetic on a few shapes (shift, alt, ctrl, shift+ctrl,
ctrl+alt), the Alt-without-a-CSI-form case, Ctrl+Space as NUL, and the three
combinations that stay errors (`<s-a>`, `<c-tab>`, `<c-s-tab>`).
2026-10-24 02:08:57 +08:00
NCBM 87f7166a77 core/tools: spell modified keys the way xterm does
The marker table only knew unmodified keys, so `<s-up>` and `<c-f5>` were
errors and the fallback was raw bytes: a TUI that wants Shift+Tab and
Ctrl+arrows could not be driven without hand-writing `\x1b[1;5A`.

A marker may now carry `c-`, `m-` (or `a-`), and `s-`, alone or combined, and
keys that have an xterm form — arrows, Home/End, Ins/Del, PgUp/PgDn, F1–F12 —
expand to the CSI sequence with the 1 + shift/alt/ctrl parameter. `<m-up>` is
`\x1b[1;3A`, not an ESC in front of an arrow, which no terminal sends. On a
plain character the old spellings stay: Ctrl is the control byte, Alt an ESC
before the character, and Alt alone still prefixes a key without a CSI form
(`<m-tab>`). `<ins>`, `<insert>`, and Ctrl+Space (NUL) come along; a modifier a
key has no sequence for is still an error rather than a guess.
2026-10-24 02:06:51 +08:00
NCBM 1c319f13b6 docs: write the key markers down
Both places that described `write_pty_keys` named the old three-notation grammar
(`\xHH`, `ctrl+x`, `key=esc`), which no longer parses.
2026-10-24 02:03:52 +08:00
NCBM dbb68e7273 test: cover the key markers and the keys line
Pins the markers that used to be unreachable (s-tab, home, bs, pgup, f5, a
repeat, a literal `<`), the two shapes of a wrong marker (unknown name, bad
repeat) coming back as an error from the tool instead of being typed, and the
pretty line naming the keys it sent.
2026-10-24 02:03:40 +08:00
NCBM a2d8159267 core/cli/display: show what write_pty_keys sends
Its pretty line read `* PTY Keys 3 (1 char)` — the one tool whose whole job is
choosing keys was the one that never said which. The markers are already
readable, so the payload goes into the prefix as `* PTY Keys 3 '<c-c><enter>'`,
flattened and shortened like the other subject previews; the outcome stays the
byte count.
2026-10-24 02:03:26 +08:00
NCBM 9c6222d742 core/tools: read keys as <name> markers in write_pty_keys
The grammar had grown three notations for the same thing — a backslash escape
(`\e`, `\t`, `\x1b`), a `ctrl+x` name, and a `key=esc|up|…` marker — so ESC had
five spellings and code and docs already disagreed on one of them
(`key=escape`). `key=` also looked uncomposable while being the only way to
send a key twice (`key=upkey=up`), and anything unrecognised was written to the
terminal verbatim: `key=hme` typed those seven characters into a shell prompt,
in a tool that never prompts.

Markers are now one form — `<name>`, `<<` for a literal `<` — with named keys
through `<f12>`, Ctrl and Alt as `<c-a>` / `<m-x>`, `s-tab` for the reverse Tab
every TUI uses, and `<up*3>` to repeat. The name table lives in one place, and
an unknown or badly repeated marker raises instead of being typed: a typo is an
error the model can fix, not bytes the human has to notice. `ctrl+` and `key=`
are gone rather than kept as aliases, so nothing has two spellings again.
2026-10-24 02:03:13 +08:00
NCBM 8cede4cbb3 docs: say what a notification body carries
The page explained what stays out of the announcement but not what is left in
it, so a reader could not tell whether the same line appears twice.
2026-10-24 01:54:05 +08:00
NCBM 4845455d4b test: cover the body a prompt notification carries
Pins the two shapes: no body at all without the reason, the reason itself (and
only it) with `include_reason`, and an OSC 9 sequence that does not leave the
title/body separator dangling when there is nothing to add.
2026-10-24 01:53:49 +08:00
NCBM bf3a01854c core/cli: stop repeating the prompt in the notification body
Both channels give the title a field of its own, so a body carrying the same
line says it twice — a desktop notification showed "Allow 'rm -rf /'?" as its
title and again underneath. The body is now only the one-line reason, which is
what `include_reason` is for, and nothing when it is off.

OSC 9 joins title and body with a colon, so a body-less one is formatted without
the separator instead of leaving it dangling.
2026-10-24 01:53:38 +08:00
NCBM abd8c4d805 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.
2026-10-24 01:50:12 +08:00
NCBM 3631af2d60 test: cover what a prompt notification asks for and offers
The channel was the one piece the fakes never reached: they record the
`DesktopRequest`, not the call into `desktop-notifier`, so nothing pinned the
expiry the library is actually asked for. A stub module stands in for the extra,
which keeps the test runnable without it. Also covers the answers a prompt names
(`limit`, batch, skill write) reaching the notification and coming back, and the
prompt that follows such an answer being announced without a second grace period.
2026-10-24 01:50:12 +08:00
NCBM 3a54745d01 core/cli+prompting: let a prompt name the answers its notification offers
Every button-bearing prompt offered the same three answers, so the ones whose
terminal read takes more than yes or no lost theirs the moment the human
answered from the notification: the limit prompt could not say "stop asking this
turn" (`yyy`), the batch confirm could not say "one at a time" (`m`), a skill
write could not say "this chat" (`s`), and the limit prompt could not be
answered at all — its wait was not even consulted.

`PromptWait` now carries `choices` (a key the terminal read accepts, plus a
label a human reads), `prompt_wait` passes them through, and the notifier sends
them as buttons, appending "Go to terminal" as the way back. The limit prompt
reads both surfaces like a timed confirm does, and answers that used to be
terminal-only tokens arrive as themselves.

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:
the grace period and the burst throttle exist for prompts the human has not
noticed yet, and they have.
2026-10-24 01:48:22 +08:00
NCBM b7044bbba7 core/cli: keep a prompt notification on screen
A prompt notification went out with the library defaults: the notification
server's own expiry. A desktop server drops such a notification after a few
seconds, so the announcement of a blocked turn had usually slid into the
notification tray by the time the human looked back.

It now goes out without expiry (`timeout=0`, the freedesktop value for "until
dismissed"). Urgency is left alone: measured on KDE Plasma, `critical` keeps the
notification up just the same, but paints it as urgent and asks the platform's
Focus mode to let it through — decoration a prompt does not need. Dismissing is
already the prompt's own job once it is answered, so nothing lingers.
2026-10-24 01:48:04 +08:00
NCBM d4485aff7a release: plyngent 0.12.0 v0.12.0 2026-10-23 10:53:08 +08:00
NCBM 0244a48998 docs: say how wrapper options are read
The destructive-confirm bullet described the old hand tables. It now names the
getopt spec per wrapper, and the rule that follows from it: an option the spec
does not know stops the unwrap and is reviewed, while the wrapper itself stays
the command the denylist judges.
2026-10-23 10:47:55 +08:00
NCBM c1b40a7bf4 test: cover options read with getopt
`--` ends a wrapper's options, jammed values (`-p2222`) and launcher flags
(`uv run --no-project pytest`) resolve, and an unknown option stops the unwrap
with the wrapper as the command — where the review asks and the denylist still
sees `sudo`.
2026-10-23 10:47:45 +08:00
NCBM 12236cffb5 core/tools: read each wrapper's options with getopt
Four hand-written tables said which wrapper options take a value, and the
comment above them admitted the failure mode: an entry for a flag eats the
program behind it, which is how `exec -c rm -rf /` hid `rm`. Guessing was the
other half — an option nobody listed was skipped bare, so the token after it
became the program.

Each wrapper now carries a `getopt` spec instead (`command_scan._WRAPPERS`:
short options with `:`, long options with `=`), which spells flags and values
apart, understands jammed forms (`-p2222`, `--with=pkg`), `--`, and unique long
prefixes, and stops at the first token that is not an option — exactly the
wrapped program. The remote clients and the `run` launchers are the same thing
with a flag on the spec.

An option the spec does not know is no longer skipped as if it were a flag: the
unwrap stops at that wrapper (`unparsed`) and the review asks — `options not
understood sudo (cannot read its options: option --new-sudo-flag not
recognized)`, with the whole argv. The wrapper itself stays the command the
denylist judges, so a new `sudo` flag cannot hide `rm` from the hard gate.
2026-10-23 10:47:36 +08:00
NCBM 3cb4d490ed core/tools: announce a remote shell instead of judging it
`ssh prod` runs no command this scan can resolve, and what is typed or piped
into the shell it opens never reaches any gate — `write_pty` and
`run_argv(stdin=…)` are both unchecked input. The tool description said nothing
about it, so the call looked like any other.

The hop is now reported even when no command follows
(`RemoteTarget.shell`), and the review announces it: `remote shell on 'prod'
(ssh with no command; its input is not checked)`. It is a soft confirm like the
other reviews (YOLO may skip it, an escalation behind a hop still may not) — the
client itself stays outside the denylist, since there is no program to name.
2026-10-23 10:43:05 +08:00
NCBM e1c857fd05 docs: record how far the command gate reaches
The destructive-confirm bullet says what the review resolves past, so it
now names remote clients: the far-end program is the one judged, `BatchMode=yes`
always asks, and an interactive remote shell — `ssh host` with no command, or
anything typed or piped into it — is outside the scan and stays a host decision.
2026-10-23 10:37:23 +08:00
NCBM 8c289db545 test: cover the far end of ssh
What the scan resolves (destination, options, jammed `-p2222`, mosh's `--`, a
hop behind `sudo`), what it leaves alone (a client with no command), and both
gates: `check_command_allowed` now refuses `ssh prod rm -rf /` and prompts for
the `sudo` behind the hop, while harmless remote commands stay allowed; the
review prints a remote `bash -c` code block and always asks under
`BatchMode=yes`.
2026-10-23 10:37:14 +08:00
NCBM 0cb740393e core/tools: judge an ssh command by what runs on the far end
The gate judged `ssh` itself: `unwrap_command` knew env wrappers and
`<launcher> run`, not a remote client, so `ssh prod rm -rf /` reached
`check_command_allowed` as "ssh" — not denylisted — and `classify_danger` found
neither an interpreter nor a detached run. The command ran unannounced.

`ssh` / `mosh` are wrappers now: their own options are skipped, the first token
that is not an option is the destination, and the program under review is the
one after it (after `--` for mosh). The hop is reported on the scan
(`RemoteTarget`), so `ssh prod bash -c …` is reviewed with the code it carries,
an escalation behind the hop keeps its confirm through YOLO, and the denylist
matches the remote program (`rm`, `sudo`, `systemctl reboot`).

`-o BatchMode=yes` cannot prompt at either end, so a command running with it is
always confirmed, whatever program it is.

A client with no command only opens a remote shell: nothing to judge at spawn
time, and what is typed or piped into that shell never reaches the scan.
2026-10-23 10:37:04 +08:00
NCBM d5c3f712ff docs: describe when a prompt listens for the answer
The page said a notification answer is treated like a typed one, without saying
that the prompt has to be listening before the notification exists — the grace
period is why. One paragraph says it, together with the price: a question that
offers a reply field reads in polling mode, so no Tab completion for that
prompt.
2026-10-23 10:25:40 +08:00
NCBM 16c5e01e44 test: cover answers from the notification
Three seams: the offer is known when the wait starts (a confirm and a question,
with and without the platform capability), a button pressed after the
notification appears answers the tool confirm it belongs to — a pty stands in
for the terminal so the read has to consult both surfaces — and `confirm()`
takes an answer passed in and one offered by the wait hook, without asking the
backend.
2026-10-23 10:24:56 +08:00
NCBM 80ad34f8fe core/cli+prompting: hear the notification while a prompt waits
Whether a prompt could be answered from its notification was decided by
`notify_desktop`, which only runs once the grace period is over — by then the
prompt had already committed to the plain terminal read. A pressed button or a
typed reply landed on a queue nobody read, and the human had to come back to the
terminal to answer the prompt the notification was showing.

A wait now asks up front whether an answer may arrive (`answer_capability`: the
kind, `actions`, the channel and the platform's capabilities) and listens on both
surfaces from its first poll. The desktop channel is resolved once, in the
background from `install_notifier`, so the capability probe never lands on a
prompt that is already waiting.

`confirm()` takes an optional `source` (the form's submit confirm and every
limits.py confirmation go through it), which also leaves one place — not two —
parsing `allow` / `deny` into a yes or no.
2026-10-23 10:24:46 +08:00
NCBM b2d6acae26 docs: record the Files API shapes
Uploading a picture and referencing it by id is the one image path the API
offers that we do not use, and the vendor shapes do not line up: the upload is
the same multipart `POST …/files` with a `file` part everywhere, but the object
that comes back is `object`/`bytes`/unix time on OpenAI, `type`/`size_bytes`/
RFC 3339 on Anthropic, and DeepSeek serves both dialects under two base URLs;
the reference block differs a third time per surface, and OpenAI's chat
completions cannot name an image by id at all.

Recorded next to the inline-path limits, with the three constraints that decide
any future wiring: an id belongs to the provider that minted it (like
`provider_blocks`), the OpenAI path implies the Responses convention, and the
token bill is unchanged — uploading only buys the inline size cap, a smaller
request body, and not re-sending the same bytes every round. The Anthropic
per-file limit is marked unverified: its API reference is unreachable from this
host's region.
2026-10-21 21:44:03 +08:00
NCBM 8926980a02 docs: describe the image gate
`image_in` is not just the mention switch any more: the table names where each
image-carrying path is gated, and says why the gate exists — a text-only model
answers `[Unsupported Image]`, and a gateway that cannot read image blocks feeds
the model raw base64, which is easy to mistake for a delivered picture.
2026-10-21 21:39:41 +08:00
NCBM 37e9395ac5 test: cover the image gate
The gate has four entry points and each now has a test: `view_image` and
`fetch_local` refuse with the message naming the config key, the registry drops
a plugin tool's picture and says why, and the workspace helpers keep `None` (no
host verdict) permissive. On the CLI side the verdict is asserted to follow the
selected model, and to reach the tools once an instance is bound.
2026-10-21 21:39:33 +08:00
NCBM da6f4b7dd7 core/tools+agent+cli: refuse a picture the model cannot take
`[providers.*.models.*].image_in` gated only the `@path` mention: `view_image`
attached whatever it was asked for, and a tool result's picture (an MCP server's
`image` content, a plugin's `ToolOutput`) went out the same way. On a model that
does not read pictures the provider answers `[Unsupported Image]` one round
later, and a gateway that cannot translate image blocks hands the model raw
base64 — the model then reports "I can only see a base64 payload", which reads
like the picture arrived when it did not.

The verdict now lives on `InstanceState.workspace` (`image_in` + the
`(provider, model)` it came from) and `tools.workspace.image_input_refusal`
turns it into the one message every path shares:

    error: model 'deepseek-v4-pro' does not accept image input
    (set image_in = true under [providers.deepseek.models."deepseek-v4-pro"])

`view_image` and `fetch_local` refuse with it before reading any bytes (the
fetch keeps its metadata line, so `body_kind=image` and the size stay visible);
the registry drops an image from a `ToolOutput` it did not vouch for and appends
the same reason to the text. That last one is the safety net for MCP and plugin
tools, which know nothing about the model.

`ReplState.sync_image_capability` publishes the verdict and runs from
`rebuild_client`, so `/provider` and `/model` flip the gate immediately, and
`_run_chat` seeds it before the first turn. A host that binds no instance
declares nothing and keeps sending pictures, as before.
2026-10-21 21:39:24 +08:00
NCBM 2769fb62de deps: lock the notify extra
CI installs with `pdm install --check -G :all`, and `-G :all` covers extras
too, so a `[notify]` extra that was never locked made the install step fail
outright: "Requested groups not in lockfile: notify". `pdm lock -G :all` brings
`desktop-notifier` and its per-platform dependencies (dbus-fast, rubicon-objc,
the winrt projections) into the lock, and re-resolving picked up the drift that
had accumulated since the last one — platformdirs 4.13.5, sqlalchemy 2.1.5,
urllib3-future 2.25.903.
v0.11.0
2026-10-21 20:30:56 +08:00
NCBM cb4e9b3744 release: plyngent 0.11.0 2026-10-21 20:29:04 +08:00
NCBM 4b90718191 core/config: re-read [skills] on reload
`ConfigStore.reload()` re-parsed every section except `[skills]`, so
`/model --persist` and any other in-place reload kept serving the skill config
the process started with — including `[skills].enabled`, which is what decides
whether the skill tools exist at all.
2026-10-21 20:26:08 +08:00
NCBM 0e8b0f8bd4 docs: describe deletes that go to the trash
A new page, `doc/trash.md`, covers what a delete does now: where a path lands on
each platform, what is still deleted outright (an explicit `permanent=true`, a
host that cleared `[trash]`, scratch paths under the OS temp dir, the
`copy_path` / `move_path` overwrite, the temporary-workspace cleanup), what
happens when a trashing is refused — the file stays and the error says how to
delete it on purpose — and the two things the trash cannot help with: a shell
`rm` and anything a plugin or MCP server does on its own.

`[trash]` joins the example config and the README snippet, the README gains the
one-paragraph version, `AGENTS.md` the tool/config notes (including the confirm
wording a delete now uses), and the default tool playbook tells the model that
`delete_path` is undoable and what `permanent=true` costs.
2026-10-21 20:25:27 +08:00
NCBM 2369592473 core/tools+cli/display: name the trash in the delete confirm
The box asked "delete path 'x'", which reads as final even now that it is not.
It now says "move path 'x' to the system trash" — and "delete path 'x'
permanently" when the path really is destroyed: an explicit `permanent=true`, a
host that turned `[trash] enabled` off, or a scratch path under the OS temporary
directory. The classifier resolves the path to answer that, and falls back to
the trash wording when path policy will refuse the call anyway.

The pretty line follows: a delete that landed in the trash prints `(to trash)`
instead of `(done)`, so a session's transcript still shows which deletes could
be undone.
2026-10-21 20:23:54 +08:00
NCBM 17813281d8 core/cli: hand the trash switch to the tools
`[trash] enabled` now reaches `delete_path` through the bound instance's
workspace policy, the same channel `[agent] path_denylist` already travels: set
when the session state is built and re-read by `reload_config_from_disk`, so a
config reload in the middle of a chat changes the next delete too.

The policy flag is what `delete_path` consults, so a host that never sets it
gets the safe default (deletes go to the trash) instead of a silent no-op.
2026-10-21 20:22:50 +08:00
NCBM c4a287c464 core/config: add the [trash] section
`[trash]` owns the one policy `delete_path` needs from the human: whether a
delete is a move to the desktop's trash (`enabled = true`, the default) or a
removal that cannot be undone (`false`, which is what the tool did before).

Unknown keys degrade to the defaults, the way `[mcp]` does, so a typo cannot
silently switch a destructive policy on. `reload()` re-reads the section.
2026-10-21 20:19:54 +08:00
NCBM 7b7c90b8ee core/tools: send a deleted path to the system trash
`delete_path` is the only destructive file entry point the agent has, and it
carries `ToolTag.YOLO` — under `--yes` / `/yolo` it runs with no confirmation at
all. A delete is now a move into the desktop's own trash (freedesktop trash on
Linux, Finder trash on macOS, Recycle Bin on Windows) through `send2trash`, so
the human can put the file back with the file manager they already have. The new
`permanent=true` argument is the old behaviour, chosen on purpose.

A trashing that fails is reported, never downgraded to a deletion: the human was
told the path is recoverable, so a volume with no writable trash directory
leaves the file where it is and the error names `permanent=true` as the way to
delete it anyway. A broken install (the library is a required dependency) is
reported the same way.

Scratch paths under the OS temporary directory are still deleted for good —
nobody restores them, and a trash on the `/tmp` volume would only collect them.
The rule now lives in `tools/trash.py` (`under_system_temp`) and the temporary
workspace cleanup uses it too, instead of keeping a second copy of the check.
2026-10-21 20:18:35 +08:00
NCBM 290f3daf60 deps: add send2trash
Deletes are about to become recoverable, and send2trash is what every desktop
already understands: the freedesktop trash specification on Linux (its own
implementation unless PyGObject/GIO is there), the Finder trash on macOS and the
Recycle Bin on Windows. It is pure Python over ctypes — no compilation, no
runtime dependencies — so it can sit on the default install instead of behind an
extra.

A required dependency rather than an optional one on purpose: a missing extra
would silently turn "recoverable" into "gone", and a safety net that disappears
without a word is worse than no safety net.
2026-10-21 20:15:41 +08:00
NCBM f3bd511251 test: cover the notify install gate
`_setup_hooks` installs the wait hook only for an interactive run: a one-shot
`plyngent "…"` never waits on a human, so `[notify]` must not arm a notifier
that could never fire. The test asserts the hook is cleared again when the run
is not interactive.
2026-10-21 20:04:17 +08:00
NCBM b197c98ce9 docs: describe prompt notifications
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.
2026-10-21 20:03:36 +08:00