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.
`--` 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`.
`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.
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`.
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.
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.
`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.
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.
`[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.
`[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.
`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.
`_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.
`ask` now reads its answer from the wait's source as well, which is what makes
the notification's reply field mean something: the reply typed into it is the
answer the caller gets. The readline path stays whenever the source offers no
answers, so line editing and Tab completion only go away when the human asked
for a surface that can answer — and a terminal answer still wins over one that
arrives through the notification.
The read itself is the poll-based one this module already had, so the same
"no deadline, wait for a line or an answer" rule and the same Ctrl+C handling
apply; a cancelled read raises `NonInteractiveError`, as the backend's own
cancellation does.
Every place the CLI blocks on the human now announces itself: the single and
batched tool confirms, the command-denylist and `fetch_local` policies, a skill
write, a directory-access request, the `ask_user_*` questions and the
limit-continue prompt. `_setup_hooks` installs the notifier from `[notify]`, and
only for an interactive run — a one-shot run never waits on a human.
An answer that came back through the notification is read as if it had been
typed: `allow` / `deny` map onto the `y` / `n` the prompts already accept, and
the confirmation prompts switch from click's readline to the poll-based read
only when the notification actually offered answers — otherwise the terminal
experience is untouched. "Go to terminal" never reaches the prompt at all.
The stdin read behind the timed prompts now watches in 100ms slices instead of
one full-length `select`, which is what lets a notification answer end it; an
answer still loses to a human typing at the terminal. A `None` timeout waits
for whichever arrives first, and a non-pollable stdin still gives up at once.
`prompt_wait` announces only the outermost prompt of a nested read: a confirm
that asks a follow-up question while it is already on screen would otherwise
buzz the human a second time.
A prompt that has waited `grace_seconds` is now announced, and where the
platform allows it the notification carries the answer: allow / deny / review
for a confirmation, a reply field for an `ask_user_*` question. Picking
"review" withdraws the notification and keeps waiting at the terminal, and a
notification is dismissed as soon as the prompt returns — answered or not.
Two channels. The desktop one is the optional `notify` extra, run on its own
event loop in a daemon thread because a notification's callbacks only fire
while the loop that owns it is running (the library's synchronous wrapper does
not provide that). The terminal one needs nothing: it writes OSC 777 / OSC 9
to `/dev/tty` — so a redirect cannot swallow it — picking the protocol from the
environment and wrapping it for tmux / screen, falling back to a plain bell.
kitty speaks OSC 99 only, which is not implemented, so it keeps the bell.
Nothing here can break a prompt: a missing extra, a desktop that refuses, a
failed write all fall through to the next channel and never raise. Titles are
flattened and stripped of control characters, and the reason line (which can
name a command or a URL) only travels with `include_reason`.
`[notify]` decides what the CLI does when a prompt is waiting on the human:
`grace_seconds` (default 5s, roughly Claude Code's permission notice) before
anything is shown, `min_interval_seconds` between two notifications,
`channels` (`auto` = desktop then terminal), `actions` for the allow/deny/review
buttons, `include_reason` for the one-line reason, and `protocol` to pin the
terminal escape protocol.
The reason stays out by default because it names commands, argv and URLs, and a
notification outlives the prompt and may be mirrored to a lock screen. Unknown
keys degrade to the defaults, the way `[mcp]` does, and `reload()` re-reads the
section.
Desktop notifications need the optional `notify` extra (`desktop-notifier`),
which is the only new dependency and stays off the default install.
A prompt that blocks on the human now announces itself through one process-wide
hook: `prompt_wait(kind, title, detail)` wraps `ask`, `ask_secret`, `choose` and
`confirm` and yields the answer source the host offers. `kind` names the prompt
family (`ask`, `secret`, `choose`, `confirm`, and the CLI's own `policy` /
`access` / `skills` / `limit`) so a host can pick a surface, while `title` stays
short enough to show anywhere and `detail` carries one line the caller has
already redacted. No hook installed means no behaviour change, which keeps
library hosts and the test suite exactly as they were.
The read side of the seam is `read_line_with_timeout`: an optional `source` ends
the read with the host's answer (echoed on the prompt line like a typed one) and
a `None` timeout now means "wait until a line or an answer arrives" instead of
giving up. Stdin keeps priority, so a human typing at the terminal is never
raced by a notification answer that shows up at the same moment.
`reset_prompting` clears the hook along with the output hook and the pause
factory.
Windows spells a path with backslashes, and three fixtures took the POSIX shape: a config value written into a TOML basic string read `C:\users\…` as an escape, tool arguments built with an f-string broke the same way, and a root was compared as `str(path)` against `/srv/skills`. Paths are encoded with `json.dumps` (whose escaping is valid TOML and valid JSON) and compared as `Path` objects, so the same root matches in either spelling.
`test_interrupt.py` delivers SIGINT with `os.kill(pid, SIGINT)`, which is `TerminateProcess` on Windows, and the router itself needs `add_signal_handler`: the module is skipped there.
`python scripts/wine_pdm.py ci` is green: ruff, basedpyright 0 errors, 1156 passed / 30 skipped.
`--yes` / `/yolo on|off|once` clears the prompt of a `YOLO`-tagged tool: interpreter runs (`run_argv`, `run_argv_batch`, `open_pty`), the file mutations (overwrite / copy / move / delete) and `fetch_local`'s cleartext and mutating requests. A tool without the tag still asks, and hard path/command denylists stay untouched.
`classify_danger` now answers with a `DangerReason` — the reason text plus `yolo_skip` — so one call can refuse that skip: a command that runs through `sudo` / `su` / `doas` / `pkexec` (as the program or a wrapper) keeps its confirm whatever YOLO mode says. It never adds a reason of its own, so the program the denylist gate already asks about is not asked twice.
`prepare_batch_confirm` leaves the covered calls out, so a skipped call neither prompts on its own nor takes a slot in the round's aggregated prompt, and one escalating step keeps the whole batch in front of the human.
Typing `@shot.png` in the line attaches that picture to the turn — no separate
command — and `@notes.txt` appends the file as text under a `--- @path ---`
header, capped like a tool result with a resumable `truncate_token`.
`@"my shot.png"` is the quoted form, and Tab completes the path after `@`
(directories end in `/`, a name with a space completes quoted).
An image is skipped, with a note naming the key to set, on a model without
`image_in`; a missing or denylisted path is a note too, and the turn still goes.
The default catalogs mark what can take a picture (`deepseek-flash` yes,
`deepseek-v4-pro` no, the OpenAI defaults yes).
`Agent.run` takes the parts, so the turn is stored with its images and shows up
as `[image]` wherever only prose is wanted.
New `view_image` reads a local picture (JPEG, PNG, GIF, WebP, sniffed from its
bytes) and hands it to the model as an image part, with an optional `detail`
level — `low` measured 3.4x cheaper on a 1200x900 picture. `read_file` now
points at it instead of returning decode noise.
`fetch_local` returns an image response as the picture itself, and MCP image
content blocks become image parts instead of `[image: ...]` placeholders, so a
screenshot server finally reaches the model.
`ToolOutput` pairs the model-facing text with the images a tool produced.
`ToolRegistry.execute` keeps its text-only contract; `execute_output` returns
the pair and the loop stores the images on the tool message, so a screenshot
tool can answer in pixels instead of a hex placeholder.
Only the text is capped by the tool-result limit; the char estimator now counts
a present image at its 1024-token ceiling, because the shrinkers never drop one.
A user turn's `content` may now be a part array (`ChatTextPart` +
`ChatImagePart`), which chat completions take as-is; the Anthropic and
Responses bridges translate it into `image` blocks and `input_image` parts.
A tool result keeps its text in `content` and carries `images` beside it:
Anthropic and Responses put them in the result item itself (a live probe reads
both back), while chat completions cannot, so
`compat.chat_messages_with_images` moves them into the user turn after the tool
run. Prose-only readers go through `content_to_text`, which renders an image as
`[image]`.
`agent.images` sniffs the real format from the bytes and builds the data URL,
capped at 8 MiB inline because every stored message is re-sent on each round.
prompt_confirm_batch_async answers a ToolBatchConfirmHook with one box that numbers every call the round wants to make (continuation lines stay under their entry). The prompt is the plain ``Allow these N tool calls?`` confirm: a deny offers the same one comment for all of them, a non-interactive host denies, and the next round asks again.
The ReplState registry installs the hook next to the per-call one, and format_tool_confirm_box now shares the box drawing with the batch header.
ToolRegistry.prepare_batch_confirm pre-scans a round of tool calls, drops the ones that cannot run (unknown tool, undecodable arguments, missing required argument), and turns two or more soft reasons into a single ToolBatchConfirmHook prompt. The BatchConfirmDecision it returns is applied per call through execute(..., decision=...): allow, or deny with one comment shared by every call of that round.
_execute_tool_calls asks before it gathers, so a batch of moves or deletes no longer stacks one confirm box per call. A single request keeps the old one-call prompt, a host without a batch hook keeps the per-call behaviour, and the answer never outlives the round it was asked in.
`provider_blocks` means something only to the surface that produced them (Anthropic `thinking` / server-tool blocks). A session that switched surface would otherwise send those dicts to a chat-completions provider as an assistant-message key it does not know; the request normalization drops the field, on a copy, so the history keeps it.
Streaming is the default path: `thinking` fragments, a `server_tool_use`'s `partial_json` query and a `web_search_tool_result`'s entries arrive as separate SSE events, so the blocks the round has to hand back exist only as fragments. The dispatch rebuilds each one from its start payload (field for field, under the stream's own names) plus the fragments that follow, and passes it to the loop as a chunk; the loop copies it onto the round's message, where a round that kept only those blocks counts as payload rather than an empty completion.
The thinking text streams as reasoning as it arrives — the search query and the signature go back with the round only.
DeepSeek's Anthropic compat answers a hosted search inside the turn: the response carries the `thinking` that picked the query, the `server_tool_use`, and the `web_search_tool_result`. The response mapping kept text and tool_use only, so the round lost its evidence — and the `thinking` signature, which only the provider can verify, could not go back at all. `AssistantChatMessage.provider_blocks` carries those blocks as raw JSON now (`thinking` text *and* signature, the query, the result entries with an `encrypted_content` the API rejects when trimmed) and the assistant turn hands them back first, ahead of its text and tool_use blocks; `thinking` also lands in `reasoning_content`, so the chain of thought streams as reasoning like every other surface's.
`AnthropicRawContentBlock` names the provider fields a streamed block carries, and `AnthropicServerToolUseContent` keeps the `caller` metadata the wire sends.
DeepSeek's Responses stream sends ``response.reasoning_text.delta`` fragments and then ``response.reasoning_text.done`` carrying the whole chain of thought those fragments spelled out. The loop folded a full delta into the round — it replaces the fragments, and that is what the assistant message hands back to the API — but streamed every delta verbatim, so the chain of thought was printed twice: once as it arrived and again as the ``.done`` text.
A full delta now streams only a tail the fragments did not cover, and nothing at all when it merely repeats them; a round whose fragments never arrived still streams the full text once. The message keeps the complete text either way.
``web_search`` is composed by the CLI host rather than the builtin catalog, so it had no ``_PRETTY_LINES`` entry and fell back to the ``[tool] …`` / ``[tool ok] …`` whole lines. It now leads with ``* Web Search '<query>' `` (the query as a 60-character preview, like the ask and skill subjects) and lands on the hit count — or on ``(no results)`` when every source came up empty, an emptiness the tool reports as ``error: the search found nothing`` and which is therefore not shown as a failure. A real error still prints in red with its message.
The guard test that keeps a builtin tool from shipping without a renderer now checks this host-composed tool too, since a catalog walk cannot see it.
``search_sources`` (a provider list, ``["deepseek", "bing"]`` by default,
``[]`` to not offer the tool at all) names what the local ``web_search`` tool
searches with, in fallback order: ``search_in_order`` asks each source in turn
and keeps the first answer, so a source that errors — a rate limit, a rejected
key — or finds nothing hands over to the next instead of failing the tool. The
handler reports the failures only when every source came up empty.
The Bing leg answers with its RSS results (``?format=rss``), which need no key
and carry a description — the snippet DeepSeek's own index does not hand out —
and it goes through the ``fetch`` client, so the SSRF and host-grant policy
applies to it too (a host that binds tool instance state, as the CLI does).
``provider_tools`` reached the effective provider only for the OpenAI preset,
so a DeepSeek ``provider_tools`` in TOML was dropped on the way — and the preset
defaulted to none, leaving search unavailable for every convention.
DeepSeek now defaults to ``[{type="web_search"}]`` like OpenAI does (``[]``
turns it off) and its list reaches the agent: the Anthropic convention runs the
hosted search inside the response, the Responses and chat conventions get the
local ``web_search`` tool (see ``config.routing.local_web_search``).