20 Commits

Author SHA1 Message Date
NCBM caecf62f47 release: plyngent 0.13.0 2026-10-24 03:24:55 +08:00
NCBM 102c377adc deps: bump ruff to 0.17.2
The commit gateway pins its hooks to the same versions as pdm.lock (see
prek.toml), so both ruff pins move together and `pdm lock` follows.
0.17.2 finds nothing new in src or tests, and `prek run --all-files` is green
on it.
2026-10-24 03:24:44 +08:00
NCBM beaaaea2d9 deps: refresh the lock
`pdm lock` re-resolved within the existing constraints and picked up
sqlalchemy 2.1.5 → 2.1.6 and wassima 2.1.4 → 2.1.5; `pdm sync` installed
both, and the suite is green on them.
2026-10-24 03:21:16 +08:00
NCBM 475f7da645 test: keep the one-shot test off the host's chat.db
`test_chat_oneshot_success` proved that an explicit `[database].url` wins over
the durable default by stat'ing the *real* `~/.local/share/plyngent/chat.db` and
comparing its mtime. So a test read the user's own session database, and it went
red whenever a live chat happened to write it — which is what a run does while
another plyngent session is open.

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

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

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

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

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

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

A prompt that follows an answer given on the notification — the "why denied"
comment, the per-call prompts after "Review one by one" — is announced at once:
the grace period and the burst throttle exist for prompts the human has not
noticed yet, and they have.
2026-10-24 01:48:22 +08:00
NCBM b7044bbba7 core/cli: keep a prompt notification on screen
A prompt notification went out with the library defaults: the notification
server's own expiry. A desktop server drops such a notification after a few
seconds, so the announcement of a blocked turn had usually slid into the
notification tray by the time the human looked back.

It now goes out without expiry (`timeout=0`, the freedesktop value for "until
dismissed"). Urgency is left alone: measured on KDE Plasma, `critical` keeps the
notification up just the same, but paints it as urgent and asks the platform's
Focus mode to let it through — decoration a prompt does not need. Dismissing is
already the prompt's own job once it is answered, so nothing lingers.
2026-10-24 01:48:04 +08:00
21 changed files with 803 additions and 167 deletions
+3 -3
View File
@@ -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'`). - **`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. - **`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=...`). - **`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). - **`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 (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill. - 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. - 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. - 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. - **`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`). 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)`, which yields the `PromptAnswerSource` the read may consult (`take()` returns `allow` / `deny` / reply text, or any key the prompt named in `choices`, without blocking) or `None` for notify-only. Only the outermost prompt of a nested read is announced; with no hook installed nothing changes. A source that *will* offer an answer says so through `supports_actions` before it offers it, so the read polls from its first moment instead of deciding once. `read_line_with_timeout` accepts a `source` and a `None` deadline, and stdin always wins over a host answer. The CLI's implementation is `cli/notify.py` (`[notify]`, desktop via the optional `desktop-notifier` extra, terminal via OSC 777/9 to `/dev/tty`): a prompt is announced only after `grace_seconds` — except one that follows an answer given on the notification, which goes out at once and past the burst throttle — `choices` become the notification's buttons (plus "Go to terminal"), the answer is treated like a typed one (never a session grant), the notification is sent without expiry (`timeout=0`, urgency at the library default) so it stays on screen until it is dismissed, and every failure falls through to the next channel instead of raising.
### CLI (`cli/`) ### CLI (`cli/`)
+2 -2
View File
@@ -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). **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 / Go to terminal**; a limit prompt: **Continue / Continue, stop asking / Stop here**; a skill write: **Allow once / This chat / Deny**) and a reply field for a question — and the answer is taken as if typed at the terminal (never as a session grant). The 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). **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. - 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 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). 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). 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. - 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.
+32 -8
View File
@@ -8,7 +8,9 @@ answering at the terminal stays silent. Bursts are throttled by
`min_interval_seconds`, and the announcement says what is being asked, not what `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 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 notification outlives the prompt (and may be mirrored to a lock screen), so it
travels only with `include_reason = true`. travels only with `include_reason = true`. The title is the prompt line and the
body is nothing but that reason — a channel gives the title a field of its own,
so repeating it in the body would only say the same thing twice.
## Configuration ## Configuration
@@ -32,18 +34,25 @@ a prompt answered inside the grace period is never announced at all.
| Prompt | Notification offers | | Prompt | Notification offers |
|---|---| |---|---|
| tool confirm (single / a round's batch) | Allow once / Deny / Go to terminal | | tool confirm (single) | Allow once / Deny / Go to terminal |
| command denylist, `fetch_local` private host | same three | | a round's batch of confirms | Allow all / Review one by one / Deny / Go to terminal |
| directory-access request, skill write | same three | | command denylist, `fetch_local` private host | Allow once / Deny / Go to terminal |
| directory-access request | same three (the level asked for, not a lower one) |
| skill write | Allow once / This chat / Deny / Go to terminal |
| limit-continue | Continue / Continue, stop asking / Stop here / Go to terminal |
| `ask_user_line` | a reply field — the answer *is* the text you type | | `ask_user_line` | a reply field — the answer *is* the text you type |
| `ask_user_choice`, `ask_user_form`, PTY secrets | nothing (terminal only) | | `ask_user_choice`, `ask_user_form`, PTY secrets | nothing (terminal only) |
| limit-continue | nothing (terminal only) |
Each prompt says which answers it accepts (`prompt_wait(..., choices=…)`), and
those are the buttons; one that names none gets Allow once / Deny / Go to
terminal. "Go to terminal" is always the last button: it withdraws the
notification and leaves the prompt waiting.
An answer read from the notification is treated exactly like a typed one — 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 `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 session grant: the button means "this once", which is why a prompt with an
notification and leaves the prompt waiting. A secret is never asked for in a extra answer (this chat, stop asking) offers it as a button of its own. A secret
notification, and never echoed into one. is never asked for in a notification, and never echoed into one.
A prompt that can be answered from the notification reads both surfaces while 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 blocks, from the first moment — the notification itself only shows up after
@@ -51,6 +60,11 @@ blocks, from the first moment — the notification itself only shows up after
terminal alone. Typing at the terminal always wins; a readline-only convenience terminal alone. Typing at the terminal always wins; a readline-only convenience
(Tab completion on `ask_user_line`) is what a question gives up in exchange. (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 ## Desktop channel
The desktop channel needs the optional extra: The desktop channel needs the optional extra:
@@ -70,6 +84,16 @@ 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 daemon thread, because callbacks only fire while the loop that owns the
notification is running. 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. 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 ## Terminal channel
No dependency and no extra: the CLI writes an escape sequence to `/dev/tty` No dependency and no extra: the CLI writes an escape sequence to `/dev/tty`
+6 -4
View File
@@ -45,13 +45,15 @@
# Notifications while a prompt waits on the human: a tool confirmation, a # Notifications while a prompt waits on the human: a tool confirmation, a
# directory-access grant, a skill write, an ``ask_user_*`` question, or the # directory-access grant, a skill write, an ``ask_user_*`` question, or the
# limit-continue prompt. A prompt is announced only after `grace_seconds` of # 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` # waiting, so one you answer at the terminal stays silent — except a prompt that
# throttles bursts. # 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: # channels: "auto" tries the desktop first (needs the `notify` extra:
# `pip install plyngent[notify]`) and falls back to a terminal escape sequence; # `pip install plyngent[notify]`) and falls back to a terminal escape sequence;
# "desktop" / "terminal" pin one of them. # "desktop" / "terminal" pin one of them.
# `actions` offers allow / deny / review as notification buttons; where the # `actions` offers the answers the prompt accepts as notification buttons (allow /
# platform has none the notification stays text-only. # deny / review when it names none); where the platform has none the notification
# stays text-only.
# `include_reason` adds the one-line reason — it can name a command or a URL, so # `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).
# [notify] # [notify]
Generated
+66 -66
View File
@@ -5,7 +5,7 @@
groups = ["default", "dev", "notify"] groups = ["default", "dev", "notify"]
strategy = ["inherit_metadata"] strategy = ["inherit_metadata"]
lock_version = "4.5.1" lock_version = "4.5.1"
content_hash = "sha256:6b62075d8b95d83a7d5d6c99c073db457750af4c8a4e988adbef8799f6f4167e" content_hash = "sha256:baade01b6537caca957e9fe9ee94e82f998e39255b52ed94db94b501e620398b"
[[metadata.targets]] [[metadata.targets]]
requires_python = "~=3.14" requires_python = "~=3.14"
@@ -659,29 +659,29 @@ files = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.10" version = "0.17.2"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust." summary = "An extremely fast Python linter and code formatter, written in Rust."
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"}, {file = "ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e"},
{file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"}, {file = "ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d"},
{file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"}, {file = "ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"}, {file = "ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"}, {file = "ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"}, {file = "ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"}, {file = "ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"}, {file = "ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"}, {file = "ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004"},
{file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"}, {file = "ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"}, {file = "ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"}, {file = "ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"}, {file = "ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"}, {file = "ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe"},
{file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"}, {file = "ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd"},
{file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"}, {file = "ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c"},
{file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"}, {file = "ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962"},
{file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"}, {file = "ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb"},
] ]
[[package]] [[package]]
@@ -697,7 +697,7 @@ files = [
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.1.5" version = "2.1.6"
requires_python = ">=3.11" requires_python = ">=3.11"
summary = "Database Abstraction Library" summary = "Database Abstraction Library"
groups = ["default"] groups = ["default"]
@@ -705,48 +705,48 @@ dependencies = [
"typing-extensions>=4.6.0", "typing-extensions>=4.6.0",
] ]
files = [ files = [
{file = "sqlalchemy-2.1.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5541a110ac4bb6377c785aea1b8f0b4d6658ed0ca5061ed1115d0725062b026b"}, {file = "sqlalchemy-2.1.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5f9400b1234555b9ae78450123dec7aaf045d8d978a1e94967437ebe561ae120"},
{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.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d106c2191a3a0dea6873c96ab909f31c13978913714a25023f492e9ccc8ffcc7"},
{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.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ca3a128260fb3aa1c33554f1993bc5ddf3f82375697028c49e7b411ab80b84"},
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:72ddd7a8ec187d02de0ef316a15aaf381ddcc594958247b02a3dcdb4c42f0b02"}, {file = "sqlalchemy-2.1.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8c34c200a124a1a5769671613b3fc05a21d0a50f7ad942cf0ee12d4390a112c"},
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f5173a6e60a15090f9e2348a8ca94cc13de72796c16733f3b62fbb22acba07b8"}, {file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:17bc5f508f5e5cee2722a5a14af0a48768b40e1fc5d1bfc92abf6ac587636bd7"},
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:09b10bd68223d57cce39c08947efae3f91ac40b552e07c7c869c3350a0fc4c23"}, {file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c757e13b63a25d59a2566c9b2e77adf14d969207a375ca82a9b4fa1a04b37845"},
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ea4cb5022462d9f15e7b72a52c267385aa97ac224c8503ac63ba90c0067ad0d9"}, {file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73aa8d2f911a54d1310e8258a752207e2d7624f4db46e2320d734fe7218320a1"},
{file = "sqlalchemy-2.1.5-cp314-cp314-win32.whl", hash = "sha256:c3ffbe6c680c626403b7e9697d92465501078731b9e8863b1410826e66b950e1"}, {file = "sqlalchemy-2.1.6-cp314-cp314-win32.whl", hash = "sha256:1927eabf26265ed962627e7a64626d9058da29cc0faea341091302ec108ce305"},
{file = "sqlalchemy-2.1.5-cp314-cp314-win_amd64.whl", hash = "sha256:061d089901319463a1160e4596191e355d2a9b4cce4852dac5adc301b085ac2f"}, {file = "sqlalchemy-2.1.6-cp314-cp314-win_amd64.whl", hash = "sha256:fd1154c6394892e727ff234273f5eb2b4319e5714917b016602435d77add8dcb"},
{file = "sqlalchemy-2.1.5-cp314-cp314-win_arm64.whl", hash = "sha256:f5bc7fb93f1c17efefeb062bc4d2742ad1b71d56c36e6e2898a371ac6a56839a"}, {file = "sqlalchemy-2.1.6-cp314-cp314-win_arm64.whl", hash = "sha256:10a4fe106adbb46c9452f750d8a5d4013d9a7e9752f63631c3b255c062b748d0"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9bb993b1d0890a2da90589684c3d09de75df82c06aa8f7eda246c7cd0ea3e820"}, {file = "sqlalchemy-2.1.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea5f3dd7450669acbfee7813d3a2a13876806b1d9e4c1626a7bf587c33c68472"},
{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.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4e4782514c16753f8bce3dd925cd37d26c847bead06e179af6fa4993ce83e88"},
{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.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da21b36d60f47dd04042e9372ffb3d1ededf5c60a76d43f8c3592221fb2d41cd"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59a56be2e4e972061f9d5c1d5b01763b8b2207b48b956fbb6879c7443f5817f7"}, {file = "sqlalchemy-2.1.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:048d1beb9bfd7ca1afbcfdb8da9519712827df6cccc80a225715ce387d37bb3b"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc1a61d7f42e71746b5a2e10e90a6b174b4add441b7b6cf14278f1e029b2b800"}, {file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8de444335e3723959913f6635d579276a3517b8c19cbdfaefb04e73b5480e88b"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:740e96dfb99a615c8056070d3747efe8bce4c145e5953ccc9e4b004c139de0d2"}, {file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:231d0fd85866f847c34c915116c386c83e677ab6f79a94c835b7ae7313b86095"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:92a0bb0595331c8e40bc615a67cc7bca9b761487409d1bf46c598b6ba5c0754d"}, {file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf937348d4052eac6cfa93c809ad214be75b1f057e3fc0dd2e374a814a23a16b"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-win32.whl", hash = "sha256:11185c72b5372369166cb721537943f8e1e6e42a399e9bac19e02f3ee20592a4"}, {file = "sqlalchemy-2.1.6-cp314-cp314t-win32.whl", hash = "sha256:bc8b805fb04472e0f9cb3bc6aad90f24b202afc274b7b8bbd48042130e82997a"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5173207b8fe1ddfc1d451c420a2a9cbbd0734b4a19896b8080e3744497e1c20c"}, {file = "sqlalchemy-2.1.6-cp314-cp314t-win_amd64.whl", hash = "sha256:84033cab9fec2a515a62d76a7b4acafa83339917db9ea911b65b017928ae7dd8"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_arm64.whl", hash = "sha256:def2f117a6ec0a25202ee36a148bbcfad3d43e2f2bb2e698e6d4448c6db5cb19"}, {file = "sqlalchemy-2.1.6-cp314-cp314t-win_arm64.whl", hash = "sha256:96ace18758136905f8d5588ec35d6ae4fdc29ce4e4902518263ca33abf10de04"},
{file = "sqlalchemy-2.1.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a0b80f91d9ed92be7a134278ea2e83a693c07d9986824ca51d859af9e57e18f5"}, {file = "sqlalchemy-2.1.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:89d05a1ef531a1fee86d68e731c428b773bd37aabf743bf63dc3709c86f7254d"},
{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.6-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e12763cb2a400879c6dfe34995a032f707de635c8cad14d6ba777a1250be90b"},
{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.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6e1a9ae71366164800942c477e663b31f3accef55d2e039cb7919fdb54b0086"},
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cefa5badc13df1c9082bacdfa435c8eb6dcb9b32717f51d09d1e160a599646f"}, {file = "sqlalchemy-2.1.6-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9bd573f8da863781ace89950d57a959397d5048b1cf69931c29b2b4ce1405bd7"},
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2549e503185897de1fc5ca7730d5daed0d27d0b26df4a059dcdd33ebe81b7b4b"}, {file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0d0424a4189f95886c78b48bb500a1dd7ae6dbae76deb552b15813dc298d9c8f"},
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:5a6871ee159a943d874012de835d56d0544ee871a99dbae05bc053da315b2c23"}, {file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:cc60fffea328f38187a4fce43f99c6bbf930de2b87b78251f43f2ba64a1af622"},
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:51e290080415325b66a94d20f6360e310606acb832f2bd677d4fcb95728e7ce1"}, {file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c390f76f2fe408aa1a3121f3ca3d5c15698e14d913197417355fe3dc016f787d"},
{file = "sqlalchemy-2.1.5-cp315-cp315-win32.whl", hash = "sha256:c228084025a28aa038aef9b821d842508650fe12fe51f06e9cdaa4393be7812c"}, {file = "sqlalchemy-2.1.6-cp315-cp315-win32.whl", hash = "sha256:79c1c20dffd4d8d7e669f096c88f261da8ba3646939cd3eb6faeccb92bae99c3"},
{file = "sqlalchemy-2.1.5-cp315-cp315-win_amd64.whl", hash = "sha256:bc09dd22c3fc491a2df7d16ca4167de35573f40c2e82dd4d6ddae589a73e7032"}, {file = "sqlalchemy-2.1.6-cp315-cp315-win_amd64.whl", hash = "sha256:f1e78019147ff7e08afaffb88fc3a0aa795171b147eacfc7ff873ce7a0d37dff"},
{file = "sqlalchemy-2.1.5-cp315-cp315-win_arm64.whl", hash = "sha256:375ef1bf78bc269a220963163d2580d5377898ed23a00bdd98a02d755421573d"}, {file = "sqlalchemy-2.1.6-cp315-cp315-win_arm64.whl", hash = "sha256:3974e33fc322b645eedeac8a3dea93022466fee41a6385c744a6656aba293cd4"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:7af1c92255cf8d50e87a808ce41096907332acdd09f285426a52f7c300e152db"}, {file = "sqlalchemy-2.1.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e269c68ec200538ae3fa7f9e8d8ed2b4b34d974c25945286ccc2a488438acf32"},
{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.6-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b25199a1b499da643de52ab73e972eb07e55e2a95bb99eaa491846098fa2214c"},
{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.6-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:641a3742dac4b8f325667b585097de0f1d20eb3498506c92d5f04c20b3a6c823"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b79ee943e2e12246f873345762b83dbadd1fcfc3e832eab83272d672704dbdea"}, {file = "sqlalchemy-2.1.6-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d96186af967d7ae3f76d1e9a8fb70b5beb0f0dbdacfd3b418a946fffbb8781ff"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e9418d87b55c469c2f1328246d4533238cdc43134c695d6b9a6b7a87da49d463"}, {file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:5a9b4cbc5f5cda3c9429e1ac503a57f3b95c59886f26af6ffb44e2e7628b7df8"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:898d86c22640fe52ffe02b4abc043a3c6754b4875837599e739be440966dffa1"}, {file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b7ddb305126d34fda64c3fe4632a43fb9b724da6cc7d1dedd8434e6c783b285b"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b3ad20cd035392f2b91a6466e24643990c1378beda80c9bda87a3a41e841e838"}, {file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:70b435f719d7156623f49ac6539f30dd8471500b5acf399f3b318c1c17f6fa7f"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-win32.whl", hash = "sha256:db34eefaf992c84460ab26ecf2fc2bdcc68965d6becfae8182cae0efeaa3e8f8"}, {file = "sqlalchemy-2.1.6-cp315-cp315t-win32.whl", hash = "sha256:654192c9f192459ee5f63e6606cc6dd8c50681c28e0689d2a494fc9522b357c1"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_amd64.whl", hash = "sha256:173054351d863ddada188284e309e87a9f030132c8d534e05458971ff061aa1e"}, {file = "sqlalchemy-2.1.6-cp315-cp315t-win_amd64.whl", hash = "sha256:a053a9d9c9ccc9b72d90ecdcbabcbe57446defc7e8c52fc9978188f78742ac19"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_arm64.whl", hash = "sha256:22c8d0873168a54f614e381559ea83d547b2cc5488b95b049bdc92667586bd5f"}, {file = "sqlalchemy-2.1.6-cp315-cp315t-win_arm64.whl", hash = "sha256:61dc3fa488208a2f03e0e25ad32950dc024545291acc7348f2e550ea1bc5abb4"},
{file = "sqlalchemy-2.1.5-py3-none-any.whl", hash = "sha256:2f77b77b716f005b2dc3cc059d56df6a919bfb5b439e4149890a9df6c36be9e3"}, {file = "sqlalchemy-2.1.6-py3-none-any.whl", hash = "sha256:106878cf3795cd3d357e2e858609f9e44f20f19b2d8e48a3532bef3f90b03fbe"},
{file = "sqlalchemy-2.1.5.tar.gz", hash = "sha256:0df76cf1ca680ad745bd378a6299a058611a8a784eeaeb4ed78ad4a0b7ce2003"}, {file = "sqlalchemy-2.1.6.tar.gz", hash = "sha256:499eae5c9051efe96cb9a73ec2fbde8ffa4186f102b9094ebab8fcc9d3b99f52"},
] ]
[[package]] [[package]]
@@ -789,14 +789,14 @@ files = [
[[package]] [[package]]
name = "wassima" name = "wassima"
version = "2.1.4" version = "2.1.5"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "Access your OS root certificates with utmost ease" summary = "Access your OS root certificates with utmost ease"
groups = ["default"] groups = ["default"]
marker = "sys_platform != \"emscripten\"" marker = "sys_platform != \"emscripten\""
files = [ files = [
{file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"}, {file = "wassima-2.1.5-py3-none-any.whl", hash = "sha256:53d9a56cf55fc969f8cdd1993f8dc69f857d1c32229fb9084dc2b267044b6dde"},
{file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"}, {file = "wassima-2.1.5.tar.gz", hash = "sha256:b02c1985e527c9539396cc85b909857d810dafaaa318a30266e585a0e7135f54"},
] ]
[[package]] [[package]]
+2 -2
View File
@@ -23,7 +23,7 @@ repos = [
types_or = ["python", "pyi"], types_or = ["python", "pyi"],
args = [], args = [],
require_serial = true, require_serial = true,
additional_dependencies = ["ruff==0.16.10"], additional_dependencies = ["ruff==0.17.2"],
}, },
{ {
id = "ruff-format", id = "ruff-format",
@@ -32,7 +32,7 @@ repos = [
language = "python", language = "python",
types_or = ["python", "pyi"], types_or = ["python", "pyi"],
require_serial = true, require_serial = true,
additional_dependencies = ["ruff==0.16.10"], additional_dependencies = ["ruff==0.17.2"],
}, },
{ {
id = "basedpyright", id = "basedpyright",
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "plyngent" name = "plyngent"
version = "0.12.0" version = "0.13.0"
description = "LLM chat and agent toolkit" description = "LLM chat and agent toolkit"
authors = [ authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" }, { name = "HivertMoZara", email = "worldmozara@163.com" },
@@ -33,7 +33,7 @@ plyngent = "plyngent.cli:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"basedpyright==1.40.2", "basedpyright==1.40.2",
"ruff==0.16.10", "ruff==0.17.2",
"pytest>=9.1.1", "pytest>=9.1.1",
"pytest-asyncio>=1.4.0", "pytest-asyncio>=1.4.0",
] ]
+32 -2
View File
@@ -34,6 +34,7 @@ if TYPE_CHECKING:
_TOOL_RESULT_PREVIEW = 120 _TOOL_RESULT_PREVIEW = 120
_TOOL_ARGS_PREVIEW = 80 _TOOL_ARGS_PREVIEW = 80
_ASK_SUBJECT_PREVIEW = 60 _ASK_SUBJECT_PREVIEW = 60
_PTY_PAYLOAD_PREVIEW = 40
# ``run_argv_batch`` lists the commands it runs above its ``* Run batch `` row — one # ``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. # line per command, a ``pipe_out`` chain joined into one — elided past this width.
_RUN_BATCH_CMD_LIMIT = 80 _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: def _open_pty_prefix(args_json: str) -> str:
argv = _json_list_arg(args_json, "command") argv = _json_list_arg(args_json, "command")
cmd = shlex.join(argv) if argv else "?" 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), "skill_edit": PrettyLine(prefix=_skill_edit_prefix, detail=_skill_edit_detail),
"open_pty": PrettyLine(prefix=_open_pty_prefix, detail=_open_pty_detail), "open_pty": PrettyLine(prefix=_open_pty_prefix, detail=_open_pty_detail),
"read_pty": _pty_session_line("Read", _read_pty_detail), "read_pty": _pty_session_line("Read", _read_pty_detail),
"write_pty": _pty_session_line("Write", _pty_write_detail), "write_pty": PrettyLine(prefix=_pty_write_prefix("Write"), detail=_pty_write_detail),
"write_pty_keys": _pty_session_line("Keys", _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), "ask_into_pty": _pty_session_line("Ask", _pty_write_detail),
"close_pty": _pty_session_line("Close", _close_pty_detail), "close_pty": _pty_session_line("Close", _close_pty_detail),
"ask_user_line": _ask_line("", "question", _ask_detail), "ask_user_line": _ask_line("", "question", _ask_detail),
+34 -4
View File
@@ -69,11 +69,33 @@ _CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"}) _CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
_CONTINUE_AUTO_TOKEN = "yyy" _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 # Batch confirm answers: ``y`` allows the round's calls, ``m`` reviews them one
# at a time through the per-call prompt. # at a time through the per-call prompt.
_BATCH_YES_TOKENS = frozenset({"y", "yes"}) _BATCH_YES_TOKENS = frozenset({"y", "yes"})
_BATCH_MANUAL_TOKENS = frozenset({"m", "manual", "each"}) _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 @dataclass
class _AutoContinue: class _AutoContinue:
@@ -202,9 +224,17 @@ def _decide_continue_limit(reason: str) -> bool:
backend = get_prompt_backend() backend = get_prompt_backend()
if not backend.is_interactive(): if not backend.is_interactive():
return False 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) _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.
token = (_read_answer(None, wait) or "").strip().lower()
if not token:
token = "y"
if token == _CONTINUE_AUTO_TOKEN: if token == _CONTINUE_AUTO_TOKEN:
_auto_continue.turn = True _auto_continue.turn = True
return True return True
@@ -453,7 +483,7 @@ def prompt_skill_write_confirm(
try: try:
with ( with (
pause_task_cancel_for_prompt(), 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) as wait,
): ):
backend = get_prompt_backend() backend = get_prompt_backend()
if not backend.is_interactive(): if not backend.is_interactive():
@@ -591,7 +621,7 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
# Non-interactive: deny, like the default of the single prompt. # Non-interactive: deny, like the default of the single prompt.
return BatchConfirmDecision(allowed=False) return BatchConfirmDecision(allowed=False)
names = ", ".join(request.name for request in requests) names = ", ".join(request.name for request in requests)
with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names) as wait: with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names, choices=_BATCH_CHOICES) as wait:
backend.echo() backend.echo()
backend.secho(format_batch_confirm_box(requests), fg="yellow") backend.secho(format_batch_confirm_box(requests), fg="yellow")
backend.secho("m = review them one call at a time", fg="bright_black") backend.secho("m = review them one call at a time", fg="bright_black")
+64 -14
View File
@@ -3,10 +3,12 @@
A prompt blocks the turn, so someone who switched to another window learns about 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 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 ``grace_seconds`` — answering at the terminal stays silent — and, where the
platform allows it, offers the answer itself: allow / deny / review buttons for platform allows it, offers the answer itself: the prompt's own answers as
a confirmation, a reply field for a question. A prompt that can be answered from buttons (allow / deny / review for a confirmation), a reply field for a
the notification reads from both surfaces from its first poll, since the question. A prompt that can be answered from the notification reads from both
announcement only appears once the grace period is over. 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 Two channels. The desktop one is the optional ``desktop-notifier`` extra
(``pip install plyngent[notify]``); the terminal one writes an OSC escape (``pip install plyngent[notify]``); the terminal one writes an OSC escape
@@ -48,6 +50,7 @@ _ANSWER_CAPABILITY: dict[str, str] = {
"policy": "buttons", "policy": "buttons",
"access": "buttons", "access": "buttons",
"skills": "buttons", "skills": "buttons",
"limit": "buttons",
"ask": "reply", "ask": "reply",
} }
@@ -60,6 +63,14 @@ _BUTTONS: tuple[tuple[str, str], ...] = (
_MAX_TITLE = 80 _MAX_TITLE = 80
_MAX_MESSAGE = 200 _MAX_MESSAGE = 200
#: 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: def _clean(text: str, limit: int) -> str:
"""One line, no control characters, truncated to *limit* characters.""" """One line, no control characters, truncated to *limit* characters."""
@@ -171,6 +182,11 @@ class DesktopNotifierChannel:
send( send(
title=request.title, title=request.title,
message=request.message, 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. Dismissing is the prompt's own job once it is
# answered, so nothing lingers.
timeout=_NO_EXPIRY,
buttons=buttons, buttons=buttons,
reply_field=reply, reply_field=reply,
) )
@@ -218,6 +234,7 @@ def load_desktop_channel() -> DesktopChannel | None:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
_OSC9 = "\x1b]9;{title}: {message}\x07" _OSC9 = "\x1b]9;{title}: {message}\x07"
_OSC9_TITLE_ONLY = "\x1b]9;{title}\x07"
_OSC777 = "\x1b]777;notify;{title};{message}\x07" _OSC777 = "\x1b]777;notify;{title};{message}\x07"
_BELL = "\a" _BELL = "\a"
#: VTE (GNOME Terminal, Tilix, Terminator, …) speaks OSC 777 from this version on. #: VTE (GNOME Terminal, Tilix, Terminator, …) speaks OSC 777 from this version on.
@@ -268,7 +285,9 @@ def terminal_sequence(protocol: str, title: str, message: str, *, multiplexer: s
safe_title = _clean(title, _MAX_TITLE) safe_title = _clean(title, _MAX_TITLE)
safe_message = _clean(message, _MAX_MESSAGE) safe_message = _clean(message, _MAX_MESSAGE)
if protocol == "osc9": 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: else:
seq = _OSC777.format(title=safe_title, message=safe_message) seq = _OSC777.format(title=safe_title, message=safe_message)
if multiplexer == "tmux": if multiplexer == "tmux":
@@ -326,12 +345,16 @@ class WaitHandle:
#: one from the first moment it blocks — the notification itself is only #: one from the first moment it blocks — the notification itself is only
#: shown after the grace period. #: shown after the grace period.
supports_actions: bool 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: def __init__(self, notifier: WaitNotifier, wait: PromptWait) -> None:
self.notifier = notifier self.notifier = notifier
self.wait = wait self.wait = wait
self.identifier = None self.identifier = None
self.supports_actions = notifier.answer_capability(wait.kind) is not None self.supports_actions = notifier.answer_capability(wait.kind) is not None
self.handoff = notifier.take_handoff()
self._answers: SimpleQueue[str] = SimpleQueue() self._answers: SimpleQueue[str] = SimpleQueue()
self._timer: threading.Timer | None = None self._timer: threading.Timer | None = None
self._closed: bool = False self._closed: bool = False
@@ -351,10 +374,14 @@ class WaitHandle:
# "review" means "I will answer in the terminal": withdraw and wait. # "review" means "I will answer in the terminal": withdraw and wait.
self.notifier.dismiss(self) self.notifier.dismiss(self)
return 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) self._answers.put(key)
def __enter__(self) -> 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 timer.daemon = True
self._timer = timer self._timer = timer
timer.start() timer.start()
@@ -373,7 +400,7 @@ class WaitHandle:
self.notifier.dismiss(self) self.notifier.dismiss(self)
def _notify(self) -> None: def _notify(self) -> None:
if self._closed or not self.notifier.may_notify(): if self._closed or not self.notifier.may_notify(self):
return return
if not self.notifier.notify_desktop(self): if not self.notifier.notify_desktop(self):
self.notifier.notify_terminal(self.wait) self.notifier.notify_terminal(self.wait)
@@ -394,6 +421,7 @@ class WaitNotifier:
_env: Mapping[str, str] _env: Mapping[str, str]
_clock: Callable[[], float] _clock: Callable[[], float]
_last_sent: float _last_sent: float
_handoff: bool
def __init__( def __init__(
self, self,
@@ -416,6 +444,7 @@ class WaitNotifier:
self._env = os.environ if env is None else env self._env = os.environ if env is None else env
self._clock = clock self._clock = clock
self._last_sent = float("-inf") self._last_sent = float("-inf")
self._handoff = False
# -- hook ------------------------------------------------------------- # # -- hook ------------------------------------------------------------- #
@@ -426,25 +455,46 @@ class WaitNotifier:
def grace_seconds(self) -> float: def grace_seconds(self) -> float:
return self._config.grace_seconds return self._config.grace_seconds
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: def _wants(self, channel: str) -> bool:
channels = self._config.channels channels = self._config.channels
return channel in channels or "auto" in channels return channel in channels or "auto" in channels
def may_notify(self) -> bool: def may_notify(self, handle: WaitHandle) -> bool:
"""Whether a notification is worth sending right now.""" """Whether this prompt is worth announcing right now."""
if not self._config.enabled or not get_prompt_backend().is_interactive(): if not self._config.enabled or not get_prompt_backend().is_interactive():
return False return False
now = self._clock() 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 return False
self._last_sent = now self._last_sent = now
return True return True
def _message(self, wait: PromptWait) -> str: def _message(self, wait: PromptWait) -> str:
title = _clean(wait.title, _MAX_TITLE) """The body: the reason line, when the config allows it, else nothing.
Both channels show the title as their own field, so repeating it in the
body only makes the notification say the same thing twice.
"""
if self._config.include_reason and wait.detail: if self._config.include_reason and wait.detail:
return f"{title}\n{_clean(wait.detail, _MAX_MESSAGE)}" return _clean(wait.detail, _MAX_MESSAGE)
return title return ""
def _buttons(self, wait: PromptWait) -> tuple[tuple[str, str], ...]:
"""The answers *wait* accepts, and always a way back to the terminal."""
if not wait.choices:
return _BUTTONS
return (*wait.choices, (REVIEW, "Go to terminal"))
def desktop_channel(self) -> DesktopChannel | None: def desktop_channel(self) -> DesktopChannel | None:
"""The desktop surface, resolved once (importing the extra on demand).""" """The desktop surface, resolved once (importing the extra on demand)."""
@@ -496,7 +546,7 @@ class WaitNotifier:
DesktopRequest( DesktopRequest(
title=_clean(wait.title, _MAX_TITLE), title=_clean(wait.title, _MAX_TITLE),
message=self._message(wait), message=self._message(wait),
buttons=_BUTTONS if capability == "buttons" else (), buttons=self._buttons(wait) if capability == "buttons" else (),
reply_field=capability == "reply", reply_field=capability == "reply",
on_answer=handle.answer, on_answer=handle.answer,
on_reply=handle.answer, on_reply=handle.answer,
+2 -1
View File
@@ -284,7 +284,8 @@ class NotifyConfig(Struct, omit_defaults=True):
"""Desktop notifications shown while a prompt waits on the human. """Desktop notifications shown while a prompt waits on the human.
A prompt is announced only after ``grace_seconds`` of waiting, so answering 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 ``channels`` picks the surfaces: ``auto`` tries the desktop first and falls
back to the terminal, ``desktop`` / ``terminal`` pin one of them. back to the terminal, ``desktop`` / ``terminal`` pin one of them.
+15 -3
View File
@@ -126,12 +126,16 @@ class PromptWait:
``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can ``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can
tailor what it shows and which answers it offers; ``title`` is short and tailor what it shows and which answers it offers; ``title`` is short and
safe to surface anywhere, while ``detail`` carries one optional line the safe to surface anywhere, while ``detail`` carries one optional line the
caller has already redacted. caller has already redacted. ``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 kind: str
title: str title: str
detail: str = "" detail: str = ""
choices: tuple[tuple[str, str], ...] = ()
@runtime_checkable @runtime_checkable
@@ -176,9 +180,17 @@ def get_prompt_wait_hook() -> PromptWaitHook | None:
@contextlib.contextmanager @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], ...] = (),
) -> Generator[PromptAnswerSource | None]:
"""Announce a blocking prompt to the host; yields its answer source (or None). """Announce a blocking prompt to the host; yields its answer source (or None).
``choices`` says which answers this prompt accepts (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 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 a follow-up question while it is already on screen must not notify — and
interrupt — a second time. interrupt — a second time.
@@ -189,7 +201,7 @@ def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnsw
return return
token = _wait_depth.set(_wait_depth.get() + 1) token = _wait_depth.set(_wait_depth.get() + 1)
try: try:
with hook(PromptWait(kind, title, detail)) as source: with hook(PromptWait(kind, title, detail, choices)) as source:
yield source yield source
finally: finally:
_wait_depth.reset(token) _wait_depth.reset(token)
+185 -24
View File
@@ -10,11 +10,9 @@ from __future__ import annotations
import re 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_BYTE = re.compile(r"\\x([0-9A-Fa-f]{2})")
_HEX_U = re.compile(r"\\u([0-9A-Fa-f]{4})") _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 = { _SIMPLE = {
r"\n": "\n", r"\n": "\n",
r"\r": "\r", r"\r": "\r",
@@ -23,18 +21,84 @@ _SIMPLE = {
r"\E": "\x1b", r"\E": "\x1b",
r"\0": "\x00", 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", "esc": "\x1b",
"escape": "\x1b",
"enter": "\r", "enter": "\r",
"tab": "\t", "tab": "\t",
"s-tab": "\x1b[Z",
"space": " ",
"bs": "\x7f",
"del": "\x1b[3~",
"ins": "\x1b[2~",
"home": "\x1b[H",
"end": "\x1b[F",
"up": "\x1b[A", "up": "\x1b[A",
"down": "\x1b[B", "down": "\x1b[B",
"right": "\x1b[C",
"left": "\x1b[D", "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). # C0 controls except TAB/LF/CR (those stay for readable logs).
_UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") _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) 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: 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`` - ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>``
- ``\\xHH`` byte, ``\\uHHHH`` Unicode code point ``<ins>`` ``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>``
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive; A-Z or @ [ \\ ] ^ _) ``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>``
- ``key=esc`` ``key=enter`` ``key=tab`` ``key=up|down|left|right`` - 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: if not data:
return data return data
@@ -75,18 +234,20 @@ def decode_write_data(data: str) -> str:
i = 0 i = 0
n = len(data) n = len(data)
while i < n: 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:] 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) m_hex = _HEX_BYTE.match(rest)
if m_hex is not None: if m_hex is not None:
out.append(chr(int(m_hex.group(1), 16))) out.append(chr(int(m_hex.group(1), 16)))
+1 -1
View File
@@ -26,7 +26,7 @@ def write_pty_payload(session_id: int, raw: str) -> str:
async def write_pty(session_id: int, data: str) -> str: async def write_pty(session_id: int, data: str) -> str:
"""Write **literal** text to a PTY session. Does not append a newline. """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`. control sequences use :func:`write_pty_keys`.
""" """
try: try:
+20 -8
View File
@@ -3,27 +3,39 @@ from __future__ import annotations
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError 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 from .write_pty import write_pty_payload
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO) @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_pty_keys(session_id: int, data: str) -> str: 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 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`` - ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>``
- ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point ``<ins>`` ``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>``
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive) ``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>``
- ``key=esc|enter|tab|up|down|left|right`` - 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: try:
raw = decode_write_data(data) raw = decode_write_data(data)
return write_pty_payload(session_id, raw) return write_pty_payload(session_id, raw)
except KeySyntaxError as exc:
return f"error: {exc}"
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
except OSError as exc: except OSError as exc:
+19 -4
View File
@@ -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" 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)])) await render_events(_aiter([_pretty_call("write_pty", '{"session_id": 3, "data": "ls -la"}'), _result(status)]))
out = capsys.readouterr().out 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( await render_events(
_aiter( _aiter(
[ [
_pretty_call("write_pty_keys", '{"session_id": 3, "data": "ctrl+c"}'), _pretty_call("write_pty_keys", '{"session_id": 3, "data": "<c-c><enter>"}'),
_result("session_id=3\nalive=true\nexit_code=\nwrote=1"), _result("session_id=3\nalive=true\nexit_code=\nwrote=2"),
] ]
) )
) )
out = capsys.readouterr().out 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: async def test_pretty_ask_into_pty_never_shows_the_answer(capsys: pytest.CaptureFixture[str]) -> None:
+74
View File
@@ -332,6 +332,80 @@ def test_batch_confirm_denied_by_the_notification() -> None:
assert _prompt_confirm_batch_sync(requests).allowed is False 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_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_every_confirm_announces_itself() -> None: def test_every_confirm_announces_itself() -> None:
"""Each blocking confirm reports its kind, so a host can pick a surface.""" """Each blocking confirm reports its kind, so a host can pick a surface."""
from pathlib import Path from pathlib import Path
+147 -6
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import sys
import time import time
from typing import TYPE_CHECKING import types
from typing import TYPE_CHECKING, ClassVar
import pytest import pytest
@@ -9,6 +11,7 @@ from plyngent.cli.notify import (
ALLOW, ALLOW,
DENY, DENY,
REVIEW, REVIEW,
DesktopNotifierChannel,
DesktopRequest, DesktopRequest,
WaitNotifier, WaitNotifier,
detect_multiplexer, detect_multiplexer,
@@ -150,6 +153,12 @@ def test_terminal_sequence_osc777_and_osc9() -> None:
assert terminal_sequence("bell", "T", "M") == "\a" 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: def test_terminal_sequence_is_wrapped_for_tmux() -> None:
seq = terminal_sequence("osc777", "T", "M", multiplexer="tmux") seq = terminal_sequence("osc777", "T", "M", multiplexer="tmux")
assert seq.startswith("\x1bPtmux;\x1b") assert seq.startswith("\x1bPtmux;\x1b")
@@ -295,13 +304,17 @@ def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None:
assert request.buttons == () assert request.buttons == ()
def test_a_limit_prompt_carries_no_answers() -> None: 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() desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop) notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("limit", "Round limit reached")) as handle: with notifier(PromptWait("limit", "Round limit reached")) as handle:
assert handle.supports_actions is True
handle._notify() handle._notify()
assert handle.supports_actions is False answer = desktop.requests[0].on_answer
assert desktop.requests[0].buttons == () assert answer is not None
answer(ALLOW)
assert handle.take() == ALLOW
assert desktop.requests[0].reply_field is False assert desktop.requests[0].reply_field is False
@@ -379,6 +392,133 @@ def test_the_notification_is_dismissed_when_the_prompt_returns() -> None:
assert desktop.cleared == ["id-1"] 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
def test_a_prompt_offers_its_own_answers() -> None:
"""The prompt's ``choices`` become the buttons, plus a way to the terminal."""
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, (REVIEW, "Go to terminal"))
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"),
(REVIEW, "Go to terminal"),
)
# 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 # Installation
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -398,9 +538,10 @@ def test_install_notifier_can_be_disabled() -> None:
def test_reason_stays_out_unless_asked_for() -> None: def test_reason_stays_out_unless_asked_for() -> None:
notifier, _ = _notifier() notifier, _ = _notifier()
wait = PromptWait("confirm", "Allow?", "argv: rm -rf /") 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) 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: def test_temporary_backend_is_transparent_to_the_notifier() -> None:
+7 -6
View File
@@ -135,10 +135,12 @@ access_key_or_token = "sk"
lambda _p, **_kw: DummyClient(), lambda _p, **_kw: DummyClient(),
) )
from platformdirs import user_data_path # 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
global_db = user_data_path("plyngent") / "chat.db" # nothing was created there.
global_mtime = global_db.stat().st_mtime if global_db.exists() else None 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() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -162,8 +164,7 @@ access_key_or_token = "sk"
assert result.exit_code == EXIT_OK assert result.exit_code == EXIT_OK
assert "pong" in result.output assert "pong" in result.output
assert db_path.is_file() assert db_path.is_file()
if global_mtime is not None: assert not (unused_data_dir / "chat.db").exists()
assert global_db.stat().st_mtime == global_mtime
def test_chat_help_mentions_prompt() -> None: def test_chat_help_mentions_prompt() -> None:
+84 -6
View File
@@ -4,6 +4,8 @@ import asyncio
import sys import sys
from pathlib import Path from pathlib import Path
import pytest
from plyngent.tools.process import ( from plyngent.tools.process import (
ask_into_pty, ask_into_pty,
close_pty, close_pty,
@@ -514,13 +516,76 @@ def test_decode_write_data_escapes() -> None:
from plyngent.tools.process.pty_terminal import decode_write_data from plyngent.tools.process.pty_terminal import decode_write_data
assert decode_write_data(r"\x0f") == "\x0f" 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(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(r"a\nb") == "a\nb"
assert decode_write_data("plain") == "plain" 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: 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) 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 assert "wrote=1" in written
text = await call_async(read_pty, session_id, timeout=2.0) text = await call_async(read_pty, session_id, timeout=2.0)
assert "error" not in text.lower() or "alive=" in text 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() 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: def test_write_pty_is_literal() -> None:
"""Plain write_pty must not call the keys decoder.""" """Plain write_pty must not call the keys decoder."""
import inspect import inspect
from plyngent.tools.process.pty_terminal import decode_write_data 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) src = inspect.getsource(write_pty.handler)
assert "decode_write_data" not in src assert "decode_write_data" not in src
doc = write_pty.description or write_pty.handler.__doc__ or "" doc = write_pty.description or write_pty.handler.__doc__ or ""
+6 -1
View File
@@ -22,6 +22,11 @@ class Capability(Enum):
THREAD = ... THREAD = ...
TIMEOUT = ... TIMEOUT = ...
class Urgency(Enum):
Critical = ...
Normal = ...
Low = ...
class Button: class Button:
def __init__( def __init__(
self, self,
@@ -50,7 +55,7 @@ class DesktopNotifier:
self, self,
title: str, title: str,
message: str, message: str,
urgency: Any = ..., urgency: Urgency = ...,
icon: Any = ..., icon: Any = ...,
buttons: Sequence[Button] = ..., buttons: Sequence[Button] = ...,
reply_field: ReplyField | None = None, reply_field: ReplyField | None = None,