mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
Compare commits
41 Commits
d4485aff7a
...
v0.14.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 4676b9af32 | |||
| 865d314234 | |||
| 9139655e54 | |||
| 741d1a8526 | |||
| 44b6ab90ed | |||
| fb77fd676b | |||
| a79039f06f | |||
| ff656b800a | |||
| 2039d4f31f | |||
| 0e8d404642 | |||
| 19707c3bd6 | |||
| ae20f5131f | |||
| a7abc50aa5 | |||
| 7452224793 | |||
| 474bf57093 | |||
| ea0827ddfb | |||
| 39258b2670 | |||
| f511c08afb | |||
| 04d34b34f3 | |||
| 9ed4620579 | |||
| 5f7a70bf84 | |||
| caecf62f47 | |||
| 102c377adc | |||
| beaaaea2d9 | |||
| 475f7da645 | |||
| ce4fa80176 | |||
| 36e37e2a6f | |||
| 280daa651d | |||
| 6e69e5046c | |||
| 87f7166a77 | |||
| 1c319f13b6 | |||
| dbb68e7273 | |||
| a2d8159267 | |||
| 9c6222d742 | |||
| 8cede4cbb3 | |||
| 4845455d4b | |||
| bf3a01854c | |||
| abd8c4d805 | |||
| 3631af2d60 | |||
| 3a54745d01 | |||
| b7044bbba7 |
@@ -100,8 +100,8 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command denylist via `match_command_denylist` (checks every program of the wrapper/`run` chain, resolved by `tools/command_scan.py`; an entry is a program, plus optional argument words that make it dangerous — `systemctl reboot poweroff …` matches `systemctl reboot` and `systemctl start reboot.target`, not `systemctl status`; basenames are lower-cased, so `RM` / `rm.exe` count; defaults cover privilege `sudo`/`su`/`doas`/`pkexec`/`run0`/`systemd-run`, destructive `rm`/`mkfs`/`dd`/`chown`/`mount`/…, and power state `shutdown`/`reboot`/`poweroff`/`halt`/`init`/`telinit`/`kexec`/`pm-*`/`s2*`; CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error names the `request_directory_access` mode the call needs (`call request_directory_access with mode='write'`).
|
||||
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range; an image file is refused with a pointer to `view_image`), `view_image` (a picture reaches the model as an image part; format sniffed from the bytes, `detail` = `auto` default / `low` / `high` / `original`), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path` (moves the path into the system trash — freedesktop trash / Finder trash / Recycle Bin — so a delete can be undone; `permanent=true`, `[trash] enabled = false`, or a path under the OS temp dir delete it for good, and a trashing that fails reports the reason and leaves the path alone; [doc/trash.md](doc/trash.md)), `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (key markers) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`<c-c>`, `<esc>`, `<c-up>`, `<c-s-f5>`, `<up*3>`, `<m-x>`, `\xHH` — xterm modifier sequences where they exist; an unknown `<…>` is an error, never typed); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run` — every wrapper's options are spelled as a `getopt` spec (`command_scan._WRAPPERS`: flags as well as value options, so no option swallows the program), and an option the spec does not know stops the unwrap and is reviewed (`options not understood … the wrapped program is a guess`) while the wrapper itself stays the command the denylist judges; deny may include a user comment for the model. `ssh` / `mosh` are unwrapped like a wrapper (`command_scan`): the program reviewed is the one the client runs on the far end (`ssh prod rm -rf /` is refused as `rm`, `ssh prod bash -c …` prints the remote code, an escalation behind the hop keeps `yolo_skip=False`), and `-o BatchMode=yes` always asks because nothing on either end can prompt. A client with **no** command only opens a remote shell: nothing typed or piped into it (`write_pty`, `run_argv(stdin=…)`) is checked, so opening one is announced and confirmed instead — `remote shell on 'prod' (ssh with no command; its input is not checked)` — while the client itself stays outside the denylist (there is no program to name). The `delete_path` confirm names what will happen — `move path 'x' to the system trash`, or `delete path 'x' permanently` when the call asks for it, the host cleared `[trash]`, or the path is scratch — and the pretty line prints `(to trash)` for a delete that landed there. Session YOLO: `/yolo on|off|once` and `--yes` (skip the soft confirms of `YOLO`-tagged tools — interpreter runs included — while untagged ones still prompt and a command that escalates privileges keeps its confirm through `DangerReason(yolo_skip=False)`; hard denylists unchanged; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, calls missing a required argument, and calls YOLO already covers are left out — nobody is asked about a call that cannot run or will not ask), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt.
|
||||
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
||||
@@ -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)`, which yields the `PromptAnswerSource` the read may consult (`take()` returns `allow` / `deny` / reply text 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`, the answer is treated like a typed one (never a session grant), 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, 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/`)
|
||||
|
||||
|
||||
@@ -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 `<workspace>/.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 **Allow once / Deny / Go to terminal** buttons for a confirmation and a reply field for a question, where the platform renders them — 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, `[notify].include_reason` keeps the reason line (a command, argv or URL) out by default, 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).
|
||||
|
||||
@@ -357,7 +357,7 @@ Safety defaults:
|
||||
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
|
||||
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` sanitizes CSI/controls
|
||||
so tool results cannot reprogram the host TTY (no host terminal reset on exit).
|
||||
`write_pty` is literal text only; use `write_pty_keys` for `\xHH`, `ctrl+x`, `key=esc|enter|…`.
|
||||
`write_pty` is literal text only; use `write_pty_keys` for keys and bytes: `<c-c>` `<esc>` `<enter>` `<s-tab>` `<up>` … `<f12>`, `<c-up>` `<s-home>` `<c-s-f5>` for modifier combinations, `<up*3>` to repeat, `<m-x>` for Alt, and `\xHH` / `\uHHHH` for raw bytes. An unknown `<…>` is an error, never text typed into the terminal.
|
||||
For passwords/sudo/ssh prompts use `ask_into_pty` (human types locally; answer never returns to the model).
|
||||
|
||||
- Mis-typed tool arguments are answered, never guessed: a string where an array is expected (`run_argv` `argv`, `ask_user_choice` `options`, `ask_user_form` `fields`), a call that omits a required argument, or a non-numeric `wait` duration (numeric strings are accepted) returns an error showing the shape/names to send — the human never sees a garbage prompt, and no confirm prompt fires for a call that cannot run.
|
||||
|
||||
+69
-13
@@ -5,10 +5,25 @@ 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`.
|
||||
`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 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 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
|
||||
|
||||
@@ -32,25 +47,53 @@ a prompt answered inside the grace period is never announced at all.
|
||||
|
||||
| Prompt | Notification offers |
|
||||
|---|---|
|
||||
| tool confirm (single / a round's batch) | Allow once / Deny / Go to terminal |
|
||||
| command denylist, `fetch_local` private host | same three |
|
||||
| directory-access request, skill write | same three |
|
||||
| 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`, PTY secrets | nothing (terminal only) |
|
||||
| limit-continue | nothing (terminal only) |
|
||||
| `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. 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". "Go to terminal" withdraws the
|
||||
notification and leaves the prompt waiting. A secret is never asked for in a
|
||||
notification, and never echoed into one.
|
||||
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 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.
|
||||
|
||||
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
|
||||
`grace_seconds`, so waiting for it to appear before listening would block on the
|
||||
terminal alone. Typing at the terminal always wins; a readline-only convenience
|
||||
terminal alone. The terminal keeps its own prompt line with the keys it accepts —
|
||||
a button elsewhere does not say what typing here would do. Typing at the terminal
|
||||
always wins; a readline-only convenience
|
||||
(Tab completion on `ask_user_line`) is what a question gives up in exchange.
|
||||
|
||||
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,
|
||||
with no grace period and past the burst throttle: the human is looking at the
|
||||
notification, and the follow-up is the next thing they must answer.
|
||||
|
||||
## Desktop channel
|
||||
|
||||
The desktop channel needs the optional extra:
|
||||
@@ -70,6 +113,19 @@ Notifications are shown from the library's async API on a loop that runs in a
|
||||
daemon thread, because callbacks only fire while the loop that owns the
|
||||
notification is running.
|
||||
|
||||
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. 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
|
||||
|
||||
No dependency and no extra: the CLI writes an escape sequence to `/dev/tty`
|
||||
|
||||
@@ -45,15 +45,28 @@
|
||||
# Notifications while a prompt waits on the human: a tool confirmation, a
|
||||
# directory-access grant, a skill write, an ``ask_user_*`` question, or the
|
||||
# limit-continue prompt. A prompt is announced only after `grace_seconds` of
|
||||
# waiting, so one you answer at the terminal stays silent; `min_interval_seconds`
|
||||
# throttles bursts.
|
||||
# waiting, so one you answer at the terminal stays silent — except a prompt that
|
||||
# follows an answer given on the notification, which is announced at once;
|
||||
# `min_interval_seconds` throttles bursts.
|
||||
# channels: "auto" tries the desktop first (needs the `notify` extra:
|
||||
# `pip install plyngent[notify]`) and falls back to a terminal escape sequence;
|
||||
# "desktop" / "terminal" pin one of them.
|
||||
# `actions` offers allow / deny / review as notification buttons; where the
|
||||
# platform has none the notification stays text-only.
|
||||
# `actions` offers the answers the prompt accepts as notification buttons (allow /
|
||||
# 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
|
||||
# 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).
|
||||
# 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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
groups = ["default", "dev", "notify"]
|
||||
strategy = ["inherit_metadata"]
|
||||
lock_version = "4.5.1"
|
||||
content_hash = "sha256:6b62075d8b95d83a7d5d6c99c073db457750af4c8a4e988adbef8799f6f4167e"
|
||||
content_hash = "sha256:baade01b6537caca957e9fe9ee94e82f998e39255b52ed94db94b501e620398b"
|
||||
|
||||
[[metadata.targets]]
|
||||
requires_python = "~=3.14"
|
||||
@@ -659,29 +659,29 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.10"
|
||||
version = "0.17.2"
|
||||
requires_python = ">=3.7"
|
||||
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"},
|
||||
{file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"},
|
||||
{file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"},
|
||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"},
|
||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"},
|
||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"},
|
||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"},
|
||||
{file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"},
|
||||
{file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"},
|
||||
{file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"},
|
||||
{file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"},
|
||||
{file = "ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e"},
|
||||
{file = "ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d"},
|
||||
{file = "ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7"},
|
||||
{file = "ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac"},
|
||||
{file = "ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160"},
|
||||
{file = "ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a"},
|
||||
{file = "ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4"},
|
||||
{file = "ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869"},
|
||||
{file = "ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004"},
|
||||
{file = "ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0"},
|
||||
{file = "ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f"},
|
||||
{file = "ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4"},
|
||||
{file = "ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a"},
|
||||
{file = "ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe"},
|
||||
{file = "ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd"},
|
||||
{file = "ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c"},
|
||||
{file = "ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962"},
|
||||
{file = "ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -697,7 +697,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.1.5"
|
||||
version = "2.1.6"
|
||||
requires_python = ">=3.11"
|
||||
summary = "Database Abstraction Library"
|
||||
groups = ["default"]
|
||||
@@ -705,48 +705,48 @@ dependencies = [
|
||||
"typing-extensions>=4.6.0",
|
||||
]
|
||||
files = [
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5541a110ac4bb6377c785aea1b8f0b4d6658ed0ca5061ed1115d0725062b026b"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f029b2f7114623c03f762596f313515a2358004c44eb18924a15940f2e27d30f"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc44dcc80e89a006af415edffc7409b1f2976a4597065449c166987f95801ab4"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:72ddd7a8ec187d02de0ef316a15aaf381ddcc594958247b02a3dcdb4c42f0b02"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f5173a6e60a15090f9e2348a8ca94cc13de72796c16733f3b62fbb22acba07b8"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:09b10bd68223d57cce39c08947efae3f91ac40b552e07c7c869c3350a0fc4c23"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ea4cb5022462d9f15e7b72a52c267385aa97ac224c8503ac63ba90c0067ad0d9"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-win32.whl", hash = "sha256:c3ffbe6c680c626403b7e9697d92465501078731b9e8863b1410826e66b950e1"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-win_amd64.whl", hash = "sha256:061d089901319463a1160e4596191e355d2a9b4cce4852dac5adc301b085ac2f"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-win_arm64.whl", hash = "sha256:f5bc7fb93f1c17efefeb062bc4d2742ad1b71d56c36e6e2898a371ac6a56839a"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9bb993b1d0890a2da90589684c3d09de75df82c06aa8f7eda246c7cd0ea3e820"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df7e71ed051e4003e73484ef6547aacfb3a2322aac0c230d530c97e48e77b4d7"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c76d4cb9c76a6906563133449a76d9eae6c8529ca09484b8615ca604442b442e"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59a56be2e4e972061f9d5c1d5b01763b8b2207b48b956fbb6879c7443f5817f7"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc1a61d7f42e71746b5a2e10e90a6b174b4add441b7b6cf14278f1e029b2b800"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:740e96dfb99a615c8056070d3747efe8bce4c145e5953ccc9e4b004c139de0d2"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:92a0bb0595331c8e40bc615a67cc7bca9b761487409d1bf46c598b6ba5c0754d"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-win32.whl", hash = "sha256:11185c72b5372369166cb721537943f8e1e6e42a399e9bac19e02f3ee20592a4"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5173207b8fe1ddfc1d451c420a2a9cbbd0734b4a19896b8080e3744497e1c20c"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_arm64.whl", hash = "sha256:def2f117a6ec0a25202ee36a148bbcfad3d43e2f2bb2e698e6d4448c6db5cb19"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a0b80f91d9ed92be7a134278ea2e83a693c07d9986824ca51d859af9e57e18f5"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3033ddeeeb34c6c536622c07e932a20f8c9f58d6dd76e56595a76b55a25011ff"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4d30723171d511ec16ac057872a38b9b395420c0aaf81b2f94dd7bf1345c5fe"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cefa5badc13df1c9082bacdfa435c8eb6dcb9b32717f51d09d1e160a599646f"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2549e503185897de1fc5ca7730d5daed0d27d0b26df4a059dcdd33ebe81b7b4b"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:5a6871ee159a943d874012de835d56d0544ee871a99dbae05bc053da315b2c23"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:51e290080415325b66a94d20f6360e310606acb832f2bd677d4fcb95728e7ce1"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-win32.whl", hash = "sha256:c228084025a28aa038aef9b821d842508650fe12fe51f06e9cdaa4393be7812c"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-win_amd64.whl", hash = "sha256:bc09dd22c3fc491a2df7d16ca4167de35573f40c2e82dd4d6ddae589a73e7032"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-win_arm64.whl", hash = "sha256:375ef1bf78bc269a220963163d2580d5377898ed23a00bdd98a02d755421573d"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:7af1c92255cf8d50e87a808ce41096907332acdd09f285426a52f7c300e152db"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85891e0828e76a77fe912995dbc3ee949099488ba0a2ab323535d2e767446b20"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a72e062d2d38826dd89231df692fb40f12d7d1b9d118be23bcc1cf93c6e31782"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b79ee943e2e12246f873345762b83dbadd1fcfc3e832eab83272d672704dbdea"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e9418d87b55c469c2f1328246d4533238cdc43134c695d6b9a6b7a87da49d463"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:898d86c22640fe52ffe02b4abc043a3c6754b4875837599e739be440966dffa1"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b3ad20cd035392f2b91a6466e24643990c1378beda80c9bda87a3a41e841e838"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-win32.whl", hash = "sha256:db34eefaf992c84460ab26ecf2fc2bdcc68965d6becfae8182cae0efeaa3e8f8"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_amd64.whl", hash = "sha256:173054351d863ddada188284e309e87a9f030132c8d534e05458971ff061aa1e"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_arm64.whl", hash = "sha256:22c8d0873168a54f614e381559ea83d547b2cc5488b95b049bdc92667586bd5f"},
|
||||
{file = "sqlalchemy-2.1.5-py3-none-any.whl", hash = "sha256:2f77b77b716f005b2dc3cc059d56df6a919bfb5b439e4149890a9df6c36be9e3"},
|
||||
{file = "sqlalchemy-2.1.5.tar.gz", hash = "sha256:0df76cf1ca680ad745bd378a6299a058611a8a784eeaeb4ed78ad4a0b7ce2003"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5f9400b1234555b9ae78450123dec7aaf045d8d978a1e94967437ebe561ae120"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d106c2191a3a0dea6873c96ab909f31c13978913714a25023f492e9ccc8ffcc7"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ca3a128260fb3aa1c33554f1993bc5ddf3f82375697028c49e7b411ab80b84"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8c34c200a124a1a5769671613b3fc05a21d0a50f7ad942cf0ee12d4390a112c"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:17bc5f508f5e5cee2722a5a14af0a48768b40e1fc5d1bfc92abf6ac587636bd7"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c757e13b63a25d59a2566c9b2e77adf14d969207a375ca82a9b4fa1a04b37845"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73aa8d2f911a54d1310e8258a752207e2d7624f4db46e2320d734fe7218320a1"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314-win32.whl", hash = "sha256:1927eabf26265ed962627e7a64626d9058da29cc0faea341091302ec108ce305"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314-win_amd64.whl", hash = "sha256:fd1154c6394892e727ff234273f5eb2b4319e5714917b016602435d77add8dcb"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314-win_arm64.whl", hash = "sha256:10a4fe106adbb46c9452f750d8a5d4013d9a7e9752f63631c3b255c062b748d0"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea5f3dd7450669acbfee7813d3a2a13876806b1d9e4c1626a7bf587c33c68472"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4e4782514c16753f8bce3dd925cd37d26c847bead06e179af6fa4993ce83e88"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da21b36d60f47dd04042e9372ffb3d1ededf5c60a76d43f8c3592221fb2d41cd"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:048d1beb9bfd7ca1afbcfdb8da9519712827df6cccc80a225715ce387d37bb3b"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8de444335e3723959913f6635d579276a3517b8c19cbdfaefb04e73b5480e88b"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:231d0fd85866f847c34c915116c386c83e677ab6f79a94c835b7ae7313b86095"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf937348d4052eac6cfa93c809ad214be75b1f057e3fc0dd2e374a814a23a16b"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314t-win32.whl", hash = "sha256:bc8b805fb04472e0f9cb3bc6aad90f24b202afc274b7b8bbd48042130e82997a"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314t-win_amd64.whl", hash = "sha256:84033cab9fec2a515a62d76a7b4acafa83339917db9ea911b65b017928ae7dd8"},
|
||||
{file = "sqlalchemy-2.1.6-cp314-cp314t-win_arm64.whl", hash = "sha256:96ace18758136905f8d5588ec35d6ae4fdc29ce4e4902518263ca33abf10de04"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:89d05a1ef531a1fee86d68e731c428b773bd37aabf743bf63dc3709c86f7254d"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e12763cb2a400879c6dfe34995a032f707de635c8cad14d6ba777a1250be90b"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6e1a9ae71366164800942c477e663b31f3accef55d2e039cb7919fdb54b0086"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9bd573f8da863781ace89950d57a959397d5048b1cf69931c29b2b4ce1405bd7"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0d0424a4189f95886c78b48bb500a1dd7ae6dbae76deb552b15813dc298d9c8f"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:cc60fffea328f38187a4fce43f99c6bbf930de2b87b78251f43f2ba64a1af622"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c390f76f2fe408aa1a3121f3ca3d5c15698e14d913197417355fe3dc016f787d"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315-win32.whl", hash = "sha256:79c1c20dffd4d8d7e669f096c88f261da8ba3646939cd3eb6faeccb92bae99c3"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315-win_amd64.whl", hash = "sha256:f1e78019147ff7e08afaffb88fc3a0aa795171b147eacfc7ff873ce7a0d37dff"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315-win_arm64.whl", hash = "sha256:3974e33fc322b645eedeac8a3dea93022466fee41a6385c744a6656aba293cd4"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e269c68ec200538ae3fa7f9e8d8ed2b4b34d974c25945286ccc2a488438acf32"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b25199a1b499da643de52ab73e972eb07e55e2a95bb99eaa491846098fa2214c"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:641a3742dac4b8f325667b585097de0f1d20eb3498506c92d5f04c20b3a6c823"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d96186af967d7ae3f76d1e9a8fb70b5beb0f0dbdacfd3b418a946fffbb8781ff"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:5a9b4cbc5f5cda3c9429e1ac503a57f3b95c59886f26af6ffb44e2e7628b7df8"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b7ddb305126d34fda64c3fe4632a43fb9b724da6cc7d1dedd8434e6c783b285b"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:70b435f719d7156623f49ac6539f30dd8471500b5acf399f3b318c1c17f6fa7f"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315t-win32.whl", hash = "sha256:654192c9f192459ee5f63e6606cc6dd8c50681c28e0689d2a494fc9522b357c1"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315t-win_amd64.whl", hash = "sha256:a053a9d9c9ccc9b72d90ecdcbabcbe57446defc7e8c52fc9978188f78742ac19"},
|
||||
{file = "sqlalchemy-2.1.6-cp315-cp315t-win_arm64.whl", hash = "sha256:61dc3fa488208a2f03e0e25ad32950dc024545291acc7348f2e550ea1bc5abb4"},
|
||||
{file = "sqlalchemy-2.1.6-py3-none-any.whl", hash = "sha256:106878cf3795cd3d357e2e858609f9e44f20f19b2d8e48a3532bef3f90b03fbe"},
|
||||
{file = "sqlalchemy-2.1.6.tar.gz", hash = "sha256:499eae5c9051efe96cb9a73ec2fbde8ffa4186f102b9094ebab8fcc9d3b99f52"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -789,14 +789,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "wassima"
|
||||
version = "2.1.4"
|
||||
version = "2.1.5"
|
||||
requires_python = ">=3.7"
|
||||
summary = "Access your OS root certificates with utmost ease"
|
||||
groups = ["default"]
|
||||
marker = "sys_platform != \"emscripten\""
|
||||
files = [
|
||||
{file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"},
|
||||
{file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"},
|
||||
{file = "wassima-2.1.5-py3-none-any.whl", hash = "sha256:53d9a56cf55fc969f8cdd1993f8dc69f857d1c32229fb9084dc2b267044b6dde"},
|
||||
{file = "wassima-2.1.5.tar.gz", hash = "sha256:b02c1985e527c9539396cc85b909857d810dafaaa318a30266e585a0e7135f54"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -23,7 +23,7 @@ repos = [
|
||||
types_or = ["python", "pyi"],
|
||||
args = [],
|
||||
require_serial = true,
|
||||
additional_dependencies = ["ruff==0.16.10"],
|
||||
additional_dependencies = ["ruff==0.17.2"],
|
||||
},
|
||||
{
|
||||
id = "ruff-format",
|
||||
@@ -32,7 +32,7 @@ repos = [
|
||||
language = "python",
|
||||
types_or = ["python", "pyi"],
|
||||
require_serial = true,
|
||||
additional_dependencies = ["ruff==0.16.10"],
|
||||
additional_dependencies = ["ruff==0.17.2"],
|
||||
},
|
||||
{
|
||||
id = "basedpyright",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.12.0"
|
||||
version = "0.14.2"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
@@ -33,7 +33,7 @@ plyngent = "plyngent.cli:main"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright==1.40.2",
|
||||
"ruff==0.16.10",
|
||||
"ruff==0.17.2",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-asyncio>=1.4.0",
|
||||
]
|
||||
|
||||
+57
-18
@@ -56,10 +56,20 @@ if TYPE_CHECKING:
|
||||
|
||||
# Per-conversation tracking of lines read with ``with_lineno=True``, keyed by
|
||||
# resolved file path, with the file's mtime at read time (so stale line numbers
|
||||
# after external/process mutations are detected). Set to a fresh ``dict`` at the
|
||||
# start of each user turn via :func:`reset_lineno_tracker`; cleared after the
|
||||
# assistant finishes. Tool handlers read/write via :func:`get_lineno_read_files` /
|
||||
# :func:`lineno_read_lines` / :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
|
||||
# after external/process mutations are detected). The dict itself is owned by
|
||||
# the :class:`ChatAgent` (a fresh one per user turn) and bound into the
|
||||
# consuming task's context via :func:`reset_lineno_tracker` /
|
||||
# :func:`bind_lineno_tracker`; tool handlers read/write it via
|
||||
# :func:`get_lineno_read_files` / :func:`lineno_read_lines` /
|
||||
# :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
|
||||
#
|
||||
# The binding lives in the *consuming task's* context: the host wraps each turn
|
||||
# attempt in its own task (auto-retry, Ctrl+C then ``/retry``), and
|
||||
# ``asyncio.create_task`` copies the context of the caller — not of the previous
|
||||
# attempt. Retrying therefore re-binds the *same* turn-scoped dict; without
|
||||
# that, ``mark_lineno_read`` would silently drop every numbered read (the
|
||||
# variable is unset in the new task) and ``edit_lineno`` would reject all edits
|
||||
# even after the model re-reads the file as its own error message instructs.
|
||||
_lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = ContextVar(
|
||||
"_lineno_read_files",
|
||||
default=None,
|
||||
@@ -67,7 +77,7 @@ _lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = Context
|
||||
|
||||
|
||||
def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
|
||||
"""Replace the tracker with a fresh empty mapping; return the previous token.
|
||||
"""Bind a fresh empty tracker; return the previous token.
|
||||
|
||||
Call at the start of a new user turn. Restore with
|
||||
``_lineno_read_files.reset(token)`` when the turn completes.
|
||||
@@ -75,6 +85,18 @@ def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
|
||||
return _lineno_read_files.set({})
|
||||
|
||||
|
||||
def bind_lineno_tracker(
|
||||
tracker: dict[str, tuple[set[int], int]],
|
||||
) -> Token[dict[str, tuple[set[int], int]] | None]:
|
||||
"""Bind an agent-owned tracker dict; return the previous token.
|
||||
|
||||
``retry`` uses this to re-bind the current turn's dict in a new attempt
|
||||
task, so numbered reads from before the failure stay valid for edits after
|
||||
it (the turn did not end, so its reads should not either).
|
||||
"""
|
||||
return _lineno_read_files.set(tracker)
|
||||
|
||||
|
||||
def get_lineno_read_files() -> set[str]:
|
||||
"""Return the current turn's set of line-numbered file paths.
|
||||
|
||||
@@ -350,6 +372,9 @@ class ChatAgent:
|
||||
reminder_last_band: int = 0,
|
||||
provider_tools: Sequence[dict[str, object]] | None = None,
|
||||
) -> None:
|
||||
# Lines read with_lineno this turn (shared dict bound into each attempt
|
||||
# task's context; see the module note above _lineno_read_files).
|
||||
self._lineno_tracker: dict[str, tuple[set[int], int]] = {}
|
||||
self.client = client
|
||||
self.model = model
|
||||
self.tools = tools
|
||||
@@ -676,14 +701,20 @@ class ChatAgent:
|
||||
*images* ride along with the text as content parts, which is the shape
|
||||
the model sees an attachment in (see ``agent.images``).
|
||||
"""
|
||||
_ = reset_lineno_tracker()
|
||||
self._ensure_system_prompt()
|
||||
user_msg = UserChatMessage(content=user_content(user_text, images))
|
||||
self.messages.append(user_msg)
|
||||
await self._persist(user_msg)
|
||||
self._persist_from = len(self.messages)
|
||||
async for event in self._run_from_user_message(user_msg):
|
||||
yield event
|
||||
# A new user turn gets a fresh tracker; the dict is owned by the agent
|
||||
# so a retry (new task, fresh context copy) can re-bind the same dict.
|
||||
self._lineno_tracker = {}
|
||||
token = bind_lineno_tracker(self._lineno_tracker)
|
||||
try:
|
||||
self._ensure_system_prompt()
|
||||
user_msg = UserChatMessage(content=user_content(user_text, images))
|
||||
self.messages.append(user_msg)
|
||||
await self._persist(user_msg)
|
||||
self._persist_from = len(self.messages)
|
||||
async for event in self._run_from_user_message(user_msg):
|
||||
yield event
|
||||
finally:
|
||||
_lineno_read_files.reset(token)
|
||||
|
||||
def _resolve_aside_tools(
|
||||
self,
|
||||
@@ -803,8 +834,16 @@ class ChatAgent:
|
||||
user_msg = self.messages[user_index]
|
||||
assert isinstance(user_msg, UserChatMessage)
|
||||
self._ensure_system_prompt()
|
||||
# Already-committed rows stay; write any the failed rollback could not.
|
||||
if self._persist_from < len(self.messages):
|
||||
await self._persist_range(self._persist_from, len(self.messages))
|
||||
async for event in self._run_from_user_message(user_msg):
|
||||
yield event
|
||||
# This attempt runs in a new task whose context copy has no tracker
|
||||
# bound; re-bind the turn's dict so numbered reads before the failure
|
||||
# stay valid for edits after it, and reads during the retry land in the
|
||||
# same dict the next tool call sees.
|
||||
token = bind_lineno_tracker(self._lineno_tracker)
|
||||
try:
|
||||
# Already-committed rows stay; write any the failed rollback could not.
|
||||
if self._persist_from < len(self.messages):
|
||||
await self._persist_range(self._persist_from, len(self.messages))
|
||||
async for event in self._run_from_user_message(user_msg):
|
||||
yield event
|
||||
finally:
|
||||
_lineno_read_files.reset(token)
|
||||
|
||||
+16
-4
@@ -129,20 +129,27 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
|
||||
return text or None
|
||||
|
||||
|
||||
def _setup_hooks(*, interactive: bool, auto_continue: bool, notify: NotifyConfig) -> None:
|
||||
def _setup_hooks(
|
||||
*,
|
||||
interactive: bool,
|
||||
auto_continue: bool,
|
||||
notify: NotifyConfig,
|
||||
workspace: Path | None = None,
|
||||
) -> None:
|
||||
"""Install limit hooks (workspace policy is set on ReplState).
|
||||
|
||||
``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so
|
||||
``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits
|
||||
without a prompt; otherwise non-interactive runs keep today's deny. The
|
||||
prompt-wait notifier only makes sense for a human at a terminal, so a
|
||||
non-interactive run clears it whatever ``[notify]`` says.
|
||||
non-interactive run clears it whatever ``[notify]`` says. *workspace* is
|
||||
what a notification names, so two chats are told apart.
|
||||
"""
|
||||
from plyngent.cli.notify import install_notifier
|
||||
from plyngent.prompting import set_prompt_wait_hook
|
||||
|
||||
if interactive:
|
||||
_ = install_notifier(notify)
|
||||
_ = install_notifier(notify, workspace=workspace)
|
||||
else:
|
||||
set_prompt_wait_hook(None)
|
||||
if interactive or auto_continue:
|
||||
@@ -258,7 +265,12 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
|
||||
_warn_recoverable_providers(store.recoverable_providers)
|
||||
|
||||
auto_effective = auto_continue or store.agent_config.auto_continue_limits
|
||||
_setup_hooks(interactive=interactive, auto_continue=auto_effective, notify=store.notify_config)
|
||||
_setup_hooks(
|
||||
interactive=interactive,
|
||||
auto_continue=auto_effective,
|
||||
notify=store.notify_config,
|
||||
workspace=workspace,
|
||||
)
|
||||
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
|
||||
from plyngent.cli.state import YoloMode
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ if TYPE_CHECKING:
|
||||
_TOOL_RESULT_PREVIEW = 120
|
||||
_TOOL_ARGS_PREVIEW = 80
|
||||
_ASK_SUBJECT_PREVIEW = 60
|
||||
_PTY_PAYLOAD_PREVIEW = 40
|
||||
# ``run_argv_batch`` lists the commands it runs above its ``* Run batch `` row — one
|
||||
# line per command, a ``pipe_out`` chain joined into one — elided past this width.
|
||||
_RUN_BATCH_CMD_LIMIT = 80
|
||||
@@ -631,6 +632,35 @@ def _pty_session_line(verb: str, detail: Callable[[str, str], str]) -> PrettyLin
|
||||
)
|
||||
|
||||
|
||||
def _pty_payload(text: str, limit: int) -> str:
|
||||
"""A PTY payload as one quoted, escaped fragment, shortened to *limit*.
|
||||
|
||||
``repr`` keeps it on one line and makes what is invisible visible: a
|
||||
trailing newline shows as ``\\n``, an ESC as ``\\x1b``, and a payload made
|
||||
only of spaces can be told apart from an empty one.
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return repr(text)
|
||||
head = repr(text[:limit])
|
||||
return f"{head[:-1]}…{head[-1]}"
|
||||
|
||||
|
||||
def _pty_write_prefix(verb: str) -> Callable[[str], str]:
|
||||
"""``* PTY <verb> <session> '<payload>' ``: what was sent, escaped and cut.
|
||||
|
||||
The payload is the one thing about a write worth seeing; the outcome line
|
||||
still carries the exact size. ``ask_into_pty`` never uses this — the
|
||||
human's answer must not be shown.
|
||||
"""
|
||||
|
||||
def prefix(args_json: str) -> str:
|
||||
payload = _json_str_arg(args_json, "data") or ""
|
||||
shown = _pty_payload(payload, _PTY_PAYLOAD_PREVIEW) if payload else "?"
|
||||
return _pretty_prefix(f"* PTY {verb} {_pty_session_label(args_json)} {shown} ")
|
||||
|
||||
return prefix
|
||||
|
||||
|
||||
def _open_pty_prefix(args_json: str) -> str:
|
||||
argv = _json_list_arg(args_json, "command")
|
||||
cmd = shlex.join(argv) if argv else "?"
|
||||
@@ -942,8 +972,8 @@ _PRETTY_LINES: dict[str, PrettyLine] = {
|
||||
"skill_edit": PrettyLine(prefix=_skill_edit_prefix, detail=_skill_edit_detail),
|
||||
"open_pty": PrettyLine(prefix=_open_pty_prefix, detail=_open_pty_detail),
|
||||
"read_pty": _pty_session_line("Read", _read_pty_detail),
|
||||
"write_pty": _pty_session_line("Write", _pty_write_detail),
|
||||
"write_pty_keys": _pty_session_line("Keys", _pty_write_detail),
|
||||
"write_pty": PrettyLine(prefix=_pty_write_prefix("Write"), detail=_pty_write_detail),
|
||||
"write_pty_keys": PrettyLine(prefix=_pty_write_prefix("Keys"), detail=_pty_write_detail),
|
||||
"ask_into_pty": _pty_session_line("Ask", _pty_write_detail),
|
||||
"close_pty": _pty_session_line("Close", _close_pty_detail),
|
||||
"ask_user_line": _ask_line("", "question", _ask_detail),
|
||||
|
||||
+91
-14
@@ -6,6 +6,7 @@ import shutil
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest
|
||||
@@ -32,7 +33,6 @@ from plyngent.tools.workspace import AccessMode, parse_access_mode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
|
||||
|
||||
@@ -69,11 +69,33 @@ _CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this
|
||||
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
|
||||
_CONTINUE_AUTO_TOKEN = "yyy"
|
||||
|
||||
# The answers each prompt accepts, as notification buttons: the key is the
|
||||
# token the terminal read takes, the label is what a human reads there.
|
||||
_LIMIT_CHOICES: tuple[tuple[str, str], ...] = (
|
||||
("y", "Continue"),
|
||||
(_CONTINUE_AUTO_TOKEN, "Continue, stop asking"),
|
||||
("n", "Stop here"),
|
||||
)
|
||||
|
||||
# Batch confirm answers: ``y`` allows the round's calls, ``m`` reviews them one
|
||||
# at a time through the per-call prompt.
|
||||
_BATCH_YES_TOKENS = frozenset({"y", "yes"})
|
||||
_BATCH_MANUAL_TOKENS = frozenset({"m", "manual", "each"})
|
||||
|
||||
_BATCH_CHOICES: tuple[tuple[str, str], ...] = (
|
||||
("y", "Allow all"),
|
||||
("m", "Review one by one"),
|
||||
("n", "Deny"),
|
||||
)
|
||||
|
||||
# Skill writes: ``o`` allows this change, ``s`` the whole directory for the rest
|
||||
# of the chat.
|
||||
_SKILL_CHOICES: tuple[tuple[str, str], ...] = (
|
||||
("o", "Allow once"),
|
||||
("s", "This chat"),
|
||||
("n", "Deny"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AutoContinue:
|
||||
@@ -202,9 +224,22 @@ def _decide_continue_limit(reason: str) -> bool:
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return False
|
||||
with prompt_wait("limit", "limit reached — continue?", reason):
|
||||
with prompt_wait("limit", "limit reached — continue?", reason, choices=_LIMIT_CHOICES) as wait:
|
||||
_echo_continue_limit(reason)
|
||||
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
|
||||
source = _answer_source(wait)
|
||||
if source is None:
|
||||
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
|
||||
else:
|
||||
# Same read as the timed confirms: stdin or the notification,
|
||||
# whichever answers first, with the terminal's default on silence.
|
||||
# The prompt line still goes to the terminal: the buttons elsewhere
|
||||
# do not say what typing here would do.
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(_CONTINUE_PROMPT)
|
||||
_ = sys.stderr.flush()
|
||||
token = (_read_answer(None, wait) or "").strip().lower()
|
||||
if not token:
|
||||
token = "y"
|
||||
if token == _CONTINUE_AUTO_TOKEN:
|
||||
_auto_continue.turn = True
|
||||
return True
|
||||
@@ -239,11 +274,34 @@ def _confirm_with(wait: PromptAnswerSource | None, prompt: str, *, default: bool
|
||||
return confirm(prompt, default=default, source=_answer_source(wait))
|
||||
|
||||
|
||||
def _tool_facts(args: Mapping[str, object]) -> tuple[str, ...]:
|
||||
"""What a call is about, short enough to travel in a notification.
|
||||
|
||||
Only the *object* — the program or the file — never an argument, a URL or a
|
||||
piece of code: those stay in the reason, which a notification carries only
|
||||
when ``include_reason`` allows it.
|
||||
"""
|
||||
facts: list[str] = []
|
||||
argv = args.get("argv")
|
||||
if isinstance(argv, list) and argv and isinstance(argv[0], str):
|
||||
facts.append(f"program: {Path(argv[0]).name}")
|
||||
for key in ("path", "dst", "src"):
|
||||
value = args.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
facts.append(f"target: {Path(value).name}")
|
||||
break
|
||||
return tuple(facts)
|
||||
|
||||
|
||||
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||
"""True allow; False deny; non-empty str = deny with comment for the model."""
|
||||
del args
|
||||
try:
|
||||
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason) as wait:
|
||||
with prompt_wait(
|
||||
"confirm",
|
||||
f"tool {name!r} needs confirmation",
|
||||
reason,
|
||||
facts=_tool_facts(args),
|
||||
) as wait:
|
||||
_echo_tool_confirm(name, reason)
|
||||
allowed = _confirm_with(wait, "Allow this tool call?", default=False)
|
||||
except NonInteractiveError:
|
||||
@@ -357,7 +415,7 @@ def prompt_policy_command_confirm(
|
||||
try:
|
||||
with (
|
||||
pause_task_cancel_for_prompt(),
|
||||
prompt_wait("policy", f"allow command {basename!r}?", reason) as wait,
|
||||
prompt_wait("policy", f"allow command {basename!r}?", reason, expires_in=timeout_seconds) as wait,
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
@@ -404,7 +462,7 @@ def prompt_policy_fetch_confirm(
|
||||
try:
|
||||
with (
|
||||
pause_task_cancel_for_prompt(),
|
||||
prompt_wait("policy", f"allow fetch_local {host!r}?", reason) as wait,
|
||||
prompt_wait("policy", f"allow fetch_local {host!r}?", reason, expires_in=timeout_seconds) as wait,
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
@@ -453,7 +511,14 @@ def prompt_skill_write_confirm(
|
||||
try:
|
||||
with (
|
||||
pause_task_cancel_for_prompt(),
|
||||
prompt_wait("skills", f"allow {action} in {skill_dir.name!r}?", detail) as wait,
|
||||
prompt_wait(
|
||||
"skills",
|
||||
f"allow {action} in {skill_dir.name!r}?",
|
||||
detail,
|
||||
choices=_SKILL_CHOICES,
|
||||
facts=(f"path: {skill_dir}",),
|
||||
expires_in=timeout_seconds,
|
||||
) as wait,
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
@@ -494,9 +559,10 @@ def prompt_directory_access_confirm(
|
||||
that level instead. The host's hook decides YOLO/``--yes`` before calling
|
||||
this. Timeout, cancel, or non-interactive → ``None`` (deny).
|
||||
"""
|
||||
kind = "directory" if path.is_dir() else "file"
|
||||
lines = [
|
||||
f"request: {mode.name.lower()} access to {path}",
|
||||
f"kind: {'directory' if path.is_dir() else 'file'}",
|
||||
f"kind: {kind}",
|
||||
]
|
||||
if reason:
|
||||
lines.append(f"why: {reason}")
|
||||
@@ -504,7 +570,13 @@ def prompt_directory_access_confirm(
|
||||
try:
|
||||
with (
|
||||
pause_task_cancel_for_prompt(),
|
||||
prompt_wait("access", f"allow {mode.name.lower()} access to {path}?", reason) as wait,
|
||||
prompt_wait(
|
||||
"access",
|
||||
f"allow {mode.name.lower()} access to {path}?",
|
||||
reason,
|
||||
facts=(f"kind: {kind}",),
|
||||
expires_in=timeout_seconds,
|
||||
) as wait,
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
@@ -543,11 +615,11 @@ def prompt_directory_access_confirm(
|
||||
|
||||
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||
"""Async confirm: True allow, False deny, str = deny with user comment."""
|
||||
del args
|
||||
facts = _tool_facts(args)
|
||||
try:
|
||||
# Box is printed inside the worker thread via confirm's backend.
|
||||
def _run() -> bool:
|
||||
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason) as wait:
|
||||
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason, facts=facts) as wait:
|
||||
_echo_tool_confirm(name, reason)
|
||||
return _confirm_with(wait, "Allow this tool call?", default=False)
|
||||
|
||||
@@ -590,8 +662,13 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
|
||||
if not backend.is_interactive():
|
||||
# Non-interactive: deny, like the default of the single prompt.
|
||||
return BatchConfirmDecision(allowed=False)
|
||||
names = ", ".join(request.name for request in requests)
|
||||
with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names) as wait:
|
||||
tools = tuple(dict.fromkeys(request.name for request in requests))
|
||||
with prompt_wait(
|
||||
"batch",
|
||||
f"{len(requests)} tool calls need confirmation",
|
||||
choices=_BATCH_CHOICES,
|
||||
facts=tools,
|
||||
) as wait:
|
||||
backend.echo()
|
||||
backend.secho(format_batch_confirm_box(requests), fg="yellow")
|
||||
backend.secho("m = review them one call at a time", fg="bright_black")
|
||||
|
||||
+219
-31
@@ -3,15 +3,22 @@
|
||||
A prompt blocks the turn, so someone who switched to another window learns about
|
||||
it only when they come back. The notifier announces a prompt once it has waited
|
||||
``grace_seconds`` — answering at the terminal stays silent — and, where the
|
||||
platform allows it, offers the answer itself: allow / deny / review buttons for
|
||||
a confirmation, a reply field for a question. A prompt that can be answered from
|
||||
the notification reads from both surfaces from its first poll, since the
|
||||
announcement only appears once the grace period is over.
|
||||
platform allows it, offers the answer itself: the prompt's own answers as
|
||||
buttons (allow / deny for a confirmation), a reply field for a question. It says
|
||||
what is asked: a question travels in the body where it is read whole, the object
|
||||
of a confirmation (a program or a file name) rides along with it, a prompt that
|
||||
takes a default answer says when not answering gives it, and the workspace it
|
||||
belongs to comes last. A prompt that can be answered from the notification reads
|
||||
from both surfaces from its first poll, since the announcement only appears once
|
||||
the grace period is over. A prompt that follows an answer given on the
|
||||
notification is announced at once: the human is already looking at it.
|
||||
|
||||
Two channels. The desktop one is the optional ``desktop-notifier`` extra
|
||||
(``pip install plyngent[notify]``); the terminal one writes an OSC escape
|
||||
sequence to the controlling terminal, which every modern terminal turns into a
|
||||
native notification without any dependency at all.
|
||||
native notification without any dependency at all. A 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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,6 +26,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
@@ -38,7 +46,6 @@ if TYPE_CHECKING:
|
||||
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
REVIEW = "review"
|
||||
|
||||
#: Kinds whose notification carries an answer, and the capability it needs: a
|
||||
#: confirmation gets buttons, a question gets a reply field.
|
||||
@@ -48,17 +55,33 @@ _ANSWER_CAPABILITY: dict[str, str] = {
|
||||
"policy": "buttons",
|
||||
"access": "buttons",
|
||||
"skills": "buttons",
|
||||
"limit": "buttons",
|
||||
"ask": "reply",
|
||||
}
|
||||
|
||||
#: The answers a confirmation offers when the prompt names none of its own.
|
||||
_BUTTONS: tuple[tuple[str, str], ...] = (
|
||||
(ALLOW, "Allow once"),
|
||||
(DENY, "Deny"),
|
||||
(REVIEW, "Go to terminal"),
|
||||
)
|
||||
|
||||
_MAX_TITLE = 80
|
||||
_MAX_MESSAGE = 200
|
||||
#: One body line is capped so a single long line cannot starve the others.
|
||||
_MAX_BODY_LINE = 100
|
||||
#: Prompt families a notification never announces: the value being asked for
|
||||
#: would have to travel back through a surface that outlives the prompt.
|
||||
_QUIET_KINDS = frozenset({"secret"})
|
||||
#: What not answering means, for a prompt that has a timed default.
|
||||
_DEADLINE = "no answer in {seconds:g}s = deny"
|
||||
|
||||
#: Seconds a desktop notification may stay up; ``0`` is the freedesktop value
|
||||
#: for "until dismissed" (``-1`` would hand the notification server's default
|
||||
#: back, which is a few seconds — after that the notification collapses into
|
||||
#: the tray, where nobody waiting on a prompt looks). Urgency stays at the
|
||||
#: library default: ``critical`` keeps the notification up just the same, but
|
||||
#: paints it as urgent and asks the platform's Focus mode to let it through.
|
||||
_NO_EXPIRY = 0
|
||||
|
||||
|
||||
def _clean(text: str, limit: int) -> str:
|
||||
@@ -68,6 +91,70 @@ def _clean(text: str, limit: int) -> str:
|
||||
return stripped if len(stripped) <= limit else stripped[: limit - 1] + "…"
|
||||
|
||||
|
||||
def _deadline(wait: PromptWait) -> str:
|
||||
"""What not answering means, when the prompt takes a default answer."""
|
||||
if wait.expires_in is None:
|
||||
return ""
|
||||
return _clean(_DEADLINE.format(seconds=wait.expires_in), _MAX_BODY_LINE)
|
||||
|
||||
|
||||
def _expiry(wait: PromptWait) -> int | None:
|
||||
"""How long the popup may stay up: as long as the prompt waits for an answer.
|
||||
|
||||
``None`` keeps it until it is dismissed — a prompt with no deadline has no
|
||||
moment at which the notification is stale.
|
||||
"""
|
||||
if wait.expires_in is None or wait.expires_in <= 0:
|
||||
return None
|
||||
return max(1, math.ceil(wait.expires_in))
|
||||
|
||||
|
||||
def _shorten_home(path: Path) -> str:
|
||||
"""The path as the human types it: ``~`` for the home directory."""
|
||||
home = Path.home()
|
||||
if path == home:
|
||||
return "~"
|
||||
with contextlib.suppress(ValueError):
|
||||
return f"~/{path.relative_to(home).as_posix()}"
|
||||
return str(path)
|
||||
|
||||
|
||||
def _body_parts(wait: PromptWait, *, include_reason: bool) -> list[str]:
|
||||
"""The lines the prompt asks for, in reading order.
|
||||
|
||||
The question first, then the reason when the config allows it, then what the
|
||||
prompt is about: a host that hides the reason still says what is being asked
|
||||
and what it concerns.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if wait.body:
|
||||
parts.append(wait.body)
|
||||
if include_reason and wait.detail:
|
||||
parts.append(wait.detail)
|
||||
if wait.facts:
|
||||
parts.append(" · ".join(wait.facts))
|
||||
return [_clean(part, _MAX_BODY_LINE) for part in parts]
|
||||
|
||||
|
||||
def _body(wait: PromptWait, *, include_reason: bool, multiline: bool, source: str = "") -> str:
|
||||
"""The notification body, with the deadline and the source kept for last.
|
||||
|
||||
The lines are filled in reading order until the budget is gone — a long
|
||||
reason drops out before the deadline does — and each one is capped, so no
|
||||
single line can starve the rest. A desktop popup wraps, so its body keeps
|
||||
the lines apart; a terminal escape sequence carries the body on one line.
|
||||
"""
|
||||
tail = [line for line in (_deadline(wait), _clean(source, _MAX_BODY_LINE)) if line]
|
||||
budget = _MAX_MESSAGE - sum(len(line) + 1 for line in tail)
|
||||
kept: list[str] = []
|
||||
for line in _body_parts(wait, include_reason=include_reason):
|
||||
if not line or len(line) + 1 > budget:
|
||||
continue
|
||||
kept.append(line)
|
||||
budget -= len(line) + 1
|
||||
return "\n".join([*kept, *tail]) if multiline else " ".join([*kept, *tail])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Desktop channel (optional `desktop-notifier` extra)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -83,6 +170,9 @@ class DesktopRequest:
|
||||
reply_field: bool = False
|
||||
on_answer: Callable[[str], None] | None = None
|
||||
on_reply: Callable[[str], None] | None = None
|
||||
on_click: Callable[[], None] | None = None
|
||||
#: Seconds the popup may stay up; ``None`` keeps it until it is dismissed.
|
||||
timeout: int | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -171,8 +261,16 @@ class DesktopNotifierChannel:
|
||||
send(
|
||||
title=request.title,
|
||||
message=request.message,
|
||||
# A prompt holds the turn until it is answered, so the
|
||||
# notification must still be on screen when the human looks
|
||||
# back: it stays up until it is dismissed — or, for a prompt
|
||||
# that gives up on its own, until that deadline.
|
||||
timeout=_NO_EXPIRY if request.timeout is None else request.timeout,
|
||||
buttons=buttons,
|
||||
reply_field=reply,
|
||||
# Clicking the body is the default action: it takes the
|
||||
# human back to the terminal instead of answering.
|
||||
on_clicked=request.on_click,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -218,6 +316,7 @@ def load_desktop_channel() -> DesktopChannel | None:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_OSC9 = "\x1b]9;{title}: {message}\x07"
|
||||
_OSC9_TITLE_ONLY = "\x1b]9;{title}\x07"
|
||||
_OSC777 = "\x1b]777;notify;{title};{message}\x07"
|
||||
_BELL = "\a"
|
||||
#: VTE (GNOME Terminal, Tilix, Terminator, …) speaks OSC 777 from this version on.
|
||||
@@ -268,7 +367,9 @@ def terminal_sequence(protocol: str, title: str, message: str, *, multiplexer: s
|
||||
safe_title = _clean(title, _MAX_TITLE)
|
||||
safe_message = _clean(message, _MAX_MESSAGE)
|
||||
if protocol == "osc9":
|
||||
seq = _OSC9.format(title=safe_title, message=safe_message)
|
||||
# OSC 9 joins the two with a colon, which would dangle on a body-less one.
|
||||
template = _OSC9 if safe_message else _OSC9_TITLE_ONLY
|
||||
seq = template.format(title=safe_title, message=safe_message)
|
||||
else:
|
||||
seq = _OSC777.format(title=safe_title, message=safe_message)
|
||||
if multiplexer == "tmux":
|
||||
@@ -326,12 +427,16 @@ class WaitHandle:
|
||||
#: one from the first moment it blocks — the notification itself is only
|
||||
#: shown after the grace period.
|
||||
supports_actions: bool
|
||||
#: Whether this prompt continues an exchange the human answered on the
|
||||
#: notification: it is then announced at once, and not throttled.
|
||||
handoff: bool
|
||||
|
||||
def __init__(self, notifier: WaitNotifier, wait: PromptWait) -> None:
|
||||
self.notifier = notifier
|
||||
self.wait = wait
|
||||
self.identifier = None
|
||||
self.supports_actions = notifier.answer_capability(wait.kind) is not None
|
||||
self.handoff = notifier.take_handoff()
|
||||
self._answers: SimpleQueue[str] = SimpleQueue()
|
||||
self._timer: threading.Timer | None = None
|
||||
self._closed: bool = False
|
||||
@@ -344,17 +449,34 @@ class WaitHandle:
|
||||
return None
|
||||
|
||||
def answer(self, key: str) -> None:
|
||||
"""Record one answer from the notification (a button, or a reply)."""
|
||||
"""Record one answer pressed on the notification (a button)."""
|
||||
if self._closed:
|
||||
return
|
||||
if key == REVIEW and self.wait.kind != "ask":
|
||||
# "review" means "I will answer in the terminal": withdraw and wait.
|
||||
self.notifier.dismiss(self)
|
||||
return
|
||||
# The human is at the notification now, so a prompt that follows this
|
||||
# answer is announced at once instead of waiting out the grace period.
|
||||
self.notifier.handoff()
|
||||
self._answers.put(key)
|
||||
|
||||
def reply(self, text: str) -> None:
|
||||
"""Record one answer typed into the notification's reply field."""
|
||||
if self._closed:
|
||||
return
|
||||
self.notifier.handoff()
|
||||
self._answers.put(text)
|
||||
|
||||
def withdraw(self) -> None:
|
||||
"""Take the notification down; the prompt keeps waiting at the terminal.
|
||||
|
||||
This is what a click on the body does, and the popup's own close button
|
||||
means the same. Nothing can raise another application's window (no
|
||||
desktop exposes that portably), which is why the bell — rung as the
|
||||
notification went out — is what said where the prompt is.
|
||||
"""
|
||||
self.notifier.dismiss(self)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
timer = threading.Timer(self.notifier.grace_seconds, self._notify)
|
||||
delay = 0.0 if self.handoff else self.notifier.grace_seconds
|
||||
timer = threading.Timer(delay, self._notify)
|
||||
timer.daemon = True
|
||||
self._timer = timer
|
||||
timer.start()
|
||||
@@ -373,10 +495,15 @@ class WaitHandle:
|
||||
self.notifier.dismiss(self)
|
||||
|
||||
def _notify(self) -> None:
|
||||
if self._closed or not self.notifier.may_notify():
|
||||
if self._closed or not self.notifier.may_notify(self):
|
||||
return
|
||||
if not self.notifier.notify_desktop(self):
|
||||
self.notifier.notify_terminal(self.wait)
|
||||
if self.notifier.notify_desktop(self):
|
||||
# The popup went to the desktop, so the human is looking at some
|
||||
# other window — possibly another virtual desktop, where a popup is
|
||||
# easy to miss. The terminal rings to say which window waits.
|
||||
self.notifier.ring()
|
||||
return
|
||||
self.notifier.notify_terminal(self.wait)
|
||||
|
||||
|
||||
class WaitNotifier:
|
||||
@@ -394,6 +521,8 @@ class WaitNotifier:
|
||||
_env: Mapping[str, str]
|
||||
_clock: Callable[[], float]
|
||||
_last_sent: float
|
||||
_handoff: bool
|
||||
_source: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -403,6 +532,7 @@ class WaitNotifier:
|
||||
write: Callable[[str], None] | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
workspace: Path | str | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
if isinstance(desktop, _Resolve):
|
||||
@@ -416,6 +546,10 @@ class WaitNotifier:
|
||||
self._env = os.environ if env is None else env
|
||||
self._clock = clock
|
||||
self._last_sent = float("-inf")
|
||||
self._handoff = False
|
||||
self._source = ""
|
||||
if workspace is not None:
|
||||
self.set_workspace(workspace)
|
||||
|
||||
# -- hook ------------------------------------------------------------- #
|
||||
|
||||
@@ -426,25 +560,73 @@ class WaitNotifier:
|
||||
def grace_seconds(self) -> float:
|
||||
return self._config.grace_seconds
|
||||
|
||||
def set_workspace(self, workspace: Path | str | None) -> None:
|
||||
"""Name the directory the prompt belongs to (``~`` for a home path).
|
||||
|
||||
A chat can be rebound to another workspace, so the label is kept on the
|
||||
notifier rather than baked into a notification: several chats look
|
||||
alike, and which one is waiting is the first thing a human needs.
|
||||
"""
|
||||
self._source = "" if workspace is None else _shorten_home(Path(workspace))
|
||||
|
||||
def ring(self) -> None:
|
||||
"""Ring the terminal's bell: the prompt is waiting *there*."""
|
||||
self._write(_BELL)
|
||||
|
||||
def handoff(self) -> None:
|
||||
"""Note that the human is answering on the notification right now."""
|
||||
self._handoff = True
|
||||
|
||||
def take_handoff(self) -> bool:
|
||||
"""Consume the handoff: this prompt is part of the same exchange."""
|
||||
handoff = self._handoff
|
||||
self._handoff = False
|
||||
return handoff
|
||||
|
||||
def _wants(self, channel: str) -> bool:
|
||||
channels = self._config.channels
|
||||
return channel in channels or "auto" in channels
|
||||
|
||||
def may_notify(self) -> bool:
|
||||
"""Whether a notification is worth sending right now."""
|
||||
def may_notify(self, handle: WaitHandle) -> bool:
|
||||
"""Whether this prompt is worth announcing right now."""
|
||||
if not self._config.enabled or not get_prompt_backend().is_interactive():
|
||||
return False
|
||||
# A secret is never announced: its value would have to come back
|
||||
# through a surface that outlives the prompt.
|
||||
if handle.wait.kind in _QUIET_KINDS:
|
||||
return False
|
||||
now = self._clock()
|
||||
if now - self._last_sent < self._config.min_interval_seconds:
|
||||
# A prompt the human reached through the notification is not a burst.
|
||||
if not handle.handoff and now - self._last_sent < self._config.min_interval_seconds:
|
||||
return False
|
||||
self._last_sent = now
|
||||
return True
|
||||
|
||||
def _message(self, wait: PromptWait) -> str:
|
||||
title = _clean(wait.title, _MAX_TITLE)
|
||||
if self._config.include_reason and wait.detail:
|
||||
return f"{title}\n{_clean(wait.detail, _MAX_MESSAGE)}"
|
||||
return title
|
||||
"""The one-line body the terminal channel carries."""
|
||||
return _body(
|
||||
wait,
|
||||
include_reason=self._config.include_reason,
|
||||
multiline=False,
|
||||
source=self._source,
|
||||
)
|
||||
|
||||
def _desktop_message(self, wait: PromptWait) -> str:
|
||||
"""The body the desktop channel carries: the same lines, kept apart."""
|
||||
return _body(
|
||||
wait,
|
||||
include_reason=self._config.include_reason,
|
||||
multiline=True,
|
||||
source=self._source,
|
||||
)
|
||||
|
||||
def _buttons(self, wait: PromptWait, capability: str) -> tuple[tuple[str, str], ...]:
|
||||
"""The answers *wait* accepts; a reply prompt has none to offer."""
|
||||
if capability == "reply":
|
||||
# The reply field carries the answer; a button beside it would only
|
||||
# be a way out, and the popup's own close is that.
|
||||
return ()
|
||||
return wait.choices or _BUTTONS
|
||||
|
||||
def desktop_channel(self) -> DesktopChannel | None:
|
||||
"""The desktop surface, resolved once (importing the extra on demand)."""
|
||||
@@ -490,16 +672,18 @@ class WaitNotifier:
|
||||
if channel is None:
|
||||
return False
|
||||
wait = handle.wait
|
||||
capability = self.answer_capability(wait.kind)
|
||||
capability = self.answer_capability(wait.kind) or ""
|
||||
try:
|
||||
handle.identifier = channel.send(
|
||||
DesktopRequest(
|
||||
title=_clean(wait.title, _MAX_TITLE),
|
||||
message=self._message(wait),
|
||||
buttons=_BUTTONS if capability == "buttons" else (),
|
||||
message=self._desktop_message(wait),
|
||||
buttons=self._buttons(wait, capability) if capability else (),
|
||||
reply_field=capability == "reply",
|
||||
on_answer=handle.answer,
|
||||
on_reply=handle.answer,
|
||||
on_reply=handle.reply,
|
||||
on_click=handle.withdraw,
|
||||
timeout=_expiry(wait),
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a notification never breaks a prompt
|
||||
@@ -529,12 +713,16 @@ class WaitNotifier:
|
||||
channel.clear(identifier)
|
||||
|
||||
|
||||
def install_notifier(config: NotifyConfig) -> WaitNotifier | None:
|
||||
"""Install the prompt-wait hook from ``[notify]`` (None clears it)."""
|
||||
def install_notifier(config: NotifyConfig, *, workspace: Path | str | None = None) -> WaitNotifier | None:
|
||||
"""Install the prompt-wait hook from ``[notify]`` (None clears it).
|
||||
|
||||
*workspace* is the directory the chat runs in: a notification names it, so
|
||||
two waiting chats are told apart.
|
||||
"""
|
||||
if not config.enabled:
|
||||
set_prompt_wait_hook(None)
|
||||
return None
|
||||
notifier = WaitNotifier(config)
|
||||
notifier = WaitNotifier(config, workspace=workspace)
|
||||
notifier.warm()
|
||||
set_prompt_wait_hook(notifier)
|
||||
return notifier
|
||||
|
||||
@@ -108,6 +108,7 @@ class ReplState:
|
||||
self.workspace = Path(self.workspace).expanduser().resolve()
|
||||
self.instance_state.workspace_root = self.workspace
|
||||
self.instance_state.workspace.root = self.workspace
|
||||
self._retarget_notifier(self.workspace)
|
||||
self._install_config_access()
|
||||
self._install_skills()
|
||||
from plyngent.cli.limits import set_auto_continue_default
|
||||
@@ -711,11 +712,24 @@ class ReplState:
|
||||
self.workspace = resolved
|
||||
self.instance_state.workspace_root = resolved
|
||||
self.instance_state.workspace.root = resolved
|
||||
# A prompt announced from here names this directory, not the one the
|
||||
# process started in.
|
||||
self._retarget_notifier(resolved)
|
||||
# Project-scope skill roots follow the workspace.
|
||||
self._install_skills()
|
||||
if hasattr(self, "agent") and self.agent.tools is not None:
|
||||
self.agent.tools.set_instance_state(self.instance_state)
|
||||
|
||||
@staticmethod
|
||||
def _retarget_notifier(workspace: Path) -> None:
|
||||
"""Point an installed prompt notifier at *workspace* (if there is one)."""
|
||||
from plyngent.cli.notify import WaitNotifier
|
||||
from plyngent.prompting import get_prompt_wait_hook
|
||||
|
||||
hook = get_prompt_wait_hook()
|
||||
if isinstance(hook, WaitNotifier):
|
||||
hook.set_workspace(workspace)
|
||||
|
||||
def _apply_session_workspace(self, row: SessionRow) -> None:
|
||||
"""Bind tools/REPL workspace to the session's directory when set."""
|
||||
if not row.workspace:
|
||||
|
||||
@@ -284,7 +284,8 @@ class NotifyConfig(Struct, omit_defaults=True):
|
||||
"""Desktop notifications shown while a prompt waits on the human.
|
||||
|
||||
A prompt is announced only after ``grace_seconds`` of waiting, so answering
|
||||
at the terminal stays silent; ``min_interval_seconds`` throttles bursts.
|
||||
at the terminal stays silent; ``min_interval_seconds`` throttles bursts, and
|
||||
a prompt that follows an answer given on the notification skips both.
|
||||
``channels`` picks the surfaces: ``auto`` tries the desktop first and falls
|
||||
back to the terminal, ``desktop`` / ``terminal`` pin one of them.
|
||||
|
||||
|
||||
@@ -125,13 +125,27 @@ class PromptWait:
|
||||
``kind`` names the prompt family (``confirm``, ``ask``, ``secret``,
|
||||
``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can
|
||||
tailor what it shows and which answers it offers; ``title`` is short and
|
||||
safe to surface anywhere, while ``detail`` carries one optional line the
|
||||
caller has already redacted.
|
||||
safe to surface anywhere. ``body`` is the prompt itself — a question, which
|
||||
the host shows as it stands — while ``detail`` carries one optional reason
|
||||
line the caller has already redacted: it can name a command or a URL, so a
|
||||
host shows it only when the human allowed that. ``facts`` are short items
|
||||
naming the *object* of the prompt (a program or a file name, never an
|
||||
argument): they travel unconditionally, so a host that hides the reason
|
||||
still says what the prompt is about. ``expires_in`` is how long the prompt
|
||||
waits before taking its default answer, which is usually "deny" — a host
|
||||
says so, because not answering is then an answer. ``choices`` are the
|
||||
answers this particular prompt accepts, as ``(key, label)`` pairs — a host
|
||||
that offers them sends the key back, so the label can stay human and the key
|
||||
stay the token the terminal read would accept. Empty means the host decides.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
title: str
|
||||
detail: str = ""
|
||||
choices: tuple[tuple[str, str], ...] = ()
|
||||
body: str = ""
|
||||
facts: tuple[str, ...] = ()
|
||||
expires_in: float | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -176,9 +190,23 @@ def get_prompt_wait_hook() -> PromptWaitHook | None:
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnswerSource | None]:
|
||||
def prompt_wait(
|
||||
kind: str,
|
||||
title: str,
|
||||
detail: str = "",
|
||||
choices: tuple[tuple[str, str], ...] = (),
|
||||
*,
|
||||
body: str = "",
|
||||
facts: tuple[str, ...] = (),
|
||||
expires_in: float | None = None,
|
||||
) -> Generator[PromptAnswerSource | None]:
|
||||
"""Announce a blocking prompt to the host; yields its answer source (or None).
|
||||
|
||||
``choices`` says which answers this prompt accepts, ``body`` what it asks,
|
||||
``facts`` what it is about and ``expires_in`` when its default answer takes
|
||||
over (see :class:`PromptWait`); a host that shows them sends the key back as
|
||||
the answer.
|
||||
|
||||
Only the outermost prompt of a nested read is announced: a confirm that asks
|
||||
a follow-up question while it is already on screen must not notify — and
|
||||
interrupt — a second time.
|
||||
@@ -189,7 +217,8 @@ def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnsw
|
||||
return
|
||||
token = _wait_depth.set(_wait_depth.get() + 1)
|
||||
try:
|
||||
with hook(PromptWait(kind, title, detail)) as source:
|
||||
wait = PromptWait(kind, title, detail, choices, body, facts, expires_in)
|
||||
with hook(wait) as source:
|
||||
yield source
|
||||
finally:
|
||||
_wait_depth.reset(token)
|
||||
@@ -542,6 +571,15 @@ def _read_with_source(
|
||||
return answer.strip()
|
||||
|
||||
|
||||
#: Titles for the prompts whose own text *is* the question: a host gives the
|
||||
#: title a line of its own and cuts it short, so the question itself goes into
|
||||
#: the body, where it is read whole.
|
||||
_ASK_TITLE = "a question is waiting"
|
||||
_CHOOSE_TITLE = "a choice is waiting"
|
||||
#: How many option labels a choice prompt names alongside its question.
|
||||
_MAX_CHOICE_FACTS = 4
|
||||
|
||||
|
||||
def ask(
|
||||
prompt: str,
|
||||
*,
|
||||
@@ -556,7 +594,7 @@ def ask(
|
||||
if not backend.is_interactive() and default is None:
|
||||
msg = f"non-interactive: cannot prompt for {prompt!r}"
|
||||
raise NonInteractiveError(msg)
|
||||
with prompt_wait("ask", prompt) as wait:
|
||||
with prompt_wait("ask", _ASK_TITLE, body=prompt) as wait:
|
||||
backend.secho(prompt, fg="yellow")
|
||||
source = wait if wait is not None and wait.supports_actions else None
|
||||
return _read_with_source("Answer", source, default=default, completions=completions)
|
||||
@@ -565,6 +603,10 @@ def ask(
|
||||
def ask_secret(prompt: str) -> str:
|
||||
"""Prompt for a secret line (no echo). Never use for values that return to the model.
|
||||
|
||||
The ``secret`` family is announced, never answered: the prompt is on screen
|
||||
at the terminal only, since a value typed into a notification would have to
|
||||
travel to get back to the process.
|
||||
|
||||
Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`.
|
||||
"""
|
||||
backend = get_prompt_backend()
|
||||
@@ -604,8 +646,9 @@ def choose(
|
||||
msg = f"non-interactive: cannot prompt for {prompt!r}"
|
||||
raise NonInteractiveError(msg)
|
||||
|
||||
facts = tuple(dict.fromkeys(option.label for option in choices[:_MAX_CHOICE_FACTS]))
|
||||
while True:
|
||||
with prompt_wait("choose", prompt):
|
||||
with prompt_wait("choose", _CHOOSE_TITLE, body=prompt, facts=facts):
|
||||
raw = backend.read_line(
|
||||
"Choice",
|
||||
default=default_display,
|
||||
|
||||
@@ -10,11 +10,9 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Match common \xNN / \uNNNN / named ctrl+ / esc sequences in write_pty_keys data.
|
||||
# Escapes and ``<name>`` key markers in write_pty_keys data.
|
||||
_HEX_BYTE = re.compile(r"\\x([0-9A-Fa-f]{2})")
|
||||
_HEX_U = re.compile(r"\\u([0-9A-Fa-f]{4})")
|
||||
_CTRL = re.compile(r"ctrl\+([a-z@\[\\\]\^_])", re.IGNORECASE)
|
||||
_KEY = re.compile(r"key=(esc|escape|enter|tab|up|down|left|right)", re.IGNORECASE)
|
||||
_SIMPLE = {
|
||||
r"\n": "\n",
|
||||
r"\r": "\r",
|
||||
@@ -23,18 +21,84 @@ _SIMPLE = {
|
||||
r"\E": "\x1b",
|
||||
r"\0": "\x00",
|
||||
}
|
||||
_SIMPLE_ESCAPE_LEN = 2
|
||||
|
||||
_KEY_MAP = {
|
||||
#: ``<name>`` markers: the keys a terminal understands, and the bytes each sends.
|
||||
_KEY_NAMES: dict[str, str] = {
|
||||
"esc": "\x1b",
|
||||
"escape": "\x1b",
|
||||
"enter": "\r",
|
||||
"tab": "\t",
|
||||
"s-tab": "\x1b[Z",
|
||||
"space": " ",
|
||||
"bs": "\x7f",
|
||||
"del": "\x1b[3~",
|
||||
"ins": "\x1b[2~",
|
||||
"home": "\x1b[H",
|
||||
"end": "\x1b[F",
|
||||
"up": "\x1b[A",
|
||||
"down": "\x1b[B",
|
||||
"right": "\x1b[C",
|
||||
"left": "\x1b[D",
|
||||
"right": "\x1b[C",
|
||||
"pgup": "\x1b[5~",
|
||||
"pgdn": "\x1b[6~",
|
||||
"f1": "\x1bOP",
|
||||
"f2": "\x1bOQ",
|
||||
"f3": "\x1bOR",
|
||||
"f4": "\x1bOS",
|
||||
"f5": "\x1b[15~",
|
||||
"f6": "\x1b[17~",
|
||||
"f7": "\x1b[18~",
|
||||
"f8": "\x1b[19~",
|
||||
"f9": "\x1b[20~",
|
||||
"f10": "\x1b[21~",
|
||||
"f11": "\x1b[23~",
|
||||
"f12": "\x1b[24~",
|
||||
}
|
||||
_SIMPLE_ESCAPE_LEN = 2
|
||||
|
||||
#: The longer spellings, so a marker can be written the way it is usually named.
|
||||
_KEY_ALIASES: dict[str, str] = {
|
||||
"escape": "esc",
|
||||
"return": "enter",
|
||||
"backspace": "bs",
|
||||
"delete": "del",
|
||||
"insert": "ins",
|
||||
"pageup": "pgup",
|
||||
"pagedown": "pgdn",
|
||||
}
|
||||
|
||||
#: Keys a modifier applies to, as the xterm sequence with the modifier slot.
|
||||
_MODIFIED: dict[str, str] = {
|
||||
"up": "\x1b[1;{m}A",
|
||||
"down": "\x1b[1;{m}B",
|
||||
"right": "\x1b[1;{m}C",
|
||||
"left": "\x1b[1;{m}D",
|
||||
"home": "\x1b[1;{m}H",
|
||||
"end": "\x1b[1;{m}F",
|
||||
"ins": "\x1b[2;{m}~",
|
||||
"del": "\x1b[3;{m}~",
|
||||
"pgup": "\x1b[5;{m}~",
|
||||
"pgdn": "\x1b[6;{m}~",
|
||||
"f1": "\x1b[1;{m}P",
|
||||
"f2": "\x1b[1;{m}Q",
|
||||
"f3": "\x1b[1;{m}R",
|
||||
"f4": "\x1b[1;{m}S",
|
||||
"f5": "\x1b[15;{m}~",
|
||||
"f6": "\x1b[17;{m}~",
|
||||
"f7": "\x1b[18;{m}~",
|
||||
"f8": "\x1b[19;{m}~",
|
||||
"f9": "\x1b[20;{m}~",
|
||||
"f10": "\x1b[21;{m}~",
|
||||
"f11": "\x1b[23;{m}~",
|
||||
"f12": "\x1b[24;{m}~",
|
||||
}
|
||||
|
||||
#: Modifier prefixes a marker may carry: Ctrl, Alt (either spelling), Shift.
|
||||
_MODIFIERS = frozenset({"c", "a", "m", "s"})
|
||||
|
||||
#: What a Ctrl may be combined with beyond A-Z: the rest of the C0 controls.
|
||||
_CONTROL_CHARS = "@[\\]^_"
|
||||
#: Longest marker content to look for, so a stray ``<`` stays a literal ``<``.
|
||||
_MAX_MARKER = 32
|
||||
|
||||
# C0 controls except TAB/LF/CR (those stay for readable logs).
|
||||
_UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||
@@ -58,15 +122,110 @@ def sanitize_pty_output_for_tool(data: str) -> str:
|
||||
return _UNSAFE_CTRL.sub(_esc_ctrl, out)
|
||||
|
||||
|
||||
class KeySyntaxError(ValueError):
|
||||
"""A ``<...>`` marker that names no key, or repeats it wrongly."""
|
||||
|
||||
|
||||
def _control(char: str) -> str:
|
||||
"""The C0 byte for a Ctrl and one character."""
|
||||
if len(char) != 1:
|
||||
msg = f"ctrl takes one character, not {char!r} (write <c-a>)"
|
||||
raise KeySyntaxError(msg)
|
||||
upper = char.upper()
|
||||
if not ("A" <= upper <= "Z" or char in _CONTROL_CHARS):
|
||||
msg = f"no control byte for <c-{char}>"
|
||||
raise KeySyntaxError(msg)
|
||||
return chr(ord(upper) & 0x1F)
|
||||
|
||||
|
||||
def _canonical(name: str) -> str:
|
||||
lowered = name.lower()
|
||||
return _KEY_ALIASES.get(lowered, lowered)
|
||||
|
||||
|
||||
def _key_bytes(name: str) -> str:
|
||||
"""What one ``<name>`` marker sends, modifiers and all."""
|
||||
known = _KEY_NAMES.get(_canonical(name))
|
||||
if known is not None:
|
||||
return known
|
||||
*prefixes, base = name.split("-")
|
||||
modifiers = {prefix.lower() for prefix in prefixes}
|
||||
if prefixes and base and modifiers <= _MODIFIERS:
|
||||
return _modified(name, base, modifiers)
|
||||
msg = f"unknown key <{name}> (see the write_pty_keys docs for the names)"
|
||||
raise KeySyntaxError(msg)
|
||||
|
||||
|
||||
def _modified(name: str, base: str, modifiers: set[str]) -> str:
|
||||
"""One key under Ctrl / Alt / Shift, spelled the way xterm spells it."""
|
||||
ctrl = "c" in modifiers
|
||||
alt = bool(modifiers & {"a", "m"})
|
||||
shift = "s" in modifiers
|
||||
template = _MODIFIED.get(_canonical(base))
|
||||
if template is not None:
|
||||
# xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
||||
return template.format(m=1 + shift * 1 + alt * 2 + ctrl * 4)
|
||||
if ctrl and _canonical(base) == "space":
|
||||
return "\x00" # Ctrl+Space is NUL, not a CSI sequence
|
||||
if len(base) == 1:
|
||||
if shift:
|
||||
msg = f"<s-{base}> is just {base.upper()!r} — drop the modifier"
|
||||
raise KeySyntaxError(msg)
|
||||
return ("\x1b" if alt else "") + (_control(base) if ctrl else base)
|
||||
known = _KEY_NAMES.get(_canonical(base))
|
||||
if known is not None and not (ctrl or shift):
|
||||
return "\x1b" + known # Alt alone: an ESC before the key, as terminals send it
|
||||
msg = f"no sequence for <{name}> (write the raw bytes with \\x1b)"
|
||||
raise KeySyntaxError(msg)
|
||||
|
||||
|
||||
def _expansion(content: str) -> str:
|
||||
"""The bytes for the inside of one marker, honouring a ``*N`` repeat."""
|
||||
name, _, count = content.partition("*")
|
||||
if not count:
|
||||
return _key_bytes(name)
|
||||
if not count.isdigit() or int(count) < 1:
|
||||
msg = f"bad repeat in <{content}> (write <up*3>)"
|
||||
raise KeySyntaxError(msg)
|
||||
return _key_bytes(name) * int(count)
|
||||
|
||||
|
||||
def _marker_at(data: str, index: int) -> tuple[str, int] | None:
|
||||
"""The marker starting at *index*, as ``(content, next index)``, or ``None``.
|
||||
|
||||
A ``<`` that is not followed by a short, whitespace-free ``>`` pair stays a
|
||||
literal ``<`` — ordinary input like ``sort < file`` must not need escaping.
|
||||
"""
|
||||
end = data.find(">", index + 1, index + 1 + _MAX_MARKER)
|
||||
if end <= index + 1:
|
||||
return None
|
||||
content = data[index + 1 : end]
|
||||
if any(char.isspace() or char == "<" for char in content):
|
||||
return None
|
||||
return content, end + 1
|
||||
|
||||
|
||||
def decode_write_data(data: str) -> str:
|
||||
"""Expand agent-friendly escapes into raw PTY input (for ``write_pty_keys`` only).
|
||||
"""Expand ``<key>`` markers and escapes into raw PTY input (``write_pty_keys``).
|
||||
|
||||
Supported (as literal characters in ``data``):
|
||||
Markers, written between angle brackets:
|
||||
|
||||
- ``\\n`` ``\\r`` ``\\t`` ``\\e`` / ``\\E`` (ESC) ``\\0``
|
||||
- ``\\xHH`` byte, ``\\uHHHH`` Unicode code point
|
||||
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive; A-Z or @ [ \\ ] ^ _)
|
||||
- ``key=esc`` ``key=enter`` ``key=tab`` ``key=up|down|left|right``
|
||||
- ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>``
|
||||
``<ins>`` ``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>``
|
||||
``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>``
|
||||
- a modifier in front, alone or combined: ``<c-up>`` ``<s-home>``
|
||||
``<m-tab>`` ``<c-s-f5>`` — the xterm sequence where a key has one. On a
|
||||
plain character, Ctrl is its control byte (``<c-a>``, ``<c-[>``) and Alt
|
||||
is an ESC before it (``<m-x>``)
|
||||
- ``<up*3>`` repeats a key three times, ``<<`` is a literal ``<``
|
||||
|
||||
An unknown name raises :class:`KeySyntaxError` rather than typing the marker
|
||||
into the terminal. Anything that is not a marker or an escape is literal.
|
||||
|
||||
Escapes (literal characters in ``data``):
|
||||
|
||||
- ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e`` / ``\\\\E`` (ESC) ``\\\\0``
|
||||
- ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point
|
||||
"""
|
||||
if not data:
|
||||
return data
|
||||
@@ -75,18 +234,20 @@ def decode_write_data(data: str) -> str:
|
||||
i = 0
|
||||
n = len(data)
|
||||
while i < n:
|
||||
if data[i] == "<":
|
||||
if data.startswith("<<", i):
|
||||
out.append("<") # the escape for a literal one
|
||||
i += 2
|
||||
continue
|
||||
marker = _marker_at(data, i)
|
||||
if marker is not None:
|
||||
content, i = marker
|
||||
out.append(_expansion(content))
|
||||
continue
|
||||
out.append("<") # a `<` that opens nothing
|
||||
i += 1
|
||||
continue
|
||||
rest = data[i:]
|
||||
m_key = _KEY.match(rest)
|
||||
if m_key is not None:
|
||||
out.append(_KEY_MAP[m_key.group(1).lower()])
|
||||
i += m_key.end()
|
||||
continue
|
||||
m_ctrl = _CTRL.match(rest)
|
||||
if m_ctrl is not None:
|
||||
ch = m_ctrl.group(1).upper()
|
||||
out.append(chr((ord(ch) - ord("@")) & 0x1F))
|
||||
i += m_ctrl.end()
|
||||
continue
|
||||
m_hex = _HEX_BYTE.match(rest)
|
||||
if m_hex is not None:
|
||||
out.append(chr(int(m_hex.group(1), 16)))
|
||||
|
||||
@@ -26,7 +26,7 @@ def write_pty_payload(session_id: int, raw: str) -> str:
|
||||
async def write_pty(session_id: int, data: str) -> str:
|
||||
"""Write **literal** text to a PTY session. Does not append a newline.
|
||||
|
||||
``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For
|
||||
``data`` is sent unchanged (no ``<enter>`` / ``\\xHH`` expansion). For
|
||||
control sequences use :func:`write_pty_keys`.
|
||||
"""
|
||||
try:
|
||||
|
||||
@@ -3,27 +3,39 @@ from __future__ import annotations
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
from .pty_terminal import decode_write_data
|
||||
from .pty_terminal import KeySyntaxError, decode_write_data
|
||||
from .write_pty import write_pty_payload
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
||||
async def write_pty_keys(session_id: int, data: str) -> str:
|
||||
"""Write to a PTY after expanding key escapes (never for normal typing).
|
||||
"""Write to a PTY after expanding ``<key>`` markers (never for normal typing).
|
||||
|
||||
Use this only for control sequences. Prefer :func:`write_pty` for plain text
|
||||
so strings like ``press ctrl+c`` are not rewritten.
|
||||
so strings like ``press <c-c>`` are not rewritten.
|
||||
|
||||
Escapes (literal characters in ``data``):
|
||||
Markers, written between angle brackets:
|
||||
|
||||
- ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0``
|
||||
- ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point
|
||||
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive)
|
||||
- ``key=esc|enter|tab|up|down|left|right``
|
||||
- ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>``
|
||||
``<ins>`` ``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>``
|
||||
``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>``
|
||||
- a modifier in front, alone or combined: ``<c-up>`` ``<s-home>``
|
||||
``<m-tab>`` ``<c-s-f5>``; on a plain character Ctrl is its control byte
|
||||
(``<c-a>``) and Alt an ESC before it (``<m-x>``)
|
||||
- ``<up*3>`` repeats a key, ``<<`` is a literal ``<``
|
||||
|
||||
An unknown marker is an error, not text typed into the terminal: a typo must
|
||||
not land in a shell prompt.
|
||||
|
||||
Escapes (as the characters ``data`` carries, so doubled inside JSON):
|
||||
``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0``,
|
||||
``\\\\xHH`` byte, ``\\\\uHHHH`` code point. Anything else is literal.
|
||||
"""
|
||||
try:
|
||||
raw = decode_write_data(data)
|
||||
return write_pty_payload(session_id, raw)
|
||||
except KeySyntaxError as exc:
|
||||
return f"error: {exc}"
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except OSError as exc:
|
||||
|
||||
@@ -445,9 +445,17 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -
|
||||
candidate = root / candidate
|
||||
resolved = candidate.expanduser().resolve()
|
||||
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
|
||||
# A refused *write* is often a scratch file the model meant to keep in
|
||||
# the workspace: name that way out too, next to asking the human.
|
||||
scratch = (
|
||||
"; if the file is only scratch, call new_temporary_workspace and write under the absolute path it returns"
|
||||
if required is AccessMode.WRITE
|
||||
else ""
|
||||
)
|
||||
msg = (
|
||||
f"path escapes workspace root ({root}): {path}; "
|
||||
f"call request_directory_access with mode={required.name.lower()!r} to request access outside the workspace"
|
||||
f"{scratch}"
|
||||
)
|
||||
raise WorkspaceError(msg)
|
||||
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Literal, cast, overload
|
||||
|
||||
import pytest
|
||||
from msgspec import UNSET
|
||||
@@ -928,6 +929,128 @@ async def test_retry_keeps_committed_tools_after_second_round_fails() -> None:
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_retry_keeps_lineno_reads_across_attempt_tasks(tmp_path: object) -> None:
|
||||
"""A numbered read before a failed round still enables edits after retry.
|
||||
|
||||
Each turn attempt runs in its own task (the host wraps turns in
|
||||
``asyncio.create_task``), so the retry attempt's context does not carry the
|
||||
first attempt's ContextVar binding. Without ``retry`` re-binding the lineno
|
||||
tracker, ``read_file(with_lineno=true)`` silently records nothing and
|
||||
``edit_lineno`` rejects every line edit — even after re-reading the file as
|
||||
its own error message instructs.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.tools.context import InstanceState
|
||||
from plyngent.tools.file import edit_lineno, read_file
|
||||
from plyngent.tools.workspace import (
|
||||
clear_workspace_allowlist,
|
||||
clear_workspace_root,
|
||||
set_workspace_root,
|
||||
)
|
||||
|
||||
root = cast("Path", tmp_path).resolve()
|
||||
(root / "f.txt").write_text("alpha\nbravo\ncharlie\n", encoding="utf-8")
|
||||
instance = InstanceState(workspace_root=root)
|
||||
from plyngent.tools.context import bind_instance
|
||||
|
||||
with bind_instance(instance):
|
||||
clear_workspace_allowlist()
|
||||
_ = set_workspace_root(root)
|
||||
try:
|
||||
registry = ToolRegistry([read_file, edit_lineno], instance_state=instance)
|
||||
|
||||
class ReadThenBoom:
|
||||
"""Round 1 reads with line numbers; round 2's model call fails."""
|
||||
|
||||
n: int
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.n = 0
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||
) -> ChatCompletionResponse: ...
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
del stream
|
||||
self.n += 1
|
||||
if self.n == 1:
|
||||
return _response(
|
||||
AssistantChatMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="c1",
|
||||
function=AssistantFunctionTool(
|
||||
name="read_file",
|
||||
arguments='{"path": "f.txt", "with_lineno": true}',
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
if self.n == 2:
|
||||
msg = "api down after read"
|
||||
raise RuntimeError(msg)
|
||||
if self.n == 3:
|
||||
edit_args = '{"path": "f.txt", "start_line": 2, "end_line": 2, "new_content": "BRAVO\\n"}'
|
||||
return _response(
|
||||
AssistantChatMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="c2",
|
||||
function=AssistantFunctionTool(
|
||||
name="edit_lineno",
|
||||
arguments=edit_args,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
return _response(AssistantChatMessage(content="done"))
|
||||
|
||||
client = ReadThenBoom()
|
||||
agent = ChatAgent(
|
||||
client,
|
||||
model="m",
|
||||
tools=registry,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
async def run_attempt() -> list[object]:
|
||||
return [e async for e in agent.run("edit line 2")]
|
||||
|
||||
# First attempt in its own task (mirrors run_cancellable): commits
|
||||
# the read, then the next model round fails.
|
||||
with pytest.raises(RuntimeError, match="api down after read"):
|
||||
await asyncio.create_task(run_attempt())
|
||||
assert isinstance(agent.messages[-1], ToolChatMessage)
|
||||
|
||||
# Retry in a *fresh* task: its context copy has no tracker bound.
|
||||
async def run_retry() -> list[object]:
|
||||
return [e async for e in agent.retry()]
|
||||
|
||||
events = await asyncio.create_task(run_retry())
|
||||
results = [e for e in events if isinstance(e, ToolResultEvent)]
|
||||
assert results, "retry must run the edit_lineno call"
|
||||
assert "replaced lines 2-2" in results[0].message.content
|
||||
assert (root / "f.txt").read_text(encoding="utf-8") == "alpha\nBRAVO\ncharlie\n"
|
||||
assert agent.pending_retry_text is None
|
||||
finally:
|
||||
clear_workspace_allowlist()
|
||||
clear_workspace_root()
|
||||
|
||||
|
||||
async def test_chat_agent_system_prompt_prepended() -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import SystemChatMessage
|
||||
|
||||
|
||||
@@ -801,18 +801,33 @@ async def test_pretty_pty_write_and_keys(capsys: pytest.CaptureFixture[str]) ->
|
||||
status = "session_id=3\nalive=true\nexit_code=\nwrote=5"
|
||||
await render_events(_aiter([_pretty_call("write_pty", '{"session_id": 3, "data": "ls -la"}'), _result(status)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Write 3 (5 chars)" in out
|
||||
assert "* PTY Write 3 'ls -la' (5 chars)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("write_pty_keys", '{"session_id": 3, "data": "ctrl+c"}'),
|
||||
_result("session_id=3\nalive=true\nexit_code=\nwrote=1"),
|
||||
_pretty_call("write_pty_keys", '{"session_id": 3, "data": "<c-c><enter>"}'),
|
||||
_result("session_id=3\nalive=true\nexit_code=\nwrote=2"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Keys 3 (1 char)" in out
|
||||
assert "* PTY Keys 3 '<c-c><enter>' (2 chars)" in out
|
||||
|
||||
|
||||
async def test_pretty_pty_write_escapes_and_shortens_the_payload(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A newline or an ESC must not break the line, and a long payload is cut."""
|
||||
status = "session_id=3\nalive=true\nexit_code=\nwrote=4"
|
||||
await render_events(_aiter([_pretty_call("write_pty", '{"session_id": 3, "data": "a\\nb"}'), _result(status)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Write 3 'a\\nb' (4 chars)" in out
|
||||
|
||||
long = "x" * 60
|
||||
call = _pretty_call("write_pty", f'{{"session_id": 3, "data": "{long}"}}')
|
||||
await render_events(_aiter([call, _result(status)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Write 3 '" + "x" * 40 + "…'" in out
|
||||
assert "x" * 41 not in out
|
||||
|
||||
|
||||
async def test_pretty_ask_into_pty_never_shows_the_answer(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
|
||||
@@ -19,9 +19,11 @@ from plyngent.cli.limits import (
|
||||
)
|
||||
from plyngent.prompting import (
|
||||
NonInteractiveBackend,
|
||||
PromptWait,
|
||||
get_prompt_backend,
|
||||
get_prompt_output_hook,
|
||||
get_prompt_wait_hook,
|
||||
set_prompt_wait_hook,
|
||||
temporary_backend,
|
||||
)
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
@@ -332,6 +334,134 @@ def test_batch_confirm_denied_by_the_notification() -> None:
|
||||
assert _prompt_confirm_batch_sync(requests).allowed is False
|
||||
|
||||
|
||||
def test_batch_confirm_can_ask_for_one_at_a_time_from_the_notification() -> None:
|
||||
"""``m`` is offered as a button, so the manual review stays reachable."""
|
||||
from plyngent.agent import ToolConfirmRequest
|
||||
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
||||
|
||||
requests = [
|
||||
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
||||
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
||||
]
|
||||
with temporary_backend(ScriptedBackend([])), _answered("m"):
|
||||
decision = _prompt_confirm_batch_sync(requests)
|
||||
assert decision.allowed is False
|
||||
assert decision.each is True
|
||||
|
||||
|
||||
def test_batch_confirm_offers_its_three_answers() -> None:
|
||||
from plyngent.agent import ToolConfirmRequest
|
||||
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
||||
from plyngent.prompting import get_prompt_wait_hook, set_prompt_wait_hook
|
||||
|
||||
offered: list[tuple[tuple[str, str], ...]] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hook(wait: object) -> Generator[None]:
|
||||
offered.append(wait.choices) # type: ignore[attr-defined]
|
||||
yield None
|
||||
|
||||
requests = [ToolConfirmRequest(name="run_argv", args={}, reason="one")]
|
||||
previous = get_prompt_wait_hook()
|
||||
set_prompt_wait_hook(hook)
|
||||
try:
|
||||
with temporary_backend(ScriptedBackend(["n"])):
|
||||
_ = _prompt_confirm_batch_sync(requests)
|
||||
finally:
|
||||
set_prompt_wait_hook(previous)
|
||||
assert [key for key, _label in offered[0]] == ["y", "m", "n"]
|
||||
|
||||
|
||||
def test_a_limit_prompt_takes_its_answer_from_the_notification() -> None:
|
||||
from plyngent.cli import limits
|
||||
|
||||
with temporary_backend(ScriptedBackend([])), _answered("y"):
|
||||
assert limits.prompt_continue_limit("hit a wall") is True
|
||||
|
||||
|
||||
def test_a_limit_prompt_can_stop_asking_from_the_notification(clean_auto_continue: None) -> None:
|
||||
"""The ``yyy`` button is the one answer a single ``y`` cannot express."""
|
||||
del clean_auto_continue
|
||||
from plyngent.cli import limits
|
||||
|
||||
with temporary_backend(ScriptedBackend([])), _answered("yyy"):
|
||||
assert limits.prompt_continue_limit("hit a wall") is True
|
||||
assert auto_continue_enabled() is True
|
||||
|
||||
|
||||
def test_a_limit_prompt_can_stop_the_turn_from_the_notification() -> None:
|
||||
from plyngent.cli import limits
|
||||
|
||||
with temporary_backend(ScriptedBackend([])), _answered("n"):
|
||||
assert limits.prompt_continue_limit("hit a wall") is False
|
||||
|
||||
|
||||
def test_a_limit_prompt_still_names_its_answers_at_the_terminal(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Buttons elsewhere do not say what typing here would do."""
|
||||
from plyngent.cli import limits
|
||||
|
||||
with temporary_backend(ScriptedBackend([])), _answered("y"):
|
||||
assert limits.prompt_continue_limit("hit a wall") is True
|
||||
assert "[Y/n/yyy]" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_a_skill_write_can_be_allowed_for_the_chat_from_the_notification() -> None:
|
||||
"""``s`` (this chat) is a button, not only a terminal token."""
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.cli import limits
|
||||
|
||||
with temporary_backend(ScriptedBackend([])), _answered("s"):
|
||||
decision = limits.prompt_skill_write_confirm(Path("/tmp/skill"), "write", "one line", 30.0)
|
||||
assert decision.allow is True
|
||||
assert decision.session is True
|
||||
|
||||
|
||||
def test_a_tool_confirm_names_the_object_it_is_about() -> None:
|
||||
"""A notification body carries the program or file, never the argument."""
|
||||
seen: list[PromptWait] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hook(wait: PromptWait) -> Generator[None]:
|
||||
seen.append(wait)
|
||||
yield None
|
||||
|
||||
previous = get_prompt_wait_hook()
|
||||
set_prompt_wait_hook(hook)
|
||||
try:
|
||||
with temporary_backend(ScriptedBackend([], confirms=[True, True])):
|
||||
_ = prompt_confirm_tool("run_argv", {"argv": ["/usr/bin/rm", "-rf", "build"]}, "command 'rm'")
|
||||
_ = prompt_confirm_tool("write_file", {"path": "notes/todo.md", "content": "x"}, "overwrite")
|
||||
finally:
|
||||
set_prompt_wait_hook(previous)
|
||||
assert [wait.facts for wait in seen] == [("program: rm",), ("target: todo.md",)]
|
||||
|
||||
|
||||
def test_a_timed_prompt_says_when_silence_answers() -> None:
|
||||
"""Timeout is a deny, so the notification must not hide the clock."""
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.cli import limits
|
||||
from plyngent.tools.workspace import AccessMode
|
||||
|
||||
seen: list[PromptWait] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hook(wait: PromptWait) -> Generator[None]:
|
||||
seen.append(wait)
|
||||
yield None
|
||||
|
||||
previous = get_prompt_wait_hook()
|
||||
set_prompt_wait_hook(hook)
|
||||
try:
|
||||
with temporary_backend(ScriptedBackend(["y"], confirms=[True])):
|
||||
_ = limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0)
|
||||
finally:
|
||||
set_prompt_wait_hook(previous)
|
||||
assert seen[0].expires_in == 30.0
|
||||
assert seen[0].facts == ("kind: directory",)
|
||||
|
||||
|
||||
def test_every_confirm_announces_itself() -> None:
|
||||
"""Each blocking confirm reports its kind, so a host can pick a surface."""
|
||||
from pathlib import Path
|
||||
|
||||
+297
-24
@@ -1,14 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.cli.notify import (
|
||||
ALLOW,
|
||||
DENY,
|
||||
REVIEW,
|
||||
DesktopNotifierChannel,
|
||||
DesktopRequest,
|
||||
WaitNotifier,
|
||||
detect_multiplexer,
|
||||
@@ -95,6 +98,7 @@ def _notifier(
|
||||
desktop: _FakeDesktop | None = None,
|
||||
grace_seconds: float = 60.0,
|
||||
min_interval_seconds: float = 0.0,
|
||||
workspace: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> tuple[WaitNotifier, list[str]]:
|
||||
"""A notifier with a captured terminal writer and a fake desktop channel."""
|
||||
@@ -109,6 +113,7 @@ def _notifier(
|
||||
desktop=desktop,
|
||||
write=written.append,
|
||||
env={"TERM_PROGRAM": "WezTerm"},
|
||||
workspace=workspace,
|
||||
)
|
||||
return notifier, written
|
||||
|
||||
@@ -150,6 +155,12 @@ def test_terminal_sequence_osc777_and_osc9() -> None:
|
||||
assert terminal_sequence("bell", "T", "M") == "\a"
|
||||
|
||||
|
||||
def test_a_body_less_notification_states_its_title_once() -> None:
|
||||
"""OSC 9 joins title and body with a colon, which must not dangle."""
|
||||
assert terminal_sequence("osc9", "T", "") == "\x1b]9;T\x07"
|
||||
assert terminal_sequence("osc777", "T", "") == "\x1b]777;notify;T;\x07"
|
||||
|
||||
|
||||
def test_terminal_sequence_is_wrapped_for_tmux() -> None:
|
||||
seq = terminal_sequence("osc777", "T", "M", multiplexer="tmux")
|
||||
assert seq.startswith("\x1bPtmux;\x1b")
|
||||
@@ -205,7 +216,9 @@ def test_desktop_channel_can_be_pinned() -> None:
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert len(desktop.requests) == 1
|
||||
assert written == []
|
||||
# No terminal notification (it was pinned), but the bell still says which
|
||||
# window holds the prompt.
|
||||
assert written == ["\a"]
|
||||
|
||||
|
||||
def test_terminal_fallback_when_the_desktop_refuses() -> None:
|
||||
@@ -246,14 +259,14 @@ def test_disabled_notifications_send_nothing() -> None:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_a_confirmation_offers_three_buttons() -> None:
|
||||
def test_a_confirmation_offers_allow_and_deny() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert handle.supports_actions is True
|
||||
request = desktop.requests[0]
|
||||
assert [key for key, _ in request.buttons] == [ALLOW, DENY, REVIEW]
|
||||
assert [key for key, _ in request.buttons] == [ALLOW, DENY]
|
||||
assert request.reply_field is False
|
||||
|
||||
|
||||
@@ -285,23 +298,147 @@ def test_no_answer_is_expected_without_the_capability() -> None:
|
||||
assert handle.supports_actions is False
|
||||
|
||||
|
||||
def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None:
|
||||
def test_a_question_offers_a_reply_field_without_the_answers() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("ask", "Which branch?")) as handle:
|
||||
with notifier(PromptWait("ask", "a question is waiting", body="Which branch?")) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.reply_field is True
|
||||
assert request.buttons == ()
|
||||
|
||||
|
||||
def test_a_limit_prompt_carries_no_answers() -> None:
|
||||
def test_a_question_carries_the_question_in_its_body() -> None:
|
||||
"""A question *is* what is asked, so the title cannot be the whole story."""
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
question = "Which branch should the release be cut from?"
|
||||
with notifier(PromptWait("ask", "a question is waiting", body=question)) as handle:
|
||||
handle._notify()
|
||||
assert desktop.requests[0].message == question
|
||||
|
||||
|
||||
def test_the_body_names_the_object_and_when_not_answering_answers() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
wait = PromptWait("policy", "allow command 'rm'?", facts=("program: rm",), expires_in=30.0)
|
||||
with notifier(wait) as handle:
|
||||
handle._notify()
|
||||
# A popup wraps, so the desktop body keeps its lines apart.
|
||||
assert desktop.requests[0].message.split("\n") == ["program: rm", "no answer in 30s = deny"]
|
||||
|
||||
|
||||
def test_the_terminal_body_stays_on_one_line() -> None:
|
||||
notifier, _ = _notifier()
|
||||
wait = PromptWait("confirm", "Allow?", facts=("program: rm",), expires_in=30.0)
|
||||
assert notifier._message(wait) == "program: rm no answer in 30s = deny"
|
||||
|
||||
|
||||
def test_a_long_reason_goes_before_the_deadline_does() -> None:
|
||||
"""The deadline is short and is the one line a human must not miss."""
|
||||
notifier, _ = _notifier(include_reason=True)
|
||||
wait = PromptWait("confirm", "Allow?", "x" * 400, facts=("program: rm",), expires_in=30.0)
|
||||
message = notifier._message(wait)
|
||||
assert message.endswith("no answer in 30s = deny")
|
||||
assert len(message) <= 200
|
||||
|
||||
|
||||
def test_a_click_on_the_notification_leaves_the_prompt_waiting() -> None:
|
||||
"""The body click is the default action: the notification goes, the wait stays."""
|
||||
desktop = _FakeDesktop()
|
||||
notifier, written = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.on_click is not None
|
||||
request.on_click()
|
||||
assert handle.take() is None
|
||||
assert desktop.cleared == ["id-1"]
|
||||
# One bell, rung when the notification went out — not again for the click.
|
||||
assert written == ["\a"]
|
||||
|
||||
|
||||
def test_the_bell_rings_when_the_popup_goes_out() -> None:
|
||||
"""A popup can sit on another virtual desktop; the bell says where to look."""
|
||||
desktop = _FakeDesktop()
|
||||
notifier, written = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert written == ["\a"]
|
||||
assert desktop.requests[0].buttons == ((ALLOW, "Allow once"), (DENY, "Deny"))
|
||||
|
||||
|
||||
def test_the_body_names_the_workspace_under_a_tilde(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Two chats look alike; which directory waits is what tells them apart."""
|
||||
monkeypatch.setenv("HOME", "/home/tester")
|
||||
notifier, _ = _notifier(workspace="/home/tester/work/plyngent")
|
||||
assert notifier._message(PromptWait("confirm", "Allow?")) == "~/work/plyngent"
|
||||
notifier.set_workspace("/srv/other")
|
||||
assert notifier._message(PromptWait("confirm", "Allow?")) == "/srv/other"
|
||||
|
||||
|
||||
def test_a_rebound_workspace_moves_the_label(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A resumed session runs in its own directory; the label follows it."""
|
||||
monkeypatch.setenv("HOME", "/home/tester")
|
||||
from plyngent.cli.state import ReplState
|
||||
|
||||
notifier = install_notifier(NotifyConfig(), workspace="/home/tester/one")
|
||||
assert notifier is not None
|
||||
ReplState._retarget_notifier(Path("/home/tester/two"))
|
||||
assert notifier._message(PromptWait("confirm", "Allow?")) == "~/two"
|
||||
|
||||
|
||||
def test_a_timed_prompt_lets_the_popup_expire_with_it() -> None:
|
||||
"""The popup must not outlive the answer window it announces."""
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("policy", "allow command 'rm'?", expires_in=30.0)) as handle:
|
||||
handle._notify()
|
||||
assert desktop.requests[0].timeout == 30
|
||||
|
||||
|
||||
def test_a_prompt_without_a_deadline_keeps_its_popup() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert desktop.requests[0].timeout is None
|
||||
|
||||
|
||||
def test_a_reply_is_an_answer_whatever_it_spells() -> None:
|
||||
"""A reply field's text is the answer, even when it reads like a button key."""
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("ask", "a question is waiting", body="What now?")) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.on_reply is not None
|
||||
request.on_reply("review")
|
||||
assert handle.take() == "review"
|
||||
|
||||
|
||||
def test_a_secret_prompt_is_never_announced() -> None:
|
||||
"""The value would have to come back through a surface that outlives it."""
|
||||
desktop = _FakeDesktop()
|
||||
notifier, written = _notifier(desktop=desktop, grace_seconds=0.0)
|
||||
with notifier(PromptWait("secret", "API key?")) as handle:
|
||||
assert notifier.may_notify(handle) is False
|
||||
handle._notify()
|
||||
assert desktop.requests == []
|
||||
assert written == []
|
||||
|
||||
|
||||
def test_a_limit_prompt_can_be_answered() -> None:
|
||||
"""A limit prompt blocks the turn too, so it offers buttons like a confirm."""
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("limit", "Round limit reached")) as handle:
|
||||
assert handle.supports_actions is True
|
||||
handle._notify()
|
||||
assert handle.supports_actions is False
|
||||
assert desktop.requests[0].buttons == ()
|
||||
answer = desktop.requests[0].on_answer
|
||||
assert answer is not None
|
||||
answer(ALLOW)
|
||||
assert handle.take() == ALLOW
|
||||
assert desktop.requests[0].reply_field is False
|
||||
|
||||
|
||||
@@ -346,18 +483,6 @@ def test_a_reply_reaches_the_prompt() -> None:
|
||||
assert handle.take() == "feature/x"
|
||||
|
||||
|
||||
def test_review_keeps_waiting_and_dismisses() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.on_answer is not None
|
||||
request.on_answer(REVIEW)
|
||||
assert handle.take() is None
|
||||
assert desktop.cleared == ["id-1"]
|
||||
|
||||
|
||||
def test_an_answer_after_the_prompt_is_ignored() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
@@ -379,6 +504,153 @@ def test_the_notification_is_dismissed_when_the_prompt_returns() -> None:
|
||||
assert desktop.cleared == ["id-1"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The desktop-notifier call itself
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _Capability:
|
||||
BUTTONS = "buttons"
|
||||
REPLY_FIELD = "reply"
|
||||
|
||||
|
||||
class _Urgency:
|
||||
Critical = "critical"
|
||||
Normal = "normal"
|
||||
Low = "low"
|
||||
|
||||
|
||||
class _Field:
|
||||
"""A stand-in for ``Button`` / ``ReplyField`` (it only carries its keywords)."""
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
class _RecordingNotifier:
|
||||
"""A stand-in for ``DesktopNotifier`` that records every send."""
|
||||
|
||||
sent: ClassVar[list[dict[str, object]]] = []
|
||||
|
||||
def __init__(self, app_name: str) -> None:
|
||||
self.app_name = app_name
|
||||
|
||||
async def get_capabilities(self) -> frozenset[str]:
|
||||
return frozenset({_Capability.BUTTONS})
|
||||
|
||||
async def send(self, **kwargs: object) -> str:
|
||||
_RecordingNotifier.sent.append(kwargs)
|
||||
return "id-1"
|
||||
|
||||
async def clear(self, identifier: str) -> None:
|
||||
del identifier
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _stub_extra(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Put a stand-in ``desktop_notifier`` in place of the real extra."""
|
||||
module = types.ModuleType("desktop_notifier")
|
||||
module.Button = _Field # type: ignore[attr-defined]
|
||||
module.ReplyField = _Field # type: ignore[attr-defined]
|
||||
module.Capability = _Capability # type: ignore[attr-defined]
|
||||
module.Urgency = _Urgency # type: ignore[attr-defined]
|
||||
module.DesktopNotifier = _RecordingNotifier # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "desktop_notifier", module)
|
||||
_RecordingNotifier.sent.clear()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_stub_extra")
|
||||
def test_a_desktop_notification_is_kept_on_screen() -> None:
|
||||
"""It must not collapse into the tray while the turn waits for an answer."""
|
||||
channel = DesktopNotifierChannel()
|
||||
identifier = channel.send(
|
||||
DesktopRequest(
|
||||
title="Allow?",
|
||||
message="Allow?",
|
||||
buttons=((ALLOW, "Allow once"),),
|
||||
reply_field=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert identifier == "id-1"
|
||||
sent = _RecordingNotifier.sent[-1]
|
||||
assert sent["timeout"] == 0 # 0 = until dismissed; -1 = the server's few seconds
|
||||
# Urgency stays at the library's default: no expiry is what keeps it up, and
|
||||
# `critical` only adds a colour and breaks through a Focus mode.
|
||||
assert "urgency" not in sent
|
||||
assert sent["buttons"] and sent["reply_field"] is not None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_stub_extra")
|
||||
def test_the_desktop_call_carries_the_click_handler() -> None:
|
||||
"""Clicking the body is the default action, so it must reach the prompt."""
|
||||
channel = DesktopNotifierChannel()
|
||||
clicked: list[str] = []
|
||||
|
||||
def _click() -> None:
|
||||
clicked.append("click")
|
||||
|
||||
_ = channel.send(DesktopRequest(title="Allow?", message="", on_click=_click))
|
||||
assert _RecordingNotifier.sent[-1]["on_clicked"] is _click
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_stub_extra")
|
||||
def test_the_popup_can_be_told_when_to_expire() -> None:
|
||||
"""A prompt that gives up on its own takes its popup with it."""
|
||||
channel = DesktopNotifierChannel()
|
||||
_ = channel.send(DesktopRequest(title="Allow?", message="", timeout=30))
|
||||
assert _RecordingNotifier.sent[-1]["timeout"] == 30
|
||||
|
||||
|
||||
def test_a_prompt_offers_its_own_answers() -> None:
|
||||
"""The prompt's ``choices`` become the buttons."""
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
choices = (("y", "Continue"), ("yyy", "Continue, stop asking"), ("n", "Stop here"))
|
||||
with notifier(PromptWait("limit", "limit reached — continue?", "why", choices)) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.buttons == choices
|
||||
assert request.on_answer is not None
|
||||
request.on_answer("yyy")
|
||||
assert handle.take() == "yyy"
|
||||
|
||||
|
||||
def test_a_prompt_without_choices_gets_the_usual_buttons() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.buttons == (
|
||||
(ALLOW, "Allow once"),
|
||||
(DENY, "Deny"),
|
||||
)
|
||||
# Neither channel repeats the title in the body.
|
||||
assert request.message == ""
|
||||
|
||||
|
||||
def test_a_prompt_after_a_notification_answer_is_announced_at_once() -> None:
|
||||
"""The deny comment follows the button press, so it must not wait again."""
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop, grace_seconds=60.0, min_interval_seconds=600.0)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
answer = desktop.requests[0].on_answer
|
||||
assert answer is not None
|
||||
answer(DENY)
|
||||
with notifier(PromptWait("ask", "Why deny?")) as follow_up:
|
||||
assert follow_up.handoff is True
|
||||
deadline = time.monotonic() + 1.0
|
||||
while len(desktop.requests) < 2 and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert len(desktop.requests) == 2
|
||||
# An unrelated prompt is throttled again, and waits out the grace period.
|
||||
with notifier(PromptWait("confirm", "Allow?")) as later:
|
||||
assert later.handoff is False
|
||||
assert notifier.may_notify(later) is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Installation
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -398,9 +670,10 @@ def test_install_notifier_can_be_disabled() -> None:
|
||||
def test_reason_stays_out_unless_asked_for() -> None:
|
||||
notifier, _ = _notifier()
|
||||
wait = PromptWait("confirm", "Allow?", "argv: rm -rf /")
|
||||
assert notifier._message(wait) == "Allow?"
|
||||
# The title has a field of its own; the body is empty without a reason.
|
||||
assert notifier._message(wait) == ""
|
||||
notifier, _ = _notifier(grace_seconds=0.0, include_reason=True)
|
||||
assert "rm -rf" in notifier._message(wait)
|
||||
assert notifier._message(wait) == "argv: rm -rf /"
|
||||
|
||||
|
||||
def test_temporary_backend_is_transparent_to_the_notifier() -> None:
|
||||
|
||||
@@ -135,10 +135,12 @@ access_key_or_token = "sk"
|
||||
lambda _p, **_kw: DummyClient(),
|
||||
)
|
||||
|
||||
from platformdirs import user_data_path
|
||||
|
||||
global_db = user_data_path("plyngent") / "chat.db"
|
||||
global_mtime = global_db.stat().st_mtime if global_db.exists() else None
|
||||
# The durable default must stay untouched, and the test must not read the
|
||||
# host's own chat.db to prove it: point that default into tmp_path and check
|
||||
# nothing was created there.
|
||||
unused_data_dir = tmp_path / "user-data"
|
||||
_ = unused_data_dir.mkdir()
|
||||
monkeypatch.setattr("plyngent.cli.app.user_data_path", lambda *_args, **_kwargs: unused_data_dir)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
@@ -162,8 +164,7 @@ access_key_or_token = "sk"
|
||||
assert result.exit_code == EXIT_OK
|
||||
assert "pong" in result.output
|
||||
assert db_path.is_file()
|
||||
if global_mtime is not None:
|
||||
assert global_db.stat().st_mtime == global_mtime
|
||||
assert not (unused_data_dir / "chat.db").exists()
|
||||
|
||||
|
||||
def test_chat_help_mentions_prompt() -> None:
|
||||
|
||||
+45
-1
@@ -356,6 +356,34 @@ def test_prompt_wait_without_hook_yields_none() -> None:
|
||||
assert source is None
|
||||
|
||||
|
||||
def test_prompt_wait_carries_what_the_caller_declares() -> None:
|
||||
"""Everything a host needs to say what is asked and when silence answers."""
|
||||
seen: list[PromptWait] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hook(wait: PromptWait) -> Generator[None]:
|
||||
seen.append(wait)
|
||||
yield None
|
||||
|
||||
set_prompt_wait_hook(hook)
|
||||
choices = (("y", "Continue"), ("n", "Stop"))
|
||||
with prompt_wait(
|
||||
"policy",
|
||||
"allow command 'rm'?",
|
||||
"argv: rm -rf x",
|
||||
choices,
|
||||
body="",
|
||||
facts=("program: rm",),
|
||||
expires_in=30.0,
|
||||
):
|
||||
pass
|
||||
assert seen[0].body == ""
|
||||
assert seen[0].detail == "argv: rm -rf x"
|
||||
assert seen[0].choices == choices
|
||||
assert seen[0].facts == ("program: rm",)
|
||||
assert seen[0].expires_in == 30.0
|
||||
|
||||
|
||||
def test_prompt_wait_hook_wraps_ask() -> None:
|
||||
"""The CLI hook sees one PromptWait per prompt and is exited afterwards."""
|
||||
seen: list[PromptWait] = []
|
||||
@@ -374,10 +402,26 @@ def test_prompt_wait_hook_wraps_ask() -> None:
|
||||
backend = ScriptedBackend(["hi"])
|
||||
with temporary_backend(backend):
|
||||
assert ask("Name?") == "hi"
|
||||
assert [(wait.kind, wait.title) for wait in seen] == [("ask", "Name?")]
|
||||
assert [(wait.kind, wait.title, wait.body) for wait in seen] == [("ask", "a question is waiting", "Name?")]
|
||||
assert entered == ["in", "out"]
|
||||
|
||||
|
||||
def test_a_choice_prompt_names_its_question_and_options() -> None:
|
||||
"""The prompt a host shows carries the question and what can be picked."""
|
||||
seen: list[PromptWait] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hook(wait: PromptWait) -> Generator[None]:
|
||||
seen.append(wait)
|
||||
yield None
|
||||
|
||||
set_prompt_wait_hook(hook)
|
||||
with temporary_backend(ScriptedBackend(["1"])):
|
||||
assert choose("Pick one", ["main", "release", "next", "other", "fifth"]) == "main"
|
||||
assert seen[0].body == "Pick one"
|
||||
assert seen[0].facts == ("main", "release", "next", "other")
|
||||
|
||||
|
||||
def test_prompt_wait_hook_reports_the_prompt_family() -> None:
|
||||
"""Each entry point announces its own kind so a host can pick a surface."""
|
||||
kinds: list[str] = []
|
||||
|
||||
@@ -4,6 +4,8 @@ import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.tools.process import (
|
||||
ask_into_pty,
|
||||
close_pty,
|
||||
@@ -514,13 +516,76 @@ def test_decode_write_data_escapes() -> None:
|
||||
from plyngent.tools.process.pty_terminal import decode_write_data
|
||||
|
||||
assert decode_write_data(r"\x0f") == "\x0f"
|
||||
assert decode_write_data("ctrl+x") == "\x18"
|
||||
assert decode_write_data("CTRL+O") == "\x0f"
|
||||
assert decode_write_data(r"\e") == "\x1b"
|
||||
assert decode_write_data("key=esc") == "\x1b"
|
||||
assert decode_write_data("key=enter") == "\r"
|
||||
assert decode_write_data(r"a\nb") == "a\nb"
|
||||
assert decode_write_data("plain") == "plain"
|
||||
# Markers: names, modifiers, repeats, and a literal `<`.
|
||||
assert decode_write_data("<esc>") == "\x1b"
|
||||
assert decode_write_data("<ESC>") == "\x1b"
|
||||
assert decode_write_data("<enter>") == "\r"
|
||||
assert decode_write_data("<s-tab>") == "\x1b[Z"
|
||||
assert decode_write_data("<c-x>") == "\x18"
|
||||
assert decode_write_data("<C-O>") == "\x0f"
|
||||
assert decode_write_data("<c-[>") == "\x1b"
|
||||
assert decode_write_data("<m-b>") == "\x1bb"
|
||||
assert decode_write_data("<m-tab>") == "\x1b\t"
|
||||
assert decode_write_data("<up*3>") == "\x1b[A" * 3
|
||||
assert decode_write_data("<<value>") == "<value>"
|
||||
assert decode_write_data("<home>") == "\x1b[H"
|
||||
assert decode_write_data("<ins>") == "\x1b[2~"
|
||||
assert decode_write_data("<bs>") == "\x7f"
|
||||
assert decode_write_data("<pgup>") == "\x1b[5~"
|
||||
assert decode_write_data("<f5>") == "\x1b[15~"
|
||||
# A modifier on a key that has one: xterm's 1 + shift/alt/ctrl parameter.
|
||||
assert decode_write_data("<s-up>") == "\x1b[1;2A"
|
||||
assert decode_write_data("<m-up>") == "\x1b[1;3A"
|
||||
assert decode_write_data("<c-up>") == "\x1b[1;5A"
|
||||
assert decode_write_data("<a-left>") == "\x1b[1;3D"
|
||||
assert decode_write_data("<s-pgdn>") == "\x1b[6;2~"
|
||||
assert decode_write_data("<c-del>") == "\x1b[3;5~"
|
||||
assert decode_write_data("<c-s-f5>") == "\x1b[15;6~"
|
||||
assert decode_write_data("<c-m-home>") == "\x1b[1;7H"
|
||||
assert decode_write_data("<c-space>") == "\x00"
|
||||
# A `<` that does not open a marker stays literal, spaces included.
|
||||
assert decode_write_data("sort < file") == "sort < file"
|
||||
assert decode_write_data("a < b > c") == "a < b > c"
|
||||
|
||||
|
||||
def test_decode_write_data_refuses_an_unknown_key() -> None:
|
||||
"""A typo must be an error, not text typed into the terminal."""
|
||||
from plyngent.tools.process.pty_terminal import KeySyntaxError, decode_write_data
|
||||
|
||||
for data in ("<hme>", "<c-1>", "<up*x>", "<up*0>", "<c->", "<s-a>", "<c-tab>", "<c-s-tab>"):
|
||||
with pytest.raises(KeySyntaxError):
|
||||
_ = decode_write_data(data)
|
||||
|
||||
|
||||
async def test_write_pty_keys_sends_modified_keys(workspace: object) -> None:
|
||||
"""`<c-up><s-tab>` leaves the session as the bytes a terminal would send."""
|
||||
del workspace
|
||||
try:
|
||||
opened = await call_async(
|
||||
open_pty,
|
||||
_py(
|
||||
"import sys, tty\n"
|
||||
"tty.setraw(0)\n"
|
||||
"sys.stdout.write('READY\\n')\n"
|
||||
"sys.stdout.flush()\n"
|
||||
"data = sys.stdin.buffer.read(9)\n"
|
||||
"sys.stdout.write('GOT ' + repr(data))\n"
|
||||
"sys.stdout.flush()\n"
|
||||
),
|
||||
)
|
||||
session_id = _session_id(opened)
|
||||
# Raw mode first: a cooked PTY would hold the keys until a newline.
|
||||
_ = await call_async(read_pty, session_id, timeout=5.0, until="READY")
|
||||
written = await call_async(write_pty_keys, session_id, "<c-up><s-tab>")
|
||||
assert "wrote=9" in written
|
||||
text = await call_async(read_pty, session_id, timeout=2.0, until="GOT ")
|
||||
assert "GOT " + repr(b"\x1b[1;5A\x1b[Z") in text
|
||||
_ = await call_async(close_pty, session_id)
|
||||
finally:
|
||||
PtyManager.close_all()
|
||||
|
||||
|
||||
def test_sanitize_pty_output_escapes_csi() -> None:
|
||||
@@ -567,7 +632,7 @@ async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
|
||||
),
|
||||
)
|
||||
session_id = _session_id(opened)
|
||||
written = await call_async(write_pty_keys, session_id, "ctrl+c")
|
||||
written = await call_async(write_pty_keys, session_id, "<c-c>")
|
||||
assert "wrote=1" in written
|
||||
text = await call_async(read_pty, session_id, timeout=2.0)
|
||||
assert "error" not in text.lower() or "alive=" in text
|
||||
@@ -576,13 +641,26 @@ async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
|
||||
PtyManager.close_all()
|
||||
|
||||
|
||||
async def test_write_pty_keys_refuses_an_unknown_marker(workspace: object) -> None:
|
||||
"""The typo comes back as an error and never reaches the session."""
|
||||
del workspace
|
||||
try:
|
||||
opened = await call_async(open_pty, _py("print('up')"))
|
||||
session_id = _session_id(opened)
|
||||
result = await call_async(write_pty_keys, session_id, "<hme>")
|
||||
assert result.startswith("error: unknown key <hme>")
|
||||
_ = await call_async(close_pty, session_id)
|
||||
finally:
|
||||
PtyManager.close_all()
|
||||
|
||||
|
||||
def test_write_pty_is_literal() -> None:
|
||||
"""Plain write_pty must not call the keys decoder."""
|
||||
import inspect
|
||||
|
||||
from plyngent.tools.process.pty_terminal import decode_write_data
|
||||
|
||||
assert decode_write_data("press ctrl+c to cancel") == "press " + chr(3) + " to cancel"
|
||||
assert decode_write_data("press <c-c> to cancel") == "press " + chr(3) + " to cancel"
|
||||
src = inspect.getsource(write_pty.handler)
|
||||
assert "decode_write_data" not in src
|
||||
doc = write_pty.description or write_pty.handler.__doc__ or ""
|
||||
|
||||
@@ -50,6 +50,21 @@ def test_escape_error_names_the_mode_to_request(workspace: object, required: Acc
|
||||
_ = resolve_path("../outside", required=required)
|
||||
|
||||
|
||||
def test_escape_error_offers_a_temporary_workspace_for_a_write(workspace: object) -> None:
|
||||
"""A refused write is often scratch, which never needs a grant."""
|
||||
del workspace
|
||||
with pytest.raises(WorkspaceError, match="new_temporary_workspace"):
|
||||
_ = resolve_path("../scratch.txt", required=AccessMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("required", [AccessMode.READ, AccessMode.EXEC])
|
||||
def test_escape_error_hides_the_scratch_hint_from_other_modes(workspace: object, required: AccessMode) -> None:
|
||||
del workspace
|
||||
with pytest.raises(WorkspaceError) as refusal:
|
||||
_ = resolve_path("../scratch.txt", required=required)
|
||||
assert "new_temporary_workspace" not in str(refusal.value)
|
||||
|
||||
|
||||
def test_access_mode_parse_and_covers() -> None:
|
||||
assert parse_access_mode(" Read ") is AccessMode.READ
|
||||
assert parse_access_mode("EXEC") is AccessMode.EXEC
|
||||
|
||||
@@ -22,6 +22,11 @@ class Capability(Enum):
|
||||
THREAD = ...
|
||||
TIMEOUT = ...
|
||||
|
||||
class Urgency(Enum):
|
||||
Critical = ...
|
||||
Normal = ...
|
||||
Low = ...
|
||||
|
||||
class Button:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -50,7 +55,7 @@ class DesktopNotifier:
|
||||
self,
|
||||
title: str,
|
||||
message: str,
|
||||
urgency: Any = ...,
|
||||
urgency: Urgency = ...,
|
||||
icon: Any = ...,
|
||||
buttons: Sequence[Button] = ...,
|
||||
reply_field: ReplyField | None = None,
|
||||
|
||||
Reference in New Issue
Block a user