18 Commits

Author SHA1 Message Date
NCBM ef8d74a92a release: plyngent 0.6.0 2026-09-09 22:58:03 +08:00
NCBM 3fa05a86c6 docs: document limit auto-continue (yyy) 2026-09-09 22:48:16 +08:00
NCBM 18cbcf6835 cli/config: --auto-continue flag and [agent] auto_continue_limits 2026-09-09 22:47:39 +08:00
NCBM aba3765def cli: reset auto-continue at turn boundaries 2026-09-09 22:46:19 +08:00
NCBM 685370d3cb cli/limits: yyy turn-scoped auto-continue for limit prompts 2026-09-09 22:44:20 +08:00
NCBM af1e4a4770 docs: document directory access grants 2026-09-09 22:24:44 +08:00
NCBM a1f106a5e2 cli: directory access confirm, /grants, and session grants 2026-09-09 22:23:25 +08:00
NCBM 22f52a0bd2 config: [agent] allow_paths static pre-allow 2026-09-09 22:19:23 +08:00
NCBM d17c80b07e memory: persist session access grants 2026-09-09 22:17:34 +08:00
NCBM 49d1e08ebe tools: request_directory_access and grant stores 2026-09-09 22:16:22 +08:00
NCBM ba021cb138 tools/workspace: mode-aware path grants and escape hint 2026-09-09 22:11:50 +08:00
NCBM 66c58cf156 release: plyngent 0.5.0 2026-09-08 14:30:44 +08:00
NCBM a1455989f2 docs: document MCP initialize instructions folding 2026-09-08 13:09:41 +08:00
NCBM 4f142f1db9 cli: fold MCP initialize instructions into agent prompt and /mcp 2026-09-08 13:09:32 +08:00
NCBM 5f28a49bc1 config: add [agent] mcp_instructions and variadic system compose 2026-09-08 13:09:23 +08:00
NCBM a4baf38a52 runtime: capture MCP initialize instructions per connection 2026-09-08 13:09:14 +08:00
NCBM c638f6a5da docs: rename CLAUDE.md to AGENTS.md 2026-09-08 12:59:26 +08:00
NCBM ede614aa9e gitignore: add .codegraph index directory 2026-09-08 12:58:13 +08:00
48 changed files with 2098 additions and 276 deletions
+3
View File
@@ -214,3 +214,6 @@ __marimo__/
# Streamlit
.streamlit/secrets.toml
# codegraph (local code index)
.codegraph/
+11 -10
View File
@@ -1,4 +1,4 @@
# CLAUDE.md
# AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@@ -62,11 +62,11 @@ TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]
`create_client(provider)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply). `normalize_http_timeout` feeds OpenAI-compatible configs and Anthropic. Mapping: `openai` → `OpenAIClient` (agent uses Responses), `openai-compatible` / `deepseek` → chat-completions clients (deepseek with effective `convention = "responses"` → `DeepseekResponsesClient`, `"anthropic"` → `DeepseekAnthropicClient`), `anthropic` → `AnthropicClient`.
MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `[mcp]` server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, `tools/list` (cursor pagination), `tools/call`, `notifications/tools/list_changed` → `tools_stale`. Spawn/handshake failures record `connection.error` (status string), never raise; `ensure_started` / `restart(config)` / `aclose` / `refresh_tools`; `statuses()` → `(server, status, tool_count)` rows; per-request timeout from config (default 30s).
MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `[mcp]` server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, `tools/list` (cursor pagination), `tools/call`, `notifications/tools/list_changed` → `tools_stale`. Spawn/handshake failures record `connection.error` (status string), never raise; `ensure_started` / `restart(config)` / `aclose` / `refresh_tools`; `statuses()` → `(server, status, tool_count)` rows; `instructions()` → `(server, text)` for every connected server that returned optional `InitializeResult.instructions` (usage guidance captured per connection); per-request timeout from config (default 30s).
### Memory (`memory/`)
Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns), default local user, sessions (bound to `workspace` path; optional `provider_name`/`model`), messages stored as msgspec chat message JSON.
Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns; `PRAGMA user_version` 4), default local user, sessions (bound to `workspace` path; optional `provider_name`/`model`; `todo_stack` + `access_grants` JSON columns), messages stored as msgspec chat message JSON.
### Agent (`agent/`)
@@ -80,24 +80,25 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
### Tools (`tools/`)
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); clearer deny messages.
- **`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 basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error hints `request_directory_access`.
- **`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), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip 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`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
- 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 shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
- **`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.
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs).
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
- **`net` / `fetch`**: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
- **`DEFAULT_TOOLS` / catalog**: file + process + vcs + chat + todo + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `grep_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `grep_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
### Prompting (`prompting.py`)
@@ -107,8 +108,8 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`).
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/mcp` (`list` default) prints per-server status/tool counts + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--auto-continue` (auto-raise limits), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`).
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count.
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors.
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
@@ -139,7 +140,7 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
- G3: multiline `"""` … `"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`)
- G4: `plyngent chat -p/--prompt` (+ non-TTY stdin); exit codes 0/1/2/3; `--yes` / non-interactive confirm deny; `--stream/--no-stream`, `--quiet`
- G5: PTY master FD non-inheritable; `read_pty`/`close_pty` via `to_thread`; `PtyManager.close_all()` on chat exit; `--log-level`; clearer invalid TOML errors; export/status stay secret-free
- G6: README, `doc/plyngent.example.toml`, CLAUDE overview/CLI notes
- G6: README, `doc/plyngent.example.toml`, AGENTS overview/CLI notes
Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).
+16 -4
View File
@@ -147,6 +147,13 @@ access_key_or_token = "sk-..."
# tool_directives = '''### Workspace ...'''
confirm_destructive = true
max_context_tokens = 200000
# Fold MCP server initialize ``instructions`` (usage guidance) into the
# system prompt when tools are on (default true; false ignores server text).
# mcp_instructions = true
# Static out-of-workspace pre-allow (path → read|write|exec); never prompts.
# allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
# Auto-raise tool/PTY limits without prompting (same as answering yyy).
# auto_continue_limits = false
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
# [plugins]
@@ -157,7 +164,7 @@ Per-provider **`timeout`** is passed to the HTTP session for chat/completions, R
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
**MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.<name>]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp__<server>__<tool>` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones).
**MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.<name>]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp__<server>__<tool>` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. A server may also return optional `instructions` (usage guidance) in its MCP `initialize` response; when tools are on those are folded into the agent system prompt as `MCP server <name> instructions:` blocks (`[agent] mcp_instructions = false` disables), and `/mcp` previews them. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones).
Supported provider presets today:
@@ -211,10 +218,13 @@ plyngent chat --session 3
| `--stream` / `--no-stream` | Streaming deltas (default on) |
| `--quiet` | Less status on stderr |
| `--yes` | YOLO on: skip destructive-tool confirms for this process |
| `--auto-continue` | Auto-raise tool/PTY limits without prompting (like answering `yyy`) |
| `--log-level` | On the root CLI: `DEBUG`, `INFO`, `WARNING`, … |
Sessions resume the **most recently updated** session for the current workspace unless you pass `--new` or `--session`. Each session remembers the last **provider** and **model** (restored on resume so you are not re-prompted).
When a tool-loop or PTY limit is hit, the prompt accepts `y` (continue once), `n` (stop), or `yyy` — **stop asking for the rest of this turn** (the next user turn prompts again). `--auto-continue` / `[agent] auto_continue_limits = true` skip the prompt for every turn.
### One-shot (scripts / CI)
```bash
@@ -260,6 +270,7 @@ Type `/help` in the REPL for the live list. Common ones:
| `/models` | List config + remote `GET /models` (always re-fetches) |
| `/models --persist` | Merge remote catalog into TOML for this provider |
| `/todos` | Todo/task stack: list, push, pop, done, clear |
| `/grants` | Directory-access grants: list, or `revoke <index\|all>` |
| `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
| `/quit` | Leave the REPL |
@@ -269,15 +280,16 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
- **Workspace** = root for file/process/VCS tools (default cwd).
- **Session** = SQLite chat bound to a workspace path.
- Paths outside the workspace need a **directory-access grant**: the model calls `request_directory_access` (`read` / `write` / `exec`), the human approves at the requested level or another one (timed y/N, default deny; `--yes`/`/yolo` auto-approve as a process-only grant), and approval lasts for the session (`/grants` lists/revokes). Static pre-allow lives under `[agent].allow_paths`. The path denylist always wins, and grants gate path-resolving tools only — they are **not** a sandbox (`run_argv`/PTY reach the whole filesystem).
- Resuming a session from another directory prompts: keep session workspace, rebind to current, or abort.
## Tools (when enabled)
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `get_truncated` (resume any truncated result via its `truncate_token=...`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
Safety defaults:
- Paths stay under the workspace; optional `path_denylist` substrings (`tree` also skips denylisted children by default).
- Paths stay under the workspace unless granted (`request_directory_access` / `[agent].allow_paths`); optional `path_denylist` substrings always apply (`tree` also skips denylisted children by default).
- Command basename denylist (e.g. dangerous shells/utilities).
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
@@ -302,7 +314,7 @@ plyngent --log-level INFO chat ...
## Architecture (short)
See [doc/architecture.md](doc/architecture.md) and [CLAUDE.md](CLAUDE.md) for developers.
See [doc/architecture.md](doc/architecture.md) and [AGENTS.md](AGENTS.md) for developers.
- **`lmproto/`** — OpenAI-compatible, OpenAI Responses, Anthropic Messages, DeepSeek msgspec models and async SSE clients
- **`agent/`** — kind-based tool loop (`chat_completions` / `responses` / `messages`), streaming, usage, compact
+2 -2
View File
@@ -27,8 +27,8 @@
- agent: Kind-based tool loop, streaming, usage, compact; bridges:
``responses_bridge``/``responses_dispatch``, ``messages_bridge``/``messages_dispatch``;
``@tool`` / tags / registry. History stays chat-completions-shaped.
- tools: Workspace file/process/VCS/chat/todo/net tools; catalog; plugins;
instance/session context and views.
- tools: Workspace file/process/VCS/chat/todo/net tools + directory-access
grants (`access`); catalog; plugins; instance/session context and views.
- prompting: Shared ask/choose/form for CLI and tools.
- cli: Click entry, slash registry, REPL, one-shot chat.
- web: Web service (Phase H; not implemented).
+13
View File
@@ -29,6 +29,9 @@
# MCP (Model Context Protocol) servers exposed as agent tools.
# Defining a server opts it in; tools appear as mcp__<server>__<tool> with
# LOCAL tags (confirm flow applies). Names in disable stay disconnected.
# A server's initialize ``instructions`` (usage guidance) is folded into the
# agent system prompt when tools are on ([agent] mcp_instructions = false
# disables; /mcp previews what each server sent).
# [mcp.servers.docs]
# command = "npx"
# args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
@@ -55,8 +58,18 @@ max_tool_result_chars = 32000
parallel_tools = true
confirm_destructive = true
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
# Static out-of-workspace pre-allow (path → read|write|exec); never prompts.
# The model can also request access at runtime via request_directory_access
# (human approves; /grants lists or revokes). Denylist still applies, and
# grants are a tool-policy gate, not a sandbox (commands reach the FS).
# allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
# Auto-raise tool/PTY limits without prompting (same as answering yyy).
# auto_continue_limits = false
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
max_context_tokens = 200000
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
# into the agent system prompt when tools are on. false = ignore server text.
# mcp_instructions = true
# How open-todo nags are injected into model context:
# developer (default) | user | synthetic_tool | none
# (legacy system is treated as developer)
Generated
+201 -151
View File
@@ -4,7 +4,7 @@
[metadata]
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
lock_version = "4.5.1"
content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82"
[[metadata.targets]]
@@ -52,53 +52,109 @@ files = [
[[package]]
name = "charset-normalizer"
version = "3.4.9"
version = "3.5.1"
requires_python = ">=3.7"
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
groups = ["default"]
files = [
{file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"},
{file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"},
{file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8"},
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102"},
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36"},
{file = "charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f"},
{file = "charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a"},
{file = "charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e"},
{file = "charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6"},
{file = "charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3"},
]
[[package]]
name = "click"
version = "8.4.2"
version = "8.5.0"
requires_python = ">=3.10"
summary = "Composable command line interface toolkit"
groups = ["default"]
dependencies = [
"colorama; platform_system == \"Windows\"",
]
files = [
{file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"},
{file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"},
{file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"},
{file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"},
]
[[package]]
@@ -106,8 +162,8 @@ name = "colorama"
version = "0.4.6"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Cross-platform colored terminal text."
groups = ["default", "dev"]
marker = "platform_system == \"Windows\" or sys_platform == \"win32\""
groups = ["dev"]
marker = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -186,43 +242,43 @@ files = [
[[package]]
name = "jh2"
version = "5.0.13"
version = "5.0.14"
requires_python = ">=3.7"
summary = "HTTP/2 State-Machine based protocol implementation"
groups = ["default"]
files = [
{file = "jh2-5.0.13-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1dd137086b13d55a53c4b7b8b75869c824060a4d35615e4f49180ae58c7783f5"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75d0b2bffb2e260a0f3578a11a4999a713c94d1bd8de7cfe692c840e20ae83cf"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f681e197bf6552df3bb7a3abfd676e81316b69e36e7263e72383477174032ca2"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bde7e7ff0f17e87aca9ef23f66b80540a2de0ff8e2c26af55eb2e288a2a5f74f"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f8d364d53cc0854d910f2305726a8254acb527f13fa933dc213a591e21e7ffa"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b572daaafbd867bb5c5f324c918704ddd6d76af1cc7c2be483c3649b76ce7940"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0772ad66f346038fe05cb2928866ff1b21bcaa9ec3f14709fa1d419ed1dc287"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1d8313b13acfdef19c5ff793fdf397bae3d8d75181f6eee62604f13e4ec20a8"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:99aedd20daacb32f377152c9103d8d0271f32d1932bd39a5ed2cd2eef97f7e07"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:53b1df29ffe966cde1d2f0085b6acfcd237504627d406a51717ef3849ec88831"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:0ee6a5dd24a1dc0772a89b3498ed20f60167fbc633404b1e02895f04ae66c47c"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:521f871864880cabe361060f070516a56a54abc3e96f07bee9ba8b061eada891"},
{file = "jh2-5.0.13-cp314-cp314t-win32.whl", hash = "sha256:5909e2273f8b6fcea93c95564aa0f366ff9261a544a7a5d640ea95341321cc37"},
{file = "jh2-5.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:2cc4923ac6f57753e0cb4aef054a04f5d5c744b274dabf863f695fa3c2fd75f9"},
{file = "jh2-5.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:d3cf203de31371289d4c1e6b4d30c9f6de95b2bd2b300fe5468ff946299d0e48"},
{file = "jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3f54095cde010ff4e52f23dda92b31d3d3160c64f5f23fc6e181f8c7938afd33"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f810dd02b4a9647eacbe4eab617111c0e3ffc68e923e3ff172948db601d2ba1e"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29c23f3937f257beb5cd5b17f8727404f1267ff25d26e54c0a35d5defd0c895e"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c697b250a935030a128b45b41f507a4d33a082d8b8e12311c7e73fbb3970f057"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47d3203ed6fec39df4922764817979fad3a13d22cf750adc9cf9dc2b3399eacf"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5146468a76bbe8f4035f23a78813c5c433c70c0ca44eb0ba5558627a9e644b8"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d3d338284fbfbe173243c3cc62e510c6fa0d6d023e67da2292bd7578c734e3"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:641f65f5414b8990251bd7609513a696831558f69d2ca77d65dc3f7087f31267"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cf3dc6f7c14340241260bf63e5d6006b2ee7db45dafcb39c16fdf164ea0240a0"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:fb8e37ecf37382c02af83da521df5a77561441bc4cbb93b00627e015b7bbf349"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:296ee44dfad08ccc3857daf433dfa4a555d5b45f2df39503ccdd90ff10b32943"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1a75eed545420a31ea356e8046b8d86fcf694234dd1035191734a03720fb8a44"},
{file = "jh2-5.0.13-cp37-abi3-win32.whl", hash = "sha256:4812710810010c428db04e9290a13117f7b7c5a3a663b46a0586fe101cf25c9d"},
{file = "jh2-5.0.13-cp37-abi3-win_amd64.whl", hash = "sha256:5d61bc0a62b4eee11039bf29d4bdbb26efcff760dfb9d8e0739740028aa52b0f"},
{file = "jh2-5.0.13-cp37-abi3-win_arm64.whl", hash = "sha256:3f74dad9036e599eed51576492b00955237b86cf886f96844708a81cd0f5c710"},
{file = "jh2-5.0.13-py3-none-any.whl", hash = "sha256:2b311414989da48a155721fe47d0dc3e3b75360efd62d779aaff72d786be91cf"},
{file = "jh2-5.0.13.tar.gz", hash = "sha256:f8c78cffb3a35c4410513c3eb7989de36028c84277c04f07c97909dd94c23a75"},
{file = "jh2-5.0.14-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a1ee369128cee3a78be7ade531e7229480c22b20b2371671cfe566f34b47e079"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cfbad7863fcb8dc66dbc9da3e9ccfe11f8944d043618d486fdfd12b1ab54f6a"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9087585bd1f8c11fbade60164b9e79f42323784b22be245ceaeca758422e6bc5"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ccbfa14b306d5c5a4a68f3fe9823d686e46406c7dba5babcccb198fd5ae53a11"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5480d38a0eeb43f2bce716aaddee2b89fe30e24b9ac5b7ecfb8cbc7fb822c257"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:348824676428d44d6ddceadb2c1d0885cc351ebe0378eadfa74e382c5359abbe"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e09fb3bb222ce3cce63bcfab99cb48cbfdc44f56e81369bb1f920ef02c1105f"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20f9b15a367961dbbedeb0f40380668141d3543f427246456f337a6e9fbb5ab2"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3f60ba14d122c0c72b0da283c69e1c0e717fe18c32b8fbd13202954d5d5c7f4b"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:9e168d952ac659837c758e206efc7aef2a8b4eb57389691a2675f9059bdbb7d7"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:dedaf4cc07aee718c4380fcb0bea231f205be6db45a0855454b1d467a0c86837"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:9fc37f6fbeaba1fd5dd686594655448e2c98fbbb87b2a387faf5a331b2bc85b6"},
{file = "jh2-5.0.14-cp314-cp314t-win32.whl", hash = "sha256:8b4d38bd4bfb3cd2fd0eec62e95e14efd3c97b50535216acbc30afea6b5963e1"},
{file = "jh2-5.0.14-cp314-cp314t-win_amd64.whl", hash = "sha256:56c3e595254d253657a640bb1f92711836676a654a64484f6b1f1112ec52e68a"},
{file = "jh2-5.0.14-cp314-cp314t-win_arm64.whl", hash = "sha256:391a5357ac82a27d72579ccb9bcb281fd58da0c76e702158416ecabaa65fc3e2"},
{file = "jh2-5.0.14-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:68a23b2b8c1750b8bd208d7e5be0fce20f96aca795f57c56e525a36470773625"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0c2a4501c2a8ae3db2228c9138dc4a4b047750ffab018c29fbf58de59334bd8"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c32cc7136c54a63fc04abbdb656cd37dcd6b8462b946cd072ebdfbcf9350be45"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c9f39c49b4b02b82202fe9b983f22f1f2aa685ae96ee5800a9b4cad511c07b68"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20517ff67db8ac5c3af0bafffe6a639721d8be32f7a9a7c2b25f0ad7f74d2c85"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4df556fe49b596774af0f49f78eff1e143448f8367609d3e6d2a50533ada3855"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3e92cd9d01351d14b15c826120bfb41f5834bb0a8860cbaa1779f557d3e1288"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6eeb6cff0f5fcd529d7e0afc75eb81ad5befd5b6b893c20f188fb7f467a0320"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:50ef70513db51c8a078e09b0bd824b0a9e3ffd92ee20122c4fa128d3a33b3625"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:52d105593e3af3384119f398682ed29fcbf79c9e87d637f8f49baaeb10adc0d4"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:bda792840b62a41bf24049849a38b9355096f1a03a82cc7bc5309e6e85789b1c"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:fddbfe1dd7c55160431975863b415b124ba408c20a8dab6ff3f2bf1eb18c3f8e"},
{file = "jh2-5.0.14-cp37-abi3-win32.whl", hash = "sha256:c41cfd834a360e3eb7b1e3d18b9d1debb16e001e08177d422f65293bd1e586aa"},
{file = "jh2-5.0.14-cp37-abi3-win_amd64.whl", hash = "sha256:1008bd1c05b879e33a9450823a67066716dd430f96a872dc652266c2271d7615"},
{file = "jh2-5.0.14-cp37-abi3-win_arm64.whl", hash = "sha256:b5c82c7045e83e89e4b59d2940c456365bd7ad7e49650ae5a977cacdd4a2354d"},
{file = "jh2-5.0.14-py3-none-any.whl", hash = "sha256:070895dfaeda94eccbdc45c7c97985d1c6ac339de7c4e329106b6f9450189bda"},
{file = "jh2-5.0.14.tar.gz", hash = "sha256:101708db41998159295c403f891dce9d8055a8dd98450c605e6eb7c34751543e"},
]
[[package]]
@@ -278,7 +334,7 @@ files = [
[[package]]
name = "niquests"
version = "3.21.0"
version = "3.21.1"
requires_python = ">=3.7"
summary = "Niquests is a simple, yet elegant, HTTP library. It is a drop-in replacement for Requests, which is under feature freeze."
groups = ["default"]
@@ -288,13 +344,13 @@ dependencies = [
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
]
files = [
{file = "niquests-3.21.0-py3-none-any.whl", hash = "sha256:dbea441b9e9d5d755e02caa2b57b94cdb97b23d754ead21b80a4ac18ef66805f"},
{file = "niquests-3.21.0.tar.gz", hash = "sha256:5b7d10a05f4c7ed08cede0af74f492ae7a8a5a71291833d029e23365fc3ea80a"},
{file = "niquests-3.21.1-py3-none-any.whl", hash = "sha256:ac1ff8061cb480cdaad07f08b95cf21ee0a68ef21fbc2b6ddf5afff8e0d0072e"},
{file = "niquests-3.21.1.tar.gz", hash = "sha256:8ba6ee8712570ae41dfc59ac2ac7e509dcde660c6cc47078b43be423a2f99e64"},
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.16.0"
version = "24.19.0"
requires_python = ">=3.7"
summary = "unoffical Node.js package"
groups = ["dev"]
@@ -302,15 +358,15 @@ dependencies = [
"typing-extensions; python_version < \"3.8\"",
]
files = [
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36"},
{file = "nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36"},
{file = "nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b"},
]
[[package]]
@@ -326,13 +382,13 @@ files = [
[[package]]
name = "platformdirs"
version = "4.11.2"
version = "4.11.7"
requires_python = ">=3.10"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"]
files = [
{file = "platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4"},
{file = "platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d"},
{file = "platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6"},
{file = "platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d"},
]
[[package]]
@@ -348,13 +404,13 @@ files = [
[[package]]
name = "pygments"
version = "2.20.0"
version = "2.21.0"
requires_python = ">=3.9"
summary = "Pygments is a syntax highlighting package written in Python."
groups = ["default", "dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
{file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"},
{file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"},
]
[[package]]
@@ -410,47 +466,47 @@ files = [
[[package]]
name = "qh3"
version = "1.9.4"
version = "2.0.3"
requires_python = ">=3.7"
summary = "A lightway and fast implementation of QUIC and HTTP/3"
summary = "A lightweight and fast implementation of QUIC and HTTP/3"
groups = ["default"]
marker = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\""
files = [
{file = "qh3-1.9.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:100368855b0e103774438aaf8b38c711fb51b79f18e3b9dc873ea573648bcc3d"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9d45e544e0a556380bd2a92af180ee98d32bca6cf622f28350ebc000611bdf"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43f80d662078c26563a1aac32a4df22649473d996bf2422e073c8aa55b1c837c"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:551f60d487d849d6f2313dda94e54ebb16b1a29acb93676540ae790a56308c24"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3755d777ff35561bc719f571ebc4b91aaa8a945c210e50b2576bf2cc69cb511f"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04b64bb18e10d8f482bea1ef43e5da3448e2d5ba78bb0c735da5b9e02d7dfb0e"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b47597daa4ae9195f81a35822217a395d3277f01a393d9c2bb3796a6b458b22c"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9bb9012bc02c44da6af1b931b6082588519685fad8a1f866c341f749394f53f"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9c7f3781ec022dd958b5eb0e5cb2e2a54e207a3d8837fb7290bc239912648fd7"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:633a24ab2db76d64881876c9d0f1b226a47d892bd7d78e61ea2c788ab59570af"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:f2ff36b84a4ab9b68806af1f6985fa3b47e29695effe766a3e70c21efac8157c"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:2100697d736dbdfd1d3f0266c9fcadea6230b66ed59e3e7f8c9a9d1a6eec0c65"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:9ce179efaf97f927f01fe158619f5624d8d874c7161b7c497f06897cdde210d5"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:476ee4fcdbd3454eb0eb4dee5678294f06c08ca8e8b83160e2d0ae3c4ae34603"},
{file = "qh3-1.9.4-cp314-cp314t-win32.whl", hash = "sha256:fe9df8136bf358d45a2757287523abaeb208fcbe2c1e34664562eb2d8f64dbdb"},
{file = "qh3-1.9.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1fbdf53feb1732faf6c1cb34f2e1cbe4b543054db7b4d19d8752f1162e9370be"},
{file = "qh3-1.9.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e03ee1e2157a31c69415c3dbfbfe64e02469499ca46e968d13d8a7b289bf520a"},
{file = "qh3-1.9.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:26f7c8ed1a7ac0e25b654f030a4f472830c80890cc284dc40a31c17ecc21fbf0"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57b3dfe5dd2da2084adea63dba0bf587a4096ebf9207339187c90139d1f460d"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fa1a3debb5199da3c7f45b0ca6efa55174dfd6229370e774099073143ebe9e9"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dc9ff40d3a06f27b47d93417666f03ba6fd6b9585d9711ebbddf84fa4b7c50f"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:010b132c33719af3df4c1d6f569e52a2a20046c55a31c5532e741019c8c53868"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:124045b0c04c05afc038a239d7717019b4c4b9d0cb967040b4331f4d588eff7d"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a94a1726bf46a451a0295d68fafde28794e83ab6fda7457cbd9309c38151de9"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b361e627b6a529ecb231c1d5f3f2abd6d1952b0d7cc02c7718d9657d55dae37"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:43b6cfb2240836e3e54bbe61ad1b8fa5716516ff5359131a2421c55676a24f26"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e6db3def610e9c2d2f3602d3e9c8b2156273225f6eedf74a4749d3bc21af8167"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:22d5879dc93d2c7ee14cc5933b12c7780d9c7705dff4553becea152fcb767d99"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:2c2b2005fe6c605d61cac05ec2b3c019cc546184fa43444f3d1e3d22ed020ab2"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:e991b0f20b172fea466d0ffd499a85bf2f26decdeeda537275c72b5f7b26ccfe"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c13775ed27a88b297f6a1aa05ffb286879453f99e1e53e0af2b22e7ed8f92ebf"},
{file = "qh3-1.9.4-cp37-abi3-win32.whl", hash = "sha256:952abe1be90c0114993886792a8a5ec2211f8391df41227f5b7a5a02ff71bc26"},
{file = "qh3-1.9.4-cp37-abi3-win_amd64.whl", hash = "sha256:5543cb40a48d401f3fa4ec89398e3a213634f195716a9d06d70417c05edbbb32"},
{file = "qh3-1.9.4-cp37-abi3-win_arm64.whl", hash = "sha256:d0bd73b3e73f4f80d2430d410b532222ecf57e9d4fe8600b7683c775cbff9479"},
{file = "qh3-1.9.4.tar.gz", hash = "sha256:bd2ea9baf19656c544a48a56a195f2ac257cd973b566f5f2998fa3b7446281a1"},
{file = "qh3-2.0.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:db98ea31b7486cac5dffc76c15b34c08208b219379aa9d274df21b1d47c7dbcb"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb0a8f54275e0ecb722c0a6cebeff73771b31da0dbfaed8e4b72efcbe98e2ab"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e466a285b8f598ea06a296910a817571f132bb3ff3f5a26cdc905ffe6d9d743c"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a266a68f27a86d6d49978e1c6b8e9e5dd514e018a042dafca48cbeb50a29d0d3"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2646d05d20e7171d928d9b478097743d0d2a5b3893f8caddd7984b2825d90daa"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ccac4672539eb2897c3beaa73b626460040adf94c30edbd420db3988bd08e0f1"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0e57b96af05653ddcc4797fe761918d5d0a25ed90a9176260333f48e7b88f72"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73845b61987f44c5d55f9606a38b4078cac7e816dadeeaceb35a49ff741d4c8e"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:4f05f1b3ac250213db0da6895aeb944ca3cb63cd1a41296f1dddafd8d78a1722"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3be9e2cfc447807c4195c12d9590052ee45cc17ed92d99f44d05a49d657bf8d4"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:bb83310f908a9d00b781fe95f7acda51025d0cea3a985929e84b0d46333709a2"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:991ff127a8b0aef9ae07a4155f595158fd005d9cc522d204ec31a5a5f197517c"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:efbcd87e7c45f51690f6e526ed4956c57e428eb4c5684878987d43ffa14a93b0"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:86f2256aa78604d6733ff12a225d6780df18a9b171e72d45dd30ee62d5ab0568"},
{file = "qh3-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:91569e8ee979c84a1972916cc9b3283760dda111fc8f67cce45ccdb4f14b67c5"},
{file = "qh3-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:eb0333f67bee13a3b5f08e91ece87fd0f5e5448e955efb11d7bd2bfacf21bfb5"},
{file = "qh3-2.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:8c9f6b22d073948b3042ec0b50dab5581865a30d41517ba6aad8dbc1a4b39a6b"},
{file = "qh3-2.0.3-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e96296b00eb23ff828d7789070b3d22c7139efbc6bc62fed366c31bad622d174"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a09e33277ded5f94f9b73b0c8c2f5b3e044f3c41ed5c830ab61e72962e22d09"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8bbbc57b1cff5acebf736965c072f35023f4d0e5975b3d8cda7b7e07250af1b"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06b8ee7434cd0ecab6b916abc63a46f4ad8b3ebbc284da405aeb650882826e91"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec715af40af199c92eedbd620bbff8ddc514e19f2bbd660e65953752c364105f"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bcbdc6a574f83f957e6bf430cda8b98a512870164563b7ef1fdd553420f032a"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5c9ba43b3f68f9ade9553ff23d89455596b9c5ba8179a103f75c8771d355535"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51480d90b4ac7e81248fd020a726d4089cf8b881a4b6cbdc3c04c329831a3fc6"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:235161f4cbd791e18ea865d10fdd52d0ddbcbb807a1849c98360c4e570c06844"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7abb8ae6c8ae6b49827b26eaffeac0892893450d2022818e1db3ff34d26a835e"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:57f43869ef306877167277c48c1fa7c446fc73fc9355fa1ccae5094507b63d9e"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:192021606e283a59b47bf1e1cb719087c77869976aea76f26d589a1df8aa0dd5"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:f33ca1ac278a8d8ff115bcde76fc4555fa1dbfa147e83dad2ebcba6bc663e6d5"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:74814e3dd91011235905856701037ead68f7a8be641f469fa5e5cdabf7d03a28"},
{file = "qh3-2.0.3-cp37-abi3-win32.whl", hash = "sha256:20ceb5482ef6f352d9c7351fbde69f94f49d07f068ce9aa4e88ea76fca7eddda"},
{file = "qh3-2.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:94ba6ddb786742ff93904db09cb4b6fbcaf7de115161c05918024cd52b2c8a61"},
{file = "qh3-2.0.3-cp37-abi3-win_arm64.whl", hash = "sha256:0b13789c68983a994e8c12e3ff0fbc02f07c0aaaeec5670b57802f5d3d555e81"},
{file = "qh3-2.0.3.tar.gz", hash = "sha256:546ab2d3193e37e98dfefe5834484e07d0bf531708f21e1a4e227a51510b648f"},
]
[[package]]
@@ -497,7 +553,7 @@ files = [
[[package]]
name = "sqlalchemy"
version = "2.0.51"
version = "2.0.52"
requires_python = ">=3.7"
summary = "Database Abstraction Library"
groups = ["default"]
@@ -507,22 +563,16 @@ dependencies = [
"typing-extensions>=4.6.0",
]
files = [
{file = "sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491"},
{file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d"},
{file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54"},
{file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e"},
{file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d"},
{file = "sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8"},
{file = "sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2"},
{file = "sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5"},
{file = "sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9"},
{file = "sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9"},
{file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e"},
{file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437"},
{file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8"},
{file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd"},
{file = "sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2"},
{file = "sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d"},
{file = "sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582"},
{file = "sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89"},
{file = "sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97"},
]
[[package]]
@@ -549,28 +599,28 @@ files = [
[[package]]
name = "urllib3-future"
version = "2.24.900"
version = "2.24.907"
requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"]
dependencies = [
"h11<1.0.0,>=0.11.0",
"jh2<6.0.0,>=5.0.3",
"qh3<2.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
"qh3<3.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
]
files = [
{file = "urllib3_future-2.24.900-py3-none-any.whl", hash = "sha256:a4fe06b59b0c3ce2fffa821c3de72eaf59b1e61619becc255dee18919ee25260"},
{file = "urllib3_future-2.24.900.tar.gz", hash = "sha256:05c2e9d09293ab29a154fed8f5b60644c7cdaffe4ae2587a07b1c7f00f23a7fb"},
{file = "urllib3_future-2.24.907-py3-none-any.whl", hash = "sha256:6f9007bd9c6d3ea13d8fe70e5d4b87a6d15638a849bd2227ab5cbdfbe5d1e842"},
{file = "urllib3_future-2.24.907.tar.gz", hash = "sha256:6565b3cc160a950821953079a6ccd4daa8fd60071a13c9a251576648929f3aa7"},
]
[[package]]
name = "wassima"
version = "2.1.3"
version = "2.1.4"
requires_python = ">=3.7"
summary = "Access your OS root certificates with utmost ease"
groups = ["default"]
marker = "sys_platform != \"emscripten\""
files = [
{file = "wassima-2.1.3-py3-none-any.whl", hash = "sha256:f8251c23720dd092117a554b3012756caee3feed6d0122c2458a2aca5d7d92c5"},
{file = "wassima-2.1.3.tar.gz", hash = "sha256:fcd6c38be0f909c393da35cb2a993101fcdcff673b8fa8d5da228f73b630d0d0"},
{file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"},
{file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"},
]
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.4.1"
version = "0.6.0"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
+1 -1
View File
@@ -22,7 +22,7 @@ DEFAULT_WIN_PYTHON = "3.14"
DEFAULT_WIN_UV_VERSION = "0.11.28"
# typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete
# (e.g. kill(sig) required); pyright default stubPath is ``typings/``.
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "CLAUDE.md", "doc")
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "AGENTS.md", "doc")
VIEW_COPIES = ("pyproject.toml", "pdm.lock")
+31 -4
View File
@@ -124,9 +124,14 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
return text or None
def _setup_hooks(*, interactive: bool) -> None:
"""Install interactive limit hooks (workspace policy is set on ReplState)."""
if interactive:
def _setup_hooks(*, interactive: bool, auto_continue: bool) -> None:
"""Install limit hooks (workspace policy is set on ReplState).
``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so
``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits
without a prompt; otherwise non-interactive runs keep today's deny.
"""
if interactive or auto_continue:
install_cli_limit_hooks()
else:
from plyngent.tools.process.pty_session import PtyManager
@@ -221,6 +226,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
prompt_text: str | None,
stream: bool,
yes: bool,
auto_continue: bool,
quiet: bool,
) -> int:
oneshot = prompt_text is not None
@@ -236,7 +242,8 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
if store.recoverable_providers:
_warn_recoverable_providers(store.recoverable_providers)
_setup_hooks(interactive=interactive)
auto_effective = auto_continue or store.agent_config.auto_continue_limits
_setup_hooks(interactive=interactive, auto_continue=auto_effective)
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode
@@ -315,6 +322,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
max_rounds=max_rounds,
stream_enabled=stream,
interactive_limits=interactive,
auto_continue_limits=auto_effective,
yolo=yolo,
mcp_manager=mcp_manager,
)
@@ -333,6 +341,10 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
prompt_policy_fetch_confirm,
instance=state.instance_state,
)
from plyngent.tools.access import set_directory_access_confirm_hook
# Installed for one-shot too: the hook itself honours --yes / non-TTY.
set_directory_access_confirm_hook(state.directory_access_confirm_hook, instance=state.instance_state)
# Seed cache if we already fetched; else warm in background for Tab.
if remote_ids is not None:
state.seed_remote_models(remote_ids)
@@ -373,6 +385,13 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state)
clear_private_grants(instance=state_obj.instance_state)
clear_ssrf_assume_public_cidrs()
from plyngent.tools.access import clear_process_access, set_directory_access_confirm_hook
set_directory_access_confirm_hook(None, instance=state_obj.instance_state)
clear_process_access(instance=state_obj.instance_state)
from plyngent.cli.limits import reset_auto_continue
reset_auto_continue()
if state_obj.mcp_manager is not None:
await state_obj.mcp_manager.aclose()
await state_obj.instance_state.shutdown()
@@ -469,6 +488,12 @@ def main(ctx: click.Context, log_level: str) -> None:
default=False,
help="Enable YOLO: skip destructive-tool confirms (sticky for this process).",
)
@click.option(
"--auto-continue",
is_flag=True,
default=False,
help="Auto-raise tool/PTY limits without prompting (like answering yyy).",
)
@click.option(
"--quiet",
is_flag=True,
@@ -487,6 +512,7 @@ def chat_cmd(
prompt: str | None,
stream: bool, # noqa: FBT001
yes: bool, # noqa: FBT001
auto_continue: bool, # noqa: FBT001
quiet: bool, # noqa: FBT001
) -> None:
"""Interactive chat REPL, or one-shot with ``-p`` / stdin.
@@ -527,6 +553,7 @@ def chat_cmd(
prompt_text=prompt_text,
stream=stream,
yes=yes,
auto_continue=auto_continue,
quiet=quiet,
)
)
+124 -8
View File
@@ -4,6 +4,7 @@ import contextlib
import selectors
import shutil
import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
@@ -18,16 +19,56 @@ from plyngent.prompting import (
confirm,
get_prompt_backend,
)
from plyngent.tools.access import AccessDecision
from plyngent.tools.process.pty_session import PtyManager
from plyngent.tools.workspace import AccessMode, parse_access_mode
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from pathlib import Path
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
_BOX_MIN_WIDTH = 40
_BOX_MAX_WIDTH = 100
_BOX_PAD = 2 # spaces inside left/right borders
# Limit prompts accept ``yyy`` to stop asking for the rest of the turn.
_CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this turn): "
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
_CONTINUE_AUTO_TOKEN = "yyy"
@dataclass
class _AutoContinue:
"""Auto-continue layers: process default (flag/config) + ``yyy`` turn skip."""
default: bool = False
turn: bool = False
_auto_continue = _AutoContinue()
def auto_continue_enabled() -> bool:
"""Whether limit prompts are auto-approved (process default or ``yyy``)."""
return _auto_continue.default or _auto_continue.turn
def set_auto_continue_default(*, enabled: bool) -> None:
"""Set the process-wide default (``--auto-continue`` / ``[agent]`` config)."""
_auto_continue.default = bool(enabled)
def reset_auto_continue_turn() -> None:
"""Clear the ``yyy`` skip at a turn boundary."""
_auto_continue.turn = False
def reset_auto_continue() -> None:
"""Clear both layers (chat exit / tests)."""
_auto_continue.default = False
_auto_continue.turn = False
def _terminal_width() -> int:
try:
@@ -104,10 +145,33 @@ def _echo_continue_limit(reason: str) -> None:
backend.echo()
def _echo_auto_continue(reason: str) -> None:
"""Echo the limit reason plus the ``yyy`` notice (no prompt)."""
backend = get_prompt_backend()
backend.secho(f"[limit] {reason}", fg="yellow")
backend.secho("[limit] auto-continuing (yyy) — no more limit prompts this turn", fg="bright_black")
backend.echo()
def _decide_continue_limit(reason: str) -> bool:
"""Shared limit decision for the sync/async prompts (may read a line)."""
if auto_continue_enabled():
_echo_auto_continue(reason)
return True
backend = get_prompt_backend()
if not backend.is_interactive():
return False
_echo_continue_limit(reason)
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
if token == _CONTINUE_AUTO_TOKEN:
_auto_continue.turn = True
return True
return token in _CONTINUE_YES_TOKENS
def _prompt_continue_limit_sync(reason: str) -> bool:
try:
_echo_continue_limit(reason)
return confirm("Raise limit and continue?", default=True)
return _decide_continue_limit(reason)
except NonInteractiveError:
return False
@@ -119,15 +183,11 @@ def prompt_continue_limit(reason: str) -> bool:
async def prompt_continue_limit_async(reason: str) -> bool:
"""Async variant: confirm off the event loop so the turn is not cancelled."""
"""Async variant: prompt off the event loop so the turn is not cancelled."""
try:
from plyngent.prompting import run_prompt_async
def _run() -> bool:
_echo_continue_limit(reason)
return confirm("Raise limit and continue?", default=True)
return await run_prompt_async(_run)
return await run_prompt_async(lambda: _decide_continue_limit(reason))
except NonInteractiveError:
return False
@@ -287,6 +347,62 @@ def prompt_policy_fetch_confirm(
return False
def prompt_directory_access_confirm(
path: Path,
mode: AccessMode,
reason: str,
timeout_seconds: float,
) -> AccessDecision | str | None:
"""Ask whether to grant access to *path* at *mode* (timed; default deny).
``y`` approves the requested level; ``read`` / ``write`` / ``exec`` approve
that level instead. The host's hook decides YOLO/``--yes`` before calling
this. Timeout, cancel, or non-interactive → ``None`` (deny).
"""
lines = [
f"request: {mode.name.lower()} access to {path}",
f"kind: {'directory' if path.is_dir() else 'file'}",
]
if reason:
lines.append(f"why: {reason}")
lines.append(f"(timeout {timeout_seconds:g}s defaults to DENY)")
try:
with pause_task_cancel_for_prompt():
backend = get_prompt_backend()
if not backend.is_interactive():
return None
backend.echo()
backend.secho(
format_tool_confirm_box("request_directory_access", "\n".join(lines)),
fg="yellow",
)
backend.echo()
prompt = (
f"[access] allow {path} ({mode.name.lower()})? [y/N|read|write|exec] (timeout {timeout_seconds:g}s): "
)
with contextlib.suppress(OSError):
_ = sys.stderr.write(prompt)
_ = sys.stderr.flush()
raw = _read_yes_no_line_with_timeout(timeout_seconds)
if raw is None:
with contextlib.suppress(OSError, NonInteractiveError):
backend.secho(
f"[access] timed out after {timeout_seconds:g}s — denied",
fg="red",
err=True,
)
return None
token = raw.strip().lower()
if token in {"y", "yes"}:
return AccessDecision(mode)
level = parse_access_mode(token)
if level is not None:
return AccessDecision(level)
return None
except NonInteractiveError, KeyboardInterrupt, EOFError:
return None
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""Async confirm: True allow, False deny, str = deny with user comment."""
del args
+4 -1
View File
@@ -36,14 +36,17 @@ def _echo_interrupted() -> None:
async def run_repl(state: ReplState) -> None:
"""Interactive chat loop with readline editing, history, and Tab completion."""
setup_readline(state)
from plyngent.cli.limits import auto_continue_enabled
yolo = state.effective_yolo()
yolo_part = f" yolo={yolo}" if yolo != "off" else ""
auto_part = " limit_auto=on" if auto_continue_enabled() else ""
click.echo(
f"plyngent chat provider={state.provider_name} model={state.model} "
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
f"stream={'on' if state.agent.stream else 'off'} "
f"verbose={'on' if state.verbose else 'off'}{yolo_part}"
f"verbose={'on' if state.verbose else 'off'}{yolo_part}{auto_part}"
)
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
+17
View File
@@ -10,6 +10,7 @@ import click
from plyngent.cli.display import render_events
from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall
from plyngent.cli.limits import reset_auto_continue_turn
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Coroutine
@@ -184,7 +185,23 @@ async def run_turn_with_retries(
further attempts use ``agent.retry`` so the user message is not duplicated.
Ctrl+C cancels the in-flight task; user message stays in DB for ``/retry``.
The ``yyy`` limit auto-continue is turn-scoped: cleared before and after the
turn, so a retry of the same turn keeps it but the next user turn prompts.
"""
reset_auto_continue_turn()
try:
return await _run_turn_with_retries(agent, starter=starter, delays=delays)
finally:
reset_auto_continue_turn()
async def _run_turn_with_retries(
agent: ChatAgent,
*,
starter: Callable[[], AsyncIterator[AgentEvent]],
delays: tuple[float, ...],
) -> bool:
max_retries = len(delays)
attempt = 0
current: Callable[[], AsyncIterator[AgentEvent]] = starter
+83
View File
@@ -521,6 +521,7 @@ def config_cmd(state: ReplState) -> None:
def status_cmd(state: ReplState) -> None:
"""Show session/provider/tools/rounds status."""
from plyngent.agent.budget import estimate_messages_chars
from plyngent.tools.access import access_grant_rows
pending = state.agent.pending_retry_text
pending_disp = "yes" if pending else "no"
@@ -555,6 +556,7 @@ def status_cmd(state: ReplState) -> None:
f"usage_last_turn={last_u.format_line(billed=True)} "
f"rounds={last_rounds}\n"
f"usage_session={session_u.format_line(billed=True)}\n"
f"grants={len(access_grant_rows(instance=state.instance_state, session=state.session_state))} "
f"workspace={state.workspace}"
)
@@ -1016,11 +1018,17 @@ def btw_cmd(
else:
await run_cancellable(side)
from plyngent.cli.limits import reset_auto_continue_turn
# Aside is its own exchange: a ``yyy`` from the main turn must not leak in.
reset_auto_continue_turn()
try:
_await(_run())
except Exception as exc: # noqa: BLE001 — surface side-turn failures
click.secho(f"error: btw failed: {exc}", fg="red")
return
finally:
reset_auto_continue_turn()
click.secho("(btw done — main session unchanged)", fg="bright_black")
@@ -1147,6 +1155,9 @@ def plugins_slash_cmd(state: ReplState, action: str | None, name: str | None) ->
raise click.UsageError(str(exc)) from exc
_MCP_PREVIEW_MAX_CHARS = 120
def _print_mcp_status(state: ReplState) -> None:
manager = state.mcp_manager
if manager is None:
@@ -1162,6 +1173,14 @@ def _print_mcp_status(state: ReplState) -> None:
(conn for conn in manager.connections() if conn.name == name),
None,
)
if connection is not None and connection.instructions:
preview = " ".join(connection.instructions.split())
if len(preview) > _MCP_PREVIEW_MAX_CHARS:
preview = f"{preview[: _MCP_PREVIEW_MAX_CHARS - 3]}..."
click.secho(
f" instructions ({len(connection.instructions)} chars): {preview}",
fg="bright_black",
)
if connection is not None and connection.error is not None:
for line in connection.stderr_tail()[-3:]:
click.secho(f" {line}", fg="bright_black")
@@ -1194,6 +1213,70 @@ def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
_print_mcp_status(state)
@slash.command("grants")
@click.argument("action", required=False, type=click.Choice(["list", "revoke"]))
@click.argument("target", required=False)
@click.pass_obj
def grants_cmd(state: ReplState, action: str | None, target: str | None) -> None:
"""List or revoke directory-access grants.
``/grants`` or ``/grants list`` — session, process, then config grants.
``/grants revoke <index|all>`` — drop one grant or all session/process
grants. Config grants come from ``[agent].allow_paths``; edit the TOML.
"""
from plyngent.tools.access import (
access_grant_rows,
clear_process_access,
clear_session_access,
revoke_access,
)
act = (action or "list").lower()
if act == "list":
rows = access_grant_rows(instance=state.instance_state, session=state.session_state)
if not rows:
click.echo("no directory-access grants")
return
click.echo(f"directory-access grants ({len(rows)}):")
for index, (path, mode, source) in enumerate(rows):
click.echo(f" {index}\t{path}\t{mode.name.lower()}\t{source}")
click.echo("revoke with /grants revoke <index> or /grants revoke all")
return
if target is None or not target.strip():
msg = "/grants revoke requires an index or 'all'"
raise click.UsageError(msg)
token = target.strip().lower()
if token == "all":
clear_session_access(session=state.session_state)
clear_process_access(instance=state.instance_state)
_await(state.persist_access_grants())
click.echo("revoked all session/process access grants (config grants unchanged)")
return
if not token.isdigit():
msg = "/grants revoke expects an index or 'all'"
raise click.UsageError(msg)
rows = access_grant_rows(instance=state.instance_state, session=state.session_state)
index = int(token)
if index < 0 or index >= len(rows):
msg = f"no grant at index {index}"
raise click.UsageError(msg)
path, _mode, source = rows[index]
if source == "config":
click.echo(f"config grant {path} is not revocable here; edit [agent].allow_paths")
return
if revoke_access(
path,
source=source,
instance=state.instance_state,
session=state.session_state,
):
_await(state.persist_access_grants())
click.echo(f"revoked {path} ({source})")
else:
click.echo(f"grant not found: {path}")
@slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj
+118 -4
View File
@@ -26,17 +26,31 @@ from plyngent.tools import InstanceState, SessionState
from plyngent.tools.view import MemoryViewStore, session_data_view
if TYPE_CHECKING:
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
from plyngent.memory.database.schema import Session as SessionRow
from plyngent.runtime.mcp_client import McpManager
from plyngent.tools.access import AccessDecision
from plyngent.tools.workspace import AccessMode
type YoloMode = Literal["off", "on", "once"]
def _parsed_access_grants(raw: Mapping[str, str]) -> dict[Path, AccessMode]:
"""Parse stored ``{path: mode}`` grants into resolved keys (skip bad modes)."""
from plyngent.tools.workspace import parse_access_mode
grants: dict[Path, AccessMode] = {}
for path_str, mode_str in raw.items():
mode = parse_access_mode(mode_str)
if mode is not None:
grants[Path(path_str).expanduser().resolve()] = mode
return grants
@dataclass
class ReplState:
"""Mutable REPL session state."""
@@ -55,6 +69,8 @@ class ReplState:
markdown_enabled: bool = True
# One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True
# Auto-raise tool/PTY limits without prompting (--auto-continue / config).
auto_continue_limits: bool = False
# Soft destructive-tool confirms: None → derive from config.confirm_destructive.
# off = confirm; on = skip (sticky); once = skip next user turn then off.
yolo: YoloMode | None = None
@@ -82,6 +98,10 @@ class ReplState:
self.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace
self.instance_state.workspace.root = self.workspace
self._install_config_access()
from plyngent.cli.limits import set_auto_continue_default
set_auto_continue_default(enabled=self.auto_continue_limits)
self.session_state = self._session_data_for_todo()
self.agent = self._make_agent()
self.sync_display_flags()
@@ -93,6 +113,12 @@ class ReplState:
set_verbose_tool_results(self.verbose)
set_markdown_enabled(self.markdown_enabled)
def _reset_limit_auto_continue(self) -> None:
"""Drop a ``yyy`` skip on session change (it is turn-scoped)."""
from plyngent.cli.limits import reset_auto_continue_turn
reset_auto_continue_turn()
def _workspace_key(self) -> str:
key = normalize_workspace(self.workspace)
if key is None:
@@ -148,11 +174,17 @@ class ReplState:
self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard)
def _session_data_for_todo(self, *, grants: dict[str, bool] | None = None) -> SessionState:
def _session_data_for_todo(
self,
*,
grants: dict[str, bool] | None = None,
access_grants: dict[Path, AccessMode] | None = None,
) -> SessionState:
"""Build a SessionState with live todo + MemoryViewStore seed (durable tree).
*grants* defaults to empty (new session / startup). Pass the previous map
only when intentionally preserving soft-confirm trust across rebinds.
*access_grants* seeds session directory grants (empty clears them).
"""
grant_map = dict(grants or {})
store = MemoryViewStore({"todo": self.todo_stack.to_raw(), "grants": dict(grant_map)})
@@ -161,7 +193,9 @@ class ReplState:
data=session_data_view(store=store),
todo=self.todo_stack,
on_todo_change=self._todo_on_change,
on_access_change=self._on_access_change,
grants=grant_map,
access_grants=dict(access_grants or {}),
)
def _bind_todo_tools(self) -> None:
@@ -203,6 +237,67 @@ class ReplState:
if hasattr(self, "agent"):
self.agent.todo_stack = self.todo_stack
def _on_access_change(self) -> None:
"""Schedule memory persist for live directory-access grants."""
import asyncio
if self.session_id is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(self.persist_access_grants())
self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard)
async def persist_access_grants(self) -> None:
"""Write live session directory grants to the active session row."""
if self.session_id is None:
return
payload = {str(path): mode.name.lower() for path, mode in self.session_state.access_grants.items()}
_ = await self.memory.update_session_access_grants(self.session_id, payload)
async def load_access_grants(self) -> None:
"""Load durable session directory grants into the live map (replace)."""
self.session_state.access_grants.clear()
if self.session_id is None:
return
raw = await self.memory.get_session_access_grants(self.session_id)
self.session_state.access_grants.update(_parsed_access_grants(raw))
def directory_access_confirm_hook(
self,
path: Path,
mode: AccessMode,
reason: str,
timeout_seconds: float,
) -> AccessDecision | str | None:
"""Directory-access confirm: YOLO/``--yes`` auto-approves, else ask."""
from plyngent.cli.limits import prompt_directory_access_confirm
from plyngent.tools.access import AccessDecision as Decision
if self.effective_yolo() != "off":
return Decision(mode, persist=False)
return prompt_directory_access_confirm(path, mode, reason, timeout_seconds)
def _install_config_access(self) -> None:
"""Install ``[agent].allow_paths`` static grants on the policy bag."""
import click
from plyngent.tools.access import set_config_access
_applied, skipped = set_config_access(
self.config.agent_config.allow_paths,
instance=self.instance_state,
)
if skipped:
click.secho(
f"warning: ignored allow_paths entries (missing path or bad mode): {', '.join(skipped)}",
fg="yellow",
err=True,
)
def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled:
return None
@@ -237,16 +332,30 @@ class ReplState:
session_state=self.session_state,
)
def _mcp_instructions_text(self) -> str:
"""Compose connected MCP servers' initialize ``instructions`` into one block.
Only meaningful when tools are on (MCP tools are registered under the
same condition); the text is attributed per server so the model can
weigh its provenance. Gated by ``[agent] mcp_instructions`` in callers.
"""
if self.mcp_manager is None or not self.tools_enabled:
return ""
parts = [f"MCP server {name!r} instructions:\n{text}" for name, text in self.mcp_manager.instructions()]
return "\n\n".join(parts)
def _make_agent(self) -> ChatAgent:
from plyngent.cli.limits import prompt_continue_limit_async
from plyngent.cli.limits import auto_continue_enabled, prompt_continue_limit_async
from plyngent.config import compose_agent_system_content
agent_cfg = self.config.agent_config
mcp_text = self._mcp_instructions_text() if agent_cfg.mcp_instructions else ""
system_prompt = compose_agent_system_content(
agent_cfg.system_prompt,
agent_cfg.tool_directives,
mcp_text,
)
on_limit = prompt_continue_limit_async if self.interactive_limits else None
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
peak = 0
band = 0
last_req = None
@@ -560,6 +669,7 @@ class ReplState:
return False
async def new_session(self, name: str = "chat") -> None:
self._reset_limit_auto_continue()
session = await self.memory.create_session(
name=name,
workspace=self.workspace,
@@ -598,6 +708,7 @@ class ReplState:
"""Load a session; on workspace mismatch, prompt keep / rebind / abort."""
from plyngent.cli.limits import prompt_workspace_mismatch
self._reset_limit_auto_continue()
row = await self.memory.get_session(session_id)
if row is None:
msg = f"session not found: {session_id}"
@@ -631,9 +742,11 @@ class ReplState:
self.agent = self._make_agent()
await self.agent.load_history()
await self.load_todo_stack()
await self.load_access_grants()
async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume most recently updated session for this workspace, or create one."""
self._reset_limit_auto_continue()
latest = await self.memory.get_latest_session(workspace=self.workspace)
if latest is None:
await self.new_session(name=name)
@@ -646,6 +759,7 @@ class ReplState:
self.agent = self._make_agent()
await self.agent.load_history()
await self.load_todo_stack()
await self.load_access_grants()
_ = await self.memory.touch_session(latest.sid)
return "resume"
+21 -11
View File
@@ -10,6 +10,9 @@ from msgspec import Struct, field
# ``https://api.deepseek.com/anthropic``)
type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
type AllowPathMode = Literal["read", "write", "exec"]
# Built-in persona when ``[agent].system_prompt`` is omitted.
# Set ``system_prompt = ""`` to omit the persona block only.
# Override with a multi-line TOML literal (prefer ''' so nested " is fine).
@@ -23,6 +26,8 @@ DEFAULT_TOOL_DIRECTIVES = """\
### Workspace
- Explore with `tree`/`listdir`/`glob_paths`/`grep_files`/`read_file` when unsure.
- Stay under the workspace (or a path from `new_temporary_workspace`). Respect path denylists.
- Need a path outside the workspace? Call `request_directory_access` \
(human approves; denylists apply) instead of retrying an escaped path.
### Files
- Prefer file tools over shell. `edit_replace` fails usually means a bad match — \
@@ -70,19 +75,15 @@ call `todo_clear` (or pop finished TOP groups) so no hygiene noise is left behin
"""
def compose_agent_system_content(
system_prompt: str,
tool_directives: str,
) -> str | None:
"""Join persona + tool playbook into one system body, or ``None`` if both empty.
def compose_agent_system_content(*parts: str) -> str | None:
"""Join persona + tool playbook + extra blocks into one system body.
Non-empty parts are stripped and joined with a blank line. Either field may
be ``""`` to disable that part while keeping the other.
Returns ``None`` when every part is empty. Non-empty parts are stripped and
joined with a blank line. Any part may be ``""`` to disable that block
(e.g. ``system_prompt = ""`` keeps the tool playbook).
"""
parts = [part.strip() for part in (system_prompt, tool_directives) if part and part.strip()]
if not parts:
return None
return "\n\n".join(parts)
joined = "\n\n".join(part.strip() for part in parts if part and part.strip())
return joined or None
class DatabaseConfig(Struct, omit_defaults=True):
@@ -106,10 +107,19 @@ class AgentConfig(Struct, omit_defaults=True):
system_prompt: str = DEFAULT_SYSTEM_PROMPT
# Tool how-to (omit → DEFAULT_TOOL_DIRECTIVES; "" disables playbook only).
tool_directives: str = DEFAULT_TOOL_DIRECTIVES
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
# into the agent system prompt when tools are on. false = ignore server text.
mcp_instructions: bool = True
max_tool_result_chars: int = 32_000
parallel_tools: bool = True
confirm_destructive: bool = True
path_denylist: list[str] = field(default_factory=list)
# Static directory pre-allow (resolved path → mode); never prompts.
# Denylist still applies, and the path must exist when chat starts.
allow_paths: dict[str, AllowPathMode] = field(default_factory=dict)
# Auto-raise tool/PTY limits without prompting (same as answering ``yyy``);
# process-wide, so every turn skips the limit confirm.
auto_continue_limits: bool = False
max_context_tokens: int = 200_000
# How to inject todo stack nags into model context (see agent/todo_nag.py).
+2
View File
@@ -34,6 +34,8 @@ class Session(PlyngentBase):
model: Mapped[str | None] = mapped_column(String(256), nullable=True)
# Todo/task stack JSON for multi-step sub-tasks (optional).
todo_stack: Mapped[dict[str, object] | None] = mapped_column(JSON(), nullable=True)
# Directory-access grants approved for this session (resolved path → mode).
access_grants: Mapped[dict[str, str] | None] = mapped_column(JSON(), nullable=True)
# Last model request context size (API prompt_tokens preferred).
last_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True)
peak_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True)
+39 -2
View File
@@ -241,6 +241,31 @@ class MemoryStore:
await session.refresh(row)
return row
async def get_session_access_grants(self, sid: int) -> dict[str, str]:
"""Return stored directory-access grants (resolved path → mode)."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
raw = row.access_grants
if raw is None:
return {}
return {str(k): str(v) for k, v in cast("dict[object, object]", raw).items()}
async def update_session_access_grants(self, sid: int, grants: Mapping[str, str]) -> Session:
"""Persist directory-access grants for a session (empty clears)."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
row.access_grants = dict(grants)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return row
async def update_session_context_usage(
self,
sid: int,
@@ -400,18 +425,30 @@ def _migrate_session_v3(sync_conn: object, columns: set[str]) -> None:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN reminder_last_band INTEGER"))
def _migrate_session_v4(sync_conn: object, columns: set[str]) -> None:
"""Post-v0.5.0: add ``access_grants`` JSON for session directory-access grants."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
if "access_grants" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN access_grants JSON"))
# Latest schema version this build knows. Bump (and append a step to
# _SCHEMA_MIGRATIONS) when a new release adds or changes columns.
_SCHEMA_VERSION = 3
_SCHEMA_VERSION = 4
# Ordered (version, step) pairs. Each step receives the current ``session``
# column set (read once) and adds only its own missing columns, so it is
# idempotent for DBs that were upgraded incrementally without a version stamp.
# Version 1 shipped in v0.1.0, version 2 in v0.1.2, version 3 post-v0.2.0.
# Version 1 shipped in v0.1.0, version 2 in v0.1.2, version 3 post-v0.2.0,
# version 4 post-v0.5.0.
_SCHEMA_MIGRATIONS: tuple[tuple[int, MigrationStep], ...] = (
(1, _migrate_session_v1),
(2, _migrate_session_v2),
(3, _migrate_session_v3),
(4, _migrate_session_v4),
)
+23 -1
View File
@@ -92,6 +92,8 @@ class McpServerConnection:
error: str | None
tools: list[McpTool]
tools_stale: bool
# Server usage guidance from the initialize result (InitializeResult.instructions).
instructions: str | None
_config: McpServerConfig
_proc: asyncio.subprocess.Process | None
@@ -111,6 +113,7 @@ class McpServerConnection:
self.error = None
self.tools = []
self.tools_stale = False
self.instructions = None
@property
def status(self) -> str:
@@ -258,7 +261,7 @@ class McpServerConnection:
self._send(payload)
async def _initialize(self) -> None:
_ = await self._request(
result = await self._request(
"initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
@@ -266,6 +269,10 @@ class McpServerConnection:
"clientInfo": {"name": MCP_CLIENT_NAME, "version": _client_version()},
},
)
# Optional server usage guidance (MCP spec: InitializeResult.instructions).
# Hosts surface this to the model when the server's tools are in play.
instructions = result.get("instructions")
self.instructions = instructions.strip() if isinstance(instructions, str) and instructions.strip() else None
await self._notify("notifications/initialized")
async def list_tools(self) -> list[McpTool]:
@@ -343,6 +350,21 @@ class McpManager:
rows.append((name, connection.status, len(connection.tools)))
return rows
def instructions(self) -> list[tuple[str, str]]:
"""(server, text) initialize ``instructions`` for every connected server.
Only non-empty guidance is returned, in config order. Hosts may fold
these into the agent system context (see cli.state.ReplState).
"""
rows: list[tuple[str, str]] = []
for name in self.enabled_servers():
connection = self._connections.get(name)
if connection is not None and connection.connected:
text = (connection.instructions or "").strip()
if text:
rows.append((name, text))
return rows
def connections(self) -> list[McpServerConnection]:
return list(self._connections.values())
+20
View File
@@ -1,3 +1,17 @@
from .access import ACCESS_TOOLS as ACCESS_TOOLS
from .access import MAX_ACCESS_GRANTS as MAX_ACCESS_GRANTS
from .access import AccessDecision as AccessDecision
from .access import access_grant_rows as access_grant_rows
from .access import active_grant_count as active_grant_count
from .access import clear_process_access as clear_process_access
from .access import clear_session_access as clear_session_access
from .access import get_directory_access_confirm_hook as get_directory_access_confirm_hook
from .access import grant_process_access as grant_process_access
from .access import grant_session_access as grant_session_access
from .access import request_directory_access as request_directory_access
from .access import revoke_access as revoke_access
from .access import set_config_access as set_config_access
from .access import set_directory_access_confirm_hook as set_directory_access_confirm_hook
from .catalog import ToolCatalog as ToolCatalog
from .catalog import ToolSource as ToolSource
from .catalog import catalog_scope as catalog_scope
@@ -66,6 +80,7 @@ from .workspace import (
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
)
from .workspace import DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS as DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
from .workspace import AccessMode as AccessMode
from .workspace import WorkspaceError as WorkspaceError
from .workspace import WorkspacePolicy as WorkspacePolicy
from .workspace import active_workspace_policy as active_workspace_policy
@@ -74,12 +89,16 @@ from .workspace import check_command_allowed as check_command_allowed
from .workspace import clear_policy_allowed_commands as clear_policy_allowed_commands
from .workspace import clear_workspace_allowlist as clear_workspace_allowlist
from .workspace import clear_workspace_root as clear_workspace_root
from .workspace import denylist_match as denylist_match
from .workspace import get_command_denylist as get_command_denylist
from .workspace import get_path_denylist as get_path_denylist
from .workspace import get_policy_confirm_timeout as get_policy_confirm_timeout
from .workspace import get_workspace_root as get_workspace_root
from .workspace import grant_policy_command as grant_policy_command
from .workspace import granted_access_mode as granted_access_mode
from .workspace import list_workspace_allowlist as list_workspace_allowlist
from .workspace import mode_covers as mode_covers
from .workspace import parse_access_mode as parse_access_mode
from .workspace import pop_owned_temporary_workspaces as pop_owned_temporary_workspaces
from .workspace import remove_workspace_allowlist as remove_workspace_allowlist
from .workspace import resolve_path as resolve_path
@@ -88,3 +107,4 @@ from .workspace import set_path_denylist as set_path_denylist
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
from .workspace import set_workspace_root as set_workspace_root
from .workspace import within_workspace_roots as within_workspace_roots
+286
View File
@@ -0,0 +1,286 @@
"""Directory-access grants: request tool plus config / session / process stores.
A grant is one ``path → mode`` pair; its *source* is structural:
- :attr:`WorkspacePolicy.config_allow` — static TOML pre-allow (never prompts),
- :attr:`SessionState.access_grants` — human-approved for this session,
- :attr:`WorkspacePolicy.yolo_allow` — ``--yes`` / YOLO auto-approval, process only.
``resolve_path`` consults all three. Grants are a policy gate for path-resolving
tools, **not** a sandbox: an ``exec`` grant (or any command) can reach the rest
of the filesystem.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple
from plyngent.agent import ToolTag, tool
from plyngent.tools.context import get_session, require_session
from plyngent.tools.workspace import (
AccessMode,
denylist_match,
get_policy_confirm_timeout,
granted_access_mode,
mode_covers,
parse_access_mode,
require_bound_instance,
within_workspace_roots,
)
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from plyngent.tools.context import InstanceState, SessionState
# Max active grants across all three stores (bounds prompt/listing noise).
MAX_ACCESS_GRANTS = 32
_HOOK_KEY = "directory_access_confirm_hook"
class AccessDecision(NamedTuple):
"""Hook approval for one directory-access request.
``mode`` is the granted level (may differ from the requested one);
``persist`` is true for a session grant, false for a process-only grant.
"""
mode: AccessMode
persist: bool = True
type DirectoryAccessConfirmHook = Callable[[Path, AccessMode, str, float], AccessDecision | str | None]
def get_directory_access_confirm_hook(instance: InstanceState | None = None) -> DirectoryAccessConfirmHook | None:
"""Return the host-installed confirm hook for this instance, if any."""
inst = instance if instance is not None else require_bound_instance()
hook = inst.extras.get(_HOOK_KEY)
if hook is None or not callable(hook):
return None
def _as_hook(path: Path, mode: AccessMode, reason: str, timeout: float) -> AccessDecision | str | None:
result: object = hook(path, mode, reason, timeout)
if isinstance(result, AccessDecision | str):
return result
return None
return _as_hook
def set_directory_access_confirm_hook(
hook: DirectoryAccessConfirmHook | None,
*,
instance: InstanceState | None = None,
) -> None:
"""Install (or clear) the confirm hook the CLI uses to ask the human."""
inst = instance if instance is not None else require_bound_instance()
inst.extras[_HOOK_KEY] = hook
def _notify_access_change(session: SessionState | None) -> None:
"""Fire the host's persist hook after the live session map changes."""
if session is not None and session.on_access_change is not None:
session.on_access_change()
def grant_session_access(
path: Path | str,
mode: AccessMode,
*,
session: SessionState | None = None,
) -> Path:
"""Grant *mode* on *path* for the bound session; returns the resolved path."""
resolved = Path(path).expanduser().resolve()
sess = session if session is not None else require_session()
sess.access_grants[resolved] = mode
_notify_access_change(sess)
return resolved
def grant_process_access(path: Path | str, mode: AccessMode) -> Path:
"""Grant *mode* on *path* for this process only (``--yes``); never persisted."""
resolved = Path(path).expanduser().resolve()
require_bound_instance().workspace.yolo_allow[resolved] = mode
return resolved
def clear_process_access(*, instance: InstanceState | None = None) -> None:
"""Drop process-scoped (``--yes``) grants."""
inst = instance if instance is not None else require_bound_instance()
inst.workspace.yolo_allow.clear()
def clear_session_access(*, session: SessionState | None = None) -> None:
"""Drop live session grants (durable copy is cleared by the host)."""
sess = session if session is not None else get_session()
if sess is not None:
sess.access_grants.clear()
_notify_access_change(sess)
def active_grant_count() -> int:
"""Total live grants across config, session, and process stores."""
policy = require_bound_instance().workspace
total = len(policy.config_allow) + len(policy.yolo_allow)
session = get_session()
if session is not None:
total += len(session.access_grants)
return total
def set_config_access(
entries: Mapping[str, str],
*,
instance: InstanceState | None = None,
) -> tuple[list[Path], list[str]]:
"""Install static TOML pre-allow entries on the instance policy bag.
Replaces any previous config grants. Returns ``(applied roots, skipped
keys)``; entries are skipped when the mode is unknown or the path does not
exist (the host warns about skipped keys).
"""
inst = instance if instance is not None else require_bound_instance()
policy = inst.workspace
policy.config_allow.clear()
applied: list[Path] = []
skipped: list[str] = []
for raw_path, raw_mode in entries.items():
mode = parse_access_mode(raw_mode)
resolved: Path | None
try:
resolved = Path(raw_path).expanduser().resolve()
except OSError:
resolved = None
if mode is None or resolved is None or not resolved.exists():
skipped.append(raw_path)
continue
policy.config_allow[resolved] = mode
applied.append(resolved)
return applied, skipped
def access_grant_rows(
*,
instance: InstanceState | None = None,
session: SessionState | None = None,
) -> list[tuple[Path, AccessMode, str]]:
"""``(path, mode, source)`` rows: session, then process, then config."""
inst = instance if instance is not None else require_bound_instance()
sess = session if session is not None else get_session()
rows: list[tuple[Path, AccessMode, str]] = []
if sess is not None:
rows.extend((path, mode, "session") for path, mode in sess.access_grants.items())
rows.extend((path, mode, "process") for path, mode in inst.workspace.yolo_allow.items())
rows.extend((path, mode, "config") for path, mode in inst.workspace.config_allow.items())
return rows
def revoke_access(
path: Path | str,
*,
source: str,
instance: InstanceState | None = None,
session: SessionState | None = None,
) -> bool:
"""Remove the exact resolved *path* from one store; false if not present.
``config`` grants are owned by ``[agent].allow_paths`` and are not removed
here (the caller should point the human at the config file).
"""
resolved = Path(path).expanduser().resolve()
if source == "session":
sess = session if session is not None else get_session()
if sess is None or sess.access_grants.pop(resolved, None) is None:
return False
_notify_access_change(sess)
return True
if source == "process":
inst = instance if instance is not None else require_bound_instance()
return inst.workspace.yolo_allow.pop(resolved, None) is not None
return False
def _resolve_target(path: str) -> Path | str:
"""Resolve a requested path; return a model-facing error string on failure."""
try:
target = Path(path).expanduser().resolve()
except OSError as exc:
return f"error: cannot resolve path {path!r}: {exc}"
if not target.exists():
return f"error: path does not exist: {target}"
return target
def _precheck_target(target: Path, requested: AccessMode) -> str | None:
"""Return a short-circuit message when no prompt is needed, else ``None``."""
if requested is AccessMode.EXEC and not target.is_dir():
return f"error: exec access requires a directory: {target}"
matched = denylist_match(target, directory=target.is_dir())
if matched is not None:
return f"error: path denied by policy (matched {matched!r}); it can never be granted: {target}"
if within_workspace_roots(target):
return f"already accessible: {target} is inside the workspace"
granted = granted_access_mode(target)
if granted is not None and mode_covers(granted, requested):
return f"already granted: {target} ({granted.name.lower()})"
if active_grant_count() >= MAX_ACCESS_GRANTS:
return f"error: too many active access grants (max {MAX_ACCESS_GRANTS}); revoke some first"
return None
def _apply_decision(target: Path, decision: AccessDecision | str | None, timeout: float) -> str:
"""Record an approved grant or format the denial for the model."""
if isinstance(decision, AccessDecision):
if decision.persist and get_session() is not None:
_ = grant_session_access(target, decision.mode)
scope = "session"
else:
_ = grant_process_access(target, decision.mode)
scope = "process"
return (
f"access granted: {target} ({decision.mode.name.lower()}, {scope})\n"
"note: the path denylist still applies; the human can revoke with /grants"
)
if isinstance(decision, str) and decision.strip():
return f"error: access to {target} denied by user; user comment: {decision.strip()}"
return f"error: access to {target} denied (declined or timed out after {timeout:g}s)"
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.SESSION_STATE)
async def request_directory_access(path: str, reason: str = "", mode: str = "read") -> str:
"""Request human approval to use a directory (or exact file) outside the workspace.
Call this instead of retrying a path that failed with ``path escapes
workspace root``. ``mode`` is ``read`` (inspect files), ``write`` (also
modify files) or ``exec`` (also use it as ``run_argv`` / PTY cwd); the human
may approve a different level. Approval lasts for the session (or for this
process when ``--yes`` auto-approves). Paths matching the configured
denylist can never be granted.
"""
requested = parse_access_mode(mode)
if requested is None:
return f"error: invalid mode {mode!r} (use read, write, or exec)"
resolved = _resolve_target(path)
if isinstance(resolved, str):
return resolved
target = resolved
precheck = _precheck_target(target, requested)
if precheck is not None:
return precheck
hook = get_directory_access_confirm_hook()
if hook is None:
return f"error: access to {target} requires a human confirm (no confirm hook installed; denied)"
timeout = get_policy_confirm_timeout()
try:
decision = hook(target, requested, reason, timeout)
except Exception as exc: # noqa: BLE001 — surface confirm failures to the model
return f"error: access confirm for {target} failed: {exc}"
return _apply_decision(target, decision, timeout)
ACCESS_TOOLS = [
request_directory_access,
]
+2
View File
@@ -218,6 +218,7 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
import time (usually the process catalog). Test ``catalog_scope(empty=True)``
overrides must still see builtins for ``default_tool_definitions``.
"""
from plyngent.tools.access import ACCESS_TOOLS
from plyngent.tools.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_TOOLS
from plyngent.tools.net import NET_TOOLS
@@ -226,6 +227,7 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
from plyngent.tools.vcs import VCS_TOOLS
for definition in (
*ACCESS_TOOLS,
*FILE_TOOLS,
*PROCESS_TOOLS,
*VCS_TOOLS,
+6 -1
View File
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
from pathlib import Path
from plyngent.agent.todo_stack import TodoStack
from plyngent.tools.workspace import WorkspacePolicy
from plyngent.tools.workspace import AccessMode, WorkspacePolicy
def _default_instance_data() -> PersistentDataView[Any]:
@@ -74,10 +74,15 @@ class SessionState:
todo: TodoStack | None = None
# Host hook after todo tools mutate (e.g. CLI memory persist).
on_todo_change: Callable[[], None] | None = None
# Host hook after directory-access grants change (e.g. CLI memory persist).
on_access_change: Callable[[], None] | None = None
# Soft-confirm grants live map: tool_name → True (Phase 1 key is tool name).
# Durable copy lives under data["grants"] (see plyngent.tools.grants).
grants: dict[str, bool] = field(default_factory=dict)
extras: dict[str, Any] = field(default_factory=dict)
# Directory-access grants for this session (resolved path → mode). Config /
# process-scoped grants live on WorkspacePolicy; see plyngent.tools.access.
access_grants: dict[Path, AccessMode] = field(default_factory=dict)
def has_grant(self, tool_name: str) -> bool:
return bool(self.grants.get(tool_name))
+3 -3
View File
@@ -4,7 +4,7 @@ import json
import shlex
from typing import TYPE_CHECKING, cast
from plyngent.tools.workspace import WorkspaceError, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, resolve_path
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
@@ -140,7 +140,7 @@ def _write_file_reason(args: Mapping[str, object]) -> str | None:
if not isinstance(path, str) or not path:
return None
try:
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.WRITE)
except WorkspaceError:
# Path policy will fail later; no soft-confirm without a resolved target.
return None
@@ -160,7 +160,7 @@ def _copy_path_reason(args: Mapping[str, object]) -> str | None:
if not isinstance(dst, str) or not dst:
return f"copy {src!r} → {dst!r} (overwrite)"
try:
target = resolve_path(dst)
target = resolve_path(dst, required=AccessMode.WRITE)
except WorkspaceError:
return None
if target.exists():
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool
from plyngent.tools.workspace import resolve_path
from plyngent.tools.workspace import AccessMode, resolve_path
if TYPE_CHECKING:
from pathlib import Path
@@ -114,7 +114,7 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
``new_content`` may be multi-line. Use an empty string to delete the range.
"""
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.WRITE)
if not target.is_file():
return f"error: not a file: {path}"
+2 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
from plyngent.tools.workspace import AccessMode, resolve_path
def _count_non_overlapping(text: str, needle: str) -> int:
@@ -60,7 +60,7 @@ async def edit_replace(path: str, old_string: str, new_string: str, max_replaces
return "error: old_string must not be empty"
if max_replaces < 1:
return "error: max_replaces must be >= 1"
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.WRITE)
if not target.is_file():
return f"error: not a file: {path}"
text = target.read_text(encoding="utf-8", errors="replace")
+19 -9
View File
@@ -4,7 +4,7 @@ import shutil
from pathlib import Path
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
def _kind(path: Path) -> str:
@@ -26,18 +26,28 @@ def _remove_existing(path: Path) -> None:
def _resolve_pair(src: str, dst: str) -> tuple[Path, Path, Path] | str:
try:
source = resolve_path(src)
dest = resolve_path(dst)
source = resolve_path(src, required=AccessMode.READ)
dest = resolve_path(dst, required=AccessMode.WRITE)
root = get_workspace_root()
except WorkspaceError as exc:
return f"error: {exc}"
return source, dest, root
def _prepare_dest(source: Path, dest: Path, root: Path, *, overwrite: bool, dst_label: str) -> Path | str:
def _display(path: Path, root: Path) -> str:
"""Workspace-relative display path, falling back to absolute outside the root."""
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
def _prepare_dest(source: Path, dest: Path, *, overwrite: bool, dst_label: str) -> Path | str:
"""Resolve copy/move destination; return error string on conflict."""
if source.is_file() and dest.is_dir():
dest = resolve_path(str((dest / source.name).relative_to(root)))
# ``dest`` may be outside the primary root (temp workspace / grant), so
# resolve the child directly instead of relativizing against the root.
dest = resolve_path(str(dest / source.name), required=AccessMode.WRITE)
if dest.exists() or dest.is_symlink():
if not overwrite:
return f"error: destination exists: {dst_label} (set overwrite=true)"
@@ -56,7 +66,7 @@ def _copy_or_move_validated(
overwrite: bool,
action: str,
) -> str:
prepared = _prepare_dest(source, dest, root, overwrite=overwrite, dst_label=dst)
prepared = _prepare_dest(source, dest, overwrite=overwrite, dst_label=dst)
if isinstance(prepared, str):
return prepared
dest = prepared
@@ -72,12 +82,12 @@ def _copy_or_move_validated(
# new. copy2/copytree preserve the source mtime, so the freshness
# check alone could miss the change — invalidate explicitly.
invalidate_lineno_read(str(dest))
return f"copied {label} {src} -> {dest.relative_to(root)}"
return f"copied {label} {src} -> {_display(dest, root)}"
moved = Path(shutil.move(str(source), str(dest))).resolve()
# shutil.move preserves mtime too; drop read state for both paths.
invalidate_lineno_read(str(source))
invalidate_lineno_read(str(moved))
return f"moved {_kind(moved)} {src} -> {moved.relative_to(root)}"
return f"moved {_kind(moved)} {src} -> {_display(moved, root)}"
except WorkspaceError as exc:
return f"error: {exc}"
except OSError as exc:
@@ -147,7 +157,7 @@ async def delete_path(path: str, *, recursive: bool = False) -> str:
``recursive=true`` (uses ``shutil.rmtree``). The workspace root cannot be deleted.
"""
try:
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.WRITE)
root = get_workspace_root()
except WorkspaceError as exc:
return f"error: {exc}"
+2 -2
View File
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
if TYPE_CHECKING:
from pathlib import Path
@@ -57,7 +57,7 @@ def _collect_glob(
def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
try:
root = get_workspace_root()
base = resolve_path(path)
base = resolve_path(path, required=AccessMode.READ)
except WorkspaceError as exc:
return f"error: {exc}"
if not base.is_dir():
+2 -2
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
if TYPE_CHECKING:
from collections.abc import Iterator
@@ -65,7 +65,7 @@ def _iter_files(base: Path, *, skip_hidden_dirs: bool) -> list[Path]:
def _resolve_files(path: str, *, skip_hidden_dirs: bool) -> list[Path] | str:
try:
base = resolve_path(path)
base = resolve_path(path, required=AccessMode.READ)
except WorkspaceError as exc:
return f"error: {exc}"
if not base.exists():
+2 -2
View File
@@ -1,13 +1,13 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
from plyngent.tools.workspace import AccessMode, resolve_path
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def listdir(path: str = ".") -> str:
"""List entries in a directory under the workspace (name and type)."""
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.READ)
if not target.is_dir():
return f"error: not a directory: {path}"
lines: list[str] = []
+3 -3
View File
@@ -5,7 +5,7 @@ from typing import cast
from plyngent.agent import ToolTag, mark_lineno_read, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.truncate_token import TruncateToken, truncate_with_token, truncation_marker
from plyngent.tools.workspace import resolve_path
from plyngent.tools.workspace import AccessMode, resolve_path
_LINENO_WIDTH = 6
@@ -41,7 +41,7 @@ def read_raw_text(path: str) -> tuple[str | None, str]:
always refer to the raw file text (never the ``L{begin}-{end}`` header).
Missing paths and directories are distinguished for the caller.
"""
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.READ)
if not target.exists():
return None, f"error: file not found: {path}"
if not target.is_file():
@@ -72,7 +72,7 @@ async def read_file(
so ``get_truncated`` can continue reading the rest without a new request.
Pass ``max_chars < 1`` to read unbounded.
"""
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.READ)
text, err = read_raw_text(path)
if err:
return err
+2 -2
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_path_denylist, resolve_path
if TYPE_CHECKING:
from collections.abc import Sequence
@@ -270,7 +270,7 @@ async def tree(
return "error: max_entries must be >= 1"
try:
origin = resolve_path(path)
origin = resolve_path(path, required=AccessMode.READ)
except WorkspaceError as exc:
return f"error: {exc}"
if not origin.is_dir():
+2 -2
View File
@@ -1,13 +1,13 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
from plyngent.tools.workspace import AccessMode, resolve_path
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_file(path: str, content: str) -> str:
"""Write text content to a file under the workspace (creates parents)."""
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.WRITE)
target.parent.mkdir(parents=True, exist_ok=True)
_ = target.write_text(content, encoding="utf-8")
# The write changes the file's mtime, so edit_lineno's freshness check
+2 -1
View File
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
from pathlib import Path
from plyngent.tools.workspace import (
AccessMode,
WorkspaceError,
check_command_allowed,
get_workspace_root,
@@ -61,7 +62,7 @@ def format_command_result(
def resolve_workdir(cwd: str) -> tuple["Path", str]:
"""Return (absolute workdir, display path relative to workspace when possible)."""
workdir = resolve_path(cwd)
workdir = resolve_path(cwd, required=AccessMode.EXEC)
if not workdir.is_dir():
msg = f"not a directory: {cwd}"
raise WorkspaceError(msg)
+2 -2
View File
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, ClassVar, cast
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, check_command_allowed, resolve_path
if TYPE_CHECKING:
from collections.abc import Callable
@@ -122,7 +122,7 @@ class PtyManager:
msg = "PTY is not available on this platform (Windows needs pywinpty)"
raise WorkspaceError(msg)
check_command_allowed(command)
workdir = resolve_path(cwd)
workdir = resolve_path(cwd, required=AccessMode.EXEC)
if not workdir.is_dir():
msg = f"cwd is not a directory: {cwd}"
raise WorkspaceError(msg)
+99 -12
View File
@@ -1,13 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
from pathlib import Path
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from collections.abc import Callable, Mapping, Sequence
from plyngent.tools.context import InstanceState
from plyngent.tools.context import InstanceState, SessionState
DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
{
@@ -39,6 +40,36 @@ MAX_TEMPORARY_WORKSPACES = 16
# Timed human override for command denylist (independent of YOLO soft-confirm).
DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS = 30.0
class AccessMode(IntEnum):
"""Ordered access level for path grants outside the workspace root.
A grant satisfies a requirement when its value is ``>=`` the requirement
(``read`` < ``write`` < ``exec``); ``exec`` also implies file writes.
"""
READ = 1
WRITE = 2
EXEC = 3
_ACCESS_MODE_NAMES: dict[str, AccessMode] = {
"read": AccessMode.READ,
"write": AccessMode.WRITE,
"exec": AccessMode.EXEC,
}
def parse_access_mode(value: str) -> AccessMode | None:
"""Parse a case-insensitive mode token (``read`` / ``write`` / ``exec``)."""
return _ACCESS_MODE_NAMES.get(value.strip().lower())
def mode_covers(granted: AccessMode, required: AccessMode) -> bool:
"""Whether a grant at *granted* satisfies a tool that needs *required*."""
return granted >= required
# Hook: (basename, argv, timeout_seconds) -> True allow for this session basename.
type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool]
@@ -63,6 +94,10 @@ class WorkspacePolicy:
policy_allowed_commands: set[str] = field(default_factory=set)
policy_confirm_hook: PolicyConfirmHook | None = None
policy_confirm_timeout_seconds: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
# Static TOML pre-allow (resolved path → mode); never prompts.
config_allow: dict[Path, AccessMode] = field(default_factory=dict)
# Process-scoped YOLO / --yes grants (resolved path → mode); never persisted.
yolo_allow: dict[Path, AccessMode] = field(default_factory=dict)
def _bound_instance() -> InstanceState | None:
@@ -71,6 +106,12 @@ def _bound_instance() -> InstanceState | None:
return get_instance()
def _bound_session() -> SessionState | None:
from plyngent.tools.context import get_session
return get_session()
def require_bound_instance() -> InstanceState:
"""Return the bound instance or raise :class:`WorkspaceError`."""
instance = _bound_instance()
@@ -243,11 +284,55 @@ def _under_any_root(resolved: Path, instance: InstanceState, policy: WorkspacePo
return False
def resolve_path(path: str | Path) -> Path:
"""Resolve ``path`` under the workspace root or an allowlisted temp root.
def granted_access_mode(resolved: Path) -> AccessMode | None:
"""Highest config / session / process mode covering *resolved*, if any."""
policy = active_workspace_policy()
session = _bound_session()
buckets: list[Mapping[Path, AccessMode]] = [policy.config_allow, policy.yolo_allow]
if session is not None:
buckets.append(session.access_grants)
best: AccessMode | None = None
for grants in buckets:
for root, granted in grants.items():
if (resolved == root or resolved.is_relative_to(root)) and (best is None or granted > best):
best = granted
return best
def _path_grant_covers(resolved: Path, required: AccessMode) -> bool:
"""Whether a config / session / process grant covers *resolved*."""
granted = granted_access_mode(resolved)
return granted is not None and mode_covers(granted, required)
def within_workspace_roots(resolved: Path) -> bool:
"""Whether *resolved* is under the primary root(s) or a temp allowlist root."""
instance = require_bound_instance()
return _under_any_root(resolved, instance, instance.workspace)
def denylist_match(resolved: Path, *, directory: bool = False) -> str | None:
"""Return the path denylist pattern matching *resolved*, or ``None``.
*directory* also matches a trailing-separator pattern (``/secret/``) against
the directory path itself, not just its children.
"""
resolved_str = str(resolved).replace("\\", "/")
if directory and not resolved_str.endswith("/"):
resolved_str += "/"
for pattern in active_workspace_policy().path_denylist:
if pattern and pattern.replace("\\", "/") in resolved_str:
return pattern
return None
def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -> Path:
"""Resolve ``path`` under the workspace root, an allowlist root, or a grant.
Relative paths resolve against the **primary** workspace root. Absolute
paths may also land under a temporary workspace allowlist entry.
paths may also land under a temporary workspace allowlist entry or a
directory-access grant; ``required`` is the access mode the caller needs
(default :attr:`AccessMode.WRITE`, the conservative choice).
"""
instance = require_bound_instance()
policy = instance.workspace
@@ -256,15 +341,17 @@ def resolve_path(path: str | Path) -> Path:
if not candidate.is_absolute():
candidate = root / candidate
resolved = candidate.expanduser().resolve()
if not _under_any_root(resolved, instance, policy):
msg = f"path escapes workspace root ({root}): {path}"
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
msg = (
f"path escapes workspace root ({root}): {path}; "
"call request_directory_access to request access to a directory outside the workspace"
)
raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
resolved_str = str(resolved).replace("\\", "/")
for pattern in policy.path_denylist:
if pattern and pattern.replace("\\", "/") in resolved_str:
msg = f"path denied by policy (matched {pattern!r}): {path}"
raise WorkspaceError(msg)
matched = denylist_match(resolved)
if matched is not None:
msg = f"path denied by policy (matched {matched!r}): {path}"
raise WorkspaceError(msg)
return resolved
+156
View File
@@ -0,0 +1,156 @@
"""CLI slash ``/grants``, session hydrate/persist, and the access confirm hook."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
import tomlkit
from plyngent.cli.slash import handle_slash
from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig, OpenAIProvider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, temporary_backend
from plyngent.tools import AccessMode, grant_session_access
from plyngent.tools.access import AccessDecision
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
async def _new_state(
tmp_path: Path,
*,
document: object | None = None,
) -> tuple[ReplState, MemoryStore]:
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(
path=tmp_path / "plyngent.toml",
document=tomlkit.document() if document is None else document,
)
config.providers = {"local": provider}
state = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
)
await state.new_session("t")
return state, memory
@pytest.fixture
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
st, memory = await _new_state(tmp_path)
yield st
await memory.close()
async def test_grants_list_empty(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/grants") is True
assert "no directory-access grants" in capsys.readouterr().out
async def test_grants_list_and_revoke_session(
state: ReplState,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
outside = (tmp_path.parent / "outside-grant").resolve()
outside.mkdir(exist_ok=True)
_ = grant_session_access(outside, AccessMode.READ, session=state.session_state)
state.instance_state.workspace.yolo_allow[outside] = AccessMode.EXEC
state.instance_state.workspace.config_allow[tmp_path] = AccessMode.READ
assert await handle_slash(state, "/grants list") is True
out = capsys.readouterr().out
assert "directory-access grants (3)" in out
assert "session" in out
assert "process" in out
assert "config" in out
# Rows are ordered session, process, config.
assert await handle_slash(state, "/grants revoke 0") is True
assert "revoked" in capsys.readouterr().out
assert state.session_state.access_grants == {}
assert await state.memory.get_session_access_grants(state.session_id) == {}
async def test_grants_revoke_all_keeps_config(
state: ReplState,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
outside = (tmp_path.parent / "outside-all").resolve()
outside.mkdir(exist_ok=True)
_ = grant_session_access(outside, AccessMode.READ, session=state.session_state)
state.instance_state.workspace.yolo_allow[outside] = AccessMode.WRITE
state.instance_state.workspace.config_allow[tmp_path] = AccessMode.READ
assert await handle_slash(state, "/grants revoke all") is True
assert "config grants unchanged" in capsys.readouterr().out
assert state.session_state.access_grants == {}
assert state.instance_state.workspace.yolo_allow == {}
assert state.instance_state.workspace.config_allow == {tmp_path: AccessMode.READ}
async def test_grants_revoke_config_points_at_toml(
state: ReplState,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
state.instance_state.workspace.config_allow[tmp_path] = AccessMode.READ
assert await handle_slash(state, "/grants revoke 0") is True
assert "edit [agent].allow_paths" in capsys.readouterr().out
assert state.instance_state.workspace.config_allow == {tmp_path: AccessMode.READ}
async def test_grants_revoke_bad_index(
state: ReplState,
capsys: pytest.CaptureFixture[str],
) -> None:
assert await handle_slash(state, "/grants revoke 99") is True
assert "no grant at index 99" in capsys.readouterr().err
async def test_persist_and_load_roundtrip(state: ReplState, tmp_path: Path) -> None:
outside = (tmp_path.parent / "outside-hydrate").resolve()
outside.mkdir(exist_ok=True)
_ = grant_session_access(outside, AccessMode.WRITE, session=state.session_state)
await state.persist_access_grants()
state.session_state.access_grants.clear()
await state.load_access_grants()
assert state.session_state.access_grants == {outside: AccessMode.WRITE}
def test_yolo_hook_auto_approves_as_process_grant(state: ReplState, tmp_path: Path) -> None:
state.set_yolo("on")
decision = state.directory_access_confirm_hook(tmp_path, AccessMode.EXEC, "", 30.0)
assert decision == AccessDecision(AccessMode.EXEC, persist=False)
def test_default_hook_denies_noninteractive(state: ReplState, tmp_path: Path) -> None:
with temporary_backend(NonInteractiveBackend()):
assert state.directory_access_confirm_hook(tmp_path, AccessMode.READ, "", 1.0) is None
async def test_config_allow_paths_installed(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
outside = (tmp_path.parent / "outside-config").resolve()
outside.mkdir(exist_ok=True)
document = tomlkit.parse(f'[agent]\nallow_paths = {{ "{outside}" = "read", "/does/not/exist" = "read" }}\n')
state, memory = await _new_state(tmp_path, document=document)
try:
assert state.instance_state.workspace.config_allow == {outside: AccessMode.READ}
assert "ignored allow_paths entries" in capsys.readouterr().err
finally:
await memory.close()
+14 -10
View File
@@ -16,6 +16,8 @@ from plyngent.cli.interrupt import (
)
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
from plyngent.cli.retry import run_cancellable
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
if TYPE_CHECKING:
pass
@@ -39,12 +41,16 @@ def test_nested_pause_depth() -> None:
def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
def _confirm(*_a: object, **_k: object) -> bool:
assert allow_task_cancel() is False
return True
backend = ScriptedBackend(["y"])
monkeypatch.setattr("click.confirm", _confirm)
assert prompt_continue_limit("too many rounds") is True
def _read_line(prompt: str, *, default: str | None = None, completions: object = None) -> str:
del prompt, default, completions
assert allow_task_cancel() is False
return "y"
monkeypatch.setattr(backend, "read_line", _read_line)
with temporary_backend(backend):
assert prompt_continue_limit("too many rounds") is True
async def test_run_in_prompt_thread_pauses_cancel() -> None:
@@ -60,11 +66,9 @@ async def test_run_in_prompt_thread_pauses_cancel() -> None:
async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> None:
def _confirm(*_a: object, **_k: object) -> bool:
return True
monkeypatch.setattr("click.confirm", _confirm)
assert await prompt_continue_limit_async("too many rounds") is True
del monkeypatch
with temporary_backend(ScriptedBackend(["y"])):
assert await prompt_continue_limit_async("too many rounds") is True
async def test_sigint_cancels_after_prompt_pause() -> None:
+115 -3
View File
@@ -1,28 +1,106 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from plyngent.cli.limits import (
auto_continue_enabled,
format_tool_confirm_box,
install_cli_limit_hooks,
prompt_confirm_tool,
prompt_continue_limit,
reset_auto_continue,
reset_auto_continue_turn,
set_auto_continue_default,
)
from plyngent.prompting import get_prompt_backend, temporary_backend
from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, temporary_backend
from plyngent.tools.process.pty_session import PtyManager
from tests.test_prompting import ScriptedBackend
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture
def clean_auto_continue() -> Iterator[None]:
reset_auto_continue()
yield
reset_auto_continue()
def test_prompt_continue_limit_yes() -> None:
backend = ScriptedBackend([], confirms=[True])
backend = ScriptedBackend(["y"])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
def test_prompt_continue_limit_default_yes() -> None:
backend = ScriptedBackend([])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
def test_prompt_continue_limit_no() -> None:
backend = ScriptedBackend([], confirms=[False])
backend = ScriptedBackend(["n"])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is False
def test_prompt_continue_limit_yyy_sets_turn_skip(clean_auto_continue: None) -> None:
del clean_auto_continue
backend = ScriptedBackend(["yyy"])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
assert auto_continue_enabled() is True
# No further prompt: the script is empty and must not be consumed.
assert prompt_continue_limit("hit a wall again") is True
def test_reset_auto_continue_turn_clears_yyy(clean_auto_continue: None) -> None:
del clean_auto_continue
with temporary_backend(ScriptedBackend(["yyy"])):
assert prompt_continue_limit("limit") is True
reset_auto_continue_turn()
assert auto_continue_enabled() is False
with temporary_backend(ScriptedBackend(["n"])):
assert prompt_continue_limit("limit") is False
def test_auto_continue_default_short_circuits_prompt(clean_auto_continue: None) -> None:
del clean_auto_continue
set_auto_continue_default(enabled=True)
assert auto_continue_enabled() is True
with temporary_backend(NonInteractiveBackend()):
assert prompt_continue_limit("limit") is True
def test_prompt_continue_limit_noninteractive_denies() -> None:
with temporary_backend(NonInteractiveBackend()):
assert prompt_continue_limit("limit") is False
def test_setup_hooks_installs_pty_hook_for_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue
from plyngent.cli.app import _setup_hooks
_setup_hooks(interactive=False, auto_continue=True)
assert PtyManager._limit_continue is not None
_setup_hooks(interactive=False, auto_continue=False)
assert PtyManager._limit_continue is None
def test_pty_offer_raise_uses_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue
PtyManager.set_limit_continue_hook(prompt_continue_limit)
set_auto_continue_default(enabled=True)
try:
with temporary_backend(NonInteractiveBackend()):
assert PtyManager._offer_raise("PTY session limit reached") is True
finally:
PtyManager.set_limit_continue_hook(None)
def test_prompt_confirm_tool_default_deny() -> None:
backend = ScriptedBackend([], confirms=[False])
with temporary_backend(backend):
@@ -54,3 +132,37 @@ def test_policy_confirm_noninteractive_denies() -> None:
with temporary_backend(NonInteractiveBackend()):
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) is False
def test_directory_access_prompt_noninteractive_denies() -> None:
from pathlib import Path
from plyngent.cli.limits import prompt_directory_access_confirm
from plyngent.prompting import NonInteractiveBackend
from plyngent.tools.workspace import AccessMode
with temporary_backend(NonInteractiveBackend()):
assert prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 1.0) is None
def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch) -> None:
from pathlib import Path
from plyngent.cli import limits
from plyngent.tools.access import AccessDecision
from plyngent.tools.workspace import AccessMode
target = Path("/tmp")
with temporary_backend(ScriptedBackend([])):
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "y\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.READ
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "exec\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.EXEC
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
+108
View File
@@ -120,3 +120,111 @@ async def test_mcp_tools_registered_on_tools_rebuild(state: ReplState) -> None:
assert out == "echo: hi"
finally:
await manager.aclose()
async def test_slash_mcp_list_shows_instructions(
state: ReplState,
capsys: pytest.CaptureFixture[str],
) -> None:
guidance = "Prefer `echo` for repeating text back."
manager = McpManager(
McpConfig(
servers={
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
},
disable=[],
)
)
await manager.ensure_started()
state.mcp_manager = manager
try:
assert await handle_slash(state, "/mcp list") is True
out = capsys.readouterr().out
assert f"instructions ({len(guidance)} chars)" in out
assert guidance in out
finally:
await manager.aclose()
async def _make_tools_state(
tmp_path: Path,
*,
agent_toml: str,
manager: McpManager,
) -> tuple[ReplState, MemoryStore]:
"""ReplState with tools on + a started MCP manager (agent built from TOML)."""
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
document = tomlkit.parse(agent_toml) if agent_toml else tomlkit.document()
config = ConfigStore(path=tmp_path / "plyngent.toml", document=document)
config.providers = {"local": provider}
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=True,
mcp_manager=manager,
)
return st, memory
async def test_mcp_instructions_folded_into_agent_system_prompt(tmp_path: Path) -> None:
from plyngent.tools.catalog import catalog_scope
guidance = "Prefer `echo` for repeating text back to the user."
manager = McpManager(
McpConfig(
servers={
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
},
disable=[],
)
)
await manager.ensure_started()
try:
with catalog_scope(empty=True):
state, memory = await _make_tools_state(tmp_path, agent_toml="", manager=manager)
try:
prompt = state.agent.system_prompt
assert prompt is not None
assert "MCP server 'fake' instructions:" in prompt
assert guidance in prompt
finally:
await memory.close()
finally:
await manager.aclose()
async def test_mcp_instructions_flag_off_skips_server_text(tmp_path: Path) -> None:
from plyngent.tools.catalog import catalog_scope
guidance = "Prefer `echo` for repeating text back to the user."
manager = McpManager(
McpConfig(
servers={
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
},
disable=[],
)
)
await manager.ensure_started()
try:
with catalog_scope(empty=True):
state, memory = await _make_tools_state(
tmp_path,
agent_toml="[agent]\nmcp_instructions = false\n",
manager=manager,
)
try:
prompt = state.agent.system_prompt
assert prompt is not None
assert guidance not in prompt
assert "MCP server" not in prompt
assert "### Workspace" in prompt # host tool playbook still composed
finally:
await memory.close()
finally:
await manager.aclose()
+26
View File
@@ -94,6 +94,32 @@ async def test_help_and_clear(state: ReplState) -> None:
assert state.agent.messages == []
async def test_auto_continue_limits_installs_on_limit(tmp_path: Path) -> None:
from plyngent.cli.limits import auto_continue_enabled, reset_auto_continue
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider}
try:
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
interactive_limits=False,
auto_continue_limits=True,
)
assert auto_continue_enabled() is True
assert st.agent.on_limit is not None
finally:
reset_auto_continue()
await memory.close()
async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/help compact") is True
out = capsys.readouterr().out
+39
View File
@@ -30,6 +30,8 @@ from plyngent.memory import MemoryStore
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from plyngent.agent import AgentEvent
def test_default_retry_delays_schedule() -> None:
assert DEFAULT_MAX_AUTO_RETRIES == 10
@@ -296,6 +298,43 @@ async def test_second_ctrl_c_during_retry_announce_stays_benign(
assert await _wait_for_retry(1, 3, 0.01) is True
async def test_turn_boundaries_reset_auto_continue() -> None:
from plyngent.cli.limits import (
auto_continue_enabled,
prompt_continue_limit,
reset_auto_continue,
)
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
reset_auto_continue()
try:
with temporary_backend(ScriptedBackend(["yyy"])):
assert prompt_continue_limit("limit") is True
assert auto_continue_enabled() is True
store = await MemoryStore.open(DatabaseConfig())
try:
session = await store.create_session(name="t")
client = FlakyClient(fail_times=0)
agent = ChatAgent(client, model="m", memory=store, session_id=session.sid)
seen: list[bool] = []
async def starter() -> AsyncIterator[AgentEvent]:
seen.append(auto_continue_enabled())
async for event in agent.run("hi"):
yield event
ok = await run_turn_with_retries(agent, starter=starter, delays=())
assert ok is True
assert seen == [False] # cleared before the turn starts
assert auto_continue_enabled() is False # cleared after the turn ends
finally:
await store.close()
finally:
reset_auto_continue()
async def test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
async def no_wait(_seconds: float) -> bool:
return True
+48
View File
@@ -19,12 +19,15 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
store = load(path)
assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT
assert store.agent_config.tool_directives == DEFAULT_TOOL_DIRECTIVES
assert store.agent_config.mcp_instructions is True
assert "professional coding agent" in store.agent_config.system_prompt
assert "### Workspace" in store.agent_config.tool_directives
assert store.agent_config.max_tool_result_chars == 32_000
assert store.agent_config.parallel_tools is True
assert store.agent_config.confirm_destructive is True
assert store.agent_config.path_denylist == []
assert store.agent_config.allow_paths == {}
assert store.agent_config.auto_continue_limits is False
assert store.agent_config.max_context_tokens == 200_000
assert store.plugins_config.enable == []
assert store.plugins_config.disable == []
@@ -64,6 +67,9 @@ def test_compose_empty_combinations() -> None:
assert compose_agent_system_content("Only persona", "") == "Only persona"
assert compose_agent_system_content("", "Only tools") == "Only tools"
assert compose_agent_system_content("Persona", "Tools") == "Persona\n\nTools"
assert compose_agent_system_content("", "", "") is None
assert compose_agent_system_content("Persona", "Tools", "Extra") == "Persona\n\nTools\n\nExtra"
assert compose_agent_system_content("Persona", "", "Extra") == "Persona\n\nExtra"
def test_agent_section_parse(tmp_path: Path) -> None:
@@ -78,6 +84,7 @@ parallel_tools = false
confirm_destructive = false
path_denylist = ["/secrets/", ".ssh/"]
max_context_tokens = 5000
auto_continue_limits = true
""",
encoding="utf-8",
)
@@ -89,6 +96,7 @@ max_context_tokens = 5000
assert store.agent_config.confirm_destructive is False
assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"]
assert store.agent_config.max_context_tokens == 5000
assert store.agent_config.auto_continue_limits is True
composed = compose_agent_system_content(
store.agent_config.system_prompt,
store.agent_config.tool_directives,
@@ -158,6 +166,46 @@ tool_directives = ""
)
def test_agent_allow_paths_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
""",
encoding="utf-8",
)
store = load(path)
assert store.agent_config.allow_paths == {"/data/datasets": "read", "/tmp/build": "exec"}
def test_agent_allow_paths_invalid_mode_falls_back(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
allow_paths = { "/data" = "sudo" }
""",
encoding="utf-8",
)
store = load(path)
# Invalid field value resets the whole [agent] section to defaults.
assert store.agent_config.allow_paths == {}
def test_agent_mcp_instructions_flag(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
mcp_instructions = false
""",
encoding="utf-8",
)
store = load(path)
assert store.agent_config.mcp_instructions is False
def test_plugins_section_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
+23 -8
View File
@@ -34,6 +34,21 @@ async def test_create_session_uses_default_user(store: MemoryStore) -> None:
assert session.uid == user.uid
async def test_session_access_grants_roundtrip(store: MemoryStore) -> None:
session = await store.create_session(name="grants")
assert await store.get_session_access_grants(session.sid) == {}
_ = await store.update_session_access_grants(
session.sid,
{"/data/datasets": "read", "/tmp/build": "exec"},
)
assert await store.get_session_access_grants(session.sid) == {
"/data/datasets": "read",
"/tmp/build": "exec",
}
_ = await store.update_session_access_grants(session.sid, {})
assert await store.get_session_access_grants(session.sid) == {}
async def test_session_context_usage_roundtrip(store: MemoryStore) -> None:
session = await store.create_session(name="usage")
row = await store.update_session_context_usage(
@@ -293,9 +308,9 @@ async def test_schema_migrations_stamp_version(tmp_path: object) -> None:
db_path = tmp_path / "fresh.db"
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close()
assert await _user_version(db_path) == 3
assert await _user_version(db_path) == 4
columns = await _session_columns(db_path)
assert {"workspace", "provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
assert {"workspace", "provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns
async def test_legacy_db_migrated_to_latest(tmp_path: object) -> None:
@@ -311,9 +326,9 @@ async def test_legacy_db_migrated_to_latest(tmp_path: object) -> None:
session = await store.create_session(name="migrated")
await store.close()
assert await _user_version(db_path) == 3
assert await _user_version(db_path) == 4
columns = await _session_columns(db_path)
assert {"workspace", "provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
assert {"workspace", "provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns
assert session.sid is not None
@@ -335,10 +350,10 @@ async def test_partially_migrated_legacy_db_noops_remaining_steps(tmp_path: obje
session = await store.create_session(name="partial", workspace=tmp_path)
await store.close()
assert await _user_version(db_path) == 3
assert await _user_version(db_path) == 4
columns = await _session_columns(db_path)
assert "workspace" in columns
assert {"provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
assert {"provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns
assert session.workspace is not None
@@ -350,8 +365,8 @@ async def test_stamped_db_skips_migrations_on_reopen(tmp_path: object) -> None:
db_path = tmp_path / "stamped.db"
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close()
assert await _user_version(db_path) == 3
assert await _user_version(db_path) == 4
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close()
assert await _user_version(db_path) == 3
assert await _user_version(db_path) == 4
+16 -9
View File
@@ -1,10 +1,17 @@
"""Minimal MCP stdio server used by the client tests (echo + sleep tools)."""
"""Minimal MCP stdio server used by the client tests (echo + sleep tools).
Set ``PLYNGENT_MCP_FAKE_INSTRUCTIONS`` (env) to have the server return that
text as ``InitializeResult.instructions`` (usage guidance).
"""
from __future__ import annotations
import json
import os
import sys
_FAKE_INSTRUCTIONS = os.environ.get("PLYNGENT_MCP_FAKE_INSTRUCTIONS", "")
def respond(request_id: int, result: object) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n")
@@ -22,14 +29,14 @@ def main() -> None:
if request_id is None:
continue # notification (notifications/initialized)
if method == "initialize":
respond(
int(request_id),
{
"protocolVersion": "2025-06-18",
"capabilities": {"tools": {}},
"serverInfo": {"name": "fake-mcp", "version": "0.1.0"},
},
)
result: dict[str, object] = {
"protocolVersion": "2025-06-18",
"capabilities": {"tools": {}},
"serverInfo": {"name": "fake-mcp", "version": "0.1.0"},
}
if _FAKE_INSTRUCTIONS:
result["instructions"] = _FAKE_INSTRUCTIONS
respond(int(request_id), result)
elif method == "tools/list":
respond(
int(request_id),
+34
View File
@@ -32,6 +32,7 @@ async def test_connection_handshake_lists_and_calls_tools() -> None:
try:
assert connection.connected
assert connection.error is None
assert connection.instructions is None
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow"]
echo = connection.tools[0]
assert echo.description == "Echo the given text back."
@@ -43,6 +44,20 @@ async def test_connection_handshake_lists_and_calls_tools() -> None:
await connection.aclose()
async def test_initialize_captures_server_instructions() -> None:
guidance = "Use echo to repeat text back to the user."
connection = McpServerConnection(
"fake",
_server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
)
await connection.start()
try:
assert connection.connected
assert connection.instructions == guidance
finally:
await connection.aclose()
async def test_tool_error_returns_error_text() -> None:
connection = McpServerConnection("fake", _server_config())
await connection.start()
@@ -87,12 +102,31 @@ async def test_manager_start_status_and_call() -> None:
await manager.ensure_started()
try:
assert [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 3)]
assert manager.instructions() == []
assert await manager.call("fake", "echo", {"text": "hi"}) == "echo: hi"
finally:
await manager.aclose()
assert manager.statuses() == [("fake", "not started", 0)]
async def test_manager_instructions_after_restart() -> None:
guidance = "Always answer with a haiku."
plain = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
await plain.ensure_started()
assert plain.instructions() == []
await plain.restart(
McpConfig(
servers={"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance})},
disable=[],
)
)
await plain.ensure_started()
try:
assert plain.instructions() == [("fake", guidance)]
finally:
await plain.aclose()
async def test_manager_disable_and_restart() -> None:
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=["fake"]))
await manager.ensure_started()
+263
View File
@@ -0,0 +1,263 @@
"""``request_directory_access`` tool and the grant stores."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from plyngent.agent import ToolTag
from plyngent.tools import (
MAX_ACCESS_GRANTS,
AccessDecision,
AccessMode,
SessionState,
WorkspaceError,
active_workspace_policy,
get_directory_access_confirm_hook,
grant_session_access,
request_directory_access,
resolve_path,
set_config_access,
set_directory_access_confirm_hook,
set_path_denylist,
)
from plyngent.tools.context import bind_session
from tests.test_tools.helpers import call_async
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture
def session() -> Iterator[SessionState]:
state = SessionState()
with bind_session(state):
yield state
def _never_called(*_args: object) -> AccessDecision | str | None:
msg = "confirm hook should not be called"
raise AssertionError(msg)
def test_tool_tags_and_registry() -> None:
tags = request_directory_access.tags
assert tags & ToolTag.LOCAL
assert tags & ToolTag.INSTANCE_STATE
assert tags & ToolTag.SESSION_STATE
assert not (tags & ToolTag.READ_ONLY)
from plyngent.tools.catalog import default_tool_definitions
assert "request_directory_access" in {definition.name for definition in default_tool_definitions()}
def test_hook_roundtrip(workspace: object, session: SessionState) -> None:
del workspace, session
assert get_directory_access_confirm_hook() is None
set_directory_access_confirm_hook(_never_called)
assert get_directory_access_confirm_hook() is not None
set_directory_access_confirm_hook(None)
assert get_directory_access_confirm_hook() is None
async def test_approve_grants_session_access(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("approved").resolve()
calls: list[tuple[Path, AccessMode, str, float]] = []
def hook(path: Path, mode: AccessMode, reason: str, timeout: float) -> AccessDecision:
calls.append((path, mode, reason, timeout))
return AccessDecision(AccessMode.WRITE)
set_directory_access_confirm_hook(hook)
out = await call_async(request_directory_access, str(outside), "need the dataset", "read")
assert "access granted" in out
assert "write, session" in out
assert calls == [(outside, AccessMode.READ, "need the dataset", 30.0)]
assert session.access_grants[outside] == AccessMode.WRITE
target = outside / "data.txt"
_ = target.write_text("x", encoding="utf-8")
assert resolve_path(str(target), required=AccessMode.WRITE) == target
async def test_process_grant_when_not_persisting(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("process").resolve()
set_directory_access_confirm_hook(lambda *_a: AccessDecision(AccessMode.READ, persist=False))
out = await call_async(request_directory_access, str(outside), mode="read")
assert "read, process" in out
assert session.access_grants == {}
assert active_workspace_policy().yolo_allow[outside] == AccessMode.READ
async def test_deny_with_comment(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("denied").resolve()
set_directory_access_confirm_hook(lambda *_a: "no, that is my tax data")
out = await call_async(request_directory_access, str(outside))
assert out.startswith("error: access to")
assert "user comment: no, that is my tax data" in out
assert session.access_grants == {}
async def test_deny_none(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("timeout").resolve()
set_directory_access_confirm_hook(lambda *_a: None)
out = await call_async(request_directory_access, str(outside))
assert "denied (declined or timed out" in out
assert session.access_grants == {}
async def test_no_hook_denies(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("nohook").resolve()
set_directory_access_confirm_hook(None)
out = await call_async(request_directory_access, str(outside))
assert "no confirm hook installed" in out
async def test_denylist_can_never_be_granted(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("secret-zone").resolve()
secret = outside / "secret"
secret.mkdir()
set_path_denylist(["/secret/"])
set_directory_access_confirm_hook(_never_called)
try:
out = await call_async(request_directory_access, str(secret), mode="read")
assert "can never be granted" in out
assert session.access_grants == {}
finally:
set_path_denylist(None)
async def test_already_accessible_inside_workspace(
workspace: object,
session: SessionState,
) -> None:
from pathlib import Path
assert isinstance(workspace, Path)
set_directory_access_confirm_hook(_never_called)
out = await call_async(request_directory_access, str(workspace), mode="read")
assert "already accessible" in out
assert session.access_grants == {}
async def test_already_granted_skips_prompt(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("granted").resolve()
_ = grant_session_access(outside, AccessMode.READ)
set_directory_access_confirm_hook(_never_called)
out = await call_async(request_directory_access, str(outside), mode="read")
assert "already granted" in out
async def test_invalid_mode(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
outside = tmp_path_factory.mktemp("mode").resolve()
out = await call_async(request_directory_access, str(outside), mode="sudo")
assert "invalid mode" in out
async def test_exec_requires_directory(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
outside = tmp_path_factory.mktemp("exec-file").resolve()
target = outside / "f.txt"
_ = target.write_text("x", encoding="utf-8")
out = await call_async(request_directory_access, str(target), mode="exec")
assert "exec access requires a directory" in out
async def test_missing_path(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
missing = tmp_path_factory.mktemp("missing").resolve() / "nope"
out = await call_async(request_directory_access, str(missing))
assert "does not exist" in out
async def test_grant_cap(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("cap").resolve()
policy = active_workspace_policy()
for index in range(MAX_ACCESS_GRANTS):
policy.config_allow[outside / f"d{index}"] = AccessMode.READ
set_directory_access_confirm_hook(_never_called)
try:
out = await call_async(request_directory_access, str(outside))
assert "too many active access grants" in out
assert session.access_grants == {}
finally:
policy.config_allow.clear()
def test_set_config_access_installs_and_skips(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
outside = tmp_path_factory.mktemp("config-allow").resolve()
target = outside / "data.txt"
_ = target.write_text("x", encoding="utf-8")
missing = outside / "missing"
applied, skipped = set_config_access(
{
str(outside): "read",
str(missing): "read",
str(target): "sudo",
}
)
assert applied == [outside]
assert sorted(skipped) == sorted([str(missing), str(target)])
assert active_workspace_policy().config_allow == {outside: AccessMode.READ}
assert resolve_path(str(target), required=AccessMode.READ) == target
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(target), required=AccessMode.WRITE)
+2 -1
View File
@@ -6,6 +6,7 @@ import inspect
from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools import default_tool_definitions, register_builtin_tools
from plyngent.tools.access import ACCESS_TOOLS
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
from plyngent.tools.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_TOOLS
@@ -18,7 +19,7 @@ from plyngent.tools.vcs import VCS_TOOLS
def test_default_tool_names_match_group_lists() -> None:
register_builtin_tools()
selected = default_tool_definitions(surface="local")
groups = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS, *NET_TOOLS]
groups = [*ACCESS_TOOLS, *FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS, *NET_TOOLS]
assert sorted(t.name for t in selected) == sorted(t.name for t in groups)
assert len(selected) == len(groups)
+88
View File
@@ -3,13 +3,18 @@ from __future__ import annotations
import pytest
from plyngent.tools import (
AccessMode,
WorkspaceError,
active_workspace_policy,
check_command_allowed,
get_workspace_root,
mode_covers,
parse_access_mode,
resolve_path,
set_command_denylist,
set_path_denylist,
)
from plyngent.tools.context import SessionState, bind_session
def test_resolve_relative_and_absolute(workspace: object) -> None:
@@ -27,6 +32,89 @@ def test_escape_rejected(workspace: object) -> None:
_ = resolve_path("../outside")
def test_escape_error_hints_request_tool(workspace: object) -> None:
del workspace
with pytest.raises(WorkspaceError, match="request_directory_access"):
_ = resolve_path("../outside")
def test_access_mode_parse_and_covers() -> None:
assert parse_access_mode(" Read ") is AccessMode.READ
assert parse_access_mode("EXEC") is AccessMode.EXEC
assert parse_access_mode("bogus") is None
assert mode_covers(AccessMode.EXEC, AccessMode.WRITE)
assert mode_covers(AccessMode.WRITE, AccessMode.WRITE)
assert not mode_covers(AccessMode.READ, AccessMode.WRITE)
def test_config_grant_modes(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("granted")
target = outside / "data.txt"
_ = target.write_text("x", encoding="utf-8")
policy = active_workspace_policy()
policy.config_allow[outside] = AccessMode.READ
try:
assert resolve_path(str(target), required=AccessMode.READ) == target
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(target), required=AccessMode.WRITE)
policy.config_allow[outside] = AccessMode.WRITE
assert resolve_path(str(target), required=AccessMode.WRITE) == target
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(target), required=AccessMode.EXEC)
finally:
policy.config_allow.clear()
def test_session_grant_covers_subtree(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("session")
nested = outside / "a" / "b"
nested.mkdir(parents=True)
with bind_session(SessionState(access_grants={outside: AccessMode.EXEC})):
assert resolve_path(str(nested), required=AccessMode.EXEC) == nested
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(nested), required=AccessMode.READ)
def test_file_grant_is_exact_path(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("file-grant")
granted = outside / "one.txt"
sibling = outside / "two.txt"
_ = granted.write_text("1", encoding="utf-8")
_ = sibling.write_text("2", encoding="utf-8")
policy = active_workspace_policy()
policy.config_allow[granted] = AccessMode.READ
try:
assert resolve_path(str(granted), required=AccessMode.READ) == granted
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(sibling), required=AccessMode.READ)
# A file grant does not cover its parent directory.
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(outside), required=AccessMode.READ)
finally:
policy.config_allow.clear()
def test_denylist_wins_over_grant(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("denied")
secret = outside / "secret"
secret.mkdir()
key = secret / "key"
_ = key.write_text("k", encoding="utf-8")
policy = active_workspace_policy()
policy.config_allow[outside] = AccessMode.WRITE
set_path_denylist(["/secret/"])
try:
with pytest.raises(WorkspaceError, match="matched '/secret/'"):
_ = resolve_path(str(key), required=AccessMode.READ)
finally:
set_path_denylist(None)
policy.config_allow.clear()
def test_path_denylist(workspace: object) -> None:
from pathlib import Path